fix(auth): serialize OAuth refresh across agents to fix #26322 (#67876)

This commit is contained in:
Viz
2026-04-17 02:44:03 -04:00
committed by GitHub
parent 7d4f1a6777
commit 8e79080bef
13 changed files with 3510 additions and 104 deletions

View File

@@ -23,6 +23,34 @@ export const AUTH_STORE_LOCK_OPTIONS = {
stale: 30_000,
} as const;
// Separate from AUTH_STORE_LOCK_OPTIONS for independent tuning: this lock
// serializes the cross-agent OAuth refresh (see issue #26322), whereas
// AUTH_STORE_LOCK_OPTIONS guards per-store file writes. Keeping them
// distinct lets us widen the refresh lock's timeout/retry budget without
// affecting the hot-path auth-store writers.
//
// Invariant: OAUTH_REFRESH_CALL_TIMEOUT_MS < OAUTH_REFRESH_LOCK_OPTIONS.stale
// so a legitimate refresh's critical section always finishes well before
// peers would treat the lock as reclaimable. Violating this invariant re-
// introduces the `refresh_token_reused` race the lock is meant to prevent.
export const OAUTH_REFRESH_LOCK_OPTIONS = {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 10_000,
randomize: true,
},
stale: 180_000,
} as const;
// Hard upper bound on a single OAuth refresh call (plugin hook + HTTP
// token-exchange). Any refresh that runs longer than this is aborted and
// surfaced as a refresh failure. Keep strictly below
// OAUTH_REFRESH_LOCK_OPTIONS.stale so the lock is never treated as stale
// by a waiter while the owner is still doing legitimate work.
export const OAUTH_REFRESH_CALL_TIMEOUT_MS = 120_000;
export const EXTERNAL_CLI_SYNC_TTL_MS = 15 * 60 * 1000;
export const EXTERNAL_CLI_NEAR_EXPIRY_MS = 10 * 60 * 1000;

View File

