fix(cli): keep agents delete local fallback

This commit is contained in:
Vincent Koc
2026-05-28 23:41:33 +02:00
parent f5c7d77fb0
commit 7be08d0376
4 changed files with 93 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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