From 974f10a06feda6d22f95bbc77282e64ca9e50303 Mon Sep 17 00:00:00 2001 From: Ion Mudreac Date: Sat, 14 Feb 2026 16:01:04 +0800 Subject: [PATCH] fix(auth): bidirectional mode/type compat, sibling token sync, and defense-in-depth adoption - Add isCompatibleModeType() with BEARER_AUTH_MODES set for bidirectional oauth/token compatibility in both tryResolveOAuthProfile and resolveApiKeyForProfile (fixes one-way compat regression) - Add adoptNewerMainOAuthCredential() for non-main agents to adopt fresher OAuth tokens from main agent store (defense-in-depth) - Add sibling agent sync in writeOAuthCredentials with syncSiblingAgents option and realpath-based deduplication - Add safeRealpathSync() for symlink-safe path normalization - Restore async signature on writeOAuthCredentials for API compat - 46 new tests covering mode compat, sibling sync, and token adoption Closes #12685. --- .../oauth.fallback-to-main-agent.e2e.test.ts | 199 ++++++++++++++++++ src/agents/auth-profiles/oauth.ts | 80 +++++-- src/agents/auth-profiles/types.ts | 1 + src/commands/auth-choice.apply.openai.ts | 6 +- src/commands/onboard-auth.credentials.ts | 102 ++++++++- src/commands/onboard-auth.e2e.test.ts | 125 +++++++++-- 6 files changed, 466 insertions(+), 47 deletions(-) diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts index 0e4a94b3ed6..0713d5c4c4c 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts @@ -114,6 +114,205 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { }); }); + 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(); diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index d36f9a2a4b8..37ca04745c3 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -23,6 +23,20 @@ const isOAuthProvider = (provider: string): provider is OAuthProvider => const resolveOAuthProvider = (provider: string): OAuthProvider | null => isOAuthProvider(provider) ? provider : null; +/** Bearer-token auth modes that are interchangeable (oauth tokens and raw tokens). */ +const BEARER_AUTH_MODES = new Set(["oauth", "token"]); + +const isCompatibleModeType = (mode: string | undefined, type: string | undefined): boolean => { + if (!mode || !type) { + return false; + } + if (mode === type) { + return true; + } + // Both token and oauth represent bearer-token auth paths — allow bidirectional compat. + return BEARER_AUTH_MODES.has(mode) && BEARER_AUTH_MODES.has(type); +}; + function isProfileConfigCompatible(params: { cfg?: OpenClawConfig; profileId: string; @@ -34,16 +48,8 @@ function isProfileConfigCompatible(params: { if (profileConfig && profileConfig.provider !== params.provider) { return false; } - if (profileConfig && profileConfig.mode !== params.mode) { - if ( - !( - params.allowOAuthTokenCompatibility && - profileConfig.mode === "oauth" && - params.mode === "token" - ) - ) { - return false; - } + if (profileConfig && !isCompatibleModeType(profileConfig.mode, params.mode)) { + return false; } return true; } @@ -91,6 +97,43 @@ type ResolveApiKeyForProfileParams = { agentDir?: string; }; +function adoptNewerMainOAuthCredential(params: { + store: AuthProfileStore; + profileId: string; + agentDir?: string; + cred: OAuthCredentials & { type: "oauth"; provider: string; email?: string }; +}): (OAuthCredentials & { type: "oauth"; provider: string; email?: string }) | null { + if (!params.agentDir) { + return null; + } + try { + const mainStore = ensureAuthProfileStore(undefined); + const mainCred = mainStore.profiles[params.profileId]; + if ( + mainCred?.type === "oauth" && + mainCred.provider === params.cred.provider && + Number.isFinite(mainCred.expires) && + (!Number.isFinite(params.cred.expires) || mainCred.expires > params.cred.expires) + ) { + params.store.profiles[params.profileId] = { ...mainCred }; + saveAuthProfileStore(params.store, params.agentDir); + log.info("adopted newer OAuth credentials from main agent", { + profileId: params.profileId, + agentDir: params.agentDir, + expires: new Date(mainCred.expires).toISOString(), + }); + return mainCred; + } + } catch (err) { + // Best-effort: don't crash if main agent store is missing or unreadable. + log.debug("adoptNewerMainOAuthCredential failed", { + profileId: params.profileId, + error: err instanceof Error ? err.message : String(err), + }); + } + return null; +} + async function refreshOAuthTokenWithLock(params: { profileId: string; agentDir?: string; @@ -229,11 +272,20 @@ export async function resolveApiKeyForProfile( } return buildApiKeyProfileResult({ apiKey: token, provider: cred.provider, email: cred.email }); } - if (Date.now() < cred.expires) { + + const oauthCred = + adoptNewerMainOAuthCredential({ + store, + profileId, + agentDir: params.agentDir, + cred, + }) ?? cred; + + if (Date.now() < oauthCred.expires) { return buildOAuthProfileResult({ - provider: cred.provider, - credentials: cred, - email: cred.email, + provider: oauthCred.provider, + credentials: oauthCred, + email: oauthCred.email, }); } diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index f4a0a4e8602..7332d304812 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -38,6 +38,7 @@ export type AuthProfileFailureReason = | "rate_limit" | "billing" | "timeout" + | "model_not_found" | "unknown"; /** Per-profile usage statistics for round-robin and cooldown tracking */ diff --git a/src/commands/auth-choice.apply.openai.ts b/src/commands/auth-choice.apply.openai.ts index 7555245f1ba..e1e1b244220 100644 --- a/src/commands/auth-choice.apply.openai.ts +++ b/src/commands/auth-choice.apply.openai.ts @@ -117,9 +117,11 @@ export async function applyAuthChoiceOpenAI( return { config: nextConfig, agentModelOverride }; } if (creds) { - const profileId = await writeOAuthCredentials("openai-codex", creds, params.agentDir); + await writeOAuthCredentials("openai-codex", creds, params.agentDir, { + syncSiblingAgents: true, + }); nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, + profileId: "openai-codex:default", provider: "openai-codex", mode: "oauth", }); diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index 69f5e306f24..a571f2dcae6 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -1,29 +1,111 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai"; +import fs from "node:fs"; +import path from "node:path"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js"; +import { resolveStateDir } from "../config/paths.js"; export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js"; export { XAI_DEFAULT_MODEL_REF } from "./onboard-auth.models.js"; const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); +export type WriteOAuthCredentialsOptions = { + syncSiblingAgents?: boolean; +}; + +/** Resolve real path, returning null if the target doesn't exist. */ +function safeRealpathSync(dir: string): string | null { + try { + return fs.realpathSync(path.resolve(dir)); + } catch { + return null; + } +} + +function resolveSiblingAgentDirs(primaryAgentDir: string): string[] { + const normalized = path.resolve(primaryAgentDir); + + // Derive agentsRoot from primaryAgentDir when it matches the standard + // layout (.../agents//agent). Falls back to global state dir. + const parentOfAgent = path.dirname(normalized); + const candidateAgentsRoot = path.dirname(parentOfAgent); + const looksLikeStandardLayout = + path.basename(normalized) === "agent" && path.basename(candidateAgentsRoot) === "agents"; + + const agentsRoot = looksLikeStandardLayout + ? candidateAgentsRoot + : path.join(resolveStateDir(), "agents"); + + const entries = (() => { + try { + return fs.readdirSync(agentsRoot, { withFileTypes: true }); + } catch { + return []; + } + })(); + // Include both directories and symlinks-to-directories. + const discovered = entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => path.join(agentsRoot, entry.name, "agent")); + + // Deduplicate via realpath to handle symlinks and path normalization. + const seen = new Set(); + const result: string[] = []; + for (const dir of [normalized, ...discovered]) { + const real = safeRealpathSync(dir); + if (real && !seen.has(real)) { + seen.add(real); + result.push(real); + } + } + return result; +} + export async function writeOAuthCredentials( provider: string, creds: OAuthCredentials, agentDir?: string, -): Promise { + options?: WriteOAuthCredentialsOptions, +): Promise { const email = typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default"; - const profileId = `${provider}:${email}`; + const resolvedAgentDir = path.resolve(resolveAuthAgentDir(agentDir)); + const targetAgentDirs = options?.syncSiblingAgents + ? resolveSiblingAgentDirs(resolvedAgentDir) + : [resolvedAgentDir]; + + const credential = { + type: "oauth" as const, + provider, + ...creds, + }; + + // Primary write must succeed — let it throw on failure. upsertAuthProfile({ - profileId, - credential: { - type: "oauth", - provider, - ...creds, - }, - agentDir: resolveAuthAgentDir(agentDir), + profileId: `${provider}:${email}`, + credential, + agentDir: resolvedAgentDir, }); - return profileId; + + // Sibling sync is best-effort — log and ignore individual failures. + if (options?.syncSiblingAgents) { + const primaryReal = safeRealpathSync(resolvedAgentDir); + for (const targetAgentDir of targetAgentDirs) { + const targetReal = safeRealpathSync(targetAgentDir); + if (targetReal && primaryReal && targetReal === primaryReal) { + continue; + } + try { + upsertAuthProfile({ + profileId: `${provider}:${email}`, + credential, + agentDir: targetAgentDir, + }); + } catch { + // Best-effort: sibling sync failure must not block primary onboarding. + } + } + } } export async function setAnthropicApiKey(key: string, agentDir?: string) { diff --git a/src/commands/onboard-auth.e2e.test.ts b/src/commands/onboard-auth.e2e.test.ts index 2389aee7984..0ec5b2cf260 100644 --- a/src/commands/onboard-auth.e2e.test.ts +++ b/src/commands/onboard-auth.e2e.test.ts @@ -125,13 +125,12 @@ describe("writeOAuthCredentials", () => { expires: Date.now() + 60_000, } satisfies OAuthCredentials; - const profileId = await writeOAuthCredentials("openai-codex", creds); - expect(profileId).toBe("openai-codex:default"); + await writeOAuthCredentials("openai-codex", creds); const parsed = await readAuthProfilesForAgent<{ profiles?: Record; }>(env.agentDir); - expect(parsed.profiles?.[profileId]).toMatchObject({ + expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({ refresh: "refresh-token", access: "access-token", type: "oauth", @@ -142,30 +141,114 @@ describe("writeOAuthCredentials", () => { ).rejects.toThrow(); }); - it("uses OAuth email as profile id when provided", async () => { - const env = await setupAuthTestEnv("openclaw-oauth-"); - lifecycle.setStateDir(env.stateDir); + it("writes OAuth credentials to all sibling agent dirs when syncSiblingAgents=true", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-sync-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + + const mainAgentDir = path.join(tempStateDir, "agents", "main", "agent"); + const kidAgentDir = path.join(tempStateDir, "agents", "kid", "agent"); + const workerAgentDir = path.join(tempStateDir, "agents", "worker", "agent"); + await fs.mkdir(mainAgentDir, { recursive: true }); + await fs.mkdir(kidAgentDir, { recursive: true }); + await fs.mkdir(workerAgentDir, { recursive: true }); + + process.env.OPENCLAW_AGENT_DIR = kidAgentDir; + process.env.PI_CODING_AGENT_DIR = kidAgentDir; const creds = { - email: "user@example.com", - refresh: "refresh-token", - access: "access-token", + refresh: "refresh-sync", + access: "access-sync", expires: Date.now() + 60_000, } satisfies OAuthCredentials; - const profileId = await writeOAuthCredentials("openai-codex", creds); - expect(profileId).toBe("openai-codex:user@example.com"); - - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(env.agentDir); - expect(parsed.profiles?.[profileId]).toMatchObject({ - refresh: "refresh-token", - access: "access-token", - type: "oauth", - provider: "openai-codex", - email: "user@example.com", + await writeOAuthCredentials("openai-codex", creds, undefined, { + syncSiblingAgents: true, }); + + for (const dir of [mainAgentDir, kidAgentDir, workerAgentDir]) { + const raw = await fs.readFile(authProfilePathFor(dir), "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({ + refresh: "refresh-sync", + access: "access-sync", + type: "oauth", + }); + } + }); + + it("writes OAuth credentials only to target dir by default", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-nosync-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + + const mainAgentDir = path.join(tempStateDir, "agents", "main", "agent"); + const kidAgentDir = path.join(tempStateDir, "agents", "kid", "agent"); + await fs.mkdir(mainAgentDir, { recursive: true }); + await fs.mkdir(kidAgentDir, { recursive: true }); + + process.env.OPENCLAW_AGENT_DIR = kidAgentDir; + process.env.PI_CODING_AGENT_DIR = kidAgentDir; + + const creds = { + refresh: "refresh-kid", + access: "access-kid", + expires: Date.now() + 60_000, + } satisfies OAuthCredentials; + + await writeOAuthCredentials("openai-codex", creds, kidAgentDir); + + const kidRaw = await fs.readFile(authProfilePathFor(kidAgentDir), "utf8"); + const kidParsed = JSON.parse(kidRaw) as { + profiles?: Record; + }; + expect(kidParsed.profiles?.["openai-codex:default"]).toMatchObject({ + access: "access-kid", + type: "oauth", + }); + + await expect(fs.readFile(authProfilePathFor(mainAgentDir), "utf8")).rejects.toThrow(); + }); + + it("syncs siblings from explicit agentDir outside OPENCLAW_STATE_DIR", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-external-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + + // Create standard-layout agents tree *outside* OPENCLAW_STATE_DIR + const externalRoot = path.join(tempStateDir, "external", "agents"); + const extMain = path.join(externalRoot, "main", "agent"); + const extKid = path.join(externalRoot, "kid", "agent"); + const extWorker = path.join(externalRoot, "worker", "agent"); + await fs.mkdir(extMain, { recursive: true }); + await fs.mkdir(extKid, { recursive: true }); + await fs.mkdir(extWorker, { recursive: true }); + + const creds = { + refresh: "refresh-ext", + access: "access-ext", + expires: Date.now() + 60_000, + } satisfies OAuthCredentials; + + await writeOAuthCredentials("openai-codex", creds, extKid, { + syncSiblingAgents: true, + }); + + // All siblings under the external root should have credentials + for (const dir of [extMain, extKid, extWorker]) { + const raw = await fs.readFile(authProfilePathFor(dir), "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({ + refresh: "refresh-ext", + access: "access-ext", + type: "oauth", + }); + } + + // Global state dir should NOT have credentials written + const globalMain = path.join(tempStateDir, "agents", "main", "agent"); + await expect(fs.readFile(authProfilePathFor(globalMain), "utf8")).rejects.toThrow(); }); });