























@@ -30,7 +30,12 @@ describe("resolveCliAuthEpoch", () => {
3030}),
3131});
323233-await expect(resolveCliAuthEpoch({ provider: "claude-cli" })).resolves.toBeUndefined();
33+await expect(
34+resolveCliAuthEpoch({
35+provider: "claude-cli",
36+authProfileId: "anthropic:work",
37+}),
38+).resolves.toBeUndefined();
3439await expect(
3540resolveCliAuthEpoch({
3641provider: "google-gemini-cli",
@@ -63,7 +68,7 @@ describe("resolveCliAuthEpoch", () => {
6368expect(second).toBe(first);
6469});
657066-it("changes claude cli token epochs when the static token changes", async () => {
71+it("keeps claude cli token epochs stable across token rotation", async () => {
6772let token = "token-a";
6873setCliAuthEpochTestDeps({
6974readClaudeCliCredentialsCached: () => ({
@@ -79,8 +84,65 @@ describe("resolveCliAuthEpoch", () => {
7984const second = await resolveCliAuthEpoch({ provider: "claude-cli" });
80858186expectCliAuthEpoch(first);
82-expectCliAuthEpoch(second);
83-expect(second).not.toBe(first);
87+// Static-token rotation is an authorized credential refresh, not an
88+// identity change. After #74312 the hash is identity-only for both
89+// OAuth and token branches, so rotation does not invalidate the epoch.
90+expect(second).toBe(first);
91+});
92+93+it("matches claude cli token and oauth epochs so partial keychain reads do not flip", async () => {
94+setCliAuthEpochTestDeps({
95+readClaudeCliCredentialsCached: () => ({
96+type: "oauth",
97+provider: "anthropic",
98+access: "access",
99+refresh: "refresh",
100+expires: 1,
101+}),
102+});
103+const oauthEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" });
104+105+setCliAuthEpochTestDeps({
106+readClaudeCliCredentialsCached: () => ({
107+type: "token",
108+provider: "anthropic",
109+token: "access",
110+expires: 1,
111+}),
112+});
113+const tokenEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" });
114+115+expectCliAuthEpoch(oauthEpoch);
116+expectCliAuthEpoch(tokenEpoch);
117+// The macOS Claude keychain rewrite is not atomic. A transient read with
118+// `refreshToken` missing falls into the parser's token branch; the OAuth
119+// and token encodings must produce the same hash so the auth-epoch does
120+// not flip during a token rotation. Regression for #74312.
121+expect(tokenEpoch).toBe(oauthEpoch);
122+});
123+124+it("drops the claude cli epoch when the credential read is absent", async () => {
125+setCliAuthEpochTestDeps({
126+readClaudeCliCredentialsCached: () => ({
127+type: "oauth",
128+provider: "anthropic",
129+access: "access",
130+refresh: "refresh",
131+expires: 1,
132+}),
133+});
134+const successfulRead = await resolveCliAuthEpoch({ provider: "claude-cli" });
135+136+// A null read can mean the credential was removed or logout left no
137+// readable auth state. Keep that absence visible so reusable sessions do
138+// not survive a true auth-state loss.
139+setCliAuthEpochTestDeps({
140+readClaudeCliCredentialsCached: () => null,
141+});
142+const nullRead = await resolveCliAuthEpoch({ provider: "claude-cli" });
143+144+expectCliAuthEpoch(successfulRead);
145+expect(nullRead).toBeUndefined();
84146});
8514786148it("keeps gemini cli oauth epochs stable through token rotation and flips on account change", async () => {
@@ -271,6 +333,143 @@ describe("resolveCliAuthEpoch", () => {
271333expect(second).not.toBe(first);
272334});
273335336+it("keeps token auth-profile epochs stable across credential.token rotation when identity is present", async () => {
337+let store: AuthProfileStore = {
338+version: 1,
339+profiles: {
340+"anthropic:work": {
341+type: "token",
342+provider: "anthropic",
343+token: "token-a",
344+tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" },
345+email: "user@example.com",
346+displayName: "Work",
347+},
348+},
349+};
350+setCliAuthEpochTestDeps({
351+readGeminiCliCredentialsCached: () => null,
352+loadAuthProfileStoreForRuntime: () => store,
353+});
354+355+const first = await resolveCliAuthEpoch({
356+provider: "google-gemini-cli",
357+authProfileId: "anthropic:work",
358+});
359+store = {
360+version: 1,
361+profiles: {
362+"anthropic:work": {
363+type: "token",
364+provider: "anthropic",
365+token: "token-b",
366+tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" },
367+email: "user@example.com",
368+displayName: "Work",
369+},
370+},
371+};
372+const second = await resolveCliAuthEpoch({
373+provider: "google-gemini-cli",
374+authProfileId: "anthropic:work",
375+});
376+377+expectCliAuthEpoch(first);
378+// Ref-backed token rotation must not flip the epoch; the token material is
379+// only a refreshable secret when the profile has a stable secret owner.
380+expect(second).toBe(first);
381+});
382+383+it("changes token auth-profile epochs when token-only credentials change", async () => {
384+let store: AuthProfileStore = {
385+version: 1,
386+profiles: {
387+"anthropic:token-only": {
388+type: "token",
389+provider: "anthropic",
390+token: "token-a",
391+displayName: "Manual token",
392+},
393+},
394+};
395+setCliAuthEpochTestDeps({
396+readGeminiCliCredentialsCached: () => null,
397+loadAuthProfileStoreForRuntime: () => store,
398+});
399+400+const first = await resolveCliAuthEpoch({
401+provider: "google-gemini-cli",
402+authProfileId: "anthropic:token-only",
403+});
404+store = {
405+version: 1,
406+profiles: {
407+"anthropic:token-only": {
408+type: "token",
409+provider: "anthropic",
410+token: "token-b",
411+displayName: "Manual token",
412+},
413+},
414+};
415+const second = await resolveCliAuthEpoch({
416+provider: "google-gemini-cli",
417+authProfileId: "anthropic:token-only",
418+});
419+420+expectCliAuthEpoch(first);
421+expectCliAuthEpoch(second);
422+// Token-only profiles have no stable account/ref identity, so the token
423+// remains the session owner and manual replacement still invalidates.
424+expect(second).not.toBe(first);
425+});
426+427+it("changes token auth-profile epochs when the email identity changes", async () => {
428+let store: AuthProfileStore = {
429+version: 1,
430+profiles: {
431+"anthropic:work": {
432+type: "token",
433+provider: "anthropic",
434+token: "token",
435+email: "user-a@example.com",
436+displayName: "Work",
437+},
438+},
439+};
440+setCliAuthEpochTestDeps({
441+readGeminiCliCredentialsCached: () => null,
442+loadAuthProfileStoreForRuntime: () => store,
443+});
444+445+const first = await resolveCliAuthEpoch({
446+provider: "google-gemini-cli",
447+authProfileId: "anthropic:work",
448+});
449+store = {
450+version: 1,
451+profiles: {
452+"anthropic:work": {
453+type: "token",
454+provider: "anthropic",
455+token: "token",
456+email: "user-b@example.com",
457+displayName: "Work",
458+},
459+},
460+};
461+const second = await resolveCliAuthEpoch({
462+provider: "google-gemini-cli",
463+authProfileId: "anthropic:work",
464+});
465+466+expectCliAuthEpoch(first);
467+expectCliAuthEpoch(second);
468+// A real account switch on a static-token profile must still invalidate
469+// the epoch so reusable CLI sessions don't outlive the identity change.
470+expect(second).not.toBe(first);
471+});
472+274473it("changes oauth auth-profile epochs when the account identity changes", async () => {
275474let store: AuthProfileStore = {
276475version: 1,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。