mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 00:52:57 +00:00
fix(qa): preserve corrupt auth profile files
This commit is contained in:
275
extensions/qa-lab/src/providers/shared/auth-store.test.ts
Normal file
275
extensions/qa-lab/src/providers/shared/auth-store.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user