diff --git a/src/agents/pi-model-discovery.auth.test.ts b/src/agents/pi-model-discovery.auth.test.ts index 68f207db1..b96fb9ab8 100644 --- a/src/agents/pi-model-discovery.auth.test.ts +++ b/src/agents/pi-model-discovery.auth.test.ts @@ -65,4 +65,52 @@ describe("discoverAuthStorage", () => { await fs.rm(agentDir, { recursive: true, force: true }); } }); + + it("scrubs static api_key entries from legacy auth.json and keeps oauth entries", async () => { + const agentDir = await createAgentDir(); + try { + saveAuthProfileStore( + { + version: 1, + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-v1-runtime", + }, + }, + }, + agentDir, + ); + await fs.writeFile( + path.join(agentDir, "auth.json"), + JSON.stringify( + { + openrouter: { type: "api_key", key: "legacy-static-key" }, + "openai-codex": { + type: "oauth", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }, + null, + 2, + ), + ); + + discoverAuthStorage(agentDir); + + const parsed = JSON.parse(await fs.readFile(path.join(agentDir, "auth.json"), "utf8")) as { + [key: string]: unknown; + }; + expect(parsed.openrouter).toBeUndefined(); + expect(parsed["openai-codex"]).toMatchObject({ + type: "oauth", + access: "oauth-access", + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/skills/env-overrides.ts b/src/agents/skills/env-overrides.ts index bb8bec225..b16b0249e 100644 --- a/src/agents/skills/env-overrides.ts +++ b/src/agents/skills/env-overrides.ts @@ -105,9 +105,10 @@ function applySkillConfigEnvOverrides(params: { } } - if (normalizedPrimaryEnv && skillConfig.apiKey && !process.env[normalizedPrimaryEnv]) { + const resolvedApiKey = typeof skillConfig.apiKey === "string" ? skillConfig.apiKey.trim() : ""; + if (normalizedPrimaryEnv && resolvedApiKey && !process.env[normalizedPrimaryEnv]) { if (!pendingOverrides[normalizedPrimaryEnv]) { - pendingOverrides[normalizedPrimaryEnv] = skillConfig.apiKey; + pendingOverrides[normalizedPrimaryEnv] = resolvedApiKey; } } diff --git a/src/cli/secrets-cli.test.ts b/src/cli/secrets-cli.test.ts index f561b589b..9b04cbd21 100644 --- a/src/cli/secrets-cli.test.ts +++ b/src/cli/secrets-cli.test.ts @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createCliRuntimeCapture } from "./test-runtime-capture.js"; const callGatewayFromCli = vi.fn(); +const runSecretsMigration = vi.fn(); +const rollbackSecretsMigration = vi.fn(); const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } = createCliRuntimeCapture(); @@ -17,6 +19,11 @@ vi.mock("../runtime.js", () => ({ defaultRuntime, })); +vi.mock("../secrets/migrate.js", () => ({ + runSecretsMigration: (options: unknown) => runSecretsMigration(options), + rollbackSecretsMigration: (options: unknown) => rollbackSecretsMigration(options), +})); + const { registerSecretsCli } = await import("./secrets-cli.js"); describe("secrets CLI", () => { @@ -30,6 +37,8 @@ describe("secrets CLI", () => { beforeEach(() => { resetRuntimeCapture(); callGatewayFromCli.mockReset(); + runSecretsMigration.mockReset(); + rollbackSecretsMigration.mockReset(); }); it("calls secrets.reload and prints human output", async () => { @@ -50,4 +59,38 @@ describe("secrets CLI", () => { await createProgram().parseAsync(["secrets", "reload", "--json"], { from: "user" }); expect(runtimeLogs.at(-1)).toContain('"ok": true'); }); + + it("runs secrets migrate as dry-run by default", async () => { + runSecretsMigration.mockResolvedValue({ + mode: "dry-run", + changed: true, + secretsFilePath: "/tmp/secrets.enc.json", + counters: { secretsWritten: 3 }, + changedFiles: ["/tmp/openclaw.json"], + }); + + await createProgram().parseAsync(["secrets", "migrate"], { from: "user" }); + + expect(runSecretsMigration).toHaveBeenCalledWith( + expect.objectContaining({ write: false, scrubEnv: true }), + ); + expect(runtimeLogs.at(-1)).toContain("dry run"); + }); + + it("runs rollback when --rollback is provided", async () => { + rollbackSecretsMigration.mockResolvedValue({ + backupId: "20260221T010203Z", + restoredFiles: 2, + deletedFiles: 1, + }); + + await createProgram().parseAsync(["secrets", "migrate", "--rollback", "20260221T010203Z"], { + from: "user", + }); + + expect(rollbackSecretsMigration).toHaveBeenCalledWith({ + backupId: "20260221T010203Z", + }); + expect(runtimeLogs.at(-1)).toContain("rollback complete"); + }); }); diff --git a/src/cli/secrets-cli.ts b/src/cli/secrets-cli.ts index 01e88b687..50248836b 100644 --- a/src/cli/secrets-cli.ts +++ b/src/cli/secrets-cli.ts @@ -1,11 +1,59 @@ import type { Command } from "commander"; import { danger } from "../globals.js"; import { defaultRuntime } from "../runtime.js"; +import { + rollbackSecretsMigration, + runSecretsMigration, + type SecretsMigrationRollbackResult, + type SecretsMigrationRunResult, +} from "../secrets/migrate.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js"; type SecretsReloadOptions = GatewayRpcOpts & { json?: boolean }; +type SecretsMigrateOptions = { + write?: boolean; + rollback?: string; + scrubEnv?: boolean; + json?: boolean; +}; + +function printMigrationResult( + result: SecretsMigrationRunResult | SecretsMigrationRollbackResult, + json: boolean, +): void { + if (json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + + if ("restoredFiles" in result) { + defaultRuntime.log( + `Secrets rollback complete for backup ${result.backupId}. Restored ${result.restoredFiles} file(s), deleted ${result.deletedFiles} file(s).`, + ); + return; + } + + if (result.mode === "dry-run") { + if (!result.changed) { + defaultRuntime.log("Secrets migrate dry run: no changes needed."); + return; + } + defaultRuntime.log( + `Secrets migrate dry run: ${result.changedFiles.length} file(s) would change, ${result.counters.secretsWritten} secret value(s) would move to ${result.secretsFilePath}.`, + ); + return; + } + + if (!result.changed) { + defaultRuntime.log("Secrets migrate: no changes applied."); + return; + } + defaultRuntime.log( + `Secrets migrated. Backup: ${result.backupId}. Moved ${result.counters.secretsWritten} secret value(s) into ${result.secretsFilePath}.`, + ); +} export function registerSecretsCli(program: Command) { const secrets = program @@ -44,4 +92,30 @@ export function registerSecretsCli(program: Command) { defaultRuntime.exit(1); } }); + + secrets + .command("migrate") + .description("Migrate plaintext secrets to file-backed SecretRefs (sops)") + .option("--write", "Apply migration changes (default is dry-run)", false) + .option("--rollback ", "Rollback a previous migration backup id") + .option("--no-scrub-env", "Keep matching plaintext values in ~/.openclaw/.env") + .option("--json", "Output JSON", false) + .action(async (opts: SecretsMigrateOptions) => { + try { + if (typeof opts.rollback === "string" && opts.rollback.trim()) { + const result = await rollbackSecretsMigration({ backupId: opts.rollback.trim() }); + printMigrationResult(result, Boolean(opts.json)); + return; + } + + const result = await runSecretsMigration({ + write: Boolean(opts.write), + scrubEnv: opts.scrubEnv ?? true, + }); + printMigrationResult(result, Boolean(opts.json)); + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + } + }); } diff --git a/src/config/config.secrets-schema.test.ts b/src/config/config.secrets-schema.test.ts index dd20b23cd..b41b28a82 100644 --- a/src/config/config.secrets-schema.test.ts +++ b/src/config/config.secrets-schema.test.ts @@ -36,6 +36,21 @@ describe("config secret refs schema", () => { expect(result.ok).toBe(true); }); + it("accepts skills entry apiKey refs", () => { + const result = validateConfigObjectRaw({ + skills: { + entries: { + "review-pr": { + enabled: true, + apiKey: { source: "env", id: "SKILL_REVIEW_PR_API_KEY" }, + }, + }, + }, + }); + + expect(result.ok).toBe(true); + }); + it("rejects invalid secret ref id", () => { const result = validateConfigObjectRaw({ models: { diff --git a/src/config/types.skills.ts b/src/config/types.skills.ts index 0b14893b8..c09523ba4 100644 --- a/src/config/types.skills.ts +++ b/src/config/types.skills.ts @@ -1,6 +1,8 @@ +import type { SecretInput } from "./types.secrets.js"; + export type SkillConfig = { enabled?: boolean; - apiKey?: string; + apiKey?: SecretInput; env?: Record; config?: Record; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 3bf8dceee..e072c1fd9 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -4,7 +4,12 @@ import { parseDurationMs } from "../cli/parse-duration.js"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js"; import { ApprovalsSchema } from "./zod-schema.approvals.js"; -import { HexColorSchema, ModelsConfigSchema, SecretsConfigSchema } from "./zod-schema.core.js"; +import { + HexColorSchema, + ModelsConfigSchema, + SecretInputSchema, + SecretsConfigSchema, +} from "./zod-schema.core.js"; import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js"; import { InstallRecordShape } from "./zod-schema.installs.js"; import { ChannelsSchema } from "./zod-schema.providers.js"; @@ -722,7 +727,7 @@ export const OpenClawSchema = z z .object({ enabled: z.boolean().optional(), - apiKey: z.string().optional().register(sensitive), + apiKey: SecretInputSchema.optional().register(sensitive), env: z.record(z.string(), z.string()).optional(), config: z.record(z.string(), z.unknown()).optional(), }) diff --git a/src/secrets/migrate.test.ts b/src/secrets/migrate.test.ts new file mode 100644 index 000000000..2fad3b879 --- /dev/null +++ b/src/secrets/migrate.test.ts @@ -0,0 +1,204 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const runExecMock = vi.hoisted(() => vi.fn()); + +vi.mock("../process/exec.js", () => ({ + runExec: runExecMock, +})); + +const { rollbackSecretsMigration, runSecretsMigration } = await import("./migrate.js"); + +describe("secrets migrate", () => { + let baseDir = ""; + let stateDir = ""; + let configPath = ""; + let env: NodeJS.ProcessEnv; + let authStorePath = ""; + let envPath = ""; + + beforeEach(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-migrate-")); + stateDir = path.join(baseDir, ".openclaw"); + configPath = path.join(stateDir, "openclaw.json"); + authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"); + envPath = path.join(stateDir, ".env"); + env = { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_CONFIG_PATH: configPath, + }; + + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.mkdir(path.dirname(authStorePath), { recursive: true }); + + await fs.writeFile( + configPath, + `${JSON.stringify( + { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-openai-plaintext", + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }, + skills: { + entries: { + "review-pr": { + enabled: true, + apiKey: "sk-skill-plaintext", + }, + }, + }, + channels: { + googlechat: { + serviceAccount: '{"type":"service_account","client_email":"bot@example.com"}', + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + await fs.writeFile( + authStorePath, + `${JSON.stringify( + { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "sk-profile-plaintext", + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + await fs.writeFile( + envPath, + "OPENAI_API_KEY=sk-openai-plaintext\nSKILL_KEY=sk-skill-plaintext\nUNRELATED=value\n", + "utf8", + ); + + runExecMock.mockReset(); + runExecMock.mockImplementation(async (_cmd: string, args: string[]) => { + if (args[0] === "--encrypt") { + const outputPath = args[args.indexOf("--output") + 1]; + const inputPath = args.at(-1); + if (!outputPath || !inputPath) { + throw new Error("missing sops encrypt paths"); + } + await fs.copyFile(inputPath, outputPath); + return { stdout: "", stderr: "" }; + } + if (args[0] === "--decrypt") { + const sourcePath = args.at(-1); + if (!sourcePath) { + throw new Error("missing sops decrypt source"); + } + const raw = await fs.readFile(sourcePath, "utf8"); + return { stdout: raw, stderr: "" }; + } + throw new Error(`unexpected sops invocation: ${args.join(" ")}`); + }); + }); + + afterEach(async () => { + await fs.rm(baseDir, { recursive: true, force: true }); + }); + + it("reports a dry-run without mutating files", async () => { + const beforeConfig = await fs.readFile(configPath, "utf8"); + const beforeAuthStore = await fs.readFile(authStorePath, "utf8"); + + const result = await runSecretsMigration({ env }); + + expect(result.mode).toBe("dry-run"); + expect(result.changed).toBe(true); + expect(result.counters.secretsWritten).toBeGreaterThanOrEqual(3); + + expect(await fs.readFile(configPath, "utf8")).toBe(beforeConfig); + expect(await fs.readFile(authStorePath, "utf8")).toBe(beforeAuthStore); + }); + + it("migrates plaintext to file-backed refs and can rollback", async () => { + const applyResult = await runSecretsMigration({ env, write: true }); + + expect(applyResult.mode).toBe("write"); + expect(applyResult.changed).toBe(true); + expect(applyResult.backupId).toBeTruthy(); + + const migratedConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as { + models: { providers: { openai: { apiKey: unknown } } }; + skills: { entries: { "review-pr": { apiKey: unknown } } }; + channels: { googlechat: { serviceAccount?: unknown; serviceAccountRef?: unknown } }; + secrets: { sources: { file: { type: string; path: string } } }; + }; + expect(migratedConfig.models.providers.openai.apiKey).toEqual({ + source: "file", + id: "/providers/openai/apiKey", + }); + expect(migratedConfig.skills.entries["review-pr"].apiKey).toEqual({ + source: "file", + id: "/skills/entries/review-pr/apiKey", + }); + expect(migratedConfig.channels.googlechat.serviceAccount).toBeUndefined(); + expect(migratedConfig.channels.googlechat.serviceAccountRef).toEqual({ + source: "file", + id: "/channels/googlechat/serviceAccount", + }); + expect(migratedConfig.secrets.sources.file.type).toBe("sops"); + + const migratedAuth = JSON.parse(await fs.readFile(authStorePath, "utf8")) as { + profiles: { "openai:default": { key?: string; keyRef?: unknown } }; + }; + expect(migratedAuth.profiles["openai:default"].key).toBeUndefined(); + expect(migratedAuth.profiles["openai:default"].keyRef).toEqual({ + source: "file", + id: "/auth-profiles/main/openai:default/key", + }); + + const migratedEnv = await fs.readFile(envPath, "utf8"); + expect(migratedEnv).not.toContain("sk-openai-plaintext"); + expect(migratedEnv).not.toContain("sk-skill-plaintext"); + expect(migratedEnv).toContain("UNRELATED=value"); + + const secretsPath = path.join(stateDir, "secrets.enc.json"); + const secretsPayload = JSON.parse(await fs.readFile(secretsPath, "utf8")) as { + providers: { openai: { apiKey: string } }; + skills: { entries: { "review-pr": { apiKey: string } } }; + channels: { googlechat: { serviceAccount: string } }; + "auth-profiles": { main: { "openai:default": { key: string } } }; + }; + expect(secretsPayload.providers.openai.apiKey).toBe("sk-openai-plaintext"); + expect(secretsPayload.skills.entries["review-pr"].apiKey).toBe("sk-skill-plaintext"); + expect(secretsPayload.channels.googlechat.serviceAccount).toContain("service_account"); + expect(secretsPayload["auth-profiles"].main["openai:default"].key).toBe("sk-profile-plaintext"); + + const rollbackResult = await rollbackSecretsMigration({ env, backupId: applyResult.backupId! }); + expect(rollbackResult.restoredFiles).toBeGreaterThan(0); + + const rolledBackConfig = await fs.readFile(configPath, "utf8"); + expect(rolledBackConfig).toContain("sk-openai-plaintext"); + expect(rolledBackConfig).toContain("sk-skill-plaintext"); + + const rolledBackAuth = await fs.readFile(authStorePath, "utf8"); + expect(rolledBackAuth).toContain("sk-profile-plaintext"); + + await expect(fs.stat(secretsPath)).rejects.toThrow(); + const rolledBackEnv = await fs.readFile(envPath, "utf8"); + expect(rolledBackEnv).toContain("OPENAI_API_KEY=sk-openai-plaintext"); + }); +}); diff --git a/src/secrets/migrate.ts b/src/secrets/migrate.ts new file mode 100644 index 000000000..8ed03b95c --- /dev/null +++ b/src/secrets/migrate.ts @@ -0,0 +1,1023 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; +import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; +import { + createConfigIO, + resolveStateDir, + type OpenClawConfig, + type SecretRef, +} from "../config/config.js"; +import { runExec } from "../process/exec.js"; +import { resolveConfigDir, resolveUserPath } from "../utils.js"; + +const DEFAULT_SOPS_TIMEOUT_MS = 5_000; +const MAX_SOPS_OUTPUT_BYTES = 10 * 1024 * 1024; +const DEFAULT_SECRETS_FILE_PATH = "~/.openclaw/secrets.enc.json"; +const BACKUP_DIRNAME = "secrets-migrate"; +const BACKUP_MANIFEST_FILENAME = "manifest.json"; +const BACKUP_RETENTION = 20; + +type MigrationCounters = { + configRefs: number; + authProfileRefs: number; + plaintextRemoved: number; + secretsWritten: number; + envEntriesRemoved: number; + authStoresChanged: number; +}; + +type AuthStoreChange = { + path: string; + nextStore: Record; +}; + +type EnvChange = { + path: string; + nextRaw: string; +}; + +type BackupManifestEntry = { + path: string; + existed: boolean; + backupPath?: string; + mode?: number; +}; + +type BackupManifest = { + version: 1; + backupId: string; + createdAt: string; + entries: BackupManifestEntry[]; +}; + +type MigrationPlan = { + changed: boolean; + counters: MigrationCounters; + stateDir: string; + configChanged: boolean; + nextConfig: OpenClawConfig; + configWriteOptions: Awaited< + ReturnType["readConfigFileSnapshotForWrite"]> + >["writeOptions"]; + authStoreChanges: AuthStoreChange[]; + payloadChanged: boolean; + nextPayload: Record; + secretsFilePath: string; + secretsFileTimeoutMs: number; + envChange: EnvChange | null; + backupTargets: string[]; +}; + +export type SecretsMigrationRunOptions = { + write?: boolean; + scrubEnv?: boolean; + env?: NodeJS.ProcessEnv; + now?: Date; +}; + +export type SecretsMigrationRunResult = { + mode: "dry-run" | "write"; + changed: boolean; + backupId?: string; + backupDir?: string; + secretsFilePath: string; + counters: MigrationCounters; + changedFiles: string[]; +}; + +export type SecretsMigrationRollbackOptions = { + backupId: string; + env?: NodeJS.ProcessEnv; +}; + +export type SecretsMigrationRollbackResult = { + backupId: string; + restoredFiles: number; + deletedFiles: number; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSecretRef(value: unknown): value is SecretRef { + if (!isRecord(value)) { + return false; + } + if (Object.keys(value).length !== 2) { + return false; + } + return ( + (value.source === "env" || value.source === "file") && + typeof value.id === "string" && + value.id.trim().length > 0 + ); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function normalizeSopsTimeoutMs(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(1, Math.floor(value)); + } + return DEFAULT_SOPS_TIMEOUT_MS; +} + +function decodeJsonPointerToken(token: string): string { + return token.replace(/~1/g, "/").replace(/~0/g, "~"); +} + +function encodeJsonPointerToken(token: string): string { + return token.replace(/~/g, "~0").replace(/\//g, "~1"); +} + +function readJsonPointer(root: unknown, pointer: string): unknown { + if (!pointer.startsWith("/")) { + return undefined; + } + const tokens = pointer + .slice(1) + .split("/") + .map((token) => decodeJsonPointerToken(token)); + + let current: unknown = root; + for (const token of tokens) { + if (Array.isArray(current)) { + const index = Number.parseInt(token, 10); + if (!Number.isFinite(index) || index < 0 || index >= current.length) { + return undefined; + } + current = current[index]; + continue; + } + if (!isRecord(current)) { + return undefined; + } + if (!Object.hasOwn(current, token)) { + return undefined; + } + current = current[token]; + } + return current; +} + +function setJsonPointer(root: Record, pointer: string, value: unknown): void { + if (!pointer.startsWith("/")) { + throw new Error(`Invalid JSON pointer "${pointer}".`); + } + const tokens = pointer + .slice(1) + .split("/") + .map((token) => decodeJsonPointerToken(token)); + + let current: Record = root; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + const isLast = index === tokens.length - 1; + if (isLast) { + current[token] = value; + return; + } + const child = current[token]; + if (!isRecord(child)) { + current[token] = {}; + } + current = current[token] as Record; + } +} + +function formatBackupId(now: Date): string { + const year = now.getUTCFullYear(); + const month = String(now.getUTCMonth() + 1).padStart(2, "0"); + const day = String(now.getUTCDate()).padStart(2, "0"); + const hour = String(now.getUTCHours()).padStart(2, "0"); + const minute = String(now.getUTCMinutes()).padStart(2, "0"); + const second = String(now.getUTCSeconds()).padStart(2, "0"); + return `${year}${month}${day}T${hour}${minute}${second}Z`; +} + +function parseEnvValue(raw: string): string { + const trimmed = raw.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function scrubEnvRaw( + raw: string, + migratedValues: Set, +): { + nextRaw: string; + removed: number; +} { + if (migratedValues.size === 0) { + return { nextRaw: raw, removed: 0 }; + } + const lines = raw.split(/\r?\n/); + const nextLines: string[] = []; + let removed = 0; + for (const line of lines) { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); + if (!match) { + nextLines.push(line); + continue; + } + const parsedValue = parseEnvValue(match[2] ?? ""); + if (migratedValues.has(parsedValue)) { + removed += 1; + continue; + } + nextLines.push(line); + } + const hadTrailingNewline = raw.endsWith("\n"); + const joined = nextLines.join("\n"); + return { + nextRaw: + hadTrailingNewline || joined.length === 0 + ? `${joined}${joined.endsWith("\n") ? "" : "\n"}` + : joined, + removed, + }; +} + +function ensureDirForFile(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); +} + +function saveJsonFile(pathname: string, value: unknown): void { + ensureDirForFile(pathname); + fs.writeFileSync(pathname, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + fs.chmodSync(pathname, 0o600); +} + +function resolveFileSource( + config: OpenClawConfig, + env: NodeJS.ProcessEnv, +): { + path: string; + timeoutMs: number; + hadConfiguredSource: boolean; +} { + const source = config.secrets?.sources?.file; + if (source && source.type === "sops" && isNonEmptyString(source.path)) { + return { + path: resolveUserPath(source.path), + timeoutMs: normalizeSopsTimeoutMs(source.timeoutMs), + hadConfiguredSource: true, + }; + } + + return { + path: resolveUserPath(resolveDefaultSecretsConfigPath(env)), + timeoutMs: DEFAULT_SOPS_TIMEOUT_MS, + hadConfiguredSource: false, + }; +} + +function resolveDefaultSecretsConfigPath(env: NodeJS.ProcessEnv): string { + if (env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim()) { + return path.join(resolveStateDir(env, os.homedir), "secrets.enc.json"); + } + return DEFAULT_SECRETS_FILE_PATH; +} + +async function decryptSopsJson( + pathname: string, + timeoutMs: number, +): Promise> { + if (!fs.existsSync(pathname)) { + return {}; + } + try { + const { stdout } = await runExec("sops", ["--decrypt", "--output-type", "json", pathname], { + timeoutMs, + maxBuffer: MAX_SOPS_OUTPUT_BYTES, + }); + const parsed = JSON.parse(stdout) as unknown; + if (!isRecord(parsed)) { + throw new Error("decrypted payload is not a JSON object"); + } + return parsed; + } catch (err) { + const error = err as NodeJS.ErrnoException & { message?: string }; + if (error.code === "ENOENT") { + throw new Error( + "sops binary not found in PATH. Install sops >= 3.9.0 to run secrets migrate.", + { + cause: err, + }, + ); + } + if (typeof error.message === "string" && error.message.toLowerCase().includes("timed out")) { + throw new Error(`sops decrypt timed out after ${timeoutMs}ms for ${pathname}.`, { + cause: err, + }); + } + throw new Error(`sops decrypt failed for ${pathname}: ${String(error.message ?? err)}`, { + cause: err, + }); + } +} + +async function encryptSopsJson(params: { + pathname: string; + timeoutMs: number; + payload: Record; +}): Promise { + ensureDirForFile(params.pathname); + const tmpPlain = path.join( + path.dirname(params.pathname), + `${path.basename(params.pathname)}.${process.pid}.${crypto.randomUUID()}.plain.tmp`, + ); + const tmpEncrypted = path.join( + path.dirname(params.pathname), + `${path.basename(params.pathname)}.${process.pid}.${crypto.randomUUID()}.enc.tmp`, + ); + + fs.writeFileSync(tmpPlain, `${JSON.stringify(params.payload, null, 2)}\n`, "utf8"); + fs.chmodSync(tmpPlain, 0o600); + + try { + await runExec( + "sops", + [ + "--encrypt", + "--input-type", + "json", + "--output-type", + "json", + "--output", + tmpEncrypted, + tmpPlain, + ], + { + timeoutMs: params.timeoutMs, + maxBuffer: MAX_SOPS_OUTPUT_BYTES, + }, + ); + fs.renameSync(tmpEncrypted, params.pathname); + fs.chmodSync(params.pathname, 0o600); + } catch (err) { + const error = err as NodeJS.ErrnoException & { message?: string }; + if (error.code === "ENOENT") { + throw new Error( + "sops binary not found in PATH. Install sops >= 3.9.0 to run secrets migrate.", + { + cause: err, + }, + ); + } + if (typeof error.message === "string" && error.message.toLowerCase().includes("timed out")) { + throw new Error( + `sops encrypt timed out after ${params.timeoutMs}ms for ${params.pathname}.`, + { + cause: err, + }, + ); + } + throw new Error(`sops encrypt failed for ${params.pathname}: ${String(error.message ?? err)}`, { + cause: err, + }); + } finally { + fs.rmSync(tmpPlain, { force: true }); + fs.rmSync(tmpEncrypted, { force: true }); + } +} + +function migrateModelProviderSecrets(params: { + config: OpenClawConfig; + payload: Record; + counters: MigrationCounters; + migratedValues: Set; +}): void { + const providers = params.config.models?.providers as + | Record + | undefined; + if (!providers) { + return; + } + for (const [providerId, provider] of Object.entries(providers)) { + if (isSecretRef(provider.apiKey)) { + continue; + } + if (!isNonEmptyString(provider.apiKey)) { + continue; + } + const value = provider.apiKey.trim(); + const id = `/providers/${encodeJsonPointerToken(providerId)}/apiKey`; + const existing = readJsonPointer(params.payload, id); + if (!isDeepStrictEqual(existing, value)) { + setJsonPointer(params.payload, id, value); + params.counters.secretsWritten += 1; + } + provider.apiKey = { source: "file", id }; + params.counters.configRefs += 1; + params.migratedValues.add(value); + } +} + +function migrateSkillEntrySecrets(params: { + config: OpenClawConfig; + payload: Record; + counters: MigrationCounters; + migratedValues: Set; +}): void { + const entries = params.config.skills?.entries as Record | undefined; + if (!entries) { + return; + } + for (const [skillKey, entry] of Object.entries(entries)) { + if (!isRecord(entry) || isSecretRef(entry.apiKey)) { + continue; + } + if (!isNonEmptyString(entry.apiKey)) { + continue; + } + const value = entry.apiKey.trim(); + const id = `/skills/entries/${encodeJsonPointerToken(skillKey)}/apiKey`; + const existing = readJsonPointer(params.payload, id); + if (!isDeepStrictEqual(existing, value)) { + setJsonPointer(params.payload, id, value); + params.counters.secretsWritten += 1; + } + entry.apiKey = { source: "file", id }; + params.counters.configRefs += 1; + params.migratedValues.add(value); + } +} + +function migrateGoogleChatServiceAccount(params: { + account: Record; + pointerId: string; + counters: MigrationCounters; + payload: Record; +}): void { + const explicitRef = isSecretRef(params.account.serviceAccountRef) + ? params.account.serviceAccountRef + : null; + const inlineRef = isSecretRef(params.account.serviceAccount) + ? params.account.serviceAccount + : null; + if (explicitRef || inlineRef) { + if ( + params.account.serviceAccount !== undefined && + !isSecretRef(params.account.serviceAccount) + ) { + delete params.account.serviceAccount; + params.counters.plaintextRemoved += 1; + } + return; + } + + const value = params.account.serviceAccount; + const hasStringValue = isNonEmptyString(value); + const hasObjectValue = isRecord(value) && Object.keys(value).length > 0; + if (!hasStringValue && !hasObjectValue) { + return; + } + + const id = `${params.pointerId}/serviceAccount`; + const normalizedValue = hasStringValue ? value.trim() : structuredClone(value); + const existing = readJsonPointer(params.payload, id); + if (!isDeepStrictEqual(existing, normalizedValue)) { + setJsonPointer(params.payload, id, normalizedValue); + params.counters.secretsWritten += 1; + } + + params.account.serviceAccountRef = { source: "file", id }; + delete params.account.serviceAccount; + params.counters.configRefs += 1; +} + +function migrateGoogleChatSecrets(params: { + config: OpenClawConfig; + payload: Record; + counters: MigrationCounters; +}): void { + const googlechat = params.config.channels?.googlechat; + if (!isRecord(googlechat)) { + return; + } + + migrateGoogleChatServiceAccount({ + account: googlechat, + pointerId: "/channels/googlechat", + payload: params.payload, + counters: params.counters, + }); + + if (!isRecord(googlechat.accounts)) { + return; + } + for (const [accountId, accountValue] of Object.entries(googlechat.accounts)) { + if (!isRecord(accountValue)) { + continue; + } + migrateGoogleChatServiceAccount({ + account: accountValue, + pointerId: `/channels/googlechat/accounts/${encodeJsonPointerToken(accountId)}`, + payload: params.payload, + counters: params.counters, + }); + } +} + +function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): string[] { + const paths = new Set(); + paths.add(resolveUserPath(resolveAuthStorePath())); + + const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); + if (fs.existsSync(agentsRoot)) { + for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + paths.add(path.join(agentsRoot, entry.name, "agent", "auth-profiles.json")); + } + } + + for (const agentId of listAgentIds(config)) { + const agentDir = resolveAgentDir(config, agentId); + paths.add(resolveUserPath(resolveAuthStorePath(agentDir))); + } + + return [...paths]; +} + +function deriveAuthStoreScope(authStorePath: string, stateDir: string): string { + const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); + const relative = path.relative(agentsRoot, authStorePath); + if (!relative.startsWith("..")) { + const segments = relative.split(path.sep); + if (segments.length >= 3 && segments[1] === "agent" && segments[2] === "auth-profiles.json") { + const candidate = segments[0]?.trim(); + if (candidate) { + return candidate; + } + } + } + + const digest = crypto.createHash("sha1").update(authStorePath).digest("hex").slice(0, 8); + return `path-${digest}`; +} + +function migrateAuthStoreSecrets(params: { + store: Record; + scope: string; + payload: Record; + counters: MigrationCounters; + migratedValues: Set; +}): boolean { + const profiles = params.store.profiles; + if (!isRecord(profiles)) { + return false; + } + + let changed = false; + for (const [profileId, profileValue] of Object.entries(profiles)) { + if (!isRecord(profileValue)) { + continue; + } + if (profileValue.type === "api_key") { + const keyRef = isSecretRef(profileValue.keyRef) ? profileValue.keyRef : null; + const key = isNonEmptyString(profileValue.key) ? profileValue.key.trim() : ""; + if (keyRef) { + if (key) { + delete profileValue.key; + params.counters.plaintextRemoved += 1; + changed = true; + } + continue; + } + if (!key) { + continue; + } + const id = `/auth-profiles/${encodeJsonPointerToken(params.scope)}/${encodeJsonPointerToken(profileId)}/key`; + const existing = readJsonPointer(params.payload, id); + if (!isDeepStrictEqual(existing, key)) { + setJsonPointer(params.payload, id, key); + params.counters.secretsWritten += 1; + } + profileValue.keyRef = { source: "file", id }; + delete profileValue.key; + params.counters.authProfileRefs += 1; + params.migratedValues.add(key); + changed = true; + continue; + } + + if (profileValue.type === "token") { + const tokenRef = isSecretRef(profileValue.tokenRef) ? profileValue.tokenRef : null; + const token = isNonEmptyString(profileValue.token) ? profileValue.token.trim() : ""; + if (tokenRef) { + if (token) { + delete profileValue.token; + params.counters.plaintextRemoved += 1; + changed = true; + } + continue; + } + if (!token) { + continue; + } + const id = `/auth-profiles/${encodeJsonPointerToken(params.scope)}/${encodeJsonPointerToken(profileId)}/token`; + const existing = readJsonPointer(params.payload, id); + if (!isDeepStrictEqual(existing, token)) { + setJsonPointer(params.payload, id, token); + params.counters.secretsWritten += 1; + } + profileValue.tokenRef = { source: "file", id }; + delete profileValue.token; + params.counters.authProfileRefs += 1; + params.migratedValues.add(token); + changed = true; + } + } + + return changed; +} + +function resolveBackupRoot(stateDir: string): string { + return path.join(resolveUserPath(stateDir), "backups", BACKUP_DIRNAME); +} + +function createBackupManifest(params: { + stateDir: string; + targets: string[]; + backupId: string; + now: Date; +}): { backupDir: string; manifestPath: string; manifest: BackupManifest } { + const backupDir = path.join(resolveBackupRoot(params.stateDir), params.backupId); + fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 }); + + const entries: BackupManifestEntry[] = []; + let index = 0; + for (const target of params.targets) { + const normalized = resolveUserPath(target); + const exists = fs.existsSync(normalized); + if (!exists) { + entries.push({ path: normalized, existed: false }); + continue; + } + + const backupName = `file-${String(index).padStart(4, "0")}.bak`; + const backupPath = path.join(backupDir, backupName); + fs.copyFileSync(normalized, backupPath); + const stats = fs.statSync(normalized); + entries.push({ + path: normalized, + existed: true, + backupPath, + mode: stats.mode & 0o777, + }); + index += 1; + } + + const manifest: BackupManifest = { + version: 1, + backupId: params.backupId, + createdAt: params.now.toISOString(), + entries, + }; + const manifestPath = path.join(backupDir, BACKUP_MANIFEST_FILENAME); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + fs.chmodSync(manifestPath, 0o600); + + return { backupDir, manifestPath, manifest }; +} + +function restoreFromManifest(manifest: BackupManifest): { + restoredFiles: number; + deletedFiles: number; +} { + let restoredFiles = 0; + let deletedFiles = 0; + + for (const entry of manifest.entries) { + if (!entry.existed) { + if (fs.existsSync(entry.path)) { + fs.rmSync(entry.path, { force: true }); + deletedFiles += 1; + } + continue; + } + + if (!entry.backupPath || !fs.existsSync(entry.backupPath)) { + throw new Error(`Backup file is missing for ${entry.path}.`); + } + ensureDirForFile(entry.path); + fs.copyFileSync(entry.backupPath, entry.path); + fs.chmodSync(entry.path, entry.mode ?? 0o600); + restoredFiles += 1; + } + + return { restoredFiles, deletedFiles }; +} + +function pruneOldBackups(stateDir: string): void { + const backupRoot = resolveBackupRoot(stateDir); + if (!fs.existsSync(backupRoot)) { + return; + } + const dirs = fs + .readdirSync(backupRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .toSorted(); + + if (dirs.length <= BACKUP_RETENTION) { + return; + } + + const toDelete = dirs.slice(0, Math.max(0, dirs.length - BACKUP_RETENTION)); + for (const dir of toDelete) { + fs.rmSync(path.join(backupRoot, dir), { recursive: true, force: true }); + } +} + +async function buildMigrationPlan(params: { + env: NodeJS.ProcessEnv; + scrubEnv: boolean; +}): Promise { + const io = createConfigIO({ env: params.env }); + const { snapshot, writeOptions } = await io.readConfigFileSnapshotForWrite(); + if (!snapshot.valid) { + const issues = + snapshot.issues.length > 0 + ? snapshot.issues.map((issue) => `${issue.path || ""}: ${issue.message}`).join("\n") + : "Unknown validation issue."; + throw new Error(`Cannot migrate secrets because config is invalid:\n${issues}`); + } + + const stateDir = resolveStateDir(params.env, os.homedir); + const nextConfig = structuredClone(snapshot.config); + const fileSource = resolveFileSource(nextConfig, params.env); + const previousPayload = await decryptSopsJson(fileSource.path, fileSource.timeoutMs); + const nextPayload = structuredClone(previousPayload); + + const counters: MigrationCounters = { + configRefs: 0, + authProfileRefs: 0, + plaintextRemoved: 0, + secretsWritten: 0, + envEntriesRemoved: 0, + authStoresChanged: 0, + }; + + const migratedValues = new Set(); + + migrateModelProviderSecrets({ + config: nextConfig, + payload: nextPayload, + counters, + migratedValues, + }); + migrateSkillEntrySecrets({ + config: nextConfig, + payload: nextPayload, + counters, + migratedValues, + }); + migrateGoogleChatSecrets({ + config: nextConfig, + payload: nextPayload, + counters, + }); + + const authStoreChanges: AuthStoreChange[] = []; + for (const authStorePath of collectAuthStorePaths(nextConfig, stateDir)) { + if (!fs.existsSync(authStorePath)) { + continue; + } + const raw = fs.readFileSync(authStorePath, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + continue; + } + if (!isRecord(parsed)) { + continue; + } + + const nextStore = structuredClone(parsed); + const scope = deriveAuthStoreScope(authStorePath, stateDir); + const changed = migrateAuthStoreSecrets({ + store: nextStore, + scope, + payload: nextPayload, + counters, + migratedValues, + }); + if (!changed) { + continue; + } + authStoreChanges.push({ path: authStorePath, nextStore }); + } + counters.authStoresChanged = authStoreChanges.length; + + if (counters.secretsWritten > 0 && !fileSource.hadConfiguredSource) { + const defaultConfigPath = resolveDefaultSecretsConfigPath(params.env); + nextConfig.secrets ??= {}; + nextConfig.secrets.sources ??= {}; + nextConfig.secrets.sources.file = { + type: "sops", + path: defaultConfigPath, + timeoutMs: DEFAULT_SOPS_TIMEOUT_MS, + }; + } + + const configChanged = !isDeepStrictEqual(snapshot.config, nextConfig); + const payloadChanged = !isDeepStrictEqual(previousPayload, nextPayload); + + let envChange: EnvChange | null = null; + if (params.scrubEnv && migratedValues.size > 0) { + const envPath = path.join(resolveConfigDir(params.env, os.homedir), ".env"); + if (fs.existsSync(envPath)) { + const rawEnv = fs.readFileSync(envPath, "utf8"); + const scrubbed = scrubEnvRaw(rawEnv, migratedValues); + if (scrubbed.removed > 0 && scrubbed.nextRaw !== rawEnv) { + counters.envEntriesRemoved = scrubbed.removed; + envChange = { + path: envPath, + nextRaw: scrubbed.nextRaw, + }; + } + } + } + + const backupTargets = new Set(); + if (configChanged) { + backupTargets.add(io.configPath); + } + if (payloadChanged) { + backupTargets.add(fileSource.path); + } + for (const change of authStoreChanges) { + backupTargets.add(change.path); + } + if (envChange) { + backupTargets.add(envChange.path); + } + + return { + changed: configChanged || payloadChanged || authStoreChanges.length > 0 || Boolean(envChange), + counters, + stateDir, + configChanged, + nextConfig, + configWriteOptions: writeOptions, + authStoreChanges, + payloadChanged, + nextPayload, + secretsFilePath: fileSource.path, + secretsFileTimeoutMs: fileSource.timeoutMs, + envChange, + backupTargets: [...backupTargets], + }; +} + +export async function runSecretsMigration( + options: SecretsMigrationRunOptions = {}, +): Promise { + const env = options.env ?? process.env; + const scrubEnv = options.scrubEnv ?? true; + const plan = await buildMigrationPlan({ env, scrubEnv }); + + if (!options.write) { + return { + mode: "dry-run", + changed: plan.changed, + secretsFilePath: plan.secretsFilePath, + counters: plan.counters, + changedFiles: plan.backupTargets, + }; + } + + if (!plan.changed) { + return { + mode: "write", + changed: false, + secretsFilePath: plan.secretsFilePath, + counters: plan.counters, + changedFiles: [], + }; + } + + const now = options.now ?? new Date(); + const backupId = formatBackupId(now); + const backup = createBackupManifest({ + stateDir: plan.stateDir, + targets: plan.backupTargets, + backupId, + now, + }); + + try { + if (plan.payloadChanged) { + await encryptSopsJson({ + pathname: plan.secretsFilePath, + timeoutMs: plan.secretsFileTimeoutMs, + payload: plan.nextPayload, + }); + } + + if (plan.configChanged) { + const io = createConfigIO({ env }); + await io.writeConfigFile(plan.nextConfig, plan.configWriteOptions); + } + + for (const change of plan.authStoreChanges) { + saveJsonFile(change.path, change.nextStore); + } + + if (plan.envChange) { + ensureDirForFile(plan.envChange.path); + fs.writeFileSync(plan.envChange.path, plan.envChange.nextRaw, "utf8"); + fs.chmodSync(plan.envChange.path, 0o600); + } + } catch (err) { + restoreFromManifest(backup.manifest); + throw new Error( + `Secrets migration failed and was rolled back from backup ${backupId}: ${String(err)}`, + { + cause: err, + }, + ); + } + + pruneOldBackups(plan.stateDir); + + return { + mode: "write", + changed: true, + backupId, + backupDir: backup.backupDir, + secretsFilePath: plan.secretsFilePath, + counters: plan.counters, + changedFiles: plan.backupTargets, + }; +} + +export function resolveSecretsMigrationBackupRoot(env: NodeJS.ProcessEnv = process.env): string { + return resolveBackupRoot(resolveStateDir(env, os.homedir)); +} + +export function listSecretsMigrationBackups(env: NodeJS.ProcessEnv = process.env): string[] { + const root = resolveSecretsMigrationBackupRoot(env); + if (!fs.existsSync(root)) { + return []; + } + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .toSorted(); +} + +export async function rollbackSecretsMigration( + options: SecretsMigrationRollbackOptions, +): Promise { + const env = options.env ?? process.env; + const backupDir = path.join(resolveSecretsMigrationBackupRoot(env), options.backupId); + const manifestPath = path.join(backupDir, BACKUP_MANIFEST_FILENAME); + if (!fs.existsSync(manifestPath)) { + const available = listSecretsMigrationBackups(env); + const suffix = + available.length > 0 + ? ` Available backups: ${available.slice(-10).join(", ")}` + : " No backups were found."; + throw new Error(`Backup "${options.backupId}" was not found.${suffix}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as unknown; + } catch (err) { + throw new Error(`Failed to read backup manifest at ${manifestPath}: ${String(err)}`, { + cause: err, + }); + } + + if (!isRecord(parsed) || !Array.isArray(parsed.entries)) { + throw new Error(`Backup manifest at ${manifestPath} is invalid.`); + } + + const manifest = parsed as BackupManifest; + const restored = restoreFromManifest(manifest); + return { + backupId: options.backupId, + restoredFiles: restored.restoredFiles, + deletedFiles: restored.deletedFiles, + }; +} diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 396b01d00..1710796de 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -33,6 +33,14 @@ describe("secrets runtime snapshot", () => { }, }, }, + skills: { + entries: { + "review-pr": { + enabled: true, + apiKey: { source: "env", id: "REVIEW_SKILL_API_KEY" }, + }, + }, + }, }; const snapshot = await prepareSecretsRuntimeSnapshot({ @@ -40,6 +48,7 @@ describe("secrets runtime snapshot", () => { env: { OPENAI_API_KEY: "sk-env-openai", GITHUB_TOKEN: "ghp-env-token", + REVIEW_SKILL_API_KEY: "sk-skill-ref", }, agentDirs: ["/tmp/openclaw-agent-main"], loadAuthStore: () => ({ @@ -62,6 +71,7 @@ describe("secrets runtime snapshot", () => { }); expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-env-openai"); + expect(snapshot.config.skills?.entries?.["review-pr"]?.apiKey).toBe("sk-skill-ref"); expect(snapshot.warnings).toHaveLength(2); expect(snapshot.authStores[0]?.store.profiles["openai:default"]).toMatchObject({ type: "api_key", diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index 499fb8f5e..a3154b958 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -40,6 +40,10 @@ type ProviderLike = { apiKey?: unknown; }; +type SkillEntryLike = { + apiKey?: unknown; +}; + type GoogleChatAccountLike = { serviceAccount?: unknown; serviceAccountRef?: unknown; @@ -127,6 +131,22 @@ async function resolveConfigSecretRefs(params: { } } + const skillEntries = resolved.skills?.entries as Record | undefined; + if (skillEntries) { + for (const [skillKey, entry] of Object.entries(skillEntries)) { + if (!isSecretRef(entry.apiKey)) { + continue; + } + const resolvedValue = await resolveSecretRefValue(entry.apiKey, params.context); + if (!isNonEmptyString(resolvedValue)) { + throw new Error( + `skills.entries.${skillKey}.apiKey resolved to a non-string or empty value.`, + ); + } + entry.apiKey = resolvedValue; + } + } + const googleChat = resolved.channels?.googlechat as GoogleChatAccountLike | undefined; if (googleChat) { await resolveGoogleChatServiceAccount(