diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd0b99176d..9e87ac56b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - Control UI: keep Stop visible during tool-only execution, preserve pending-send busy state, and clear stale ClawHub search results as soon as the query changes. (#54528, #59800, #60267) - MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198) - Agents/Claude CLI: persist explicit `openclaw agent --session-id` runs under a stable session key so follow-ups can reuse the stored CLI binding and resume the same underlying Claude session. +- Agents/CLI backends: invalidate stored CLI session reuse when local CLI login state or the selected auth profile credential changes, so relogin and token rotation stop resuming stale sessions. - Auth/failover: persist selected fallback overrides before retrying, shorten `auth_permanent` lockouts, and refresh websocket/shared-auth sessions only when real auth changes occur so retries and secret rotations behave predictably. (#60404, #60323, #60387) - Cron: replay interrupted recurring jobs on the first gateway restart instead of waiting for a second restart. (#60583) Thanks @joelnishanth. - Plugins/media understanding: enable bundled Groq and Deepgram providers by default so configured transcription models work without extra plugin activation config. (#59982) Thanks @yxjsxy. diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index cb8a8e641f4..f6b29f2fd29 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -170,6 +170,7 @@ Serialization notes: - `serialize: true` keeps same-lane runs ordered. - Most CLIs serialize on one provider lane. - `claude-cli` is narrower: resumed runs serialize per Claude session id, and fresh runs serialize per workspace path. Independent workspaces can run in parallel. +- OpenClaw drops stored CLI session reuse when the backend auth state changes, including relogin, token rotation, or a changed auth profile credential. ## Images (pass-through) diff --git a/src/agents/cli-auth-epoch.test.ts b/src/agents/cli-auth-epoch.test.ts new file mode 100644 index 00000000000..6f9d860888c --- /dev/null +++ b/src/agents/cli-auth-epoch.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; +import { + resetCliAuthEpochTestDeps, + resolveCliAuthEpoch, + setCliAuthEpochTestDeps, +} from "./cli-auth-epoch.js"; + +describe("resolveCliAuthEpoch", () => { + afterEach(() => { + resetCliAuthEpochTestDeps(); + }); + + it("returns undefined when no local or auth-profile credentials exist", async () => { + setCliAuthEpochTestDeps({ + readClaudeCliCredentialsCached: () => null, + readCodexCliCredentialsCached: () => null, + loadAuthProfileStoreForRuntime: () => ({ + version: 1, + profiles: {}, + }), + }); + + await expect(resolveCliAuthEpoch({ provider: "claude-cli" })).resolves.toBeUndefined(); + await expect( + resolveCliAuthEpoch({ + provider: "google-gemini-cli", + authProfileId: "google:work", + }), + ).resolves.toBeUndefined(); + }); + + it("changes when claude cli credentials change", async () => { + let access = "access-a"; + setCliAuthEpochTestDeps({ + readClaudeCliCredentialsCached: () => ({ + type: "oauth", + provider: "anthropic", + access, + refresh: "refresh", + expires: 1, + }), + }); + + const first = await resolveCliAuthEpoch({ provider: "claude-cli" }); + access = "access-b"; + const second = await resolveCliAuthEpoch({ provider: "claude-cli" }); + + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(second).not.toBe(first); + }); + + it("changes when auth profile credentials change", async () => { + let store: AuthProfileStore = { + version: 1, + profiles: { + "anthropic:work": { + type: "oauth", + provider: "anthropic", + access: "access-a", + refresh: "refresh", + expires: 1, + }, + }, + }; + setCliAuthEpochTestDeps({ + loadAuthProfileStoreForRuntime: () => store, + }); + + const first = await resolveCliAuthEpoch({ + provider: "google-gemini-cli", + authProfileId: "anthropic:work", + }); + store = { + version: 1, + profiles: { + "anthropic:work": { + type: "oauth", + provider: "anthropic", + access: "access-b", + refresh: "refresh", + expires: 1, + }, + }, + }; + const second = await resolveCliAuthEpoch({ + provider: "google-gemini-cli", + authProfileId: "anthropic:work", + }); + + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(second).not.toBe(first); + }); + + it("mixes local codex and auth-profile state", async () => { + let access = "local-access-a"; + let refresh = "profile-refresh-a"; + setCliAuthEpochTestDeps({ + readCodexCliCredentialsCached: () => ({ + type: "oauth", + provider: "openai-codex", + access, + refresh: "local-refresh", + expires: 1, + accountId: "acct-1", + }), + loadAuthProfileStoreForRuntime: () => ({ + version: 1, + profiles: { + "openai:work": { + type: "oauth", + provider: "openai", + access: "profile-access", + refresh, + expires: 1, + }, + }, + }), + }); + + const first = await resolveCliAuthEpoch({ + provider: "codex-cli", + authProfileId: "openai:work", + }); + access = "local-access-b"; + const second = await resolveCliAuthEpoch({ + provider: "codex-cli", + authProfileId: "openai:work", + }); + refresh = "profile-refresh-b"; + const third = await resolveCliAuthEpoch({ + provider: "codex-cli", + authProfileId: "openai:work", + }); + + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(third).toBeDefined(); + expect(second).not.toBe(first); + expect(third).not.toBe(second); + }); +}); diff --git a/src/agents/cli-auth-epoch.ts b/src/agents/cli-auth-epoch.ts new file mode 100644 index 00000000000..ac50b6227aa --- /dev/null +++ b/src/agents/cli-auth-epoch.ts @@ -0,0 +1,165 @@ +import crypto from "node:crypto"; +import { loadAuthProfileStoreForRuntime } from "./auth-profiles/store.js"; +import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js"; +import { + readClaudeCliCredentialsCached, + readCodexCliCredentialsCached, + type ClaudeCliCredential, + type CodexCliCredential, +} from "./cli-credentials.js"; + +type CliAuthEpochDeps = { + readClaudeCliCredentialsCached: typeof readClaudeCliCredentialsCached; + readCodexCliCredentialsCached: typeof readCodexCliCredentialsCached; + loadAuthProfileStoreForRuntime: typeof loadAuthProfileStoreForRuntime; +}; + +const defaultCliAuthEpochDeps: CliAuthEpochDeps = { + readClaudeCliCredentialsCached, + readCodexCliCredentialsCached, + loadAuthProfileStoreForRuntime, +}; + +const cliAuthEpochDeps: CliAuthEpochDeps = { ...defaultCliAuthEpochDeps }; + +export function setCliAuthEpochTestDeps(overrides: Partial): void { + Object.assign(cliAuthEpochDeps, overrides); +} + +export function resetCliAuthEpochTestDeps(): void { + Object.assign(cliAuthEpochDeps, defaultCliAuthEpochDeps); +} + +function hashCliAuthEpochPart(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function encodeUnknown(value: unknown): string { + return JSON.stringify(value ?? null); +} + +function encodeClaudeCredential(credential: ClaudeCliCredential): string { + if (credential.type === "oauth") { + return JSON.stringify([ + "oauth", + credential.provider, + credential.access, + credential.refresh, + credential.expires, + ]); + } + return JSON.stringify(["token", credential.provider, credential.token, credential.expires]); +} + +function encodeCodexCredential(credential: CodexCliCredential): string { + return JSON.stringify([ + credential.type, + credential.provider, + credential.access, + credential.refresh, + credential.expires, + credential.accountId ?? null, + ]); +} + +function encodeAuthProfileCredential(credential: AuthProfileCredential): string { + switch (credential.type) { + case "api_key": + return JSON.stringify([ + "api_key", + credential.provider, + credential.key ?? null, + encodeUnknown(credential.keyRef), + credential.email ?? null, + credential.displayName ?? null, + encodeUnknown(credential.metadata), + ]); + case "token": + return JSON.stringify([ + "token", + credential.provider, + credential.token ?? null, + encodeUnknown(credential.tokenRef), + credential.expires ?? null, + credential.email ?? null, + credential.displayName ?? null, + ]); + case "oauth": + return JSON.stringify([ + "oauth", + credential.provider, + credential.access, + credential.refresh, + credential.expires, + credential.clientId ?? null, + credential.email ?? null, + credential.displayName ?? null, + credential.enterpriseUrl ?? null, + credential.projectId ?? null, + credential.accountId ?? null, + credential.managedBy ?? null, + ]); + } +} + +function getLocalCliCredentialFingerprint(provider: string): string | undefined { + switch (provider) { + case "claude-cli": { + const credential = cliAuthEpochDeps.readClaudeCliCredentialsCached({ + ttlMs: 5000, + allowKeychainPrompt: false, + }); + return credential ? hashCliAuthEpochPart(encodeClaudeCredential(credential)) : undefined; + } + case "codex-cli": { + const credential = cliAuthEpochDeps.readCodexCliCredentialsCached({ + ttlMs: 5000, + }); + return credential ? hashCliAuthEpochPart(encodeCodexCredential(credential)) : undefined; + } + default: + return undefined; + } +} + +function getAuthProfileCredential( + store: AuthProfileStore, + authProfileId: string | undefined, +): AuthProfileCredential | undefined { + if (!authProfileId) { + return undefined; + } + return store.profiles[authProfileId]; +} + +export async function resolveCliAuthEpoch(params: { + provider: string; + authProfileId?: string; +}): Promise { + const provider = params.provider.trim(); + const authProfileId = params.authProfileId?.trim() || undefined; + const parts: string[] = []; + + const localFingerprint = getLocalCliCredentialFingerprint(provider); + if (localFingerprint) { + parts.push(`local:${provider}:${localFingerprint}`); + } + + if (authProfileId) { + const store = cliAuthEpochDeps.loadAuthProfileStoreForRuntime(undefined, { + readOnly: true, + allowKeychainPrompt: false, + }); + const credential = getAuthProfileCredential(store, authProfileId); + if (credential) { + parts.push( + `profile:${authProfileId}:${hashCliAuthEpochPart(encodeAuthProfileCredential(credential))}`, + ); + } + } + + if (parts.length === 0) { + return undefined; + } + return hashCliAuthEpochPart(parts.join("\n")); +} diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 9ae4d3909d9..d2eedf06401 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -33,6 +33,7 @@ export async function runCliAgent(params: RunCliAgentParams): Promise { setCliSessionBinding(entry, "claude-cli", { sessionId: "cli-session-1", authProfileId: "anthropic:work", + authEpoch: "auth-epoch", extraSystemPromptHash: "prompt-hash", mcpConfigHash: "mcp-hash", }); @@ -28,6 +29,7 @@ describe("cli-session helpers", () => { expect(getCliSessionBinding(entry, "claude-cli")).toEqual({ sessionId: "cli-session-1", authProfileId: "anthropic:work", + authEpoch: "auth-epoch", extraSystemPromptHash: "prompt-hash", mcpConfigHash: "mcp-hash", }); @@ -79,6 +81,7 @@ describe("cli-session helpers", () => { const binding = { sessionId: "cli-session-1", authProfileId: "anthropic:work", + authEpoch: "auth-epoch-a", extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }; @@ -87,6 +90,7 @@ describe("cli-session helpers", () => { resolveCliSessionReuse({ binding, authProfileId: "anthropic:personal", + authEpoch: "auth-epoch-a", extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }), @@ -95,6 +99,16 @@ describe("cli-session helpers", () => { resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", + authEpoch: "auth-epoch-b", + extraSystemPromptHash: "prompt-a", + mcpConfigHash: "mcp-a", + }), + ).toEqual({ invalidatedReason: "auth-epoch" }); + expect( + resolveCliSessionReuse({ + binding, + authProfileId: "anthropic:work", + authEpoch: "auth-epoch-a", extraSystemPromptHash: "prompt-b", mcpConfigHash: "mcp-a", }), @@ -103,6 +117,7 @@ describe("cli-session helpers", () => { resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", + authEpoch: "auth-epoch-a", extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-b", }), @@ -113,6 +128,7 @@ describe("cli-session helpers", () => { const binding = { sessionId: "cli-session-1", authProfileId: "anthropic:work", + authEpoch: "auth-epoch-a", extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }; @@ -121,6 +137,7 @@ describe("cli-session helpers", () => { resolveCliSessionReuse({ binding, authProfileId: "anthropic:work", + authEpoch: "auth-epoch-a", extraSystemPromptHash: "prompt-a", mcpConfigHash: "mcp-a", }), diff --git a/src/agents/cli-session.ts b/src/agents/cli-session.ts index 1cf35fdfcc8..09fdc701a4c 100644 --- a/src/agents/cli-session.ts +++ b/src/agents/cli-session.ts @@ -31,6 +31,7 @@ export function getCliSessionBinding( return { sessionId: bindingSessionId, authProfileId: trimOptional(fromBindings?.authProfileId), + authEpoch: trimOptional(fromBindings?.authEpoch), extraSystemPromptHash: trimOptional(fromBindings?.extraSystemPromptHash), mcpConfigHash: trimOptional(fromBindings?.mcpConfigHash), }; @@ -76,6 +77,7 @@ export function setCliSessionBinding( ...(trimOptional(binding.authProfileId) ? { authProfileId: trimOptional(binding.authProfileId) } : {}), + ...(trimOptional(binding.authEpoch) ? { authEpoch: trimOptional(binding.authEpoch) } : {}), ...(trimOptional(binding.extraSystemPromptHash) ? { extraSystemPromptHash: trimOptional(binding.extraSystemPromptHash) } : {}), @@ -116,21 +118,30 @@ export function clearAllCliSessions(entry: SessionEntry): void { export function resolveCliSessionReuse(params: { binding?: CliSessionBinding; authProfileId?: string; + authEpoch?: string; extraSystemPromptHash?: string; mcpConfigHash?: string; -}): { sessionId?: string; invalidatedReason?: "auth-profile" | "system-prompt" | "mcp" } { +}): { + sessionId?: string; + invalidatedReason?: "auth-profile" | "auth-epoch" | "system-prompt" | "mcp"; +} { const binding = params.binding; const sessionId = trimOptional(binding?.sessionId); if (!sessionId) { return {}; } const currentAuthProfileId = trimOptional(params.authProfileId); + const currentAuthEpoch = trimOptional(params.authEpoch); const currentExtraSystemPromptHash = trimOptional(params.extraSystemPromptHash); const currentMcpConfigHash = trimOptional(params.mcpConfigHash); const storedAuthProfileId = trimOptional(binding?.authProfileId); if (storedAuthProfileId !== currentAuthProfileId) { return { invalidatedReason: "auth-profile" }; } + const storedAuthEpoch = trimOptional(binding?.authEpoch); + if (storedAuthEpoch !== currentAuthEpoch) { + return { invalidatedReason: "auth-epoch" }; + } const storedExtraSystemPromptHash = trimOptional(binding?.extraSystemPromptHash); if (storedExtraSystemPromptHash !== currentExtraSystemPromptHash) { return { invalidatedReason: "system-prompt" }; diff --git a/src/commands/agent/session-store.test.ts b/src/commands/agent/session-store.test.ts index 1cdaf1a2cbc..baf977f49f4 100644 --- a/src/commands/agent/session-store.test.ts +++ b/src/commands/agent/session-store.test.ts @@ -167,6 +167,7 @@ describe("updateSessionStoreAfterAgentRun", () => { sessionId: "claude-cli-session-1", cliSessionBinding: { sessionId: "claude-cli-session-1", + authEpoch: "auth-epoch-1", }, }, }, @@ -181,11 +182,13 @@ describe("updateSessionStoreAfterAgentRun", () => { expect(second.sessionKey).toBe(first.sessionKey); expect(second.sessionEntry?.cliSessionBindings?.["claude-cli"]).toEqual({ sessionId: "claude-cli-session-1", + authEpoch: "auth-epoch-1", }); const persisted = loadSessionStore(storePath, { skipCache: true })[first.sessionKey!]; expect(persisted?.cliSessionBindings?.["claude-cli"]).toEqual({ sessionId: "claude-cli-session-1", + authEpoch: "auth-epoch-1", }); }); }); diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index e1e594cd98a..00f7dc989f4 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -68,6 +68,7 @@ export type AcpSessionRuntimeOptions = { export type CliSessionBinding = { sessionId: string; authProfileId?: string; + authEpoch?: string; extraSystemPromptHash?: string; mcpConfigHash?: string; };