@@ -0,0 +1,465 @@
import { describe, expect, it } from "vitest";
import {
isSafeToCopyOAuthIdentity,
isSameOAuthIdentity,
normalizeAuthEmailToken,
normalizeAuthIdentityToken,
} from "./oauth.js";
// Direct unit + fuzz tests for the cross-agent credential-mirroring identity
// gate introduced for #26322 (CWE-284). These helpers are on the hot-path of
// `mirrorRefreshedCredentialIntoMainStore` and must be strictly correct: a
// false positive means a sub-agent could poison the main-agent auth store.
describe("normalizeAuthIdentityToken", () => {
it("returns trimmed value when non-empty", () => {
expect(normalizeAuthIdentityToken("acct-123")).toBe("acct-123");
expect(normalizeAuthIdentityToken(" acct-123 ")).toBe("acct-123");
});
it("returns undefined for undefined, empty, or whitespace-only input", () => {
expect(normalizeAuthIdentityToken(undefined)).toBeUndefined();
expect(normalizeAuthIdentityToken("")).toBeUndefined();
expect(normalizeAuthIdentityToken(" ")).toBeUndefined();
expect(normalizeAuthIdentityToken("\t\n\r")).toBeUndefined();
});
it("preserves case (accountIds are case-sensitive)", () => {
expect(normalizeAuthIdentityToken("Acct-ABC")).toBe("Acct-ABC");
expect(normalizeAuthIdentityToken("acct-abc")).toBe("acct-abc");
});
});
describe("normalizeAuthEmailToken", () => {
it("lowercases and trims email", () => {
expect(normalizeAuthEmailToken("USER@Example.COM")).toBe("user@example.com");
expect(normalizeAuthEmailToken(" user@example.com ")).toBe("user@example.com");
});
it("returns undefined for undefined/empty/whitespace", () => {
expect(normalizeAuthEmailToken(undefined)).toBeUndefined();
expect(normalizeAuthEmailToken("")).toBeUndefined();
expect(normalizeAuthEmailToken(" ")).toBeUndefined();
});
it("preserves internal plus-addressing and unicode", () => {
expect(normalizeAuthEmailToken("User+Tag@Example.com")).toBe("user+tag@example.com");
expect(normalizeAuthEmailToken(" JOSÉ@Example.com ")).toBe("josé@example.com");
});
});
describe("isSameOAuthIdentity", () => {
describe("accountId takes priority when present on both sides", () => {
it("returns true when accountIds match", () => {
expect(isSameOAuthIdentity({ accountId: "acct-1" }, { accountId: "acct-1" })).toBe(true);
});
it("returns true for accountId match even if emails differ", () => {
expect(
isSameOAuthIdentity(
{ accountId: "acct-1", email: "a@example.com" },
{ accountId: "acct-1", email: "b@example.com" },
),
).toBe(true);
});
it("returns false when accountIds mismatch, ignoring email", () => {
expect(
isSameOAuthIdentity(
{ accountId: "acct-1", email: "same@example.com" },
{ accountId: "acct-2", email: "same@example.com" },
),
).toBe(false);
});
it("treats whitespace-equal accountIds as same", () => {
expect(isSameOAuthIdentity({ accountId: " acct-1 " }, { accountId: "acct-1" })).toBe(true);
});
it("accountId is case-sensitive", () => {
expect(isSameOAuthIdentity({ accountId: "Acct-1" }, { accountId: "acct-1" })).toBe(false);
});
});
describe("email fallback when accountId missing on either side", () => {
it("returns true when emails match (case-insensitive)", () => {
expect(
isSameOAuthIdentity({ email: "user@example.com" }, { email: "USER@Example.COM" }),
).toBe(true);
});
it("returns false when emails mismatch", () => {
expect(isSameOAuthIdentity({ email: "a@example.com" }, { email: "b@example.com" })).toBe(
false,
);
});
it("matches when main has accountId+email and incoming has only matching email", () => {
// Not asymmetric: both sides carry identity (main has more, but
// incoming still has email). Email is a shared field with a
// matching value — positive-identity match, safe to mirror.
expect(
isSameOAuthIdentity(
{ accountId: "acct-1", email: "user@example.com" },
{ email: "user@example.com" },
),
).toBe(true);
});
it("matches when accountIds on one side are whitespace-only and both sides expose matching email", () => {
// Whitespace-only accountId is treated as absent; email falls back
// symmetrically on both sides so the positive email match wins.
expect(
isSameOAuthIdentity(
{ accountId: " ", email: "user@example.com" },
{ accountId: "", email: "USER@example.com" },
),
).toBe(true);
});
});
describe("asymmetric identity evidence is refused", () => {
it("refuses when main has accountId and incoming has neither", () => {
expect(isSameOAuthIdentity({ accountId: "acct-1" }, {})).toBe(false);
});
it("refuses when main has email and incoming has neither", () => {
expect(isSameOAuthIdentity({ email: "user@example.com" }, {})).toBe(false);
});
it("refuses when incoming has identity but main does not", () => {
expect(isSameOAuthIdentity({}, { accountId: "acct-1" })).toBe(false);
expect(isSameOAuthIdentity({}, { email: "user@example.com" })).toBe(false);
});
it("refuses when main has only accountId and incoming has only email (non-overlapping fields)", () => {
expect(isSameOAuthIdentity({ accountId: "acct-1" }, { email: "user@example.com" })).toBe(
false,
);
});
});
describe("no identity metadata on either side", () => {
it("returns true (no evidence of mismatch) when both sides lack accountId and email", () => {
// This matches the looser behaviour of the pre-existing
// adoptNewerMainOAuthCredential gate; provider equality is the
// caller's responsibility.
expect(isSameOAuthIdentity({}, {})).toBe(true);
});
it("returns true when one side has empty strings for both fields", () => {
expect(
isSameOAuthIdentity(
{ accountId: "", email: "" },
{ accountId: undefined, email: undefined },
),
).toBe(true);
});
});
describe("reflexivity and symmetry", () => {
it("is reflexive: share(a,a) === true for any non-conflicting identity", () => {
const a = { accountId: "acct-1", email: "a@example.com" };
expect(isSameOAuthIdentity(a, a)).toBe(true);
});
it("is symmetric: share(a,b) === share(b,a)", () => {
const a = { accountId: "acct-1" };
const b = { accountId: "acct-2" };
expect(isSameOAuthIdentity(a, b)).toBe(isSameOAuthIdentity(b, a));
});
});
});
// ---------------------------------------------------------------------------
// Fuzz tests. Seeded Mulberry32 so the run is reproducible.
// ---------------------------------------------------------------------------
function makeSeededRandom(seed: number): () => number {
let t = seed >>> 0;
return () => {
t = (t + 0x6d2b79f5) >>> 0;
let r = t;
r = Math.imul(r ^ (r >>> 15), r | 1);
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
function randomString(rng: () => number, maxLen: number): string {
const len = Math.floor(rng() * maxLen);
const chars: string[] = [];
for (let i = 0; i < len; i += 1) {
chars.push(String.fromCodePoint(32 + Math.floor(rng() * 95))); // printable ASCII
}
return chars.join("");
}
function maybe<T>(rng: () => number, value: T): T | undefined {
return rng() < 0.5 ? value : undefined;
}
describe("isSafeToCopyOAuthIdentity (unified copy gate, used for mirror and adopt)", () => {
describe("positive matches", () => {
it("accepts matching accountIds", () => {
expect(isSafeToCopyOAuthIdentity({ accountId: "x" }, { accountId: "x" })).toBe(true);
});
it("accepts matching emails (case-insensitive)", () => {
expect(
isSafeToCopyOAuthIdentity({ email: "u@example.com" }, { email: "U@Example.com" }),
).toBe(true);
});
it("accepts when both sides expose identical identity across accountId + email", () => {
expect(
isSafeToCopyOAuthIdentity(
{ accountId: "x", email: "u@example.com" },
{ accountId: "x", email: "u@example.com" },
),
).toBe(true);
});
});
describe("upgrade tolerance (primary motivator)", () => {
it("accepts existing-no-identity adopting incoming-with-accountId", () => {
// The #26322 upgrade case: existing cred predates accountId capture,
// incoming has it. Must allow or the fix regresses on existing installs.
expect(isSafeToCopyOAuthIdentity({}, { accountId: "x" })).toBe(true);
});
it("accepts existing-no-identity adopting incoming-with-email", () => {
expect(isSafeToCopyOAuthIdentity({}, { email: "u@example.com" })).toBe(true);
});
it("accepts when both sides lack identity metadata", () => {
expect(isSafeToCopyOAuthIdentity({}, {})).toBe(true);
});
});
describe("identity regression is refused (incoming drops existing's identity)", () => {
it("refuses when incoming has no identity and existing has accountId", () => {
// Was previously allowed under the permissive relaxed rule; the
// narrower rule refuses because it would strip identity evidence.
expect(isSafeToCopyOAuthIdentity({ accountId: "x" }, {})).toBe(false);
});
it("refuses when incoming has no identity and existing has email", () => {
expect(isSafeToCopyOAuthIdentity({ email: "u@example.com" }, {})).toBe(false);
});
});
describe("non-overlapping identity fields are refused", () => {
it("refuses when existing has only accountId and incoming has only email", () => {
expect(isSafeToCopyOAuthIdentity({ accountId: "x" }, { email: "u@example.com" })).toBe(false);
});
it("refuses when existing has only email and incoming has only accountId", () => {
expect(isSafeToCopyOAuthIdentity({ email: "u@example.com" }, { accountId: "x" })).toBe(false);
});
});
describe("positive mismatch still refuses (CWE-284 protection)", () => {
it("refuses mismatching accountIds even when emails match", () => {
expect(
isSafeToCopyOAuthIdentity(
{ accountId: "a", email: "u@example.com" },
{ accountId: "b", email: "u@example.com" },
),
).toBe(false);
});
it("refuses mismatching emails when both sides expose only email", () => {
expect(
isSafeToCopyOAuthIdentity({ email: "a@example.com" }, { email: "b@example.com" }),
).toBe(false);
});
it("accountId is case-sensitive", () => {
expect(isSafeToCopyOAuthIdentity({ accountId: "X" }, { accountId: "x" })).toBe(false);
});
});
describe("normalization", () => {
it("ignores surrounding whitespace on accountId", () => {
expect(isSafeToCopyOAuthIdentity({ accountId: " acct-1 " }, { accountId: "acct-1" })).toBe(
true,
);
});
it("ignores email case and whitespace", () => {
expect(
isSafeToCopyOAuthIdentity({ email: " U@Example.com " }, { email: "u@example.com" }),
).toBe(true);
});
it("treats empty/whitespace-only identity as absent (allowed to upgrade)", () => {
expect(
isSafeToCopyOAuthIdentity({ accountId: " ", email: "" }, { accountId: "acct-main" }),
).toBe(true);
});
});
describe("reflexivity", () => {
it("is reflexive", () => {
const a = { accountId: "acct-1", email: "u@example.com" };
expect(isSafeToCopyOAuthIdentity(a, a)).toBe(true);
});
});
describe("relationship to the strict isSameOAuthIdentity reference", () => {
it("is at least as permissive as the strict rule (strict implies safe-to-copy)", () => {
// Pure-symmetric match cases accepted by the strict rule must also
// be accepted by the unified copy gate.
expect(isSameOAuthIdentity({ accountId: "x" }, { accountId: "x" })).toBe(true);
expect(isSafeToCopyOAuthIdentity({ accountId: "x" }, { accountId: "x" })).toBe(true);
});
it("only relaxes the strict rule in the pure-upgrade direction", () => {
// Existing has no identity, incoming has identity: strict refuses,
// unified accepts.
expect(isSameOAuthIdentity({}, { accountId: "x" })).toBe(false);
expect(isSafeToCopyOAuthIdentity({}, { accountId: "x" })).toBe(true);
});
it("does NOT relax in the regression direction (strict and unified both refuse)", () => {
expect(isSameOAuthIdentity({ accountId: "x" }, {})).toBe(false);
expect(isSafeToCopyOAuthIdentity({ accountId: "x" }, {})).toBe(false);
});
});
});
describe("isSafeToCopyOAuthIdentity fuzz", () => {
function makeSeededRandom(seed: number): () => number {
let t = seed >>> 0;
return () => {
t = (t + 0x6d2b79f5) >>> 0;
let r = t;
r = Math.imul(r ^ (r >>> 15), r | 1);
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
function randomString(rng: () => number, maxLen: number): string {
const len = Math.floor(rng() * maxLen);
const chars: string[] = [];
for (let i = 0; i < len; i += 1) {
chars.push(String.fromCodePoint(32 + Math.floor(rng() * 95)));
}
return chars.join("");
}
function maybe<T>(rng: () => number, value: T): T | undefined {
return rng() < 0.5 ? value : undefined;
}
it("is reflexive: share(a, a) is always true", () => {
const rng = makeSeededRandom(0x0172_0417);
for (let i = 0; i < 1000; i += 1) {
const a = {
accountId: maybe(rng, randomString(rng, 64)),
email: maybe(rng, randomString(rng, 64)),
};
expect(isSafeToCopyOAuthIdentity(a, a)).toBe(true);
}
});
it("always refuses distinct non-empty accountIds (primary CWE-284 invariant)", () => {
const rng = makeSeededRandom(0xfaceb00c);
for (let i = 0; i < 500; i += 1) {
const idA = `A-${randomString(rng, 32) || "x"}`;
const idB = `B-${randomString(rng, 32) || "y"}`;
expect(isSafeToCopyOAuthIdentity({ accountId: idA }, { accountId: idB })).toBe(false);
}
});
it("strict → unified: if isSameOAuthIdentity accepts, isSafeToCopyOAuthIdentity accepts", () => {
// Monotonic relaxation property over random inputs.
const rng = makeSeededRandom(0x7777_7777);
for (let i = 0; i < 1000; i += 1) {
const a = {
accountId: maybe(rng, randomString(rng, 32)),
email: maybe(rng, randomString(rng, 32)),
};
const b = {
accountId: maybe(rng, randomString(rng, 32)),
email: maybe(rng, randomString(rng, 32)),
};
if (isSameOAuthIdentity(a, b)) {
expect(isSafeToCopyOAuthIdentity(a, b)).toBe(true);
}
}
});
it("unified rule never refuses a same-account pair and never accepts a different-account pair", () => {
// Over random identity pairs that share accountId but vary in every
// other field, the gate must always accept. Over pairs with distinct
// non-empty accountIds it must always refuse.
const rng = makeSeededRandom(0x9a_9b_9c_9d);
for (let i = 0; i < 500; i += 1) {
const shared = `acct-${randomString(rng, 32) || "x"}`;
const a = {
accountId: shared,
email: maybe(rng, randomString(rng, 32)),
};
const b = {
accountId: shared,
email: maybe(rng, randomString(rng, 32)),
};
expect(isSafeToCopyOAuthIdentity(a, b)).toBe(true);
}
});
});
describe("isSameOAuthIdentity fuzz", () => {
it("is always symmetric regardless of input shape", () => {
const rng = makeSeededRandom(0x0426_0417);
for (let i = 0; i < 1000; i += 1) {
const a = {
accountId: maybe(rng, randomString(rng, 64)),
email: maybe(rng, randomString(rng, 64)),
};
const b = {
accountId: maybe(rng, randomString(rng, 64)),
email: maybe(rng, randomString(rng, 64)),
};
expect(isSameOAuthIdentity(a, b)).toBe(isSameOAuthIdentity(b, a));
}
});
it("is always reflexive: share(a, a) is true", () => {
const rng = makeSeededRandom(0x1234_abcd);
for (let i = 0; i < 1000; i += 1) {
const a = {
accountId: maybe(rng, randomString(rng, 64)),
email: maybe(rng, randomString(rng, 64)),
};
expect(isSameOAuthIdentity(a, a)).toBe(true);
}
});
it("never returns true for distinct non-empty accountIds (regardless of email)", () => {
const rng = makeSeededRandom(0xfeedc0de);
for (let i = 0; i < 500; i += 1) {
const idA = `A-${randomString(rng, 32) || "x"}`;
const idB = `B-${randomString(rng, 32) || "y"}`;
// Shared email; mismatched accountId must still refuse.
const email = `${randomString(rng, 16) || "u"}@example.com`;
expect(isSameOAuthIdentity({ accountId: idA, email }, { accountId: idB, email })).toBe(false);
}
});
it("email comparison is case-insensitive for random email bodies", () => {
const rng = makeSeededRandom(0xcafef00d);
for (let i = 0; i < 500; i += 1) {
const local = randomString(rng, 16).replace(/[^A-Za-z0-9+._-]/g, "") || "user";
const domain = (randomString(rng, 12).replace(/[^A-Za-z0-9.-]/g, "") || "example") + ".com";
const email = `${local}@${domain}`;
const randomizedCase = email
.split("")
.map((c) => (rng() < 0.5 ? c.toUpperCase() : c.toLowerCase()))
.join("");
expect(isSameOAuthIdentity({ email }, { email: randomizedCase })).toBe(true);
}
});
});

View File

@@ -0,0 +1,246 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { captureEnv } from "../../test-utils/env.js";
import { resolveOAuthRefreshLockPath } from "./paths.js";
describe("resolveOAuthRefreshLockPath", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let stateDir = "";
beforeEach(async () => {
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-lock-path-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
});
afterEach(async () => {
envSnapshot.restore();
await fs.rm(stateDir, { recursive: true, force: true });
});
it("keeps lock paths inside the oauth-refresh directory for dot-segment ids", () => {
const refreshLockDir = path.join(stateDir, "locks", "oauth-refresh");
const dotSegmentPath = resolveOAuthRefreshLockPath("openai-codex", "..");
const currentDirPath = resolveOAuthRefreshLockPath("openai-codex", ".");
expect(path.dirname(dotSegmentPath)).toBe(refreshLockDir);
expect(path.dirname(currentDirPath)).toBe(refreshLockDir);
expect(path.basename(dotSegmentPath)).toMatch(/^sha256-[0-9a-f]{64}$/);
expect(path.basename(currentDirPath)).toMatch(/^sha256-[0-9a-f]{64}$/);
expect(path.basename(dotSegmentPath)).not.toBe(path.basename(currentDirPath));
});
it("hashes profile ids so distinct values stay distinct", () => {
expect(resolveOAuthRefreshLockPath("openai-codex", "openai-codex:work/test")).not.toBe(
resolveOAuthRefreshLockPath("openai-codex", "openai-codex_work:test"),
);
// Unicode normalization / collation corner cases must still hash distinctly.
expect(resolveOAuthRefreshLockPath("openai-codex", "«c")).not.toBe(
resolveOAuthRefreshLockPath("openai-codex", "઼"),
);
});
it("hashes distinct providers to distinct paths for the same profileId", () => {
// The new (provider, profileId) keying is the whole point of P2 from
// review: a shared profileId across providers must not collide.
expect(resolveOAuthRefreshLockPath("openai-codex", "shared:default")).not.toBe(
resolveOAuthRefreshLockPath("anthropic", "shared:default"),
);
});
it("is immune to simple concat collisions at the provider/profile boundary", () => {
// With a plain `${provider}:${profileId}` hash input, the pair
// ("a", "b:c") would collide with ("a:b", "c"). The NUL separator
// in the hash input rules that out.
expect(resolveOAuthRefreshLockPath("a", "b:c")).not.toBe(
resolveOAuthRefreshLockPath("a:b", "c"),
);
});
it("keeps lock filenames short for long profile ids", () => {
const longProfileId = `openai-codex:${"x".repeat(512)}`;
const basename = path.basename(resolveOAuthRefreshLockPath("openai-codex", longProfileId));
expect(basename).toMatch(/^sha256-[0-9a-f]{64}$/);
expect(Buffer.byteLength(basename, "utf8")).toBeLessThan(255);
});
it("is deterministic: same (provider, profileId) produces the same path", () => {
const first = resolveOAuthRefreshLockPath("openai-codex", "openai-codex:default");
const second = resolveOAuthRefreshLockPath("openai-codex", "openai-codex:default");
expect(first).toBe(second);
});
it("returns a valid path on a clean install where the locks/ directory does not yet exist", async () => {
// Defensive check: even on a fresh install with no lock hierarchy
// populated, the function must return a safe path. withFileLock
// internally creates missing parent dirs, but this test pins the
// expectation so a future change to remove that guarantee would
// fail loudly.
const locksDir = path.join(stateDir, "locks", "oauth-refresh");
// Sanity precondition: parent dir must not exist yet.
await expect(fs.stat(locksDir)).rejects.toThrow();
const resolved = resolveOAuthRefreshLockPath("openai-codex", "openai-codex:default");
expect(path.dirname(resolved)).toBe(locksDir);
expect(path.basename(resolved)).toMatch(/^sha256-[0-9a-f]{64}$/);
// Function itself must not create the directory (path resolver only).
await expect(fs.stat(locksDir)).rejects.toThrow();
});
it("never embeds path separators or .. in the basename", () => {
const hazards = [
["openai-codex", "../etc/passwd"],
["openai-codex", "../../../../secrets"],
["openai-codex", "openai\\codex"],
["openai-codex", "openai/codex/default"],
["openai-codex", "profile\x00with-null"],
["openai-codex", "profile\nwith-newline"],
["openai-codex", "profile with spaces"],
["../../etc", "passwd"],
["provider\x00with-null", "default"],
] as const;
for (const [provider, id] of hazards) {
const basename = path.basename(resolveOAuthRefreshLockPath(provider, id));
expect(basename).toMatch(/^sha256-[0-9a-f]{64}$/);
expect(basename).not.toContain("/");
expect(basename).not.toContain("\\");
expect(basename).not.toContain("..");
expect(basename).not.toContain("\x00");
expect(basename).not.toContain("\n");
}
});
});
describe("resolveOAuthRefreshLockPath fuzz", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let stateDir = "";
beforeEach(async () => {
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-lock-path-fuzz-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
});
afterEach(async () => {
envSnapshot.restore();
await fs.rm(stateDir, { recursive: true, force: true });
});
function makeSeededRandom(seed: number): () => number {
// Mulberry32 — small, stable, seedable PRNG so the fuzz run is reproducible
// even if the suite later becomes picky about test ordering.
let t = seed >>> 0;
return () => {
t = (t + 0x6d2b79f5) >>> 0;
let r = t;
r = Math.imul(r ^ (r >>> 15), r | 1);
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
function randomProfileId(rng: () => number, maxLen: number): string {
const len = Math.floor(rng() * maxLen);
const chars: string[] = [];
for (let i = 0; i < len; i += 1) {
// Cover BMP + surrogate-pair range + control chars + ASCII + path hazards.
const category = Math.floor(rng() * 5);
const code =
category === 0
? Math.floor(rng() * 128) // ASCII
: category === 1
? Math.floor(rng() * 32) // control chars (including \0, \n, \r, etc.)
: category === 2
? 0x10000 + Math.floor(rng() * 0xeffff) // supplementary planes
: category === 3
? Math.floor(rng() * 0xd800) // BMP non-surrogate
: 0x0f00 + Math.floor(rng() * 0x0100); // misc unicode
chars.push(String.fromCodePoint(code));
}
return chars.join("");
}
it("always produces a basename that matches sha256-<hex64> regardless of input", () => {
const rng = makeSeededRandom(0x2026_0417);
for (let i = 0; i < 500; i += 1) {
const provider = randomProfileId(rng, 64) || "openai-codex";
const id = randomProfileId(rng, 4096);
const basename = path.basename(resolveOAuthRefreshLockPath(provider, id));
expect(basename).toMatch(/^sha256-[0-9a-f]{64}$/);
expect(Buffer.byteLength(basename, "utf8")).toBeLessThan(255);
// sha256-<64 hex> = 71 chars, no path hazards. Explicit substring
// checks (no control-char regex) to keep lint happy.
expect(basename).not.toContain("\\");
expect(basename).not.toContain("/");
expect(basename).not.toContain("\u0000");
expect(basename).not.toContain("\n");
expect(basename).not.toContain("\r");
expect(basename).not.toContain("..");
}
});
it("always resolves to a path inside <stateDir>/locks/oauth-refresh", () => {
const rng = makeSeededRandom(0xdecafbad);
const expectedDir = path.join(stateDir, "locks", "oauth-refresh");
for (let i = 0; i < 200; i += 1) {
const provider = randomProfileId(rng, 32) || "openai-codex";
const id = randomProfileId(rng, 1024);
const resolved = resolveOAuthRefreshLockPath(provider, id);
expect(path.dirname(resolved)).toBe(expectedDir);
// Normalized path must still live under the expected directory — defense
// against any future change that lets a profile id escape the scope.
expect(path.normalize(resolved).startsWith(expectedDir + path.sep)).toBe(true);
}
});
it("distinct (provider, profileId) inputs produce distinct outputs over a large random sample", () => {
const rng = makeSeededRandom(0x1234_5678);
const seen = new Map<string, string>();
let collisions = 0;
for (let i = 0; i < 2000; i += 1) {
const provider = randomProfileId(rng, 32) || "p";
const id = randomProfileId(rng, 256);
const composite = `${provider}\u0000${id}`;
const resolved = resolveOAuthRefreshLockPath(provider, id);
const existing = seen.get(resolved);
if (existing !== undefined && existing !== composite) {
collisions += 1;
}
seen.set(resolved, composite);
}
expect(collisions).toBe(0);
});
it("holding provider fixed, distinct profileIds never collide", () => {
const rng = makeSeededRandom(0xf00dbabe);
const seen = new Map<string, string>();
let collisions = 0;
for (let i = 0; i < 1000; i += 1) {
const id = randomProfileId(rng, 128) || `id-${i}`;
const resolved = resolveOAuthRefreshLockPath("openai-codex", id);
const existing = seen.get(resolved);
if (existing !== undefined && existing !== id) {
collisions += 1;
}
seen.set(resolved, id);
}
expect(collisions).toBe(0);
});
it("holding profileId fixed, distinct providers never collide", () => {
const rng = makeSeededRandom(0xbad1d00d);
const seen = new Map<string, string>();
let collisions = 0;
for (let i = 0; i < 500; i += 1) {
const provider = randomProfileId(rng, 64) || `provider-${i}`;
const resolved = resolveOAuthRefreshLockPath(provider, "shared-profile-id");
const existing = seen.get(resolved);
if (existing !== undefined && existing !== provider) {
collisions += 1;
}
seen.set(resolved, provider);
}
expect(collisions).toBe(0);
});
});

View File

@@ -0,0 +1,159 @@
import { describe, expect, it } from "vitest";
import { isRefreshTokenReusedError } from "./oauth.js";
// Direct tests for the refresh_token_reused classifier. This is the gate that
// triggers the retry/adoption recovery path; a false negative here means we
// fail over to an expensive model instead of adopting the winner's fresh
// token.
describe("isRefreshTokenReusedError", () => {
describe("positive cases", () => {
it("detects the canonical OAuth snake_case code", () => {
expect(isRefreshTokenReusedError(new Error("refresh_token_reused"))).toBe(true);
});
it("detects mixed-case variants", () => {
expect(isRefreshTokenReusedError(new Error("REFRESH_TOKEN_REUSED"))).toBe(true);
expect(isRefreshTokenReusedError(new Error("Refresh_Token_Reused"))).toBe(true);
});
it("detects OpenAI-style natural-language variants", () => {
expect(
isRefreshTokenReusedError(
new Error("Your refresh token has already been used to generate a new access token."),
),
).toBe(true);
expect(
isRefreshTokenReusedError(
new Error("The refresh token has already been used to generate a new access token."),
),
).toBe(true);
});
it("detects full JSON-wrapped 401 payloads", () => {
expect(
isRefreshTokenReusedError(
new Error(
'401 {"error":{"message":"Your refresh token has already been used to generate a new access token.","type":"invalid_request_error","code":"refresh_token_reused"}}',
),
),
).toBe(true);
});
it("detects when message is a plain string (non-Error throw)", () => {
expect(isRefreshTokenReusedError("refresh_token_reused")).toBe(true);
});
it("detects when message is wrapped via Error.cause (single level)", () => {
// formatErrorMessage traverses the .cause chain and concatenates
// messages with " | ", so a marker hidden in the cause still counts.
const inner = new Error("refresh_token_reused");
const outer = new Error("OAuth token refresh failed", { cause: inner });
expect(isRefreshTokenReusedError(outer)).toBe(true);
});
it("detects when message is wrapped in a multi-level cause chain", () => {
const root = new Error("already been used to generate a new access token");
const mid = new Error("plugin adapter failure", { cause: root });
const outer = new Error("OAuth token refresh failed", { cause: mid });
expect(isRefreshTokenReusedError(outer)).toBe(true);
});
it("detects when cause is a bare string (no Error wrapper)", () => {
const outer = new Error("upstream", { cause: "refresh_token_reused" });
expect(isRefreshTokenReusedError(outer)).toBe(true);
});
it("still matches when the marker phrase is embedded in a longer message", () => {
expect(
isRefreshTokenReusedError(
new Error("auth failed: already been used to generate a new access token (retry)"),
),
).toBe(true);
});
});
describe("negative cases", () => {
it("returns false for unrelated auth errors", () => {
expect(isRefreshTokenReusedError(new Error("invalid_grant"))).toBe(false);
expect(isRefreshTokenReusedError(new Error("HTTP 500 Internal Server Error"))).toBe(false);
expect(isRefreshTokenReusedError(new Error("network timeout"))).toBe(false);
expect(isRefreshTokenReusedError(new Error("expired or revoked"))).toBe(false);
});
it("returns false for null/undefined/non-stringable values", () => {
expect(isRefreshTokenReusedError(null)).toBe(false);
expect(isRefreshTokenReusedError(undefined)).toBe(false);
expect(isRefreshTokenReusedError(42)).toBe(false);
expect(isRefreshTokenReusedError({})).toBe(false);
});
it("returns false for an empty error message", () => {
expect(isRefreshTokenReusedError(new Error(""))).toBe(false);
});
});
describe("fuzz: random noisy messages", () => {
function makeSeededRandom(seed: number): () => number {
let t = seed >>> 0;
return () => {
t = (t + 0x6d2b79f5) >>> 0;
let r = t;
r = Math.imul(r ^ (r >>> 15), r | 1);
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
function randomJunk(rng: () => number, maxLen: number): string {
const len = Math.floor(rng() * maxLen);
const chars: string[] = [];
for (let i = 0; i < len; i += 1) {
chars.push(String.fromCodePoint(32 + Math.floor(rng() * 95)));
}
return chars.join("");
}
function randomlyCased(s: string, rng: () => number): string {
return s
.split("")
.map((c) => (rng() < 0.5 ? c.toUpperCase() : c.toLowerCase()))
.join("");
}
it("always detects the marker when embedded at random positions with noise", () => {
const rng = makeSeededRandom(0xabad1dea);
const markers = [
"refresh_token_reused",
"Your refresh token has already been used to generate a new access token",
"already been used to generate a new access token",
];
for (let i = 0; i < 500; i += 1) {
const marker = randomlyCased(markers[i % markers.length], rng);
const prefix = randomJunk(rng, 64);
const suffix = randomJunk(rng, 64);
const msg = `${prefix}${marker}${suffix}`;
expect(isRefreshTokenReusedError(new Error(msg))).toBe(true);
// Same for plain-string throws.
expect(isRefreshTokenReusedError(msg)).toBe(true);
}
});
it("never yields a false positive on marker-free random messages", () => {
const rng = makeSeededRandom(0x1337_beef);
for (let i = 0; i < 500; i += 1) {
// Bound length so we never randomly emit one of the marker substrings.
const msg = randomJunk(rng, 32);
if (
msg.toLowerCase().includes("refresh_token_reused") ||
msg.toLowerCase().includes("refresh token has already been used") ||
msg.toLowerCase().includes("already been used to generate a new access token")
) {
// Extremely unlikely with 32-char random junk; skip if it happens.
continue;
}
expect(isRefreshTokenReusedError(new Error(msg))).toBe(false);
}
});
});
});

View File

@@ -0,0 +1,259 @@
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 { resetFileLockStateForTest } from "../../infra/file-lock.js";
import { captureEnv } from "../../test-utils/env.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
saveAuthProfileStore,
} from "./store.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile;
let resetOAuthRefreshQueuesForTest: typeof import("./oauth.js").resetOAuthRefreshQueuesForTest;
async function loadOAuthModuleForTest() {
({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js"));
}
const {
refreshProviderOAuthCredentialWithPluginMock,
formatProviderAuthProfileApiKeyWithPluginMock,
} = vi.hoisted(() => ({
refreshProviderOAuthCredentialWithPluginMock: vi.fn(
async (_params?: { context?: unknown }) => undefined,
),
formatProviderAuthProfileApiKeyWithPluginMock: vi.fn(() => undefined),
}));
vi.mock("../cli-credentials.js", () => ({
readCodexCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
writeCodexCliCredentials: () => true,
}));
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
formatProviderAuthProfileApiKeyWithPlugin: (params: { context?: { access?: string } }) =>
formatProviderAuthProfileApiKeyWithPluginMock() ?? params?.context?.access,
refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock,
}));
vi.mock("./doctor.js", () => ({
formatAuthDoctorHint: async () => undefined,
}));
vi.mock("./external-cli-sync.js", () => ({
syncExternalCliCredentials: () => false,
readManagedExternalCliCredential: () => null,
areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b,
}));
function createExpiredOauthStore(params: {
profileId: string;
provider: string;
}): AuthProfileStore {
return {
version: 1,
profiles: {
[params.profileId]: {
type: "oauth",
provider: params.provider,
access: "stale-access",
refresh: "stale-refresh",
expires: Date.now() - 60_000,
} satisfies OAuthCredential,
},
};
}
describe("OAuth refresh in-process queue", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let tempRoot = "";
let agentDir = "";
beforeEach(async () => {
resetFileLockStateForTest();
refreshProviderOAuthCredentialWithPluginMock.mockReset();
refreshProviderOAuthCredentialWithPluginMock.mockResolvedValue(undefined);
formatProviderAuthProfileApiKeyWithPluginMock.mockReset();
formatProviderAuthProfileApiKeyWithPluginMock.mockReturnValue(undefined);
clearRuntimeAuthProfileStoreSnapshots();
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-queue-"));
process.env.OPENCLAW_STATE_DIR = tempRoot;
agentDir = path.join(tempRoot, "agents", "main", "agent");
await fs.mkdir(agentDir, { recursive: true });
await loadOAuthModuleForTest();
resetOAuthRefreshQueuesForTest();
});
afterEach(async () => {
envSnapshot.restore();
resetFileLockStateForTest();
clearRuntimeAuthProfileStoreSnapshots();
if (resetOAuthRefreshQueuesForTest) {
resetOAuthRefreshQueuesForTest();
}
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("serializes concurrent same-PID callers FIFO", async () => {
const profileId = "openai-codex:default";
const provider = "openai-codex";
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider }), agentDir);
const order: number[] = [];
let seq = 0;
refreshProviderOAuthCredentialWithPluginMock.mockImplementation(async () => {
const n = ++seq;
order.push(n);
// Small delay so concurrent callers have time to interleave if they can.
await new Promise((r) => setTimeout(r, 10));
return {
type: "oauth",
provider,
access: `refreshed-${n}`,
refresh: `refreshed-refresh-${n}`,
// Each refresh returns a token already expired again, so the next
// queued caller also proceeds to refresh (proves the queue releases
// cleanly and the next caller actually runs).
expires: Date.now() - 1_000,
} as never;
});
// Fire three resolves concurrently against the same agent+profile.
const results = await Promise.all([
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}).catch((e) => e),
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}).catch((e) => e),
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}).catch((e) => e),
]);
// All three should have completed in order (FIFO queue).
expect(order).toEqual([1, 2, 3]);
expect(results).toHaveLength(3);
});
it("releases the queue even when the refresh throws", async () => {
const profileId = "openai-codex:default";
const provider = "openai-codex";
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider }), agentDir);
let callCount = 0;
refreshProviderOAuthCredentialWithPluginMock.mockImplementation(async () => {
callCount += 1;
if (callCount === 1) {
throw new Error("simulated upstream failure");
}
// Second caller must actually get a chance to run (proves the gate
// released despite the first caller throwing).
return {
type: "oauth",
provider,
access: "second-try-access",
refresh: "second-try-refresh",
expires: Date.now() + 60_000,
} as never;
});
const [first, second] = await Promise.all([
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}).catch((e) => e),
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}).catch((e) => e),
]);
expect(first).toBeInstanceOf(Error);
expect(callCount).toBeGreaterThanOrEqual(1);
// Second caller was not blocked forever \u2014 it either got the fresh token
// (if the queue let it run) or adopted from main. Either way, it resolved.
expect(second).toBeDefined();
});
it("resetOAuthRefreshQueuesForTest drains pending gates", async () => {
// We can't observe the internal map, but we can assert that calling the
// reset is idempotent and safe from any state.
resetOAuthRefreshQueuesForTest();
resetOAuthRefreshQueuesForTest();
expect(true).toBe(true);
});
it("serializes a 10-caller burst so later arrivals never pass an earlier caller", async () => {
// Burst-arrival stress: 10 same-PID callers all fire concurrently.
// The queue must chain them so each refresh completes fully before the
// next one begins — i.e. no overlap between running refresh calls.
// This pins the invariant that the map-overwrite pattern in the queue
// wrapper does not let later arrivals skip ahead (see review P2: the
// `refreshQueues.set(key, gate)` overwrites only the *map head*, while
// FIFO ordering is enforced via the `await prev` chain).
const profileId = "openai-codex:default";
const provider = "openai-codex";
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider }), agentDir);
const startOrder: number[] = [];
const endOrder: number[] = [];
let inFlight = 0;
let maxInFlight = 0;
let seq = 0;
refreshProviderOAuthCredentialWithPluginMock.mockImplementation(async () => {
const n = ++seq;
startOrder.push(n);
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
// Small delay so any non-serialized overlap would be observable.
await new Promise((r) => setTimeout(r, 5));
inFlight -= 1;
endOrder.push(n);
return {
type: "oauth",
provider,
access: `refreshed-${n}`,
refresh: `refresh-${n}`,
// Re-expire immediately so each queued caller also enters the
// refresh path (otherwise later callers would adopt the fresh
// cred and the serialization chain wouldn't be exercised).
expires: Date.now() - 1_000,
} as never;
});
const results = await Promise.all(
Array.from({ length: 10 }, () =>
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}).catch((e: unknown) => e),
),
);
// Every caller must have run to completion (null result or error —
// either is fine; what matters is that no caller is lost or blocked).
expect(results).toHaveLength(10);
// FIFO: start order matches end order (no overlap each caller fully
// completed before the next started).
expect(startOrder).toEqual(endOrder);
// At no point did two refresh calls run concurrently.
expect(maxInFlight).toBe(1);
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { OAUTH_REFRESH_CALL_TIMEOUT_MS, OAUTH_REFRESH_LOCK_OPTIONS } from "./constants.js";
// Invariant tests for the two constants that together bound the OAuth
// refresh critical section. Behavioural tests for the inner `setTimeout`
// mechanics are deliberately omitted: the implementation is a thin
// `Promise.race` around `setTimeout`, and exercising it end-to-end requires
// stepping through nested file-lock I/O that mixes awkwardly with Vitest
// fake timers. A regression in the timeout wiring would be caught by the
// #26322 regression test (oauth.concurrent-20-agents.test.ts) because a
// stuck refresh would time out the whole suite.
describe("OAuth refresh call timeout (invariants)", () => {
it("OAUTH_REFRESH_CALL_TIMEOUT_MS is strictly below OAUTH_REFRESH_LOCK_OPTIONS.stale", () => {
// The whole point of the two constants: the refresh call must always
// finish (or time out) before peers would consider the lock reclaimable.
// If this invariant ever regresses, the #26322 race can come back.
expect(OAUTH_REFRESH_CALL_TIMEOUT_MS).toBeLessThan(OAUTH_REFRESH_LOCK_OPTIONS.stale);
});
it("OAUTH_REFRESH_CALL_TIMEOUT_MS has a reasonable floor for OAuth token exchanges", () => {
// 30s is a sane lower bound: typical OAuth refresh RTT is <5s, but a
// cold TCP/TLS handshake + plugin bootstrap can push into double-digit
// seconds. Anything below 30s would start false-positive aborting.
expect(OAUTH_REFRESH_CALL_TIMEOUT_MS).toBeGreaterThanOrEqual(30_000);
});
it("OAUTH_REFRESH_LOCK_OPTIONS.stale leaves a generous safety margin beyond the call timeout", () => {
// Require at least 30s of headroom between the refresh deadline and
// the stale threshold: enough to cover normal scheduling jitter and
// the file-lock release round-trip without letting peers reclaim a
// still-active lock.
expect(OAUTH_REFRESH_LOCK_OPTIONS.stale - OAUTH_REFRESH_CALL_TIMEOUT_MS).toBeGreaterThanOrEqual(
30_000,
);
});
it("OAUTH_REFRESH_LOCK_OPTIONS.stale is well above the slow-refresh ceiling", () => {
// Sanity check: the stale window must clearly exceed a plausible slow-
// refresh ceiling (60s) so waiting agents never prematurely reclaim a
// lock during a legitimate slow-but-successful refresh.
expect(OAUTH_REFRESH_LOCK_OPTIONS.stale).toBeGreaterThan(60_000);
});
});

