305 lines
8.8 KiB
TypeScript
305 lines
8.8 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { captureEnv } from "../../test-utils/env.js";
|
|
import { resolveApiKeyForProfile } from "./oauth.js";
|
|
import { ensureAuthProfileStore } from "./store.js";
|
|
import type { AuthProfileStore } from "./types.js";
|
|
|
|
describe("resolveApiKeyForProfile fallback to main agent", () => {
|
|
const envSnapshot = captureEnv([
|
|
"OPENCLAW_STATE_DIR",
|
|
"OPENCLAW_AGENT_DIR",
|
|
"PI_CODING_AGENT_DIR",
|
|
]);
|
|
let tmpDir: string;
|
|
let mainAgentDir: string;
|
|
let secondaryAgentDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-fallback-test-"));
|
|
mainAgentDir = path.join(tmpDir, "agents", "main", "agent");
|
|
secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent");
|
|
await fs.mkdir(mainAgentDir, { recursive: true });
|
|
await fs.mkdir(secondaryAgentDir, { recursive: true });
|
|
|
|
// Set environment variables so resolveOpenClawAgentDir() returns mainAgentDir
|
|
process.env.OPENCLAW_STATE_DIR = tmpDir;
|
|
process.env.OPENCLAW_AGENT_DIR = mainAgentDir;
|
|
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
|
|
});
|
|
|
|
function createOauthStore(params: {
|
|
profileId: string;
|
|
access: string;
|
|
refresh: string;
|
|
expires: number;
|
|
provider?: string;
|
|
}): AuthProfileStore {
|
|
return {
|
|
version: 1,
|
|
profiles: {
|
|
[params.profileId]: {
|
|
type: "oauth",
|
|
provider: params.provider ?? "anthropic",
|
|
access: params.access,
|
|
refresh: params.refresh,
|
|
expires: params.expires,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function writeAuthProfilesStore(agentDir: string, store: AuthProfileStore) {
|
|
await fs.writeFile(path.join(agentDir, "auth-profiles.json"), JSON.stringify(store));
|
|
}
|
|
|
|
function stubOAuthRefreshFailure() {
|
|
const fetchSpy = vi.fn(async () => {
|
|
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
});
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
}
|
|
|
|
async function resolveFromSecondaryAgent(profileId: string) {
|
|
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
|
|
return resolveApiKeyForProfile({
|
|
store: loadedSecondaryStore,
|
|
profileId,
|
|
agentDir: secondaryAgentDir,
|
|
});
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
|
|
envSnapshot.restore();
|
|
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function resolveOauthProfileForConfiguredMode(mode: "token" | "api_key") {
|
|
const profileId = "anthropic:default";
|
|
const store: AuthProfileStore = {
|
|
version: 1,
|
|
profiles: {
|
|
[profileId]: {
|
|
type: "oauth",
|
|
provider: "anthropic",
|
|
access: "oauth-token",
|
|
refresh: "refresh-token",
|
|
expires: Date.now() + 60_000,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await resolveApiKeyForProfile({
|
|
cfg: {
|
|
auth: {
|
|
profiles: {
|
|
[profileId]: {
|
|
provider: "anthropic",
|
|
mode,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
store,
|
|
profileId,
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => {
|
|
const profileId = "anthropic:claude-cli";
|
|
const now = Date.now();
|
|
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
|
|
const freshTime = now + 60 * 60 * 1000; // 1 hour from now
|
|
|
|
// Write expired credentials for secondary agent
|
|
await writeAuthProfilesStore(
|
|
secondaryAgentDir,
|
|
createOauthStore({
|
|
profileId,
|
|
access: "expired-access-token",
|
|
refresh: "expired-refresh-token",
|
|
expires: expiredTime,
|
|
}),
|
|
);
|
|
|
|
// Write fresh credentials for main agent
|
|
await writeAuthProfilesStore(
|
|
mainAgentDir,
|
|
createOauthStore({
|
|
profileId,
|
|
access: "fresh-access-token",
|
|
refresh: "fresh-refresh-token",
|
|
expires: freshTime,
|
|
}),
|
|
);
|
|
|
|
// Mock fetch to simulate OAuth refresh failure
|
|
stubOAuthRefreshFailure();
|
|
|
|
// Load the secondary agent's store (will merge with main agent's store)
|
|
// Call resolveApiKeyForProfile with the secondary agent's expired credentials:
|
|
// refresh fails, then fallback copies main credentials to secondary.
|
|
const result = await resolveFromSecondaryAgent(profileId);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.apiKey).toBe("fresh-access-token");
|
|
expect(result?.provider).toBe("anthropic");
|
|
|
|
// Verify the credentials were copied to the secondary agent
|
|
const updatedSecondaryStore = JSON.parse(
|
|
await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
|
|
) as AuthProfileStore;
|
|
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
|
|
access: "fresh-access-token",
|
|
expires: freshTime,
|
|
});
|
|
});
|
|
|
|
it("adopts newer OAuth token from main agent even when secondary token is still valid", async () => {
|
|
const profileId = "anthropic:claude-cli";
|
|
const now = Date.now();
|
|
const secondaryExpiry = now + 30 * 60 * 1000;
|
|
const mainExpiry = now + 2 * 60 * 60 * 1000;
|
|
|
|
await writeAuthProfilesStore(
|
|
secondaryAgentDir,
|
|
createOauthStore({
|
|
profileId,
|
|
access: "secondary-access-token",
|
|
refresh: "secondary-refresh-token",
|
|
expires: secondaryExpiry,
|
|
}),
|
|
);
|
|
|
|
await writeAuthProfilesStore(
|
|
mainAgentDir,
|
|
createOauthStore({
|
|
profileId,
|
|
access: "main-newer-access-token",
|
|
refresh: "main-newer-refresh-token",
|
|
expires: mainExpiry,
|
|
}),
|
|
);
|
|
|
|
const result = await resolveFromSecondaryAgent(profileId);
|
|
|
|
expect(result?.apiKey).toBe("main-newer-access-token");
|
|
|
|
const updatedSecondaryStore = JSON.parse(
|
|
await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
|
|
) as AuthProfileStore;
|
|
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
|
|
access: "main-newer-access-token",
|
|
expires: mainExpiry,
|
|
});
|
|
});
|
|
|
|
it("adopts main token when secondary expires is NaN/malformed", async () => {
|
|
const profileId = "anthropic:claude-cli";
|
|
const now = Date.now();
|
|
const mainExpiry = now + 2 * 60 * 60 * 1000;
|
|
|
|
await writeAuthProfilesStore(
|
|
secondaryAgentDir,
|
|
createOauthStore({
|
|
profileId,
|
|
access: "secondary-stale",
|
|
refresh: "secondary-refresh",
|
|
expires: NaN,
|
|
}),
|
|
);
|
|
|
|
await writeAuthProfilesStore(
|
|
mainAgentDir,
|
|
createOauthStore({
|
|
profileId,
|
|
access: "main-fresh-token",
|
|
refresh: "main-refresh",
|
|
expires: mainExpiry,
|
|
}),
|
|
);
|
|
|
|
const result = await resolveFromSecondaryAgent(profileId);
|
|
|
|
expect(result?.apiKey).toBe("main-fresh-token");
|
|
});
|
|
|
|
it("accepts mode=token + type=oauth for legacy compatibility", async () => {
|
|
const result = await resolveOauthProfileForConfiguredMode("token");
|
|
|
|
expect(result?.apiKey).toBe("oauth-token");
|
|
});
|
|
|
|
it("accepts mode=oauth + type=token (regression)", async () => {
|
|
const profileId = "anthropic:default";
|
|
const store: AuthProfileStore = {
|
|
version: 1,
|
|
profiles: {
|
|
[profileId]: {
|
|
type: "token",
|
|
provider: "anthropic",
|
|
token: "static-token",
|
|
expires: Date.now() + 60_000,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await resolveApiKeyForProfile({
|
|
cfg: {
|
|
auth: {
|
|
profiles: {
|
|
[profileId]: {
|
|
provider: "anthropic",
|
|
mode: "oauth",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
store,
|
|
profileId,
|
|
});
|
|
|
|
expect(result?.apiKey).toBe("static-token");
|
|
});
|
|
|
|
it("rejects true mode/type mismatches", async () => {
|
|
const result = await resolveOauthProfileForConfiguredMode("api_key");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("throws error when both secondary and main agent credentials are expired", async () => {
|
|
const profileId = "anthropic:claude-cli";
|
|
const now = Date.now();
|
|
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
|
|
|
|
// Write expired credentials for both agents
|
|
const expiredStore = createOauthStore({
|
|
profileId,
|
|
access: "expired-access-token",
|
|
refresh: "expired-refresh-token",
|
|
expires: expiredTime,
|
|
});
|
|
await writeAuthProfilesStore(secondaryAgentDir, expiredStore);
|
|
await writeAuthProfilesStore(mainAgentDir, expiredStore);
|
|
|
|
// Mock fetch to simulate OAuth refresh failure
|
|
stubOAuthRefreshFailure();
|
|
|
|
// Should throw because both agents have expired credentials
|
|
await expect(resolveFromSecondaryAgent(profileId)).rejects.toThrow(
|
|
/OAuth token refresh failed/,
|
|
);
|
|
});
|
|
});
|