| author | |
| committer | |
| log | e7f6a30caba5c390d5bbc9c300de89b68601c057 |
| tree | 302e30d16477f6d0979f397bdc75bdcafe0dabbc |
| parent | fe281c5408164250da6688de6fc71ac12d8a9d8f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 25 | 25 | ||
| 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 | ); |
| 225 | 225 | ||
| 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 | */ |
| 35 | import { | 35 | import { |
| 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 { |
| 93 | const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => | 94 | const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => |
| 94 | Equal.equals(a, b); | 95 | Equal.equals(a, b); |
| 95 | 96 | ||
| 96 | const 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 | */ | ||
| 105 | const 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 | }; |
| 103 | 112 | ||
| 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 | ); |
| 489 | 489 | ||
| 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); |
| 61 | const textEncoder = new TextEncoder(); | 61 | const textEncoder = new TextEncoder(); |
| 62 | const textDecoder = new TextDecoder(); | 62 | const textDecoder = new TextDecoder(); |
| 63 | 63 | ||
| 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 | */ | ||
| 72 | const 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 | |||
| 64 | const normalizeServerSettings = ( | 112 | const 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 | }); |
| 308 | 357 | ||
| 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"; |
| 15 | import { useState, type ReactNode } from "react"; | 15 | import { useState, type ReactNode } from "react"; |
| 16 | import { | 16 | import { |
| 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-trip | 369 | * 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 its | 373 | * driver-specific `config.enabled` into the envelope on load and both |
| 373 | * driver-specific `config.enabled`. We treat the envelope flag as the | 374 | * sides resolve through `resolveProviderInstanceEnabled` (an explicit |
| 374 | * single source of truth from the UI — built-in cards used to write | 375 | * 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 | */ |
| 378 | export function ProviderInstanceCard({ | 377 | export 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 to | 397 | // The server-reported status wins when present; otherwise fall back to |
| 399 | // "disabled"/"warning" based on the local `enabled` flag so the dot | 398 | // "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"; |
| 16 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 17 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 17 | import { | 18 | import { |
| ... | @@ -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 for | 550 | // 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 = explicitInstance | 223 | const enabled = explicitInstance |
| 223 | ? (explicitInstance.enabled ?? true) | 224 | ? resolveProviderInstanceEnabled(explicitInstance) |
| 224 | : entry.isDefault | 225 | : 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` | |
| 64 | 64 | ||
| 65 | Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn | ||
| 66 | them on in **Settings** → the provider's card when you want to use them. | ||
| 67 | |||
| 65 | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that | 68 | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that |
| 66 | T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. | 69 | T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. |
| 67 | 70 |
packages/contracts/src/settings.test.ts+43-1| ... | @@ -1,11 +1,13 @@ | ... | @@ -1,11 +1,13 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | import { ProviderInstanceId } from "./providerInstance.ts"; | 4 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 5 | import { | 5 | import { |
| 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 | }); |
| 179 | 181 | ||
| 182 | describe("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 | |||
| 180 | describe("ServerSettings worktree defaults", () => { | 222 | describe("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"; |
| 21 | import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; | 21 | import { |
| 22 | ProviderInstanceConfig, | ||
| 23 | ProviderInstanceId, | ||
| 24 | type ProviderDriverKind, | ||
| 25 | } from "./providerInstance.ts"; | ||
| 22 | 26 | ||
| 23 | // ── Client Settings (local-only) ─────────────────────────────── | 27 | // ── Client Settings (local-only) ─────────────────────────────── |
| 24 | 28 | ||
| ... | @@ -403,6 +407,8 @@ export type ClaudeSettings = typeof ClaudeSettings.Type; | ... | @@ -403,6 +407,8 @@ export type ClaudeSettings = typeof ClaudeSettings.Type; |
| 403 | 407 | ||
| 404 | export const CursorSettings = makeProviderSettingsSchema( | 408 | export 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; |
| 438 | 444 | ||
| 439 | export const GrokSettings = makeProviderSettingsSchema( | 445 | export 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; |
| 462 | 470 | ||
| 463 | export const OpenCodeSettings = makeProviderSettingsSchema( | 471 | export 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; |
| 667 | 677 | ||
| 668 | export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); | 678 | export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); |
| 669 | 679 | ||
| 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 | */ | ||
| 686 | export 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 | */ | ||
| 700 | export 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 | */ | ||
| 714 | export 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 | |||
| 670 | export const ServerSettingsOperation = Schema.Literals([ | 724 | export 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 @@ |
| 1 | import { | 1 | import { |
| 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 | } |
| 41 | 42 | ||
| 42 | return ( | 43 | return ( |