diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 43b6eaf1ce669ea302a41920b88338aad4208b66..1cbd6c7bf813a692c2d98b09967089909c58d75b 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -9,6 +9,7 @@ import { resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, + UsageLimitSourceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; @@ -1440,6 +1441,156 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect( + "uses the same secret lifecycle for provider config, environment, host tokens, and quota keys", + () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const store = yield* ServerSecretStore.ServerSecretStore; + const instanceId = ProviderInstanceId.make("antigravity_secrets"); + const saved = yield* service.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("antigravity"), + config: { apiKey: "config-secret" }, + environment: [ + { name: "API_TOKEN", value: "env-secret", sensitive: true }, + { name: "BASE_URL", value: "https://example.test", sensitive: false }, + ], + }, + }, + sourceControlHosts: { + github: { tokens: { "github.com": "github-secret" } }, + bitbucket: { email: "me@example.test", accessToken: "bitbucket-secret" }, + gitcafe: { token: "gitcafe-secret" }, + }, + usageLimitSources: { + [UsageLimitSourceId.make("hub")]: { + kind: "cliproxy", + url: "https://hub.example.test", + managementKey: "quota-secret", + enabled: true, + }, + }, + }); + const values = [ + "config-secret", + "env-secret", + "github-secret", + "bitbucket-secret", + "gitcafe-secret", + "quota-secret", + ]; + const client = ServerSettingsModule.redactServerSettingsForClient(saved); + const disk = yield* fs.readFileString(config.settingsPath); + for (const value of values) { + assert.include(JSON.stringify(saved), value); + assert.notInclude(JSON.stringify(client), value); + assert.notInclude(disk, value); + } + assert.include(JSON.stringify(client), "https://example.test"); + assert.include(JSON.stringify(client), "me@example.test"); + const echoed = yield* service.updateSettings({ + providerInstances: client.providerInstances, + sourceControlHosts: client.sourceControlHosts, + usageLimitSources: client.usageLimitSources, + }); + for (const value of values) assert.include(JSON.stringify(echoed), value); + const renamed = yield* service.updateProviderInstance({ + operation: "upsert", + instanceId, + instance: { ...client.providerInstances[instanceId]!, displayName: "Renamed" }, + }); + assert.propertyVal( + renamed.providerInstances[instanceId]?.config, + "apiKey", + "config-secret", + ); + + for (const apiKey of ["replacement-secret", ""]) { + const changed = yield* service.updateProviderInstance({ + operation: "upsert", + instanceId, + instance: { ...client.providerInstances[instanceId]!, config: { apiKey } }, + }); + assert.propertyVal(changed.providerInstances[instanceId]?.config, "apiKey", apiKey); + const redacted = ServerSettingsModule.redactServerSettingsForClient(changed); + assert.propertyVal( + redacted.providerInstances[instanceId]?.config, + "apiKey", + apiKey ? "••••••" : "", + ); + } + // Restore the value so removing the instance below exercises deletion of both secrets. + yield* service.updateProviderInstance({ + operation: "upsert", + instanceId, + instance: saved.providerInstances[instanceId]!, + }); + + const secretNames = [ + "provider-config-YW50aWdyYXZpdHlfc2VjcmV0cw-YW50aWdyYXZpdHk-YXBpS2V5", + "provider-env-YW50aWdyYXZpdHlfc2VjcmV0cw-QVBJX1RPS0VO", + sourceControlHostSecretName({ + kind: "github", + field: "tokens", + serverHost: "github.com", + }), + sourceControlHostSecretName({ + kind: "bitbucket", + field: "accessToken", + serverHost: null, + }), + sourceControlHostSecretName({ kind: "gitcafe", field: "token", serverHost: null }), + "usage-limit-source-aHVi", + ]; + for (const name of secretNames) { + assert.isTrue(Option.isSome(yield* store.get(name)), name); + } + yield* service.updateSettings({ + providerInstances: {}, + sourceControlHosts: { + github: { tokens: {} }, + bitbucket: { accessToken: "" }, + gitcafe: { token: "" }, + }, + usageLimitSources: { [UsageLimitSourceId.make("hub")]: null }, + }); + for (const name of secretNames) { + assert.isTrue(Option.isNone(yield* store.get(name))); + } + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); + + it.effect("moves a saved Antigravity key to the secret store when settings load", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + providerInstances: { + antigravity: { driver: "antigravity", config: { apiKey: "legacy-api-key" } }, + }, + }), + ); + const saved = yield* service.getSettings; + assert.propertyVal( + saved.providerInstances[ProviderInstanceId.make("antigravity")]?.config, + "apiKey", + "legacy-api-key", + ); + assert.notInclude(yield* fs.readFileString(config.settingsPath), "legacy-api-key"); + assert.notInclude( + JSON.stringify(ServerSettingsModule.redactServerSettingsForClient(saved)), + "legacy-api-key", + ); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("stores sensitive provider instance environment values outside settings.json", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -1714,7 +1865,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ? Effect.fail(writeFailure) : fileSystem.rename(fromPath, toPath), }); - const instanceId = ProviderInstanceId.make("codex_write_failure"); + const instanceId = ProviderInstanceId.make("antigravity_write_failure"); const layerSettings = layerServerSettings().pipe( Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, failingFileSystem)), ); @@ -1726,9 +1877,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { operation: "upsert", instanceId, instance: { - driver: ProviderDriverKind.make("codex"), + driver: ProviderDriverKind.make("antigravity"), environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }], - config: {}, + config: { apiKey: "config-kept" }, }, }); @@ -1738,9 +1889,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { operation: "upsert", instanceId, instance: { - driver: ProviderDriverKind.make("codex"), + driver: ProviderDriverKind.make("antigravity"), environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }], - config: {}, + config: { apiKey: "config-new" }, }, }) .pipe(Effect.result); @@ -1750,6 +1901,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { ?.value, "sk-kept", ); + assert.propertyVal( + (yield* serverSettings.getSettings).providerInstances[instanceId]?.config, + "apiKey", + "config-kept", + ); const failed = yield* serverSettings .updateProviderInstance({ operation: "remove", instanceId }) @@ -1760,6 +1916,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { ?.value, "sk-kept", ); + assert.propertyVal( + (yield* serverSettings.getSettings).providerInstances[instanceId]?.config, + "apiKey", + "config-kept", + ); }).pipe(Effect.provide(layerSettings)); }), ); @@ -1841,9 +2002,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { yield* serverSettings.updateSettings({ providerInstances: { [instanceId]: { - driver: ProviderDriverKind.make("codex"), + driver: ProviderDriverKind.make("antigravity"), environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }], - config: {}, + config: { apiKey: "config-kept" }, }, }, }); @@ -1853,9 +2014,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { .updateSettings({ providerInstances: { [instanceId]: { - driver: ProviderDriverKind.make("codex"), + driver: ProviderDriverKind.make("antigravity"), environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }], - config: {}, + config: { apiKey: "config-new" }, }, }, }) @@ -1868,6 +2029,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { ?.value, "sk-kept", ); + assert.propertyVal( + (yield* serverSettings.getSettings).providerInstances[instanceId]?.config, + "apiKey", + "config-kept", + ); }).pipe(Effect.provide(layerSettings)); }, ); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 18f3ada048e3f73fdb67a92d4902f48efbc01134..44e645858c49121dacfb711f9da3eab6690762e0 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -17,8 +17,6 @@ import { ProjectScript, ProjectSettingsOverrides, type ProviderInstanceConfig, - type ProviderInstanceEnvironmentVariable, - type UsageLimitSourceConfig, type ProviderInstanceMutation, defaultInstanceIdForDriver, isProviderDriverKind, @@ -64,12 +62,11 @@ import { import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import { readSecretFieldValues, - SOURCE_CONTROL_HOST_SECRET_FIELDS, - type SourceControlHostSecretSlot, sourceControlHostSecretName, - writeSecretFieldValues, } from "./sourceControl/sourceControlHostSecrets.ts"; +import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts"; + export { resolveReviewGuideModelSelection, resolveSourceControlWriterModelSelection, @@ -147,106 +144,12 @@ const normalizeServerSettings = ( ), ); -function providerEnvironmentSecretName(input: { - readonly instanceId: string; - readonly name: string; -}): string { - return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; -} - -/** - * On disk a hub key or Bitbucket token is replaced by this marker and the - * real value lives in the secret store, mirroring provider environment - * secrets. A client that sends the marker back means "keep what you have". - */ -const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; - -function usageLimitSourceSecretName(sourceId: string): string { - return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; -} - -const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); - -/** Each host's settings with every secret slot's value replaced by `map(slot, value)`. */ -function mapSlotValues( - hosts: ServerSettings["sourceControlHosts"], - map: (slot: SourceControlHostSecretSlot, value: string) => string, -): ServerSettings["sourceControlHosts"] { - return Object.fromEntries( - Object.entries(hosts).map(([kind, fields]) => { - const secretFields = SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []; - if (secretFields.length === 0) return [kind, fields]; - const next = { ...fields }; - for (const field of secretFields) { - if (!(field in next)) continue; - next[field] = writeSecretFieldValues( - next[field], - readSecretFieldValues(next[field]).map( - ([serverHost, value]) => [serverHost, map({ kind, field, serverHost }, value)] as const, - ), - ); - } - return [kind, next]; - }), - ); -} - -/** Every saved secret slot in `hosts`, with its current value. */ -function sourceControlHostSecretSlots( - hosts: ServerSettings["sourceControlHosts"], -): ReadonlyArray { - return Object.entries(hosts).flatMap(([kind, fields]) => - (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) => - readSecretFieldValues(fields[field]).map(([serverHost, value]) => ({ - kind, - field, - serverHost, - value, - })), - ), - ); -} - -function redactProviderEnvironmentVariable( - variable: ProviderInstanceEnvironmentVariable, -): ProviderInstanceEnvironmentVariable { - if (!variable.sensitive) { - const { valueRedacted: _omit, ...rest } = variable; - return rest; - } - return { - ...variable, - value: "", - ...(variable.value.length > 0 || variable.valueRedacted ? { valueRedacted: true } : {}), - }; -} - export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { - const providerInstances = Object.fromEntries( - Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ - instanceId, - instance.environment - ? { - ...instance, - environment: instance.environment.map(redactProviderEnvironmentVariable), - } - : instance, - ]), + return mapSettingsSecrets(settings, (secret) => + secret.sensitive + ? { value: "", redacted: secret.redacted || secret.value.length > 0 } + : { value: secret.value, redacted: false }, ); - // The hub key is a bearer secret; clients only need to know one is set. - const usageLimitSources = Object.fromEntries( - Object.entries(settings.usageLimitSources).map(([id, source]) => [ - id, - { - ...source, - managementKey: redactSecret(source.managementKey), - }, - ]), - ); - const sourceControlHosts = mapSlotValues(settings.sourceControlHosts, (_slot, value) => - redactSecret(value), - ); - return { ...settings, providerInstances, usageLimitSources, sourceControlHosts }; } export function applyProviderInstanceMutation( @@ -781,35 +684,36 @@ const make = Effect.gen(function* () { ); /** - * Moves source control host secrets hand-edited into settings.json into the secret store as - * they load, so plaintext does not stay on disk. If the store is unavailable, the secret keeps - * working from the file and the move is retried on the next load. + * Migrate plaintext schema-declared credentials on load. Failed moves stay usable from + * the file and are retried next time; client redaction does not depend on migration. */ - const moveInlineSourceControlHostSecrets = (settings: ServerSettings) => + const moveInlineSettingsSecrets = (settings: ServerSettings) => Effect.gen(function* () { + const pending: SettingsSecret[] = []; + mapSettingsSecrets(settings, (secret) => { + if (secret.kind === "field" && !secret.redacted && secret.value.length > 0) { + pending.push(secret); + } + return secret; + }); const moved = new Set(); - for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) { - if (slot.value.length === 0 || slot.value === SECRET_REDACTED) continue; + for (const secret of pending) { const stored = yield* secretStore - .set(sourceControlHostSecretName(slot), textEncoder.encode(slot.value)) + .set(secret.secretName, textEncoder.encode(secret.value)) .pipe( Effect.as(true), Effect.catch(() => - Effect.logWarning("failed to move a source control secret into the secret store", { - kind: slot.kind, - field: slot.field, - }).pipe(Effect.as(false)), + Effect.logWarning("failed to move a settings secret into the secret store").pipe( + Effect.as(false), + ), ), ); - if (stored) moved.add(sourceControlHostSecretName(slot)); + if (stored) moved.add(secret.secretName); } if (moved.size === 0) return settings; - return { - ...settings, - sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) => - moved.has(sourceControlHostSecretName(slot)) ? SECRET_REDACTED : value, - ), - }; + return mapSettingsSecrets(settings, (secret) => + moved.has(secret.secretName) ? { value: "", redacted: true } : secret, + ); }); const loadSettingsFromDisk = Effect.gen(function* () { @@ -934,7 +838,7 @@ const make = Effect.gen(function* () { : { settings: folded, secretMoves: [] }; // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. const migrated = settingsFileTrusted - ? yield* moveInlineSourceControlHostSecrets(sourceControlMigration.settings) + ? yield* moveInlineSettingsSecrets(sourceControlMigration.settings) : folded; // A file still carrying the retired `providers`, `bitbucket`, or `github` keys is rewritten // once so the migrated values persist and the old keys disappear. @@ -962,96 +866,49 @@ const make = Effect.gen(function* () { const getSettingsFromCache = Cache.get(settingsCache, cacheKey); - const materializeProviderEnvironmentSecrets = ( + const materializeSettingsSecrets = ( settings: ServerSettings, ): Effect.Effect => Effect.gen(function* () { - const providerInstances: Record = { - ...settings.providerInstances, - }; - for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { - if (!instance.environment) continue; - const environment: ProviderInstanceEnvironmentVariable[] = []; - for (const variable of instance.environment) { - if (!variable.sensitive || !variable.valueRedacted) { - environment.push(variable); - continue; - } - const secret = yield* secretStore - .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) - .pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath, - operation: "read-secret", - providerInstanceId: instanceId, - environmentVariable: variable.name, - cause, - }), - ), - ); - environment.push({ - ...variable, - value: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", - }); - } - providerInstances[instanceId] = { - ...instance, - environment, - } satisfies ProviderInstanceConfig; - } - const usageLimitSources: Record = {}; - for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) { - if (source.managementKey !== SECRET_REDACTED) { - usageLimitSources[sourceId] = source; - continue; - } - const secret = yield* secretStore - .get(usageLimitSourceSecretName(sourceId)) - .pipe( - Effect.mapError( - (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), - ), - ); - usageLimitSources[sourceId] = { - ...source, - managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", - }; - } const storedSecrets = new Map(); - for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) { - if (slot.value !== SECRET_REDACTED) continue; - const secret = yield* secretStore - .get(sourceControlHostSecretName(slot)) - .pipe( - Effect.mapError( - (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), - ), - ); + const pending = new Map(); + mapSettingsSecrets(settings, (secret) => { + if (secret.sensitive && secret.redacted) pending.set(secret.secretName, secret); + return secret; + }); + for (const secret of pending.values()) { + const value = yield* secretStore.get(secret.secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-secret", + providerInstanceId: secret.providerInstanceId, + environmentVariable: secret.environmentVariable, + cause, + }), + ), + ); storedSecrets.set( - sourceControlHostSecretName(slot), - Option.isSome(secret) ? textDecoder.decode(secret.value) : "", + secret.secretName, + Option.isSome(value) ? textDecoder.decode(value.value) : "", ); } - return { - ...settings, - providerInstances: providerInstances as ServerSettings["providerInstances"], - usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], - sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) => - value === SECRET_REDACTED - ? (storedSecrets.get(sourceControlHostSecretName(slot)) ?? "") - : value, - ), - }; + return mapSettingsSecrets(settings, (secret) => ({ + value: + secret.sensitive && secret.redacted + ? (storedSecrets.get(secret.secretName) ?? "") + : secret.value, + redacted: secret.sensitive && secret.kind === "environment" && secret.redacted, + })); }); const materializeChanges = (changes: Stream.Stream) => changes.pipe( Stream.mapEffect((settings) => - materializeProviderEnvironmentSecrets(settings).pipe( + materializeSettingsSecrets(settings).pipe( Effect.catch((error: ServerSettingsError) => - Effect.logWarning("failed to materialize provider environment secrets", { + Effect.logWarning("failed to materialize settings secrets", { operation: error.operation, providerInstanceId: error.providerInstanceId, environmentVariable: error.environmentVariable, @@ -1072,162 +929,65 @@ const make = Effect.gen(function* () { | { readonly kind: "remove"; readonly operation: "remove-secret" | "remove-stale-secret" } ); - const persistProviderEnvironmentSecrets = (current: ServerSettings, next: ServerSettings) => + const persistSettingsSecrets = (current: ServerSettings, next: ServerSettings) => Effect.sync(() => { - const providerInstances: Record = { - ...next.providerInstances, - }; + const previous = new Map(); + mapSettingsSecrets(current, (secret) => { + // Duplicate environment names follow the provider's last-value-wins behavior. + previous.set(secret.secretName, secret); + return secret; + }); const changes: SecretChange[] = []; - - const nextSecretKeys = new Set(); - for (const [instanceId, instance] of Object.entries(next.providerInstances)) { - if (!instance.environment) continue; - const environment: ProviderInstanceEnvironmentVariable[] = []; - for (const variable of instance.environment) { - const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); - if (!variable.sensitive) { - changes.push({ - kind: "remove", - secretName, - operation: "remove-secret", - providerInstanceId: instanceId, - environmentVariable: variable.name, - }); - environment.push(redactProviderEnvironmentVariable(variable)); - continue; - } - - nextSecretKeys.add(secretName); - // Match the provider environment's last-value-wins behavior for duplicate names. - const previous = variable.valueRedacted - ? current.providerInstances[ProviderInstanceId.make(instanceId)]?.environment?.findLast( - (entry) => entry.name === variable.name, - ) - : undefined; - const inlineValue = - previous?.sensitive && !previous.valueRedacted && previous.value.length > 0 - ? previous.value - : undefined; - const value = inlineValue ?? variable.value; - if (!variable.valueRedacted || inlineValue !== undefined) { - if (value.length > 0) { - changes.push({ - kind: "write", - secretName, - value: textEncoder.encode(value), - providerInstanceId: instanceId, - environmentVariable: variable.name, - }); - environment.push({ ...variable, value: "", valueRedacted: true }); - } else { - changes.push({ - kind: "remove", - secretName, - operation: "remove-secret", - providerInstanceId: instanceId, - environmentVariable: variable.name, - }); - const { valueRedacted: _omit, ...rest } = variable; - environment.push(rest); - } - continue; - } - - environment.push(redactProviderEnvironmentVariable(variable)); - } - providerInstances[instanceId] = { - ...instance, - environment, - } satisfies ProviderInstanceConfig; - } - - for (const [instanceId, instance] of Object.entries(current.providerInstances)) { - for (const variable of instance.environment ?? []) { - if (!variable.sensitive) continue; - const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); - if (nextSecretKeys.has(secretName)) continue; - changes.push({ - kind: "remove", - secretName, - operation: "remove-stale-secret", - providerInstanceId: instanceId, - environmentVariable: variable.name, - }); - } - } - - const usageLimitSources: Record = {}; - for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { - const secretName = usageLimitSourceSecretName(sourceId); - if (source.managementKey === SECRET_REDACTED) { - usageLimitSources[sourceId] = source; - continue; - } - if (source.managementKey.length === 0) { - changes.push({ kind: "remove", secretName, operation: "remove-secret" }); - usageLimitSources[sourceId] = source; - continue; - } - changes.push({ - kind: "write", - secretName, - value: textEncoder.encode(source.managementKey), - }); - usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED }; - } - for (const sourceId of Object.keys(current.usageLimitSources)) { - if (sourceId in next.usageLimitSources) continue; - changes.push({ - kind: "remove", - secretName: usageLimitSourceSecretName(sourceId), - operation: "remove-stale-secret", - }); - } - - // Hand-edited plaintext not yet in the store, so the redaction marker can move it there. - const inlineSecrets = new Map( - sourceControlHostSecretSlots(current.sourceControlHosts).flatMap((slot) => - slot.value.length > 0 && slot.value !== SECRET_REDACTED - ? [[sourceControlHostSecretName(slot), slot.value] as const] - : [], - ), - ); const nextSecretNames = new Set(); - const sourceControlHosts = mapSlotValues(next.sourceControlHosts, (slot, raw) => { - const secretName = sourceControlHostSecretName(slot); - // The marker keeps what is saved; a hand-edited plaintext value moves into the store. - const value = raw === SECRET_REDACTED ? inlineSecrets.get(secretName) : raw; + const settings = mapSettingsSecrets(next, (secret) => { + const metadata = { + secretName: secret.secretName, + ...(secret.providerInstanceId !== undefined + ? { providerInstanceId: secret.providerInstanceId } + : {}), + ...(secret.environmentVariable !== undefined + ? { environmentVariable: secret.environmentVariable } + : {}), + }; + if (!secret.sensitive) { + changes.push({ ...metadata, kind: "remove", operation: "remove-secret" }); + return { value: secret.value, redacted: false }; + } + const old = previous.get(secret.secretName); + const inlineValue = + old?.sensitive && !old.redacted && old.value.length > 0 ? old.value : undefined; + const value = secret.redacted ? inlineValue : secret.value; if (value === undefined) { - nextSecretNames.add(secretName); - return SECRET_REDACTED; + nextSecretNames.add(secret.secretName); + return { value: "", redacted: true }; } if (value.length === 0) { - changes.push({ kind: "remove", secretName, operation: "remove-secret" }); - return ""; + changes.push({ ...metadata, kind: "remove", operation: "remove-secret" }); + return { value: "", redacted: false }; } - nextSecretNames.add(secretName); - changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); - return SECRET_REDACTED; + nextSecretNames.add(secret.secretName); + changes.push({ ...metadata, kind: "write", value: textEncoder.encode(value) }); + return { value: "", redacted: true }; }); - for (const slot of sourceControlHostSecretSlots(current.sourceControlHosts)) { - const secretName = sourceControlHostSecretName(slot); - if (nextSecretNames.has(secretName)) continue; - if (changes.some((change) => change.secretName === secretName)) continue; - changes.push({ kind: "remove", secretName, operation: "remove-stale-secret" }); + for (const secret of previous.values()) { + if (!secret.sensitive || nextSecretNames.has(secret.secretName)) continue; + if (changes.some((change) => change.secretName === secret.secretName)) continue; + changes.push({ + kind: "remove", + secretName: secret.secretName, + operation: "remove-stale-secret", + ...(secret.providerInstanceId !== undefined + ? { providerInstanceId: secret.providerInstanceId } + : {}), + ...(secret.environmentVariable !== undefined + ? { environmentVariable: secret.environmentVariable } + : {}), + }); } - - return { - settings: { - ...next, - providerInstances: providerInstances as ServerSettings["providerInstances"], - usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], - sourceControlHosts, - }, - changes, - }; + return { settings, changes }; }); - const rollbackProviderEnvironmentSecretWrites = ( + const rollbackSettingsSecretWrites = ( writes: ReadonlyArray<{ readonly secretName: string; readonly previousValue: Option.Option; @@ -1243,7 +1003,7 @@ const make = Effect.gen(function* () { : secretStore.remove(write.secretName) ).pipe( Effect.catch((cause) => - Effect.logWarning("failed to roll back provider environment secret", { + Effect.logWarning("failed to roll back settings secret", { providerInstanceId: write.providerInstanceId, environmentVariable: write.environmentVariable, cause, @@ -1253,14 +1013,14 @@ const make = Effect.gen(function* () { { discard: true }, ); - const applyProviderEnvironmentSecretChanges = (changes: ReadonlyArray) => { + const applySettingsSecretChanges = (changes: ReadonlyArray) => { const applied: Array<{ readonly secretName: string; readonly previousValue: Option.Option; readonly providerInstanceId?: string; readonly environmentVariable?: string; }> = []; - const rollback = Effect.suspend(() => rollbackProviderEnvironmentSecretWrites(applied)); + const rollback = Effect.suspend(() => rollbackSettingsSecretWrites(applied)); return Effect.forEach( changes, (change) => @@ -1310,16 +1070,12 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const current = yield* getSettingsFromCache; const updated = yield* update(current); - const persisted = yield* persistProviderEnvironmentSecrets(current, updated); + const persisted = yield* persistSettingsSecrets(current, updated); const next = yield* normalizeServerSettings(persisted.settings); const materialized = yield* Effect.uninterruptibleMask(() => Effect.gen(function* () { - const rollbackSecretChanges = yield* applyProviderEnvironmentSecretChanges( - persisted.changes, - ); - const materializedExit = yield* Effect.exit( - materializeProviderEnvironmentSecrets(next), - ); + const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes); + const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next)); if (Exit.isFailure(materializedExit)) { yield* rollbackSecretChanges; return yield* Effect.failCause(materializedExit.cause); @@ -1341,7 +1097,7 @@ const make = Effect.gen(function* () { const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) => writeSemaphore.withPermits(1)( getSettingsFromCache.pipe( - Effect.flatMap(materializeProviderEnvironmentSecrets), + Effect.flatMap(materializeSettingsSecrets), Effect.map(resolveTextGenerationProvider), Effect.flatMap(use), ), @@ -1457,7 +1213,7 @@ const make = Effect.gen(function* () { start, ready: Deferred.await(startedDeferred), getSettings: getSettingsFromCache.pipe( - Effect.flatMap(materializeProviderEnvironmentSecrets), + Effect.flatMap(materializeSettingsSecrets), Effect.map(resolveTextGenerationProvider), ), updateSettings: (patch) => diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts new file mode 100644 index 0000000000000000000000000000000000000000..42adf7977ebd59e36d03c212cfe012137fe18616 --- /dev/null +++ b/apps/server/src/settingsSecrets.ts @@ -0,0 +1,150 @@ +import { + AntigravitySettings, + ClaudeSettings, + CodexSettings, + type ServerSettings, + UsageLimitSourceConfig, + secretSettingsFields, +} from "@t3tools/contracts"; +import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; +import { cursorClient } from "@t3tools/provider-cursor/client"; +import { grokClient } from "@t3tools/provider-grok/client"; +import { museClient } from "@t3tools/provider-muse/client"; +import { openCodeClient } from "@t3tools/provider-opencode/client"; +import { piClient } from "@t3tools/provider-pi/client"; +import { + readSecretFieldValues, + SOURCE_CONTROL_HOST_SECRET_FIELDS, + sourceControlHostSecretName, + writeSecretFieldValues, +} from "./sourceControl/sourceControlHostSecrets.ts"; + +export const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; + +const providerSecretFields = new Map([ + ["codex", secretSettingsFields(CodexSettings)], + ["claudeAgent", secretSettingsFields(ClaudeSettings)], + ["antigravity", secretSettingsFields(AntigravitySettings)], + ...[cursorClient, grokClient, museClient, openCodeClient, piClient, acpRegistryClient].map( + (definition) => + [definition.driverKind, secretSettingsFields(definition.settingsSchema)] as const, + ), +]); +const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig); +const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); + +export interface SettingsSecret { + readonly secretName: string; + readonly kind: "field" | "environment"; + readonly value: string; + readonly redacted: boolean; + readonly sensitive: boolean; + readonly providerInstanceId?: string; + readonly environmentVariable?: string; +} + +type SecretValue = Pick; + +/** + * Visits credential fields declared by schemas and dynamically sensitive environment variables. + * Store keys and client representations stay here; callers supply only the secret operation. + */ +export function mapSettingsSecrets( + settings: ServerSettings, + map: (secret: SettingsSecret) => SecretValue, +): ServerSettings { + const mapFields = ( + fields: Readonly>, + secretFields: ReadonlyArray, + name: (field: string, serverHost: string | null) => string, + ) => { + const next = { ...fields }; + for (const field of secretFields) { + if (!(field in next)) continue; + next[field] = writeSecretFieldValues( + next[field], + readSecretFieldValues(next[field]).map(([serverHost, value]) => { + const result = map({ + secretName: name(field, serverHost), + kind: "field", + value, + redacted: value === SECRET_REDACTED, + sensitive: true, + }); + return [serverHost, result.redacted ? SECRET_REDACTED : result.value] as const; + }), + ); + } + return next; + }; + + const providerInstances = Object.fromEntries( + Object.entries(settings.providerInstances).map(([instanceId, instance]) => { + const config = instance.config; + return [ + instanceId, + { + ...instance, + ...(config !== null && typeof config === "object" && !Array.isArray(config) + ? { + config: mapFields( + config as Record, + providerSecretFields.get(instance.driver) ?? [], + (field, serverHost) => + `provider-config-${encode(instanceId)}-${encode(instance.driver)}-${encode(field)}` + + (serverHost === null ? "" : `-${encode(serverHost)}`), + ), + } + : {}), + ...(instance.environment + ? { + environment: instance.environment.map((variable) => { + const result = map({ + secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, + kind: "environment", + value: variable.value, + redacted: variable.valueRedacted === true, + sensitive: variable.sensitive, + providerInstanceId: instanceId, + environmentVariable: variable.name, + }); + const { valueRedacted: _omit, ...rest } = variable; + return { + ...rest, + value: result.value, + ...(result.redacted ? { valueRedacted: true } : {}), + }; + }), + } + : {}), + }, + ]; + }), + ); + + const sourceControlHosts = Object.fromEntries( + Object.entries(settings.sourceControlHosts).map(([kind, fields]) => [ + kind, + mapFields(fields, SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? [], (field, serverHost) => + sourceControlHostSecretName({ kind, field, serverHost }), + ), + ]), + ) as ServerSettings["sourceControlHosts"]; + + const usageLimitSources = Object.fromEntries( + Object.entries(settings.usageLimitSources).map(([id, source]) => [ + id, + mapFields( + source, + usageLimitSecretFields, + (field, serverHost) => + `usage-limit-source-${encode(id)}` + + (field === "managementKey" && serverHost === null + ? "" + : `-${encode(field)}${serverHost === null ? "" : `-${encode(serverHost)}`}`), + ), + ]), + ) as ServerSettings["usageLimitSources"]; + + return { ...settings, providerInstances, sourceControlHosts, usageLimitSources }; +} diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 7790c1dbc033490edf4f799ad7896f0c4e74af08..8c566318f13b3d01f763858d48065523a9361571 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -7,12 +7,10 @@ * * @module sourceControl/sourceControlHostSecrets */ +import { secretSettingsFields } from "@t3tools/contracts"; import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; -import { - secretSourceControlHostSettingsFields, - type SourceControlClientDefinition, -} from "@t3tools/source-control-core/client/definition"; +import type { SourceControlClientDefinition } from "@t3tools/source-control-core/client/definition"; import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; import * as GitHub from "@t3tools/source-control-github/client/definition"; @@ -33,9 +31,7 @@ export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap< ReadonlyArray > = new Map( definitions.flatMap((definition) => { - const fields = definition.settings - ? secretSourceControlHostSettingsFields(definition.settings) - : []; + const fields = definition.settings ? secretSettingsFields(definition.settings) : []; return fields.length > 0 ? [[definition.kind, fields] as const] : []; }), ); diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index 80727a8e970f4971c8158a5fad3b112d0574cc92..aacfcca9bbccb92625118bcf46c4abe3450ed3a3 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -13,6 +13,7 @@ import { DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; +import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; /** @@ -118,10 +119,8 @@ export function AddUsageLimitSourceDialog({
- setManagementKey(event.target.value)} /> diff --git a/apps/web/src/components/settings/GitHubTokenSettings.tsx b/apps/web/src/components/settings/GitHubTokenSettings.tsx index 9516d448b73aa5dacdb3586dbec7704f673d7c02..a6f5c07b29e2769ddf2a94d35e800f9def0f0b26 100644 --- a/apps/web/src/components/settings/GitHubTokenSettings.tsx +++ b/apps/web/src/components/settings/GitHubTokenSettings.tsx @@ -8,6 +8,7 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button, InlineButton } from "../ui/button"; import { Input } from "../ui/input"; +import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; const DEFAULT_HOST = "github.com"; @@ -103,12 +104,10 @@ export function GitHubTokenSettings({
- setDraft(event.target.value)} /> diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 4a95cdccd55563b277cfc53df7b39df9d52d085d..b9f2a4569b0c24b298983a875ea4115bf1c23a90 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -44,6 +44,7 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { MorphIcon } from "~/components/MorphIcon"; import { DraftInput } from "../ui/draft-input"; +import { SecretInput } from "./SecretInput"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -263,9 +264,6 @@ function ProviderEnvironmentFieldRow(props: { }) { const inputId = `${props.idPrefix}-environment-${props.field.name}`; const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? ""); - const placeholder = props.variable?.valueRedacted - ? "Stored secret - enter a new value to replace" - : props.field.placeholder; return ( - props.onCommit(props.field, next)} - placeholder={placeholder} - spellCheck={false} - /> + {props.field.sensitive === false ? ( + props.onCommit(props.field, next)} + placeholder={props.field.placeholder} + spellCheck={false} + /> + ) : ( + props.onCommit(props.field, next)} + placeholder={props.field.placeholder} + configured={props.variable?.valueRedacted === true} + spellCheck={false} + /> + )} {props.variable ? (