diff --git a/CHANGELOG.md b/CHANGELOG.md index db3bafc43731..ccd3734bf4a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents: fall back to local config pruning when the optional `agents delete` Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces. - Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987. - Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987. - Agents/Codex: keep spawned agent cwd/workspace state separated, keep hook context prompt-local, release session locks on timeout abort, avoid session event queue self-wait, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, and bound compaction/steering retries. (#87218, #86875, #86123, #87399, #87375, #87383, #87400) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, and @sjf. diff --git a/src/commands/agents.commands.delete.ts b/src/commands/agents.commands.delete.ts index f0414bf392da..f40c32e2cd90 100644 --- a/src/commands/agents.commands.delete.ts +++ b/src/commands/agents.commands.delete.ts @@ -7,7 +7,11 @@ import { purgeAgentSessionStoreEntries, resolveSessionTranscriptsDirForAgent, } from "../config/sessions.js"; -import { callGateway, isGatewayTransportError } from "../gateway/call.js"; +import { + callGateway, + isGatewayCredentialsRequiredError, + isGatewayTransportError, +} from "../gateway/call.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -45,7 +49,7 @@ async function maybeDeleteAgentThroughGateway(params: { requiredMethods: ["agents.delete"], }); } catch (error) { - if (isGatewayTransportError(error)) { + if (isGatewayTransportError(error) || isGatewayCredentialsRequiredError(error)) { return null; } throw error; diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index 4aa124b18999..cb196966a25a 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -21,6 +21,7 @@ const fsSafeMocks = vi.hoisted(() => ({ const gatewayMocks = vi.hoisted(() => ({ callGateway: vi.fn(), + isGatewayCredentialsRequiredError: vi.fn(), isGatewayTransportError: vi.fn(), })); @@ -32,6 +33,7 @@ vi.mock("../config/config.js", async () => ({ vi.mock("../gateway/call.js", () => ({ callGateway: gatewayMocks.callGateway, + isGatewayCredentialsRequiredError: gatewayMocks.isGatewayCredentialsRequiredError, isGatewayTransportError: gatewayMocks.isGatewayTransportError, })); @@ -98,6 +100,11 @@ describe("agents delete command", () => { gatewayMocks.callGateway.mockRejectedValue( Object.assign(new Error("closed"), { name: "GatewayTransportError" }), ); + gatewayMocks.isGatewayCredentialsRequiredError.mockReset(); + gatewayMocks.isGatewayCredentialsRequiredError.mockImplementation( + (error: unknown) => + error instanceof Error && error.name === "GatewayCredentialsRequiredError", + ); gatewayMocks.isGatewayTransportError.mockReset(); gatewayMocks.isGatewayTransportError.mockImplementation( (error: unknown) => error instanceof Error && error.name === "GatewayTransportError", @@ -150,6 +157,50 @@ describe("agents delete command", () => { }); }); + it("falls back to local deletion when the optional Gateway probe needs credentials", async () => { + await withStateDirEnv("openclaw-agents-delete-gateway-auth-", async ({ stateDir }) => { + const now = Date.now(); + const cfg: OpenClawConfig = { + agents: { + list: [ + { id: "main", workspace: path.join(stateDir, "workspace-shared") }, + { id: "ops", workspace: path.join(stateDir, "workspace-shared") }, + ], + }, + } satisfies OpenClawConfig; + await arrangeAgentsDeleteTest({ + stateDir, + cfg, + deletedAgentId: "ops", + sessions: { + "agent:ops:main": { sessionId: "sess-ops-main", updatedAt: now + 1 }, + "agent:main:main": { sessionId: "sess-main", updatedAt: now + 2 }, + }, + }); + gatewayMocks.callGateway.mockRejectedValue( + Object.assign( + new Error("gateway agents.delete requires credentials before opening a websocket"), + { + name: "GatewayCredentialsRequiredError", + method: "agents.delete", + configPath: path.join(stateDir, "openclaw.json"), + }, + ), + ); + + await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime); + + expect(runtime.exit).not.toHaveBeenCalled(); + expect(gatewayMocks.callGateway).toHaveBeenCalledOnce(); + expect(configMocks.replaceConfigFile).toHaveBeenCalledOnce(); + const output = readJsonLogs()[0]; + expect(output?.agentId).toBe("ops"); + expect(output?.workspaceRetained).toBe(true); + expect(output?.workspaceRetainedReason).toBe("shared"); + expect(output?.transport).toBeUndefined(); + }); + }); + it("purges deleted agent entries from the session store", async () => { await withStateDirEnv("openclaw-agents-delete-", async ({ stateDir }) => { const now = Date.now(); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index d19584f2ecab..4b3df026ecdd 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -134,6 +134,24 @@ export class GatewayTransportError extends Error { } } +export class GatewayCredentialsRequiredError extends Error { + readonly method: string; + readonly configPath: string; + + constructor(params: { method: string; configPath: string }) { + super( + [ + `gateway ${params.method} requires credentials before opening a websocket`, + "Fix: configure gateway.auth token/password, pair this device, or pass --token/--password.", + `Config: ${params.configPath}`, + ].join("\n"), + ); + this.name = "GatewayCredentialsRequiredError"; + this.method = params.method; + this.configPath = params.configPath; + } +} + export type GatewayTransportErrorJson = { ok: false; error: { @@ -198,6 +216,19 @@ export function isGatewayTransportError(value: unknown): value is GatewayTranspo ); } +export function isGatewayCredentialsRequiredError( + value: unknown, +): value is GatewayCredentialsRequiredError { + if (value instanceof GatewayCredentialsRequiredError) { + return true; + } + if (!(value instanceof Error) || value.name !== "GatewayCredentialsRequiredError") { + return false; + } + const candidate = value as Partial; + return typeof candidate.method === "string" && typeof candidate.configPath === "string"; +} + const defaultCreateGatewayClient = (opts: GatewayClientOptions) => new GatewayClient(opts); const defaultGatewayCallDeps = { createGatewayClient: defaultCreateGatewayClient, @@ -410,13 +441,10 @@ function ensureGatewayCallCanAuthenticate(params: { if (hasStoredOperatorDeviceAuthToken(params.deviceIdentity)) { return; } - throw new Error( - [ - `gateway ${params.opts.method} requires credentials before opening a websocket`, - "Fix: configure gateway.auth token/password, pair this device, or pass --token/--password.", - `Config: ${params.context.configPath}`, - ].join("\n"), - ); + throw new GatewayCredentialsRequiredError({ + method: params.opts.method, + configPath: params.context.configPath, + }); } export type { ExplicitGatewayAuth } from "./credentials.js";