mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 02:40:43 +00:00
Fixes #70889 and #70890. Retains overlapping/shared agent workspaces during `openclaw agents delete`, keeps `--json` output machine-readable, and repairs the stale hook-runner test harness mock that blocked CI. Thanks @kaseonedge.
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { loadSessionStore, resolveStorePath, saveSessionStore } from "../config/sessions.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
|
|
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
|
|
|
|
const configMocks = vi.hoisted(() => ({
|
|
readConfigFileSnapshot: vi.fn(),
|
|
replaceConfigFile: vi.fn(async () => {}),
|
|
}));
|
|
|
|
const processMocks = vi.hoisted(() => ({
|
|
runCommandWithTimeout: vi.fn(async () => ({ stdout: "", stderr: "", code: 0 })),
|
|
}));
|
|
|
|
vi.mock("../config/config.js", async () => ({
|
|
...(await vi.importActual<typeof import("../config/config.js")>("../config/config.js")),
|
|
readConfigFileSnapshot: configMocks.readConfigFileSnapshot,
|
|
replaceConfigFile: configMocks.replaceConfigFile,
|
|
}));
|
|
|
|
vi.mock("../process/exec.js", () => ({
|
|
runCommandWithTimeout: processMocks.runCommandWithTimeout,
|
|
}));
|
|
|
|
import { agentsDeleteCommand } from "./agents.js";
|
|
|
|
const runtime = createTestRuntime();
|
|
|
|
async function arrangeAgentsDeleteTest(params: {
|
|
stateDir: string;
|
|
cfg: OpenClawConfig;
|
|
deletedAgentId?: string;
|
|
sessions: Record<string, { sessionId: string; updatedAt: number }>;
|
|
}) {
|
|
const deletedAgentId = params.deletedAgentId ?? "ops";
|
|
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: deletedAgentId });
|
|
await saveSessionStore(storePath, params.sessions);
|
|
await fs.mkdir(path.join(params.stateDir, `workspace-${deletedAgentId}`), { recursive: true });
|
|
await fs.mkdir(path.join(params.stateDir, "agents", deletedAgentId, "agent"), {
|
|
recursive: true,
|
|
});
|
|
|
|
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
|
...baseConfigSnapshot,
|
|
config: params.cfg,
|
|
runtimeConfig: params.cfg,
|
|
sourceConfig: params.cfg,
|
|
resolved: params.cfg,
|
|
});
|
|
|
|
return storePath;
|
|
}
|
|
|
|
function expectSessionStore(
|
|
storePath: string,
|
|
sessions: Record<string, { sessionId: string; updatedAt: number }>,
|
|
) {
|
|
expect(loadSessionStore(storePath, { skipCache: true })).toEqual(sessions);
|
|
}
|
|
|
|
function readJsonLogs(): Array<Record<string, unknown>> {
|
|
return runtime.log.mock.calls
|
|
.filter((call): call is [string, ...unknown[]] => {
|
|
const arg = call[0];
|
|
return typeof arg === "string" && arg.startsWith("{");
|
|
})
|
|
.map((call) => JSON.parse(call[0]) as Record<string, unknown>);
|
|
}
|
|
|
|
describe("agents delete command", () => {
|
|
beforeEach(() => {
|
|
configMocks.readConfigFileSnapshot.mockReset();
|
|
configMocks.replaceConfigFile.mockReset();
|
|
processMocks.runCommandWithTimeout.mockClear();
|
|
runtime.log.mockClear();
|
|
runtime.error.mockClear();
|
|
runtime.exit.mockClear();
|
|
});
|
|
|
|
it("purges deleted agent entries from the session store", async () => {
|
|
await withStateDirEnv("openclaw-agents-delete-", async ({ stateDir }) => {
|
|
const now = Date.now();
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
list: [
|
|
{ id: "main", workspace: path.join(stateDir, "workspace-main") },
|
|
{ id: "ops", workspace: path.join(stateDir, "workspace-ops") },
|
|
],
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
const storePath = await arrangeAgentsDeleteTest({
|
|
stateDir,
|
|
cfg,
|
|
sessions: {
|
|
"agent:ops:main": { sessionId: "sess-ops-main", updatedAt: now + 1 },
|
|
"agent:ops:quietchat:direct:u1": { sessionId: "sess-ops-direct", updatedAt: now + 2 },
|
|
"agent:main:main": { sessionId: "sess-main", updatedAt: now + 3 },
|
|
},
|
|
});
|
|
|
|
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
|
|
|
expect(runtime.exit).not.toHaveBeenCalled();
|
|
expect(configMocks.replaceConfigFile).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
nextConfig: {
|
|
agents: { list: [{ id: "main", workspace: path.join(stateDir, "workspace-main") }] },
|
|
},
|
|
}),
|
|
);
|
|
expectSessionStore(storePath, {
|
|
"agent:main:main": { sessionId: "sess-main", updatedAt: now + 3 },
|
|
});
|
|
});
|
|
});
|
|
|
|
it("purges legacy main-alias entries owned by the deleted default agent", async () => {
|
|
await withStateDirEnv("openclaw-agents-delete-main-alias-", async ({ stateDir }) => {
|
|
const now = Date.now();
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
list: [{ id: "ops", default: true, workspace: path.join(stateDir, "workspace-ops") }],
|
|
},
|
|
};
|
|
const storePath = await arrangeAgentsDeleteTest({
|
|
stateDir,
|
|
cfg,
|
|
sessions: {
|
|
"agent:main:main": { sessionId: "sess-default-alias", updatedAt: now + 1 },
|
|
"agent:ops:quietchat:direct:u1": { sessionId: "sess-ops-direct", updatedAt: now + 2 },
|
|
"agent:main:quietchat:direct:u2": {
|
|
sessionId: "sess-stale-main",
|
|
updatedAt: now + 3,
|
|
},
|
|
global: { sessionId: "sess-global", updatedAt: now + 4 },
|
|
},
|
|
});
|
|
|
|
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
|
|
|
expect(runtime.exit).not.toHaveBeenCalled();
|
|
expectSessionStore(storePath, {
|
|
"agent:main:quietchat:direct:u2": {
|
|
sessionId: "sess-stale-main",
|
|
updatedAt: now + 3,
|
|
},
|
|
global: { sessionId: "sess-global", updatedAt: now + 4 },
|
|
});
|
|
});
|
|
});
|
|
|
|
it("preserves shared-store legacy default keys when deleting another agent", async () => {
|
|
await withStateDirEnv("openclaw-agents-delete-shared-store-", async ({ stateDir }) => {
|
|
const now = Date.now();
|
|
const cfg: OpenClawConfig = {
|
|
session: { store: path.join(stateDir, "sessions.json") },
|
|
agents: {
|
|
list: [
|
|
{ id: "main", default: true, workspace: path.join(stateDir, "workspace-main") },
|
|
{ id: "ops", workspace: path.join(stateDir, "workspace-ops") },
|
|
],
|
|
},
|
|
};
|
|
const storePath = await arrangeAgentsDeleteTest({
|
|
stateDir,
|
|
cfg,
|
|
sessions: {
|
|
main: { sessionId: "sess-main", updatedAt: now + 1 },
|
|
"quietchat:direct:u1": { sessionId: "sess-main-direct", updatedAt: now + 2 },
|
|
"agent:ops:main": { sessionId: "sess-ops-main", updatedAt: now + 3 },
|
|
"agent:ops:quietchat:direct:u2": { sessionId: "sess-ops-direct", updatedAt: now + 4 },
|
|
},
|
|
});
|
|
|
|
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
|
|
|
expect(runtime.exit).not.toHaveBeenCalled();
|
|
expectSessionStore(storePath, {
|
|
main: { sessionId: "sess-main", updatedAt: now + 1 },
|
|
"quietchat:direct:u1": { sessionId: "sess-main-direct", updatedAt: now + 2 },
|
|
});
|
|
});
|
|
});
|
|
|
|
it("skips workspace removal when another agent shares the same workspace (#70890)", async () => {
|
|
await withStateDirEnv("openclaw-agents-delete-shared-workspace-", async ({ stateDir }) => {
|
|
const sharedWorkspace = path.join(stateDir, "workspace-shared");
|
|
await fs.mkdir(sharedWorkspace, { recursive: true });
|
|
|
|
const now = Date.now();
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
list: [
|
|
{ id: "main", workspace: sharedWorkspace },
|
|
{ id: "ops", workspace: sharedWorkspace },
|
|
],
|
|
},
|
|
} 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 },
|
|
},
|
|
});
|
|
|
|
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
|
|
|
// Workspace should still exist — it was shared
|
|
const stat = await fs.stat(sharedWorkspace).catch(() => null);
|
|
expect(stat).not.toBeNull();
|
|
|
|
// The JSON output should report why the workspace was retained.
|
|
const jsonOutput = readJsonLogs();
|
|
expect(jsonOutput).toHaveLength(1);
|
|
expect(jsonOutput[0]).toMatchObject({
|
|
workspaceRetained: true,
|
|
workspaceRetainedReason: "shared",
|
|
workspaceSharedWith: ["main"],
|
|
});
|
|
expect(processMocks.runCommandWithTimeout).not.toHaveBeenCalledWith(
|
|
["trash", sharedWorkspace],
|
|
{ timeoutMs: 5000 },
|
|
);
|
|
});
|
|
});
|
|
|
|
it("skips workspace removal when another agent workspace overlaps a child path (#70890)", async () => {
|
|
await withStateDirEnv("openclaw-agents-delete-overlapping-workspace-", async ({ stateDir }) => {
|
|
const sharedWorkspace = path.join(stateDir, "workspace-shared");
|
|
const childWorkspace = path.join(sharedWorkspace, "ops-child");
|
|
await fs.mkdir(childWorkspace, { recursive: true });
|
|
|
|
const now = Date.now();
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
list: [
|
|
{ id: "main", workspace: sharedWorkspace },
|
|
{ id: "ops", workspace: childWorkspace },
|
|
],
|
|
},
|
|
} 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 },
|
|
},
|
|
});
|
|
|
|
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
|
|
|
expect(readJsonLogs()[0]).toMatchObject({
|
|
workspaceRetained: true,
|
|
workspaceSharedWith: ["main"],
|
|
});
|
|
expect(processMocks.runCommandWithTimeout).not.toHaveBeenCalledWith(
|
|
["trash", childWorkspace],
|
|
{ timeoutMs: 5000 },
|
|
);
|
|
});
|
|
});
|
|
|
|
it("skips workspace removal when deleting a parent workspace that contains another agent workspace (#70890)", async () => {
|
|
await withStateDirEnv("openclaw-agents-delete-parent-workspace-", async ({ stateDir }) => {
|
|
const sharedWorkspace = path.join(stateDir, "workspace-shared");
|
|
const childWorkspace = path.join(sharedWorkspace, "main-child");
|
|
await fs.mkdir(childWorkspace, { recursive: true });
|
|
|
|
const now = Date.now();
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
list: [
|
|
{ id: "main", workspace: childWorkspace },
|
|
{ id: "ops", workspace: sharedWorkspace },
|
|
],
|
|
},
|
|
} 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 },
|
|
},
|
|
});
|
|
|
|
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
|
|
|
expect(readJsonLogs()[0]).toMatchObject({
|
|
workspaceRetained: true,
|
|
workspaceSharedWith: ["main"],
|
|
});
|
|
expect(processMocks.runCommandWithTimeout).not.toHaveBeenCalledWith(
|
|
["trash", sharedWorkspace],
|
|
{ timeoutMs: 5000 },
|
|
);
|
|
});
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"skips workspace removal when another agent reaches the same directory through a symlink (#70890)",
|
|
async () => {
|
|
await withStateDirEnv("openclaw-agents-delete-symlink-workspace-", async ({ stateDir }) => {
|
|
const realWorkspace = path.join(stateDir, "workspace-real");
|
|
const aliasWorkspace = path.join(stateDir, "workspace-alias");
|
|
await fs.mkdir(realWorkspace, { recursive: true });
|
|
await fs.symlink(realWorkspace, aliasWorkspace, "dir");
|
|
|
|
const now = Date.now();
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
list: [
|
|
{ id: "main", workspace: realWorkspace },
|
|
{ id: "ops", workspace: aliasWorkspace },
|
|
],
|
|
},
|
|
} 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 },
|
|
},
|
|
});
|
|
|
|
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
|
|
|
expect(readJsonLogs()[0]).toMatchObject({
|
|
workspaceRetained: true,
|
|
workspaceSharedWith: ["main"],
|
|
});
|
|
expect(processMocks.runCommandWithTimeout).not.toHaveBeenCalledWith(
|
|
["trash", aliasWorkspace],
|
|
{ timeoutMs: 5000 },
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
it("trashes workspace when no other agent shares it", async () => {
|
|
await withStateDirEnv("openclaw-agents-delete-unique-workspace-", async ({ stateDir }) => {
|
|
const opsWorkspace = path.join(stateDir, "workspace-ops");
|
|
const mainWorkspace = path.join(stateDir, "workspace-main");
|
|
await fs.mkdir(opsWorkspace, { recursive: true });
|
|
await fs.mkdir(mainWorkspace, { recursive: true });
|
|
|
|
const now = Date.now();
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
list: [
|
|
{ id: "main", workspace: mainWorkspace },
|
|
{ id: "ops", workspace: opsWorkspace },
|
|
],
|
|
},
|
|
} 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 },
|
|
},
|
|
});
|
|
|
|
await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime);
|
|
|
|
// trash command should have been called for the workspace
|
|
expect(processMocks.runCommandWithTimeout).toHaveBeenCalledWith(["trash", opsWorkspace], {
|
|
timeoutMs: 5000,
|
|
});
|
|
});
|
|
});
|
|
});
|