fix(qa): preserve corrupt auth profile files

This commit is contained in:
Peter Steinberger
2026-05-26 02:49:34 +01:00
parent cb34175dfd
commit efebf6bfcf
2 changed files with 422 additions and 6 deletions

View File

@@ -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<string> {
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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
};
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<string, unknown>;
};
expect(written.profiles?.existing).toEqual({
mode: "api_key",
provider: "openai",
apiKey: "qa-existing-key",
});
});
});

View File

@@ -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<string, QaAuthProfileCredential>;
}): Promise<void> {
const authPath = path.join(params.agentDir, "auth-profiles.json");
const existing = await fs
.readFile(authPath, "utf8")
.then((raw) => JSON.parse(raw) as { profiles?: Record<string, QaAuthProfileCredential> })
.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<string, QaAuthProfileCredential> }> {
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<string, QaAuthProfileCredential> } {
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<string, QaAuthProfileCredential> };
}
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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}