mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
test: split oauth mirror policy coverage
This commit is contained in:
@@ -4,7 +4,9 @@ import {
|
||||
isSameOAuthIdentity,
|
||||
normalizeAuthEmailToken,
|
||||
normalizeAuthIdentityToken,
|
||||
} from "./oauth.js";
|
||||
shouldMirrorRefreshedOAuthCredential,
|
||||
} from "./oauth-identity.js";
|
||||
import type { AuthProfileCredential } from "./types.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
|
||||
@@ -328,6 +330,154 @@ describe("isSafeToCopyOAuthIdentity (unified copy gate, used for mirror and adop
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldMirrorRefreshedOAuthCredential", () => {
|
||||
type MirrorCase = {
|
||||
name: string;
|
||||
existing: AuthProfileCredential | undefined;
|
||||
shouldMirror: boolean;
|
||||
reason: string;
|
||||
};
|
||||
const refreshed = {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "fresh-access",
|
||||
refresh: "fresh-refresh",
|
||||
expires: 2_000,
|
||||
accountId: "acct-1",
|
||||
} as const;
|
||||
|
||||
const cases: MirrorCase[] = [
|
||||
{
|
||||
name: "empty main store",
|
||||
existing: undefined,
|
||||
shouldMirror: true,
|
||||
reason: "no-existing-credential",
|
||||
},
|
||||
{
|
||||
name: "matching older oauth credential",
|
||||
existing: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "old",
|
||||
refresh: "old-refresh",
|
||||
expires: 1_000,
|
||||
accountId: "acct-1",
|
||||
},
|
||||
shouldMirror: true,
|
||||
reason: "incoming-fresher",
|
||||
},
|
||||
{
|
||||
name: "non-finite existing expiry",
|
||||
existing: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "old",
|
||||
refresh: "old-refresh",
|
||||
expires: Number.NaN,
|
||||
accountId: "acct-1",
|
||||
},
|
||||
shouldMirror: true,
|
||||
reason: "incoming-fresher",
|
||||
},
|
||||
{
|
||||
name: "identity upgrade",
|
||||
existing: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "old",
|
||||
refresh: "old-refresh",
|
||||
expires: 1_000,
|
||||
},
|
||||
shouldMirror: true,
|
||||
reason: "incoming-fresher",
|
||||
},
|
||||
{
|
||||
name: "api key override",
|
||||
existing: {
|
||||
type: "api_key",
|
||||
provider: "openai-codex",
|
||||
key: "operator-key",
|
||||
},
|
||||
shouldMirror: false,
|
||||
reason: "non-oauth-existing-credential",
|
||||
},
|
||||
{
|
||||
name: "provider mismatch",
|
||||
existing: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "old",
|
||||
refresh: "old-refresh",
|
||||
expires: 1_000,
|
||||
accountId: "acct-1",
|
||||
},
|
||||
shouldMirror: false,
|
||||
reason: "provider-mismatch",
|
||||
},
|
||||
{
|
||||
name: "identity mismatch",
|
||||
existing: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "old",
|
||||
refresh: "old-refresh",
|
||||
expires: 1_000,
|
||||
accountId: "acct-2",
|
||||
},
|
||||
shouldMirror: false,
|
||||
reason: "identity-mismatch-or-regression",
|
||||
},
|
||||
{
|
||||
name: "strictly fresher existing credential",
|
||||
existing: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "main-fresh",
|
||||
refresh: "main-fresh-refresh",
|
||||
expires: 3_000,
|
||||
accountId: "acct-1",
|
||||
},
|
||||
shouldMirror: false,
|
||||
reason: "incoming-not-fresher",
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)("returns $reason for $name", ({ existing, shouldMirror, reason }) => {
|
||||
expect(
|
||||
shouldMirrorRefreshedOAuthCredential({
|
||||
existing,
|
||||
refreshed,
|
||||
}),
|
||||
).toEqual({ shouldMirror, reason });
|
||||
});
|
||||
|
||||
it("refuses identity regression from a known-account main credential", () => {
|
||||
expect(
|
||||
shouldMirrorRefreshedOAuthCredential({
|
||||
existing: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "main-identity-access",
|
||||
refresh: "main-identity-refresh",
|
||||
expires: 1_000,
|
||||
accountId: "acct-main",
|
||||
},
|
||||
refreshed: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "fresh-access",
|
||||
refresh: "fresh-refresh",
|
||||
expires: 2_000,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
shouldMirror: false,
|
||||
reason: "identity-mismatch-or-regression",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSafeToCopyOAuthIdentity fuzz", () => {
|
||||
function makeSeededRandom(seed: number): () => number {
|
||||
let t = seed >>> 0;
|
||||
|
||||
116
src/agents/auth-profiles/oauth-identity.ts
Normal file
116
src/agents/auth-profiles/oauth-identity.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { AuthProfileCredential, OAuthCredential } from "./types.js";
|
||||
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
|
||||
if (aHasIdentity !== bHasIdentity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (aHasIdentity) {
|
||||
if (aAcct !== undefined && bAcct !== undefined) {
|
||||
return aAcct === bAcct;
|
||||
}
|
||||
if (aEmail !== undefined && bEmail !== undefined) {
|
||||
return aEmail === bEmail;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-sided copy gate for both directions:
|
||||
* - mirror: sub-agent refresh -> main-agent store
|
||||
* - adopt: main-agent store -> sub-agent store
|
||||
*/
|
||||
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);
|
||||
|
||||
if (aAcct !== undefined && bAcct !== undefined) {
|
||||
return aAcct === bAcct;
|
||||
}
|
||||
if (aEmail !== undefined && bEmail !== undefined) {
|
||||
return aEmail === bEmail;
|
||||
}
|
||||
|
||||
const aHasIdentity = aAcct !== undefined || aEmail !== undefined;
|
||||
if (aHasIdentity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type OAuthMirrorDecisionReason =
|
||||
| "no-existing-credential"
|
||||
| "incoming-fresher"
|
||||
| "non-oauth-existing-credential"
|
||||
| "provider-mismatch"
|
||||
| "identity-mismatch-or-regression"
|
||||
| "incoming-not-fresher";
|
||||
|
||||
export type OAuthMirrorDecision =
|
||||
| {
|
||||
shouldMirror: true;
|
||||
reason: Extract<OAuthMirrorDecisionReason, "no-existing-credential" | "incoming-fresher">;
|
||||
}
|
||||
| {
|
||||
shouldMirror: false;
|
||||
reason: Exclude<OAuthMirrorDecisionReason, "no-existing-credential" | "incoming-fresher">;
|
||||
};
|
||||
|
||||
export function shouldMirrorRefreshedOAuthCredential(params: {
|
||||
existing: AuthProfileCredential | undefined;
|
||||
refreshed: OAuthCredential;
|
||||
}): OAuthMirrorDecision {
|
||||
const { existing, refreshed } = params;
|
||||
if (!existing) {
|
||||
return { shouldMirror: true, reason: "no-existing-credential" };
|
||||
}
|
||||
if (existing.type !== "oauth") {
|
||||
return { shouldMirror: false, reason: "non-oauth-existing-credential" };
|
||||
}
|
||||
if (existing.provider !== refreshed.provider) {
|
||||
return { shouldMirror: false, reason: "provider-mismatch" };
|
||||
}
|
||||
if (!isSafeToCopyOAuthIdentity(existing, refreshed)) {
|
||||
return { shouldMirror: false, reason: "identity-mismatch-or-regression" };
|
||||
}
|
||||
if (
|
||||
Number.isFinite(existing.expires) &&
|
||||
Number.isFinite(refreshed.expires) &&
|
||||
existing.expires >= refreshed.expires
|
||||
) {
|
||||
return { shouldMirror: false, reason: "incoming-not-fresher" };
|
||||
}
|
||||
return { shouldMirror: true, reason: "incoming-fresher" };
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
shouldReplaceStoredOAuthCredential,
|
||||
type RuntimeExternalOAuthProfile,
|
||||
} from "./oauth-shared.js";
|
||||
import { shouldMirrorRefreshedOAuthCredential } from "./oauth-identity.js";
|
||||
import { ensureAuthStoreFile, resolveAuthStorePath, resolveOAuthRefreshLockPath } from "./paths.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
@@ -304,10 +305,16 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
|
||||
agentDir: undefined,
|
||||
updater: (store) => {
|
||||
const existing = store.profiles[params.profileId];
|
||||
if (existing && existing.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
if (existing && existing.provider !== params.refreshed.provider) {
|
||||
const decision = shouldMirrorRefreshedOAuthCredential({
|
||||
existing,
|
||||
refreshed: params.refreshed,
|
||||
});
|
||||
if (!decision.shouldMirror) {
|
||||
if (decision.reason === "identity-mismatch-or-regression") {
|
||||
log.warn("refused to mirror OAuth credential: identity mismatch or regression", {
|
||||
profileId: params.profileId,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (existing && !isSafeToAdoptMainStoreOAuthIdentity(existing, params.refreshed)) {
|
||||
@@ -316,14 +323,6 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -240,302 +240,6 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
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 resolveApiKeyForProfileInTest({
|
||||
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 resolveApiKeyForProfileInTest({
|
||||
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 resolveApiKeyForProfileInTest({
|
||||
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 resolveApiKeyForProfileInTest({
|
||||
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 resolveApiKeyForProfileInTest({
|
||||
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
|
||||
@@ -652,123 +356,6 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
|
||||
});
|
||||
});
|
||||
|
||||
it("mirrors identity-bearing refreshes into a pre-capture main store", async () => {
|
||||
// Pre-capture main credentials may lack account identity. Allow the
|
||||
// refreshed sub-agent credential to upgrade the main store with identity.
|
||||
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 resolveApiKeyForProfileInTest({
|
||||
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;
|
||||
expect(mainRaw.profiles[profileId]).toMatchObject({
|
||||
access: "sub-refreshed-access",
|
||||
refresh: "sub-refreshed-refresh",
|
||||
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 resolveApiKeyForProfileInTest({
|
||||
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.
|
||||
|
||||
@@ -25,6 +25,15 @@ import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
|
||||
import { loadAuthProfileStoreForSecretsRuntime } from "./store.js";
|
||||
import type { AuthProfileStore, OAuthCredential } from "./types.js";
|
||||
|
||||
export {
|
||||
isSafeToCopyOAuthIdentity,
|
||||
isSameOAuthIdentity,
|
||||
normalizeAuthEmailToken,
|
||||
normalizeAuthIdentityToken,
|
||||
shouldMirrorRefreshedOAuthCredential,
|
||||
} from "./oauth-identity.js";
|
||||
export type { OAuthMirrorDecision, OAuthMirrorDecisionReason } from "./oauth-identity.js";
|
||||
|
||||
function listOAuthProviderIds(): string[] {
|
||||
if (typeof getOAuthProviders !== "function") {
|
||||
return [];
|
||||
|
||||
Reference in New Issue
Block a user