1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {
3 DEFAULT_SERVER_SETTINGS,
4 ModelSelection,
5 ProjectId,
6 ProjectScript,
7 ProviderDriverKind,
8 ProviderInstanceId,
9 resolveProviderInstanceEnabled,
10 ServerSettings,
11 ServerSettingsPatch,
12 UsageLimitSourceId,
13} from "@t3tools/contracts";
14import { createModelSelection } from "@t3tools/shared/model";
15import { assert, it } from "@effect/vitest";
16import * as Deferred from "effect/Deferred";
17import * as Effect from "effect/Effect";
18import * as Duration from "effect/Duration";
19import * as FileSystem from "effect/FileSystem";
20import * as Fiber from "effect/Fiber";
21import * as Layer from "effect/Layer";
22import * as Option from "effect/Option";
23import * as Path from "effect/Path";
24import * as PlatformError from "effect/PlatformError";
25import * as Schema from "effect/Schema";
26import * as Stream from "effect/Stream";
27import * as TestClock from "effect/testing/TestClock";
28import * as SqlClient from "effect/sql/SqlClient";
29import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
30import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts";
31import * as ServerConfig from "./config.ts";
32import * as SqlitePersistence from "./persistence/Sqlite.ts";
33import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
34import * as ServerSettingsModule from "./serverSettings.ts";
35import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";
36
37const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch);
38const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
39const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings));
40
41const layerServerSettings = () =>
42 ServerSettingsModule.layer.pipe(
43 Layer.provide(ServerSecretStore.layer),
44 Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)),
45 Layer.provideMerge(
46 Layer.fresh(
47 ServerConfig.layerTest(process.cwd(), {
48 prefix: "t3code-server-settings-test-",
49 }),
50 ),
51 ),
52 );
53
54/** Like `layerServerSettings`, but also exposes the secret store for assertions. */
55const layerServerSettingsWithSecrets = () =>
56 ServerSettingsModule.layer.pipe(
57 Layer.provideMerge(ServerSecretStore.layer),
58 Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)),
59 Layer.provideMerge(
60 Layer.fresh(
61 ServerConfig.layerTest(process.cwd(), {
62 prefix: "t3code-server-settings-test-",
63 }),
64 ),
65 ),
66 );
67
68const layerFailingSecretStore = (cause: ServerSecretStore.SecretStoreError) =>
69 Layer.succeed(
70 ServerSecretStore.ServerSecretStore,
71 ServerSecretStore.ServerSecretStore.of({
72 get: () => Effect.fail(cause),
73 set: () => Effect.void,
74 create: () => Effect.void,
75 getOrCreateRandom: () => Effect.succeed(new Uint8Array()),
76 remove: () => Effect.void,
77 }),
78 );
79
80const recordProviderUsage = (provider: string, instanceId: string | null = provider) =>
81 Effect.gen(function* () {
82 const sql = yield* SqlClient.SqlClient;
83 yield* sql`
84 INSERT INTO projection_thread_sessions (
85 thread_id,
86 status,
87 provider_name,
88 provider_instance_id,
89 updated_at
90 )
91 VALUES (
92 ${`thread-${instanceId ?? provider}`},
93 ${"ready"},
94 ${provider},
95 ${instanceId},
96 ${"2026-08-25T00:00:00.000Z"}
97 )
98 `;
99 });
100
101it.layer(NodeServices.layer)("server settings", (it) => {
102 it.effect("serializes concurrent model preference edits without cancelling duplicate adds", () =>
103 Effect.gen(function* () {
104 const service = yield* ServerSettingsModule.ServerSettingsService;
105 const provider = ProviderInstanceId.make("codex_work");
106 yield* Effect.all(
107 [
108 service.updateSettings({
109 setModelFavorites: [{ provider, model: "sol", favorite: true }],
110 }),
111 service.updateSettings({
112 setModelFavorites: [{ provider, model: "sol", favorite: true }],
113 }),
114 service.updateSettings({
115 setModelFavorites: [{ provider, model: "luna", favorite: true }],
116 }),
117 service.updateSettings({ setModelsHidden: [{ provider, model: "astra", hidden: true }] }),
118 service.updateSettings({ setModelsHidden: [{ provider, model: "luna", hidden: true }] }),
119 service.updateSettings({
120 setProviderModelOrder: { provider, modelOrder: ["luna", "sol"] },
121 }),
122 ],
123 { concurrency: "unbounded" },
124 );
125 const settings = yield* service.getSettings;
126 assert.deepEqual(settings.favorites?.map((entry) => entry.model).sort(), ["luna", "sol"]);
127 assert.deepEqual(settings.providerModelPreferences?.[provider]?.hiddenModels.toSorted(), [
128 "astra",
129 "luna",
130 ]);
131 assert.deepEqual(settings.providerModelPreferences?.[provider]?.modelOrder, ["luna", "sol"]);
132 }).pipe(Effect.provide(layerServerSettings())),
133 );
134 it.effect("persists and broadcasts model preferences to other connected clients", () =>
135 Effect.scoped(
136 Effect.gen(function* () {
137 const service = yield* ServerSettingsModule.ServerSettingsService;
138 const config = yield* ServerConfig.ServerConfig;
139 const fs = yield* FileSystem.FileSystem;
140 const provider = ProviderInstanceId.make("codex_work");
141 const patch = {
142 favorites: [{ provider, model: "sol" }],
143 providerModelPreferences: {
144 [provider]: { hiddenModels: ["astra"], modelOrder: ["sol"] },
145 },
146 };
147 const changes = yield* service.subscribeChanges;
148 yield* service.updateSettings(patch);
149 const received = Option.getOrThrow(yield* Stream.runHead(changes));
150 assert.deepEqual(received.favorites, patch.favorites);
151 assert.deepEqual(received.providerModelPreferences, patch.providerModelPreferences);
152 const saved = yield* decodeServerSettingsJson(
153 yield* fs.readFileString(config.settingsPath),
154 );
155 assert.deepEqual(saved.favorites, patch.favorites);
156 assert.deepEqual(saved.providerModelPreferences, patch.providerModelPreferences);
157 const hidden = yield* service.updateSettings({
158 setModelsHidden: [{ provider, model: "sol", hidden: true }],
159 });
160 assert.deepEqual(hidden.providerModelPreferences?.[provider], {
161 hiddenModels: ["astra", "sol"],
162 modelOrder: ["sol"],
163 });
164 const shown = yield* service.updateSettings({
165 setModelsHidden: [{ provider, model: "astra", hidden: false }],
166 });
167 assert.deepEqual(shown.providerModelPreferences?.[provider], {
168 hiddenModels: ["sol"],
169 modelOrder: ["sol"],
170 });
171 assert.deepEqual(
172 (yield* decodeServerSettingsJson(yield* fs.readFileString(config.settingsPath)))
173 .providerModelPreferences,
174 shown.providerModelPreferences,
175 );
176 yield* service.updateSettings({ favorites: [], providerModelPreferences: {} });
177 // An older device's delayed seed cannot undo a clear, even after persistence.
178 const migrated = yield* service.updateSettings({ migrateModelPreferences: patch });
179 assert.deepEqual(migrated.favorites, []);
180 assert.deepEqual(migrated.providerModelPreferences, {});
181 }),
182 ).pipe(Effect.provide(layerServerSettings())),
183 );
184 it.effect("migrates saved token delivery to paragraph buffering without resetting settings", () =>
185 Effect.gen(function* () {
186 const config = yield* ServerConfig.ServerConfig;
187 const fs = yield* FileSystem.FileSystem;
188 const service = yield* ServerSettingsModule.ServerSettingsService;
189 yield* fs.writeFileString(
190 config.settingsPath,
191 `{
192 "responseStreamingMode": "token",
193 "enableAgentBrowserAccess": false,
194 "projectSettingsOverrides": {
195 "legacy": { "responseStreamingMode": "token", "defaultAutoPull": true },
196 "buffered": { "responseStreamingMode": "turn" },
197 "inherited": { "defaultAutoPull": false }
198 }
199 }`,
200 );
201
202 const settings = yield* service.getSettings;
203 assert.equal(settings.responseStreamingMode, "paragraph");
204 assert.isFalse(settings.enableAgentBrowserAccess);
205 assert.deepEqual(settings.projectSettingsOverrides, {
206 [ProjectId.make("legacy")]: { responseStreamingMode: "paragraph", defaultAutoPull: true },
207 [ProjectId.make("buffered")]: { responseStreamingMode: "turn" },
208 [ProjectId.make("inherited")]: { defaultAutoPull: false },
209 });
210
211 yield* service.updateSettings({ responseStreamingMode: "turn" });
212 const persisted = yield* decodeServerSettingsJson(
213 yield* fs.readFileString(config.settingsPath),
214 );
215 assert.equal(persisted.responseStreamingMode, "turn");
216 assert.deepEqual(persisted.projectSettingsOverrides, settings.projectSettingsOverrides);
217 }).pipe(Effect.provide(layerServerSettings())),
218 );
219
220 it.effect("saves through a symlinked settings file without replacing the link", () =>
221 Effect.gen(function* () {
222 const config = yield* ServerConfig.ServerConfig;
223 const fs = yield* FileSystem.FileSystem;
224 const path = yield* Path.Path;
225 const service = yield* ServerSettingsModule.ServerSettingsService;
226 const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" });
227 const linkedSettingsPath = path.join(dotfiles, "settings.json");
228 yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`);
229 yield* fs.remove(config.settingsPath, { force: true });
230 yield* fs.symlink(linkedSettingsPath, config.settingsPath);
231
232 yield* service.updateSettings({ responseStreamingMode: "paragraph" });
233
234 assert.equal(yield* fs.readLink(config.settingsPath), linkedSettingsPath);
235 const persisted = yield* decodeServerSettingsJson(
236 yield* fs.readFileString(linkedSettingsPath),
237 );
238 assert.equal(persisted.responseStreamingMode, "paragraph");
239 }).pipe(Effect.provide(layerServerSettings())),
240 );
241
242 it.effect("reloads when the destination of a symlinked settings file changes", () =>
243 Effect.scoped(
244 Effect.gen(function* () {
245 const config = yield* ServerConfig.ServerConfig;
246 const fs = yield* FileSystem.FileSystem;
247 const path = yield* Path.Path;
248 const service = yield* ServerSettingsModule.ServerSettingsService;
249 const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" });
250 const linkedSettingsPath = path.join(dotfiles, "settings.json");
251 yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`);
252 yield* fs.remove(config.settingsPath, { force: true });
253 yield* fs.symlink(linkedSettingsPath, config.settingsPath);
254 yield* service.start;
255 const changes = yield* service.subscribeChanges;
256
257 yield* writeFileStringAtomically({
258 filePath: linkedSettingsPath,
259 contents: `{ "responseStreamingMode": "paragraph" }`,
260 });
261
262 const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds"));
263 assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph");
264 }),
265 ).pipe(TestClock.withLive, Effect.provide(layerServerSettings())),
266 );
267
268 it.effect("follows a settings link that is repointed to another directory", () =>
269 Effect.scoped(
270 Effect.gen(function* () {
271 const config = yield* ServerConfig.ServerConfig;
272 const fs = yield* FileSystem.FileSystem;
273 const path = yield* Path.Path;
274 const service = yield* ServerSettingsModule.ServerSettingsService;
275 const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" });
276 const firstSettingsPath = path.join(dotfiles, "first", "settings.json");
277 const secondSettingsPath = path.join(dotfiles, "second", "settings.json");
278 yield* fs.makeDirectory(path.dirname(firstSettingsPath), { recursive: true });
279 yield* fs.makeDirectory(path.dirname(secondSettingsPath), { recursive: true });
280 yield* fs.writeFileString(firstSettingsPath, `{ "responseStreamingMode": "turn" }`);
281 yield* fs.writeFileString(secondSettingsPath, `{ "responseStreamingMode": "paragraph" }`);
282 yield* fs.remove(config.settingsPath, { force: true });
283 yield* fs.symlink(firstSettingsPath, config.settingsPath);
284 yield* service.start;
285
286 const repointChanges = yield* service.subscribeChanges;
287 yield* fs.remove(config.settingsPath);
288 yield* fs.symlink(secondSettingsPath, config.settingsPath);
289 const repointed = yield* repointChanges.pipe(Stream.runHead, Effect.timeout("2 seconds"));
290 assert.equal(Option.getOrUndefined(repointed)?.responseStreamingMode, "paragraph");
291
292 const editChanges = yield* service.subscribeChanges;
293 yield* writeFileStringAtomically({
294 filePath: secondSettingsPath,
295 contents: `{ "responseStreamingMode": "turn" }`,
296 });
297 const edited = yield* editChanges.pipe(Stream.runHead, Effect.timeout("2 seconds"));
298 assert.equal(Option.getOrUndefined(edited)?.responseStreamingMode, "turn");
299 }),
300 ).pipe(TestClock.withLive, Effect.provide(layerServerSettings())),
301 );
302
303 it.effect("reloads when a dangling settings link gets its destination", () =>
304 Effect.scoped(
305 Effect.gen(function* () {
306 const config = yield* ServerConfig.ServerConfig;
307 const fs = yield* FileSystem.FileSystem;
308 const path = yield* Path.Path;
309 const service = yield* ServerSettingsModule.ServerSettingsService;
310 const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" });
311 const linkedSettingsPath = path.join(dotfiles, "not-yet", "settings.json");
312 yield* fs.remove(config.settingsPath, { force: true });
313 yield* fs.symlink(linkedSettingsPath, config.settingsPath);
314 yield* service.start;
315 const changes = yield* service.subscribeChanges;
316
317 yield* writeFileStringAtomically({
318 filePath: linkedSettingsPath,
319 contents: `{ "responseStreamingMode": "paragraph" }`,
320 });
321
322 const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds"));
323 assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph");
324 }),
325 ).pipe(TestClock.withLive, Effect.provide(layerServerSettings())),
326 );
327
328 it.effect("preserves context when reading a provider environment secret fails", () => {
329 const platformCause = PlatformError.systemError({
330 _tag: "PermissionDenied",
331 module: "FileSystem",
332 method: "readFile",
333 pathOrDescriptor: "provider environment secret",
334 description: "Secret backend unavailable.",
335 });
336 const cause = new ServerSecretStore.SecretStoreReadError({
337 resource: "provider environment secret",
338 cause: platformCause,
339 });
340 const layerConfig = Layer.fresh(
341 ServerConfig.layerTest(process.cwd(), {
342 prefix: "t3code-server-settings-secret-failure-test-",
343 }),
344 );
345 const layerSettings = ServerSettingsModule.layer.pipe(
346 Layer.provide(layerFailingSecretStore(cause)),
347 Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)),
348 Layer.provideMerge(layerConfig),
349 );
350
351 return Effect.gen(function* () {
352 const serverConfig = yield* ServerConfig.ServerConfig;
353 const fileSystem = yield* FileSystem.FileSystem;
354 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
355 yield* fileSystem.writeFileString(
356 serverConfig.settingsPath,
357 '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"OPENROUTER_API_KEY","value":"","sensitive":true,"valueRedacted":true}],"config":{}}}}',
358 );
359
360 const error = yield* Effect.flip(serverSettings.getSettings);
361
362 assert.deepInclude(error, {
363 _tag: "ServerSettingsError",
364 operation: "read-secret",
365 providerInstanceId: "codex_personal",
366 environmentVariable: "OPENROUTER_API_KEY",
367 });
368 assert.strictEqual(error.cause, cause);
369 assert.notInclude(error.message, cause.message);
370 }).pipe(Effect.provide(layerSettings));
371 });
372
373 it.effect("identifies provider history query failures", () =>
374 Effect.gen(function* () {
375 const serverConfig = yield* ServerConfig.ServerConfig;
376 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
377 const sql = yield* SqlClient.SqlClient;
378 yield* sql`DROP TABLE projection_thread_sessions`;
379
380 const error = yield* Effect.flip(serverSettings.getSettings);
381
382 assert.deepInclude(error, {
383 _tag: "ServerSettingsError",
384 operation: "read-provider-history",
385 settingsPath: serverConfig.settingsPath,
386 });
387 }).pipe(Effect.provide(layerServerSettings())),
388 );
389
390 it.effect("retries a failed settings read instead of keeping the failure", () =>
391 Effect.gen(function* () {
392 const serverConfig = yield* ServerConfig.ServerConfig;
393 const fileSystem = yield* FileSystem.FileSystem;
394 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
395 // A directory where the file should be makes the read itself fail.
396 yield* fileSystem.makeDirectory(serverConfig.settingsPath);
397
398 const error = yield* Effect.flip(serverSettings.getSettings);
399 assert.deepInclude(error, { _tag: "ServerSettingsError", operation: "read-file" });
400
401 yield* fileSystem.remove(serverConfig.settingsPath, { recursive: true });
402 yield* fileSystem.writeFileString(
403 serverConfig.settingsPath,
404 `{ "responseStreamingMode": "turn" }`,
405 );
406
407 const settings = yield* serverSettings.getSettings;
408 assert.equal(settings.responseStreamingMode, "turn");
409 }).pipe(Effect.provide(layerServerSettings())),
410 );
411
412 it.effect("decodes nested settings patches", () =>
413 Effect.gen(function* () {
414 assert.deepEqual(
415 yield* decodeSettingsPatch({
416 textGenerationModelSelection: {
417 options: [{ id: "fastMode", value: false }],
418 },
419 }),
420 {
421 textGenerationModelSelection: {
422 options: [{ id: "fastMode", value: false }],
423 },
424 },
425 );
426 }),
427 );
428
429 it.effect(
430 "decodes legacy object-shaped textGenerationModelSelection.options from settings.json",
431 () =>
432 Effect.gen(function* () {
433 const decoded = yield* decodeServerSettings({
434 textGenerationModelSelection: {
435 provider: ProviderDriverKind.make("codex"),
436 model: "gpt-5.4-mini",
437 options: { reasoningEffort: "low" },
438 },
439 });
440
441 assert.deepEqual(decoded.textGenerationModelSelection, {
442 instanceId: ProviderInstanceId.make("codex"),
443 model: "gpt-5.4-mini",
444 options: [{ id: "reasoningEffort", value: "low" }],
445 });
446 }),
447 );
448
449 it.effect("deep merges nested settings updates without dropping siblings", () =>
450 Effect.gen(function* () {
451 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
452
453 yield* serverSettings.updateSettings({
454 observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" },
455 textGenerationModelSelection: {
456 instanceId: ProviderInstanceId.make("codex"),
457 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
458 options: createModelSelection(
459 ProviderInstanceId.make("codex"),
460 DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
461 [
462 { id: "reasoningEffort", value: "high" },
463 { id: "fastMode", value: true },
464 ],
465 ).options!,
466 },
467 });
468
469 const next = yield* serverSettings.updateSettings({
470 observability: { otlpMetricsUrl: "http://localhost:4318/v1/metrics" },
471 textGenerationModelSelection: {
472 options: [{ id: "fastMode", value: false }],
473 },
474 });
475
476 assert.equal(next.observability.otlpTracesUrl, "http://localhost:4318/v1/traces");
477 assert.equal(next.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics");
478 assert.deepEqual(
479 next.textGenerationModelSelection,
480 createModelSelection(
481 ProviderInstanceId.make("codex"),
482 DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
483 [
484 { id: "reasoningEffort", value: "high" },
485 { id: "fastMode", value: false },
486 ],
487 ),
488 );
489 }).pipe(Effect.provide(layerServerSettings())),
490 );
491
492 it.effect("creates provider instances atomically without overwriting a concurrent add", () =>
493 Effect.gen(function* () {
494 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
495 const instanceId = ProviderInstanceId.make("acpRegistry_shared");
496 const results = yield* Effect.all(
497 ["First", "Second"].map((displayName) =>
498 serverSettings
499 .updateProviderInstance({
500 operation: "create",
501 instanceId,
502 instance: {
503 driver: ProviderDriverKind.make("acpRegistry"),
504 displayName,
505 config: { agentId: "shared", distribution: "auto" },
506 },
507 })
508 .pipe(Effect.result),
509 ),
510 { concurrency: "unbounded" },
511 );
512
513 assert.equal(results.filter((result) => result._tag === "Success").length, 1);
514 assert.equal(results.filter((result) => result._tag === "Failure").length, 1);
515 assert.isTrue(
516 ["First", "Second"].includes(
517 (yield* serverSettings.getSettings).providerInstances[instanceId]?.displayName ?? "",
518 ),
519 );
520 }).pipe(Effect.provide(layerServerSettings())),
521 );
522
523 it.effect("pauses provider-instance mutations while a settings snapshot is in use", () =>
524 Effect.gen(function* () {
525 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
526 const snapshotEntered = yield* Deferred.make<void>();
527 const releaseSnapshot = yield* Deferred.make<void>();
528 const mutationCompleted = yield* Deferred.make<void>();
529 const instanceId = ProviderInstanceId.make("acpRegistry_kilo");
530
531 const snapshotFiber = yield* serverSettings
532 .withSettingsSnapshot(() =>
533 Deferred.succeed(snapshotEntered, undefined).pipe(
534 Effect.andThen(Deferred.await(releaseSnapshot)),
535 ),
536 )
537 .pipe(Effect.forkChild({ startImmediately: true }));
538 yield* Deferred.await(snapshotEntered);
539
540 const mutationFiber = yield* serverSettings
541 .updateProviderInstance({
542 operation: "upsert",
543 instanceId,
544 instance: {
545 driver: ProviderDriverKind.make("acpRegistry"),
546 displayName: "Kilo",
547 config: { agentId: "kilo", distribution: "auto" },
548 },
549 })
550 .pipe(
551 Effect.tap(() => Deferred.succeed(mutationCompleted, undefined)),
552 Effect.forkChild({ startImmediately: true }),
553 );
554 yield* Effect.yieldNow;
555
556 assert.isTrue(Option.isNone(yield* Deferred.poll(mutationCompleted)));
557 yield* Deferred.succeed(releaseSnapshot, undefined);
558 yield* Fiber.join(snapshotFiber);
559 yield* Fiber.join(mutationFiber);
560 assert.equal(
561 (yield* serverSettings.getSettings).providerInstances[instanceId]?.displayName,
562 "Kilo",
563 );
564 }).pipe(Effect.provide(layerServerSettings())),
565 );
566
567 it.effect("buffers changes after a subscription is acquired but before it is consumed", () =>
568 Effect.scoped(
569 Effect.gen(function* () {
570 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
571 const changes = yield* serverSettings.subscribeChanges;
572
573 yield* serverSettings.updateSettings({
574 addProjectBaseDirectory: "~/next",
575 });
576
577 const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second"));
578 assert.equal(Option.getOrUndefined(firstChange)?.addProjectBaseDirectory, "~/next");
579 }),
580 ).pipe(Effect.provide(layerServerSettings())),
581 );
582
583 it.effect("preserves saved usage settings during cleanup edits and removes custom prices", () =>
584 Effect.scoped(
585 Effect.gen(function* () {
586 const serverConfig = yield* ServerConfig.ServerConfig;
587 const fileSystem = yield* FileSystem.FileSystem;
588 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
589 const prices = {
590 inputCostPerMillionTokens: 2,
591 outputCostPerMillionTokens: 8,
592 cacheReadCostPerMillionTokens: 0,
593 };
594 const readPersisted = fileSystem
595 .readFileString(serverConfig.settingsPath)
596 .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))));
597
598 yield* serverSettings.updateSettings({ usagePriceOverrides: { "example-model": prices } });
599 const usageModelAliases = { "example-preview": "example-model" };
600 const sourceId = UsageLimitSourceId.make("hub");
601 const usageLimitSources = {
602 [sourceId]: {
603 kind: "cliproxy" as const,
604 url: "https://hub.example.test",
605 managementKey: "",
606 enabled: true,
607 },
608 };
609 const saved = yield* serverSettings.updateSettings({
610 usageModelAliases,
611 usageLimitSources,
612 });
613 const updated = yield* serverSettings.updateSettings({
614 storageCleanup: { worktreeSettledAfterDays: 0, worktreeKeepWhen: "any-local-files" },
615 });
616 assert.deepStrictEqual(updated.usageLimitSources, saved.usageLimitSources);
617 const persisted = yield* readPersisted;
618 assert.deepStrictEqual(persisted.usagePriceOverrides, { "example-model": prices });
619 assert.deepStrictEqual(persisted.usageModelAliases, usageModelAliases);
620 assert.deepStrictEqual(persisted.usageLimitSources, usageLimitSources);
621 assert.equal(persisted.storageCleanup.worktreeSettledAfterDays, 0);
622 assert.equal(persisted.storageCleanup.worktreeKeepWhen, "any-local-files");
623
624 yield* serverSettings.updateSettings({ usagePriceOverrides: { "example-model": null } });
625 const restored = yield* readPersisted;
626 assert.deepStrictEqual(restored.usagePriceOverrides, {});
627 }),
628 ).pipe(Effect.provide(layerServerSettings())),
629 );
630
631 it.effect("persists and broadcasts thread settlement settings", () =>
632 Effect.scoped(
633 Effect.gen(function* () {
634 const serverConfig = yield* ServerConfig.ServerConfig;
635 const fileSystem = yield* FileSystem.FileSystem;
636 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
637 const changes = yield* serverSettings.subscribeChanges;
638
639 const next = yield* serverSettings.updateSettings({
640 sidebarAutoSettleAfterDays: null,
641 sidebarAutoSettleOnMerge: false,
642 });
643 const change = Option.getOrUndefined(yield* Stream.runHead(changes));
644 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
645 // Inspect raw persisted JSON before schema decoding can apply defaults.
646 const persisted = JSON.parse(raw) as Record<string, unknown>;
647
648 assert.strictEqual(next.sidebarAutoSettleAfterDays, null);
649 assert.isFalse(next.sidebarAutoSettleOnMerge);
650 assert.strictEqual(change?.sidebarAutoSettleAfterDays, null);
651 assert.isFalse(change?.sidebarAutoSettleOnMerge);
652 assert.strictEqual(persisted.sidebarAutoSettleAfterDays, null);
653 assert.isFalse(persisted.sidebarAutoSettleOnMerge);
654 }),
655 ).pipe(Effect.provide(layerServerSettings())),
656 );
657
658 it.effect("preserves model when switching providers via textGenerationModelSelection", () =>
659 Effect.gen(function* () {
660 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
661
662 // Start with Claude text generation selection
663 yield* serverSettings.updateSettings({
664 textGenerationModelSelection: {
665 instanceId: ProviderInstanceId.make("claudeAgent"),
666 model: "claude-sonnet-4-6",
667 options: createModelSelection(
668 ProviderInstanceId.make("claudeAgent"),
669 "claude-sonnet-4-6",
670 [{ id: "effort", value: "high" }],
671 ).options!,
672 },
673 });
674
675 // Switch to Codex — the stale Claude "effort" in options must not
676 // cause the update to lose the selected model.
677 const next = yield* serverSettings.updateSettings({
678 textGenerationModelSelection: {
679 instanceId: ProviderInstanceId.make("codex"),
680 model: "gpt-5.4",
681 options: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
682 { id: "reasoningEffort", value: "high" },
683 ]).options!,
684 },
685 });
686
687 assert.deepEqual(
688 next.textGenerationModelSelection,
689 createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
690 { id: "reasoningEffort", value: "high" },
691 ]),
692 );
693 }).pipe(Effect.provide(layerServerSettings())),
694 );
695
696 it.effect("preserves custom provider instance text generation selections", () =>
697 Effect.gen(function* () {
698 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
699
700 const next = yield* serverSettings.updateSettings({
701 providerInstances: {
702 [ProviderInstanceId.make("claude_openrouter")]: {
703 driver: ProviderDriverKind.make("claudeAgent"),
704 enabled: true,
705 config: { customModels: ["openai/gpt-5.5"] },
706 },
707 },
708 textGenerationModelSelection: {
709 instanceId: ProviderInstanceId.make("claude_openrouter"),
710 model: "openai/gpt-5.5",
711 },
712 });
713
714 assert.deepEqual(next.textGenerationModelSelection, {
715 instanceId: ProviderInstanceId.make("claude_openrouter"),
716 model: "openai/gpt-5.5",
717 });
718 }).pipe(Effect.provide(layerServerSettings())),
719 );
720
721 it.effect(
722 "keeps a custom instance's selection when the driver's default instance is disabled",
723 () =>
724 Effect.gen(function* () {
725 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
726 const instanceId = ProviderInstanceId.make("claude_openrouter");
727
728 const next = yield* serverSettings.updateSettings({
729 providerInstances: {
730 [ProviderInstanceId.make("claudeAgent")]: {
731 driver: ProviderDriverKind.make("claudeAgent"),
732 enabled: false,
733 },
734 [instanceId]: {
735 driver: ProviderDriverKind.make("claudeAgent"),
736 enabled: true,
737 config: { customModels: ["openai/gpt-5.5"] },
738 },
739 },
740 textGenerationModelSelection: {
741 instanceId,
742 model: "openai/gpt-5.5",
743 },
744 });
745
746 assert.deepEqual(next.textGenerationModelSelection, {
747 instanceId,
748 model: "openai/gpt-5.5",
749 });
750 }).pipe(Effect.provide(layerServerSettings())),
751 );
752
753 // Only driver-keyed instances are fallback candidates; a custom instance id
754 // is not one, so the selection stays put until the user changes it.
755 it.effect.each(["codex"])(
756 "falls back to enabled instance %s after disabling the selection",
757 (fallbackId) =>
758 Effect.scoped(
759 Effect.gen(function* () {
760 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
761 const serverConfig = yield* ServerConfig.ServerConfig;
762 const fileSystem = yield* FileSystem.FileSystem;
763 const writerId = ProviderInstanceId.make("writer");
764 const fallbackInstanceId = ProviderInstanceId.make(fallbackId);
765 const selection = { instanceId: writerId, model: "claude-sonnet-4-6" };
766 const providerInstances = {
767 [ProviderInstanceId.make("claudeAgent")]: {
768 driver: ProviderDriverKind.make("claudeAgent"),
769 enabled: false,
770 },
771 [fallbackInstanceId]: {
772 driver: ProviderDriverKind.make("codex"),
773 enabled: true,
774 config: {},
775 },
776 [writerId]: {
777 driver: ProviderDriverKind.make("claudeAgent"),
778 enabled: true,
779 config: {},
780 },
781 };
782
783 yield* serverSettings.updateSettings({
784 providerInstances,
785 textGenerationModelSelection: selection,
786 });
787 const changes = yield* serverSettings.subscribeChanges;
788
789 const next = yield* serverSettings.updateSettings({
790 providerInstances: {
791 ...providerInstances,
792 [writerId]: { ...providerInstances[writerId]!, enabled: false },
793 },
794 });
795 const fallbackSelection = {
796 instanceId: fallbackInstanceId,
797 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
798 };
799 assert.deepEqual(next.textGenerationModelSelection, fallbackSelection);
800 assert.deepEqual(
801 (yield* serverSettings.getSettings).textGenerationModelSelection,
802 fallbackSelection,
803 );
804 const change = Option.getOrUndefined(yield* Stream.runHead(changes));
805 assert.deepEqual(change?.textGenerationModelSelection, fallbackSelection);
806
807 const persisted = yield* fileSystem
808 .readFileString(serverConfig.settingsPath)
809 .pipe(
810 Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))),
811 );
812 assert.deepEqual(persisted.textGenerationModelSelection, selection);
813
814 const restored = yield* serverSettings.updateSettings({ providerInstances });
815 assert.deepEqual(restored.textGenerationModelSelection, selection);
816 }),
817 ).pipe(Effect.provide(layerServerSettings())),
818 );
819
820 it.effect("skips explicitly disabled default instances when choosing a fallback", () =>
821 Effect.gen(function* () {
822 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
823 const next = yield* serverSettings.updateSettings({
824 providerInstances: {
825 [ProviderInstanceId.make("codex")]: {
826 driver: ProviderDriverKind.make("codex"),
827 enabled: false,
828 config: {},
829 },
830 },
831 });
832
833 assert.equal(next.textGenerationModelSelection.instanceId, "claudeAgent");
834 assert.equal(next.textGenerationModelSelection.model, "claude-haiku-5-5");
835 }).pipe(Effect.provide(layerServerSettings())),
836 );
837
838 // A backend behind a custom model may refuse the built-in default (#12651).
839 it.effect("falls back to the thread default when it is a custom model on that instance", () =>
840 Effect.gen(function* () {
841 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
842 const claude = ProviderInstanceId.make("claudeAgent");
843 const providerInstances = {
844 [ProviderInstanceId.make("codex")]: {
845 driver: ProviderDriverKind.make("codex"),
846 enabled: false,
847 config: {},
848 },
849 [claude]: {
850 driver: ProviderDriverKind.make("claudeAgent"),
851 config: { customModels: ["z-ai/glm-5.3-flash"] },
852 },
853 };
854
855 const custom = yield* serverSettings.updateSettings({
856 providerInstances,
857 defaultModelSelection: {
858 instanceId: claude,
859 model: "z-ai/glm-5.3-flash",
860 options: [{ id: "thinking", value: false }],
861 },
862 });
863 assert.deepEqual(custom.textGenerationModelSelection, {
864 instanceId: claude,
865 model: "z-ai/glm-5.3-flash",
866 options: [{ id: "thinking", value: false }],
867 });
868
869 const builtIn = yield* serverSettings.updateSettings({
870 defaultModelSelection: { instanceId: claude, model: "claude-fable-5-1" },
871 });
872 assert.equal(builtIn.textGenerationModelSelection.model, "claude-haiku-5-5");
873 }).pipe(Effect.provide(layerServerSettings())),
874 );
875
876 it.effect("preserves enabled text generation selections for non-built-in drivers", () =>
877 Effect.gen(function* () {
878 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
879 const instanceId = ProviderInstanceId.make("openrouter_text");
880
881 const next = yield* serverSettings.updateSettings({
882 providerInstances: {
883 [instanceId]: {
884 driver: ProviderDriverKind.make("openrouter"),
885 enabled: true,
886 config: { customModels: ["openai/gpt-5.5"] },
887 },
888 },
889 textGenerationModelSelection: {
890 instanceId,
891 model: "openai/gpt-5.5",
892 },
893 });
894
895 assert.deepEqual(next.textGenerationModelSelection, {
896 instanceId,
897 model: "openai/gpt-5.5",
898 });
899 }).pipe(Effect.provide(layerServerSettings())),
900 );
901
902 it.effect(
903 "preserves the source control writer selection when its provider instance is disabled",
904 () =>
905 Effect.gen(function* () {
906 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
907 const serverConfig = yield* ServerConfig.ServerConfig;
908 const fileSystem = yield* FileSystem.FileSystem;
909 const instanceId = ProviderInstanceId.make("codex_writer");
910 const sourceControlWriterModelSelection = {
911 instanceId,
912 model: "gpt-5.4-mini",
913 };
914
915 yield* serverSettings.updateSettings({
916 providerInstances: {
917 [instanceId]: {
918 driver: ProviderDriverKind.make("codex"),
919 enabled: true,
920 config: {},
921 },
922 },
923 sourceControlWriterModelSelection,
924 });
925
926 const next = yield* serverSettings.updateSettings({
927 providerInstances: {
928 [instanceId]: {
929 driver: ProviderDriverKind.make("codex"),
930 enabled: false,
931 config: {},
932 },
933 },
934 });
935
936 assert.deepEqual(next.sourceControlWriterModelSelection, sourceControlWriterModelSelection);
937 assert.deepEqual(
938 ServerSettingsModule.resolveSourceControlWriterModelSelection(next, undefined, null),
939 next.textGenerationModelSelection,
940 );
941 assert.deepEqual(
942 (yield* serverSettings.getSettings).sourceControlWriterModelSelection,
943 sourceControlWriterModelSelection,
944 );
945
946 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
947 assert.deepEqual(
948 JSON.parse(raw).sourceControlWriterModelSelection,
949 sourceControlWriterModelSelection,
950 );
951
952 const restored = yield* serverSettings.updateSettings({
953 providerInstances: {
954 [instanceId]: {
955 driver: ProviderDriverKind.make("codex"),
956 enabled: true,
957 config: {},
958 },
959 },
960 });
961 assert.deepEqual(
962 ServerSettingsModule.resolveSourceControlWriterModelSelection(restored, undefined, null),
963 sourceControlWriterModelSelection,
964 );
965 }).pipe(Effect.provide(layerServerSettings())),
966 );
967
968 it.effect("drops stale text generation options when resetting model selection", () =>
969 Effect.gen(function* () {
970 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
971
972 yield* serverSettings.updateSettings({
973 textGenerationModelSelection: {
974 instanceId: ProviderInstanceId.make("codex"),
975 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
976 options: createModelSelection(
977 ProviderInstanceId.make("codex"),
978 DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
979 [
980 { id: "reasoningEffort", value: "high" },
981 { id: "fastMode", value: true },
982 ],
983 ).options!,
984 },
985 });
986
987 const next = yield* serverSettings.updateSettings({
988 textGenerationModelSelection: {
989 instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId,
990 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
991 },
992 });
993
994 assert.deepEqual(next.textGenerationModelSelection, {
995 instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId,
996 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
997 });
998 }).pipe(Effect.provide(layerServerSettings())),
999 );
1000
1001 it.effect("replaces provider instance maps when clearing optional fields", () =>
1002 Effect.gen(function* () {
1003 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1004 const codexId = ProviderInstanceId.make("codex");
1005
1006 yield* serverSettings.updateSettings({
1007 providerInstances: {
1008 [codexId]: {
1009 driver: ProviderDriverKind.make("codex"),
1010 displayName: "Codex Work",
1011 accentColor: "#7c3aed",
1012 enabled: true,
1013 config: { homePath: "~/.codex" },
1014 },
1015 },
1016 });
1017
1018 const next = yield* serverSettings.updateSettings({
1019 providerInstances: {
1020 [codexId]: {
1021 driver: ProviderDriverKind.make("codex"),
1022 displayName: "Codex Work",
1023 enabled: true,
1024 config: { homePath: "~/.codex" },
1025 },
1026 },
1027 });
1028
1029 assert.deepEqual(next.providerInstances[codexId], {
1030 driver: ProviderDriverKind.make("codex"),
1031 displayName: "Codex Work",
1032 enabled: true,
1033 config: { homePath: "~/.codex" },
1034 });
1035 }).pipe(Effect.provide(layerServerSettings())),
1036 );
1037
1038 it.effect("moves customized legacy provider settings into default instances on load", () =>
1039 Effect.gen(function* () {
1040 const serverConfig = yield* ServerConfig.ServerConfig;
1041 const fileSystem = yield* FileSystem.FileSystem;
1042 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1043 yield* fileSystem.writeFileString(
1044 serverConfig.settingsPath,
1045 JSON.stringify({
1046 addProjectBaseDirectory: "~/Development",
1047 providers: {
1048 codex: { binaryPath: "/opt/guard/bin/codex" },
1049 claudeAgent: { enabled: false },
1050 cursor: { enabled: false },
1051 grok: { enabled: false },
1052 opencode: { enabled: false },
1053 },
1054 }),
1055 );
1056
1057 const settings = yield* serverSettings.getSettings;
1058
1059 assert.deepEqual(settings.providerInstances, {
1060 [ProviderInstanceId.make("codex")]: {
1061 driver: ProviderDriverKind.make("codex"),
1062 config: { binaryPath: "/opt/guard/bin/codex" },
1063 },
1064 [ProviderInstanceId.make("claudeAgent")]: {
1065 driver: ProviderDriverKind.make("claudeAgent"),
1066 enabled: false,
1067 config: {},
1068 },
1069 });
1070 // The file is rewritten once: instances persist and the retired map is gone.
1071 const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath));
1072 assert.isUndefined(persisted.providers);
1073 assert.equal(persisted.addProjectBaseDirectory, "~/Development");
1074 assert.deepEqual(persisted.providerInstances.codex, {
1075 driver: "codex",
1076 config: { binaryPath: "/opt/guard/bin/codex" },
1077 });
1078 }).pipe(Effect.provide(layerServerSettings())),
1079 );
1080
1081 it.effect("keeps an explicit default instance over the legacy blob for its driver", () =>
1082 Effect.gen(function* () {
1083 const serverConfig = yield* ServerConfig.ServerConfig;
1084 const fileSystem = yield* FileSystem.FileSystem;
1085 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1086 yield* fileSystem.writeFileString(
1087 serverConfig.settingsPath,
1088 JSON.stringify({
1089 providers: { codex: { binaryPath: "/legacy/codex" } },
1090 providerInstances: {
1091 codex: { driver: "codex", enabled: true, config: { binaryPath: "/explicit/codex" } },
1092 },
1093 }),
1094 );
1095
1096 const settings = yield* serverSettings.getSettings;
1097
1098 assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], {
1099 driver: ProviderDriverKind.make("codex"),
1100 enabled: true,
1101 config: { binaryPath: "/explicit/codex" },
1102 });
1103 }).pipe(Effect.provide(layerServerSettings())),
1104 );
1105
1106 it.effect("enables previously used optional providers while migrating legacy settings", () =>
1107 Effect.gen(function* () {
1108 const serverConfig = yield* ServerConfig.ServerConfig;
1109 const fileSystem = yield* FileSystem.FileSystem;
1110 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1111 yield* fileSystem.writeFileString(
1112 serverConfig.settingsPath,
1113 JSON.stringify({
1114 providers: { opencode: { serverUrl: "http://127.0.0.1:4096" }, cursor: {} },
1115 providerInstances: {
1116 cursor_work: { driver: "cursor", config: {} },
1117 opencode_unused: { driver: "opencode", config: {} },
1118 },
1119 }),
1120 );
1121 yield* recordProviderUsage("opencode");
1122 yield* recordProviderUsage("grok", null);
1123 yield* recordProviderUsage("cursor", "cursor_work");
1124
1125 const settings = yield* serverSettings.getSettings;
1126
1127 assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("opencode")], {
1128 driver: ProviderDriverKind.make("opencode"),
1129 enabled: true,
1130 config: { serverUrl: "http://127.0.0.1:4096" },
1131 });
1132 // Used without any legacy blob or instance: the slot is created enabled.
1133 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled);
1134 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled);
1135 // Using any cursor instance counts as opting into the driver.
1136 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled);
1137 const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")];
1138 assert.isDefined(unused);
1139 assert.isFalse(resolveProviderInstanceEnabled(unused));
1140 }).pipe(Effect.provide(layerServerSettings())),
1141 );
1142
1143 it.effect("keeps explicit legacy disables even when provider history shows use", () =>
1144 Effect.gen(function* () {
1145 const serverConfig = yield* ServerConfig.ServerConfig;
1146 const fileSystem = yield* FileSystem.FileSystem;
1147 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1148 yield* fileSystem.writeFileString(
1149 serverConfig.settingsPath,
1150 JSON.stringify({ providers: { grok: { enabled: false, binaryPath: "/opt/grok" } } }),
1151 );
1152 yield* recordProviderUsage("grok");
1153
1154 const settings = yield* serverSettings.getSettings;
1155
1156 assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("grok")], {
1157 driver: ProviderDriverKind.make("grok"),
1158 enabled: false,
1159 config: { binaryPath: "/opt/grok" },
1160 });
1161 }).pipe(Effect.provide(layerServerSettings())),
1162 );
1163
1164 it.effect("runs provider history only for files that still carry the legacy map", () =>
1165 Effect.gen(function* () {
1166 const serverConfig = yield* ServerConfig.ServerConfig;
1167 const fileSystem = yield* FileSystem.FileSystem;
1168 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1169 yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}");
1170 yield* recordProviderUsage("grok");
1171
1172 const settings = yield* serverSettings.getSettings;
1173
1174 assert.deepEqual(settings.providerInstances, {});
1175 }).pipe(Effect.provide(layerServerSettings())),
1176 );
1177
1178 it.effect("migrates legacy providers from an invalid file without rewriting it", () =>
1179 Effect.gen(function* () {
1180 const serverConfig = yield* ServerConfig.ServerConfig;
1181 const fileSystem = yield* FileSystem.FileSystem;
1182 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1183 const raw =
1184 '{"addProjectBaseDirectory":42,"continueThreadsAfterServerUpdate":"false","providers":{"codex":{"binaryPath":"/x"}}}';
1185 yield* fileSystem.writeFileString(serverConfig.settingsPath, raw);
1186
1187 const settings = yield* serverSettings.getSettings;
1188
1189 // The readable provider settings still apply, but the file stays for the user to repair.
1190 assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], {
1191 driver: ProviderDriverKind.make("codex"),
1192 config: { binaryPath: "/x" },
1193 });
1194 // An unreadable file may hold an opt-out, so threads are not resumed on a guess.
1195 assert.isFalse(settings.continueThreadsAfterServerUpdate);
1196 assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), raw);
1197 }).pipe(Effect.provide(layerServerSettings())),
1198 );
1199
1200 it.effect("skips a disabled provider instance when picking the text generation fallback", () =>
1201 Effect.gen(function* () {
1202 const serverConfig = yield* ServerConfig.ServerConfig;
1203 const fileSystem = yield* FileSystem.FileSystem;
1204 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1205 yield* fileSystem.writeFileString(
1206 serverConfig.settingsPath,
1207 '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}',
1208 );
1209
1210 const settings = yield* serverSettings.getSettings;
1211
1212 assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent");
1213 }).pipe(Effect.provide(layerServerSettings())),
1214 );
1215
1216 it.effect("keeps a default-off provider's new instance disabled and sparse on disk", () =>
1217 Effect.gen(function* () {
1218 const serverConfig = yield* ServerConfig.ServerConfig;
1219 const fileSystem = yield* FileSystem.FileSystem;
1220 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1221
1222 const next = yield* serverSettings.updateSettings({
1223 providerInstances: {
1224 [ProviderInstanceId.make("grok")]: {
1225 driver: ProviderDriverKind.make("grok"),
1226 config: {},
1227 },
1228 },
1229 });
1230
1231 const grok = next.providerInstances[ProviderInstanceId.make("grok")];
1232 assert.isDefined(grok);
1233 assert.isFalse(resolveProviderInstanceEnabled(grok));
1234 const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath));
1235 assert.isUndefined(persisted.providers);
1236 assert.isUndefined(persisted.providerInstances.grok.enabled);
1237 }).pipe(Effect.provide(layerServerSettings())),
1238 );
1239
1240 it.effect("folds a legacy in-config enabled flag into the envelope on load", () =>
1241 Effect.gen(function* () {
1242 const serverConfig = yield* ServerConfig.ServerConfig;
1243 const fileSystem = yield* FileSystem.FileSystem;
1244 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1245 // Old settings files can carry both flags with conflicting values.
1246 // The explicit false must win so a user's disable sticks.
1247 yield* fileSystem.writeFileString(
1248 serverConfig.settingsPath,
1249 '{"providerInstances":{"grok":{"driver":"grok","enabled":true,"config":{"enabled":false}},"codex_work":{"driver":"codex","config":{"enabled":true,"homePath":"~/.codex"}},"cursor":{"driver":"cursor","config":{"enabled":"nope"}}}}',
1250 );
1251
1252 const settings = yield* serverSettings.getSettings;
1253
1254 const grokId = ProviderInstanceId.make("grok");
1255 const codexWorkId = ProviderInstanceId.make("codex_work");
1256 assert.deepEqual(settings.providerInstances[grokId], {
1257 driver: ProviderDriverKind.make("grok"),
1258 enabled: false,
1259 config: {},
1260 });
1261 // A lone in-config flag is lifted to the envelope and stripped.
1262 assert.deepEqual(settings.providerInstances[codexWorkId], {
1263 driver: ProviderDriverKind.make("codex"),
1264 enabled: true,
1265 config: { homePath: "~/.codex" },
1266 });
1267 // A malformed flag is left alone so driver schema validation can
1268 // surface it instead of the fold silently repairing the config.
1269 assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("cursor")], {
1270 driver: ProviderDriverKind.make("cursor"),
1271 config: { enabled: "nope" },
1272 });
1273 }).pipe(Effect.provide(layerServerSettings())),
1274 );
1275
1276 it.effect("folds in-config enabled flags arriving through updates", () =>
1277 Effect.gen(function* () {
1278 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1279 const grokId = ProviderInstanceId.make("grok");
1280
1281 const next = yield* serverSettings.updateSettings({
1282 providerInstances: {
1283 [grokId]: {
1284 driver: ProviderDriverKind.make("grok"),
1285 enabled: true,
1286 config: { enabled: false, binaryPath: "/opt/grok" },
1287 },
1288 },
1289 });
1290
1291 assert.deepEqual(next.providerInstances[grokId], {
1292 driver: ProviderDriverKind.make("grok"),
1293 enabled: false,
1294 config: { binaryPath: "/opt/grok" },
1295 });
1296 }).pipe(Effect.provide(layerServerSettings())),
1297 );
1298
1299 it.effect("trims observability settings when updates are applied", () =>
1300 Effect.gen(function* () {
1301 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1302
1303 const next = yield* serverSettings.updateSettings({
1304 addProjectBaseDirectory: " ~/Development ",
1305 observability: {
1306 otlpTracesUrl: " http://localhost:4318/v1/traces ",
1307 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
1308 otlpLogsUrl: " http://localhost:4318/v1/logs ",
1309 },
1310 });
1311
1312 assert.equal(next.addProjectBaseDirectory, "~/Development");
1313 assert.deepEqual(next.observability, {
1314 otlpTracesUrl: "http://localhost:4318/v1/traces",
1315 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
1316 otlpLogsUrl: "http://localhost:4318/v1/logs",
1317 });
1318 }).pipe(Effect.provide(layerServerSettings())),
1319 );
1320
1321 it.effect("writes only non-default settings to disk", () =>
1322 Effect.gen(function* () {
1323 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1324 const serverConfig = yield* ServerConfig.ServerConfig;
1325 const fileSystem = yield* FileSystem.FileSystem;
1326 yield* serverSettings.updateSettings({
1327 addProjectBaseDirectory: "~/Development",
1328 observability: {
1329 otlpTracesUrl: "http://localhost:4318/v1/traces",
1330 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
1331 },
1332 automaticGitFetchInterval: Duration.seconds(10),
1333 });
1334
1335 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1336 assert.deepEqual(JSON.parse(raw), {
1337 addProjectBaseDirectory: "~/Development",
1338 observability: {
1339 otlpTracesUrl: "http://localhost:4318/v1/traces",
1340 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
1341 },
1342 backgroundActivity: {
1343 schemaVersion: 1,
1344 profile: "custom",
1345 baseProfile: "balanced",
1346 overrides: {
1347 automaticGitFetchInterval: 10_000,
1348 },
1349 },
1350 automaticGitFetchInterval: 10_000,
1351 });
1352 }).pipe(Effect.provide(layerServerSettings())),
1353 );
1354
1355 it.effect("keeps the inline value on disk when secret migration fails", () => {
1356 const cause = new ServerSecretStore.SecretStorePersistError({
1357 resource: "provider environment secret",
1358 cause: new Error("Secret storage unavailable"),
1359 });
1360 const layerSecret = Layer.effect(
1361 ServerSecretStore.ServerSecretStore,
1362 Effect.map(ServerSecretStore.ServerSecretStore, (store) => ({
1363 ...store,
1364 set: () => Effect.fail(cause),
1365 })),
1366 ).pipe(Layer.provide(ServerSecretStore.layer));
1367 const layerSettings = ServerSettingsModule.layer.pipe(
1368 Layer.provide(layerSecret),
1369 Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)),
1370 Layer.provideMerge(
1371 Layer.fresh(
1372 ServerConfig.layerTest(process.cwd(), {
1373 prefix: "t3code-inline-secret-failure-test-",
1374 }),
1375 ),
1376 ),
1377 );
1378 return Effect.gen(function* () {
1379 const instanceId = ProviderInstanceId.make("codex_personal");
1380 const service = yield* ServerSettingsModule.ServerSettingsService;
1381 const config = yield* ServerConfig.ServerConfig;
1382 const fs = yield* FileSystem.FileSystem;
1383 const original =
1384 '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}';
1385 yield* fs.writeFileString(config.settingsPath, original);
1386 const error = yield* Effect.flip(
1387 service.updateSettings({
1388 providerInstances: {
1389 [instanceId]: {
1390 driver: ProviderDriverKind.make("codex"),
1391 environment: [{ name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }],
1392 config: {},
1393 },
1394 },
1395 }),
1396 );
1397 assert.equal(error.operation, "write-secret");
1398 assert.strictEqual(error.cause, cause);
1399 assert.equal(yield* fs.readFileString(config.settingsPath), original);
1400 const settings = yield* service.getSettings;
1401 assert.equal(
1402 settings.providerInstances[instanceId]?.environment?.[0]?.value,
1403 "inline-test-token",
1404 );
1405 }).pipe(Effect.provide(layerSettings));
1406 });
1407
1408 it.effect.each(
1409 [
1410 {
1411 label: "preserves an inline secret on a redacted settings save",
1412 variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true },
1413 expected: "inline-test-token",
1414 },
1415 {
1416 label: "preserves the effective last inline secret when names are duplicated",
1417 variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true },
1418 expected: "last-inline-test-token",
1419 duplicate: true,
1420 },
1421 {
1422 label: "replaces an inline secret with an explicit value",
1423 variable: { name: "API_TOKEN", value: "replacement-test-token", sensitive: true },
1424 expected: "replacement-test-token",
1425 },
1426 {
1427 label: "clears an inline secret with an explicit empty value",
1428 variable: { name: "API_TOKEN", value: "", sensitive: true },
1429 expected: "",
1430 },
1431 ].map((testCase) => [testCase.label, testCase] as const),
1432 )("%s", ([, { variable, expected, duplicate }]) =>
1433 Effect.gen(function* () {
1434 const instanceId = ProviderInstanceId.make("codex_personal");
1435 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1436 const serverConfig = yield* ServerConfig.ServerConfig;
1437 const fileSystem = yield* FileSystem.FileSystem;
1438 yield* fileSystem.writeFileString(
1439 serverConfig.settingsPath,
1440 duplicate
1441 ? '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true},{"name":"API_TOKEN","value":"last-inline-test-token","sensitive":true}],"config":{}}}}'
1442 : '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}',
1443 );
1444 const initial = yield* serverSettings.getSettings;
1445 assert.equal(
1446 initial.providerInstances[instanceId]?.environment?.[0]?.value,
1447 "inline-test-token",
1448 );
1449
1450 const next = yield* serverSettings.updateSettings({
1451 providerInstances: {
1452 [instanceId]: {
1453 driver: ProviderDriverKind.make("codex"),
1454 displayName: "Renamed provider",
1455 environment: duplicate ? [variable, variable] : [variable],
1456 config: {},
1457 },
1458 },
1459 });
1460 assert.equal(next.providerInstances[instanceId]?.environment?.[0]?.value, expected);
1461 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1462 assert.notInclude(raw, "inline-test-token");
1463 assert.notInclude(raw, "replacement-test-token");
1464
1465 const reloaded = yield* Effect.gen(function* () {
1466 const fresh = yield* ServerSettingsModule.ServerSettingsService;
1467 return yield* fresh.getSettings;
1468 }).pipe(
1469 Effect.provide(
1470 Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)),
1471 ),
1472 );
1473 assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected);
1474 }).pipe(Effect.provide(layerServerSettings())),
1475 );
1476
1477 it.effect.each([true, false])(
1478 "preserves duplicate secret operation order (sensitive last: %s)",
1479 (sensitiveLast) =>
1480 Effect.gen(function* () {
1481 const service = yield* ServerSettingsModule.ServerSettingsService;
1482 const instanceId = ProviderInstanceId.make("codex_duplicate");
1483 const secret = { name: "API_TOKEN", value: "secret-last", sensitive: true };
1484 const plain = { name: "API_TOKEN", value: "plain-last", sensitive: false };
1485 const next = yield* service.updateSettings({
1486 providerInstances: {
1487 [instanceId]: {
1488 driver: ProviderDriverKind.make("codex"),
1489 environment: sensitiveLast ? [plain, secret] : [secret, plain],
1490 config: {},
1491 },
1492 },
1493 });
1494 assert.equal(
1495 next.providerInstances[instanceId]?.environment?.at(-1)?.value,
1496 sensitiveLast ? "secret-last" : "plain-last",
1497 );
1498 assert.equal(
1499 next.providerInstances[instanceId]?.environment?.find((v) => v.sensitive)?.value,
1500 sensitiveLast ? "secret-last" : "",
1501 );
1502 }).pipe(Effect.provide(layerServerSettings())),
1503 );
1504
1505 it.effect(
1506 "uses the same secret lifecycle for provider config, environment, host tokens, and quota keys",
1507 () =>
1508 Effect.gen(function* () {
1509 const service = yield* ServerSettingsModule.ServerSettingsService;
1510 const config = yield* ServerConfig.ServerConfig;
1511 const fs = yield* FileSystem.FileSystem;
1512 const store = yield* ServerSecretStore.ServerSecretStore;
1513 const instanceId = ProviderInstanceId.make("antigravity_secrets");
1514 const saved = yield* service.updateSettings({
1515 providerInstances: {
1516 [instanceId]: {
1517 driver: ProviderDriverKind.make("antigravity"),
1518 config: { apiKey: "config-secret" },
1519 environment: [
1520 { name: "API_TOKEN", value: "env-secret", sensitive: true },
1521 { name: "BASE_URL", value: "https://example.test", sensitive: false },
1522 ],
1523 },
1524 },
1525 sourceControlHosts: {
1526 github: { tokens: { "github.com": "github-secret" } },
1527 bitbucket: { email: "me@example.test", accessToken: "bitbucket-secret" },
1528 gitcafe: { token: "gitcafe-secret" },
1529 },
1530 usageLimitSources: {
1531 [UsageLimitSourceId.make("hub")]: {
1532 kind: "cliproxy",
1533 url: "https://hub.example.test",
1534 managementKey: "quota-secret",
1535 enabled: true,
1536 },
1537 },
1538 });
1539 const values = [
1540 "config-secret",
1541 "env-secret",
1542 "github-secret",
1543 "bitbucket-secret",
1544 "gitcafe-secret",
1545 "quota-secret",
1546 ];
1547 const client = ServerSettingsModule.redactServerSettingsForClient(saved);
1548 const disk = yield* fs.readFileString(config.settingsPath);
1549 for (const value of values) {
1550 assert.include(JSON.stringify(saved), value);
1551 assert.notInclude(JSON.stringify(client), value);
1552 assert.notInclude(disk, value);
1553 }
1554 assert.include(JSON.stringify(client), "https://example.test");
1555 assert.include(JSON.stringify(client), "me@example.test");
1556 const echoed = yield* service.updateSettings({
1557 providerInstances: client.providerInstances,
1558 sourceControlHosts: client.sourceControlHosts,
1559 usageLimitSources: client.usageLimitSources,
1560 });
1561 for (const value of values) assert.include(JSON.stringify(echoed), value);
1562 const renamed = yield* service.updateProviderInstance({
1563 operation: "upsert",
1564 instanceId,
1565 instance: { ...client.providerInstances[instanceId]!, displayName: "Renamed" },
1566 });
1567 assert.propertyVal(
1568 renamed.providerInstances[instanceId]?.config,
1569 "apiKey",
1570 "config-secret",
1571 );
1572
1573 for (const apiKey of ["replacement-secret", ""]) {
1574 const changed = yield* service.updateProviderInstance({
1575 operation: "upsert",
1576 instanceId,
1577 instance: { ...client.providerInstances[instanceId]!, config: { apiKey } },
1578 });
1579 assert.propertyVal(changed.providerInstances[instanceId]?.config, "apiKey", apiKey);
1580 const redacted = ServerSettingsModule.redactServerSettingsForClient(changed);
1581 assert.propertyVal(
1582 redacted.providerInstances[instanceId]?.config,
1583 "apiKey",
1584 apiKey ? "••••••" : "",
1585 );
1586 }
1587 // Restore the value so removing the instance below exercises deletion of both secrets.
1588 yield* service.updateProviderInstance({
1589 operation: "upsert",
1590 instanceId,
1591 instance: saved.providerInstances[instanceId]!,
1592 });
1593
1594 const secretNames = [
1595 "provider-config-YW50aWdyYXZpdHlfc2VjcmV0cw-YW50aWdyYXZpdHk-YXBpS2V5",
1596 "provider-env-YW50aWdyYXZpdHlfc2VjcmV0cw-QVBJX1RPS0VO",
1597 sourceControlHostSecretName({
1598 kind: "github",
1599 field: "tokens",
1600 serverHost: "github.com",
1601 }),
1602 sourceControlHostSecretName({
1603 kind: "bitbucket",
1604 field: "accessToken",
1605 serverHost: null,
1606 }),
1607 sourceControlHostSecretName({ kind: "gitcafe", field: "token", serverHost: null }),
1608 "usage-limit-source-aHVi",
1609 ];
1610 for (const name of secretNames) {
1611 assert.isTrue(Option.isSome(yield* store.get(name)), name);
1612 }
1613 yield* service.updateSettings({
1614 providerInstances: {},
1615 sourceControlHosts: {
1616 github: { tokens: {} },
1617 bitbucket: { accessToken: "" },
1618 gitcafe: { token: "" },
1619 },
1620 usageLimitSources: { [UsageLimitSourceId.make("hub")]: null },
1621 });
1622 for (const name of secretNames) {
1623 assert.isTrue(Option.isNone(yield* store.get(name)));
1624 }
1625 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1626 );
1627
1628 it.effect("moves a saved Antigravity key to the secret store when settings load", () =>
1629 Effect.gen(function* () {
1630 const service = yield* ServerSettingsModule.ServerSettingsService;
1631 const config = yield* ServerConfig.ServerConfig;
1632 const fs = yield* FileSystem.FileSystem;
1633 yield* fs.writeFileString(
1634 config.settingsPath,
1635 JSON.stringify({
1636 providerInstances: {
1637 antigravity: { driver: "antigravity", config: { apiKey: "legacy-api-key" } },
1638 },
1639 }),
1640 );
1641 const saved = yield* service.getSettings;
1642 assert.propertyVal(
1643 saved.providerInstances[ProviderInstanceId.make("antigravity")]?.config,
1644 "apiKey",
1645 "legacy-api-key",
1646 );
1647 assert.notInclude(yield* fs.readFileString(config.settingsPath), "legacy-api-key");
1648 assert.notInclude(
1649 JSON.stringify(ServerSettingsModule.redactServerSettingsForClient(saved)),
1650 "legacy-api-key",
1651 );
1652 }).pipe(Effect.provide(layerServerSettings())),
1653 );
1654
1655 it.effect("stores sensitive provider instance environment values outside settings.json", () =>
1656 Effect.gen(function* () {
1657 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1658 const serverConfig = yield* ServerConfig.ServerConfig;
1659 const fileSystem = yield* FileSystem.FileSystem;
1660 const instanceId = ProviderInstanceId.make("codex_personal");
1661
1662 const next = yield* serverSettings.updateSettings({
1663 providerInstances: {
1664 [instanceId]: {
1665 driver: ProviderDriverKind.make("codex"),
1666 environment: [
1667 { name: "OPENROUTER_API_KEY", value: "sk-or-secret", sensitive: true },
1668 { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false },
1669 ],
1670 config: {},
1671 },
1672 },
1673 });
1674
1675 assert.deepEqual(next.providerInstances[instanceId]?.environment, [
1676 {
1677 name: "OPENROUTER_API_KEY",
1678 value: "sk-or-secret",
1679 sensitive: true,
1680 valueRedacted: true,
1681 },
1682 { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false },
1683 ]);
1684
1685 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1686 assert.notInclude(raw, "sk-or-secret");
1687 assert.deepEqual(JSON.parse(raw).providerInstances.codex_personal.environment, [
1688 {
1689 name: "OPENROUTER_API_KEY",
1690 value: "",
1691 sensitive: true,
1692 valueRedacted: true,
1693 },
1694 { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false },
1695 ]);
1696
1697 const roundTripped = yield* serverSettings.updateSettings({
1698 providerInstances: {
1699 [instanceId]: {
1700 driver: ProviderDriverKind.make("codex"),
1701 displayName: "Codex Personal",
1702 environment: [
1703 { name: "OPENROUTER_API_KEY", value: "", sensitive: true, valueRedacted: true },
1704 { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false },
1705 ],
1706 config: {},
1707 },
1708 },
1709 });
1710
1711 assert.equal(
1712 roundTripped.providerInstances[instanceId]?.environment?.[0]?.value,
1713 "sk-or-secret",
1714 );
1715 }).pipe(Effect.provide(layerServerSettings())),
1716 );
1717
1718 it.effect(
1719 "keeps source control host secrets in the secret store and tells clients only that one is set",
1720 () =>
1721 Effect.gen(function* () {
1722 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1723 const secrets = yield* ServerSecretStore.ServerSecretStore;
1724 const serverConfig = yield* ServerConfig.ServerConfig;
1725 const fileSystem = yield* FileSystem.FileSystem;
1726
1727 const saved = yield* serverSettings.updateSettings({
1728 sourceControlHosts: {
1729 bitbucket: { email: "me@example.com", accessToken: "bb-access" },
1730 github: { tokens: { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } },
1731 },
1732 });
1733 assert.deepEqual(saved.sourceControlHosts.bitbucket, {
1734 email: "me@example.com",
1735 accessToken: "bb-access",
1736 });
1737 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1738 for (const secret of ["bb-access", "ghp_dotcom", "ghp_ghe"]) assert.notInclude(raw, secret);
1739 assert.include(raw, "me@example.com");
1740
1741 const forClient =
1742 ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts;
1743 assert.equal(forClient.bitbucket?.email, "me@example.com");
1744 assert.notInclude(String(forClient.bitbucket?.accessToken), "bb-access");
1745 assert.isAbove(String(forClient.bitbucket?.accessToken).length, 0);
1746
1747 // Echoing the redacted values back keeps them; other fields change on their own.
1748 yield* serverSettings.updateSettings({ sourceControlHosts: forClient });
1749 yield* serverSettings.updateSettings({
1750 sourceControlHosts: { bitbucket: { email: "other@example.com" } },
1751 });
1752 const kept = (yield* serverSettings.getSettings).sourceControlHosts;
1753 assert.deepEqual(kept.bitbucket, { email: "other@example.com", accessToken: "bb-access" });
1754 assert.deepEqual(kept.github?.tokens, {
1755 "github.com": "ghp_dotcom",
1756 "ghe.acme.test": "ghp_ghe",
1757 });
1758
1759 // An empty value removes that secret, and one per server host leaves the others.
1760 const cleared = yield* serverSettings.updateSettings({
1761 sourceControlHosts: {
1762 bitbucket: { accessToken: "" },
1763 github: {
1764 tokens: {
1765 "github.com": "",
1766 "ghe.acme.test": (forClient.github!.tokens as Record<string, string>)[
1767 "ghe.acme.test"
1768 ],
1769 },
1770 },
1771 },
1772 });
1773 assert.equal(cleared.sourceControlHosts.bitbucket?.accessToken, "");
1774 const stored = (kind: string, field: string, serverHost: string | null) =>
1775 secrets.get(sourceControlHostSecretName({ kind, field, serverHost }));
1776 assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null)));
1777 assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com")));
1778 assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test")));
1779 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1780 );
1781
1782 it.effect("keeps the retired keys for another load when a token cannot be copied", () => {
1783 const cause = new ServerSecretStore.SecretStoreReadError({
1784 resource: "bitbucket-access-token",
1785 cause: PlatformError.systemError({
1786 _tag: "PermissionDenied",
1787 module: "FileSystem",
1788 method: "readFile",
1789 pathOrDescriptor: "bitbucket-access-token",
1790 description: "Secret backend unavailable.",
1791 }),
1792 });
1793 const layerSettings = ServerSettingsModule.layer.pipe(
1794 Layer.provide(layerFailingSecretStore(cause)),
1795 Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)),
1796 Layer.provideMerge(
1797 Layer.fresh(
1798 ServerConfig.layerTest(process.cwd(), {
1799 prefix: "t3code-server-settings-legacy-failure-test-",
1800 }),
1801 ),
1802 ),
1803 );
1804 return Effect.gen(function* () {
1805 const serverConfig = yield* ServerConfig.ServerConfig;
1806 const fileSystem = yield* FileSystem.FileSystem;
1807 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1808 const legacy = JSON.stringify({
1809 bitbucket: { email: "me@example.com", accessToken: "\u2022\u2022\u2022\u2022\u2022\u2022" },
1810 });
1811 yield* fileSystem.writeFileString(serverConfig.settingsPath, legacy);
1812
1813 yield* Effect.exit(serverSettings.getSettings);
1814
1815 assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), legacy);
1816 }).pipe(Effect.provide(layerSettings));
1817 });
1818
1819 it.effect("moves the retired bitbucket and github keys and their secrets on load", () =>
1820 Effect.gen(function* () {
1821 const serverConfig = yield* ServerConfig.ServerConfig;
1822 const fileSystem = yield* FileSystem.FileSystem;
1823 const secrets = yield* ServerSecretStore.ServerSecretStore;
1824 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1825 const marker = "\u2022\u2022\u2022\u2022\u2022\u2022";
1826 yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("bb-saved"));
1827 yield* secrets.set(
1828 `github-token-${Buffer.from("github.com", "utf8").toString("base64url")}`,
1829 new TextEncoder().encode("ghp_saved"),
1830 );
1831 yield* fileSystem.writeFileString(
1832 serverConfig.settingsPath,
1833 JSON.stringify({
1834 bitbucket: { email: "me@example.com", accessToken: marker },
1835 github: {
1836 hosts: { "github.com": { account: "work" } },
1837 tokens: { "github.com": marker },
1838 },
1839 }),
1840 );
1841
1842 const loaded = (yield* serverSettings.getSettings).sourceControlHosts;
1843
1844 assert.deepEqual(loaded.bitbucket, { email: "me@example.com", accessToken: "bb-saved" });
1845 assert.deepEqual(loaded.github?.tokens, { "github.com": "ghp_saved" });
1846 assert.deepEqual(loaded.github?.hosts, { "github.com": { account: "work" } });
1847 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1848 assert.notInclude(raw, '"bitbucket":{"email"');
1849 assert.include(raw, "sourceControlHosts");
1850 assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token")));
1851 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1852 );
1853
1854 it.effect(
1855 "moves a hand-edited source control token into the secret store when settings load",
1856 () =>
1857 Effect.gen(function* () {
1858 const serverConfig = yield* ServerConfig.ServerConfig;
1859 const fileSystem = yield* FileSystem.FileSystem;
1860 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1861 yield* fileSystem.writeFileString(
1862 serverConfig.settingsPath,
1863 '{"sourceControlHosts":{"bitbucket":{"accessToken":"hand-edited-token"}}}',
1864 );
1865
1866 // Loading alone moves it: no settings update is needed.
1867 const loaded = yield* serverSettings.getSettings;
1868
1869 assert.equal(loaded.sourceControlHosts.bitbucket?.accessToken, "hand-edited-token");
1870 assert.notInclude(
1871 yield* fileSystem.readFileString(serverConfig.settingsPath),
1872 "hand-edited-token",
1873 );
1874 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1875 );
1876
1877 it.effect("materializes provider secrets for terminal environment resolution", () =>
1878 Effect.gen(function* () {
1879 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1880 const serverConfig = yield* ServerConfig.ServerConfig;
1881 const fileSystem = yield* FileSystem.FileSystem;
1882 const path = yield* Path.Path;
1883 const instanceId = ProviderInstanceId.make("codex_terminal");
1884
1885 yield* serverSettings.updateSettings({
1886 providerInstances: {
1887 [instanceId]: {
1888 driver: ProviderDriverKind.make("codex"),
1889 environment: [
1890 { name: "OPENROUTER_API_KEY", value: "sk-terminal-secret", sensitive: true },
1891 ],
1892 config: { homePath: "~/.codex-terminal" },
1893 },
1894 },
1895 });
1896
1897 const environment = yield* resolveProviderInstanceTerminalEnvironment({
1898 serverSettings,
1899 path,
1900 rawProviderInstanceId: instanceId,
1901 env: undefined,
1902 });
1903 const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath);
1904
1905 assert.equal(environment.OPENROUTER_API_KEY, "sk-terminal-secret");
1906 assert.match(environment.CODEX_HOME ?? "", /[\\/][.]codex-terminal$/);
1907 assert.notInclude(persisted, "sk-terminal-secret");
1908 assert.include(persisted, '"valueRedacted": true');
1909 }).pipe(Effect.provide(layerServerSettings())),
1910 );
1911 it.effect("rolls back provider secret changes when the settings file commit fails", () =>
1912 Effect.gen(function* () {
1913 const fileSystem = yield* FileSystem.FileSystem;
1914 let failRename = false;
1915 let settingsPathToFail: string | undefined;
1916 const writeFailure = PlatformError.systemError({
1917 _tag: "PermissionDenied",
1918 module: "FileSystem",
1919 method: "rename",
1920 description: "Forced settings write failure.",
1921 });
1922 const failingFileSystem = FileSystem.FileSystem.of({
1923 ...fileSystem,
1924 rename: (fromPath, toPath) =>
1925 failRename && toPath === settingsPathToFail
1926 ? Effect.fail(writeFailure)
1927 : fileSystem.rename(fromPath, toPath),
1928 });
1929 const instanceId = ProviderInstanceId.make("antigravity_write_failure");
1930 const layerSettings = layerServerSettings().pipe(
1931 Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, failingFileSystem)),
1932 );
1933
1934 yield* Effect.gen(function* () {
1935 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1936 settingsPathToFail = (yield* ServerConfig.ServerConfig).settingsPath;
1937 yield* serverSettings.updateProviderInstance({
1938 operation: "upsert",
1939 instanceId,
1940 instance: {
1941 driver: ProviderDriverKind.make("antigravity"),
1942 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }],
1943 config: { apiKey: "config-kept" },
1944 },
1945 });
1946
1947 failRename = true;
1948 const failedUpdate = yield* serverSettings
1949 .updateProviderInstance({
1950 operation: "upsert",
1951 instanceId,
1952 instance: {
1953 driver: ProviderDriverKind.make("antigravity"),
1954 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }],
1955 config: { apiKey: "config-new" },
1956 },
1957 })
1958 .pipe(Effect.result);
1959 assert.equal(failedUpdate._tag, "Failure");
1960 assert.equal(
1961 (yield* serverSettings.getSettings).providerInstances[instanceId]?.environment?.[0]
1962 ?.value,
1963 "sk-kept",
1964 );
1965 assert.propertyVal(
1966 (yield* serverSettings.getSettings).providerInstances[instanceId]?.config,
1967 "apiKey",
1968 "config-kept",
1969 );
1970
1971 const failed = yield* serverSettings
1972 .updateProviderInstance({ operation: "remove", instanceId })
1973 .pipe(Effect.result);
1974 assert.equal(failed._tag, "Failure");
1975 assert.equal(
1976 (yield* serverSettings.getSettings).providerInstances[instanceId]?.environment?.[0]
1977 ?.value,
1978 "sk-kept",
1979 );
1980 assert.propertyVal(
1981 (yield* serverSettings.getSettings).providerInstances[instanceId]?.config,
1982 "apiKey",
1983 "config-kept",
1984 );
1985 }).pipe(Effect.provide(layerSettings));
1986 }),
1987 );
1988
1989 it.effect.each(["response materialization", "partially committed write"] as const)(
1990 "rolls back provider secret changes after %s fails",
1991 (failure) => {
1992 const textDecoder = new TextDecoder();
1993 const secrets = new Map<string, Uint8Array>();
1994 let rejectNewSecret = false;
1995 const layerSecretStore = Layer.succeed(
1996 ServerSecretStore.ServerSecretStore,
1997 ServerSecretStore.ServerSecretStore.of({
1998 get: (name) =>
1999 Effect.suspend(() => {
2000 const value = secrets.get(name);
2001 if (
2002 failure === "response materialization" &&
2003 rejectNewSecret &&
2004 value !== undefined &&
2005 textDecoder.decode(value) === "sk-new"
2006 ) {
2007 return Effect.fail(
2008 new ServerSecretStore.SecretStoreReadError({
2009 resource: `secret ${name}`,
2010 cause: "Forced response materialization failure.",
2011 }),
2012 );
2013 }
2014 return Effect.succeed(
2015 value === undefined ? Option.none() : Option.some(Uint8Array.from(value)),
2016 );
2017 }),
2018 set: (name, value) =>
2019 Effect.suspend(() => {
2020 secrets.set(name, Uint8Array.from(value));
2021 return failure === "partially committed write" &&
2022 rejectNewSecret &&
2023 textDecoder.decode(value) === "sk-new"
2024 ? Effect.fail(
2025 new ServerSecretStore.SecretStorePersistError({
2026 resource: `secret ${name}`,
2027 cause: "chmod failed after rename",
2028 }),
2029 )
2030 : Effect.void;
2031 }),
2032 create: (name, value) =>
2033 Effect.sync(() => {
2034 secrets.set(name, Uint8Array.from(value));
2035 }),
2036 getOrCreateRandom: (name, bytes) =>
2037 Effect.sync(() => {
2038 const value = secrets.get(name) ?? new Uint8Array(bytes);
2039 secrets.set(name, value);
2040 return Uint8Array.from(value);
2041 }),
2042 remove: (name) =>
2043 Effect.sync(() => {
2044 secrets.delete(name);
2045 }),
2046 }),
2047 );
2048 const layerSettings = ServerSettingsModule.layer.pipe(
2049 Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)),
2050 Layer.provide(layerSecretStore),
2051 Layer.provideMerge(
2052 Layer.fresh(
2053 ServerConfig.layerTest(process.cwd(), {
2054 prefix: "t3code-server-settings-materialization-failure-test-",
2055 }),
2056 ),
2057 ),
2058 );
2059 const instanceId = ProviderInstanceId.make("codex_materialization_failure");
2060
2061 return Effect.gen(function* () {
2062 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
2063 yield* serverSettings.updateSettings({
2064 providerInstances: {
2065 [instanceId]: {
2066 driver: ProviderDriverKind.make("antigravity"),
2067 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }],
2068 config: { apiKey: "config-kept" },
2069 },
2070 },
2071 });
2072
2073 rejectNewSecret = true;
2074 const failedUpdate = yield* serverSettings
2075 .updateSettings({
2076 providerInstances: {
2077 [instanceId]: {
2078 driver: ProviderDriverKind.make("antigravity"),
2079 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }],
2080 config: { apiKey: "config-new" },
2081 },
2082 },
2083 })
2084 .pipe(Effect.result);
2085
2086 assert.equal(failedUpdate._tag, "Failure");
2087 rejectNewSecret = false;
2088 assert.equal(
2089 (yield* serverSettings.getSettings).providerInstances[instanceId]?.environment?.[0]
2090 ?.value,
2091 "sk-kept",
2092 );
2093 assert.propertyVal(
2094 (yield* serverSettings.getSettings).providerInstances[instanceId]?.config,
2095 "apiKey",
2096 "config-kept",
2097 );
2098 }).pipe(Effect.provide(layerSettings));
2099 },
2100 );
2101
2102 it.effect("folds legacy project overrides into projectSettingsOverrides once", () =>
2103 Effect.gen(function* () {
2104 const serverConfig = yield* ServerConfig.ServerConfig;
2105 const fileSystem = yield* FileSystem.FileSystem;
2106 const sql = yield* SqlClient.SqlClient;
2107 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
2108 const legacyProject = ProjectId.make("project-legacy");
2109 const scriptedProject = ProjectId.make("project-scripted");
2110 const script: ProjectScript = {
2111 id: "check",
2112 name: "Check",
2113 command: "npm test",
2114 icon: "play",
2115 runOnWorktreeCreate: false,
2116 };
2117 const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5");
2118 const modelJson = yield* Schema.encodeEffect(Schema.fromJsonString(ModelSelection))(model);
2119 const scriptsJson = yield* Schema.encodeEffect(
2120 Schema.fromJsonString(Schema.Array(ProjectScript)),
2121 )([script]);
2122 for (const [projectId, modelColumn, envMode, autoPull, scripts] of [
2123 // The legacy project also carries aggregate scripts, but its stored
2124 // null override reset them; the fold must not bring them back.
2125 [legacyProject, modelJson, "worktree", 1, scriptsJson],
2126 [scriptedProject, null, null, 0, scriptsJson],
2127 ] as const) {
2128 yield* sql`
2129 INSERT INTO projection_projects (
2130 project_id, title, workspace_root, default_model_selection_json,
2131 default_thread_env_mode, auto_pull, scripts_json, created_at, updated_at
2132 )
2133 VALUES (
2134 ${projectId}, ${"Project"}, ${`/tmp/${projectId}`}, ${modelColumn},
2135 ${envMode}, ${autoPull}, ${scripts},
2136 ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"}
2137 )
2138 `;
2139 }
2140 yield* fileSystem.writeFileString(
2141 serverConfig.settingsPath,
2142 `{"projectAgentBrowserAccessOverrides":{"${legacyProject}":false},"projectAutoPullOverrides":{"${scriptedProject}":true},"projectScriptOverrides":{"${legacyProject}":null}}`,
2143 );
2144
2145 const settings = yield* serverSettings.getSettings;
2146 assert.isTrue(settings.projectSettingsFolded);
2147 assert.deepEqual<ServerSettings["projectSettingsOverrides"]>(
2148 settings.projectSettingsOverrides,
2149 {
2150 [legacyProject]: {
2151 enableAgentBrowserAccess: false,
2152 defaultModelSelection: model,
2153 defaultThreadEnvMode: "worktree",
2154 defaultAutoPull: true,
2155 },
2156 [scriptedProject]: { defaultAutoPull: true, defaultProjectScripts: [script] },
2157 },
2158 );
2159 // Derived legacy views keep older clients reading the same values.
2160 assert.deepEqual<ServerSettings["projectAutoPullOverrides"]>(
2161 settings.projectAutoPullOverrides,
2162 {
2163 [legacyProject]: true,
2164 [scriptedProject]: true,
2165 },
2166 );
2167 assert.deepEqual<ServerSettings["projectScriptOverrides"]>(settings.projectScriptOverrides, {
2168 [scriptedProject]: [script],
2169 });
2170
2171 // A reset survives the next load: the fold does not run again.
2172 yield* serverSettings.updateSettings({
2173 projectSettingsOverrides: { [legacyProject]: null },
2174 });
2175 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
2176 const persisted = yield* decodeServerSettings(JSON.parse(raw));
2177 assert.isTrue(persisted.projectSettingsFolded);
2178 assert.isUndefined(persisted.projectSettingsOverrides[legacyProject]);
2179 }).pipe(Effect.provide(layerServerSettings())),
2180 );
2181
2182 it.effect("leaves an unreadable settings.json untouched instead of folding over it", () =>
2183 Effect.gen(function* () {
2184 const serverConfig = yield* ServerConfig.ServerConfig;
2185 const fileSystem = yield* FileSystem.FileSystem;
2186 const sql = yield* SqlClient.SqlClient;
2187 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
2188 yield* sql`
2189 INSERT INTO projection_projects (
2190 project_id, title, workspace_root, auto_pull, scripts_json, created_at, updated_at
2191 )
2192 VALUES (
2193 ${"project-broken"}, ${"Project"}, ${"/tmp/project-broken"}, ${1}, ${"[]"},
2194 ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"}
2195 )
2196 `;
2197 const broken = '{"defaultAutoPull": tru';
2198 yield* fileSystem.writeFileString(serverConfig.settingsPath, broken);
2199
2200 const settings = yield* serverSettings.getSettings;
2201 assert.isFalse(settings.projectSettingsFolded);
2202 assert.isFalse(settings.continueThreadsAfterServerUpdate);
2203 assert.deepEqual(settings.projectSettingsOverrides, {});
2204 // The user's file is still there to repair; nothing was written over it.
2205 assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), broken);
2206 }).pipe(Effect.provide(layerServerSettings())),
2207 );
2208});