fix(agents): invalidate stale cli sessions on auth changes

This commit is contained in:
Peter Steinberger
2026-04-05 07:11:48 +01:00
parent 903cb3c48c
commit e5023cc141
11 changed files with 354 additions and 2 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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);
});
});

View File

@@ -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<CliAuthEpochDeps>): 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<string | undefined> {
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"));
}

View File

@@ -33,6 +33,7 @@ export async function runCliAgent(params: RunCliAgentParams): Promise<EmbeddedPi
cliSessionBinding: {
sessionId: resultParams.effectiveCliSessionId,
...(params.authProfileId ? { authProfileId: params.authProfileId } : {}),
...(context.authEpoch ? { authEpoch: context.authEpoch } : {}),
...(context.extraSystemPromptHash
? { extraSystemPromptHash: context.extraSystemPromptHash }
: {}),

View File

@@ -14,6 +14,7 @@ import {
makeBootstrapWarn as makeBootstrapWarnImpl,
resolveBootstrapContextForRun as resolveBootstrapContextForRunImpl,
} from "../bootstrap-files.js";
import { resolveCliAuthEpoch } from "../cli-auth-epoch.js";
import { resolveCliBackendConfig } from "../cli-backends.js";
import { hashCliSessionText, resolveCliSessionReuse } from "../cli-session.js";
import { resolveOpenClawDocsPath } from "../docs-path.js";
@@ -65,6 +66,10 @@ export async function prepareCliRunContext(
if (!backendResolved) {
throw new Error(`Unknown CLI backend: ${params.provider}`);
}
const authEpoch = await resolveCliAuthEpoch({
provider: params.provider,
authProfileId: params.authProfileId,
});
const extraSystemPrompt = params.extraSystemPrompt?.trim() ?? "";
const extraSystemPromptHash = hashCliSessionText(extraSystemPrompt);
const modelId = (params.model ?? "default").trim() || "default";
@@ -130,6 +135,7 @@ export async function prepareCliRunContext(
params.cliSessionBinding ??
(params.cliSessionId ? { sessionId: params.cliSessionId } : undefined),
authProfileId: params.authProfileId,
authEpoch,
extraSystemPromptHash,
mcpConfigHash: preparedBackend.mcpConfigHash,
});
@@ -197,6 +203,7 @@ export async function prepareCliRunContext(
systemPromptReport,
bootstrapPromptWarningLines: bootstrapPromptWarning.lines,
heartbeatPrompt,
authEpoch,
extraSystemPromptHash,
};
}

View File

@@ -43,7 +43,7 @@ export type CliPreparedBackend = {
export type CliReusableSession = {
sessionId?: string;
invalidatedReason?: "auth-profile" | "system-prompt" | "mcp";
invalidatedReason?: "auth-profile" | "auth-epoch" | "system-prompt" | "mcp";
};
export type PreparedCliRunContext = {
@@ -59,5 +59,6 @@ export type PreparedCliRunContext = {
systemPromptReport: SessionSystemPromptReport;
bootstrapPromptWarningLines: string[];
heartbeatPrompt?: string;
authEpoch?: string;
extraSystemPromptHash?: string;
};

View File

@@ -19,6 +19,7 @@ describe("cli-session helpers", () => {
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",
}),

View File

@@ -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" };

View File

@@ -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",
});
});
});

View File

@@ -68,6 +68,7 @@ export type AcpSessionRuntimeOptions = {
export type CliSessionBinding = {
sessionId: string;
authProfileId?: string;
authEpoch?: string;
extraSystemPromptHash?: string;
mcpConfigHash?: string;
};