From 64b94daf92c4d80ac39f5fd5eb70d3458dce6ec8 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Fri, 15 May 2026 18:36:49 -0500 Subject: [PATCH] Fix gateway auth logout aborting active runs (#82346) * fix gateway auth logout aborts active runs * docs changelog for auth logout abort fix * test fix auth logout typecheck * test fix auth profile mock shape --- CHANGELOG.md | 1 + docs/gateway/authentication.md | 12 + packages/sdk/src/client.ts | 1 + packages/sdk/src/index.test.ts | 38 ++- packages/sdk/src/normalize.ts | 1 + src/agents/agent-command.ts | 4 + src/agents/auth-profiles.ts | 1 + src/agents/command/types.ts | 2 + .../isolated-agent.model-formatting.test.ts | 8 +- src/gateway/chat-abort.test.ts | 35 +++ src/gateway/chat-abort.ts | 58 ++++ src/gateway/methods/core-descriptors.ts | 1 + src/gateway/server-methods/agent.ts | 30 +- src/gateway/server-methods/chat.ts | 24 +- .../server-methods/models-auth-status.test.ts | 258 +++++++++++++++++- .../server-methods/models-auth-status.ts | 114 +++++++- src/secrets/runtime.ts | 15 + 17 files changed, 588 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b58720054..1548a6ade9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Control UI/WebChat: keep optimistic image messages from embedding large inline `data:` previews and preserve image-only user turns in chat history, avoiding browser stack overflows when sending image attachments. Fixes #82182. Thanks @ExploreSheep. - Agents/media: preserve message-tool-only delivery for generated music and video completion handoffs, so group/channel completions do not finish without posting the generated attachment. - Telegram: drain queued outbound deliveries after polling reconnect confirms fresh `getUpdates` activity, so stale-socket and network recovery do not leave failed replies stranded. Fixes #50040. Refs #82175. Thanks @dmitriiforpost-commits and @shellyrocklobster. +- Gateway/model auth: abort active provider runs when saved auth is removed through the Gateway control plane, refresh live runtime auth snapshots, and surface `stopReason: "auth-revoked"` to clients. Fixes #81987. (#82346) Thanks @joshavant. - Telegram: mark isolated polling ingress unhealthy when a spooled inbound backlog stalls while Bot API polling still succeeds, so gateway/channel health no longer stays green after Telegram DM processing wedges. Fixes #82175. Thanks @shellyrocklobster. - Agents: strip Gemini/Gemma `` tags with attributes or self-closing syntax from delivered replies, including strict final-tag streaming enforcement. Fixes #65867. - macOS/update: disarm legacy `ai.openclaw.update.*` LaunchAgents when `openclaw update` starts from one, preventing KeepAlive relaunch loops that repeatedly restart the Gateway and replay update continuations. Fixes #82167. diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index 48aa73f822c..6dd0e66f5b5 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -179,6 +179,18 @@ requests`, `ThrottlingException`, `concurrency limit reached`, or - Non-rate-limit errors are not retried with alternate keys. - If all keys fail, the final error from the last attempt is returned. +## Removing provider auth while the gateway is running + +When provider auth is removed through the Gateway control plane, OpenClaw deletes +the saved auth profiles for that provider and aborts active chat or agent runs +whose selected model provider matches the removed provider. The aborted runs emit +the normal chat cancellation and lifecycle events with +`stopReason: "auth-revoked"`, so connected clients can show that the run was +stopped because credentials were removed. + +Removing saved auth does not revoke keys at the provider. Rotate or revoke the +key in the provider dashboard when you need provider-side invalidation. + ## Controlling which credential is used ### Per-session (chat command) diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 83e123450fb..151fceba5f4 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -73,6 +73,7 @@ function runStatusFromWaitPayload(payload: unknown): RunResult["status"] { stopReason === "cancelled" || stopReason === "canceled" || stopReason === "killed" || + stopReason === "auth-revoked" || stopReason === "rpc" || stopReason === "user" || (record.aborted === true && stopReason === "stop") diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index a3626cc754f..e2726a4e5f0 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -143,6 +143,24 @@ describe("OpenClaw SDK", () => { expect(result.error?.message).toBe("aborted by operator"); }); + it("maps auth-revoked wait snapshots to cancelled", async () => { + const transport = new FakeTransport({ + "agent.wait": { + status: "timeout", + runId: "run_auth_revoked", + stopReason: "auth-revoked", + error: "provider auth was removed", + }, + }); + const oc = new OpenClaw({ transport }); + + const result = await oc.runs.wait("run_auth_revoked"); + + expect(result.runId).toBe("run_auth_revoked"); + expect(result.status).toBe("cancelled"); + expect(result.error?.message).toBe("provider auth was removed"); + }); + it("keeps wait-only deadlines non-terminal", async () => { const transport = new FakeTransport({ "agent.wait": { status: "timeout", runId: "run_still_active" }, @@ -989,9 +1007,27 @@ describe("OpenClaw SDK", () => { expect(cancelled.runId).toBe("run_1"); expect(cancelled.data).toEqual({ phase: "end", aborted: true, stopReason: "rpc" }); - const timedOut = normalizeGatewayEvent({ + const authRevoked = normalizeGatewayEvent({ event: "agent", seq: 6, + payload: { + runId: "run_1", + stream: "lifecycle", + ts, + data: { phase: "end", aborted: true, stopReason: "auth-revoked" }, + }, + }); + expect(authRevoked.type).toBe("run.cancelled"); + expect(authRevoked.runId).toBe("run_1"); + expect(authRevoked.data).toEqual({ + phase: "end", + aborted: true, + stopReason: "auth-revoked", + }); + + const timedOut = normalizeGatewayEvent({ + event: "agent", + seq: 7, payload: { runId: "run_1", stream: "lifecycle", diff --git a/packages/sdk/src/normalize.ts b/packages/sdk/src/normalize.ts index e3ce0059af0..2d3568d2043 100644 --- a/packages/sdk/src/normalize.ts +++ b/packages/sdk/src/normalize.ts @@ -28,6 +28,7 @@ function normalizeLifecycleEndEventType(data: JsonObject): OpenClawEventType { stopReason === "cancelled" || stopReason === "canceled" || stopReason === "killed" || + stopReason === "auth-revoked" || stopReason === "rpc" || stopReason === "user" || (data.aborted === true && stopReason === "stop") diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index aae7fab287d..4a9c069e78a 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -1055,6 +1055,10 @@ async function agentCommandInternal( run: async (providerOverride, modelOverride, runOptions) => { const isFallbackRetry = fallbackAttemptIndex > 0; fallbackAttemptIndex += 1; + opts.onActiveModelSelected?.({ + provider: providerOverride, + model: modelOverride, + }); return attemptExecutionRuntime.runAgentAttempt({ providerOverride, modelOverride, diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index fd51a3f2749..f1bdfa8077b 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -32,6 +32,7 @@ export { dedupeProfileIds, listProfilesForProvider, markAuthProfileSuccess, + removeProviderAuthProfilesWithLock, setAuthProfileOrder, upsertAuthProfile, upsertAuthProfileWithLock, diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 6af743b79b1..76205bdf3a4 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -118,6 +118,8 @@ export type AgentCommandOpts = { cleanupCliLiveSessionOnRunEnd?: boolean; /** Internal local CLI callers can annotate result metadata before JSON/text output. */ resultMetaOverrides?: AgentCommandResultMetaOverrides; + /** Called when the actual run model is selected, including fallback retries. */ + onActiveModelSelected?: (ctx: { provider: string; model: string }) => void; /** Internal one-shot model probe mode: no tools, no workspace/chat prompt policy. */ modelRun?: boolean; /** Internal prompt-mode override for trusted local/gateway callsites. */ diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index b47d1dbbdc0..291fabd1b9f 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import type { AgentConfig } from "../config/types.agents.js"; const { loadModelCatalogMock, @@ -71,12 +72,7 @@ type AgentTurnPayload = { type SelectModelOptions = { cfg?: Record; - agentConfigOverride?: { - model?: unknown; - subagents?: { - model?: unknown; - }; - }; + agentConfigOverride?: Pick; payload?: AgentTurnPayload; sessionEntry?: { modelOverride?: string; diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index 41e0d0eb753..8571903e864 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { abortChatRunById, + abortChatRunsForProvider, isChatStopCommandText, type ChatAbortOps, type ChatAbortControllerEntry, + updateChatRunProvider, } from "./chat-abort.js"; type ChatAbortPayload = { @@ -193,3 +195,36 @@ describe("abortChatRunById", () => { }); }); }); + +describe("abortChatRunsForProvider", () => { + it("uses updated provider metadata after model fallback", () => { + const runId = "run-1"; + const sessionKey = "main"; + const entry = createActiveEntry(sessionKey); + entry.providerId = "openai"; + entry.authProviderId = "openai"; + const ops = createOps({ runId, entry }); + + const updated = updateChatRunProvider(ops.chatAbortControllers, { + runId, + providerId: "openrouter", + authProviderId: "openrouter", + }); + const result = abortChatRunsForProvider(ops, { + providerId: "openrouter", + stopReason: "auth-revoked", + }); + + expect(updated).toBe(true); + expect(result.runIds).toEqual([runId]); + expect(entry.controller.signal.aborted).toBe(true); + expect(ops.broadcast).toHaveBeenCalledWith( + "chat", + expect.objectContaining({ + runId, + state: "aborted", + stopReason: "auth-revoked", + }), + ); + }); +}); diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index e3eb1a6ddfd..71242353364 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -12,6 +12,8 @@ export type ChatAbortControllerEntry = { expiresAtMs: number; ownerConnId?: string; ownerDeviceId?: string; + providerId?: string; + authProviderId?: string; /** * Which RPC owns this registration. Absent (undefined) is treated as * `"chat-send"` so pre-existing callers that constructed entries without @@ -76,6 +78,8 @@ export function registerChatAbortController(params: { timeoutMs: number; ownerConnId?: string; ownerDeviceId?: string; + providerId?: string; + authProviderId?: string; kind?: ChatAbortControllerEntry["kind"]; now?: number; expiresAtMs?: number; @@ -102,12 +106,19 @@ export function registerChatAbortController(params: { params.expiresAtMs ?? resolveChatRunExpiresAtMs({ now, timeoutMs: params.timeoutMs }), ownerConnId: params.ownerConnId, ownerDeviceId: params.ownerDeviceId, + providerId: normalizeProviderIdForActiveRun(params.providerId), + authProviderId: normalizeProviderIdForActiveRun(params.authProviderId), kind: params.kind, }; params.chatAbortControllers.set(params.runId, entry); return { controller, registered: true, entry, cleanup }; } +function normalizeProviderIdForActiveRun(providerId: string | undefined): string | undefined { + const trimmed = providerId?.trim().toLowerCase(); + return trimmed || undefined; +} + export type ChatAbortOps = { chatAbortControllers: Map; chatRunBuffers: Map; @@ -208,3 +219,50 @@ export function abortChatRunById( } return { aborted: true }; } + +export function updateChatRunProvider( + chatAbortControllers: Map, + params: { + runId: string; + providerId?: string; + authProviderId?: string; + }, +): boolean { + const entry = chatAbortControllers.get(params.runId); + if (!entry) { + return false; + } + entry.providerId = normalizeProviderIdForActiveRun(params.providerId); + entry.authProviderId = normalizeProviderIdForActiveRun(params.authProviderId); + return true; +} + +export function abortChatRunsForProvider( + ops: ChatAbortOps, + params: { + providerId: string; + stopReason?: string; + }, +): { runIds: string[] } { + const providerId = normalizeProviderIdForActiveRun(params.providerId); + if (!providerId) { + return { runIds: [] }; + } + const matches = [...ops.chatAbortControllers.entries()].filter( + ([, entry]) => + normalizeProviderIdForActiveRun(entry.authProviderId) === providerId || + normalizeProviderIdForActiveRun(entry.providerId) === providerId, + ); + const runIds: string[] = []; + for (const [runId, entry] of matches) { + const result = abortChatRunById(ops, { + runId, + sessionKey: entry.sessionKey, + stopReason: params.stopReason, + }); + if (result.aborted) { + runIds.push(runId); + } + } + return { runIds }; +} diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index bbe6ff5d617..1a458395e6c 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -85,6 +85,7 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "commands.list", scope: "operator.read" }, { name: "models.list", scope: "operator.read", startup: true }, { name: "models.authStatus", scope: "operator.read" }, + { name: "models.authLogout", scope: "operator.admin", controlPlaneWrite: true }, { name: "tools.catalog", scope: "operator.read" }, { name: "tools.effective", scope: "operator.read", startup: true }, { name: "tools.invoke", scope: "operator.write" }, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index b6627a993c0..ac99ca7de94 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -16,6 +16,7 @@ import { import { AGENT_INTERNAL_EVENT_TYPE_TASK_COMPLETION } from "../../agents/internal-event-contract.js"; import type { AgentInternalEvent } from "../../agents/internal-events.js"; import { resolveTrustedGroupId } from "../../agents/pi-tools.policy.js"; +import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { resolveSandboxConfigForAgent } from "../../agents/sandbox/config.js"; import { normalizeSpawnedRunMetadata, @@ -95,7 +96,11 @@ import { normalizeMessageChannel, } from "../../utils/message-channel.js"; import { resolveAssistantIdentity } from "../assistant-identity.js"; -import { registerChatAbortController, resolveAgentRunExpiresAtMs } from "../chat-abort.js"; +import { + registerChatAbortController, + resolveAgentRunExpiresAtMs, + updateChatRunProvider, +} from "../chat-abort.js"; import { MediaOffloadError, parseMessageWithAttachments, @@ -1320,6 +1325,18 @@ export const agentHandlers: GatewayRequestHandlers = { cfg: cfgForAgent ?? cfg, overrideSeconds: typeof request.timeout === "number" ? request.timeout : undefined, }); + const activeModelProvider = + providerOverride ?? + resolveSessionModelRef( + cfgForAgent ?? cfg, + sessionEntry, + resolvedSessionKey + ? resolveAgentIdFromSessionKey(resolvedSessionKey) + : (agentId ?? resolveDefaultAgentId(cfgForAgent ?? cfg)), + ).provider; + const activeAuthProvider = resolveProviderIdForAuth(activeModelProvider, { + config: cfgForAgent ?? cfg, + }); const activeRunAbort = registerChatAbortController({ chatAbortControllers: context.chatAbortControllers, runId, @@ -1331,6 +1348,8 @@ export const agentHandlers: GatewayRequestHandlers = { ownerConnId: typeof client?.connId === "string" ? client.connId : undefined, ownerDeviceId: typeof client?.connect?.device?.id === "string" ? client.connect.device.id : undefined, + providerId: activeModelProvider, + authProviderId: activeAuthProvider, kind: "agent", }); if (!activeRunAbort.registered && context.chatAbortControllers.has(runId)) { @@ -1498,6 +1517,15 @@ export const agentHandlers: GatewayRequestHandlers = { }), cleanupBundleMcpOnRunEnd: request.cleanupBundleMcpOnRunEnd, abortSignal: activeRunAbort.controller.signal, + onActiveModelSelected: ({ provider }) => { + updateChatRunProvider(context.chatAbortControllers, { + runId, + providerId: provider, + authProviderId: resolveProviderIdForAuth(provider, { + config: cfgForAgent ?? cfg, + }), + }); + }, // Internal-only: allow workspace override for spawned subagent runs. workspaceDir: resolveIngressWorkspaceOverrideForSpawnedRun({ spawnedBy: spawnedByValue, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 5288cce57cf..5d7b8643ad4 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -6,6 +6,7 @@ import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { rewriteTranscriptEntriesInSessionFile } from "../../agents/pi-embedded-runner/transcript-rewrite.js"; +import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox/context.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; @@ -62,6 +63,7 @@ import { type ChatAbortOps, isChatStopCommandText, registerChatAbortController, + updateChatRunProvider, } from "../chat-abort.js"; import { type ChatImageContent, @@ -2013,6 +2015,10 @@ export const chatHandlers: GatewayRequestHandlers = { sessionKey, config: cfg, }); + const resolvedSessionModel = resolveSessionModelRef(cfg, entry, agentId); + const resolvedSessionAuthProvider = resolveProviderIdForAuth(resolvedSessionModel.provider, { + config: cfg, + }); let parsedMessage = inboundMessage; let parsedImages: ChatImageContent[] = []; let imageOrder: PromptImageOrderEntry[] = []; @@ -2113,11 +2119,10 @@ export const chatHandlers: GatewayRequestHandlers = { await measureDiagnosticsTimelineSpan( "gateway.chat_send.prepare_attachments", async () => { - const modelRef = resolveSessionModelRef(cfg, entry, agentId); const supportsSessionModelImages = await resolveGatewayModelSupportsImages({ loadGatewayModelCatalog: context.loadGatewayModelCatalog, - provider: modelRef.provider, - model: modelRef.model, + provider: resolvedSessionModel.provider, + model: resolvedSessionModel.model, }); // Bound plugin sessions own the real recipient model, so keep image // attachments even when the parent OpenClaw session model is text-only. @@ -2195,6 +2200,8 @@ export const chatHandlers: GatewayRequestHandlers = { now, ownerConnId: normalizeOptionalText(client?.connId), ownerDeviceId: normalizeOptionalText(client?.connect?.device?.id), + providerId: resolvedSessionModel.provider, + authProviderId: resolvedSessionAuthProvider, kind: "chat-send", }); if (!activeRunAbort.registered) { @@ -2545,7 +2552,16 @@ export const chatHandlers: GatewayRequestHandlers = { } } }, - onModelSelected, + onModelSelected: (modelSelection) => { + updateChatRunProvider(context.chatAbortControllers, { + runId: clientRunId, + providerId: modelSelection.provider, + authProviderId: resolveProviderIdForAuth(modelSelection.provider, { + config: cfg, + }), + }); + onModelSelected(modelSelection); + }, }, }), { diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index 10c9b22a107..5012fe840f3 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthHealthSummary } from "../../agents/auth-health.js"; +import type { AuthProfileStore } from "../../agents/auth-profiles.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; const mocks = vi.hoisted(() => ({ @@ -8,8 +9,20 @@ const mocks = vi.hoisted(() => ({ ensureAuthProfileStore: vi.fn((agentDir?: string, options?: unknown) => { void agentDir; void options; - return { profiles: {} }; + return { version: 1, profiles: {} }; }), + ensureAuthProfileStoreWithoutExternalProfiles: vi.fn((agentDir?: string) => { + void agentDir; + return { version: 1, profiles: {} }; + }), + listProfilesForProvider: vi.fn((): string[] => []), + removeProviderAuthProfilesWithLock: vi.fn( + async (): Promise => ({ version: 1, profiles: {} }), + ), + resolvePersistedAuthProfileOwnerAgentDir: vi.fn( + (params: { agentDir?: string }) => params.agentDir, + ), + refreshActiveSecretsRuntimeSnapshot: vi.fn(async () => false), buildAuthHealthSummary: vi.fn( (): AuthHealthSummary => ({ now: 0, warnAfterMs: 0, profiles: [], providers: [] }), ), @@ -31,6 +44,11 @@ vi.mock("../../agents/auth-profiles.js", async () => { return { ...actual, ensureAuthProfileStore: mocks.ensureAuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles: + mocks.ensureAuthProfileStoreWithoutExternalProfiles, + listProfilesForProvider: mocks.listProfilesForProvider, + removeProviderAuthProfilesWithLock: mocks.removeProviderAuthProfilesWithLock, + resolvePersistedAuthProfileOwnerAgentDir: mocks.resolvePersistedAuthProfileOwnerAgentDir, }; }); @@ -48,10 +66,15 @@ vi.mock("../../infra/provider-usage.load.js", () => ({ loadProviderUsageSummary: mocks.loadProviderUsageSummary, })); +vi.mock("../../secrets/runtime.js", () => ({ + refreshActiveSecretsRuntimeSnapshot: mocks.refreshActiveSecretsRuntimeSnapshot, +})); + import { aggregateOAuthStatus, invalidateModelAuthStatusCache, modelsAuthStatusHandlers, + type ModelAuthLogoutResult, type ModelAuthStatusResult, } from "./models-auth-status.js"; @@ -70,6 +93,48 @@ function createOptions( } const handler = modelsAuthStatusHandlers["models.authStatus"]; +const logoutHandler = modelsAuthStatusHandlers["models.authLogout"]; + +function createActiveRun(providerId: string, authProviderId?: string) { + return { + controller: new AbortController(), + sessionId: `session-${providerId}`, + sessionKey: `agent:main:${providerId}`, + startedAtMs: 1, + expiresAtMs: 60_000, + providerId, + authProviderId, + }; +} + +function createLogoutOptions( + params: Record = {}, +): GatewayRequestHandlerOptions & { respond: ReturnType } { + const respond = vi.fn(); + const context = { + getRuntimeConfig: mocks.getRuntimeConfig, + chatAbortControllers: new Map(), + chatRunBuffers: new Map(), + chatDeltaSentAt: new Map(), + chatDeltaLastBroadcastLen: new Map(), + chatDeltaLastBroadcastText: new Map(), + agentDeltaSentAt: new Map(), + bufferedAgentEvents: new Map(), + chatAbortedRuns: new Map(), + removeChatRun: vi.fn(), + agentRunSeq: new Map(), + broadcast: vi.fn(), + nodeSendToSession: vi.fn(), + }; + return { + req: { type: "req", id: "req-logout", method: "models.authLogout", params }, + params, + client: null, + isWebchatConnect: () => false, + respond, + context, + } as unknown as GatewayRequestHandlerOptions & { respond: ReturnType }; +} function requireRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -126,7 +191,16 @@ describe("models.authStatus", () => { vi.clearAllMocks(); invalidateModelAuthStatusCache(); mocks.getRuntimeConfig.mockReturnValue({}); - mocks.ensureAuthProfileStore.mockReturnValue({ profiles: {} }); + mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} }); + mocks.ensureAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ + version: 1, + profiles: {}, + }); + mocks.listProfilesForProvider.mockReturnValue([]); + mocks.removeProviderAuthProfilesWithLock.mockResolvedValue({ version: 1, profiles: {} }); + mocks.resolvePersistedAuthProfileOwnerAgentDir.mockImplementation( + (params: { agentDir?: string }) => params.agentDir, + ); mocks.buildAuthHealthSummary.mockReturnValue({ now: 0, warnAfterMs: 0, @@ -509,6 +583,186 @@ describe("models.authStatus", () => { }); }); +describe("models.authLogout", () => { + beforeEach(() => { + vi.clearAllMocks(); + invalidateModelAuthStatusCache(); + mocks.getRuntimeConfig.mockReturnValue({}); + mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} }); + mocks.ensureAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ + version: 1, + profiles: {}, + }); + mocks.listProfilesForProvider.mockReturnValue([]); + mocks.removeProviderAuthProfilesWithLock.mockResolvedValue({ version: 1, profiles: {} }); + mocks.resolvePersistedAuthProfileOwnerAgentDir.mockImplementation( + (params: { agentDir?: string }) => params.agentDir, + ); + mocks.refreshActiveSecretsRuntimeSnapshot.mockResolvedValue(false); + }); + + it("removes provider auth profiles and invalidates the status cache", async () => { + mocks.listProfilesForProvider.mockReturnValue(["openrouter:default"]); + await handler(createOptions()); + expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(1); + + const opts = createLogoutOptions({ provider: "OpenRouter" }); + await logoutHandler(opts); + + expect(mocks.removeProviderAuthProfilesWithLock).toHaveBeenCalledWith({ + provider: "openrouter", + agentDir: "/tmp/agent", + }); + expect(mocks.refreshActiveSecretsRuntimeSnapshot).toHaveBeenCalledTimes(1); + const [ok, payload] = firstRespondCall(opts) ?? []; + expect(ok).toBe(true); + expect((payload as ModelAuthLogoutResult).removedProfiles).toEqual(["openrouter:default"]); + + await handler(createOptions()); + expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2); + }); + + it("aborts active runs for the removed provider only", async () => { + const opts = createLogoutOptions({ provider: "openrouter" }); + const openrouterRun = createActiveRun("openrouter"); + const openaiRun = createActiveRun("openai"); + opts.context.chatAbortControllers.set("run-openrouter", openrouterRun); + opts.context.chatAbortControllers.set("run-openai", openaiRun); + + await logoutHandler(opts); + + expect(openrouterRun.controller.signal.aborted).toBe(true); + expect(openaiRun.controller.signal.aborted).toBe(false); + expect(opts.context.chatAbortControllers.has("run-openrouter")).toBe(false); + expect(opts.context.chatAbortControllers.has("run-openai")).toBe(true); + expect(opts.context.removeChatRun).toHaveBeenCalledWith( + "run-openrouter", + "run-openrouter", + openrouterRun.sessionKey, + ); + expect(opts.context.broadcast).toHaveBeenCalledWith( + "chat", + expect.objectContaining({ + runId: "run-openrouter", + state: "aborted", + stopReason: "auth-revoked", + }), + ); + const [, payload] = firstRespondCall(opts) ?? []; + expect((payload as ModelAuthLogoutResult).abortedRunIds).toEqual(["run-openrouter"]); + }); + + it("aborts provider runs but preserves config SecretRef auth", async () => { + const cfg = { + models: { + providers: { + openrouter: { + auth: "api-key", + apiKey: { + source: "env", + provider: "default", + id: "OPENROUTER_API_KEY", + }, + }, + }, + }, + }; + mocks.getRuntimeConfig.mockReturnValue(cfg); + mocks.listProfilesForProvider.mockReturnValue([]); + const opts = createLogoutOptions({ provider: "openrouter" }); + const activeRun = createActiveRun("openrouter"); + opts.context.chatAbortControllers.set("run-openrouter", activeRun); + + await logoutHandler(opts); + + expect(mocks.removeProviderAuthProfilesWithLock).toHaveBeenCalledWith({ + provider: "openrouter", + agentDir: "/tmp/agent", + }); + expect(cfg.models.providers.openrouter.apiKey).toEqual({ + source: "env", + provider: "default", + id: "OPENROUTER_API_KEY", + }); + expect(activeRun.controller.signal.aborted).toBe(true); + const [ok, payload] = firstRespondCall(opts) ?? []; + expect(ok).toBe(true); + expect((payload as ModelAuthLogoutResult).removedProfiles).toEqual([]); + expect((payload as ModelAuthLogoutResult).abortedRunIds).toEqual(["run-openrouter"]); + }); + + it("removes inherited main-store auth profiles", async () => { + mocks.listProfilesForProvider.mockReturnValue(["openrouter:main"]); + mocks.resolvePersistedAuthProfileOwnerAgentDir.mockReturnValue(undefined); + const opts = createLogoutOptions({ provider: "openrouter" }); + + await logoutHandler(opts); + + expect(mocks.removeProviderAuthProfilesWithLock).toHaveBeenCalledWith({ + provider: "openrouter", + agentDir: "/tmp/agent", + }); + expect(mocks.removeProviderAuthProfilesWithLock).toHaveBeenCalledWith({ + provider: "openrouter", + agentDir: undefined, + }); + const [ok] = firstRespondCall(opts) ?? []; + expect(ok).toBe(true); + }); + + it("aborts active runs that share a provider auth alias", async () => { + const opts = createLogoutOptions({ provider: "byteplus" }); + const aliasedRun = createActiveRun("byteplus-plan", "byteplus"); + opts.context.chatAbortControllers.set("run-byteplus-plan", aliasedRun); + + await logoutHandler(opts); + + expect(aliasedRun.controller.signal.aborted).toBe(true); + const [, payload] = firstRespondCall(opts) ?? []; + expect((payload as ModelAuthLogoutResult).abortedRunIds).toEqual(["run-byteplus-plan"]); + }); + + it("does not abort runs when auth profile removal fails", async () => { + mocks.removeProviderAuthProfilesWithLock.mockResolvedValue(null); + const opts = createLogoutOptions({ provider: "openrouter" }); + const activeRun = createActiveRun("openrouter"); + opts.context.chatAbortControllers.set("run-openrouter", activeRun); + + await logoutHandler(opts); + + expect(activeRun.controller.signal.aborted).toBe(false); + expect(opts.context.chatAbortControllers.has("run-openrouter")).toBe(true); + const [ok, payload, error] = firstRespondCall(opts) ?? []; + expect(ok).toBe(false); + expect(payload).toBeUndefined(); + expect(error?.message).toContain("failed to remove saved auth profiles"); + }); + + it("does not abort runs when runtime auth snapshot refresh fails", async () => { + mocks.refreshActiveSecretsRuntimeSnapshot.mockRejectedValue(new Error("refresh failed")); + const opts = createLogoutOptions({ provider: "openrouter" }); + const activeRun = createActiveRun("openrouter"); + opts.context.chatAbortControllers.set("run-openrouter", activeRun); + + await logoutHandler(opts); + + expect(activeRun.controller.signal.aborted).toBe(false); + expect(opts.context.chatAbortControllers.has("run-openrouter")).toBe(true); + const [ok, payload, error] = firstRespondCall(opts) ?? []; + expect(ok).toBe(false); + expect(payload).toBeUndefined(); + expect(error?.message).toContain("refresh failed"); + }); + + it("rejects missing provider", async () => { + const opts = createLogoutOptions(); + await logoutHandler(opts); + const [ok, , error] = firstRespondCall(opts) ?? []; + expect(ok).toBe(false); + expect(error?.message).toBe("provider is required"); + }); +}); + // Direct unit tests for aggregateOAuthStatus — this helper was introduced to // prevent a specific regression (mixed OAuth+token rollup mis-reporting // providers). Pinning its behavior here so refactors can't silently re-break diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index dfcdb87a506..e6c13cfe6d9 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -9,8 +9,13 @@ import { } from "../../agents/auth-health.js"; import { ensureAuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles, externalCliDiscoveryForConfigStatus, + listProfilesForProvider, + removeProviderAuthProfilesWithLock, + resolvePersistedAuthProfileOwnerAgentDir, } from "../../agents/auth-profiles.js"; +import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; import type { OpenClawConfig } from "../../config/config.js"; import { isSecretRef } from "../../config/types.secrets.js"; @@ -18,9 +23,11 @@ import { loadProviderUsageSummary } from "../../infra/provider-usage.load.js"; import { PROVIDER_LABELS, resolveUsageProviderId } from "../../infra/provider-usage.shared.js"; import type { UsageProviderId, UsageWindow } from "../../infra/provider-usage.types.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { refreshActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js"; +import { abortChatRunsForProvider, type ChatAbortOps } from "../chat-abort.js"; import { ErrorCodes, errorShape } from "../protocol/index.js"; import { formatForLog } from "../ws-log.js"; -import type { GatewayRequestHandlers } from "./types.js"; +import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; const log = createSubsystemLogger("models-auth-status"); @@ -65,6 +72,12 @@ export type ModelAuthStatusResult = { providers: ModelAuthStatusProvider[]; }; +export type ModelAuthLogoutResult = { + provider: string; + removedProfiles: string[]; + abortedRunIds: string[]; +}; + const CACHE_TTL_MS = 60_000; let cached: { ts: number; result: ModelAuthStatusResult } | null = null; @@ -78,6 +91,58 @@ export function invalidateModelAuthStatusCache(): void { cached = null; } +function readProviderParam(params: Record): string | null { + const raw = params.provider; + if (typeof raw !== "string") { + return null; + } + const provider = normalizeProviderId(raw); + return provider || null; +} + +function createAuthLogoutAbortOps(context: GatewayRequestContext): ChatAbortOps { + return { + chatAbortControllers: context.chatAbortControllers, + chatRunBuffers: context.chatRunBuffers, + chatDeltaSentAt: context.chatDeltaSentAt, + chatDeltaLastBroadcastLen: context.chatDeltaLastBroadcastLen, + chatDeltaLastBroadcastText: context.chatDeltaLastBroadcastText, + agentDeltaSentAt: context.agentDeltaSentAt, + bufferedAgentEvents: context.bufferedAgentEvents, + chatAbortedRuns: context.chatAbortedRuns, + removeChatRun: context.removeChatRun, + agentRunSeq: context.agentRunSeq, + broadcast: context.broadcast, + nodeSendToSession: context.nodeSendToSession, + }; +} + +async function removeProviderAuthProfilesAcrossOwnerStores(params: { + provider: string; + agentDir: string; + profileIds: string[]; +}): Promise { + const ownerAgentDirs = new Set([params.agentDir]); + for (const profileId of params.profileIds) { + ownerAgentDirs.add( + resolvePersistedAuthProfileOwnerAgentDir({ + agentDir: params.agentDir, + profileId, + }), + ); + } + for (const ownerAgentDir of ownerAgentDirs) { + const updatedStore = await removeProviderAuthProfilesWithLock({ + provider: params.provider, + agentDir: ownerAgentDir, + }); + if (!updatedStore) { + return false; + } + } + return true; +} + function buildExpiry( remainingMs: number | undefined, expiresAt: number | undefined, @@ -285,6 +350,53 @@ function resolveConfiguredProviders(cfg: OpenClawConfig): { } export const modelsAuthStatusHandlers: GatewayRequestHandlers = { + "models.authLogout": async ({ params, respond, context }) => { + const provider = readProviderParam(params); + if (!provider) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "provider is required")); + return; + } + try { + const cfg = context.getRuntimeConfig(); + const agentDir = resolveDefaultAgentDir(cfg); + const authProvider = resolveProviderIdForAuth(provider, { config: cfg }); + const store = ensureAuthProfileStoreWithoutExternalProfiles(agentDir); + const removedProfiles = listProfilesForProvider(store, provider); + const removed = await removeProviderAuthProfilesAcrossOwnerStores({ + provider, + agentDir, + profileIds: removedProfiles, + }); + if (!removed) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + `failed to remove saved auth profiles for provider ${provider}`, + ), + ); + return; + } + await refreshActiveSecretsRuntimeSnapshot(); + invalidateModelAuthStatusCache(); + const { runIds: abortedRunIds } = abortChatRunsForProvider( + createAuthLogoutAbortOps(context), + { + providerId: authProvider, + stopReason: "auth-revoked", + }, + ); + const result: ModelAuthLogoutResult = { + provider, + removedProfiles, + abortedRunIds, + }; + respond(true, result, undefined); + } catch (err) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); + } + }, "models.authStatus": async ({ params, respond, context }) => { const now = Date.now(); const bypassCache = Boolean((params as { refresh?: boolean } | undefined)?.refresh); diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index b6faba6e896..c42118e4503 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -473,6 +473,21 @@ export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeS }); } +export async function refreshActiveSecretsRuntimeSnapshot(): Promise { + if (!activeSnapshot || !activeRefreshContext) { + return false; + } + const refreshed = await prepareSecretsRuntimeSnapshot({ + config: activeSnapshot.sourceConfig, + env: activeRefreshContext.env, + agentDirs: resolveRefreshAgentDirs(activeSnapshot.sourceConfig, activeRefreshContext), + loadAuthStore: activeRefreshContext.loadAuthStore, + loadablePluginOrigins: activeRefreshContext.loadablePluginOrigins, + }); + activateSecretsRuntimeSnapshot(refreshed); + return true; +} + export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null { if (!activeSnapshot) { return null;