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 提供商与已登录的厂商账户提供初始值。选择器始终暴露七个规范