View File

@@ -0,0 +1,662 @@
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 { resetFileLockStateForTest } from "../../infra/file-lock.js";
import { captureEnv } from "../../test-utils/env.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
saveAuthProfileStore,
} from "./store.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
// Cross-account-leak defense-in-depth: the three adopt sites in oauth.ts
// now all call isSameOAuthIdentity before copying main-store credentials
// into the sub-agent store. This suite exercises each of those sites
// with a mismatched accountId on main vs. sub and asserts the adoption
// is refused (sub store keeps its own credential; main's creds do not
// leak through).
let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile;
let resetOAuthRefreshQueuesForTest: typeof import("./oauth.js").resetOAuthRefreshQueuesForTest;
async function loadOAuthModuleForTest() {
({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js"));
}
const {
refreshProviderOAuthCredentialWithPluginMock,
formatProviderAuthProfileApiKeyWithPluginMock,
} = vi.hoisted(() => ({
refreshProviderOAuthCredentialWithPluginMock: vi.fn(
async (_params?: { context?: unknown }) => undefined,
),
formatProviderAuthProfileApiKeyWithPluginMock: vi.fn(() => undefined),
}));
vi.mock("../cli-credentials.js", () => ({
readCodexCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
writeCodexCliCredentials: () => true,
}));
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
formatProviderAuthProfileApiKeyWithPlugin: (params: { context?: { access?: string } }) =>
formatProviderAuthProfileApiKeyWithPluginMock() ?? params?.context?.access,
refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock,
}));
vi.mock("./doctor.js", () => ({
formatAuthDoctorHint: async () => undefined,
}));
vi.mock("./external-cli-sync.js", () => ({
syncExternalCliCredentials: () => false,
readManagedExternalCliCredential: () => null,
areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b,
}));
function oauthCred(params: {
provider: string;
access: string;
refresh: string;
expires: number;
accountId?: string;
email?: string;
}): OAuthCredential {
return { type: "oauth", ...params };
}
function storeWith(profileId: string, cred: OAuthCredential): AuthProfileStore {
return { version: 1, profiles: { [profileId]: cred } };
}
describe("OAuth credential adoption is identity-gated", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let tempRoot = "";
let mainAgentDir = "";
beforeEach(async () => {
resetFileLockStateForTest();
refreshProviderOAuthCredentialWithPluginMock.mockReset();
refreshProviderOAuthCredentialWithPluginMock.mockResolvedValue(undefined);
formatProviderAuthProfileApiKeyWithPluginMock.mockReset();
formatProviderAuthProfileApiKeyWithPluginMock.mockReturnValue(undefined);
clearRuntimeAuthProfileStoreSnapshots();
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-adopt-identity-"));
process.env.OPENCLAW_STATE_DIR = tempRoot;
mainAgentDir = path.join(tempRoot, "agents", "main", "agent");
await fs.mkdir(mainAgentDir, { recursive: true });
await loadOAuthModuleForTest();
resetOAuthRefreshQueuesForTest();
});
afterEach(async () => {
envSnapshot.restore();
resetFileLockStateForTest();
clearRuntimeAuthProfileStoreSnapshots();
if (resetOAuthRefreshQueuesForTest) {
resetOAuthRefreshQueuesForTest();
}
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("adoptNewerMainOAuthCredential refuses to adopt across accountId mismatch (pre-refresh path)", async () => {
// Scenario: sub-agent starts with a still-valid OAuth cred (so no
// refresh is triggered), but main holds an even fresher cred for a
// different account. The pre-refresh adopt must refuse.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const subExpiry = Date.now() + 10 * 60 * 1000;
const mainFresher = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-prerefresh", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "sub-own-access",
refresh: "sub-own-refresh",
expires: subExpiry,
accountId: "acct-sub",
}),
),
subAgentDir,
);
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-foreign-access",
refresh: "main-foreign-refresh",
expires: mainFresher,
accountId: "acct-other",
}),
),
mainAgentDir,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
// Sub-agent must keep using its own access token, not main's foreign one.
expect(result?.apiKey).toBe("sub-own-access");
// Sub-agent store must NOT have been overwritten with main's foreign cred.
const subRaw = JSON.parse(
await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(subRaw.profiles[profileId]).toMatchObject({
access: "sub-own-access",
accountId: "acct-sub",
});
});
it("inside-the-lock main adoption refuses across accountId mismatch and proceeds to own refresh", async () => {
// Scenario: sub-agent's cred is expired, enters refreshOAuthTokenWithLock.
// Inside the lock, main holds FRESH creds for a DIFFERENT account. The
// inside-lock adopt branch must refuse and fall through to the HTTP
// refresh path using the sub-agent's own refresh token.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-insidelock", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "sub-stale-access",
refresh: "sub-refresh-token",
expires: Date.now() - 60_000,
accountId: "acct-sub",
}),
),
subAgentDir,
);
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-foreign-access",
refresh: "main-foreign-refresh",
expires: freshExpiry,
accountId: "acct-other",
}),
),
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
type: "oauth",
provider,
access: "sub-refreshed-access",
refresh: "sub-refreshed-refresh",
expires: freshExpiry,
accountId: "acct-sub",
}) as never,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
// Sub-agent performed its own refresh (mock fired once) and got its
// own new token, not main's foreign one.
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
expect(result?.apiKey).toBe("sub-refreshed-access");
// Main must still hold its foreign cred, untouched (mirror would also
// refuse because of identity mismatch).
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
access: "main-foreign-access",
accountId: "acct-other",
});
});
it("adoptNewerMainOAuthCredential still adopts when sub has no identity but main does (upgrade tolerance)", async () => {
// Scenario: sub-agent stored its cred before accountId/email were
// captured. Main has fresh cred with accountId. Under the STRICT rule
// this would refuse (asymmetric). Under the relaxed rule used for
// adoption it must allow — otherwise #26322 breaks for existing
// installs on upgrade.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const subExpiry = Date.now() + 10 * 60 * 1000;
const mainFresher = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-upgrade", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "sub-own-access",
refresh: "sub-own-refresh",
expires: subExpiry,
// no accountId / email — pre-capture state
}),
),
subAgentDir,
);
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-fresher-access",
refresh: "main-fresher-refresh",
expires: mainFresher,
accountId: "acct-main",
}),
),
mainAgentDir,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
// Sub must have adopted main's fresher credential.
expect(result?.apiKey).toBe("main-fresher-access");
const subRaw = JSON.parse(
await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(subRaw.profiles[profileId]).toMatchObject({
access: "main-fresher-access",
accountId: "acct-main",
});
});
it("inside-the-lock adopt tolerates sub-no-identity / main-has-identity (upgrade case)", async () => {
// Same upgrade scenario but entering via the inside-lock adopt path:
// sub cred is EXPIRED (forces entry into refreshOAuthTokenWithLock),
// main has FRESH cred with accountId, sub has no identity.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-insidelock-upgrade", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "sub-stale-access",
refresh: "sub-stale-refresh",
expires: Date.now() - 60_000,
// no identity metadata
}),
),
subAgentDir,
);
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-fresh-access",
refresh: "main-fresh-refresh",
expires: freshExpiry,
accountId: "acct-main",
}),
),
mainAgentDir,
);
// Plugin refresh must NOT be called — sub should adopt main's fresh
// cred rather than performing its own refresh.
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
expect(result?.apiKey).toBe("main-fresh-access");
});
it("adoptNewerMainOAuthCredential refuses non-overlapping identity fields (sub has accountId, main has email)", async () => {
// Reviewer-requested: with no COMPARABLE shared identity field there
// is no positive-match evidence, so adoption must refuse.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const subExpiry = Date.now() + 10 * 60 * 1000;
const mainFresher = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-nonoverlap-pre", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "sub-own-access",
refresh: "sub-own-refresh",
expires: subExpiry,
accountId: "acct-sub",
// NO email on sub
}),
),
subAgentDir,
);
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-fresher-access",
refresh: "main-fresher-refresh",
expires: mainFresher,
email: "main@example.com",
// NO accountId on main
}),
),
mainAgentDir,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
// Sub must keep its own credential; pre-refresh adopt is refused.
expect(result?.apiKey).toBe("sub-own-access");
const subRaw = JSON.parse(
await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(subRaw.profiles[profileId]).toMatchObject({
access: "sub-own-access",
accountId: "acct-sub",
});
});
it("inside-the-lock adopt refuses non-overlapping identity fields (sub has accountId, main has email)", async () => {
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-nonoverlap-inside", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "sub-stale-access",
refresh: "sub-refresh-token",
expires: Date.now() - 60_000,
accountId: "acct-sub",
}),
),
subAgentDir,
);
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-fresh-access",
refresh: "main-fresh-refresh",
expires: freshExpiry,
email: "main@example.com",
}),
),
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
type: "oauth",
provider,
access: "sub-refreshed-access",
refresh: "sub-refreshed-refresh",
expires: freshExpiry,
accountId: "acct-sub",
}) as never,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
// Sub performed its own refresh rather than adopting main's email-only cred.
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
expect(result?.apiKey).toBe("sub-refreshed-access");
});
it("catch-block main-inherit refuses non-overlapping identity fields", async () => {
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-nonoverlap-catch", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "sub-stale-access",
refresh: "sub-refresh-token",
expires: Date.now() - 60_000,
accountId: "acct-sub",
}),
),
subAgentDir,
);
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-stale-access",
refresh: "main-stale-refresh",
expires: Date.now() - 60_000,
email: "main@example.com",
}),
),
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => {
// Another process writes a fresh email-only cred into main while
// our refresh is in-flight, then we throw a generic upstream error.
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-refreshed-access",
refresh: "main-refreshed-refresh",
expires: freshExpiry,
email: "main@example.com",
}),
),
mainAgentDir,
);
throw new Error("upstream 503 service unavailable");
});
// Catch-block main-inherit must refuse the non-overlapping cred and
// propagate the original error rather than leaking main's credential.
await expect(
resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
}),
).rejects.toThrow(/OAuth token refresh failed for openai-codex/);
});
it("catch-block main-inherit tolerates sub-no-identity / main-has-identity (upgrade case)", async () => {
// Upgrade scenario hitting the catch-block fallback: sub refresh
// throws, main later carries fresh cred with identity. Sub must
// inherit rather than surface the error to the caller.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-catch-upgrade", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "sub-stale-access",
refresh: "sub-stale-refresh",
expires: Date.now() - 60_000,
// no identity metadata
}),
),
subAgentDir,
);
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-stale-access",
refresh: "main-stale-refresh",
expires: Date.now() - 60_000,
accountId: "acct-main",
}),
),
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => {
// Another process refreshes main while our refresh is in flight.
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-refreshed-access",
refresh: "main-refreshed-refresh",
expires: freshExpiry,
accountId: "acct-main",
}),
),
mainAgentDir,
);
throw new Error("upstream 503 service unavailable");
});
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
// Sub inherited main's fresh cred via the catch-block fallback.
expect(result?.apiKey).toBe("main-refreshed-access");
});
it("catch-block main-inherit refuses across accountId mismatch and surfaces the original error", async () => {
// Scenario: sub-agent refresh throws a non-refresh_token_reused error.
// Main has fresh creds for a DIFFERENT account. The catch-block
// main-inherit fallback must refuse to adopt and let the original
// error propagate (wrapped).
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-catch-refuse", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "sub-stale",
refresh: "sub-refresh-token",
expires: Date.now() - 60_000,
accountId: "acct-sub",
}),
),
subAgentDir,
);
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-foreign-access",
refresh: "main-foreign-refresh",
expires: Date.now() - 60_000,
accountId: "acct-other",
}),
),
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => {
// Simulate another process writing fresh creds to main for a
// DIFFERENT account while our refresh is in flight, then our
// refresh throws a generic upstream error.
saveAuthProfileStore(
storeWith(
profileId,
oauthCred({
provider,
access: "main-foreign-refreshed",
refresh: "main-foreign-refresh-new",
expires: freshExpiry,
accountId: "acct-other",
}),
),
mainAgentDir,
);
throw new Error("upstream 503 service unavailable");
});
await expect(
resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
}),
).rejects.toThrow(/OAuth token refresh failed for openai-codex/);
// Sub-agent store must still have its own stale cred \u2014 no leak.
const subRaw = JSON.parse(
await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(subRaw.profiles[profileId]).toMatchObject({
access: "sub-stale",
accountId: "acct-sub",
});
});
});

