From efebf6bfcfa89f22e7c154db4d2718c409ada335 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 02:49:34 +0100 Subject: [PATCH] fix(qa): preserve corrupt auth profile files --- .../src/providers/shared/auth-store.test.ts | 275 ++++++++++++++++++ .../qa-lab/src/providers/shared/auth-store.ts | 153 +++++++++- 2 files changed, 422 insertions(+), 6 deletions(-) create mode 100644 extensions/qa-lab/src/providers/shared/auth-store.test.ts diff --git a/extensions/qa-lab/src/providers/shared/auth-store.test.ts b/extensions/qa-lab/src/providers/shared/auth-store.test.ts new file mode 100644 index 00000000000..0abf1cbfee9 --- /dev/null +++ b/extensions/qa-lab/src/providers/shared/auth-store.test.ts @@ -0,0 +1,275 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { writeQaAuthProfiles } from "./auth-store.js"; + +const tempDirs: string[] = []; + +async function createTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qa-auth-store-")); + tempDirs.push(dir); + return dir; +} + +describe("QA auth profile store", () => { + afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it("writes a new auth profile file when none exists", async () => { + const agentDir = await createTempDir(); + + await writeQaAuthProfiles({ + agentDir, + profiles: { + "qa-mock-openai": { + type: "api_key", + provider: "openai", + key: "qa-mock-not-a-real-key", + }, + }, + }); + + await expect(fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8")).resolves.toContain( + "qa-mock-openai", + ); + }); + + it("does not replace corrupt auth profile files", async () => { + const agentDir = await createTempDir(); + const authPath = path.join(agentDir, "auth-profiles.json"); + await fs.writeFile(authPath, "{not-json", "utf8"); + + await expect( + writeQaAuthProfiles({ + agentDir, + profiles: { + "qa-mock-openai": { + type: "api_key", + provider: "openai", + key: "qa-mock-not-a-real-key", + }, + }, + }), + ).rejects.toThrow(); + await expect(fs.readFile(authPath, "utf8")).resolves.toBe("{not-json"); + }); + + it("does not merge malformed auth profile shapes", async () => { + const agentDir = await createTempDir(); + const authPath = path.join(agentDir, "auth-profiles.json"); + const original = JSON.stringify({ version: 1, profiles: { broken: "token" } }); + await fs.writeFile(authPath, original, "utf8"); + + await expect( + writeQaAuthProfiles({ + agentDir, + profiles: { + "qa-mock-openai": { + type: "api_key", + provider: "openai", + key: "qa-mock-not-a-real-key", + }, + }, + }), + ).rejects.toThrow("Invalid QA auth profiles file"); + await expect(fs.readFile(authPath, "utf8")).resolves.toBe(original); + }); + + it("preserves existing ref-backed auth profile shapes", async () => { + const agentDir = await createTempDir(); + const authPath = path.join(agentDir, "auth-profiles.json"); + await fs.writeFile( + authPath, + `${JSON.stringify({ + version: 1, + profiles: { + existing: { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }, + })}\n`, + "utf8", + ); + + await writeQaAuthProfiles({ + agentDir, + profiles: { + "qa-mock-anthropic": { + type: "api_key", + provider: "anthropic", + key: "qa-mock-not-a-real-key", + }, + }, + }); + + const written = JSON.parse(await fs.readFile(authPath, "utf8")) as { + profiles?: Record; + }; + expect(written.profiles?.existing).toEqual({ + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }); + expect(written.profiles?.["qa-mock-anthropic"]).toMatchObject({ + type: "api_key", + provider: "anthropic", + }); + }); + + it("preserves existing token and oauth auth profile shapes", async () => { + const agentDir = await createTempDir(); + const authPath = path.join(agentDir, "auth-profiles.json"); + await fs.writeFile( + authPath, + `${JSON.stringify({ + version: 1, + profiles: { + tokenProfile: { + type: "token", + provider: "github", + token: { source: "file", provider: "vault", id: "github/token" }, + }, + oauthProfile: { + type: "oauth", + provider: "chatgpt", + access: "qa-access-token", + refresh: "qa-refresh-token", + expires: 1_900_000_000_000, + }, + legacyOAuthProfile: { + type: "oauth", + provider: "openai-codex", + expires: 1_900_000_000_000, + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + id: "0123456789abcdef0123456789abcdef", + }, + }, + }, + })}\n`, + "utf8", + ); + + await writeQaAuthProfiles({ + agentDir, + profiles: { + "qa-mock-openai": { + type: "api_key", + provider: "openai", + key: "qa-mock-not-a-real-key", + }, + }, + }); + + const written = JSON.parse(await fs.readFile(authPath, "utf8")) as { + profiles?: Record; + }; + expect(written.profiles?.tokenProfile).toEqual({ + type: "token", + provider: "github", + token: { source: "file", provider: "vault", id: "github/token" }, + }); + expect(written.profiles?.oauthProfile).toEqual({ + type: "oauth", + provider: "chatgpt", + access: "qa-access-token", + refresh: "qa-refresh-token", + expires: 1_900_000_000_000, + }); + expect(written.profiles?.legacyOAuthProfile).toEqual({ + type: "oauth", + provider: "openai-codex", + expires: 1_900_000_000_000, + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + id: "0123456789abcdef0123456789abcdef", + }, + }); + }); + + it("preserves existing providerless secret refs", async () => { + const agentDir = await createTempDir(); + const authPath = path.join(agentDir, "auth-profiles.json"); + await fs.writeFile( + authPath, + `${JSON.stringify({ + version: 1, + profiles: { + existing: { + type: "api_key", + provider: "openai", + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }, + }, + })}\n`, + "utf8", + ); + + await writeQaAuthProfiles({ + agentDir, + profiles: { + "qa-mock-anthropic": { + type: "api_key", + provider: "anthropic", + key: "qa-mock-not-a-real-key", + }, + }, + }); + + const written = JSON.parse(await fs.readFile(authPath, "utf8")) as { + profiles?: Record; + }; + expect(written.profiles?.existing).toEqual({ + type: "api_key", + provider: "openai", + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }); + }); + + it("preserves existing legacy api key alias profiles", async () => { + const agentDir = await createTempDir(); + const authPath = path.join(agentDir, "auth-profiles.json"); + await fs.writeFile( + authPath, + `${JSON.stringify({ + version: 1, + profiles: { + existing: { + mode: "api_key", + provider: "openai", + apiKey: "qa-existing-key", + }, + }, + })}\n`, + "utf8", + ); + + await writeQaAuthProfiles({ + agentDir, + profiles: { + "qa-mock-anthropic": { + type: "api_key", + provider: "anthropic", + key: "qa-mock-not-a-real-key", + }, + }, + }); + + const written = JSON.parse(await fs.readFile(authPath, "utf8")) as { + profiles?: Record; + }; + expect(written.profiles?.existing).toEqual({ + mode: "api_key", + provider: "openai", + apiKey: "qa-existing-key", + }); + }); +}); diff --git a/extensions/qa-lab/src/providers/shared/auth-store.ts b/extensions/qa-lab/src/providers/shared/auth-store.ts index 29195750873..8c3d865ef09 100644 --- a/extensions/qa-lab/src/providers/shared/auth-store.ts +++ b/extensions/qa-lab/src/providers/shared/auth-store.ts @@ -5,15 +5,44 @@ type QaAuthProfileCredential = | { type: "api_key"; provider: string; - key: string; + key?: string; + keyRef?: QaSecretRef; displayName?: string; } | { type: "token"; provider: string; - token: string; + token?: string; + tokenRef?: QaSecretRef; + expires?: number; + } + | { + type: "oauth"; + provider: string; + access?: string; + refresh?: string; + expires?: number; + idToken?: string; + clientId?: string; + enterpriseUrl?: string; + projectId?: string; + accountId?: string; + chatgptPlanType?: string; + oauthRef?: QaLegacyOAuthRef; }; +type QaSecretRef = { + source: "env" | "file" | "exec"; + provider?: string; + id: string; +}; + +type QaLegacyOAuthRef = { + source: "openclaw-credentials"; + provider: "openai-codex"; + id: string; +}; + export function resolveQaAgentAuthDir(params: { stateDir: string; agentId: string }): string { return path.join(params.stateDir, "agents", params.agentId, "agent"); } @@ -23,10 +52,7 @@ export async function writeQaAuthProfiles(params: { profiles: Record; }): Promise { const authPath = path.join(params.agentDir, "auth-profiles.json"); - const existing = await fs - .readFile(authPath, "utf8") - .then((raw) => JSON.parse(raw) as { profiles?: Record }) - .catch(() => ({ profiles: {} })); + const existing = await readExistingQaAuthProfiles(authPath); await fs.mkdir(params.agentDir, { recursive: true }); await fs.writeFile( authPath, @@ -34,3 +60,118 @@ export async function writeQaAuthProfiles(params: { "utf8", ); } + +async function readExistingQaAuthProfiles( + authPath: string, +): Promise<{ profiles?: Record }> { + try { + const raw = await fs.readFile(authPath, "utf8"); + return parseQaAuthProfiles(raw); + } catch (err) { + if (err && typeof err === "object" && (err as { code?: unknown }).code === "ENOENT") { + return { profiles: {} }; + } + throw err; + } +} + +function parseQaAuthProfiles(raw: string): { profiles?: Record } { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Invalid QA auth profiles file"); + } + const profiles = (parsed as { profiles?: unknown }).profiles; + if (profiles === undefined) { + return {}; + } + if (!profiles || typeof profiles !== "object" || Array.isArray(profiles)) { + throw new Error("Invalid QA auth profiles file"); + } + for (const value of Object.values(profiles)) { + if (!isQaAuthProfileRecord(value)) { + throw new Error("Invalid QA auth profiles file"); + } + } + return { profiles: profiles as Record }; +} + +function isQaAuthProfileRecord(value: unknown): value is QaAuthProfileCredential { + if (!isRecord(value) || typeof value.provider !== "string" || !value.provider.trim()) { + return false; + } + const credentialType = typeof value.type === "string" ? value.type : value.mode; + switch (credentialType) { + case "api_key": + return ( + isQaSecretInput(value.key) && + isQaSecretInput(value.apiKey) && + isOptionalQaSecretRef(value.keyRef) + ); + case "token": + return ( + isQaSecretInput(value.token) && + isOptionalQaSecretRef(value.tokenRef) && + isOptionalFiniteNumber(value.expires) + ); + case "oauth": + return ( + isOptionalString(value.access) && + isOptionalString(value.refresh) && + isOptionalString(value.idToken) && + isOptionalString(value.clientId) && + isOptionalString(value.enterpriseUrl) && + isOptionalString(value.projectId) && + isOptionalString(value.accountId) && + isOptionalString(value.chatgptPlanType) && + isOptionalFiniteNumber(value.expires) && + isOptionalLegacyOAuthRef(value.oauthRef) + ); + default: + return false; + } +} + +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +function isOptionalFiniteNumber(value: unknown): boolean { + return value === undefined || (typeof value === "number" && Number.isFinite(value)); +} + +function isOptionalQaSecretRef(value: unknown): boolean { + return value === undefined || isQaSecretRef(value); +} + +function isQaSecretInput(value: unknown): boolean { + return value === undefined || typeof value === "string" || isQaSecretRef(value); +} + +function isQaSecretRef(value: unknown): value is QaSecretRef { + return ( + isRecord(value) && + (value.source === "env" || value.source === "file" || value.source === "exec") && + (value.provider === undefined || + (typeof value.provider === "string" && value.provider.trim().length > 0)) && + typeof value.id === "string" && + value.id.trim().length > 0 + ); +} + +function isOptionalLegacyOAuthRef(value: unknown): boolean { + return value === undefined || isQaLegacyOAuthRef(value); +} + +function isQaLegacyOAuthRef(value: unknown): value is QaLegacyOAuthRef { + return ( + isRecord(value) && + value.source === "openclaw-credentials" && + value.provider === "openai-codex" && + typeof value.id === "string" && + /^[a-f0-9]{32}$/.test(value.id) + ); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +}