diff --git a/CHANGELOG.md b/CHANGELOG.md index e1ef9e7aec9..a6f50e96d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet. - Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant. - Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd. +- Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii. - Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek. - Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0. - Anthropic/Agents: preserve required pi-ai default OAuth beta headers when `context1m` injects `anthropic-beta`, preventing 401 auth failures for `sk-ant-oat-*` tokens. (#19789, fixes #19769) Thanks @minupla. 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.test.ts b/src/agents/auth-profiles/oauth.test.ts index aeb936d275e..60c112aef68 100644 --- a/src/agents/auth-profiles/oauth.test.ts +++ b/src/agents/auth-profiles/oauth.test.ts @@ -60,7 +60,7 @@ describe("resolveApiKeyForProfile config compatibility", () => { expect(result).toBeNull(); }); - it("rejects oauth credentials when config mode is token", async () => { + it("accepts oauth credentials when config mode is token (bidirectional compat)", async () => { const profileId = "anthropic:oauth"; const store: AuthProfileStore = { version: 1, @@ -80,7 +80,12 @@ describe("resolveApiKeyForProfile config compatibility", () => { store, profileId, }); - expect(result).toBeNull(); + // token ↔ oauth are bidirectionally compatible bearer-token auth paths. + expect(result).toEqual({ + apiKey: "access-123", + provider: "anthropic", + email: undefined, + }); }); it("rejects credentials when provider does not match config", async () => { 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/agents/failover-error.ts b/src/agents/failover-error.ts index d2ec6c35c52..766da7ccf2d 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -51,6 +51,8 @@ export function resolveFailoverStatus(reason: FailoverReason): number | undefine return 408; case "format": return 400; + case "model_not_found": + return 404; default: return undefined; } diff --git a/src/agents/model-fallback.e2e.test.ts b/src/agents/model-fallback.e2e.test.ts index 318ea1bf629..fc01f730cea 100644 --- a/src/agents/model-fallback.e2e.test.ts +++ b/src/agents/model-fallback.e2e.test.ts @@ -262,6 +262,49 @@ describe("runWithModelFallback", () => { ]); }); + it("falls back on unknown model errors", async () => { + const cfg = makeCfg(); + const run = vi + .fn() + .mockRejectedValueOnce(new Error("Unknown model: anthropic/claude-opus-4-6")) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", + run, + }); + + // Override model failed with model_not_found → falls back to configured primary. + // (Same candidate-resolution path as other override-model failures.) + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(run.mock.calls[1]?.[0]).toBe("openai"); + expect(run.mock.calls[1]?.[1]).toBe("gpt-4.1-mini"); + }); + + it("falls back on model not found errors", async () => { + const cfg = makeCfg(); + const run = vi + .fn() + .mockRejectedValueOnce(new Error("Model not found: openai/gpt-6")) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-6", + run, + }); + + // Override model failed with model_not_found → falls back to configured primary. + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(run.mock.calls[1]?.[0]).toBe("openai"); + expect(run.mock.calls[1]?.[1]).toBe("gpt-4.1-mini"); + }); + it("skips providers when all profiles are in cooldown", async () => { const provider = `cooldown-test-${crypto.randomUUID()}`; const profileId = `${provider}:default`; diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 5c45fb05093..06bf2b1938b 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -16,6 +16,7 @@ export { getApiErrorPayloadFingerprint, isAuthAssistantError, isAuthErrorMessage, + isModelNotFoundErrorMessage, isBillingAssistantError, parseApiErrorInfo, sanitizeUserFacingText, diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 088707eef56..b24cec95517 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -765,6 +765,37 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean return isAuthErrorMessage(msg.errorMessage ?? ""); } +export function isModelNotFoundErrorMessage(raw: string): boolean { + if (!raw) { + return false; + } + const lower = raw.toLowerCase(); + + // Direct pattern matches from OpenClaw internals and common providers. + if ( + lower.includes("unknown model") || + lower.includes("model not found") || + lower.includes("model_not_found") || + lower.includes("not_found_error") || + (lower.includes("does not exist") && lower.includes("model")) || + (lower.includes("invalid model") && !lower.includes("invalid model reference")) + ) { + return true; + } + + // Google Gemini: "models/X is not found for api version" + if (/models\/[^\s]+ is not found/i.test(raw)) { + return true; + } + + // JSON error payloads: {"status": "NOT_FOUND"} or {"code": 404} combined with not-found text. + if (/\b404\b/.test(raw) && /not[-_ ]?found/i.test(raw)) { + return true; + } + + return false; +} + export function classifyFailoverReason(raw: string): FailoverReason | null { if (isImageDimensionErrorMessage(raw)) { return null; @@ -772,6 +803,9 @@ export function classifyFailoverReason(raw: string): FailoverReason | null { if (isImageSizeError(raw)) { return null; } + if (isModelNotFoundErrorMessage(raw)) { + return "model_not_found"; + } if (isTransientHttpError(raw)) { // Treat transient 5xx provider failures as retryable transport issues. return "timeout"; diff --git a/src/agents/pi-embedded-helpers/types.ts b/src/agents/pi-embedded-helpers/types.ts index f76ee6deac1..2753e979eb2 100644 --- a/src/agents/pi-embedded-helpers/types.ts +++ b/src/agents/pi-embedded-helpers/types.ts @@ -1,3 +1,10 @@ export type EmbeddedContextFile = { path: string; content: string }; -export type FailoverReason = "auth" | "format" | "rate_limit" | "billing" | "timeout" | "unknown"; +export type FailoverReason = + | "auth" + | "format" + | "rate_limit" + | "billing" + | "timeout" + | "model_not_found" + | "unknown"; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 72e9f3999ac..af4fa2886f3 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -274,7 +274,11 @@ export async function runEmbeddedPiAgent( params.config, ); if (!model) { - throw new Error(error ?? `Unknown model: ${provider}/${modelId}`); + throw new FailoverError(error ?? `Unknown model: ${provider}/${modelId}`, { + reason: "model_not_found", + provider, + model: modelId, + }); } const ctxInfo = resolveContextWindowInfo({ diff --git a/src/commands/auth-choice.apply.openai.ts b/src/commands/auth-choice.apply.openai.ts index 7555245f1ba..2d1beaf041c 100644 --- a/src/commands/auth-choice.apply.openai.ts +++ b/src/commands/auth-choice.apply.openai.ts @@ -117,7 +117,9 @@ export async function applyAuthChoiceOpenAI( return { config: nextConfig, agentModelOverride }; } if (creds) { - const profileId = await writeOAuthCredentials("openai-codex", creds, params.agentDir); + const profileId = await writeOAuthCredentials("openai-codex", creds, params.agentDir, { + syncSiblingAgents: true, + }); nextConfig = applyAuthProfileConfig(nextConfig, { profileId, provider: "openai-codex", diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index 69f5e306f24..03a0390363b 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -1,28 +1,112 @@ +import fs from "node:fs"; +import path from "node:path"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; 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, + 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), + credential, + agentDir: resolvedAgentDir, }); + + // 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, + credential, + agentDir: targetAgentDir, + }); + } catch { + // Best-effort: sibling sync failure must not block primary onboarding. + } + } + } return profileId; } diff --git a/src/commands/onboard-auth.e2e.test.ts b/src/commands/onboard-auth.e2e.test.ts index 2389aee7984..49401616de6 100644 --- a/src/commands/onboard-auth.e2e.test.ts +++ b/src/commands/onboard-auth.e2e.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; @@ -111,6 +112,9 @@ describe("writeOAuthCredentials", () => { "OPENCLAW_OAUTH_DIR", ]); + let tempStateDir: string; + const authProfilePathFor = (dir: string) => path.join(dir, "auth-profiles.json"); + afterEach(async () => { await lifecycle.cleanup(); }); @@ -125,13 +129,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 +145,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(); }); });