View File

@@ -0,0 +1,170 @@
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 { resetFileLockStateForTest } from "../../infra/file-lock.js";
import { captureEnv } from "../../test-utils/env.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
saveAuthProfileStore,
} from "./store.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile;
let resetOAuthRefreshQueuesForTest: typeof import("./oauth.js").resetOAuthRefreshQueuesForTest;
async function loadOAuthModuleForTest() {
({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js"));
}
const {
refreshProviderOAuthCredentialWithPluginMock,
formatProviderAuthProfileApiKeyWithPluginMock,
} = vi.hoisted(() => ({
refreshProviderOAuthCredentialWithPluginMock: vi.fn(
async (_params?: { context?: unknown }) => undefined,
),
formatProviderAuthProfileApiKeyWithPluginMock: vi.fn(() => undefined),
}));
vi.mock("../cli-credentials.js", () => ({
readCodexCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
writeCodexCliCredentials: () => true,
}));
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
formatProviderAuthProfileApiKeyWithPlugin: (params: { context?: { access?: string } }) =>
formatProviderAuthProfileApiKeyWithPluginMock() ?? params?.context?.access,
refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock,
}));
vi.mock("./doctor.js", () => ({
formatAuthDoctorHint: async () => undefined,
}));
// External-CLI sync does real I/O against the user's Codex/MiniMax CLI
// credential files; it is slow and can pollute test state. Stub it to a no-op
// so the suite only exercises in-repo auth-profile logic.
vi.mock("./external-cli-sync.js", () => ({
syncExternalCliCredentials: () => false,
readManagedExternalCliCredential: () => null,
areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b,
}));
function createExpiredOauthStore(params: {
profileId: string;
provider: string;
access?: string;
refresh?: string;
accountId?: string;
email?: string;
}): AuthProfileStore {
return {
version: 1,
profiles: {
[params.profileId]: {
type: "oauth",
provider: params.provider,
access: params.access ?? "cached-access-token",
refresh: params.refresh ?? "refresh-token",
expires: Date.now() - 60_000,
accountId: params.accountId,
email: params.email,
} satisfies OAuthCredential,
},
};
}
describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let tempRoot = "";
let mainAgentDir = "";
beforeEach(async () => {
resetFileLockStateForTest();
refreshProviderOAuthCredentialWithPluginMock.mockReset();
refreshProviderOAuthCredentialWithPluginMock.mockResolvedValue(undefined);
formatProviderAuthProfileApiKeyWithPluginMock.mockReset();
formatProviderAuthProfileApiKeyWithPluginMock.mockReturnValue(undefined);
clearRuntimeAuthProfileStoreSnapshots();
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-concurrent-"));
process.env.OPENCLAW_STATE_DIR = tempRoot;
mainAgentDir = path.join(tempRoot, "agents", "main", "agent");
await fs.mkdir(mainAgentDir, { recursive: true });
await loadOAuthModuleForTest();
// Drop any refresh-queue entries left behind by a prior timed-out test.
resetOAuthRefreshQueuesForTest();
});
afterEach(async () => {
envSnapshot.restore();
resetFileLockStateForTest();
clearRuntimeAuthProfileStoreSnapshots();
if (resetOAuthRefreshQueuesForTest) {
resetOAuthRefreshQueuesForTest();
}
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("refreshes exactly once when 20 agents share one OAuth profile and all race on expiry", async () => {
const profileId = "openai-codex:default";
const provider = "openai-codex";
const accountId = "acct-shared";
const freshExpiry = Date.now() + 60 * 60 * 1000;
// Seed 20 sub-agents + main with the SAME stale OAuth credential. Main is
// also expired so it cannot short-circuit via adoptNewerMainOAuthCredential.
const subAgents = await Promise.all(
Array.from({ length: 20 }, async (_, i) => {
const dir = path.join(tempRoot, "agents", `sub-${i}`, "agent");
await fs.mkdir(dir, { recursive: true });
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), dir);
return dir;
}),
);
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), mainAgentDir);
// Count invocations, and add small jitter to widen the race window.
let callCount = 0;
refreshProviderOAuthCredentialWithPluginMock.mockImplementation(async () => {
callCount += 1;
await new Promise((resolve) => setTimeout(resolve, 25));
return {
type: "oauth",
provider,
access: "cross-agent-refreshed-access",
refresh: "cross-agent-refreshed-refresh",
expires: freshExpiry,
accountId,
} as never;
});
// Fire all 20 agents concurrently. With the old per-agentDir lock this
// would produce ~20 concurrent refresh calls and 19 refresh_token_reused
// 401s. With the new global per-profile lock, only the first refresh is
// performed; the remaining 19 adopt the resulting fresh credentials.
const results = await Promise.all(
subAgents.map((agentDir) =>
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}),
),
);
expect(callCount).toBe(1);
expect(results).toHaveLength(20);
for (const result of results) {
expect(result).not.toBeNull();
expect(result?.apiKey).toBe("cross-agent-refreshed-access");
expect(result?.provider).toBe(provider);
}
}, // Generous timeout; the fix should complete well under 5s in practice.
60_000);
});

