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>
This commit is contained in:
alkor2000
2026-05-28 07:29:28 +08:00
committed by GitHub
parent b5bd6e8828
commit 603aa8a2ed
3 changed files with 462 additions and 10 deletions
@@ -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<OpenClawTestState> {
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);
});
});
+131 -9
View File
@@ -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<string, AuthProfileRepairCandidate>();
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<LegacyFlatAuthProfileRepairResult> {
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<string, unknown>;
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<LegacyFlatAuthProfileRepairResult> {
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<string, Record<string, unknown>>;
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;
}
+5 -1
View File
@@ -133,7 +133,7 @@ async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise<voi
}
async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void> {
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<void>
cfg: ctx.cfg,
prompter: ctx.prompter,
});
await maybeRepairCanonicalApiKeyFieldAlias({
cfg: ctx.cfg,
prompter: ctx.prompter,
});
await maybeRepairLegacyOAuthSidecarProfiles({
cfg: ctx.cfg,
prompter: ctx.prompter,