diff --git a/src/agents/cli-auth-epoch.test.ts b/src/agents/cli-auth-epoch.test.ts index 2bebf2598d3..882ae366543 100644 --- a/src/agents/cli-auth-epoch.test.ts +++ b/src/agents/cli-auth-epoch.test.ts @@ -30,21 +30,23 @@ describe("resolveCliAuthEpoch", () => { ).resolves.toBeUndefined(); }); - it("keeps claude cli oauth epochs stable across access-token refreshes", async () => { + it("keeps identity-less claude cli oauth epochs stable across token changes", async () => { let access = "access-a"; + let refresh = "refresh-a"; let expires = 1; setCliAuthEpochTestDeps({ readClaudeCliCredentialsCached: () => ({ type: "oauth", provider: "anthropic", access, - refresh: "refresh", + refresh, expires, }), }); const first = await resolveCliAuthEpoch({ provider: "claude-cli" }); access = "access-b"; + refresh = "refresh-b"; expires = 2; const second = await resolveCliAuthEpoch({ provider: "claude-cli" }); @@ -52,20 +54,19 @@ describe("resolveCliAuthEpoch", () => { expect(second).toBe(first); }); - it("changes claude cli oauth epochs when the refresh token changes", async () => { - let refresh = "refresh-a"; + it("changes claude cli token epochs when the static token changes", async () => { + let token = "token-a"; setCliAuthEpochTestDeps({ readClaudeCliCredentialsCached: () => ({ - type: "oauth", + type: "token", provider: "anthropic", - access: "access", - refresh, + token, expires: 1, }), }); const first = await resolveCliAuthEpoch({ provider: "claude-cli" }); - refresh = "refresh-b"; + token = "token-b"; const second = await resolveCliAuthEpoch({ provider: "claude-cli" }); expect(first).toBeDefined(); @@ -73,7 +74,7 @@ describe("resolveCliAuthEpoch", () => { expect(second).not.toBe(first); }); - it("keeps oauth auth-profile epochs stable across access-token refreshes", async () => { + it("keeps oauth auth-profile epochs stable across token refreshes", async () => { let store: AuthProfileStore = { version: 1, profiles: { @@ -81,8 +82,9 @@ describe("resolveCliAuthEpoch", () => { type: "oauth", provider: "anthropic", access: "access-a", - refresh: "refresh", + refresh: "refresh-a", expires: 1, + email: "user@example.com", }, }, }; @@ -101,8 +103,9 @@ describe("resolveCliAuthEpoch", () => { type: "oauth", provider: "anthropic", access: "access-b", - refresh: "refresh", + refresh: "refresh-b", expires: 2, + email: "user@example.com", }, }, }; @@ -115,7 +118,7 @@ describe("resolveCliAuthEpoch", () => { expect(second).toBe(first); }); - it("changes oauth auth-profile epochs when the refresh token changes", async () => { + it("changes oauth auth-profile epochs when the account identity changes", async () => { let store: AuthProfileStore = { version: 1, profiles: { @@ -123,8 +126,9 @@ describe("resolveCliAuthEpoch", () => { type: "oauth", provider: "anthropic", access: "access", - refresh: "refresh-a", + refresh: "refresh", expires: 1, + email: "user-a@example.com", }, }, }; @@ -143,8 +147,9 @@ describe("resolveCliAuthEpoch", () => { type: "oauth", provider: "anthropic", access: "access", - refresh: "refresh-b", + refresh: "refresh", expires: 1, + email: "user-b@example.com", }, }, }; @@ -162,6 +167,8 @@ describe("resolveCliAuthEpoch", () => { let access = "local-access-a"; let localRefresh = "local-refresh-a"; let refresh = "profile-refresh-a"; + let accountId = "acct-1"; + let email = "user-a@example.com"; setCliAuthEpochTestDeps({ readCodexCliCredentialsCached: () => ({ type: "oauth", @@ -169,7 +176,7 @@ describe("resolveCliAuthEpoch", () => { access, refresh: localRefresh, expires: 1, - accountId: "acct-1", + accountId, }), loadAuthProfileStoreForRuntime: () => ({ version: 1, @@ -180,6 +187,7 @@ describe("resolveCliAuthEpoch", () => { access: "profile-access", refresh, expires: 1, + email, }, }, }), @@ -204,18 +212,31 @@ describe("resolveCliAuthEpoch", () => { provider: "codex-cli", authProfileId: "openai:work", }); + accountId = "acct-2"; + const fifth = await resolveCliAuthEpoch({ + provider: "codex-cli", + authProfileId: "openai:work", + }); + email = "user-b@example.com"; + const sixth = await resolveCliAuthEpoch({ + provider: "codex-cli", + authProfileId: "openai:work", + }); expect(first).toBeDefined(); - expect(third).toBeDefined(); - expect(fourth).toBeDefined(); expect(second).toBe(first); - expect(third).not.toBe(second); - expect(fourth).not.toBe(third); + expect(third).toBe(second); + expect(fourth).toBe(third); + expect(fifth).toBeDefined(); + expect(sixth).toBeDefined(); + expect(fifth).not.toBe(fourth); + expect(sixth).not.toBe(fifth); }); it("can ignore local codex state when the backend is profile-owned", async () => { let localAccess = "local-access-a"; let profileRefresh = "profile-refresh-a"; + let profileAccountId = "acct-1"; setCliAuthEpochTestDeps({ readCodexCliCredentialsCached: () => ({ type: "oauth", @@ -234,7 +255,7 @@ describe("resolveCliAuthEpoch", () => { access: "profile-access", refresh: profileRefresh, expires: 1, - accountId: "acct-1", + accountId: profileAccountId, }, }, }), @@ -257,10 +278,17 @@ describe("resolveCliAuthEpoch", () => { authProfileId: "openai-codex:default", skipLocalCredential: true, }); + profileAccountId = "acct-2"; + const fourth = await resolveCliAuthEpoch({ + provider: "codex-cli", + authProfileId: "openai-codex:default", + skipLocalCredential: true, + }); expect(first).toBeDefined(); expect(second).toBe(first); - expect(third).toBeDefined(); - expect(third).not.toBe(second); + expect(third).toBe(second); + expect(fourth).toBeDefined(); + expect(fourth).not.toBe(third); }); }); diff --git a/src/agents/cli-auth-epoch.ts b/src/agents/cli-auth-epoch.ts index a648856d54f..9edb2c97e27 100644 --- a/src/agents/cli-auth-epoch.ts +++ b/src/agents/cli-auth-epoch.ts @@ -23,7 +23,7 @@ const defaultCliAuthEpochDeps: CliAuthEpochDeps = { const cliAuthEpochDeps: CliAuthEpochDeps = { ...defaultCliAuthEpochDeps }; -export const CLI_AUTH_EPOCH_VERSION = 2; +export const CLI_AUTH_EPOCH_VERSION = 3; export function setCliAuthEpochTestDeps(overrides: Partial): void { Object.assign(cliAuthEpochDeps, overrides); @@ -41,20 +41,35 @@ function encodeUnknown(value: unknown): string { return JSON.stringify(value ?? null); } +function encodeOAuthIdentity(credential: { + type: "oauth"; + provider: string; + clientId?: string; + email?: string; + enterpriseUrl?: string; + projectId?: string; + accountId?: string; +}): string { + return JSON.stringify([ + "oauth", + credential.provider, + credential.clientId ?? null, + credential.email ?? null, + credential.enterpriseUrl ?? null, + credential.projectId ?? null, + credential.accountId ?? null, + ]); +} + function encodeClaudeCredential(credential: ClaudeCliCredential): string { if (credential.type === "oauth") { - return JSON.stringify(["oauth", credential.provider, credential.refresh]); + return encodeOAuthIdentity(credential); } return JSON.stringify(["token", credential.provider, credential.token]); } function encodeCodexCredential(credential: CodexCliCredential): string { - return JSON.stringify([ - credential.type, - credential.provider, - credential.refresh, - credential.accountId ?? null, - ]); + return encodeOAuthIdentity(credential); } function encodeAuthProfileCredential(credential: AuthProfileCredential): string { @@ -79,16 +94,7 @@ function encodeAuthProfileCredential(credential: AuthProfileCredential): string credential.displayName ?? null, ]); case "oauth": - return JSON.stringify([ - "oauth", - credential.provider, - credential.refresh, - credential.clientId ?? null, - credential.email ?? null, - credential.enterpriseUrl ?? null, - credential.projectId ?? null, - credential.accountId ?? null, - ]); + return encodeOAuthIdentity(credential); } throw new Error("Unsupported auth profile credential type"); } diff --git a/src/agents/cli-session.test.ts b/src/agents/cli-session.test.ts index 5718b0d26d3..1af4c564fc0 100644 --- a/src/agents/cli-session.test.ts +++ b/src/agents/cli-session.test.ts @@ -160,6 +160,28 @@ describe("cli-session helpers", () => { ).toEqual({ sessionId: "cli-session-1" }); }); + it("accepts older auth epoch versions for binding upgrades", () => { + const binding = { + sessionId: "cli-session-1", + authProfileId: "anthropic:work", + authEpoch: "refresh-token-auth-epoch", + authEpochVersion: 2, + extraSystemPromptHash: "prompt-a", + mcpConfigHash: "mcp-a", + }; + + expect( + resolveCliSessionReuse({ + binding, + authProfileId: "anthropic:work", + authEpoch: "identity-auth-epoch", + authEpochVersion: 3, + extraSystemPromptHash: "prompt-a", + mcpConfigHash: "mcp-a", + }), + ).toEqual({ sessionId: "cli-session-1" }); + }); + it("does not treat model changes as a session mismatch", () => { const binding = { sessionId: "cli-session-1",