Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,16 @@ jobs:
name: Build ${{ matrix.name }}
needs: verify
env:
# Tag pushes always sign. workflow_dispatch signs unless explicitly disabled.
MACOS_SIGN_RELEASE: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos == true }}
# Signing is opt-in on a fork. A tag push must not force signing: this
# repository owns no Apple Developer secrets, and the assertion in
# "Require macOS signing and notarization secrets" additionally pins
# APPLE_TEAM_ID to the upstream maintainer's team, so no locally obtained
# certificate can satisfy it. An unsigned tag build still publishes every
# installer; only the Developer ID seal and the notarization ticket are
# absent. Set the repository variable MACOS_SIGN_RELEASE=true to restore
# upstream's sign-always behaviour once this repository owns a
# Developer ID certificate.
MACOS_SIGN_RELEASE: ${{ (github.event_name == 'workflow_dispatch' && inputs.sign_macos == true) || vars.MACOS_SIGN_RELEASE == 'true' }}
strategy:
fail-fast: false
matrix:
Expand Down
10 changes: 4 additions & 6 deletions apps/desktop/src/components/settings/ServiceRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import { CapabilityRowMenu, type CapabilityMenuItem } from "./AgentCapabilityLay
import { ServiceMonogram } from "./ServiceMonogram";
import {
serviceRowBadges,
serviceRowKind,
serviceRowMeta,
serviceRowTitle,
serviceRowToggle,
} from "./service-row-status";
import type { AccountEntry } from "./useVendorAccounts";

