From eeb820da8ca47981e4b8e3268504281fde22d7b2 Mon Sep 17 00:00:00 2001 From: LiYao <2113239898x@gmail.com> Date: Wed, 30 Sep 2026 10:45:09 +0800 Subject: [PATCH 1/3] ci(release): make macOS signing opt-in for unsigned fork builds A tag push forced MACOS_SIGN_RELEASE to true, so the release pipeline always demanded the five Apple Developer secrets. This fork owns none of them, and the guard in "Require macOS signing and notarization secrets" additionally pins APPLE_TEAM_ID to the upstream maintainer's team (DUV63RKYTW) while CSC_NAME is hardcoded to their certificate common name, so no certificate obtained by this repository can satisfy the check. Both macOS matrix legs then failed, the build job failed with them, and the publish job was skipped by needs, leaving a release workflow that could not produce a Release on this fork. Signing is now opt-in: a tag push builds unsigned installers, and setting the repository variable MACOS_SIGN_RELEASE=true restores the sign-always behaviour once this repository owns a Developer ID certificate. workflow_dispatch keeps honouring its own sign_macos input, so the existing debug lane is unchanged. Unsigned macOS artifacts lose only the Developer ID seal and the notarization ticket; installer layout, artifact names, the updater feeds, and the publish job are all independent of signing. --- .github/workflows/release.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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: From c7278ecdd5ab4981998f39c1ca3ab53f4a0d9e97 Mon Sep 17 00:00:00 2001 From: LiYao <2113239898x@gmail.com> Date: Wed, 30 Sep 2026 10:52:36 +0800 Subject: [PATCH 2/3] test(release): assert macOS signing is opt-in instead of tag-forced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ci-workflow contract test pinned the old MACOS_SIGN_RELEASE expression, in which a tag push always signs. That guard is exactly what the release workflow change had to relax, so leaving it would have kept the pipeline red: the verify job runs the unit tests before any build job, and its failure skips publish. The test now pins the new contract instead: the opt-in expression must be present, the tag-forced form must be absent, and the existing assertions covering both macOS lanes are unchanged. Verified in both directions — it passes against the new expression and fails when the workflow is reverted to the old one, so the guard is not vacuous. --- apps/desktop/test/ci-workflow.test.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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:)/, From 7a0160387f4ac1978cc2edb93a2b4df38243c13e Mon Sep 17 00:00:00 2001 From: LiYao <2113239898x@gmail.com> Date: Wed, 30 Sep 2026 11:42:35 +0800 Subject: [PATCH 3/3] feat(settings): give vendor accounts the enable switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vendor subscription account had no enable switch on its service row, so the only way to stop an account's models from being offered was to remove the account and sign in again. Every layer below the row already treated a disabled provider the same way it treats an API service: `providers.list` with `includeDisabled=false` drops it from the model picker, session launch fails with `PROVIDER_DISABLED`, and the settings list still shows the row with a disabled badge so it can be turned back on. Only the UI entry point was missing, which made the account look like it could not be switched off. The switch decision moves into a pure `serviceRowToggle` helper next to the row's other rules, so which kinds get a switch — and which get a locked one — is covered by tests instead of by a condition in JSX. A plugin-declared row still has no switch, because the plugin owns that row and refreshes it from its manifest on every load; that is now stated in the spec rather than only in a comment. The source-text assertion in settings-general.test.mjs followed the old condition and is repointed at the helper call; the behavior it stood for is asserted directly in service-row-status.test.mjs. fixes #930 --- .../src/components/settings/ServiceRow.tsx | 10 +++--- .../components/settings/service-row-status.ts | 20 ++++++++++++ apps/desktop/test/service-row-status.test.mjs | 32 +++++++++++++++++++ apps/desktop/test/settings-general.test.mjs | 9 ++++-- .../03-runtime/11-provider-model-system.md | 7 +++- .../03-runtime/11-provider-model-system.md | 5 ++- 6 files changed, 72 insertions(+), 11 deletions(-) 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/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 提供商与已登录的厂商账户提供初始值。选择器始终暴露七个规范