mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(cli): stabilize claude auth epochs on token rotation
Stabilizes Claude CLI reusable sessions when Claude token rotation causes transient token-shaped credential reads. Local Claude CLI OAuth and token credential encodings now share the same identity-only auth-epoch, while ref-backed token auth profiles ignore refreshed token material and plaintext token profiles remain epoch-sensitive on manual token replacement. Fixes #74312. Proof: focused local Vitest, autoreview, Testbox-through-Crabbox tbx_01ksyrcknbt743x32x6k1s95qw, and GitHub CI run 26709864094 all passed. Co-authored-by: stainlu <stainlu@newtype-ai.org>
This commit is contained in:
@@ -30,7 +30,12 @@ describe("resolveCliAuthEpoch", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(resolveCliAuthEpoch({ provider: "claude-cli" })).resolves.toBeUndefined();
|
||||
await expect(
|
||||
resolveCliAuthEpoch({
|
||||
provider: "claude-cli",
|
||||
authProfileId: "anthropic:work",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
resolveCliAuthEpoch({
|
||||
provider: "google-gemini-cli",
|
||||
@@ -63,7 +68,7 @@ describe("resolveCliAuthEpoch", () => {
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("changes claude cli token epochs when the static token changes", async () => {
|
||||
it("keeps claude cli token epochs stable across token rotation", async () => {
|
||||
let token = "token-a";
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => ({
|
||||
@@ -79,8 +84,65 @@ describe("resolveCliAuthEpoch", () => {
|
||||
const second = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
expectCliAuthEpoch(first);
|
||||
expectCliAuthEpoch(second);
|
||||
expect(second).not.toBe(first);
|
||||
// Static-token rotation is an authorized credential refresh, not an
|
||||
// identity change. After #74312 the hash is identity-only for both
|
||||
// OAuth and token branches, so rotation does not invalidate the epoch.
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("matches claude cli token and oauth epochs so partial keychain reads do not flip", async () => {
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => ({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
}),
|
||||
});
|
||||
const oauthEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => ({
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "access",
|
||||
expires: 1,
|
||||
}),
|
||||
});
|
||||
const tokenEpoch = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
expectCliAuthEpoch(oauthEpoch);
|
||||
expectCliAuthEpoch(tokenEpoch);
|
||||
// The macOS Claude keychain rewrite is not atomic. A transient read with
|
||||
// `refreshToken` missing falls into the parser's token branch; the OAuth
|
||||
// and token encodings must produce the same hash so the auth-epoch does
|
||||
// not flip during a token rotation. Regression for #74312.
|
||||
expect(tokenEpoch).toBe(oauthEpoch);
|
||||
});
|
||||
|
||||
it("drops the claude cli epoch when the credential read is absent", async () => {
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => ({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
}),
|
||||
});
|
||||
const successfulRead = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
// A null read can mean the credential was removed or logout left no
|
||||
// readable auth state. Keep that absence visible so reusable sessions do
|
||||
// not survive a true auth-state loss.
|
||||
setCliAuthEpochTestDeps({
|
||||
readClaudeCliCredentialsCached: () => null,
|
||||
});
|
||||
const nullRead = await resolveCliAuthEpoch({ provider: "claude-cli" });
|
||||
|
||||
expectCliAuthEpoch(successfulRead);
|
||||
expect(nullRead).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps gemini cli oauth epochs stable through token rotation and flips on account change", async () => {
|
||||
@@ -271,6 +333,143 @@ describe("resolveCliAuthEpoch", () => {
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("keeps token auth-profile epochs stable across credential.token rotation when identity is present", async () => {
|
||||
let store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:work": {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "token-a",
|
||||
tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" },
|
||||
email: "user@example.com",
|
||||
displayName: "Work",
|
||||
},
|
||||
},
|
||||
};
|
||||
setCliAuthEpochTestDeps({
|
||||
readGeminiCliCredentialsCached: () => null,
|
||||
loadAuthProfileStoreForRuntime: () => store,
|
||||
});
|
||||
|
||||
const first = await resolveCliAuthEpoch({
|
||||
provider: "google-gemini-cli",
|
||||
authProfileId: "anthropic:work",
|
||||
});
|
||||
store = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:work": {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "token-b",
|
||||
tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" },
|
||||
email: "user@example.com",
|
||||
displayName: "Work",
|
||||
},
|
||||
},
|
||||
};
|
||||
const second = await resolveCliAuthEpoch({
|
||||
provider: "google-gemini-cli",
|
||||
authProfileId: "anthropic:work",
|
||||
});
|
||||
|
||||
expectCliAuthEpoch(first);
|
||||
// Ref-backed token rotation must not flip the epoch; the token material is
|
||||
// only a refreshable secret when the profile has a stable secret owner.
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("changes token auth-profile epochs when token-only credentials change", async () => {
|
||||
let store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:token-only": {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "token-a",
|
||||
displayName: "Manual token",
|
||||
},
|
||||
},
|
||||
};
|
||||
setCliAuthEpochTestDeps({
|
||||
readGeminiCliCredentialsCached: () => null,
|
||||
loadAuthProfileStoreForRuntime: () => store,
|
||||
});
|
||||
|
||||
const first = await resolveCliAuthEpoch({
|
||||
provider: "google-gemini-cli",
|
||||
authProfileId: "anthropic:token-only",
|
||||
});
|
||||
store = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:token-only": {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "token-b",
|
||||
displayName: "Manual token",
|
||||
},
|
||||
},
|
||||
};
|
||||
const second = await resolveCliAuthEpoch({
|
||||
provider: "google-gemini-cli",
|
||||
authProfileId: "anthropic:token-only",
|
||||
});
|
||||
|
||||
expectCliAuthEpoch(first);
|
||||
expectCliAuthEpoch(second);
|
||||
// Token-only profiles have no stable account/ref identity, so the token
|
||||
// remains the session owner and manual replacement still invalidates.
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("changes token auth-profile epochs when the email identity changes", async () => {
|
||||
let store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:work": {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "token",
|
||||
email: "user-a@example.com",
|
||||
displayName: "Work",
|
||||
},
|
||||
},
|
||||
};
|
||||
setCliAuthEpochTestDeps({
|
||||
readGeminiCliCredentialsCached: () => null,
|
||||
loadAuthProfileStoreForRuntime: () => store,
|
||||
});
|
||||
|
||||
const first = await resolveCliAuthEpoch({
|
||||
provider: "google-gemini-cli",
|
||||
authProfileId: "anthropic:work",
|
||||
});
|
||||
store = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:work": {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "token",
|
||||
email: "user-b@example.com",
|
||||
displayName: "Work",
|
||||
},
|
||||
},
|
||||
};
|
||||
const second = await resolveCliAuthEpoch({
|
||||
provider: "google-gemini-cli",
|
||||
authProfileId: "anthropic:work",
|
||||
});
|
||||
|
||||
expectCliAuthEpoch(first);
|
||||
expectCliAuthEpoch(second);
|
||||
// A real account switch on a static-token profile must still invalidate
|
||||
// the epoch so reusable CLI sessions don't outlive the identity change.
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("changes oauth auth-profile epochs when the account identity changes", async () => {
|
||||
let store: AuthProfileStore = {
|
||||
version: 1,
|
||||
|
||||
@@ -27,7 +27,7 @@ const defaultCliAuthEpochDeps: CliAuthEpochDeps = {
|
||||
|
||||
const cliAuthEpochDeps: CliAuthEpochDeps = { ...defaultCliAuthEpochDeps };
|
||||
|
||||
export const CLI_AUTH_EPOCH_VERSION = 4;
|
||||
export const CLI_AUTH_EPOCH_VERSION = 5;
|
||||
|
||||
export function setCliAuthEpochTestDeps(overrides: Partial<CliAuthEpochDeps>): void {
|
||||
Object.assign(cliAuthEpochDeps, overrides);
|
||||
@@ -69,10 +69,19 @@ function encodeOAuthIdentity(credential: {
|
||||
}
|
||||
|
||||
function encodeClaudeCredential(credential: ClaudeCliCredential): string {
|
||||
if (credential.type === "oauth") {
|
||||
return encodeOAuthIdentity(credential);
|
||||
}
|
||||
return JSON.stringify(["token", credential.provider, credential.token]);
|
||||
// Identity-only hashing for both OAuth and token Claude CLI credentials.
|
||||
// The Claude CLI keychain rewrite is not atomic: a token rotation can
|
||||
// briefly produce a partial read where `refreshToken` is missing, and the
|
||||
// parser falls back to a token-shaped credential. With the previous
|
||||
// token-inclusive hash, that transient race flipped the auth-epoch and
|
||||
// forced a session reset on every rotation. Routing both branches through
|
||||
// `encodeOAuthIdentity` collapses partial reads and rotations onto the
|
||||
// same provider-keyed identity hash, while a real account switch would
|
||||
// still surface as different identity fields. Fixes #74312.
|
||||
return encodeOAuthIdentity({
|
||||
type: "oauth",
|
||||
provider: credential.provider,
|
||||
});
|
||||
}
|
||||
|
||||
function encodeCodexCredential(credential: CodexCliCredential): string {
|
||||
@@ -103,6 +112,19 @@ function encodeAuthProfileCredential(credential: AuthProfileCredential): string
|
||||
encodeUnknown(credential.metadata),
|
||||
]);
|
||||
case "token":
|
||||
if (credential.tokenRef !== undefined) {
|
||||
// When a token profile has a stable account/ref identity, token
|
||||
// material is a refreshable secret rather than the session owner.
|
||||
// Plain token-only profiles still hash the token below so manual token
|
||||
// replacement keeps invalidating reusable sessions.
|
||||
return JSON.stringify([
|
||||
"token-identity",
|
||||
credential.provider,
|
||||
encodeUnknown(credential.tokenRef),
|
||||
credential.email ?? null,
|
||||
credential.displayName ?? null,
|
||||
]);
|
||||
}
|
||||
return JSON.stringify([
|
||||
"token",
|
||||
credential.provider,
|
||||
@@ -143,6 +165,9 @@ function getLocalCliCredentialFingerprint(provider: string): string | undefined
|
||||
ttlMs: 5000,
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
// Keep true credential absence absent so logout/removal invalidates
|
||||
// reusable sessions. The 5s credential cache still masks transient
|
||||
// null reads immediately after a successful read.
|
||||
return credential ? hashCliAuthEpochPart(encodeClaudeCredential(credential)) : undefined;
|
||||
}
|
||||
case "codex-cli": {
|
||||
|
||||
Reference in New Issue
Block a user