authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-18 21:17:12-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-18 18:17:12-07:00
loge7f6a30caba5c390d5bbc9c300de89b68601c057
tree302e30d16477f6d0979f397bdc75bdcafe0dabbc
parentfe281c5408164250da6688de6fc71ac12d8a9d8f
signature Signed by PGP key B5690EEEBB952194

fix(server): stop probing Grok, Cursor, and OpenCode unless turned on (#7459)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

12 files changed, 294 insertions(+), 31 deletions(-)

apps/server/src/provider/Layers/GrokProvider.test.ts+11-1
...@@ -23,9 +23,19 @@ describe("buildInitialGrokProviderSnapshot", () => {...@@ -23,9 +23,19 @@ describe("buildInitialGrokProviderSnapshot", () => {
23 }),23 }),
24 );24 );
2525
26 it.effect("returns a pending snapshot by default", () =>26 it.effect("returns a disabled snapshot by default — Grok is opt-in", () =>
27 Effect.gen(function* () {27 Effect.gen(function* () {
28 const snapshot = yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({}));28 const snapshot = yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({}));
29 expect(snapshot.enabled).toBe(false);
30 expect(snapshot.status).toBe("disabled");
31 }),
32 );
33
34 it.effect("returns a pending snapshot when enabled", () =>
35 Effect.gen(function* () {
36 const snapshot = yield* buildInitialGrokProviderSnapshot(
37 decodeGrokSettings({ enabled: true }),
38 );
29 expect(snapshot.enabled).toBe(true);39 expect(snapshot.enabled).toBe(true);
30 expect(snapshot.installed).toBe(true);40 expect(snapshot.installed).toBe(true);
31 expect(snapshot.status).toBe("warning");41 expect(snapshot.status).toBe("warning");
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+26
...@@ -223,6 +223,32 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {...@@ -223,6 +223,32 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
223 }).pipe(Effect.provide(testLayer)),223 }).pipe(Effect.provide(testLayer)),
224 );224 );
225225
226 it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () =>
227 Effect.gen(function* () {
228 // Old settings files can carry both flags with conflicting values.
229 // The explicit false must win so a user's disable is never undone.
230 const staleId = ProviderInstanceId.make("codex_stale");
231 const configMap: ProviderInstanceConfigMap = {
232 [staleId]: {
233 driver: ProviderDriverKind.make("codex"),
234 enabled: true,
235 config: makeCodexConfig({ enabled: false }),
236 },
237 };
238
239 const { registry } = yield* makeProviderInstanceRegistry({
240 drivers: [CodexDriver],
241 configMap,
242 });
243
244 const instance = yield* registry.getInstance(staleId);
245 expect(instance).toBeDefined();
246 expect(instance!.enabled).toBe(false);
247 const snapshot = yield* instance!.snapshot.getSnapshot;
248 expect(snapshot.enabled).toBe(false);
249 }).pipe(Effect.provide(testLayer)),
250 );
251
226 it.live(252 it.live(
227 "shadows instances whose driver is not registered in this build without failing boot",253 "shadows instances whose driver is not registered in this build without failing boot",
228 () =>254 () =>
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts+15-6
...@@ -34,6 +34,7 @@...@@ -34,6 +34,7 @@
34 */34 */
35import {35import {
36 defaultInstanceIdForDriver,36 defaultInstanceIdForDriver,
37 providerInstanceConfigEnabledFlag,
37 ProviderInstanceId,38 ProviderInstanceId,
38 type ProviderInstanceConfig,39 type ProviderInstanceConfig,
39 type ProviderInstanceConfigMap,40 type ProviderInstanceConfigMap,
...@@ -93,12 +94,20 @@ interface RegistryState {...@@ -93,12 +94,20 @@ interface RegistryState {
93const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean =>94const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean =>
94 Equal.equals(a, b);95 Equal.equals(a, b);
9596
96const decodedConfigEnabled = (config: unknown): boolean | undefined => {97/**
97 if (!config || typeof config !== "object" || globalThis.Array.isArray(config)) {98 * Resolve an entry's enabled state. An explicit false on either the
98 return undefined;99 * envelope or the raw config blob wins (most restrictive) — old settings
100 * files can carry both flags with conflicting values, and a user's disable
101 * must never be silently undone. Otherwise the envelope flag wins, then the
102 * decoded config's flag (which carries the driver schema's default for
103 * built-ins and forks alike), then enabled by default.
104 */
105const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => {
106 const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config);
107 if (entry.enabled === false || rawConfigEnabled === false) {
108 return false;
99 }109 }
100 const enabled = (config as { readonly enabled?: unknown }).enabled;110 return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true;
101 return typeof enabled === "boolean" ? enabled : undefined;
102};111};
103112
104/**113/**
...@@ -171,7 +180,7 @@ const buildEntry = <R>(input: {...@@ -171,7 +180,7 @@ const buildEntry = <R>(input: {
171 displayName: entry.displayName,180 displayName: entry.displayName,
172 accentColor: entry.accentColor,181 accentColor: entry.accentColor,
173 environment: entry.environment ?? [],182 environment: entry.environment ?? [],
174 enabled: entry.enabled ?? decodedConfigEnabled(typedConfig) ?? true,183 enabled: resolveEntryEnabled(entry, typedConfig),
175 config: typedConfig,184 config: typedConfig,
176 })185 })
177 .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result);186 .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result);
apps/server/src/serverSettings.test.ts+61-1
...@@ -487,6 +487,65 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -487,6 +487,65 @@ it.layer(NodeServices.layer)("server settings", (it) => {
487 }).pipe(Effect.provide(makeServerSettingsLayer())),487 }).pipe(Effect.provide(makeServerSettingsLayer())),
488 );488 );
489489
490 it.effect("folds a legacy in-config enabled flag into the envelope on load", () =>
491 Effect.gen(function* () {
492 const serverConfig = yield* ServerConfig.ServerConfig;
493 const fileSystem = yield* FileSystem.FileSystem;
494 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
495 // Old settings files can carry both flags with conflicting values.
496 // The explicit false must win so a user's disable sticks.
497 yield* fileSystem.writeFileString(
498 serverConfig.settingsPath,
499 '{"providerInstances":{"grok":{"driver":"grok","enabled":true,"config":{"enabled":false}},"codex_work":{"driver":"codex","config":{"enabled":true,"homePath":"~/.codex"}},"cursor":{"driver":"cursor","config":{"enabled":"nope"}}}}',
500 );
501
502 const settings = yield* serverSettings.getSettings;
503
504 const grokId = ProviderInstanceId.make("grok");
505 const codexWorkId = ProviderInstanceId.make("codex_work");
506 assert.deepEqual(settings.providerInstances[grokId], {
507 driver: ProviderDriverKind.make("grok"),
508 enabled: false,
509 config: {},
510 });
511 // A lone in-config flag is lifted to the envelope and stripped.
512 assert.deepEqual(settings.providerInstances[codexWorkId], {
513 driver: ProviderDriverKind.make("codex"),
514 enabled: true,
515 config: { homePath: "~/.codex" },
516 });
517 // A malformed flag is left alone so driver schema validation can
518 // surface it instead of the fold silently repairing the config.
519 assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("cursor")], {
520 driver: ProviderDriverKind.make("cursor"),
521 config: { enabled: "nope" },
522 });
523 }).pipe(Effect.provide(makeServerSettingsLayer())),
524 );
525
526 it.effect("folds in-config enabled flags arriving through updates", () =>
527 Effect.gen(function* () {
528 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
529 const grokId = ProviderInstanceId.make("grok");
530
531 const next = yield* serverSettings.updateSettings({
532 providerInstances: {
533 [grokId]: {
534 driver: ProviderDriverKind.make("grok"),
535 enabled: true,
536 config: { enabled: false, binaryPath: "/opt/grok" },
537 },
538 },
539 });
540
541 assert.deepEqual(next.providerInstances[grokId], {
542 driver: ProviderDriverKind.make("grok"),
543 enabled: false,
544 config: { binaryPath: "/opt/grok" },
545 });
546 }).pipe(Effect.provide(makeServerSettingsLayer())),
547 );
548
490 it.effect("trims provider path settings when updates are applied", () =>549 it.effect("trims provider path settings when updates are applied", () =>
491 Effect.gen(function* () {550 Effect.gen(function* () {
492 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;551 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
...@@ -524,7 +583,8 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -524,7 +583,8 @@ it.layer(NodeServices.layer)("server settings", (it) => {
524 launchArgs: "",583 launchArgs: "",
525 });584 });
526 assert.deepEqual(next.providers.opencode, {585 assert.deepEqual(next.providers.opencode, {
527 enabled: true,586 // OpenCode is disabled by default; this update only touches paths.
587 enabled: false,
528 binaryPath: "/opt/homebrew/bin/opencode",588 binaryPath: "/opt/homebrew/bin/opencode",
529 serverUrl: "http://127.0.0.1:4096",589 serverUrl: "http://127.0.0.1:4096",
530 serverPassword: "secret-password",590 serverPassword: "secret-password",
apps/server/src/serverSettings.ts+50-1
...@@ -61,11 +61,60 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);...@@ -61,11 +61,60 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
61const textEncoder = new TextEncoder();61const textEncoder = new TextEncoder();
62const textDecoder = new TextDecoder();62const textDecoder = new TextDecoder();
6363
64/**
65 * Fold the legacy in-config `enabled` flag into the envelope-level
66 * `ProviderInstanceConfig.enabled` and strip it from the config blob, so
67 * explicit provider instances carry exactly one enabled flag. Old settings
68 * files can hold both flags with conflicting values; an explicit false on
69 * either side wins so a user's disable is never silently undone. Runs on
70 * every load and update — the file converges on the next write.
71 */
72const foldProviderInstanceEnabledFlags = (settings: ServerSettings): ServerSettings => {
73 let changed = false;
74 const providerInstances: Record<string, ProviderInstanceConfig> = {};
75 for (const [instanceId, instance] of Object.entries(settings.providerInstances)) {
76 const config = instance.config;
77 // Only fold boolean flags: a malformed `enabled` (e.g. `"false"`) must
78 // stay in the blob so driver schema validation flags it instead of the
79 // fold silently repairing the config.
80 if (
81 config === null ||
82 typeof config !== "object" ||
83 Array.isArray(config) ||
84 typeof (config as { readonly enabled?: unknown }).enabled !== "boolean"
85 ) {
86 providerInstances[instanceId] = instance;
87 continue;
88 }
89 const { enabled: configEnabled, ...restConfig } = config as Record<string, unknown> & {
90 readonly enabled: boolean;
91 };
92 const resolved =
93 instance.enabled === false || configEnabled === false
94 ? false
95 : (instance.enabled ?? configEnabled);
96 changed = true;
97 providerInstances[instanceId] = {
98 ...instance,
99 enabled: resolved,
100 config: restConfig,
101 } satisfies ProviderInstanceConfig;
102 }
103 if (!changed) {
104 return settings;
105 }
106 return {
107 ...settings,
108 providerInstances: providerInstances as ServerSettings["providerInstances"],
109 };
110};
111
64const normalizeServerSettings = (112const normalizeServerSettings = (
65 settings: ServerSettings,113 settings: ServerSettings,
66): Effect.Effect<ServerSettings, ServerSettingsError> =>114): Effect.Effect<ServerSettings, ServerSettingsError> =>
67 encodeServerSettings(settings).pipe(115 encodeServerSettings(settings).pipe(
68 Effect.flatMap(decodeServerSettings),116 Effect.flatMap(decodeServerSettings),
117 Effect.map(foldProviderInstanceEnabledFlags),
69 Effect.mapError(118 Effect.mapError(
70 (cause) =>119 (cause) =>
71 new ServerSettingsError({120 new ServerSettingsError({
...@@ -303,7 +352,7 @@ const make = Effect.gen(function* () {...@@ -303,7 +352,7 @@ const make = Effect.gen(function* () {
303 });352 });
304 return DEFAULT_SERVER_SETTINGS;353 return DEFAULT_SERVER_SETTINGS;
305 }354 }
306 return decoded.value;355 return foldProviderInstanceEnabledFlags(decoded.value);
307 });356 });
308357
309 const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({358 const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({
apps/web/src/components/settings/ProviderInstanceCard.tsx+6-7
...@@ -15,6 +15,7 @@ import * as Result from "effect/Result";...@@ -15,6 +15,7 @@ import * as Result from "effect/Result";
15import { useState, type ReactNode } from "react";15import { useState, type ReactNode } from "react";
16import {16import {
17 isProviderDriverKind,17 isProviderDriverKind,
18 resolveProviderInstanceEnabled,
18 type ProviderInstanceConfig,19 type ProviderInstanceConfig,
19 type ProviderInstanceEnvironmentVariable,20 type ProviderInstanceEnvironmentVariable,
20 type ProviderInstanceId,21 type ProviderInstanceId,
...@@ -368,12 +369,10 @@ interface ProviderInstanceCardProps {...@@ -368,12 +369,10 @@ interface ProviderInstanceCardProps {
368 * notice instead of editable fields, so fork instances round-trip369 * notice instead of editable fields, so fork instances round-trip
369 * without accidentally destroying their config.370 * without accidentally destroying their config.
370 * - The enabled Switch writes to the envelope's `instance.enabled`371 * - The enabled Switch writes to the envelope's `instance.enabled`
371 * field; the server's registry consults this at `entry.enabled ?? true`372 * field, which is the single enabled flag: the server folds any legacy
372 * before materializing the instance, and the probe also checks its373 * driver-specific `config.enabled` into the envelope on load and both
373 * driver-specific `config.enabled`. We treat the envelope flag as the374 * sides resolve through `resolveProviderInstanceEnabled` (an explicit
374 * single source of truth from the UI — built-in cards used to write375 * false wins, then envelope, then config, then the driver default).
375 * the inner flag, but on the promotion-to-instance path every edit
376 * flows through the envelope.
377 */376 */
378export function ProviderInstanceCard({377export function ProviderInstanceCard({
379 instanceId,378 instanceId,
...@@ -394,7 +393,7 @@ export function ProviderInstanceCard({...@@ -394,7 +393,7 @@ export function ProviderInstanceCard({
394 onRunUpdate,393 onRunUpdate,
395 isUpdating = false,394 isUpdating = false,
396}: ProviderInstanceCardProps) {395}: ProviderInstanceCardProps) {
397 const enabled = instance.enabled ?? true;396 const enabled = resolveProviderInstanceEnabled(instance);
398 // The server-reported status wins when present; otherwise fall back to397 // The server-reported status wins when present; otherwise fall back to
399 // "disabled"/"warning" based on the local `enabled` flag so the dot398 // "disabled"/"warning" based on the local `enabled` flag so the dot
400 // reflects the persisted intent even before the first probe completes.399 // reflects the persisted intent even before the first probe completes.
apps/web/src/components/settings/ProviderSettingsPanel.tsx+18-9
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 ProviderDriverKind,12 ProviderDriverKind,
13 type ProviderInstanceConfig,13 type ProviderInstanceConfig,
14 type ProviderInstanceId,14 type ProviderInstanceId,
15 resolveProviderInstanceEnabled,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
16import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";17import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
17import {18import {
...@@ -529,15 +530,23 @@ export function EnvironmentProviderSettings({...@@ -529,15 +530,23 @@ export function EnvironmentProviderSettings({
529 // instance or a legacy blob there is nothing to render for the slot.530 // instance or a legacy blob there is nothing to render for the slot.
530 const legacyConfig = legacyProviders[providerSettings.provider];531 const legacyConfig = legacyProviders[providerSettings.provider];
531 const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider];532 const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider];
533 // The envelope is the single enabled flag: keep the legacy in-config
534 // flag out of the synthesized blob, or an explicit `enabled: false`
535 // would keep winning over the envelope and the Switch could never
536 // turn a default-off provider on.
537 const synthesizedInstance = (): ProviderInstanceConfig | undefined => {
538 if (legacyConfig === undefined) {
539 return undefined;
540 }
541 const { enabled: legacyEnabled, ...legacyConfigRest } = legacyConfig;
542 return {
543 driver,
544 enabled: legacyEnabled,
545 config: legacyConfigRest,
546 } satisfies ProviderInstanceConfig;
547 };
532 const effectiveInstance: ProviderInstanceConfig | undefined =548 const effectiveInstance: ProviderInstanceConfig | undefined =
533 explicitInstance ??549 explicitInstance ?? synthesizedInstance();
534 (legacyConfig !== undefined
535 ? ({
536 driver,
537 enabled: legacyConfig.enabled,
538 config: legacyConfig,
539 } satisfies ProviderInstanceConfig)
540 : undefined);
541 // Only the default slot depends on the legacy blob; custom instances for550 // Only the default slot depends on the legacy blob; custom instances for
542 // the driver must still render even when the slot has nothing to show.551 // the driver must still render even when the slot has nothing to show.
543 if (effectiveInstance !== undefined) {552 if (effectiveInstance !== undefined) {
...@@ -838,7 +847,7 @@ export function EnvironmentProviderSettings({...@@ -838,7 +847,7 @@ export function EnvironmentProviderSettings({
838 }))847 }))
839 }848 }
840 onUpdate={(next) => {849 onUpdate={(next) => {
841 const wasEnabled = row.instance.enabled ?? true;850 const wasEnabled = resolveProviderInstanceEnabled(row.instance);
842 const isDisabling = next.enabled === false && wasEnabled;851 const isDisabling = next.enabled === false && wasEnabled;
843 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;852 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;
844 if (shouldClearTextGen) {853 if (shouldClearTextGen) {
apps/web/src/providerInstances.ts+2-1
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 DEFAULT_MODEL_BY_PROVIDER,16 DEFAULT_MODEL_BY_PROVIDER,
17 defaultInstanceIdForDriver,17 defaultInstanceIdForDriver,
18 PROVIDER_DISPLAY_NAMES,18 PROVIDER_DISPLAY_NAMES,
19 resolveProviderInstanceEnabled,
19 type ModelSelection,20 type ModelSelection,
20 type ProviderDriverKind,21 type ProviderDriverKind,
21 ProviderInstanceId,22 ProviderInstanceId,
...@@ -220,7 +221,7 @@ export function applyProviderInstanceSettings(...@@ -220,7 +221,7 @@ export function applyProviderInstanceSettings(
220 return entries.map((entry) => {221 return entries.map((entry) => {
221 const explicitInstance = settings.providerInstances?.[entry.instanceId];222 const explicitInstance = settings.providerInstances?.[entry.instanceId];
222 const enabled = explicitInstance223 const enabled = explicitInstance
223 ? (explicitInstance.enabled ?? true)224 ? resolveProviderInstanceEnabled(explicitInstance)
224 : entry.isDefault225 : entry.isDefault
225 ? (legacyProviders[entry.driverKind]?.enabled ?? entry.enabled)226 ? (legacyProviders[entry.driverKind]?.enabled ?? entry.enabled)
226 : false;227 : false;
docs/user/install.md+3
...@@ -62,6 +62,9 @@ to use, then authenticate it....@@ -62,6 +62,9 @@ to use, then authenticate it.
62| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` |62| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` |
63| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` |63| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` |
6464
65Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn
66them on in **Settings** → the provider's card when you want to use them.
67
65Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that68Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that
66T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`.69T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`.
6770
packages/contracts/src/settings.test.ts+43-1
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
33
4import { ProviderInstanceId } from "./providerInstance.ts";4import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
5import {5import {
6 ClientSettingsSchema,6 ClientSettingsSchema,
7 ClientSettingsPatch,7 ClientSettingsPatch,
8 DEFAULT_SERVER_SETTINGS,8 DEFAULT_SERVER_SETTINGS,
9 defaultEnabledForDriver,
10 resolveProviderInstanceEnabled,
9 ServerSettings,11 ServerSettings,
10 ServerSettingsPatch,12 ServerSettingsPatch,
11} from "./settings.ts";13} from "./settings.ts";
...@@ -177,6 +179,46 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {...@@ -177,6 +179,46 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
177 });179 });
178});180});
179181
182describe("provider enabled defaults", () => {
183 it("enables only the stable bindings by default", () => {
184 const decoded = decodeServerSettings({});
185 expect(decoded.providers.codex.enabled).toBe(true);
186 expect(decoded.providers.claudeAgent.enabled).toBe(true);
187 expect(decoded.providers.cursor.enabled).toBe(false);
188 expect(decoded.providers.grok.enabled).toBe(false);
189 expect(decoded.providers.opencode.enabled).toBe(false);
190 });
191
192 it("derives per-driver defaults from the settings schemas", () => {
193 expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true);
194 expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false);
195 // Unknown fork drivers stay enabled; their own build decides otherwise.
196 expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true);
197 });
198
199 it("resolves instance enabled state with explicit false winning", () => {
200 const grok = ProviderDriverKind.make("grok");
201 const codex = ProviderDriverKind.make("codex");
202 // No flags anywhere: driver default applies.
203 expect(resolveProviderInstanceEnabled({ driver: grok, config: {} })).toBe(false);
204 expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true);
205 // Envelope flag wins over the driver default.
206 expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true);
207 expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe(
208 false,
209 );
210 // Legacy in-config flag fills in when the envelope is silent.
211 expect(resolveProviderInstanceEnabled({ driver: grok, config: { enabled: true } })).toBe(true);
212 // Conflicting flags: the explicit false wins, whichever side it is on.
213 expect(
214 resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: { enabled: false } }),
215 ).toBe(false);
216 expect(
217 resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: { enabled: true } }),
218 ).toBe(false);
219 });
220});
221
180describe("ServerSettings worktree defaults", () => {222describe("ServerSettings worktree defaults", () => {
181 it("defaults start-from-origin on for legacy configs", () => {223 it("defaults start-from-origin on for legacy configs", () => {
182 expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true);224 expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true);
packages/contracts/src/settings.ts+57-3
...@@ -18,7 +18,11 @@ import {...@@ -18,7 +18,11 @@ import {
18 PreviewViewportSetting,18 PreviewViewportSetting,
19 PreviewZoomFactor,19 PreviewZoomFactor,
20} from "./preview.ts";20} from "./preview.ts";
21import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";21import {
22 ProviderInstanceConfig,
23 ProviderInstanceId,
24 type ProviderDriverKind,
25} from "./providerInstance.ts";
2226
23// ── Client Settings (local-only) ───────────────────────────────27// ── Client Settings (local-only) ───────────────────────────────
2428
...@@ -403,6 +407,8 @@ export type ClaudeSettings = typeof ClaudeSettings.Type;...@@ -403,6 +407,8 @@ export type ClaudeSettings = typeof ClaudeSettings.Type;
403407
404export const CursorSettings = makeProviderSettingsSchema(408export const CursorSettings = makeProviderSettingsSchema(
405 {409 {
410 // Off by default (like Grok and OpenCode): the binding is not yet
411 // stable enough to probe on every install. Users opt in from Settings.
406 enabled: Schema.Boolean.pipe(412 enabled: Schema.Boolean.pipe(
407 Schema.withDecodingDefault(Effect.succeed(false)),413 Schema.withDecodingDefault(Effect.succeed(false)),
408 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),414 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
...@@ -438,8 +444,10 @@ export type CursorSettings = typeof CursorSettings.Type;...@@ -438,8 +444,10 @@ export type CursorSettings = typeof CursorSettings.Type;
438444
439export const GrokSettings = makeProviderSettingsSchema(445export const GrokSettings = makeProviderSettingsSchema(
440 {446 {
447 // Off by default (like Cursor and OpenCode): the binding is not yet
448 // stable enough to probe on every install. Users opt in from Settings.
441 enabled: Schema.Boolean.pipe(449 enabled: Schema.Boolean.pipe(
442 Schema.withDecodingDefault(Effect.succeed(true)),450 Schema.withDecodingDefault(Effect.succeed(false)),
443 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),451 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
444 ),452 ),
445 binaryPath: makeBinaryPathSetting("grok").pipe(453 binaryPath: makeBinaryPathSetting("grok").pipe(
...@@ -462,8 +470,10 @@ export type GrokSettings = typeof GrokSettings.Type;...@@ -462,8 +470,10 @@ export type GrokSettings = typeof GrokSettings.Type;
462470
463export const OpenCodeSettings = makeProviderSettingsSchema(471export const OpenCodeSettings = makeProviderSettingsSchema(
464 {472 {
473 // Off by default (like Cursor and Grok): the binding is not yet stable
474 // enough to probe on every install. Users opt in from Settings.
465 enabled: Schema.Boolean.pipe(475 enabled: Schema.Boolean.pipe(
466 Schema.withDecodingDefault(Effect.succeed(true)),476 Schema.withDecodingDefault(Effect.succeed(false)),
467 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),477 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
468 ),478 ),
469 binaryPath: makeBinaryPathSetting("opencode").pipe(479 binaryPath: makeBinaryPathSetting("opencode").pipe(
...@@ -667,6 +677,50 @@ export type ServerSettings = typeof ServerSettings.Type;...@@ -667,6 +677,50 @@ export type ServerSettings = typeof ServerSettings.Type;
667677
668export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({});678export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({});
669679
680/**
681 * Read the legacy `enabled` flag embedded in a provider instance config
682 * blob. The envelope-level `ProviderInstanceConfig.enabled` is the single
683 * flag going forward; this reader exists for legacy `providers.<kind>`
684 * blobs and old settings files that still carry the flag in-config.
685 */
686export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | undefined => {
687 if (config === null || typeof config !== "object" || Array.isArray(config)) {
688 return undefined;
689 }
690 const enabled = (config as { readonly enabled?: unknown }).enabled;
691 return typeof enabled === "boolean" ? enabled : undefined;
692};
693
694/**
695 * Default enabled state for a built-in driver when neither the envelope nor
696 * the config blob carries a flag. Derived from the driver's settings schema
697 * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays
698 * the single source of truth. Unknown (fork) drivers default to enabled.
699 */
700export const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => {
701 const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record<
702 string,
703 { readonly enabled?: boolean } | undefined
704 >;
705 return legacyDefaults[driver]?.enabled ?? true;
706};
707
708/**
709 * Resolve whether a configured provider instance is enabled. An explicit
710 * false on either the envelope or the in-config flag wins (most
711 * restrictive), so a user's disable is never silently undone by the other
712 * flag. Otherwise: envelope, then config, then the driver's default.
713 */
714export const resolveProviderInstanceEnabled = (
715 instance: Pick<ProviderInstanceConfig, "driver" | "enabled" | "config">,
716): boolean => {
717 const configEnabled = providerInstanceConfigEnabledFlag(instance.config);
718 if (instance.enabled === false || configEnabled === false) {
719 return false;
720 }
721 return instance.enabled ?? configEnabled ?? defaultEnabledForDriver(instance.driver);
722};
723
670export const ServerSettingsOperation = Schema.Literals([724export const ServerSettingsOperation = Schema.Literals([
671 "normalize",725 "normalize",
672 "check-exists",726 "check-exists",
packages/shared/src/serverSettings.ts+2-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 isProviderDriverKind,2 isProviderDriverKind,
3 isProviderAvailable,3 isProviderAvailable,
4 resolveProviderInstanceEnabled,
4 type ModelSelection,5 type ModelSelection,
5 type ProviderDriverKind,6 type ProviderDriverKind,
6 type ServerProvider,7 type ServerProvider,
...@@ -36,7 +37,7 @@ export function isModelSelectionProviderEnabled(...@@ -36,7 +37,7 @@ export function isModelSelectionProviderEnabled(
36): boolean {37): boolean {
37 const instanceConfig = settings.providerInstances[selection.instanceId];38 const instanceConfig = settings.providerInstances[selection.instanceId];
38 if (instanceConfig !== undefined) {39 if (instanceConfig !== undefined) {
39 return instanceConfig.enabled ?? true;40 return resolveProviderInstanceEnabled(instanceConfig);
40 }41 }
4142
42 return (43 return (