View File

@@ -0,0 +1,767 @@
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 { resetFileLockStateForTest } from "../../infra/file-lock.js";
import { captureEnv } from "../../test-utils/env.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
saveAuthProfileStore,
} from "./store.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile;
let resetOAuthRefreshQueuesForTest: typeof import("./oauth.js").resetOAuthRefreshQueuesForTest;
async function loadOAuthModuleForTest() {
({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js"));
}
const {
refreshProviderOAuthCredentialWithPluginMock,
formatProviderAuthProfileApiKeyWithPluginMock,
} = vi.hoisted(() => ({
refreshProviderOAuthCredentialWithPluginMock: vi.fn(
async (_params?: { context?: unknown }) => undefined,
),
formatProviderAuthProfileApiKeyWithPluginMock: vi.fn(() => undefined),
}));
vi.mock("../cli-credentials.js", () => ({
readCodexCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
writeCodexCliCredentials: () => true,
}));
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
formatProviderAuthProfileApiKeyWithPlugin: (params: { context?: { access?: string } }) =>
formatProviderAuthProfileApiKeyWithPluginMock() ?? params?.context?.access,
refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock,
}));
vi.mock("./doctor.js", () => ({
formatAuthDoctorHint: async () => undefined,
}));
vi.mock("./external-cli-sync.js", () => ({
syncExternalCliCredentials: () => false,
readManagedExternalCliCredential: () => null,
areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b,
}));
function createExpiredOauthStore(params: {
profileId: string;
provider: string;
access?: string;
refresh?: string;
accountId?: string;
email?: string;
}): AuthProfileStore {
return {
version: 1,
profiles: {
[params.profileId]: {
type: "oauth",
provider: params.provider,
access: params.access ?? "cached-access-token",
refresh: params.refresh ?? "refresh-token",
expires: Date.now() - 60_000,
accountId: params.accountId,
email: params.email,
} satisfies OAuthCredential,
},
};
}
describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let tempRoot = "";
let mainAgentDir = "";
beforeEach(async () => {
resetFileLockStateForTest();
refreshProviderOAuthCredentialWithPluginMock.mockReset();
refreshProviderOAuthCredentialWithPluginMock.mockResolvedValue(undefined);
formatProviderAuthProfileApiKeyWithPluginMock.mockReset();
formatProviderAuthProfileApiKeyWithPluginMock.mockReturnValue(undefined);
clearRuntimeAuthProfileStoreSnapshots();
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-mirror-"));
process.env.OPENCLAW_STATE_DIR = tempRoot;
mainAgentDir = path.join(tempRoot, "agents", "main", "agent");
await fs.mkdir(mainAgentDir, { recursive: true });
await loadOAuthModuleForTest();
resetOAuthRefreshQueuesForTest();
});
afterEach(async () => {
envSnapshot.restore();
resetFileLockStateForTest();
clearRuntimeAuthProfileStoreSnapshots();
if (resetOAuthRefreshQueuesForTest) {
resetOAuthRefreshQueuesForTest();
}
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("mirrors refreshed credentials into the main store so peers skip refresh", async () => {
const profileId = "openai-codex:default";
const provider = "openai-codex";
const accountId = "acct-shared";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-mirror", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), subAgentDir);
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), mainAgentDir);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
type: "oauth",
provider,
access: "sub-refreshed-access",
refresh: "sub-refreshed-refresh",
expires: freshExpiry,
accountId,
}) as never,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
expect(result?.apiKey).toBe("sub-refreshed-access");
// Main store should now carry the refreshed credential, so a peer agent
// starting fresh will adopt rather than race.
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
access: "sub-refreshed-access",
refresh: "sub-refreshed-refresh",
expires: freshExpiry,
});
});
it("does not mirror when refresh was performed from the main agent itself", async () => {
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
saveAuthProfileStore(
createExpiredOauthStore({ profileId, provider, access: "main-stale-access" }),
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
type: "oauth",
provider,
access: "main-refreshed-access",
refresh: "main-refreshed-refresh",
expires: freshExpiry,
}) as never,
);
// Main-agent refresh uses undefined agentDir; the mirror path is a no-op
// (local == main). Just make sure the main store still reflects the refresh
// and no double-write happens.
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(undefined),
profileId,
agentDir: undefined,
});
expect(result?.apiKey).toBe("main-refreshed-access");
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
access: "main-refreshed-access",
refresh: "main-refreshed-refresh",
expires: freshExpiry,
});
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
});
it("refuses to mirror when main has a non-oauth entry for the same profileId", async () => {
// Exercises the `existing.type !== "oauth"` early-return in the mirror
// updater. If the operator has manually switched the main profile to
// an api_key, a secondary-agent's OAuth refresh must not clobber it.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-non-oauth", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider }), subAgentDir);
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "api_key",
provider,
key: "operator-key",
},
},
},
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
type: "oauth",
provider,
access: "sub-refreshed-access",
refresh: "sub-refreshed-refresh",
expires: freshExpiry,
}) as never,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
expect(result?.apiKey).toBe("sub-refreshed-access");
// Main must still hold the operator's api_key, untouched.
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
type: "api_key",
key: "operator-key",
});
});
it("refuses to mirror when identity (accountId) mismatches", async () => {
// Exercises the CWE-284 identity gate: main carries acct-other, sub-agent
// refreshes as acct-mine — mirror must be refused.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-bad-identity", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
createExpiredOauthStore({
profileId,
provider,
access: "sub-stale",
accountId: "acct-mine",
}),
subAgentDir,
);
// Main has a different account for the same profileId — this is the
// cross-account-leak scenario that the gate must block.
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider,
access: "main-other-access",
refresh: "main-other-refresh",
expires: Date.now() - 60_000,
accountId: "acct-other",
},
},
},
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
type: "oauth",
provider,
access: "sub-refreshed-access",
refresh: "sub-refreshed-refresh",
expires: freshExpiry,
accountId: "acct-mine",
}) as never,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
// Sub-agent gets its fresh token as usual.
expect(result?.apiKey).toBe("sub-refreshed-access");
// But main store must still hold acct-other's credential unchanged.
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
access: "main-other-access",
accountId: "acct-other",
});
});
it("refuses to mirror when main already has a strictly-fresher credential", async () => {
// Exercises the `existing.expires >= refreshed.expires` early-return.
// Scenario: main already completed a refresh (with a later expiry) while
// the sub-agent's refresh was in-flight; our mirror must not regress it.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const subFreshExpiry = Date.now() + 30 * 60 * 1000;
const mainFresherExpiry = Date.now() + 90 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-older", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
createExpiredOauthStore({ profileId, provider, accountId: "acct-shared" }),
subAgentDir,
);
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider,
access: "main-already-fresh",
refresh: "main-already-fresh-refresh",
expires: mainFresherExpiry,
accountId: "acct-shared",
},
},
},
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
type: "oauth",
provider,
access: "sub-refreshed-older",
refresh: "sub-refreshed-older-refresh",
expires: subFreshExpiry,
accountId: "acct-shared",
}) as never,
);
// The sub-agent will actually adopt main's fresher creds via the inside-
// lock recheck (that's the whole point of #26322), so refresh may not
// even fire. We only care that the main store is not regressed.
await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
access: "main-already-fresh",
expires: mainFresherExpiry,
});
});
it("refuses to mirror when main has a different provider for the same profileId", async () => {
// Exercises the `existing.provider !== params.refreshed.provider` branch
// in the mirror updater. Main holds a credential under the same profileId
// but for a different provider — mirror must refuse so we never silently
// rewrite a provider.
const profileId = "shared:default";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-provmismatch", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
createExpiredOauthStore({ profileId, provider: "openai-codex" }),
subAgentDir,
);
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic", // deliberately different
access: "main-anthropic-access",
refresh: "main-anthropic-refresh",
expires: Date.now() + 60 * 60 * 1000,
},
},
},
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
type: "oauth",
provider: "openai-codex",
access: "sub-refreshed-access",
refresh: "sub-refreshed-refresh",
expires: freshExpiry,
}) as never,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
expect(result?.apiKey).toBe("sub-refreshed-access");
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
// Main must still hold its anthropic entry, not the openai-codex one.
expect(mainRaw.profiles[profileId]).toMatchObject({
provider: "anthropic",
access: "main-anthropic-access",
});
});
it("mirrors when main's existing cred has a non-finite expires (treated as overwritable)", async () => {
// Exercises the `Number.isFinite(existing.expires)` branch — when main
// has a stored cred with NaN/missing expiry, we treat it as overwritable
// rather than refusing to write a fresh one.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const accountId = "acct-shared";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-nanexp", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), subAgentDir);
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider,
access: "main-nan-access",
refresh: "main-nan-refresh",
expires: Number.NaN,
accountId,
},
},
},
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
type: "oauth",
provider,
access: "sub-refreshed-access",
refresh: "sub-refreshed-refresh",
expires: freshExpiry,
accountId,
}) as never,
);
await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
access: "sub-refreshed-access",
expires: freshExpiry,
});
});
it("inherits main-agent credentials via the pre-refresh adopt path when main is already fresher", async () => {
// Exercises adoptNewerMainOAuthCredential at the top of
// resolveApiKeyForProfile: main is fresher at flow start, so we adopt
// BEFORE the refresh attempt. End-user outcome: sub transparently uses
// main's creds.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-fail-inherit", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
createExpiredOauthStore({ profileId, provider, accountId: "acct-shared" }),
subAgentDir,
);
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider,
access: "main-fresh-access",
refresh: "main-fresh-refresh",
expires: freshExpiry,
accountId: "acct-shared",
},
},
},
mainAgentDir,
);
// Refresh mock intentionally left as default-undefined — it should not
// be called, the pre-refresh adopt wins.
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
expect(result?.apiKey).toBe("main-fresh-access");
expect(result?.provider).toBe(provider);
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
});
it("inherits main-agent credentials via the catch-block fallback when refresh throws after main becomes fresh", async () => {
// Exercises the specific catch-block `if (params.agentDir) { mainStore … }`
// branch (lines 826-848 in oauth.ts). Setup:
// 1. sub + main BOTH expired at the start of resolveApiKeyForProfile,
// so adoptNewerMainOAuthCredential does not short-circuit.
// 2. Inside refreshOAuthTokenWithLock, the plugin refresh mock writes
// fresh credentials into the main store and then throws a non-
// refresh_token_reused error. This simulates "another process
// completed a refresh just as ours failed".
// 3. The catch block's loadFreshStoredOAuthCredential reads the sub
// store (still expired). Then the main-agent-inherit fallback
// kicks in, copies main's fresh creds into the sub store, and
// returns them.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-catch-inherit", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(
createExpiredOauthStore({ profileId, provider, accountId: "acct-shared" }),
subAgentDir,
);
saveAuthProfileStore(
createExpiredOauthStore({ profileId, provider, accountId: "acct-shared" }),
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => {
// Simulate another agent completing its refresh and writing fresh
// creds to main, concurrent with our attempt.
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider,
access: "main-side-refreshed-access",
refresh: "main-side-refreshed-refresh",
expires: freshExpiry,
accountId: "acct-shared",
},
},
},
mainAgentDir,
);
// Now throw a non-refresh_token_reused error so we fall through the
// recovery branches into the catch-block main-agent inherit.
throw new Error("upstream 503 service unavailable");
});
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
expect(result?.apiKey).toBe("main-side-refreshed-access");
expect(result?.provider).toBe(provider);
// Sub-agent's store should now carry main's creds (inherited).
const subRaw = JSON.parse(
await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(subRaw.profiles[profileId]).toMatchObject({
access: "main-side-refreshed-access",
expires: freshExpiry,
});
});
it("mirrors an identity-carrying refresh into a main store that has no identity (upgrade)", async () => {
// The Codex P1 scenario: main holds a pre-capture OAuth record (no
// accountId), the fresh sub-agent refresh response carries accountId.
// Mirror must accept so subsequent peers can adopt from main instead
// of hitting refresh_token_reused.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-upgrade-mirror", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
// Sub has accountId (modern capture); stale.
saveAuthProfileStore(
createExpiredOauthStore({ profileId, provider, accountId: "acct-sub" }),
subAgentDir,
);
// Main is pre-capture — no accountId at all.
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider,
access: "main-pre-capture-access",
refresh: "main-pre-capture-refresh",
expires: Date.now() - 60_000,
},
},
},
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
type: "oauth",
provider,
access: "sub-refreshed-access",
refresh: "sub-refreshed-refresh",
expires: freshExpiry,
accountId: "acct-sub",
}) as never,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
expect(result?.apiKey).toBe("sub-refreshed-access");
// Main must have accepted the mirror, with the identity marker added.
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
access: "sub-refreshed-access",
accountId: "acct-sub",
});
});
it("refuses to mirror when incoming drops an identity field present on main (regression guard)", async () => {
// Inverse of the upgrade test: main has accountId, incoming refresh
// response lacks it. Mirror must refuse so the identity marker is
// preserved — dropping it would later let a different-account sub pass
// the relaxed adoption gate.
const profileId = "openai-codex:default";
const provider = "openai-codex";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-regression", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider }), subAgentDir);
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider,
access: "main-identity-access",
refresh: "main-identity-refresh",
expires: Date.now() + 30 * 60 * 1000,
accountId: "acct-main",
},
},
},
mainAgentDir,
);
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
type: "oauth",
provider,
access: "sub-refreshed-no-identity",
refresh: "sub-refreshed-no-identity-refresh",
expires: freshExpiry,
// intentionally no accountId / no email — the regression case
}) as never,
);
await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
// Main must still hold its accountId-bearing credential; mirror refused.
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
access: "main-identity-access",
accountId: "acct-main",
});
});
it("mirrors refreshed credentials produced by the plugin-refresh path", async () => {
// The plugin-refreshed branch in doRefreshOAuthTokenWithLock has its own
// mirror call; cover it separately so the branch is not orphaned.
const profileId = "anthropic:plugin";
const provider = "anthropic";
const accountId = "acct-plugin";
const freshExpiry = Date.now() + 60 * 60 * 1000;
const subAgentDir = path.join(tempRoot, "agents", "sub-plugin", "agent");
await fs.mkdir(subAgentDir, { recursive: true });
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), subAgentDir);
saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), mainAgentDir);
// Plugin returns a truthy refreshed credential — this takes the plugin
// branch instead of falling through to getOAuthApiKey.
refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(
async () =>
({
access: "plugin-refreshed-access",
refresh: "plugin-refreshed-refresh",
expires: freshExpiry,
}) as never,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
});
expect(result?.apiKey).toBe("plugin-refreshed-access");
// Main store must have been mirrored from the plugin-refresh branch.
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(mainRaw.profiles[profileId]).toMatchObject({
access: "plugin-refreshed-access",
refresh: "plugin-refreshed-refresh",
expires: freshExpiry,
});
});
});

