diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f81dc0e370..04a92254df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/apps/desktop/src/components/settings/ServiceRow.tsx b/apps/desktop/src/components/settings/ServiceRow.tsx index ab786d18c1..32bcbaab56 100644 --- a/apps/desktop/src/components/settings/ServiceRow.tsx +++ b/apps/desktop/src/components/settings/ServiceRow.tsx @@ -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"; @@ -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; @@ -167,13 +167,11 @@ export function ServiceRow({
- {/* 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 ? ( ) : null} diff --git a/apps/desktop/src/components/settings/service-row-status.ts b/apps/desktop/src/components/settings/service-row-status.ts index 09e3ed0f33..00df796cff 100644 --- a/apps/desktop/src/components/settings/service-row-status.ts +++ b/apps/desktop/src/components/settings/service-row-status.ts @@ -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 diff --git a/apps/desktop/test/ci-workflow.test.mjs b/apps/desktop/test/ci-workflow.test.mjs index b1124653fd..adbe18b1f4 100644 --- a/apps/desktop/test/ci-workflow.test.mjs +++ b/apps/desktop/test/ci-workflow.test.mjs @@ -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:)/, diff --git a/apps/desktop/test/service-row-status.test.mjs b/apps/desktop/test/service-row-status.test.mjs index e33c42efae..427d5221f9 100644 --- a/apps/desktop/test/service-row-status.test.mjs +++ b/apps/desktop/test/service-row-status.test.mjs @@ -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. @@ -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 }); +}); diff --git a/apps/desktop/test/settings-general.test.mjs b/apps/desktop/test/settings-general.test.mjs index 0baecd97ff..92a0ed97b2 100644 --- a/apps/desktop/test/settings-general.test.mjs +++ b/apps/desktop/test/settings-general.test.mjs @@ -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/); diff --git a/docs/spec/03-runtime/11-provider-model-system.md b/docs/spec/03-runtime/11-provider-model-system.md index cf31aefc83..cafc1cd407 100644 --- a/docs/spec/03-runtime/11-provider-model-system.md +++ b/docs/spec/03-runtime/11-provider-model-system.md @@ -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 diff --git a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md index 1a00893c76..3811905162 100644 --- a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md +++ b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md @@ -481,7 +481,10 @@ type ModelDescriptor = { 所用的同一份规范化 JSON - 登录/退出厂商账户,并看到某一行使用的是哪个账户 - 编辑厂商账户的非机密标签、自定义请求头与默认模型 -- enable/disable 提供商 +- enable/disable 提供商。厂商订阅账户与 API 服务共用同一个开关:停用后其模型 + 会从模型选择器中移除,会话启动以 `PROVIDER_DISABLED` 失败(§11);设置列表 + 仍保留该行(并显示「已禁用」徽章),以便重新启用。插件声明的服务没有开关 —— + 插件拥有自己那一行,每次加载都从 manifest 重新生成。 - 测试连接 - 选择多个模型并编辑每条绑定的上下文窗口、输出上限与启用的思考级别;目录 元数据为 API 提供商与已登录的厂商账户提供初始值。选择器始终暴露七个规范