diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index cbdf58c4d67..b52b577c5b5 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -327,7 +327,9 @@ export const make = Effect.gen(function* () { Effect.catch((error) => Effect.logWarning("failed to start server settings runtime", { path: error.settingsPath, - detail: error.detail, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, cause: error.cause, }), ), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 87feee669ec..504d99e18de 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -12,6 +12,7 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; @@ -32,7 +33,63 @@ const makeServerSettingsLayer = () => ), ); +const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => + Layer.succeed( + ServerSecretStore.ServerSecretStore, + ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.fail(cause), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.succeed(new Uint8Array()), + remove: () => Effect.void, + }), + ); + it.layer(NodeServices.layer)("server settings", (it) => { + it.effect("preserves context when reading a provider environment secret fails", () => { + const platformCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "provider environment secret", + description: "Secret backend unavailable.", + }); + const cause = new ServerSecretStore.SecretStoreReadError({ + resource: "provider environment secret", + cause: platformCause, + }); + const configLayer = Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-secret-failure-test-", + }), + ); + const settingsLayer = ServerSettingsModule.layer.pipe( + Layer.provide(makeFailingSecretStoreLayer(cause)), + Layer.provideMerge(configLayer), + ); + + return Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"OPENROUTER_API_KEY","value":"","sensitive":true,"valueRedacted":true}],"config":{}}}}', + ); + + const error = yield* Effect.flip(serverSettings.getSettings); + + assert.deepInclude(error, { + _tag: "ServerSettingsError", + operation: "read-secret", + providerInstanceId: "codex_personal", + environmentVariable: "OPENROUTER_API_KEY", + }); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(settingsLayer)); + }); + it.effect("decodes nested settings patches", () => Effect.gen(function* () { assert.deepEqual( diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index a5fcdc30c02..4119a72640f 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -40,7 +40,6 @@ import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as SchemaIssue from "effect/SchemaIssue"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -67,7 +66,7 @@ const normalizeServerSettings = ( (cause) => new ServerSettingsError({ settingsPath: "", - detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + operation: "normalize", cause, }), ), @@ -277,7 +276,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to check settings file existence", + operation: "check-exists", cause, }), ), @@ -288,7 +287,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to read settings file", + operation: "read-file", cause, }), ), @@ -305,6 +304,7 @@ const make = Effect.gen(function* () { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, issues: Cause.pretty(decoded.cause), + cause: decoded.cause, }); return DEFAULT_SERVER_SETTINGS; } @@ -318,13 +318,6 @@ const make = Effect.gen(function* () { const getSettingsFromCache = Cache.get(settingsCache, cacheKey); - const toSettingsError = (detail: string, cause: unknown) => - new ServerSettingsError({ - settingsPath, - detail, - cause, - }); - const materializeProviderEnvironmentSecrets = ( settings: ServerSettings, ): Effect.Effect => @@ -343,11 +336,15 @@ const make = Effect.gen(function* () { const secret = yield* secretStore .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) .pipe( - Effect.mapError((cause) => - toSettingsError( - `failed to read sensitive environment variable ${variable.name}`, - cause, - ), + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), ), ); environment.push({ @@ -382,13 +379,18 @@ const make = Effect.gen(function* () { for (const variable of instance.environment) { const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (!variable.sensitive) { - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to remove environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); environment.push(redactProviderEnvironmentVariable(variable)); continue; } @@ -396,22 +398,32 @@ const make = Effect.gen(function* () { nextSecretKeys.add(secretName); if (!variable.valueRedacted) { if (variable.value.length > 0) { - yield* secretStore - .set(secretName, textEncoder.encode(variable.value)) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to persist environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.set(secretName, textEncoder.encode(variable.value)).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "write-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); environment.push({ ...variable, value: "", valueRedacted: true }); } else { - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError(`failed to remove environment secret ${variable.name}`, cause), - ), - ); + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + cause, + }), + ), + ); const { valueRedacted: _omit, ...rest } = variable; environment.push(rest); } @@ -431,16 +443,18 @@ const make = Effect.gen(function* () { if (!variable.sensitive) continue; const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (nextSecretKeys.has(secretName)) continue; - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError((cause) => - toSettingsError( - `failed to remove stale environment secret ${variable.name}`, + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-stale-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, cause, - ), - ), - ); + }), + ), + ); } } @@ -468,7 +482,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to write settings file", + operation: "write-file", cause, }), ), @@ -492,7 +506,7 @@ const make = Effect.gen(function* () { (cause) => new ServerSettingsError({ settingsPath, - detail: "failed to prepare settings directory", + operation: "prepare-directory", cause, }), ), @@ -571,7 +585,10 @@ const make = Effect.gen(function* () { materializeProviderEnvironmentSecrets(settings).pipe( Effect.catch((error: ServerSettingsError) => Effect.logWarning("failed to materialize provider environment secrets", { - detail: error.detail, + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, }).pipe(Effect.as(settings)), ), ), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1cb57a98254..7ba267b1e72 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -414,16 +414,37 @@ export type ServerSettings = typeof ServerSettings.Type; export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); +export const ServerSettingsOperation = Schema.Literals([ + "normalize", + "check-exists", + "read-file", + "read-secret", + "remove-secret", + "remove-stale-secret", + "write-secret", + "write-file", + "prepare-directory", +]); +export type ServerSettingsOperation = typeof ServerSettingsOperation.Type; + export class ServerSettingsError extends Schema.TaggedErrorClass()( "ServerSettingsError", { settingsPath: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: ServerSettingsOperation, + providerInstanceId: Schema.optional(Schema.String), + environmentVariable: Schema.optional(Schema.String), + cause: Schema.Defect(), }, ) { override get message(): string { - return `Server settings error at ${this.settingsPath}: ${this.detail}`; + const provider = + this.providerInstanceId === undefined ? "" : ` for provider ${this.providerInstanceId}`; + const variable = + this.environmentVariable === undefined + ? "" + : ` and environment variable ${this.environmentVariable}`; + return `Server settings ${this.operation} failed${provider}${variable} at ${this.settingsPath}.`; } }