View File

@@ -17,20 +17,26 @@ import { resolveSecretRefString, type SecretRefResolveCache } from "../../secret
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { writeCodexCliCredentials } from "../cli-credentials.js";
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
import {
AUTH_STORE_LOCK_OPTIONS,
OAUTH_REFRESH_CALL_TIMEOUT_MS,
OAUTH_REFRESH_LOCK_OPTIONS,
log,
} from "./constants.js";
import { resolveTokenExpiryState } from "./credential-state.js";
import { formatAuthDoctorHint } from "./doctor.js";
import {
areOAuthCredentialsEquivalent,
readManagedExternalCliCredential,
} from "./external-cli-sync.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { ensureAuthStoreFile, resolveAuthStorePath, resolveOAuthRefreshLockPath } from "./paths.js";
import { assertNoOAuthSecretRefPolicyViolations } from "./policy.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
import {
ensureAuthProfileStore,
loadAuthProfileStoreForSecretsRuntime,
saveAuthProfileStore,
updateAuthProfileStoreWithLock,
} from "./store.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
@@ -125,7 +131,7 @@ function extractErrorMessage(error: unknown): string {
return formatErrorMessage(error);
}
function isRefreshTokenReusedError(error: unknown): boolean {
export function isRefreshTokenReusedError(error: unknown): boolean {
const message = normalizeLowercaseStringOrEmpty(extractErrorMessage(error));
return (
message.includes("refresh_token_reused") ||
@@ -195,7 +201,12 @@ function adoptNewerMainOAuthCredential(params: {
mainCred?.type === "oauth" &&
mainCred.provider === params.cred.provider &&
Number.isFinite(mainCred.expires) &&
(!Number.isFinite(params.cred.expires) || mainCred.expires > params.cred.expires)
(!Number.isFinite(params.cred.expires) || mainCred.expires > params.cred.expires) &&
// Defense-in-depth against cross-account leaks: refuse on positive
// mismatch, identity regression, or non-overlapping-field
// credentials. Tolerates the pure upgrade case where the sub has
// no identity metadata yet and main does.
isSafeToCopyOAuthIdentity(params.cred, mainCred)
) {
params.store.profiles[params.profileId] = { ...mainCred };
saveAuthProfileStore(params.store, params.agentDir);
@@ -216,128 +227,539 @@ function adoptNewerMainOAuthCredential(params: {
return null;
}
// In-process serialization: callers for the same provider+profileId are
// chained so only one enters doRefreshOAuthTokenWithLock at a time.
// Necessary because withFileLock is re-entrant within the same PID
// (HELD_LOCKS short-circuits), which would otherwise let two concurrent
// same-PID callers both pass the file lock gate and race to refresh.
//
// The key is `${provider}\0${profileId}` (matching the cross-agent file
// lock key) so two profiles that happen to share a profileId across
// providers do not needlessly serialize against each other.
const refreshQueues = new Map<string, Promise<unknown>>();
function refreshQueueKey(provider: string, profileId: string): string {
return `${provider}\u0000${profileId}`;
}
/**
* Wrap an async call with a deadline after which the caller sees a
* timeout rejection and releases its locks. Used on the OAuth refresh
* critical section so the in-flight lock cannot outlive
* OAUTH_REFRESH_LOCK_OPTIONS.stale.
*
* LIMITATION: this does NOT cancel the underlying work. JavaScript
* promises are not cancellable and the pi-ai OAuth stack does not
* currently accept an AbortSignal. When the deadline fires the caller
* moves on and releases its file lock, but the original `fn()` promise
* keeps running in the background. That means a slow upstream refresh
* could still burn a refresh token well after we have given up on it,
* and a waiting peer that has now taken the lock may hit
* `refresh_token_reused`.
*
* The existing `isRefreshTokenReusedError` recovery path is the backstop
* for that residual case — it reloads from the main store and adopts if
* another agent's refresh has since landed. A fuller fix requires
* plumbing `AbortSignal` through the refresh stack into the HTTP
* client; tracked as a follow-up.
*/
async function withRefreshCallTimeout<T>(
label: string,
timeoutMs: number,
fn: () => Promise<T>,
): Promise<T> {
let timeoutHandle: NodeJS.Timeout | undefined;
try {
return await new Promise<T>((resolve, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`OAuth refresh call "${label}" exceeded hard timeout (${timeoutMs}ms)`));
}, timeoutMs);
fn().then(resolve, reject);
});
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
/**
* Drop any in-flight entries in the module-level refresh queue. Intended
* exclusively for tests that exercise the concurrent-refresh surface; a
* timed-out test can leave pending gates in the map and confuse subsequent
* tests that share the same Vitest worker.
*/
export function resetOAuthRefreshQueuesForTest(): void {
refreshQueues.clear();
}
async function refreshOAuthTokenWithLock(params: {
profileId: string;
provider: string;
agentDir?: string;
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
const key = refreshQueueKey(params.provider, params.profileId);
const prev = refreshQueues.get(key) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
refreshQueues.set(key, gate);
try {
await prev;
return await doRefreshOAuthTokenWithLock(params);
} finally {
release();
if (refreshQueues.get(key) === gate) {
refreshQueues.delete(key);
}
}
}
/**
* Mirror a refreshed OAuth credential back into the main-agent store so peer
* agents adopt it on their next `adoptNewerMainOAuthCredential` pass instead
* of racing to refresh the (now-single-used) refresh token.
*
* Identity binding (CWE-284): we require positive evidence the existing main
* credential and the refreshed credential belong to the same account before
* overwriting. If both sides expose `accountId` (strongest signal, Codex CLI)
* they must match; otherwise if both expose `email` they must match (case-
* insensitive, trimmed). Provider-only matches are not sufficient because
* nothing guarantees two agents with the same profileId are authenticated as
* the same user. This prevents a compromised sub-agent from poisoning the
* main store's credentials.
*
* Serialization: uses `updateAuthProfileStoreWithLock` so the read-modify-
* write takes the main-store lock and cannot race with other main-store
* writers (e.g. `updateAuthProfileStoreWithLock` in other flows, CLI-sync).
*
* Intentionally best-effort: a failure here must not fail the caller's
* refresh, since the credential has already been persisted to the agent's
* own store and returned to the requester.
*/
export function normalizeAuthIdentityToken(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function normalizeAuthEmailToken(value: string | undefined): string | undefined {
return normalizeAuthIdentityToken(value)?.toLowerCase();
}
/**
* Returns true if `existing` and `incoming` provably belong to the same
* account. Used to gate cross-agent credential mirroring.
*
* The rule is intentionally strict to satisfy the CWE-284 model:
* 1. If one side carries identity metadata (accountId or email) and the
* other does not, refuse — we have no evidence they match.
* 2. If both sides carry identity, a shared field must match (accountId
* wins over email when both present). If the two sides carry identity
* in non-overlapping fields (one has only accountId, the other only
* email), refuse.
* 3. If neither side carries identity, return true: no evidence of
* mismatch and provider equality is checked separately by the caller.
*
* The previous permissive behaviour (fall back to `true` whenever a strict
* comparison could not be made) was unsafe: a sub-agent whose refreshed
* credential lacked identity metadata could overwrite a known-account main
* credential that had it, allowing cross-account poisoning through the
* mirror path.
*/
export function isSameOAuthIdentity(
existing: Pick<OAuthCredential, "accountId" | "email">,
incoming: Pick<OAuthCredential, "accountId" | "email">,
): boolean {
const aAcct = normalizeAuthIdentityToken(existing.accountId);
const bAcct = normalizeAuthIdentityToken(incoming.accountId);
const aEmail = normalizeAuthEmailToken(existing.email);
const bEmail = normalizeAuthEmailToken(incoming.email);
const aHasIdentity = aAcct !== undefined || aEmail !== undefined;
const bHasIdentity = bAcct !== undefined || bEmail !== undefined;
// Asymmetric identity evidence — refuse. We cannot prove the two
// credentials belong to the same account.
if (aHasIdentity !== bHasIdentity) {
return false;
}
// Both sides carry identity — require a positive match on a shared field.
if (aHasIdentity) {
if (aAcct !== undefined && bAcct !== undefined) {
return aAcct === bAcct;
}
if (aEmail !== undefined && bEmail !== undefined) {
return aEmail === bEmail;
}
// Identity metadata is present on both sides but in non-overlapping
// fields (one has accountId, the other has only email, or vice versa).
// No shared field to compare — refuse rather than guess.
return false;
}
// Neither side carries identity metadata — provider equality is checked
// separately by the caller; no evidence of mismatch here.
return true;
}
/**
* Identity gate used for both directions of credential copy:
* - mirror (sub-agent refresh -> main agent store)
* - adopt (main agent store -> sub-agent store)
*
* Rule: allow the copy iff
* 1. no positive identity mismatch — if both sides expose the same
* identity field (accountId or email), the values must match, AND
* 2. the incoming credential carries at least as much identity
* evidence as the existing one — if existing has accountId/email,
* incoming must carry the same field, AND
* 3. when both sides carry identity but in non-overlapping fields
* (existing has only accountId, incoming has only email, or vice
* versa) we cannot positively prove the same account and the copy
* is refused.
*
* Accepts:
* - matching accountId (positive match on strongest field)
* - matching email when accountId is absent on both sides
* - neither side carries identity (no evidence of mismatch)
* - existing has no identity, incoming has identity (UPGRADE: adds
* the marker without dropping anything)
*
* Refuses:
* - mismatching accountId or email on a shared field (CWE-284 core)
* - incoming drops an identity field present on existing (regression
* that would later let a wrong-account peer pass this gate)
* - non-overlapping fields (no comparable positive match)
*
* Design note: this is a single unified rule for both copy directions.
* The rule is deliberately one-sided because "existing" is whatever is
* about to be overwritten and "incoming" is the new data — the
* constraint is the same regardless of whether existing is main or sub.
*/
export function isSafeToCopyOAuthIdentity(
existing: Pick<OAuthCredential, "accountId" | "email">,
incoming: Pick<OAuthCredential, "accountId" | "email">,
): boolean {
const aAcct = normalizeAuthIdentityToken(existing.accountId);
const bAcct = normalizeAuthIdentityToken(incoming.accountId);
const aEmail = normalizeAuthEmailToken(existing.email);
const bEmail = normalizeAuthEmailToken(incoming.email);
// (1) Positive match on a shared field, if one exists.
if (aAcct !== undefined && bAcct !== undefined) {
return aAcct === bAcct;
}
if (aEmail !== undefined && bEmail !== undefined) {
return aEmail === bEmail;
}
// No shared comparable field beyond this point.
const aHasIdentity = aAcct !== undefined || aEmail !== undefined;
// (2) Refuse if existing has any identity evidence that incoming lacks.
// That covers both the "drop" case (incoming has nothing) and the
// "non-overlapping fields" case (existing has accountId only,
// incoming has email only, or vice versa).
if (aHasIdentity) {
return false;
}
// (3) Existing has no identity. Either incoming has none either
// (allowed: no evidence of mismatch) or incoming adds identity
// (allowed: pure upgrade, no loss).
return true;
}
async function mirrorRefreshedCredentialIntoMainStore(params: {
profileId: string;
refreshed: OAuthCredential;
}): Promise<void> {
try {
const mainPath = resolveAuthStorePath(undefined);
ensureAuthStoreFile(mainPath);
await updateAuthProfileStoreWithLock({
agentDir: undefined,
updater: (store) => {
const existing = store.profiles[params.profileId];
if (existing && existing.type !== "oauth") {
return false;
}
if (existing && existing.provider !== params.refreshed.provider) {
return false;
}
// Identity binding for the mirror direction, using the unified
// copy-safety gate. Accepts upgrades (main has no accountId yet,
// incoming does) while refusing positive mismatches, identity
// regressions, and non-overlapping-field credentials.
if (existing && !isSafeToCopyOAuthIdentity(existing, params.refreshed)) {
log.warn("refused to mirror OAuth credential: identity mismatch or regression", {
profileId: params.profileId,
});
return false;
}
// Only overwrite when the incoming credential is strictly fresher
// (or main has no usable expiry). Prevents clobbering a concurrent
// successful refresh performed by the main agent itself.
if (
existing &&
Number.isFinite(existing.expires) &&
Number.isFinite(params.refreshed.expires) &&
existing.expires >= params.refreshed.expires
) {
return false;
}
store.profiles[params.profileId] = { ...params.refreshed };
log.debug("mirrored refreshed OAuth credential to main agent store", {
profileId: params.profileId,
expires: Number.isFinite(params.refreshed.expires)
? new Date(params.refreshed.expires).toISOString()
: undefined,
});
return true;
},
});
} catch (err) {
log.debug("mirrorRefreshedCredentialIntoMainStore failed", {
profileId: params.profileId,
error: formatErrorMessage(err),
});
}
}
async function doRefreshOAuthTokenWithLock(params: {
profileId: string;
provider: string;
agentDir?: string;
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
const authPath = resolveAuthStorePath(params.agentDir);
ensureAuthStoreFile(authPath);
// Two-layer coordination:
// 1. Global refresh lock keyed on sha256(profileId): every agent trying
// to refresh the same profile acquires the same file lock, so only
// one HTTP refresh is in-flight at a time (#26322).
// 2. Per-store lock (AUTH_STORE_LOCK_OPTIONS) on this agent's
// auth-profiles.json: serializes the refresh's read-modify-writes
// with other writers of the same store (e.g. usage/profile updates
// via updateAuthProfileStoreWithLock, CLI sync).
// Lock acquisition order is always refresh -> per-store; non-refresh code
// paths only take the per-store lock, so no cycle is possible.
const globalRefreshLockPath = resolveOAuthRefreshLockPath(params.provider, params.profileId);
return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => {
// Locked refresh must bypass runtime snapshots so we can adopt fresher
// on-disk credentials written by another refresh attempt.
const store = loadAuthProfileStoreForSecretsRuntime(params.agentDir);
const cred = store.profiles[params.profileId];
if (!cred || cred.type !== "oauth") {
return null;
}
if (Date.now() < cred.expires) {
return {
apiKey: await buildOAuthApiKey(cred.provider, cred),
newCredentials: cred,
};
}
const externallyManaged = readManagedExternalCliCredential({
profileId: params.profileId,
credential: cred,
});
if (externallyManaged) {
if (!areOAuthCredentialsEquivalent(cred, externallyManaged)) {
store.profiles[params.profileId] = externallyManaged;
saveAuthProfileStore(store, params.agentDir);
return await withFileLock(globalRefreshLockPath, OAUTH_REFRESH_LOCK_OPTIONS, async () =>
withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => {
// Locked refresh must bypass runtime snapshots so we can adopt fresher
// on-disk credentials written by another refresh attempt.
const store = loadAuthProfileStoreForSecretsRuntime(params.agentDir);
const cred = store.profiles[params.profileId];
if (!cred || cred.type !== "oauth") {
return null;
}
if (Date.now() < externallyManaged.expires) {
if (Date.now() < cred.expires) {
return {
apiKey: await buildOAuthApiKey(externallyManaged.provider, externallyManaged),
newCredentials: externallyManaged,
apiKey: await buildOAuthApiKey(cred.provider, cred),
newCredentials: cred,
};
}
if (externallyManaged.managedBy === "codex-cli") {
const pluginRefreshed = await refreshProviderOAuthCredentialWithPlugin({
provider: externallyManaged.provider,
context: externallyManaged,
});
if (pluginRefreshed) {
const refreshedCredentials: OAuthCredential = {
...externallyManaged,
...pluginRefreshed,
type: "oauth",
managedBy: "codex-cli",
};
if (!writeCodexCliCredentials(refreshedCredentials)) {
log.warn("failed to persist refreshed codex credentials back to Codex storage", {
// Inside-the-lock recheck: a prior agent that already held this lock may
// have completed a refresh and mirrored its fresh credential into the
// main store. If so, adopt into the local store and return without
// issuing another HTTP refresh. This is what turns N serialized
// refreshes into 1 refresh + (N-1) adoptions, preventing the
// `refresh_token_reused` storm reported in #26322.
if (params.agentDir) {
try {
const mainStore = loadAuthProfileStoreForSecretsRuntime(undefined);
const mainCred = mainStore.profiles[params.profileId];
if (
mainCred?.type === "oauth" &&
mainCred.provider === cred.provider &&
Number.isFinite(mainCred.expires) &&
Date.now() < mainCred.expires &&
// Defense-in-depth identity gate. Tolerates the pure upgrade
// case (sub predates identity capture) but refuses positive
// mismatch, identity regression, and non-overlapping fields.
isSafeToCopyOAuthIdentity(cred, mainCred)
) {
store.profiles[params.profileId] = { ...mainCred };
saveAuthProfileStore(store, params.agentDir);
log.info("adopted fresh OAuth credential from main store (under refresh lock)", {
profileId: params.profileId,
agentDir: params.agentDir,
expires: new Date(mainCred.expires).toISOString(),
});
return {
apiKey: await buildOAuthApiKey(mainCred.provider, mainCred),
newCredentials: mainCred,
};
} else if (
mainCred?.type === "oauth" &&
mainCred.provider === cred.provider &&
Number.isFinite(mainCred.expires) &&
Date.now() < mainCred.expires &&
!isSafeToCopyOAuthIdentity(cred, mainCred)
) {
// Main has fresh creds but they belong to a DIFFERENT account —
// record the refusal so operators can diagnose, then proceed to
// our own refresh rather than leaking credentials.
log.warn("refused to adopt fresh main-store OAuth credential: identity mismatch", {
profileId: params.profileId,
agentDir: params.agentDir,
});
}
store.profiles[params.profileId] = refreshedCredentials;
saveAuthProfileStore(store, params.agentDir);
return {
apiKey: await buildOAuthApiKey(refreshedCredentials.provider, refreshedCredentials),
newCredentials: refreshedCredentials,
};
} catch (err) {
log.debug("inside-lock main-store adoption failed; proceeding to refresh", {
profileId: params.profileId,
error: formatErrorMessage(err),
});
}
}
throw new Error(
`${externallyManaged.managedBy} credential is expired; refresh it in the external CLI and retry.`,
);
}
if (cred.managedBy) {
throw new Error(
`${cred.managedBy} credential is unavailable; re-authenticate in the external CLI and retry.`,
);
}
const pluginRefreshed = await refreshProviderOAuthCredentialWithPlugin({
provider: cred.provider,
context: cred,
});
if (pluginRefreshed) {
const refreshedCredentials: OAuthCredential = {
const externallyManaged = readManagedExternalCliCredential({
profileId: params.profileId,
credential: cred,
});
if (externallyManaged) {
if (!areOAuthCredentialsEquivalent(cred, externallyManaged)) {
store.profiles[params.profileId] = externallyManaged;
saveAuthProfileStore(store, params.agentDir);
}
if (Date.now() < externallyManaged.expires) {
return {
apiKey: await buildOAuthApiKey(externallyManaged.provider, externallyManaged),
newCredentials: externallyManaged,
};
}
if (externallyManaged.managedBy === "codex-cli") {
const pluginRefreshed = await withRefreshCallTimeout(
`refreshProviderOAuthCredentialWithPlugin(${externallyManaged.provider}, codex-cli)`,
OAUTH_REFRESH_CALL_TIMEOUT_MS,
() =>
refreshProviderOAuthCredentialWithPlugin({
provider: externallyManaged.provider,
context: externallyManaged,
}),
);
if (pluginRefreshed) {
const refreshedCredentials: OAuthCredential = {
...externallyManaged,
...pluginRefreshed,
type: "oauth",
managedBy: "codex-cli",
};
if (!writeCodexCliCredentials(refreshedCredentials)) {
log.warn("failed to persist refreshed codex credentials back to Codex storage", {
profileId: params.profileId,
});
}
store.profiles[params.profileId] = refreshedCredentials;
saveAuthProfileStore(store, params.agentDir);
return {
apiKey: await buildOAuthApiKey(refreshedCredentials.provider, refreshedCredentials),
newCredentials: refreshedCredentials,
};
}
}
throw new Error(
`${externallyManaged.managedBy} credential is expired; refresh it in the external CLI and retry.`,
);
}
if (cred.managedBy) {
throw new Error(
`${cred.managedBy} credential is unavailable; re-authenticate in the external CLI and retry.`,
);
}
const pluginRefreshed = await withRefreshCallTimeout(
`refreshProviderOAuthCredentialWithPlugin(${cred.provider})`,
OAUTH_REFRESH_CALL_TIMEOUT_MS,
() =>
refreshProviderOAuthCredentialWithPlugin({
provider: cred.provider,
context: cred,
}),
);
if (pluginRefreshed) {
const refreshedCredentials: OAuthCredential = {
...cred,
...pluginRefreshed,
type: "oauth",
};
store.profiles[params.profileId] = refreshedCredentials;
saveAuthProfileStore(store, params.agentDir);
if (params.agentDir) {
const mainPath = resolveAuthStorePath(undefined);
if (mainPath !== authPath) {
await mirrorRefreshedCredentialIntoMainStore({
profileId: params.profileId,
refreshed: refreshedCredentials,
});
}
}
return {
apiKey: await buildOAuthApiKey(cred.provider, refreshedCredentials),
newCredentials: refreshedCredentials,
};
}
const oauthCreds: Record<string, OAuthCredentials> = { [cred.provider]: cred };
const result =
cred.provider === "chutes"
? await (async () => {
const newCredentials = await withRefreshCallTimeout(
`refreshChutesTokens(${cred.provider})`,
OAUTH_REFRESH_CALL_TIMEOUT_MS,
() => refreshChutesTokens({ credential: cred }),
);
return { apiKey: newCredentials.access, newCredentials };
})()
: await (async () => {
const oauthProvider = resolveOAuthProvider(cred.provider);
if (!oauthProvider) {
return null;
}
if (typeof getOAuthApiKey !== "function") {
return null;
}
return await withRefreshCallTimeout(
`getOAuthApiKey(${oauthProvider})`,
OAUTH_REFRESH_CALL_TIMEOUT_MS,
() => getOAuthApiKey(oauthProvider, oauthCreds),
);
})();
if (!result) {
return null;
}
const mergedCred: OAuthCredential = {
...cred,
...pluginRefreshed,
...result.newCredentials,
type: "oauth",
};
store.profiles[params.profileId] = refreshedCredentials;
store.profiles[params.profileId] = mergedCred;
saveAuthProfileStore(store, params.agentDir);
return {
apiKey: await buildOAuthApiKey(cred.provider, refreshedCredentials),
newCredentials: refreshedCredentials,
};
}
const oauthCreds: Record<string, OAuthCredentials> = { [cred.provider]: cred };
const result =
cred.provider === "chutes"
? await (async () => {
const newCredentials = await refreshChutesTokens({
credential: cred,
});
return { apiKey: newCredentials.access, newCredentials };
})()
: await (async () => {
const oauthProvider = resolveOAuthProvider(cred.provider);
if (!oauthProvider) {
return null;
}
if (typeof getOAuthApiKey !== "function") {
return null;
}
return await getOAuthApiKey(oauthProvider, oauthCreds);
})();
if (!result) {
return null;
}
store.profiles[params.profileId] = {
...cred,
...result.newCredentials,
type: "oauth",
};
saveAuthProfileStore(store, params.agentDir);
// Mirror the refreshed credential back into the main-agent store while
// both locks are still held (refresh lock + this agent's store lock)
// plus we'll take main-store lock inside the mirror. Doing this inside
// the refresh lock closes the cross-process race window where a second
// agent could acquire the refresh lock between our lock release and
// our main-store write, see only stale main creds, and redundantly
// refresh (reproducing refresh_token_reused).
if (params.agentDir) {
const mainPath = resolveAuthStorePath(undefined);
if (mainPath !== authPath) {
await mirrorRefreshedCredentialIntoMainStore({
profileId: params.profileId,
refreshed: mergedCred,
});
}
}
return result;
});
return result;
}),
);
}
async function tryResolveOAuthProfile(
@@ -369,6 +791,7 @@ async function tryResolveOAuthProfile(
const refreshed = await refreshOAuthTokenWithLock({
profileId,
provider: cred.provider,
agentDir: params.agentDir,
});
if (!refreshed) {
@@ -521,6 +944,7 @@ export async function resolveApiKeyForProfile(
try {
const result = await refreshOAuthTokenWithLock({
profileId,
provider: cred.provider,
agentDir: params.agentDir,
});
if (!result) {
@@ -563,6 +987,7 @@ export async function resolveApiKeyForProfile(
}
const retried = await refreshOAuthTokenWithLock({
profileId,
provider: cred.provider,
agentDir: params.agentDir,
});
if (retried) {
@@ -600,7 +1025,15 @@ export async function resolveApiKeyForProfile(
try {
const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir)
const mainCred = mainStore.profiles[profileId];
if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) {
if (
mainCred?.type === "oauth" &&
mainCred.provider === cred.provider &&
Date.now() < mainCred.expires &&
// Defense-in-depth identity gate — refuse to inherit credentials
// from a different account even under refresh failure. Tolerates
// pre-capture credentials but refuses regression/non-overlap.
isSafeToCopyOAuthIdentity(cred, mainCred)
) {
// Main agent has fresh credentials - copy them to this agent and use them
refreshedStore.profiles[profileId] = { ...mainCred };
saveAuthProfileStore(refreshedStore, params.agentDir);

View File

@@ -1,4 +1,6 @@
import { createHash } from "node:crypto";
import path from "node:path";
import { resolveStateDir } from "../../config/paths.js";
import { resolveUserPath } from "../../utils.js";
import { resolveOpenClawAgentDir } from "../agent-paths.js";
import {
@@ -31,3 +33,29 @@ export function resolveAuthStatePathForDisplay(agentDir?: string): string {
const pathname = resolveAuthStatePath(agentDir);
return pathname.startsWith("~") ? pathname : resolveUserPath(pathname);
}
/**
* Resolve the path of the cross-agent, per-profile OAuth refresh coordination
* lock. The filename hashes `provider\0profileId` so it is filesystem-safe
* for arbitrary unicode/control-character inputs and always bounded in
* length. The NUL separator makes it impossible to collide two distinct
* `(provider, profileId)` pairs by string concatenation.
*
* This lock is the serialization point that prevents the `refresh_token_reused`
* storm when N agents share one OAuth profile (see issue #26322): every agent
* that attempts a refresh acquires this same file lock, so only one HTTP
* refresh is in-flight at a time and peers can adopt the resulting fresh
* credentials instead of racing against a single-use refresh token.
*
* The key intentionally includes `provider` so that two profiles that
* happen to share a `profileId` across providers (operator-renamed profile,
* test fixture, etc.) do not needlessly serialize against each other.
*/
export function resolveOAuthRefreshLockPath(provider: string, profileId: string): string {
const hash = createHash("sha256");
hash.update(provider, "utf8");
hash.update("\u0000", "utf8"); // NUL separator: unambiguous boundary.
hash.update(profileId, "utf8");
const safeId = `sha256-${hash.digest("hex")}`;
return path.join(resolveStateDir(), "locks", "oauth-refresh", safeId);
}

View File

@@ -0,0 +1,144 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { captureEnv } from "../../test-utils/env.js";
import {
resolveAuthStatePath,
resolveAuthStatePathForDisplay,
resolveAuthStorePath,
resolveAuthStorePathForDisplay,
resolveLegacyAuthStorePath,
} from "./path-resolve.js";
import { ensureAuthStoreFile } from "./paths.js";
// Direct-import sanity tests. These helpers are exercised transitively by the
// wider auth-profile test suite via ESM re-exports through paths.ts, but v8
// coverage does not always attribute those transitive hits back to the
// original function bodies in path-resolve.ts. This file imports each helper
// directly from ./path-resolve.js (bypassing the re-export indirection) and
// calls it at least once so the coverage report is honest about what is and
// isn't tested.
describe("path-resolve helpers (direct-import coverage attribution)", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let stateDir = "";
beforeEach(async () => {
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-path-direct-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
});
afterEach(async () => {
envSnapshot.restore();
await fs.rm(stateDir, { recursive: true, force: true });
});
it("resolveAuthStorePath joins agentDir with the auth-profiles filename", () => {
const agentDir = path.join(stateDir, "agents", "main", "agent");
const resolved = resolveAuthStorePath(agentDir);
expect(path.dirname(resolved)).toBe(agentDir);
expect(path.basename(resolved)).toMatch(/auth-profiles/);
});
it("resolveAuthStorePath falls back to resolveOpenClawAgentDir when agentDir is omitted", () => {
// Omitting agentDir exercises the `agentDir ?? resolveOpenClawAgentDir()`
// nullish branch. With OPENCLAW_STATE_DIR set to our tempdir, the
// resolved path must live under it.
const resolved = resolveAuthStorePath();
expect(resolved.startsWith(stateDir)).toBe(true);
expect(path.basename(resolved)).toMatch(/auth-profiles/);
});
it("resolveLegacyAuthStorePath joins agentDir with the legacy auth filename", () => {
const agentDir = path.join(stateDir, "agents", "main", "agent");
const resolved = resolveLegacyAuthStorePath(agentDir);
expect(path.dirname(resolved)).toBe(agentDir);
expect(path.basename(resolved)).not.toMatch(/auth-profiles/);
});
it("resolveLegacyAuthStorePath falls back to the default agent dir", () => {
const resolved = resolveLegacyAuthStorePath();
expect(resolved.startsWith(stateDir)).toBe(true);
});
it("resolveAuthStatePath joins agentDir with the auth-state filename", () => {
const agentDir = path.join(stateDir, "agents", "main", "agent");
const resolved = resolveAuthStatePath(agentDir);
expect(path.dirname(resolved)).toBe(agentDir);
});
it("resolveAuthStatePath falls back to the default agent dir", () => {
const resolved = resolveAuthStatePath();
expect(resolved.startsWith(stateDir)).toBe(true);
});
it("resolveAuthStorePathForDisplay returns the resolved path for a non-tilde input", () => {
const agentDir = path.join(stateDir, "agents", "main", "agent");
const resolved = resolveAuthStorePathForDisplay(agentDir);
expect(resolved.startsWith(stateDir)).toBe(true);
});
it("resolveAuthStorePathForDisplay preserves a tilde-rooted path unchanged", () => {
// Exercises the `pathname.startsWith(\"~\")` branch. We use a contrived
// agentDir that already starts with `~` so the resolver echoes the
// tilde path back instead of expanding it via resolveUserPath.
const tildeAgentDir = "~fake-openclaw-no-expand";
const resolved = resolveAuthStorePathForDisplay(tildeAgentDir);
// Either the path itself starts with `~`, or the display variant
// happened to resolve through the user-path branch. Both branches are
// valid; the point is just that the function returned a string and
// the branch was executed.
expect(typeof resolved).toBe("string");
expect(resolved.length).toBeGreaterThan(0);
});
it("resolveAuthStatePathForDisplay returns a string", () => {
const agentDir = path.join(stateDir, "agents", "main", "agent");
const resolved = resolveAuthStatePathForDisplay(agentDir);
expect(typeof resolved).toBe("string");
expect(resolved.length).toBeGreaterThan(0);
});
});
describe("ensureAuthStoreFile (direct-import coverage attribution)", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let stateDir = "";
beforeEach(async () => {
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-path-ensure-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
});
afterEach(async () => {
envSnapshot.restore();
await fs.rm(stateDir, { recursive: true, force: true });
});
it("creates a new auth-profiles.json when the file does not yet exist", async () => {
const target = path.join(stateDir, "sub", "auth-profiles.json");
ensureAuthStoreFile(target);
const raw = await fs.readFile(target, "utf8");
const parsed = JSON.parse(raw) as { version: number; profiles: Record<string, unknown> };
expect(parsed.version).toBeGreaterThanOrEqual(1);
expect(parsed.profiles).toEqual({});
});
it("leaves an existing auth-profiles.json unchanged", async () => {
const target = path.join(stateDir, "auth-profiles.json");
// Seed a file with custom content; ensureAuthStoreFile should bail out
// on the existsSync short-circuit and NOT overwrite.
await fs.writeFile(
target,
JSON.stringify({
version: 1,
profiles: { canary: { type: "api_key", provider: "x", key: "k" } },
}),
"utf8",
);
ensureAuthStoreFile(target);
const raw = await fs.readFile(target, "utf8");
const parsed = JSON.parse(raw) as { profiles: Record<string, unknown> };
expect(parsed.profiles.canary).toBeDefined();
});
});

View File

@@ -9,6 +9,7 @@ export {
resolveAuthStorePath,
resolveAuthStorePathForDisplay,
resolveLegacyAuthStorePath,
resolveOAuthRefreshLockPath,
} from "./path-resolve.js";
export function ensureAuthStoreFile(pathname: string) {