Files
openclaw/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts
mudrii 7ecfc1d93c fix(auth): bidirectional mode/type compat + sync OAuth to all agents (#12692)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 2dee8e1174
Co-authored-by: mudrii <220262+mudrii@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-02-20 16:01:09 +05:30

361 lines
10 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;
});
afterEach(async () => {
vi.unstubAllGlobals();
envSnapshot.restore();
await fs.rm(tmpDir, { recursive: true, force: true });
});
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
const secondaryStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "expired-access-token",
refresh: "expired-refresh-token",
expires: expiredTime,
},
},
};
await fs.writeFile(
path.join(secondaryAgentDir, "auth-profiles.json"),
JSON.stringify(secondaryStore),
);
// Write fresh credentials for main agent
const mainStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "fresh-access-token",
refresh: "fresh-refresh-token",
expires: freshTime,
},
},
};
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
// Mock fetch to simulate OAuth refresh failure
const fetchSpy = vi.fn(async () => {
return new Response(JSON.stringify({ error: "invalid_grant" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchSpy);
// Load the secondary agent's store (will merge with main agent's store)
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
// Call resolveApiKeyForProfile with the secondary agent's expired credentials
// This should:
// 1. Try to refresh the expired token (fails due to mocked fetch)
// 2. Fall back to main agent's fresh credentials
// 3. Copy those credentials to the secondary agent
const result = await resolveApiKeyForProfile({
store: loadedSecondaryStore,
profileId,
agentDir: secondaryAgentDir,
});
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;
const secondaryStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "secondary-access-token",
refresh: "secondary-refresh-token",
expires: secondaryExpiry,
},
},
};
await fs.writeFile(
path.join(secondaryAgentDir, "auth-profiles.json"),
JSON.stringify(secondaryStore),
);
const mainStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "main-newer-access-token",
refresh: "main-newer-refresh-token",
expires: mainExpiry,
},
},
};
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
const result = await resolveApiKeyForProfile({
store: loadedSecondaryStore,
profileId,
agentDir: secondaryAgentDir,
});
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;
const secondaryStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "secondary-stale",
refresh: "secondary-refresh",
expires: NaN,
},
},
};
await fs.writeFile(
path.join(secondaryAgentDir, "auth-profiles.json"),
JSON.stringify(secondaryStore),
);
const mainStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "main-fresh-token",
refresh: "main-refresh",
expires: mainExpiry,
},
},
};
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
const result = await resolveApiKeyForProfile({
store: loadedSecondaryStore,
profileId,
agentDir: secondaryAgentDir,
});
expect(result?.apiKey).toBe("main-fresh-token");
});
it("accepts mode=token + type=oauth for legacy compatibility", async () => {
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: "token",
},
},
},
},
store,
profileId,
});
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 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: "api_key",
},
},
},
},
store,
profileId,
});
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: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "expired-access-token",
refresh: "expired-refresh-token",
expires: expiredTime,
},
},
};
await fs.writeFile(
path.join(secondaryAgentDir, "auth-profiles.json"),
JSON.stringify(expiredStore),
);
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(expiredStore));
// Mock fetch to simulate OAuth refresh failure
const fetchSpy = vi.fn(async () => {
return new Response(JSON.stringify({ error: "invalid_grant" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchSpy);
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
// Should throw because both agents have expired credentials
await expect(
resolveApiKeyForProfile({
store: loadedSecondaryStore,
profileId,
agentDir: secondaryAgentDir,
}),
).rejects.toThrow(/OAuth token refresh failed/);
});
});