Expand Down Expand Up @@ -70,10 +70,10 @@ export function ServiceRow({
reorderEvents,
}: ServiceRowProps) {
const { t } = useTranslation();
const kind = serviceRowKind(provider);
const title = serviceRowTitle(provider, entry, t);
const badges = serviceRowBadges(provider, { isDefault, entry }, t);
const meta = serviceRowMeta(provider, t);
const toggle = serviceRowToggle(provider, busy);
// Read at pointerdown, before the outside press has closed the menu.
const menuWasOpen = useRef(false);
const { onPointerDown, onClickCapture, onKeyDown, ...reorderAttributes } = reorderEvents;
Expand Down Expand Up @@ -167,13 +167,11 @@ export function ServiceRow({
</div>

<div className="model-provider-row-actions">
{/* A plugin refreshes its row from its manifest on every load, so the
switch is not the user's to flip; an account has none at all. */}
{kind !== "account" ? (
{toggle.show ? (
<SettingsToggle
checked={provider.enabled}
label={t("settings.enabledToggle")}
disabled={busy || kind === "plugin"}
disabled={toggle.locked}
onChange={onToggleEnabled}
/>
) : null}
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/components/settings/service-row-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,26 @@ export function serviceRowBadges(
return badges;
}

/**
* Whether a row shows the enable switch, and whether the switch is locked.
*
* A plugin refreshes its row from its manifest on every load, so the switch is
* not the user's to flip: it is hidden there. An API service and a vendor
* account are both the user's to enable or disable (#930); a disabled provider
* is filtered out of the model picker (`providers.list` with
* `includeDisabled=false`) and fails session launch with `PROVIDER_DISABLED`,
* so the same switch carries the same meaning on each kind. `busy` only ever
* greys the switch out mid-request.
*/
export function serviceRowToggle(
provider: ProviderPublic,
busy: boolean,
): { show: boolean; locked: boolean } {
const kind = serviceRowKind(provider);
const show = kind !== "plugin";
return { show, locked: kind === "plugin" || busy };
}

/**
* The quiet second line: where an API service points and how many models it
* serves. An account has no endpoint worth showing, and a signed-out one says
Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/test/ci-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -208,16 +208,26 @@ test("release matrix packages both native macOS architectures", () => {
assert.match(releaseWorkflowSource, /Merge macOS updater metadata[\s\S]*?ruby/);
});

test("macOS release signing is required on tag pushes", () => {
test("macOS release signing is opt-in and workflow_dispatch still honours sign_macos", () => {
assert.match(
releaseWorkflowSource,
/workflow_dispatch:\s+inputs:\s+sign_macos:[\s\S]*?default:\s*true[\s\S]*?type:\s*boolean/,
);
// A tag push must not force signing: a fork owns no Apple Developer
// secrets, and the guard below pins APPLE_TEAM_ID to the upstream
// maintainer's team, so no locally obtained certificate can satisfy it.
// Signing is opted into with the repository variable, and the manual
// dispatch lane keeps its own sign_macos input.
assert.ok(
releaseWorkflowSource.includes(
"MACOS_SIGN_RELEASE: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos == true }}",
"MACOS_SIGN_RELEASE: ${{ (github.event_name == 'workflow_dispatch' && inputs.sign_macos == true) || vars.MACOS_SIGN_RELEASE == 'true' }}",
),
);
assert.doesNotMatch(
releaseWorkflowSource,
/MACOS_SIGN_RELEASE: \$\{\{ github\.event_name != 'workflow_dispatch'/,
"a tag push no longer forces macOS signing",
);

const unsignedBlock = releaseWorkflowSource.match(
/- name: Package unsigned macOS installer[\s\S]*?(?=\n - name:)/,
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/test/service-row-status.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
serviceRowKind,
serviceRowMeta,
serviceRowTitle,
serviceRowToggle,
} from "../src/components/settings/service-row-status.ts";

// Echoes the key and its options, so assertions see which string was chosen.
Expand Down Expand Up @@ -146,3 +147,34 @@ test("the endpoint host survives an unparseable or missing base URL", () => {
assert.equal(hostFromBaseUrl("api.example.com/v1"), "api.example.com");
assert.equal(hostFromBaseUrl(undefined), "—");
});

test("the enable switch belongs to the user on a service and an account alike", () => {
// An API service keeps the switch it always had.
assert.deepEqual(serviceRowToggle(provider(), false), { show: true, locked: false });
// A vendor account is the user's to disable too (#930). Before this the row
// rendered no switch at all, even though the host already filtered a
// disabled provider out of the model picker and failed its session launch.
assert.deepEqual(serviceRowToggle(account(), false), { show: true, locked: false });
// A signed-out account is still the user's to toggle; the row already says
// "needs sign-in" through its own badge.
assert.deepEqual(serviceRowToggle(account({ hasOauth: false }), false), {
show: true,
locked: false,
});
// A disabled account keeps its switch so the user can turn it back on.
assert.deepEqual(serviceRowToggle(account({ enabled: false }), false), {
show: true,
locked: false,
});
});

test("only a plugin-declared row hides and locks the enable switch", () => {
const plugin = provider({ ownerPluginId: "acme" });
// A plugin refreshes its row from its manifest on every load, so the switch
// is not the user's to flip — hidden rather than merely disabled.
assert.deepEqual(serviceRowToggle(plugin, false), { show: false, locked: true });
// A busy request greys out whatever switch the row does have, without
// changing whether it is shown.
assert.deepEqual(serviceRowToggle(provider(), true), { show: true, locked: true });
assert.deepEqual(serviceRowToggle(account(), true), { show: true, locked: true });
});
9 changes: 6 additions & 3 deletions apps/desktop/test/settings-general.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,12 @@ test("a service row opens its editor and keeps only a switch and one menu", () =
// presses on the row's own controls never open the editor.
assert.match(serviceRowSource, /const OWN_CONTROLS = "button, input, select, textarea, a, label/);
assert.match(serviceRowSource, /event\.target !== event\.currentTarget \|\| !onOpen \|\| busy/);
// An account has no enable switch; a plugin's switch belongs to the plugin.
assert.match(serviceRowSource, /kind !== "account" \? \(/);
assert.match(serviceRowSource, /disabled=\{busy \|\| kind === "plugin"\}/);
// An account and an API service both get the enable switch; only a plugin's
// switch belongs to the plugin. The row delegates that call to the pure
// helper, whose behavior is covered in service-row-status.test.mjs (#930).
assert.match(serviceRowSource, /const toggle = serviceRowToggle\(provider, busy\)/);
assert.match(serviceRowSource, /\{toggle\.show \? \(/);
assert.match(serviceRowSource, /disabled=\{toggle\.locked\}/);
// A plugin owns its row, so neither edit nor remove is offered for one.
assert.match(serviceListSource, /if \(kind !== "plugin"\) \{\s*items\.push\(\{\s*key: "edit"/);
assert.match(serviceListSource, /if \(kind !== "plugin"\) \{\s*const isArmed/);
Expand Down
7 changes: 6 additions & 1 deletion docs/spec/03-runtime/11-provider-model-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,12 @@ type ModelDescriptor = {
copy the same normalized JSON used for persistence
- sign in to / out of a vendor account, and see which account a row uses
- edit a vendor account's non-secret label, custom headers, and default model
- enable/disable provider
- enable/disable provider. A vendor subscription account carries the same
switch as an API service: disabling it removes its models from the model
picker and fails its session launch with `PROVIDER_DISABLED` (§11), and the
settings list keeps the row visible (with a disabled badge) so it can be
turned back on. A plugin-declared row has no switch — the plugin owns its
row and refreshes it from its manifest on every load.
- test connection
- select multiple models and edit each binding's context window, output limit,
and enabled thinking levels; catalog metadata supplies the initial values for
Expand Down
5 changes: 4 additions & 1 deletion docs/zh-CN/spec/03-runtime/11-provider-model-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,10 @@ type ModelDescriptor = {
所用的同一份规范化 JSON
- 登录/退出厂商账户,并看到某一行使用的是哪个账户
- 编辑厂商账户的非机密标签、自定义请求头与默认模型
- enable/disable 提供商
- enable/disable 提供商。厂商订阅账户与 API 服务共用同一个开关:停用后其模型
会从模型选择器中移除,会话启动以 `PROVIDER_DISABLED` 失败(§11);设置列表
仍保留该行(并显示「已禁用」徽章),以便重新启用。插件声明的服务没有开关 ——
插件拥有自己那一行,每次加载都从 manifest 重新生成。
- 测试连接
- 选择多个模型并编辑每条绑定的上下文窗口、输出上限与启用的思考级别;目录
元数据为 API 提供商与已登录的厂商账户提供初始值。选择器始终暴露七个规范
Expand Down