From 603aa8a2ed1870c2460125242152a4d13476a49b Mon Sep 17 00:00:00 2001 From: alkor2000 <131229172+alkor2000@users.noreply.github.com> Date: Thu, 28 May 2026 07:29:28 +0800 Subject: [PATCH] fix(doctor): rewrite non-canonical api_key auth profiles Rewrites non-canonical api_key fields in auth-profiles.json to canonical key via openclaw doctor --fix, with backups, while preserving canonical key/keyRef credentials and active-agent auth stores. Fixes #57389. Co-authored-by: alkor2000 <200923177@qq.com> --- ...octor-auth-canonical-api-key-alias.test.ts | 326 ++++++++++++++++++ src/commands/doctor-auth-flat-profiles.ts | 140 +++++++- src/flows/doctor-health-contributions.ts | 6 +- 3 files changed, 462 insertions(+), 10 deletions(-) create mode 100644 src/commands/doctor-auth-canonical-api-key-alias.test.ts diff --git a/src/commands/doctor-auth-canonical-api-key-alias.test.ts b/src/commands/doctor-auth-canonical-api-key-alias.test.ts new file mode 100644 index 000000000000..f47458fb8223 --- /dev/null +++ b/src/commands/doctor-auth-canonical-api-key-alias.test.ts @@ -0,0 +1,326 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles/store.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../test-utils/openclaw-test-state.js"; +import { maybeRepairCanonicalApiKeyFieldAlias } from "./doctor-auth-flat-profiles.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; + +const states: OpenClawTestState[] = []; + +function makePrompter(shouldRepair: boolean): DoctorPrompter { + return { + confirm: vi.fn(async () => shouldRepair), + confirmAutoFix: vi.fn(async () => shouldRepair), + confirmAggressiveAutoFix: vi.fn(async () => shouldRepair), + confirmRuntimeRepair: vi.fn(async () => shouldRepair), + select: vi.fn(async (_params, fallback) => fallback), + shouldRepair, + shouldForce: false, + repairMode: { + shouldRepair, + shouldForce: false, + nonInteractive: false, + canPrompt: true, + updateInProgress: false, + }, + }; +} + +async function makeTestState(): Promise { + const state = await createOpenClawTestState({ + layout: "state-only", + prefix: "openclaw-doctor-canonical-api-key-", + env: { + OPENCLAW_AGENT_DIR: undefined, + }, + }); + states.push(state); + return state; +} + +afterEach(async () => { + clearRuntimeAuthProfileStoreSnapshots(); + for (const state of states.splice(0)) { + await state.cleanup(); + } +}); + +describe("maybeRepairCanonicalApiKeyFieldAlias", () => { + it('rewrites the non-canonical "api_key" field to "key" with a backup (57389)', async () => { + const state = await makeTestState(); + const canonical = { + version: 1, + profiles: { + "my-key": { + type: "api_key", + provider: "my-provider", + api_key: "sk-snake-case-key", + }, + }, + order: { + "my-provider": ["my-key"], + }, + }; + const authPath = await state.writeAuthProfiles(canonical); + + const result = await maybeRepairCanonicalApiKeyFieldAlias({ + cfg: {}, + prompter: makePrompter(true), + now: () => 123, + }); + + expect(result.detected).toEqual([authPath]); + expect(result.changes).toStrictEqual([ + `Rewrote 1 "api_key" field(s) to "key" in ${authPath} (backup: ${authPath}.api-key-alias.123.bak).`, + ]); + expect(result.warnings).toStrictEqual([]); + // After the fix: api_key is aliased to the canonical key, other fields untouched. + expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({ + version: 1, + profiles: { + "my-key": { + type: "api_key", + provider: "my-provider", + key: "sk-snake-case-key", + }, + }, + order: { + "my-provider": ["my-key"], + }, + }); + // The backup preserves the original non-canonical shape. + expect(JSON.parse(fs.readFileSync(`${authPath}.api-key-alias.123.bak`, "utf8"))).toEqual( + canonical, + ); + }); + + it('rewrites non-canonical SecretRef "api_key" fields to canonical "key"', async () => { + const state = await makeTestState(); + const canonical = { + version: 1, + profiles: { + "my-key": { + type: "api_key", + provider: "my-provider", + api_key: { source: "env", provider: "default", id: "MY_PROVIDER_API_KEY" }, + }, + }, + }; + const authPath = await state.writeAuthProfiles(canonical); + + const result = await maybeRepairCanonicalApiKeyFieldAlias({ + cfg: {}, + prompter: makePrompter(true), + now: () => 123, + }); + + expect(result.detected).toEqual([authPath]); + expect(result.changes).toStrictEqual([ + `Rewrote 1 "api_key" field(s) to "key" in ${authPath} (backup: ${authPath}.api-key-alias.123.bak).`, + ]); + expect(result.warnings).toStrictEqual([]); + expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({ + version: 1, + profiles: { + "my-key": { + type: "api_key", + provider: "my-provider", + key: { source: "env", provider: "default", id: "MY_PROVIDER_API_KEY" }, + }, + }, + }); + expect(JSON.parse(fs.readFileSync(`${authPath}.api-key-alias.123.bak`, "utf8"))).toEqual( + canonical, + ); + }); + + it("repairs auth profiles from OPENCLAW_AGENT_DIR", async () => { + const state = await makeTestState(); + const agentDir = state.path("external-agent"); + const authPath = path.join(agentDir, "auth-profiles.json"); + const canonical = { + version: 1, + profiles: { + "my-key": { + type: "api_key", + provider: "my-provider", + api_key: "sk-snake-case-key", + }, + }, + }; + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(authPath, `${JSON.stringify(canonical, null, 2)}\n`, "utf8"); + + const result = await maybeRepairCanonicalApiKeyFieldAlias({ + cfg: {}, + prompter: makePrompter(true), + now: () => 123, + env: { + ...state.env, + OPENCLAW_AGENT_DIR: agentDir, + }, + }); + + expect(result.detected).toEqual([authPath]); + expect(result.changes).toStrictEqual([ + `Rewrote 1 "api_key" field(s) to "key" in ${authPath} (backup: ${authPath}.api-key-alias.123.bak).`, + ]); + expect(result.warnings).toStrictEqual([]); + expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({ + version: 1, + profiles: { + "my-key": { + type: "api_key", + provider: "my-provider", + key: "sk-snake-case-key", + }, + }, + }); + }); + + it("repairs auth profiles from PI_CODING_AGENT_DIR", async () => { + const state = await makeTestState(); + const agentDir = state.path("legacy-external-agent"); + const authPath = path.join(agentDir, "auth-profiles.json"); + const canonical = { + version: 1, + profiles: { + "my-key": { + type: "api_key", + provider: "my-provider", + api_key: "sk-snake-case-key", + }, + }, + }; + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(authPath, `${JSON.stringify(canonical, null, 2)}\n`, "utf8"); + + const result = await maybeRepairCanonicalApiKeyFieldAlias({ + cfg: {}, + prompter: makePrompter(true), + now: () => 123, + env: { + ...state.env, + OPENCLAW_AGENT_DIR: undefined, + PI_CODING_AGENT_DIR: agentDir, + }, + }); + + expect(result.detected).toEqual([authPath]); + expect(JSON.parse(fs.readFileSync(authPath, "utf8")).profiles["my-key"]).toEqual({ + type: "api_key", + provider: "my-provider", + key: "sk-snake-case-key", + }); + }); + + it('does not touch profiles that already have the canonical "key" field', async () => { + const state = await makeTestState(); + const canonical = { + version: 1, + profiles: { + "good-key": { + type: "api_key", + provider: "my-provider", + key: "sk-already-canonical", + }, + }, + }; + const authPath = await state.writeAuthProfiles(canonical); + + const result = await maybeRepairCanonicalApiKeyFieldAlias({ + cfg: {}, + prompter: makePrompter(true), + now: () => 123, + }); + + expect(result.detected).toStrictEqual([]); + expect(result.changes).toStrictEqual([]); + expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual(canonical); + expect(fs.existsSync(`${authPath}.api-key-alias.123.bak`)).toBe(false); + }); + + it('does not replace canonical "keyRef" credentials with stale "api_key" fields', async () => { + const state = await makeTestState(); + const canonical = { + version: 1, + profiles: { + "ref-key": { + type: "api_key", + provider: "my-provider", + keyRef: { source: "env", provider: "default", id: "MY_PROVIDER_API_KEY" }, + api_key: "stale-inline-key", + }, + }, + }; + const authPath = await state.writeAuthProfiles(canonical); + + const result = await maybeRepairCanonicalApiKeyFieldAlias({ + cfg: {}, + prompter: makePrompter(true), + now: () => 123, + }); + + expect(result.detected).toStrictEqual([]); + expect(result.changes).toStrictEqual([]); + expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual(canonical); + expect(fs.existsSync(`${authPath}.api-key-alias.123.bak`)).toBe(false); + }); + + it('does not replace inline canonical SecretRef "key" credentials with stale "api_key" fields', async () => { + const state = await makeTestState(); + const canonical = { + version: 1, + profiles: { + "inline-ref-key": { + type: "api_key", + provider: "my-provider", + key: { source: "env", provider: "default", id: "MY_PROVIDER_API_KEY" }, + api_key: "stale-inline-key", + }, + }, + }; + const authPath = await state.writeAuthProfiles(canonical); + + const result = await maybeRepairCanonicalApiKeyFieldAlias({ + cfg: {}, + prompter: makePrompter(true), + now: () => 123, + }); + + expect(result.detected).toStrictEqual([]); + expect(result.changes).toStrictEqual([]); + expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual(canonical); + expect(fs.existsSync(`${authPath}.api-key-alias.123.bak`)).toBe(false); + }); + + it('reports the non-canonical "api_key" field without rewriting when repair is declined', async () => { + const state = await makeTestState(); + const canonical = { + version: 1, + profiles: { + "my-key": { + type: "api_key", + provider: "my-provider", + api_key: "sk-snake-case-key", + }, + }, + }; + const authPath = await state.writeAuthProfiles(canonical); + + const result = await maybeRepairCanonicalApiKeyFieldAlias({ + cfg: {}, + prompter: makePrompter(false), + now: () => 123, + }); + + expect(result.detected).toEqual([authPath]); + expect(result.changes).toStrictEqual([]); + expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual(canonical); + expect(fs.existsSync(`${authPath}.api-key-alias.123.bak`)).toBe(false); + }); +}); diff --git a/src/commands/doctor-auth-flat-profiles.ts b/src/commands/doctor-auth-flat-profiles.ts index 9e955e7a8819..f59ac3238a5c 100644 --- a/src/commands/doctor-auth-flat-profiles.ts +++ b/src/commands/doctor-auth-flat-profiles.ts @@ -12,6 +12,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveStateDir } from "../config/paths.js"; import type { AuthProfileConfig } from "../config/types.auth.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { coerceSecretRef } from "../config/types.secrets.js"; import { loadJsonFile } from "../infra/json-file.js"; import { isRecord } from "../shared/record-coerce.js"; import { note } from "../terminal/note.js"; @@ -173,8 +174,8 @@ function addCandidate( candidates.set(path.resolve(authPath), { agentDir, authPath }); } -function listExistingAgentDirsFromState(): string[] { - const root = path.join(resolveStateDir(), "agents"); +function listExistingAgentDirsFromState(env: NodeJS.ProcessEnv): string[] { + const root = path.join(resolveStateDir(env), "agents"); let entries: fs.Dirent[]; try { entries = fs.readdirSync(root, { withFileTypes: true }); @@ -193,13 +194,21 @@ function listExistingAgentDirsFromState(): string[] { }); } -function listAuthProfileRepairCandidates(cfg: OpenClawConfig): AuthProfileRepairCandidate[] { +function listAuthProfileRepairCandidates( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): AuthProfileRepairCandidate[] { const candidates = new Map(); - addCandidate(candidates, resolveDefaultAgentDir(cfg)); - for (const agentId of listAgentIds(cfg)) { - addCandidate(candidates, resolveAgentDir(cfg, agentId)); + addCandidate(candidates, resolveDefaultAgentDir(cfg, env)); + const envAgentDir = + readNonEmptyString(env.OPENCLAW_AGENT_DIR) ?? readNonEmptyString(env.PI_CODING_AGENT_DIR); + if (envAgentDir) { + addCandidate(candidates, envAgentDir); } - for (const agentDir of listExistingAgentDirsFromState()) { + for (const agentId of listAgentIds(cfg)) { + addCandidate(candidates, resolveAgentDir(cfg, agentId, env)); + } + for (const agentDir of listExistingAgentDirsFromState(env)) { addCandidate(candidates, agentDir); } return [...candidates.values()]; @@ -303,12 +312,14 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: { cfg: OpenClawConfig; prompter: DoctorPrompter; now?: () => number; + env?: NodeJS.ProcessEnv; }): Promise { const now = params.now ?? Date.now; - const legacyStores = listAuthProfileRepairCandidates(params.cfg) + const env = params.env ?? process.env; + const legacyStores = listAuthProfileRepairCandidates(params.cfg, env) .map(resolveLegacyFlatStore) .filter((entry): entry is LegacyFlatAuthProfileStore => entry !== null); - const awsSdkMarkerStores = listAuthProfileRepairCandidates(params.cfg) + const awsSdkMarkerStores = listAuthProfileRepairCandidates(params.cfg, env) .map(resolveAwsSdkAuthProfileMarkerStore) .filter((entry): entry is AwsSdkAuthProfileMarkerStore => entry !== null); @@ -399,3 +410,114 @@ export async function maybeRepairLegacyFlatAuthProfileStores(params: { } return result; } + +type CanonicalApiKeyAliasRepair = { + authPath: string; + raw: Record; + profileIds: string[]; +}; + +function resolveCanonicalApiKeyAliasRepair( + candidate: AuthProfileRepairCandidate, +): CanonicalApiKeyAliasRepair | null { + if (!fs.existsSync(candidate.authPath)) { + return null; + } + const raw = loadJsonFile(candidate.authPath); + if (!isRecord(raw) || !isRecord(raw.profiles)) { + return null; + } + const profileIds: string[] = []; + for (const [profileId, value] of Object.entries(raw.profiles)) { + if (!isRecord(value)) { + continue; + } + const type = readNonEmptyString(value.type) ?? readNonEmptyString(value.mode); + const hasApiKeyField = + readNonEmptyString(value["api_key"]) !== undefined || + coerceSecretRef(value["api_key"]) !== null; + const hasCanonicalKey = + readNonEmptyString(value.key) !== undefined || coerceSecretRef(value.key) !== null; + const hasCanonicalKeyRef = coerceSecretRef(value.keyRef) !== null; + if (type === "api_key" && hasApiKeyField && !hasCanonicalKey && !hasCanonicalKeyRef) { + profileIds.push(profileId); + } + } + return profileIds.length > 0 ? { authPath: candidate.authPath, raw, profileIds } : null; +} + +function backupCanonicalApiKeyAlias(authPath: string, now: () => number): string { + const backupPath = `${authPath}.api-key-alias.${now()}.bak`; + fs.copyFileSync(authPath, backupPath); + return backupPath; +} + +export async function maybeRepairCanonicalApiKeyFieldAlias(params: { + cfg: OpenClawConfig; + prompter: DoctorPrompter; + now?: () => number; + env?: NodeJS.ProcessEnv; +}): Promise { + const now = params.now ?? Date.now; + const env = params.env ?? process.env; + const repairs = listAuthProfileRepairCandidates(params.cfg, env) + .map(resolveCanonicalApiKeyAliasRepair) + .filter((entry): entry is CanonicalApiKeyAliasRepair => entry !== null); + + const result: LegacyFlatAuthProfileRepairResult = { + detected: repairs.map((entry) => entry.authPath), + changes: [], + warnings: [], + }; + if (repairs.length === 0) { + return result; + } + + const noteLines = repairs.map( + (entry) => + `- ${shortenHomePath(entry.authPath)} has ${entry.profileIds.length} profile(s) using the non-canonical "api_key" field; the canonical field is "key".`, + ); + noteLines.push( + `- Runtime auth parsing only reads canonical "key" and "keyRef" fields, so these profiles are silently skipped; ${formatCliCommand("openclaw doctor --fix")} rewrites "api_key" to "key" with a backup.`, + ); + note(noteLines.join("\n"), "Auth profiles"); + + const shouldRepair = await params.prompter.confirmAutoFix({ + message: 'Rewrite non-canonical "api_key" fields to "key" now?', + initialValue: true, + }); + if (!shouldRepair) { + return result; + } + + for (const entry of repairs) { + try { + const backupPath = backupCanonicalApiKeyAlias(entry.authPath, now); + const profiles = entry.raw.profiles as Record>; + for (const profileId of entry.profileIds) { + const profile = profiles[profileId]; + if (!isRecord(profile)) { + continue; + } + profile.key = profile["api_key"]; + delete profile["api_key"]; + } + fs.writeFileSync(entry.authPath, `${JSON.stringify(entry.raw, null, 2)}\n`); + result.changes.push( + `Rewrote ${entry.profileIds.length} "api_key" field(s) to "key" in ${shortenHomePath(entry.authPath)} (backup: ${shortenHomePath(backupPath)}).`, + ); + } catch (err) { + result.warnings.push( + `Failed to rewrite "api_key" fields in ${shortenHomePath(entry.authPath)}: ${String(err)}`, + ); + } + } + clearRuntimeAuthProfileStoreSnapshots(); + if (result.changes.length > 0) { + note(result.changes.map((change) => `- ${change}`).join("\n"), "Doctor changes"); + } + if (result.warnings.length > 0) { + note(result.warnings.map((warning) => `- ${warning}`).join("\n"), "Doctor warnings"); + } + return result; +} diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 75831da4ae14..e3dd36ac154c 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -133,7 +133,7 @@ async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise { - const { maybeRepairLegacyFlatAuthProfileStores } = + const { maybeRepairLegacyFlatAuthProfileStores, maybeRepairCanonicalApiKeyFieldAlias } = await import("../commands/doctor-auth-flat-profiles.js"); const { maybeRepairLegacyOAuthProfileIds } = await import("../commands/doctor-auth-legacy-oauth.js"); @@ -147,6 +147,10 @@ async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise cfg: ctx.cfg, prompter: ctx.prompter, }); + await maybeRepairCanonicalApiKeyFieldAlias({ + cfg: ctx.cfg, + prompter: ctx.prompter, + }); await maybeRepairLegacyOAuthSidecarProfiles({ cfg: ctx.cfg, prompter: ctx.prompter,