mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(cli): keep agents delete local fallback
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user