mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 10:44:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,6 +32,7 @@ export {
|
||||
dedupeProfileIds,
|
||||
listProfilesForProvider,
|
||||
markAuthProfileSuccess,
|
||||
removeProviderAuthProfilesWithLock,
|
||||
setAuthProfileOrder,
|
||||
upsertAuthProfile,
|
||||
upsertAuthProfileWithLock,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user