| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { |
| 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"; |
| 14 | import { createModelSelection } from "@t3tools/shared/model"; |
| 15 | import { assert, it } from "@effect/vitest"; |
| 16 | import * as Deferred from "effect/Deferred"; |
| 17 | import * as Effect from "effect/Effect"; |
| 18 | import * as Duration from "effect/Duration"; |
| 19 | import * as FileSystem from "effect/FileSystem"; |
| 20 | import * as Fiber from "effect/Fiber"; |
| 21 | import * as Layer from "effect/Layer"; |
| 22 | import * as Option from "effect/Option"; |
| 23 | import * as Path from "effect/Path"; |
| 24 | import * as PlatformError from "effect/PlatformError"; |
| 25 | import * as Schema from "effect/Schema"; |
| 26 | import * as Stream from "effect/Stream"; |
| 27 | import * as TestClock from "effect/testing/TestClock"; |
| 28 | import * as SqlClient from "effect/sql/SqlClient"; |
| 29 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 30 | import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts"; |
| 31 | import * as ServerConfig from "./config.ts"; |
| 32 | import * as SqlitePersistence from "./persistence/Sqlite.ts"; |
| 33 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; |
| 34 | import * as ServerSettingsModule from "./serverSettings.ts"; |
| 35 | import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; |
| 36 | |
| 37 | const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); |
| 38 | const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); |
| 39 | const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); |
| 40 | |
| 41 | const 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. */ |
| 55 | const 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 | |
| 68 | const 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 | |
| 80 | const 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 | |
| 101 | it.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 | }); |