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
This commit is contained in:
Josh Avant
2026-05-15 18:36:49 -05:00
committed by GitHub
parent ea16a5e9e1
commit 64b94daf92
17 changed files with 588 additions and 15 deletions

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -32,6 +32,7 @@ export {
dedupeProfileIds,
listProfilesForProvider,
markAuthProfileSuccess,
removeProviderAuthProfilesWithLock,
setAuthProfileOrder,
upsertAuthProfile,
upsertAuthProfileWithLock,

View File

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

View File

@@ -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<string, unknown>;
agentConfigOverride?: {
model?: unknown;
subagents?: {
model?: unknown;
};
};
agentConfigOverride?: Pick<AgentConfig, "model" | "subagents">;
payload?: AgentTurnPayload;
sessionEntry?: {
modelOverride?: string;

View File

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

View File

@@ -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<string, ChatAbortControllerEntry>;
chatRunBuffers: Map<string, string>;
@@ -208,3 +219,50 @@ export function abortChatRunById(
}
return { aborted: true };
}
export function updateChatRunProvider(
chatAbortControllers: Map<string, ChatAbortControllerEntry>,
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 };
}

View File

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

View File

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

View File

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

View File

@@ -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<AuthProfileStore | null> => ({ 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<string, unknown> = {},
): GatewayRequestHandlerOptions & { respond: ReturnType<typeof vi.fn> } {
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<typeof vi.fn> };
}
function requireRecord(value: unknown): Record<string, unknown> {
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

View File

@@ -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, unknown>): 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<boolean> {
const ownerAgentDirs = new Set<string | undefined>([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);

View File

@@ -473,6 +473,21 @@ export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeS
});
}
export async function refreshActiveSecretsRuntimeSnapshot(): Promise<boolean> {
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;