// Agent mutation tests cover create/update/delete handlers, safe workspace file // access, config preconditions, trash cleanup, and workspace-state handling. import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { AgentDeletionAuthorityRollbackError } from "../../agents/agent-lifecycle-registry.js"; import { WORKSPACE_BOOTSTRAP_FILENAMES } from "../../agents/workspace.js"; import { FsSafeError } from "../../infra/fs-safe.js"; /* ------------------------------------------------------------------ */ /* Mocks */ /* ------------------------------------------------------------------ */ const mocks = vi.hoisted(() => ({ sharedAuthStoreOwnership: { location: "legacy-main" } as { location: "legacy-main" | "state-db"; }, loadConfigReturn: {} as Record, listAgentEntries: vi.fn((_cfg?: unknown) => [] as Array>), findAgentEntryIndex: vi.fn((_list?: unknown, _agentId?: string) => -1), applyAgentConfig: vi.fn((_cfg: unknown, _opts: unknown) => ({})), pruneAgentConfig: vi.fn(() => ({ config: {}, removedBindings: 0 })), writeConfigFile: vi.fn(async (_nextConfig?: unknown, _writeOptions?: unknown) => {}), omitConfigMutationResult: false, ensureAgentWorkspace: vi.fn( async (params?: { dir?: string }): Promise<{ dir: string; identityPathCreated: boolean }> => ({ dir: params?.dir ? params.dir.startsWith("/resolved/") ? params.dir : `/resolved${params.dir.startsWith("/") ? "" : "/"}${params.dir}` : "/resolved/workspace", identityPathCreated: false, }), ), isWorkspaceSetupCompleted: vi.fn(async () => false), deleteWorkspaceState: vi.fn(), prepareWorkspaceStateDeletion: vi.fn((workspaceDir: string) => ({ workspaceDir })), withAgentExecApprovalsRemoved: vi.fn( async (_agentId: string, commit: () => Promise) => await commit(), ), assertAgentDeletionCurrent: vi.fn(), beginAgentDeletionRollback: vi.fn(), beginAgentDeletionFinish: vi.fn(), runAgentDatabaseCleanup: vi.fn( async (_target: unknown, run: () => Promise) => await run(), ), claimCompletedAgentDeletion: vi.fn(() => true), readAgentDeletionJournal: vi.fn(() => undefined as Record | undefined), resolveOpenClawAgentSqlitePath: vi.fn( (params?: { path?: string }) => params?.path ?? "/agents/test-agent/openclaw-agent.sqlite", ), closeOpenClawAgentDatabaseByPath: vi.fn((_pathname?: string, _expectedAgentId?: string) => true), listOpenClawRegisteredAgentDatabases: vi.fn(() => [] as Array>), unregisterOpenClawAgentDatabase: vi.fn(), assertNoOpenClawAgentDatabaseLeases: vi.fn(), registerResolvedAgentDir: vi.fn(), resolveRegisteredAgentIdForDir: vi.fn((_pathname?: string) => undefined as string | undefined), isPathOwnedByAnotherRegisteredAgent: vi.fn( (_params: { agentId: string; pathname: string }) => false, ), normalizeAgentDirRegistryPath: vi.fn((pathname: string) => path.resolve(pathname)), unregisterResolvedAgentDir: vi.fn((_params: { agentId: string; agentDir: string }) => true), cronRemoveAgentJobsTransactional: vi.fn( async (_agentId: string, commit: () => Promise) => await commit(), ), resolveAgentDir: vi.fn((_cfg?: unknown, agentId?: string) => agentId === "main" ? "/agents/main/agent" : "/agents/test-agent", ), resolveAgentWorkspaceDir: vi.fn((_cfg?: unknown, _agentId?: string) => "/workspace/test-agent"), resolveSessionTranscriptsDirForAgent: vi.fn((_agentId?: string) => "/transcripts/test-agent"), listAgentsForGateway: vi.fn(() => ({ defaultId: "main", mainKey: "agent:main:main", scope: "global", agents: [], })), movePathToTrash: vi.fn(async (_pathname?: string) => "/trashed"), fsAccess: vi.fn(async () => {}), fsMkdir: vi.fn(async () => undefined), fsAppendFile: vi.fn(async () => {}), fsReadFile: vi.fn(async () => ""), fsStat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null), fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null), fsRealpath: vi.fn(async (p: string) => p), fsReadlink: vi.fn(async () => ""), fsRm: vi.fn(async () => undefined), fsOpen: vi.fn(async () => ({}) as unknown), rootRead: vi.fn(async (_params?: unknown) => ({ buffer: Buffer.from(""), realPath: "/workspace/test-agent/AGENTS.md", stat: { size: 0, mtimeMs: 0 }, })), rootOpen: vi.fn(async (_params?: unknown) => ({ handle: { close: vi.fn(async () => {}) }, realPath: "/workspace/test-agent/AGENTS.md", stat: { size: 0, mtimeMs: 0 }, })), rootStat: vi.fn(async (_params?: unknown) => ({ isFile: true, isSymbolicLink: false, mtimeMs: 0, nlink: 1, size: 0, })), rootWrite: vi.fn(async (_params?: unknown) => {}), migrateLegacyMainSessionKeys: vi.fn(), purgeAgentSessionStoreEntries: vi.fn(async () => false), })); const RESERVED_SYSTEM_AGENT_IDS_FOR_TEST = ["openclaw", "crestodian"] as const; // reserved ids vi.mock("../../config/config.js", async () => { const actual = await vi.importActual("../../config/config.js"); return { ...actual, getRuntimeConfig: () => mocks.loadConfigReturn, writeConfigFile: mocks.writeConfigFile, replaceConfigFile: async (params: { nextConfig: unknown }) => await mocks.writeConfigFile(params.nextConfig), readConfigFileSnapshotForWrite: async () => ({ snapshot: { sourceConfig: mocks.loadConfigReturn }, }), mutateConfigFileWithRetry: async (params: { writeOptions?: unknown; mutate: (draft: Record, context: unknown) => unknown; }) => { const draft = structuredClone(mocks.loadConfigReturn); const result = await params.mutate(draft, { snapshot: { path: "/tmp/openclaw/config.json" }, previousHash: "test-hash", attempt: 0, }); await mocks.writeConfigFile(draft, params.writeOptions); return { path: "/tmp/openclaw/config.json", previousHash: "test-hash", persistedHash: "persisted-hash", snapshot: { path: "/tmp/openclaw/config.json" }, nextConfig: draft, result: mocks.omitConfigMutationResult ? undefined : result, attempts: 1, afterWrite: { mode: "auto" }, followUp: { action: "none" }, }; }, transformConfigFileWithRetry: async (params: { transform: ( config: Record, context: unknown, ) => Promise<{ nextConfig: Record; result?: unknown }>; }) => { const transformed = await params.transform(structuredClone(mocks.loadConfigReturn), { snapshot: { path: "/tmp/openclaw/config.json" }, previousHash: "test-hash", attempt: 0, }); await mocks.writeConfigFile(transformed.nextConfig); mocks.loadConfigReturn = transformed.nextConfig; return { path: "/tmp/openclaw/config.json", previousHash: "test-hash", persistedHash: "persisted-hash", snapshot: { path: "/tmp/openclaw/config.json" }, nextConfig: transformed.nextConfig, result: transformed.result, attempts: 1, afterWrite: { mode: "auto" }, followUp: { action: "none" }, }; }, withConfigMutationExclusive: async (fn: (config: unknown) => Promise) => await fn(mocks.loadConfigReturn), }; }); vi.mock("../../commands/agents.config.js", () => ({ applyAgentConfig: mocks.applyAgentConfig, findAgentEntryIndex: mocks.findAgentEntryIndex, listAgentEntries: mocks.listAgentEntries, pruneAgentConfig: mocks.pruneAgentConfig, })); vi.mock("../../agents/auth-profiles/path-resolve.js", async () => ({ ...(await vi.importActual( "../../agents/auth-profiles/path-resolve.js", )), resolveSharedAuthStoreOwnership: () => mocks.sharedAuthStoreOwnership, resolveSharedAuthStorePath: () => "/resolved/agents/main/agent/openclaw-agent.sqlite", })); vi.mock("../../config/sessions/legacy-main-session-migration.js", () => ({ migrateLegacyMainSessionKeys: mocks.migrateLegacyMainSessionKeys, })); vi.mock("../../config/sessions.js", async (importOriginal) => ({ ...(await importOriginal()), purgeAgentSessionStoreEntries: mocks.purgeAgentSessionStoreEntries, })); vi.mock("../../agents/agent-scope.js", () => ({ listAgentIds: () => ["main"], listAgentEntries: mocks.listAgentEntries, resolveDefaultAgentId: (cfg: unknown) => { const defaults = getAgentList(cfg).filter((entry) => entry.default === true); if (defaults.length !== 1) { throw new Error("expected exactly one default agent"); } return defaults[0]!.id; }, tryResolveSoleAgentId: (cfg: unknown) => { const entries = getAgentList(cfg); return entries.length === 1 ? entries[0]?.id : undefined; }, resolveAgentDir: mocks.resolveAgentDir, resolveAgentConfig: (cfg: unknown, agentId: string) => getAgentList(cfg).find((entry) => entry.id === agentId), resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, })); vi.mock("../../agents/agent-dir-registry.js", () => ({ isPathOwnedByAnotherRegisteredAgent: mocks.isPathOwnedByAnotherRegisteredAgent, normalizeAgentDirRegistryPath: mocks.normalizeAgentDirRegistryPath, registerResolvedAgentDir: mocks.registerResolvedAgentDir, resolveRegisteredAgentIdForDir: mocks.resolveRegisteredAgentIdForDir, unregisterResolvedAgentDir: mocks.unregisterResolvedAgentDir, })); vi.mock("../../agents/agent-lifecycle-registry.js", () => ({ AgentDeletionAuthorityRollbackError: class extends AggregateError {}, AgentDeletionCommitUncertainError: class extends Error { constructor(cause: unknown) { super(cause instanceof Error ? cause.message : String(cause)); } }, withAgentDeletion: async ( _agentId: string, run: (begin: (entry: Record) => unknown) => Promise, ) => run((entry) => ({ entry: Object.assign(entry, { databasePaths: entry.databasePaths ?? [], cleanupPaths: entry.cleanupPaths ?? [], }), assertCurrent: mocks.assertAgentDeletionCurrent, fenceDatabasePaths: (paths: string[]) => { entry.databasePaths = [...new Set(paths)]; }, fenceCleanupPaths: (paths: unknown[]) => { entry.cleanupPaths = [...paths]; }, finish: mocks.beginAgentDeletionFinish, rollback: mocks.beginAgentDeletionRollback, runDatabaseCleanup: mocks.runAgentDatabaseCleanup, })), claimCompletedAgentDeletion: mocks.claimCompletedAgentDeletion, isAgentDeletionBlocked: () => false, })); vi.mock("../../infra/exec-approvals.js", () => ({ withAgentExecApprovalsRemoved: mocks.withAgentExecApprovalsRemoved, })); vi.mock("../../state/openclaw-agent-db.js", () => ({ closeOpenClawAgentDatabaseByPath: mocks.closeOpenClawAgentDatabaseByPath, closeOpenClawAgentDatabaseByPathAsync: async (pathname?: string, expectedAgentId?: string) => mocks.closeOpenClawAgentDatabaseByPath(pathname, expectedAgentId), listOpenClawRegisteredAgentDatabases: mocks.listOpenClawRegisteredAgentDatabases, resolveOpenClawAgentSqlitePath: mocks.resolveOpenClawAgentSqlitePath, resolveIncognitoOpenClawAgentSqlitePath: () => "/agents/test-agent/incognito-openclaw-agent.sqlite", })); vi.mock("../../state/agent-deletion-journal.js", () => ({ readAgentDeletionJournal: mocks.readAgentDeletionJournal, })); vi.mock("../../state/openclaw-agent-db-registry.js", () => ({ isSameOpenClawAgentDatabasePath: (left: string, right: string) => path.resolve(left) === path.resolve(right), unregisterOpenClawAgentDatabase: mocks.unregisterOpenClawAgentDatabase, })); vi.mock("../../state/openclaw-agent-db-lease.js", () => ({ assertNoOpenClawAgentDatabaseLeases: mocks.assertNoOpenClawAgentDatabaseLeases, })); vi.mock("../../agents/workspace.js", async () => { const actual = await vi.importActual( "../../agents/workspace.js", ); return { ...actual, ensureAgentWorkspace: mocks.ensureAgentWorkspace, isWorkspaceSetupCompleted: mocks.isWorkspaceSetupCompleted, }; }); vi.mock("../../agents/workspace-state-store.js", async () => ({ ...(await vi.importActual( "../../agents/workspace-state-store.js", )), deleteWorkspaceState: mocks.deleteWorkspaceState, prepareWorkspaceStateDeletion: mocks.prepareWorkspaceStateDeletion, })); vi.mock("../../config/sessions/paths.js", () => ({ resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent, })); vi.mock("../../plugin-sdk/browser-maintenance.js", () => ({ movePathToTrash: mocks.movePathToTrash, })); function expectTrashedWithinParent(pathname: string, declaredPath = pathname): void { expect(mocks.movePathToTrash).toHaveBeenCalledWith( pathname, expect.objectContaining({ allowedRoots: expect.arrayContaining([path.dirname(declaredPath)]), }), ); } vi.mock("../../utils.js", async () => { const actual = await vi.importActual("../../utils.js"); return { ...actual, resolveUserPath: (p: string) => `/resolved${p.startsWith("/") ? "" : "/"}${p}`, }; }); vi.mock("../session-utils.js", () => ({ listAgentsForGateway: mocks.listAgentsForGateway, })); vi.mock("../../infra/fs-safe.js", async () => { const actual = await vi.importActual("../../infra/fs-safe.js"); return { ...actual, root: vi.fn(async (rootDir: string) => ({ rootReal: rootDir, open: async (relativePath: string, options?: Record) => await mocks.rootOpen({ rootDir, relativePath, ...options }), stat: async (relativePath: string) => await mocks.rootStat({ rootDir, relativePath }), read: async (relativePath: string, options?: Record) => await mocks.rootRead({ rootDir, relativePath, ...options }), write: async ( relativePath: string, data: string | Buffer, options?: Record, ) => await mocks.rootWrite({ rootDir, relativePath, data, ...options, }), })), }; }); // Mock node:fs/promises – agents.ts uses `import fs from "node:fs/promises"` // which resolves to the module namespace default, so we spread actual and // override the methods we need, plus set `default` explicitly. vi.mock("node:fs/promises", async () => { const actual = await vi.importActual("node:fs/promises"); const patched = { ...actual, access: mocks.fsAccess, mkdir: mocks.fsMkdir, appendFile: mocks.fsAppendFile, readFile: mocks.fsReadFile, stat: mocks.fsStat, lstat: mocks.fsLstat, realpath: mocks.fsRealpath, readlink: mocks.fsReadlink, rm: mocks.fsRm, open: mocks.fsOpen, }; return { ...patched, default: patched }; }); /* ------------------------------------------------------------------ */ /* Import after mocks are set up */ /* ------------------------------------------------------------------ */ const { testing: agentsTesting, agentsHandlers } = await import("./agents.js"); /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ beforeEach(() => { agentsTesting.resetDepsForTests(); mocks.omitConfigMutationResult = false; mocks.sharedAuthStoreOwnership = { location: "legacy-main" }; mocks.migrateLegacyMainSessionKeys.mockReset().mockResolvedValue({ armed: true, changes: [], complete: true, ledgerComplete: true, legacyAgentId: "main", mainKey: "main", outcomes: [{ kind: "no-legacy-rows", detail: "matching completed ledger" }], ownerAgentId: "robby", warnings: [], }); mocks.withAgentExecApprovalsRemoved .mockReset() .mockImplementation(async (_agentId: string, commit: () => Promise) => await commit()); mocks.assertAgentDeletionCurrent.mockReset(); mocks.beginAgentDeletionRollback.mockReset(); mocks.beginAgentDeletionFinish.mockReset(); mocks.readAgentDeletionJournal.mockReset().mockReturnValue(undefined); mocks.resolveOpenClawAgentSqlitePath .mockReset() .mockImplementation( (params?: { path?: string }) => params?.path ?? "/agents/test-agent/openclaw-agent.sqlite", ); mocks.closeOpenClawAgentDatabaseByPath.mockReset().mockReturnValue(true); mocks.listOpenClawRegisteredAgentDatabases.mockReset().mockReturnValue([]); mocks.unregisterOpenClawAgentDatabase.mockReset(); mocks.registerResolvedAgentDir.mockReset(); mocks.resolveRegisteredAgentIdForDir .mockReset() .mockImplementation((pathname?: string) => pathname === "/agents/test-agent" || pathname === "/journal/agent" ? "test-agent" : undefined, ); mocks.isPathOwnedByAnotherRegisteredAgent.mockReset().mockReturnValue(false); mocks.normalizeAgentDirRegistryPath.mockReset().mockImplementation((pathname) => pathname); mocks.unregisterResolvedAgentDir.mockReset().mockReturnValue(true); mocks.cronRemoveAgentJobsTransactional .mockReset() .mockImplementation(async (_agentId: string, commit: () => Promise) => await commit()); mocks.loadConfigReturn = {}; mocks.listAgentEntries.mockImplementation((cfg: unknown) => getAgentList(cfg)); mocks.findAgentEntryIndex.mockImplementation((list: unknown, agentId?: string) => (Array.isArray(list) ? (list as MockAgentEntry[]) : []).findIndex( (entry) => entry.id === agentId, ), ); mocks.applyAgentConfig.mockImplementation((cfg: unknown, opts: unknown) => mergeAgentConfig(cfg, opts), ); mocks.resolveAgentWorkspaceDir.mockImplementation((cfg: unknown, agentId?: string) => resolveMockWorkspaceDir(cfg, agentId), ); mocks.rootOpen.mockResolvedValue({ handle: { close: vi.fn(async () => {}) }, realPath: "/workspace/test-agent/AGENTS.md", stat: { size: 0, mtimeMs: 0 }, }); mocks.rootRead.mockResolvedValue({ buffer: Buffer.from(""), realPath: "/workspace/test-agent/AGENTS.md", stat: { size: 0, mtimeMs: 0 }, }); mocks.rootStat.mockImplementation(async (params?: unknown) => { const { rootDir, relativePath } = params as { rootDir: string; relativePath: string }; const stat = await mocks.fsLstat(path.join(rootDir, relativePath)); return { dev: stat?.dev, ino: stat?.ino, isFile: stat?.isFile?.() ?? true, isSymbolicLink: stat?.isSymbolicLink?.() ?? false, mtimeMs: stat?.mtimeMs ?? 0, nlink: stat?.nlink ?? 1, size: stat?.size ?? 0, }; }); mocks.rootWrite.mockResolvedValue(undefined); }); function makeRootForTest(overrides?: { open?: (params: Record) => Promise; read?: (params: Record) => Promise; stat?: (params: Record) => Promise; write?: (params: Record) => Promise; }) { return async (rootDir: string) => ({ rootReal: rootDir, open: async (relativePath: string, options?: Record) => await (overrides?.open ?? mocks.rootOpen)({ rootDir, relativePath, ...options }), stat: async (relativePath: string) => await (overrides?.stat ?? mocks.rootStat)({ rootDir, relativePath }), read: async (relativePath: string, options?: Record) => await (overrides?.read ?? mocks.rootRead)({ rootDir, relativePath, ...options }), write: async ( relativePath: string, data: string | Buffer, options?: Record, ) => await (overrides?.write ?? mocks.rootWrite)({ rootDir, relativePath, data, ...options, }), }) as never; } function makeCall(method: keyof typeof agentsHandlers, params: Record) { const respond = vi.fn(); const handler = expectDefined(agentsHandlers[method], "agentsHandlers[method] test invariant"); const promise = handler({ params, respond, context: { getRuntimeConfig: () => mocks.loadConfigReturn, cron: { removeAgentJobsTransactional: mocks.cronRemoveAgentJobsTransactional }, } as never, req: { type: "req" as const, id: "1", method }, client: null, isWebchatConnect: () => false, }); return { respond, promise }; } function expectRecordFields(record: unknown, expected: Record) { if (!record || typeof record !== "object") { throw new Error("Expected record"); } const actual = record as Record; for (const [key, value] of Object.entries(expected)) { expect(actual[key]).toEqual(value); } return actual; } function mockCallArg(mock: ReturnType, callIndex = 0, argIndex = 0) { const call = mock.mock.calls[callIndex]; if (!call) { throw new Error(`Expected mock call ${callIndex}`); } return call[argIndex]; } function expectRespondOk(respond: ReturnType, expected: Record) { expect(mockCallArg(respond)).toBe(true); const payload = expectRecordFields(mockCallArg(respond, 0, 1), expected); expect(mockCallArg(respond, 0, 2)).toBeUndefined(); return payload; } function expectRespondErrorContaining(respond: ReturnType, text: string) { expect(mockCallArg(respond)).toBe(false); expect(mockCallArg(respond, 0, 1)).toBeUndefined(); const error = expectRecordFields(mockCallArg(respond, 0, 2), {}); expectStringContaining(error.message, text); return error; } function firstRespondResult(respond: ReturnType): unknown { return mockCallArg(respond, 0, 1); } function expectStringContaining(value: unknown, text: string) { expect(typeof value).toBe("string"); expect(value as string).toContain(text); } function expectStringNotContaining(value: unknown, text: string) { expect(typeof value).toBe("string"); expect(value as string).not.toContain(text); } function createEnoentError() { const err = new Error("ENOENT") as NodeJS.ErrnoException; err.code = "ENOENT"; return err; } function createErrnoError(code: string) { const err = new Error(code) as NodeJS.ErrnoException; err.code = code; return err; } function makeFileStat(params?: { size?: number; mtimeMs?: number; dev?: number; ino?: number; nlink?: number; }): import("node:fs").Stats { return { isFile: () => true, isSymbolicLink: () => false, size: params?.size ?? 10, mtimeMs: params?.mtimeMs ?? 1234, dev: params?.dev ?? 1, ino: params?.ino ?? 1, nlink: params?.nlink ?? 1, } as unknown as import("node:fs").Stats; } type MockIdentity = { name?: string; theme?: string; emoji?: string; avatar?: string; }; type MockAgentEntry = { id: string; default?: boolean; name?: string; workspace?: string; agentDir?: string; model?: string; identity?: MockIdentity; }; type MockConfig = { agents?: { list?: MockAgentEntry[]; }; }; function getAgentList(cfg: unknown): MockAgentEntry[] { return ((cfg as MockConfig | undefined)?.agents?.list ?? []).map((entry) => Object.assign({}, entry), ); } function mergeAgentConfig(cfg: unknown, opts: unknown): MockConfig { const config = (cfg as MockConfig | undefined) ?? {}; const params = (opts as { agentId?: string; name?: string; workspace?: string; agentDir?: string; model?: string | null; identity?: MockIdentity; }) ?? { agentId: "" }; const list = getAgentList(config); const agentId = params.agentId ?? ""; const index = list.findIndex((entry) => entry.id === agentId); const base = index >= 0 ? expectDefined(list[index], "existing agent entry") : { id: agentId }; const nextEntry: MockAgentEntry = { ...base, ...(params.name ? { name: params.name } : {}), ...(params.workspace ? { workspace: params.workspace } : {}), ...(params.agentDir ? { agentDir: params.agentDir } : {}), ...(params.model ? { model: params.model } : {}), ...(params.identity ? { identity: { ...base.identity, ...params.identity } } : {}), }; if (params.model === null) { delete nextEntry.model; } if (index >= 0) { list[index] = nextEntry; } else { list.push(nextEntry); } return { ...config, agents: { ...config.agents, list, }, }; } function resolveMockWorkspaceDir(cfg: unknown, agentId?: string): string { const resolvedAgentId = agentId ?? ""; return ( getAgentList(cfg).find((entry) => entry.id === resolvedAgentId)?.workspace ?? `/workspace/${resolvedAgentId}` ); } function mockWorkspaceStateRead(params: { setupCompletedAt?: string; errorCode?: string; rawContent?: string; }) { agentsTesting.setDepsForTests({ isWorkspaceSetupCompleted: async () => { if (params.errorCode) { throw createErrnoError(params.errorCode); } if (typeof params.rawContent === "string") { throw new SyntaxError("Expected property name or '}' in JSON"); } return ( typeof params.setupCompletedAt === "string" && params.setupCompletedAt.trim().length > 0 ); }, }); mocks.isWorkspaceSetupCompleted.mockImplementation(async () => { if (params.errorCode) { throw createErrnoError(params.errorCode); } if (typeof params.rawContent === "string") { throw new SyntaxError("Expected property name or '}' in JSON"); } return typeof params.setupCompletedAt === "string" && params.setupCompletedAt.trim().length > 0; }); } async function listAgentFileNames(agentId = "main") { const { respond, promise } = makeCall("agents.files.list", { agentId }); await promise; const result = firstRespondResult(respond); const files = (result as { files: Array<{ name: string }> }).files; return files.map((file) => file.name); } function expectNotFoundResponseAndNoWrite(respond: ReturnType) { expectRespondErrorContaining(respond, "not found"); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); } async function expectUnsafeWorkspaceFile(method: "agents.files.get" | "agents.files.set") { const params = method === "agents.files.set" ? { agentId: "main", name: "AGENTS.md", content: "x" } : { agentId: "main", name: "AGENTS.md" }; const { respond, promise } = makeCall(method, params); await promise; expectRespondErrorContaining(respond, "unsafe workspace file"); } beforeEach(() => { mocks.fsReadFile.mockImplementation(async () => { throw createEnoentError(); }); mocks.fsStat.mockImplementation(async () => { throw createEnoentError(); }); mocks.fsLstat.mockImplementation(async () => { throw createEnoentError(); }); mocks.fsRealpath.mockImplementation(async (p: string) => p); mocks.fsOpen.mockImplementation( async () => ({ stat: async () => makeFileStat(), readFile: async () => Buffer.from(""), truncate: async () => {}, writeFile: async () => {}, close: async () => {}, }) as unknown, ); }); /* ------------------------------------------------------------------ */ /* Tests */ /* ------------------------------------------------------------------ */ describe("agents.create", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = { agents: { list: [{ id: "main", default: true }] }, }; }); it("creates a new agent successfully", async () => { const { respond, promise } = makeCall("agents.create", { name: "Test Agent", workspace: "/home/user/agents/test", }); await promise; expectRespondOk(respond, { ok: true, agentId: "test-agent", name: "Test Agent", }); expect(mocks.ensureAgentWorkspace).toHaveBeenCalled(); expect(mocks.writeConfigFile).toHaveBeenCalled(); }); it("defaults an omitted workspace", async () => { const { respond, promise } = makeCall("agents.create", { name: "Test Agent" }); await promise; expect(mocks.resolveAgentWorkspaceDir).toHaveBeenCalledWith(expect.any(Object), "test-agent"); expectRespondOk(respond, { ok: true, agentId: "test-agent", workspace: "/resolved/workspace/test-agent", }); }); it("sets up the workspace before publishing agent config", async () => { const callOrder: string[] = []; mocks.ensureAgentWorkspace.mockImplementation(async () => { callOrder.push("ensureAgentWorkspace"); return { dir: "/resolved/tmp/ws", identityPathCreated: false }; }); mocks.writeConfigFile.mockImplementation(async () => { callOrder.push("writeConfigFile"); }); const { promise } = makeCall("agents.create", { name: "Order Test", workspace: "/tmp/ws", }); await promise; expect(callOrder.indexOf("ensureAgentWorkspace")).toBeLessThan( callOrder.indexOf("writeConfigFile"), ); }); it("routes main through the canonical shared-auth creation gate", async () => { mocks.loadConfigReturn = { agents: { list: [{ id: "robby" }] } }; const { respond, promise } = makeCall("agents.create", { name: "main", workspace: "/tmp/ws", }); await promise; expectRespondErrorContaining(respond, "owns the shared auth store"); expect(mocks.migrateLegacyMainSessionKeys).toHaveBeenCalledOnce(); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("creates main through the canonical service after both gates clear", async () => { mocks.loadConfigReturn = { agents: { list: [{ id: "robby" }] } }; mocks.sharedAuthStoreOwnership = { location: "state-db" }; const { respond, promise } = makeCall("agents.create", { name: "main", workspace: "/tmp/ws", }); await promise; expectRespondOk(respond, { ok: true, agentId: "main", name: "main" }); expect(mocks.writeConfigFile).toHaveBeenCalledOnce(); }); it.each(RESERVED_SYSTEM_AGENT_IDS_FOR_TEST)( "rejects creating an agent with reserved system-agent id %s", async (name) => { const { respond, promise } = makeCall("agents.create", { name, workspace: "/tmp/ws", }); await promise; expectRespondErrorContaining(respond, `"${name}" is reserved`); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }, ); it("rejects creating a duplicate agent", async () => { mocks.findAgentEntryIndex.mockReturnValue(0); const { respond, promise } = makeCall("agents.create", { name: "Existing", workspace: "/tmp/ws", }); await promise; expectRespondErrorContaining(respond, "already exists"); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("rejects invalid params (missing name)", async () => { const { respond, promise } = makeCall("agents.create", { workspace: "/tmp/ws", }); await promise; expectRespondErrorContaining(respond, "invalid"); }); it("writes identity to both config and IDENTITY.md", async () => { const { promise } = makeCall("agents.create", { name: "Plain Agent", workspace: "/tmp/ws", }); await promise; const configOptions = expectRecordFields(mockCallArg(mocks.applyAgentConfig, 0, 1), {}); expectRecordFields(configOptions.identity, { name: "Plain Agent" }); const write = expectRecordFields(mockCallArg(mocks.rootWrite), { rootDir: "/resolved/tmp/ws", relativePath: "IDENTITY.md", }); expectStringContaining(write.data, "- Name: Plain Agent"); }); it("writes emoji and avatar to both config and IDENTITY.md", async () => { const { promise } = makeCall("agents.create", { name: "Fancy Agent", workspace: "/tmp/ws", emoji: "🤖", avatar: "https://example.com/avatar.png", }); await promise; const configOptions = expectRecordFields(mockCallArg(mocks.applyAgentConfig, 0, 1), {}); expectRecordFields(configOptions.identity, { name: "Fancy Agent", emoji: "🤖", avatar: "https://example.com/avatar.png", }); const write = expectRecordFields(mockCallArg(mocks.rootWrite), { rootDir: "/resolved/tmp/ws", relativePath: "IDENTITY.md", }); expect(write.data).toBe( [ "# IDENTITY.md - Agent Identity", "", "- Name: Fancy Agent", "- Emoji: 🤖", "- Avatar: https://example.com/avatar.png", "", ].join("\n"), ); }); it("does not publish config when IDENTITY.md write fails with FsSafeError", async () => { mocks.rootWrite.mockRejectedValueOnce( new FsSafeError("path-mismatch", "path escapes workspace root"), ); const { respond, promise } = makeCall("agents.create", { name: "Unsafe Agent", workspace: "/tmp/ws", }); await promise; expectRespondErrorContaining(respond, "unsafe workspace file"); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(getAgentList(mocks.loadConfigReturn)).toEqual([{ id: "main", default: true }]); }); it("passes model to applyAgentConfig when provided", async () => { const { respond, promise } = makeCall("agents.create", { name: "Model Agent", workspace: "/tmp/ws", model: "sonnet-4.6", }); await promise; expectRespondOk(respond, { ok: true, model: "sonnet-4.6" }); expectRecordFields(mockCallArg(mocks.applyAgentConfig, 0, 1), { model: "sonnet-4.6" }); }); }); describe("agents.update", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = { agents: { list: [ { id: "test-agent", workspace: "/workspace/test-agent", identity: { name: "Current Agent", theme: "steady", emoji: "🐢", }, }, ], }, }; }); it("updates an existing agent successfully", async () => { const { respond, promise } = makeCall("agents.update", { agentId: "test-agent", name: "Updated Name", }); await promise; expect(respond).toHaveBeenCalledWith(true, { ok: true, agentId: "test-agent" }, undefined); expect(mocks.writeConfigFile).toHaveBeenCalled(); }); it("rejects updating a nonexistent agent", async () => { mocks.findAgentEntryIndex.mockReturnValue(-1); const { respond, promise } = makeCall("agents.update", { agentId: "nonexistent", }); await promise; expectNotFoundResponseAndNoWrite(respond); }); it("returns not found when a concurrent delete wins the update race", async () => { let findCallCount = 0; mocks.findAgentEntryIndex.mockImplementation(() => { findCallCount += 1; return findCallCount >= 2 ? -1 : 0; }); const { respond, promise } = makeCall("agents.update", { agentId: "test-agent", model: "gpt-5.5", }); await promise; expectNotFoundResponseAndNoWrite(respond); }); it("clears an existing model override", async () => { mocks.loadConfigReturn = { agents: { defaults: { model: { primary: "openai/gpt-5.6-luna" } }, list: [ { id: "test-agent", workspace: "/workspace/test-agent", model: "anthropic/claude-sonnet-4-6", }, ], }, }; const { respond, promise } = makeCall("agents.update", { agentId: "test-agent", model: null, }); await promise; expectRespondOk(respond, { ok: true, agentId: "test-agent" }); expectRecordFields(mockCallArg(mocks.applyAgentConfig, 0, 1), { model: null }); const persisted = expectRecordFields(mockCallArg(mocks.writeConfigFile), {}); const agents = expectRecordFields(persisted.agents, {}); const [agent] = agents.list as MockAgentEntry[]; expect(agent).not.toHaveProperty("model"); }); it("ensures workspace when workspace changes", async () => { const { promise } = makeCall("agents.update", { agentId: "test-agent", workspace: "/new/workspace", }); await promise; expect(mocks.ensureAgentWorkspace).toHaveBeenCalled(); }); it("does not ensure workspace when workspace is unchanged", async () => { const { promise } = makeCall("agents.update", { agentId: "test-agent", name: "Just a rename", }); await promise; expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); }); it("writes merged identity to IDENTITY.md when only avatar changes", async () => { const { respond, promise } = makeCall("agents.update", { agentId: "test-agent", avatar: "https://example.com/avatar.png", }); await promise; expectRespondOk(respond, { ok: true, agentId: "test-agent" }); const configOptions = expectRecordFields(mockCallArg(mocks.applyAgentConfig, 0, 1), {}); expectRecordFields(configOptions.identity, { avatar: "https://example.com/avatar.png", }); const write = expectRecordFields(mockCallArg(mocks.rootWrite), { rootDir: "/workspace/test-agent", relativePath: "IDENTITY.md", }); expect(write.data).toBe( [ "# IDENTITY.md - Agent Identity", "", "- Name: Current Agent", "- Theme: steady", "- Emoji: 🐢", "- Avatar: https://example.com/avatar.png", "", ].join("\n"), ); }); it("writes merged identity to IDENTITY.md when only emoji changes", async () => { const { respond, promise } = makeCall("agents.update", { agentId: "test-agent", emoji: "🦀", }); await promise; expectRespondOk(respond, { ok: true, agentId: "test-agent" }); const configOptions = expectRecordFields(mockCallArg(mocks.applyAgentConfig, 0, 1), {}); expectRecordFields(configOptions.identity, { emoji: "🦀" }); const write = expectRecordFields(mockCallArg(mocks.rootWrite), { rootDir: "/workspace/test-agent", relativePath: "IDENTITY.md", }); expect(write.data).toBe( [ "# IDENTITY.md - Agent Identity", "", "- Name: Current Agent", "- Theme: steady", "- Emoji: 🦀", "", ].join("\n"), ); }); it("writes combined identity fields to both config and IDENTITY.md", async () => { const { respond, promise } = makeCall("agents.update", { agentId: "test-agent", name: "New Name", emoji: "🤖", avatar: "https://example.com/new.png", }); await promise; expectRespondOk(respond, { ok: true, agentId: "test-agent" }); const configOptions = expectRecordFields(mockCallArg(mocks.applyAgentConfig, 0, 1), { name: "New Name", }); expectRecordFields(configOptions.identity, { name: "New Name", emoji: "🤖", avatar: "https://example.com/new.png", }); const write = expectRecordFields(mockCallArg(mocks.rootWrite), { rootDir: "/workspace/test-agent", relativePath: "IDENTITY.md", }); expect(write.data).toBe( [ "# IDENTITY.md - Agent Identity", "", "- Name: New Name", "- Theme: steady", "- Emoji: 🤖", "- Avatar: https://example.com/new.png", "", ].join("\n"), ); }); it("syncs existing identity into a new workspace even without identity params", async () => { mocks.ensureAgentWorkspace.mockResolvedValueOnce({ dir: "/resolved/new/workspace", identityPathCreated: true, }); agentsTesting.setDepsForTests({ root: makeRootForTest({ read: async ({ rootDir, relativePath }) => { const filePath = `${String(rootDir)}/${String(relativePath)}`; if (filePath === "/workspace/test-agent/IDENTITY.md") { return { buffer: Buffer.from( [ "# IDENTITY.md - Agent Identity", "", "- **Name:** Current Agent", "- **Creature:** Steady Turtle", "- **Vibe:** Calm and methodical", "- **Emoji:** 🐢", "", "## Role", "", "Protect the queue.", "", ].join("\n"), ), realPath: filePath, stat: makeFileStat(), }; } if (filePath === "/resolved/new/workspace/IDENTITY.md") { return { buffer: Buffer.from( [ "# IDENTITY.md - Agent Identity", "", "- **Name:** C-3PO (Clawd's Third Protocol Observer)", "- **Creature:** Flustered Protocol Droid", "", "## Role", "", "Debug agent for `--dev` mode.", "", ].join("\n"), ), realPath: filePath, stat: makeFileStat(), }; } throw createEnoentError(); }, }), }); const { respond, promise } = makeCall("agents.update", { agentId: "test-agent", workspace: "/new/workspace", }); await promise; expectRespondOk(respond, { ok: true, agentId: "test-agent" }); const write = expectRecordFields(mockCallArg(mocks.rootWrite), { rootDir: "/resolved/new/workspace", relativePath: "IDENTITY.md", }); expectStringContaining(write.data, "- **Creature:** Steady Turtle"); expectStringContaining(write.data, "## Role"); expectStringNotContaining(write.data, "Flustered Protocol Droid"); }); it("preserves an existing destination identity file when workspace changes", async () => { mocks.ensureAgentWorkspace.mockResolvedValueOnce({ dir: "/resolved/new/workspace", identityPathCreated: false, }); agentsTesting.setDepsForTests({ root: makeRootForTest({ read: async ({ rootDir, relativePath }) => { const filePath = `${String(rootDir)}/${String(relativePath)}`; if (filePath === "/workspace/test-agent/IDENTITY.md") { return { buffer: Buffer.from( [ "# IDENTITY.md - Agent Identity", "", "- **Name:** Current Agent", "- **Creature:** Old Turtle", "", "## Role", "", "Old workspace role.", "", ].join("\n"), ), realPath: filePath, stat: makeFileStat(), }; } if (filePath === "/resolved/new/workspace/IDENTITY.md") { return { buffer: Buffer.from( [ "# IDENTITY.md - Agent Identity", "", "- **Name:** Destination Agent", "- **Creature:** Destination Fox", "", "## Role", "", "Destination workspace role.", "", ].join("\n"), ), realPath: filePath, stat: makeFileStat(), }; } throw createEnoentError(); }, }), }); const { respond, promise } = makeCall("agents.update", { agentId: "test-agent", workspace: "/new/workspace", }); await promise; expectRespondOk(respond, { ok: true, agentId: "test-agent" }); const write = expectRecordFields(mockCallArg(mocks.rootWrite), { rootDir: "/resolved/new/workspace", relativePath: "IDENTITY.md", }); expectStringContaining(write.data, "- **Creature:** Destination Fox"); expectStringContaining(write.data, "Destination workspace role."); expectStringNotContaining(write.data, "Old workspace role."); }); it("does not persist config when IDENTITY.md write fails on update", async () => { mocks.rootWrite.mockRejectedValueOnce( new FsSafeError("path-mismatch", "path escapes workspace root"), ); const { respond, promise } = makeCall("agents.update", { agentId: "test-agent", name: "Bad Update", avatar: "https://example.com/avatar.png", }); await promise; expectRespondErrorContaining(respond, "unsafe workspace file"); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("treats unsafe IDENTITY.md reads as invalid update requests", async () => { agentsTesting.setDepsForTests({ root: makeRootForTest({ read: async () => { throw new FsSafeError("invalid-path", "path is not a regular file under root"); }, }), }); const { respond, promise } = makeCall("agents.update", { agentId: "test-agent", avatar: "https://example.com/unsafe.png", }); await promise; expectRespondErrorContaining(respond, 'unsafe workspace file "IDENTITY.md"'); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.rootWrite).not.toHaveBeenCalled(); }); it("uses non-blocking reads for IDENTITY.md during agents.update", async () => { const rootRead = vi.fn(async () => { throw new FsSafeError("not-found", "file not found"); }); agentsTesting.setDepsForTests({ root: makeRootForTest({ read: rootRead }) }); const { promise } = makeCall("agents.update", { agentId: "test-agent", name: "Updated NB", }); await promise; expectRecordFields(mockCallArg(rootRead), { relativePath: "IDENTITY.md", nonBlockingRead: true, }); }); }); describe("agents.delete", () => { beforeEach(() => { vi.clearAllMocks(); mocks.resolveAgentDir.mockImplementation((_cfg?: unknown, agentId?: string) => agentId === "main" ? "/agents/main/agent" : "/agents/test-agent", ); mocks.fsLstat.mockResolvedValue({ isSymbolicLink: () => false, } as unknown as import("node:fs").Stats); mocks.fsRealpath.mockImplementation(async (pathname: string) => pathname); mocks.loadConfigReturn = { agents: { list: [ { id: "test-agent", workspace: "/workspace/test-agent" }, { id: "main", default: true }, ], }, }; mocks.findAgentEntryIndex.mockReturnValue(0); mocks.pruneAgentConfig.mockReturnValue({ config: { agents: { list: [{ id: "main", default: true }] } }, removedBindings: 2, }); mocks.movePathToTrash.mockReset().mockResolvedValue("/trashed"); mocks.purgeAgentSessionStoreEntries.mockReset().mockResolvedValue(false); }); it("rejects deleting the auth-inheritance owner before starting cleanup", async () => { mocks.loadConfigReturn = { agents: { defaults: { authInheritance: { agentId: "test-agent" } }, list: [ { id: "test-agent", workspace: "/workspace/test-agent" }, { id: "main", default: true }, ], }, }; const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondErrorContaining(respond, "agents.defaults.authInheritance.agentId"); expect(mocks.cronRemoveAgentJobsTransactional).not.toHaveBeenCalled(); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); }); it("removes only the deleted agent's authority before committing its roster removal", async () => { const cronJobs = [ { id: "deleted-job", agentId: "test-agent" }, { id: "other-job", agentId: "other-agent" }, ]; const approvals = new Set(["test-agent", "other-agent"]); const events: string[] = []; mocks.cronRemoveAgentJobsTransactional.mockImplementation( async (agentId: string, commit: () => Promise) => { const snapshot = structuredClone(cronJobs); cronJobs.splice(0, cronJobs.length, ...cronJobs.filter((job) => job.agentId !== agentId)); events.push("cron"); try { return await commit(); } catch (error) { cronJobs.splice(0, cronJobs.length, ...snapshot); throw error; } }, ); mocks.withAgentExecApprovalsRemoved.mockImplementation( async (agentId: string, commit: () => Promise) => { const existed = approvals.delete(agentId); events.push("approvals"); try { return await commit(); } catch (error) { if (existed) { approvals.add(agentId); } throw error; } }, ); mocks.writeConfigFile.mockImplementationOnce(async () => { events.push("config"); }); mocks.unregisterResolvedAgentDir.mockImplementationOnce(() => { events.push("directory"); return true; }); mocks.closeOpenClawAgentDatabaseByPath.mockImplementation(() => { events.push("database"); return true; }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(cronJobs).toEqual([{ id: "other-job", agentId: "other-agent" }]); expect(approvals).toEqual(new Set(["other-agent"])); expect(mocks.cronRemoveAgentJobsTransactional).toHaveBeenCalledWith( "test-agent", expect.any(Function), ); expect(mocks.withAgentExecApprovalsRemoved).toHaveBeenCalledWith( "test-agent", expect.any(Function), ); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/agents/test-agent/openclaw-agent.sqlite", "test-agent", ); expect(mocks.unregisterOpenClawAgentDatabase).toHaveBeenCalledWith({ agentId: "test-agent", path: "/agents/test-agent/openclaw-agent.sqlite", }); expect(mocks.unregisterResolvedAgentDir).toHaveBeenCalledWith({ agentId: "test-agent", agentDir: "/agents/test-agent", }); expect(events).toEqual(["database", "database", "cron", "approvals", "config", "directory"]); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("rolls cron back and keeps the roster when authority cleanup fails", async () => { const cronJobs = [ { id: "deleted-job", agentId: "test-agent" }, { id: "other-job", agentId: "other-agent" }, ]; mocks.cronRemoveAgentJobsTransactional.mockImplementation( async (agentId: string, commit: () => Promise) => { const snapshot = structuredClone(cronJobs); cronJobs.splice(0, cronJobs.length, ...cronJobs.filter((job) => job.agentId !== agentId)); try { return await commit(); } catch (error) { cronJobs.splice(0, cronJobs.length, ...snapshot); throw error; } }, ); mocks.withAgentExecApprovalsRemoved.mockRejectedValueOnce(new Error("approvals busy")); const { promise } = makeCall("agents.delete", { agentId: "test-agent" }); await expect(promise).rejects.toThrow("approvals busy"); expect(cronJobs).toEqual([ { id: "deleted-job", agentId: "test-agent" }, { id: "other-job", agentId: "other-agent" }, ]); expect(mocks.beginAgentDeletionRollback).toHaveBeenCalledOnce(); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledTimes(2); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); }); it("keeps a recovered deletion journal fenced when retry cleanup fails", async () => { mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", agentDir: "/journal/agent", workspaceDir: "/journal/workspace", sessionsDir: "/journal/sessions", createdAt: 1, }); mocks.withAgentExecApprovalsRemoved.mockRejectedValueOnce(new Error("approvals busy")); const { promise } = makeCall("agents.delete", { agentId: "test-agent" }); await expect(promise).rejects.toThrow("approvals busy"); expect(mocks.beginAgentDeletionRollback).not.toHaveBeenCalled(); expect(mocks.beginAgentDeletionFinish).not.toHaveBeenCalled(); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("keeps a new deletion fenced when authority rollback fails", async () => { mocks.withAgentExecApprovalsRemoved.mockRejectedValueOnce( new AgentDeletionAuthorityRollbackError( [new Error("config failed"), new Error("approval restore failed")], "approval rollback failed", ), ); const { promise } = makeCall("agents.delete", { agentId: "test-agent" }); await expect(promise).rejects.toThrow("approval rollback failed"); expect(mocks.beginAgentDeletionRollback).not.toHaveBeenCalled(); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("keeps deletion fenced when config persistence succeeds before reporting failure", async () => { let configuredCheck = 0; mocks.findAgentEntryIndex.mockImplementation(() => { configuredCheck += 1; return configuredCheck < 4 ? 0 : -1; }); mocks.writeConfigFile.mockImplementationOnce(async (nextConfig?: unknown) => { if (!nextConfig || typeof nextConfig !== "object") { throw new Error("expected config object"); } mocks.loadConfigReturn = nextConfig as Record; throw new Error("post-write refresh failed"); }); const { promise } = makeCall("agents.delete", { agentId: "test-agent" }); await expect(promise).rejects.toThrow("post-write refresh failed"); expect(mocks.beginAgentDeletionRollback).not.toHaveBeenCalled(); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalled(); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); }); it("does not perform destructive cleanup without a config deletion result", async () => { mocks.omitConfigMutationResult = true; const { promise } = makeCall("agents.delete", { agentId: "test-agent" }); await expect(promise).rejects.toThrow("config mutation did not return its target"); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledTimes(2); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); expect(mocks.unregisterOpenClawAgentDatabase).not.toHaveBeenCalled(); expect(mocks.beginAgentDeletionRollback).toHaveBeenCalledOnce(); }); it("keeps authority removed when the committed config omits its mutation result", async () => { mocks.omitConfigMutationResult = true; let configuredCheck = 0; mocks.findAgentEntryIndex.mockImplementation(() => { configuredCheck += 1; return configuredCheck < 4 ? 0 : -1; }); const { promise } = makeCall("agents.delete", { agentId: "test-agent" }); await expect(promise).rejects.toThrow("config mutation did not return its target"); expect(mocks.beginAgentDeletionRollback).not.toHaveBeenCalled(); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); expect(mocks.unregisterOpenClawAgentDatabase).not.toHaveBeenCalled(); }); it("resumes a journaled deletion while its directory is still owned by the deleted agent", async () => { mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", agentDir: "/journal/agent", workspaceDir: "/journal/workspace", sessionsDir: "/journal/sessions", createdAt: 1, }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/journal/agent/openclaw-agent.sqlite", "test-agent", ); expect(mocks.unregisterResolvedAgentDir).toHaveBeenCalledWith({ agentId: "test-agent", agentDir: "/journal/agent", }); expectTrashedWithinParent("/journal/workspace"); expectTrashedWithinParent("/journal/agent"); expectTrashedWithinParent("/journal/sessions"); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("converges after partial cleanup and makes the agent id recreatable", async () => { const journal = { agentId: "test-agent", operationId: "delete-1", agentDir: "/agents/test-agent", workspaceDir: "/workspace/test-agent", sessionsDir: "/transcripts/test-agent", createdAt: 1, cleanupCompleted: false, deleteFiles: true, }; const trashed = new Set(); let workspaceAttempts = 0; mocks.fsLstat.mockImplementation(async (pathname: unknown) => { if (trashed.has(String(pathname))) { throw createEnoentError(); } return null as unknown as import("node:fs").Stats; }); mocks.movePathToTrash.mockImplementation(async (pathname?: string) => { if (pathname === journal.workspaceDir && workspaceAttempts++ === 0) { throw new Error("workspace trash failed"); } trashed.add(pathname ?? ""); return "/trashed"; }); mocks.beginAgentDeletionFinish.mockImplementation(() => { journal.cleanupCompleted = true; }); mocks.readAgentDeletionJournal.mockReturnValue(journal); const firstDelete = makeCall("agents.delete", { agentId: "test-agent" }); await firstDelete.promise; expectRespondOk(firstDelete.respond, { failed: [{ path: journal.workspaceDir, reason: "workspace trash failed" }], }); expect(mocks.beginAgentDeletionFinish).not.toHaveBeenCalled(); const completedAgentDir = ( journal as typeof journal & { cleanupPaths: Array<{ path: string; done: boolean }>; } ).cleanupPaths.find((entry) => entry.path === journal.agentDir); expect(completedAgentDir?.done).toBe(true); const agentDirMoveCount = mocks.movePathToTrash.mock.calls.filter( ([pathname]) => pathname === journal.agentDir, ).length; trashed.delete(journal.agentDir); mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue(journal); const blockedCreate = makeCall("agents.create", { name: "Test Agent" }); await blockedCreate.promise; expectRespondErrorContaining(blockedCreate.respond, "still pending"); const recovery = makeCall("agents.delete", { agentId: "test-agent" }); await recovery.promise; expectRespondOk(recovery.respond, { failed: [] }); expect( mocks.movePathToTrash.mock.calls.filter(([pathname]) => pathname === journal.agentDir), ).toHaveLength(agentDirMoveCount); expect(trashed.has(journal.agentDir)).toBe(false); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); expect(journal.cleanupCompleted).toBe(true); const recreated = makeCall("agents.create", { name: "Test Agent" }); await recreated.promise; expectRespondOk(recreated.respond, { ok: true, agentId: "test-agent" }); }); it("sweeps leaked files that appeared at prepared-absent journal paths", async () => { const workspaceDir = "/journal/workspace"; const workspaceAlias = "/journal/workspace-alias"; const journal = { agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir, sessionsDir: "/journal/sessions", databasePaths: [workspaceAlias], cleanupPaths: [ { path: "/journal/agent", canonicalPath: "/journal/agent", parentPath: "/journal", kind: "target" as const, sourcePaths: ["/journal/agent"], dev: 1, ino: 10, coversDescendants: true, done: true, }, { path: workspaceDir, canonicalPath: workspaceDir, parentPath: "/journal", kind: "target" as const, sourcePaths: [workspaceDir], dev: null, ino: null, coversDescendants: true, done: false, }, { path: "/journal/sessions", canonicalPath: "/journal/sessions", parentPath: "/journal", kind: "target" as const, sourcePaths: ["/journal/sessions"], dev: 1, ino: 30, coversDescendants: true, done: true, }, ], createdAt: 1, cleanupCompleted: false, deleteFiles: true, }; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue(journal); mocks.fsRealpath.mockImplementation(async (pathname: string) => pathname === workspaceAlias ? workspaceDir : pathname, ); mocks.fsLstat.mockImplementation( async (pathname: unknown) => ({ dev: 2, ino: pathname === workspaceDir ? 200 : 100, isFile: () => false, isSymbolicLink: () => false, nlink: 1, }) as unknown as import("node:fs").Stats, ); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { failed: [] }); // The journal fence blocked legitimate claims while this path was prepared // absent, so the appeared occupant is leaked deleted-agent state: sweep it // instead of preserving it and finishing over a surviving tree. expectTrashedWithinParent(workspaceDir); const appearedRecord = journal.cleanupPaths.find((entry) => entry.path === workspaceDir); expect(appearedRecord).toMatchObject({ done: true }); expect(appearedRecord).not.toHaveProperty("note"); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("protects a pending ancestor when a done descendant is recreated", async () => { const workspaceDir = "/journal/workspace"; const completedChild = `${workspaceDir}/cleaned`; const journal = { agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir, sessionsDir: "/journal/sessions", databasePaths: [], cleanupPaths: [ { path: completedChild, canonicalPath: completedChild, parentPath: workspaceDir, kind: "target" as const, sourcePaths: [workspaceDir], dev: 1, ino: 10, coversDescendants: true, done: true, }, { path: workspaceDir, canonicalPath: workspaceDir, parentPath: "/journal", kind: "target" as const, sourcePaths: [workspaceDir], dev: 1, ino: 20, coversDescendants: true, done: false, }, ], createdAt: 1, cleanupCompleted: false, deleteFiles: true, }; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue(journal); mocks.fsLstat.mockImplementation(async (pathname: unknown) => { if (pathname !== completedChild && pathname !== workspaceDir) { throw createEnoentError(); } return { dev: pathname === completedChild ? 2 : 1, ino: pathname === completedChild ? 100 : 20, isFile: () => false, isSymbolicLink: () => false, nlink: 1, } as unknown as import("node:fs").Stats; }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { failed: [] }); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith(completedChild); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith(workspaceDir); expect(journal.cleanupPaths.find((entry) => entry.path === workspaceDir)).toMatchObject({ done: true, note: "completed cleanup path is occupied; replacement preserved", }); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("extends a recovery cleanup snapshot with newly discovered database files", async () => { const relocatedDatabase = "/relocated/recovered.sqlite"; const journal = { agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir: "/journal/workspace", sessionsDir: "/journal/sessions", databasePaths: [], cleanupPaths: [ { path: "/journal/agent", canonicalPath: "/journal/agent", parentPath: "/journal", kind: "target" as const, sourcePaths: ["/journal/agent"], dev: null, ino: null, coversDescendants: true, done: false, }, { path: "/journal/workspace", canonicalPath: "/journal/workspace", parentPath: "/journal", kind: "target" as const, sourcePaths: ["/journal/workspace"], dev: null, ino: null, coversDescendants: true, done: false, }, { path: "/journal/sessions", canonicalPath: "/journal/sessions", parentPath: "/journal", kind: "target" as const, sourcePaths: ["/journal/sessions"], dev: null, ino: null, coversDescendants: true, done: false, }, ], createdAt: 1, cleanupCompleted: false, deleteFiles: true, }; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue(journal); mocks.listOpenClawRegisteredAgentDatabases.mockReturnValue([ { agentId: "test-agent", path: relocatedDatabase }, ]); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { failed: [] }); for (const databasePath of [ relocatedDatabase, `${relocatedDatabase}-wal`, `${relocatedDatabase}-shm`, ]) { expect(journal.cleanupPaths.some((entry) => entry.sourcePaths.includes(databasePath))).toBe( true, ); expectTrashedWithinParent(databasePath); } expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("treats a source that disappears during Trash as successful cleanup", async () => { let workspaceLstatCalls = 0; mocks.fsLstat.mockImplementation(async (pathname: unknown) => { if (pathname === "/workspace/test-agent" && workspaceLstatCalls++ > 1) { throw createEnoentError(); } return null as unknown as import("node:fs").Stats; }); mocks.movePathToTrash.mockImplementation(async (pathname?: string) => { if (pathname === "/workspace/test-agent") { throw createEnoentError(); } return "/trashed"; }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; const result = expectRespondOk(respond, { failed: [] }); expect(result.removed).toEqual( expect.arrayContaining([{ path: "/workspace/test-agent", method: "missing" }]), ); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("canonicalizes overlapping journal paths and cleans descendants first", async () => { mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir: "/journal", sessionsDir: "/journal/agent/sessions", createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { failed: [] }); const trashedPaths = mocks.movePathToTrash.mock.calls.map(([pathname]) => pathname); expect(trashedPaths).toHaveLength(new Set(trashedPaths).size); expect(trashedPaths.indexOf("/journal/agent/sessions")).toBeLessThan( trashedPaths.indexOf("/journal/agent"), ); expect(trashedPaths.indexOf("/journal/agent")).toBeLessThan(trashedPaths.indexOf("/journal")); expect(mocks.deleteWorkspaceState).toHaveBeenCalledWith( { workspaceDir: "/journal" }, { assertCurrent: mocks.assertAgentDeletionCurrent }, ); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("skips an overlapping workspace that covers an agent directory claimed by a survivor", async () => { mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir: "/journal", sessionsDir: "/deleted/sessions", createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); mocks.resolveRegisteredAgentIdForDir.mockImplementation((pathname?: string) => pathname === "/journal/agent" ? "other-agent" : undefined, ); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { failed: [] }); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/journal"); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/journal/agent"); expectTrashedWithinParent("/deleted/sessions"); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("does not move a symlink ancestor when its canonical descendant fails", async () => { const agentLink = "/deep/journal/link"; const agentTarget = "/target"; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: agentLink, workspaceDir: "/deep", sessionsDir: "/deep/sessions", createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); mocks.resolveRegisteredAgentIdForDir.mockImplementation((pathname?: string) => pathname === agentLink ? "test-agent" : undefined, ); mocks.normalizeAgentDirRegistryPath.mockImplementation((pathname: string) => pathname.replace(agentLink, agentTarget), ); mocks.fsRealpath.mockImplementation(async (pathname: string) => pathname.replace(agentLink, agentTarget), ); mocks.fsLstat.mockImplementation( async (pathname: unknown) => ({ isSymbolicLink: () => pathname === agentLink, }) as unknown as import("node:fs").Stats, ); mocks.movePathToTrash.mockImplementation(async (pathname?: string) => { if (pathname === agentTarget) { throw new Error("agent trash failed"); } return "/trashed"; }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { failed: [{ path: agentTarget, reason: "agent trash failed" }], }); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith(agentLink); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/deep"); expectTrashedWithinParent(agentTarget, agentLink); expect(mocks.unregisterResolvedAgentDir).not.toHaveBeenCalled(); expect(mocks.beginAgentDeletionFinish).not.toHaveBeenCalled(); }); it("does not trash a replacement at a journaled symlink path", async () => { const workspaceLink = "/journal/workspace-link"; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir: workspaceLink, sessionsDir: "/journal/sessions", cleanupPaths: [ { path: "/canonical/workspace", canonicalPath: "/canonical/workspace", parentPath: "/canonical", kind: "target", sourcePaths: [workspaceLink], dev: null, ino: null, coversDescendants: true, done: false, }, { path: workspaceLink, canonicalPath: workspaceLink, parentPath: "/journal", kind: "symlink", sourcePaths: [workspaceLink], dev: null, ino: null, coversDescendants: false, done: false, }, { path: "/journal/agent", canonicalPath: "/journal/agent", parentPath: "/journal", kind: "target", sourcePaths: ["/journal/agent"], dev: null, ino: null, coversDescendants: true, done: false, }, { path: "/journal/sessions", canonicalPath: "/journal/sessions", parentPath: "/journal", kind: "target", sourcePaths: ["/journal/sessions"], dev: null, ino: null, coversDescendants: true, done: false, }, ], createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { failed: [] }); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith(workspaceLink); const replacementRecord = expectDefined( mocks.readAgentDeletionJournal.mock.results[0]?.value?.cleanupPaths?.find( (entry: { path?: string }) => entry.path === workspaceLink, ), "replacement cleanup record", ); expect(replacementRecord).toMatchObject({ done: true, note: "cleanup path changed from symlink before deletion", }); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("resolves a symlinked workspace and removes descendants before its target and link", async () => { const workspaceParent = "/tmp-root"; const workspaceParentTarget = "/real-tmp/source-parent"; const workspaceLink = `${workspaceParent}/workspace-link`; const canonicalWorkspaceLink = `${workspaceParentTarget}/workspace-link`; const workspaceTarget = "/real-tmp/workspace"; const agentDir = `${workspaceLink}/agent`; const sessionsDir = `${workspaceLink}/transcripts`; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir, workspaceDir: workspaceLink, sessionsDir, createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); mocks.resolveRegisteredAgentIdForDir.mockImplementation((pathname?: string) => pathname === agentDir ? "test-agent" : undefined, ); mocks.normalizeAgentDirRegistryPath.mockImplementation((pathname: string) => pathname.replace(workspaceLink, workspaceTarget), ); mocks.fsRealpath.mockImplementation(async (pathname: string) => { if (pathname === workspaceParent) { return workspaceParentTarget; } return pathname.replace(workspaceLink, workspaceTarget); }); mocks.fsLstat.mockImplementation( async (pathname: unknown) => ({ isSymbolicLink: () => pathname === workspaceLink || pathname === canonicalWorkspaceLink, }) as unknown as import("node:fs").Stats, ); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { failed: [] }); const trashedPaths = mocks.movePathToTrash.mock.calls.map(([pathname]) => String(pathname)); const targetIndex = trashedPaths.indexOf(workspaceTarget); expect(trashedPaths.indexOf(`${workspaceTarget}/agent`)).toBeLessThan(targetIndex); expect(trashedPaths.indexOf(`${workspaceTarget}/transcripts`)).toBeLessThan(targetIndex); expect(targetIndex).toBeLessThan(trashedPaths.indexOf(canonicalWorkspaceLink)); expectTrashedWithinParent(workspaceTarget, workspaceLink); expectTrashedWithinParent(canonicalWorkspaceLink, workspaceLink); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith(workspaceLink); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("does not commit deletion when a journaled path cannot be resolved", async () => { const resolutionError = Object.assign(new Error("workspace target is inaccessible"), { code: "EACCES", }); mocks.fsRealpath.mockImplementation(async (pathname: string) => { if (pathname === "/workspace/test-agent") { throw resolutionError; } return pathname; }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await expect(promise).rejects.toBe(resolutionError); expect(respond).not.toHaveBeenCalled(); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); expect(mocks.beginAgentDeletionRollback).toHaveBeenCalledOnce(); expect(mocks.beginAgentDeletionFinish).not.toHaveBeenCalled(); }); it("recovers against the persisted target when a workspace symlink is retargeted", async () => { const workspaceLink = "/tmp-root/workspace-link"; const originalTarget = "/real-tmp/original-workspace"; const retargetedWorkspace = "/real-tmp/replacement-workspace"; const missingTranscriptTarget = `${originalTarget}/transcripts`; const journal = { agentId: "test-agent", operationId: "delete-1", agentDir: `${workspaceLink}/agent`, workspaceDir: workspaceLink, sessionsDir: `${workspaceLink}/transcripts`, createdAt: 1, cleanupCompleted: false, deleteFiles: true, }; const trashed = new Set(); let workspaceAttempts = 0; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue(journal); mocks.resolveRegisteredAgentIdForDir.mockImplementation((pathname?: string) => pathname === journal.agentDir ? "test-agent" : undefined, ); mocks.normalizeAgentDirRegistryPath.mockImplementation((pathname: string) => pathname.replace(workspaceLink, originalTarget), ); mocks.fsRealpath.mockImplementation(async (pathname: string) => { if (pathname === journal.sessionsDir) { throw createEnoentError(); } return pathname.replace(workspaceLink, originalTarget); }); mocks.fsLstat.mockImplementation(async (pathname: unknown) => { if ( pathname === journal.sessionsDir || pathname === missingTranscriptTarget || trashed.has(String(pathname)) ) { throw createEnoentError(); } return { isSymbolicLink: () => pathname === workspaceLink, } as unknown as import("node:fs").Stats; }); mocks.movePathToTrash.mockImplementation(async (pathname?: string) => { if (pathname === originalTarget && workspaceAttempts++ === 0) { throw new Error("workspace trash failed"); } trashed.add(pathname ?? ""); return "/trashed"; }); const firstDelete = makeCall("agents.delete", { agentId: "test-agent" }); await firstDelete.promise; expectRespondOk(firstDelete.respond, { failed: [{ path: originalTarget, reason: "workspace trash failed" }], }); expect(mocks.beginAgentDeletionFinish).not.toHaveBeenCalled(); expect(journal).toHaveProperty("cleanupPaths"); mocks.fsRealpath.mockClear(); mocks.fsRealpath.mockImplementation(async (pathname: string) => pathname.replace(workspaceLink, retargetedWorkspace), ); const recovery = makeCall("agents.delete", { agentId: "test-agent" }); await recovery.promise; expectRespondOk(recovery.respond, { failed: [] }); expect(mocks.fsRealpath).not.toHaveBeenCalled(); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith(retargetedWorkspace); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith(`${retargetedWorkspace}/transcripts`); expectTrashedWithinParent(originalTarget, workspaceLink); expectTrashedWithinParent(workspaceLink); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("does not follow a retargeted canonical ancestor during recovery", async () => { const canonicalRoot = "/canonical/deleted"; const unrelatedRoot = "/unrelated/current"; const workspaceDir = "/linked/workspace"; const journal = { agentId: "test-agent", operationId: "delete-1", agentDir: `${workspaceDir}/agent`, workspaceDir, sessionsDir: `${workspaceDir}/transcripts`, cleanupPaths: [ { path: `${canonicalRoot}/workspace/agent`, canonicalPath: `${canonicalRoot}/workspace/agent`, parentPath: `${canonicalRoot}/workspace`, kind: "target" as const, sourcePaths: [`${workspaceDir}/agent`], dev: null, ino: null, coversDescendants: true, done: false, }, { path: `${canonicalRoot}/workspace/transcripts`, canonicalPath: `${canonicalRoot}/workspace/transcripts`, parentPath: `${canonicalRoot}/workspace`, kind: "target" as const, sourcePaths: [`${workspaceDir}/transcripts`], dev: null, ino: null, coversDescendants: true, done: false, }, { path: `${canonicalRoot}/workspace`, canonicalPath: `${canonicalRoot}/workspace`, parentPath: canonicalRoot, kind: "target" as const, sourcePaths: [workspaceDir], dev: null, ino: null, coversDescendants: true, done: false, }, ], createdAt: 1, cleanupCompleted: false, deleteFiles: true, }; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue(journal); mocks.resolveRegisteredAgentIdForDir.mockImplementation((pathname?: string) => pathname === journal.agentDir ? "test-agent" : undefined, ); agentsTesting.setDepsForTests({ root: (async (rootDir: string) => ({ rootReal: rootDir.startsWith(canonicalRoot) ? rootDir.replace(canonicalRoot, unrelatedRoot) : rootDir, stat: async (relativePath: string) => await mocks.rootStat({ rootDir, relativePath }), })) as never, }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; const result = expectRespondOk(respond, {}); expect(result.failed).toEqual( expect.arrayContaining([ expect.objectContaining({ reason: "cleanup path parent changed before deletion" }), ]), ); expect( mocks.movePathToTrash.mock.calls.some(([pathname]) => String(pathname).startsWith(unrelatedRoot), ), ).toBe(false); expect(mocks.beginAgentDeletionFinish).not.toHaveBeenCalled(); }); it("reclaims durable journal ownership after a process restart", async () => { const directoryOwners = new Map(); mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir: "/journal/workspace", sessionsDir: "/journal/sessions", createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); mocks.resolveRegisteredAgentIdForDir.mockImplementation((pathname?: string) => directoryOwners.get(pathname ?? ""), ); mocks.registerResolvedAgentDir.mockImplementation( ({ agentId, agentDir }: { agentId: string; agentDir: string }) => { directoryOwners.set(agentDir, agentId); }, ); mocks.unregisterResolvedAgentDir.mockImplementation( ({ agentId, agentDir }: { agentId: string; agentDir: string }) => { if (directoryOwners.get(agentDir) !== agentId) { return false; } return directoryOwners.delete(agentDir); }, ); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.registerResolvedAgentDir).toHaveBeenCalledWith({ agentId: "test-agent", agentDir: "/journal/agent", }); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/journal/agent/openclaw-agent.sqlite", "test-agent", ); expectTrashedWithinParent("/journal/agent"); expect(directoryOwners.has("/journal/agent")).toBe(false); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("settles recovery without touching a journaled directory claimed by another agent", async () => { const directoryOwners = new Map([["/journal/agent", "other-agent"]]); const databaseRows = [ { agentId: "test-agent", path: "/journal/agent/openclaw-agent.sqlite" }, { agentId: "other-agent", path: "/journal/agent/openclaw-agent.sqlite" }, ]; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir: "/journal/workspace", sessionsDir: "/journal/sessions", createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); mocks.resolveRegisteredAgentIdForDir.mockImplementation((pathname?: string) => directoryOwners.get(pathname ?? ""), ); mocks.isPathOwnedByAnotherRegisteredAgent.mockImplementation( ({ agentId, pathname }: { agentId: string; pathname: string }) => (pathname === "/journal/agent" || pathname.startsWith("/journal/agent/")) && directoryOwners.get("/journal/agent") !== agentId, ); mocks.unregisterResolvedAgentDir.mockImplementation( ({ agentId, agentDir }: { agentId: string; agentDir: string }) => { if (directoryOwners.get(agentDir) !== agentId) { return false; } return directoryOwners.delete(agentDir); }, ); mocks.listOpenClawRegisteredAgentDatabases.mockImplementation(() => databaseRows); mocks.unregisterOpenClawAgentDatabase.mockImplementation( ({ agentId, path: databasePath }: { agentId: string; path: string }) => { const index = databaseRows.findIndex( (entry) => entry.agentId === agentId && entry.path === databasePath, ); if (index >= 0) { databaseRows.splice(index, 1); } }, ); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.listOpenClawRegisteredAgentDatabases).toHaveBeenCalled(); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/journal/agent/openclaw-agent.sqlite", "test-agent", ); expect(mocks.assertNoOpenClawAgentDatabaseLeases).toHaveBeenCalledWith("test-agent", {}); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/journal/agent"); expect(mocks.unregisterOpenClawAgentDatabase).toHaveBeenCalledWith({ agentId: "test-agent", path: "/journal/agent/openclaw-agent.sqlite", }); expect(databaseRows).toEqual([ { agentId: "other-agent", path: "/journal/agent/openclaw-agent.sqlite" }, ]); expect(mocks.unregisterResolvedAgentDir).toHaveBeenCalledWith({ agentId: "test-agent", agentDir: "/journal/agent", }); expect(directoryOwners.get("/journal/agent")).toBe("other-agent"); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("revalidates database ownership after earlier filesystem cleanup", async () => { let claimed = false; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir: "/journal/workspace", sessionsDir: "/journal/sessions", createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); mocks.listOpenClawRegisteredAgentDatabases.mockImplementation(() => claimed ? [{ agentId: "other-agent", path: "/journal/agent/survivor.sqlite" }] : [], ); mocks.movePathToTrash.mockImplementation(async (pathname?: string) => { if (pathname === "/journal/agent/openclaw-agent.sqlite") { claimed = true; } return "/trashed"; }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expectTrashedWithinParent("/journal/agent/openclaw-agent.sqlite"); expectTrashedWithinParent("/journal/workspace"); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/journal/agent"); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/journal/agent/survivor.sqlite"); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("cleans a relocated deleted-agent database while preserving a claimed agent directory", async () => { mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir: "/journal/workspace", sessionsDir: "/journal/sessions", createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); mocks.resolveRegisteredAgentIdForDir.mockImplementation((pathname?: string) => pathname === "/journal/agent" ? "other-agent" : undefined, ); mocks.isPathOwnedByAnotherRegisteredAgent.mockImplementation( ({ pathname }: { agentId: string; pathname: string }) => pathname === "/journal/agent" || pathname.startsWith("/journal/agent/"), ); mocks.listOpenClawRegisteredAgentDatabases.mockReturnValue([ { agentId: "other-agent", path: "/journal/agent/openclaw-agent.sqlite" }, { agentId: "test-agent", path: "/relocated/deleted.sqlite" }, ]); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledTimes(3); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/relocated/deleted.sqlite", "test-agent", ); expect(mocks.unregisterOpenClawAgentDatabase).toHaveBeenCalledWith({ agentId: "test-agent", path: "/relocated/deleted.sqlite", }); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/journal/agent"); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("protects every journaled path claimed as a surviving agent workspace", async () => { mocks.loadConfigReturn = { agents: { list: [{ id: "other-agent", workspace: "/journal", default: true }] }, }; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir: "/journal/workspace", sessionsDir: "/journal/sessions", createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true, removed: [], failed: [] }); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/journal/agent/openclaw-agent.sqlite", "test-agent", ); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("preserves a relocated database path registered to another agent", async () => { const databaseRows = [ { agentId: "test-agent", path: "/linked/shared/agent.sqlite" }, { agentId: "other-agent", path: "/real/shared/agent.sqlite" }, ]; mocks.normalizeAgentDirRegistryPath.mockImplementation((pathname: string) => pathname.startsWith("/linked/shared/") ? pathname.replace("/linked/shared/", "/real/shared/") : path.resolve(pathname), ); mocks.listOpenClawRegisteredAgentDatabases.mockImplementation(() => databaseRows); mocks.unregisterOpenClawAgentDatabase.mockImplementation( ({ agentId, path: databasePath }: { agentId: string; path: string }) => { const index = databaseRows.findIndex( (entry) => entry.agentId === agentId && entry.path === databasePath, ); if (index >= 0) { databaseRows.splice(index, 1); } }, ); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledTimes(3); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/agents/test-agent/openclaw-agent.sqlite", "test-agent", ); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/linked/shared/agent.sqlite", "test-agent", ); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/linked/shared/agent.sqlite"); expect(databaseRows).toEqual([{ agentId: "other-agent", path: "/real/shared/agent.sqlite" }]); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("preserves a parent directory containing another agent's relocated database", async () => { mocks.listOpenClawRegisteredAgentDatabases.mockReturnValue([ { agentId: "other-agent", path: "/agents/test-agent/survivor.sqlite" }, ]); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/agents/test-agent/openclaw-agent.sqlite", "test-agent", ); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/agents/test-agent"); expectTrashedWithinParent("/agents/test-agent/openclaw-agent.sqlite"); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("retains database files whose companion file is registered to another agent", async () => { mocks.listOpenClawRegisteredAgentDatabases.mockReturnValue([ { agentId: "test-agent", path: "/shared/deleted.sqlite" }, { agentId: "other-agent", path: "/shared/deleted.sqlite-wal" }, ]); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/shared/deleted.sqlite", "test-agent", ); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/shared/deleted.sqlite-wal"); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("drops stale deleted ownership when overlap intentionally skips directory cleanup", async () => { const directoryOwners = new Map([ ["/journal/agent", "test-agent"], ["/journal/agent/current", "other-agent"], ]); mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir: "/journal/workspace", sessionsDir: "/journal/sessions", createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); mocks.resolveRegisteredAgentIdForDir.mockImplementation((pathname?: string) => directoryOwners.get(pathname ?? ""), ); mocks.isPathOwnedByAnotherRegisteredAgent.mockImplementation( ({ agentId, pathname }: { agentId: string; pathname: string }) => pathname === "/journal/agent" && directoryOwners.get("/journal/agent/current") !== agentId, ); mocks.unregisterResolvedAgentDir.mockImplementation( ({ agentId, agentDir }: { agentId: string; agentDir: string }) => { if (directoryOwners.get(agentDir) !== agentId) { return false; } return directoryOwners.delete(agentDir); }, ); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/journal/agent"); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/journal/agent/openclaw-agent.sqlite", "test-agent", ); expect(mocks.unregisterOpenClawAgentDatabase).toHaveBeenCalledWith({ agentId: "test-agent", path: "/journal/agent/openclaw-agent.sqlite", }); expect(directoryOwners.has("/journal/agent")).toBe(false); expect(directoryOwners.get("/journal/agent/current")).toBe("other-agent"); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("unregisters the captured canonical directory after trashing a symlink path", async () => { mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/linked-agent", workspaceDir: "/journal/workspace", sessionsDir: "/journal/sessions", createdAt: 1, cleanupCompleted: false, deleteFiles: true, }); mocks.resolveRegisteredAgentIdForDir.mockImplementation((pathname?: string) => pathname === "/journal/linked-agent" ? "test-agent" : undefined, ); mocks.normalizeAgentDirRegistryPath.mockImplementation((pathname: string) => pathname === "/journal/linked-agent" ? "/canonical/agent" : pathname, ); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.unregisterResolvedAgentDir).toHaveBeenCalledWith({ agentId: "test-agent", agentDir: "/canonical/agent", }); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("preserves the original keep-files intent while recovering deletion", async () => { mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "delete-1", agentDir: "/journal/agent", workspaceDir: "/journal/workspace", sessionsDir: "/journal/sessions", createdAt: 1, cleanupCompleted: false, deleteFiles: false, }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true, removed: [], failed: [] }); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); expect(mocks.unregisterOpenClawAgentDatabase).not.toHaveBeenCalled(); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("uses the new request intent when deleting a recreated roster entry", async () => { mocks.readAgentDeletionJournal.mockReturnValue({ agentId: "test-agent", operationId: "old-delete", agentDir: "/old/agent", workspaceDir: "/old/workspace", sessionsDir: "/old/sessions", createdAt: 1, cleanupCompleted: true, deleteFiles: true, }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", deleteFiles: false, }); await promise; expectRespondOk(respond, { ok: true, removed: [], failed: [] }); expect(mocks.claimCompletedAgentDeletion).toHaveBeenCalledWith("test-agent", "old-delete"); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); expect(mocks.unregisterOpenClawAgentDatabase).not.toHaveBeenCalled(); }); it("releases cleaned directory ownership while a sibling Trash cleanup remains fenced", async () => { mocks.movePathToTrash.mockImplementation(async (pathname?: string) => { if (pathname === "/workspace/test-agent") { throw new Error("trash unavailable"); } return "/trashed"; }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.beginAgentDeletionFinish).not.toHaveBeenCalled(); expect(mocks.unregisterResolvedAgentDir).toHaveBeenCalledWith({ agentId: "test-agent", agentDir: "/agents/test-agent", }); }); it("keeps directory ownership registered when that Trash cleanup fails", async () => { mocks.movePathToTrash.mockImplementation(async (pathname?: string) => { if (pathname === "/agents/test-agent") { throw new Error("trash unavailable"); } return "/trashed"; }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.beginAgentDeletionFinish).not.toHaveBeenCalled(); expect(mocks.unregisterResolvedAgentDir).not.toHaveBeenCalled(); }); it("unregisters a closed database row after committing deletion", async () => { mocks.closeOpenClawAgentDatabaseByPath.mockReturnValueOnce(false); const { promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expect(mocks.unregisterOpenClawAgentDatabase).toHaveBeenCalledWith({ agentId: "test-agent", path: "/agents/test-agent/openclaw-agent.sqlite", }); expect(mocks.writeConfigFile.mock.invocationCallOrder[0]).toBeLessThan( expectDefined( mocks.unregisterOpenClawAgentDatabase.mock.invocationCallOrder[0], "database unregister call order", ), ); }); it("closes and archives every registered database path owned by the deleted agent", async () => { mocks.listOpenClawRegisteredAgentDatabases.mockReturnValue([ { agentId: "test-agent", path: "/relocated/test-agent.sqlite" }, { agentId: "other-agent", path: "/relocated/other-agent.sqlite" }, ]); const { promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/agents/test-agent/openclaw-agent.sqlite", "test-agent", ); expect(mocks.closeOpenClawAgentDatabaseByPath).toHaveBeenCalledWith( "/relocated/test-agent.sqlite", "test-agent", ); expect(mocks.closeOpenClawAgentDatabaseByPath).not.toHaveBeenCalledWith( "/relocated/other-agent.sqlite", "test-agent", ); expectTrashedWithinParent("/relocated/test-agent.sqlite"); expect(mocks.unregisterOpenClawAgentDatabase).toHaveBeenCalledWith({ agentId: "test-agent", path: "/relocated/test-agent.sqlite", }); }); it("retains relocated database ownership when its Trash archival fails", async () => { mocks.listOpenClawRegisteredAgentDatabases.mockReturnValue([ { agentId: "test-agent", path: "/relocated/test-agent.sqlite" }, ]); mocks.movePathToTrash.mockImplementation(async (pathname?: string) => { if (pathname === "/relocated/test-agent.sqlite") { throw new Error("relocated trash failed"); } return "/trashed"; }); const { promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expect(mocks.unregisterOpenClawAgentDatabase).not.toHaveBeenCalled(); expect(mocks.beginAgentDeletionFinish).not.toHaveBeenCalled(); }); it("deletes an existing agent and trashes files by default", async () => { const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", }); await promise; const result = expectRespondOk(respond, { ok: true, agentId: "test-agent", removedBindings: 2, failed: [], }); expect(result).not.toHaveProperty("purgeFailed"); expect(result.removed).toEqual( expect.arrayContaining([ { path: "/workspace/test-agent", method: "trash" }, { path: "/agents/test-agent", method: "trash" }, { path: "/transcripts/test-agent", method: "trash" }, ]), ); expect(mocks.writeConfigFile).toHaveBeenCalled(); expect(mocks.writeConfigFile).toHaveBeenCalledWith(expect.anything(), { assertConfigPathForWrite: mocks.assertAgentDeletionCurrent, allowedAgentRosterRemovals: ["test-agent"], }); expect(mocks.movePathToTrash).toHaveBeenCalled(); }); it("reports failed session cleanup and retains its journal and files for retry", async () => { mocks.purgeAgentSessionStoreEntries.mockResolvedValueOnce(true); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { ok: true, purgeFailed: true }); expect(mocks.purgeAgentSessionStoreEntries).toHaveBeenCalledWith( expect.anything(), "test-agent", { runDatabaseCleanup: mocks.runAgentDatabaseCleanup }, ); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); expect(mocks.beginAgentDeletionFinish).not.toHaveBeenCalled(); }); it("sweeps WAL sidecars recreated between deletion preparation and cleanup", async () => { const agentDir = "/agents/test-agent"; const walPath = `${agentDir}/openclaw-agent.sqlite-wal`; const shmPath = `${agentDir}/openclaw-agent.sqlite-shm`; const presentStats = new Map([ [agentDir, { dev: 1, ino: 10, file: false }], ["/workspace/test-agent", { dev: 1, ino: 20, file: false }], ["/transcripts/test-agent", { dev: 1, ino: 30, file: false }], ]); mocks.fsLstat.mockImplementation(async (pathname: unknown) => { const stat = presentStats.get(String(pathname)); if (!stat) { throw createEnoentError(); } return { dev: stat.dev, ino: stat.ino, isFile: () => stat.file, isSymbolicLink: () => false, nlink: 1, } as unknown as import("node:fs").Stats; }); // The live runtime reopens the agent database while deletion awaits config, // cron, and session-purge work, recreating -wal/-shm after preparation. mocks.cronRemoveAgentJobsTransactional.mockImplementation( async (_agentId: string, commit: () => Promise) => { presentStats.set(walPath, { dev: 1, ino: 40, file: true }); presentStats.set(shmPath, { dev: 1, ino: 41, file: true }); return await commit(); }, ); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await promise; expectRespondOk(respond, { failed: [] }); const trashedPaths = mocks.movePathToTrash.mock.calls.map(([pathname]) => pathname); expect(trashedPaths).toContain(walPath); expect(trashedPaths).toContain(shmPath); expect(trashedPaths).toContain(agentDir); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); }); it("deletes workspace state after removing the last owner's workspace", async () => { const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", }); await promise; expectRespondOk(respond, { ok: true }); expectTrashedWithinParent("/workspace/test-agent"); expect(mocks.deleteWorkspaceState).toHaveBeenCalledWith( { workspaceDir: "/workspace/test-agent", }, { assertCurrent: mocks.assertAgentDeletionCurrent }, ); }); it("keeps directory ownership when deletion retires during workspace cleanup", async () => { const retired = new Error("deletion owner retired"); mocks.deleteWorkspaceState.mockImplementationOnce(async () => { await Promise.resolve(); mocks.assertAgentDeletionCurrent.mockImplementation(() => { throw retired; }); }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent" }); await expect(promise).rejects.toBe(retired); expect(respond).not.toHaveBeenCalled(); expect(mocks.unregisterResolvedAgentDir).not.toHaveBeenCalled(); expect(mocks.beginAgentDeletionFinish).not.toHaveBeenCalled(); }); it("trashes a dangling workspace symlink before deleting its state", async () => { mocks.fsAccess.mockRejectedValueOnce( Object.assign(new Error("missing target"), { code: "ENOENT" }), ); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", }); await promise; expectRespondOk(respond, { ok: true }); expectTrashedWithinParent("/workspace/test-agent"); expect(mocks.deleteWorkspaceState).toHaveBeenCalled(); }); it("keeps workspace state when another agent still owns the workspace", async () => { mocks.pruneAgentConfig.mockReturnValue({ config: { agents: { list: [{ id: "other", workspace: "/workspace/test-agent" }] } }, removedBindings: 2, }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", }); await promise; expectRespondOk(respond, { ok: true }); expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled(); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/workspace/test-agent"); }); it("reports trash failures without deleting the retained directory", async () => { const actualFs = await vi.importActual("node:fs/promises"); const workspaceDir = await actualFs.realpath( await actualFs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-delete-trash-failure-")), ); mocks.resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, agentId?: string) => agentId === "test-agent" ? workspaceDir : `/workspace/${agentId ?? "unknown"}`, ); mocks.movePathToTrash.mockImplementation(async (pathname?: string) => { if (pathname === workspaceDir) { throw Object.assign(new Error("trash destination missing"), { code: "ENOENT" }); } return "/trashed"; }); try { const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", }); await promise; expectRespondOk(respond, { failed: [{ path: workspaceDir, reason: "trash destination missing" }], }); await expect(actualFs.stat(workspaceDir)).resolves.toBeDefined(); expect(mocks.fsRm).not.toHaveBeenCalled(); expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled(); } finally { await actualFs.rm(workspaceDir, { recursive: true, force: true }); } }); it("reports an absent source without invoking the trash backend", async () => { mocks.fsLstat.mockImplementation(async (pathname: unknown) => { if (pathname === "/workspace/test-agent") { throw createEnoentError(); } return null as unknown as import("node:fs").Stats; }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", }); await promise; const result = expectRespondOk(respond, { failed: [] }); expect(result.removed).toEqual( expect.arrayContaining([{ path: "/workspace/test-agent", method: "missing" }]), ); expect(mocks.movePathToTrash).not.toHaveBeenCalledWith("/workspace/test-agent"); expect(mocks.deleteWorkspaceState).toHaveBeenCalled(); }); it("does not commit deletion when workspace presence cannot be checked", async () => { const permissionError = Object.assign(new Error("permission denied"), { code: "EACCES" }); mocks.fsLstat.mockImplementation(async (pathname: unknown) => { if (pathname === "/workspace/test-agent") { throw permissionError; } return null as unknown as import("node:fs").Stats; }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", }); await expect(promise).rejects.toBe(permissionError); expect(respond).not.toHaveBeenCalled(); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled(); expect(mocks.beginAgentDeletionRollback).toHaveBeenCalledOnce(); }); it("skips file deletion when deleteFiles is false", async () => { mocks.fsLstat.mockClear(); mocks.movePathToTrash.mockClear(); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", deleteFiles: false, }); await promise; expectRespondOk(respond, { ok: true }); // No filesystem cleanup should run. expect(mocks.fsLstat).not.toHaveBeenCalled(); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); expect(mocks.deleteWorkspaceState).not.toHaveBeenCalled(); expect(mocks.unregisterOpenClawAgentDatabase).not.toHaveBeenCalled(); }); it("rejects deleting the main agent", async () => { mocks.loadConfigReturn = { agents: { list: [{ id: "main" }, { id: "ops", default: true }] }, }; const { respond, promise } = makeCall("agents.delete", { agentId: "main", }); await promise; expectRespondErrorContaining(respond, "owns the legacy shared auth store"); expectRespondErrorContaining(respond, "openclaw doctor --fix"); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("rejects an unrepresentable id before targeting the main agent", async () => { mocks.sharedAuthStoreOwnership = { location: "state-db" }; mocks.loadConfigReturn = { agents: { list: [{ id: "main" }, { id: "ops", default: true }] }, }; const { respond, promise } = makeCall("agents.delete", { agentId: "агент✨", }); await promise; expectRespondErrorContaining(respond, 'agent "агент✨" not found'); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); }); it.each([false, true])( "deletes main after shared auth relocation (legacy default: %s)", async (legacyDefault) => { mocks.sharedAuthStoreOwnership = { location: "state-db" }; mocks.loadConfigReturn = { agents: { list: [{ id: "main" }, { id: "ops", ...(legacyDefault ? { default: true } : {}) }], }, }; const { respond, promise } = makeCall("agents.delete", { agentId: "main", }); await promise; expectRespondOk(respond, { ok: true, agentId: "main" }); expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce(); expect(mocks.writeConfigFile).toHaveBeenCalledOnce(); }, ); it("preserves an explicit inherited auth owner after shared auth relocation", async () => { mocks.sharedAuthStoreOwnership = { location: "state-db" }; mocks.loadConfigReturn = { agents: { defaults: { authInheritance: { agentId: "main" } }, list: [{ id: "main" }, { id: "ops" }], }, }; const { respond, promise } = makeCall("agents.delete", { agentId: "main" }); await promise; expectRespondErrorContaining(respond, "owns inherited credentials"); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); }); it("returns not found when a concurrent delete wins the delete race", async () => { let findCallCount = 0; mocks.findAgentEntryIndex.mockImplementation(() => { findCallCount += 1; return findCallCount >= 2 ? -1 : 0; }); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", }); await promise; expectNotFoundResponseAndNoWrite(respond); expect(mocks.movePathToTrash).not.toHaveBeenCalled(); }); it("rejects invalid params (missing agentId)", async () => { const { respond, promise } = makeCall("agents.delete", {}); await promise; expectRespondErrorContaining(respond, "invalid"); }); }); describe("agents.files.list", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = {}; mocks.isWorkspaceSetupCompleted.mockReset().mockResolvedValue(false); mocks.fsReadlink.mockReset().mockResolvedValue(""); }); it("includes BOOTSTRAP.md when setup has not completed", async () => { const names = await listAgentFileNames(); expect(names).toContain("BOOTSTRAP.md"); }); it("does not expose retired HEARTBEAT.md workspace files", async () => { const names = await listAgentFileNames(); expect(names).not.toContain("HEARTBEAT.md"); }); // Pins the derivation: a private copy of this list in the gateway is what let a // retired file linger in the Control UI as a permanently-missing tab. it("lists the canonical workspace filenames except IDENTITY.md", async () => { const names = await listAgentFileNames(); expect(names).toStrictEqual( WORKSPACE_BOOTSTRAP_FILENAMES.filter((name) => name !== "IDENTITY.md"), ); }); it("lists the canonical filenames without BOOTSTRAP.md once setup completed", async () => { mocks.isWorkspaceSetupCompleted.mockResolvedValue(true); const names = await listAgentFileNames(); expect(names).toStrictEqual( WORKSPACE_BOOTSTRAP_FILENAMES.filter( (name) => name !== "IDENTITY.md" && name !== "BOOTSTRAP.md", ), ); }); // The identity form owns this file via agents.update; raw writes stay available // so removing the editor tab does not remove the capability. it("still accepts direct IDENTITY.md writes even though it is not listed", async () => { const { respond, promise } = makeCall("agents.files.set", { agentId: "main", name: "IDENTITY.md", content: "- Name: Ada\n", }); await promise; expectRespondOk(respond, { ok: true }); }); it("rejects writes to retired HEARTBEAT.md workspace files", async () => { const { respond, promise } = makeCall("agents.files.set", { agentId: "main", name: "HEARTBEAT.md", content: "legacy checklist", }); await promise; expectRecordFields(expectRespondErrorContaining(respond, "unsupported file"), { code: "INVALID_REQUEST", message: 'unsupported file "HEARTBEAT.md"', }); expect(mocks.fsMkdir).not.toHaveBeenCalled(); expect(mocks.rootWrite).not.toHaveBeenCalled(); }); it("hides BOOTSTRAP.md when workspace setup is complete", async () => { mockWorkspaceStateRead({ setupCompletedAt: "2026-02-15T14:00:00.000Z" }); const names = await listAgentFileNames(); expect(names).not.toContain("BOOTSTRAP.md"); }); it("falls back to showing BOOTSTRAP.md when workspace state cannot be read", async () => { mockWorkspaceStateRead({ errorCode: "EACCES" }); const names = await listAgentFileNames(); expect(names).toContain("BOOTSTRAP.md"); }); it("falls back to showing BOOTSTRAP.md when workspace state is malformed JSON", async () => { mockWorkspaceStateRead({ rawContent: "{" }); const names = await listAgentFileNames(); expect(names).toContain("BOOTSTRAP.md"); }); // The editor renders a missing-file fault only when absence is unexpected; the // optional profile files and MEMORY.md are normal to be absent and are offered // for creation instead. it("marks normally-absent bootstrap files as expected", async () => { const rootStat = vi.fn(async () => { throw createEnoentError(); }); agentsTesting.setDepsForTests({ root: makeRootForTest({ stat: rootStat }) }); const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); await promise; const result = firstRespondResult(respond); const files = ( result as { files: Array<{ name: string; missing: boolean; expectedAbsent?: boolean }> } ).files; const expectedAbsentNames = files .filter((file) => file.expectedAbsent === true) .map((file) => file.name); expect(files.every((file) => file.missing)).toBe(true); expect(expectedAbsentNames).toStrictEqual(["SOUL.md", "USER.md", "MEMORY.md"]); }); it("omits expectedAbsent for files that exist", async () => { const rootStat = vi.fn(async ({ relativePath }: Record) => { if (relativePath === "SOUL.md") { return { isFile: true, isSymbolicLink: false, mtimeMs: 1234, nlink: 1, size: 9 }; } throw createEnoentError(); }); agentsTesting.setDepsForTests({ root: makeRootForTest({ stat: rootStat }) }); const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); await promise; const result = firstRespondResult(respond); const files = ( result as { files: Array<{ name: string; missing: boolean; expectedAbsent?: boolean }> } ).files; const soul = files.find((file) => file.name === "SOUL.md"); expectRecordFields(soul, { name: "SOUL.md", missing: false }); expect(soul).not.toHaveProperty("expectedAbsent"); }); // Clients merge the get response over the listed entry, so dropping the flag here // made a picked optional file re-render as a fault in the Control UI. it("carries expectedAbsent through agents.files.get for a missing file", async () => { agentsTesting.setDepsForTests({ root: makeRootForTest({ read: async () => { throw new FsSafeError("not-found", "no such file"); }, }), }); const { respond, promise } = makeCall("agents.files.get", { agentId: "main", name: "SOUL.md", }); await promise; const result = firstRespondResult(respond); expectRecordFields((result as { file: unknown }).file, { name: "SOUL.md", missing: true, expectedAbsent: true, }); }); it("reports unreadable workspace files as present in list responses", async () => { const rootOpen = vi.fn(async () => { throw createErrnoError("EACCES"); }); const rootStat = vi.fn(async ({ relativePath }: Record) => { if (relativePath === "AGENTS.md") { return { isFile: true, isSymbolicLink: false, mtimeMs: 4567, nlink: 1, size: 17, }; } throw createEnoentError(); }); agentsTesting.setDepsForTests({ root: makeRootForTest({ open: rootOpen, stat: rootStat }) }); const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); await promise; const result = firstRespondResult(respond); const files = (result as { files: Array<{ name: string; missing: boolean; size?: number }> }) .files; const file = files.find((entry) => entry.name === "AGENTS.md"); expectRecordFields(file, { name: "AGENTS.md", missing: false, size: 17, }); expect(rootOpen).not.toHaveBeenCalled(); }); it("falls back to fixed-path lstat when safe stat is unavailable", async () => { const rootStat = vi.fn(async () => { throw createErrnoError("helper-unavailable"); }); agentsTesting.setDepsForTests({ root: makeRootForTest({ stat: rootStat }) }); mocks.fsLstat.mockImplementation(async (filePath: unknown) => { if (filePath === "/workspace/main/AGENTS.md") { return makeFileStat({ size: 23, mtimeMs: 6789 }); } throw createEnoentError(); }); const { respond, promise } = makeCall("agents.files.list", { agentId: "main" }); await promise; const result = firstRespondResult(respond); const files = (result as { files: Array<{ name: string; missing: boolean; size?: number }> }) .files; const file = files.find((entry) => entry.name === "AGENTS.md"); expectRecordFields(file, { name: "AGENTS.md", missing: false, size: 23, }); expect(rootStat).toHaveBeenCalled(); }); }); describe("agents.files.get/set symlink safety", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = { agents: { list: [{ id: "main", workspace: "/workspace/test-agent" }], }, }; mocks.fsMkdir.mockResolvedValue(undefined); }); function mockWorkspaceEscapeSymlink() { const safeOpenError = new FsSafeError("invalid-path", "path escapes workspace root"); agentsTesting.setDepsForTests({ root: makeRootForTest({ open: async () => { throw safeOpenError; }, read: async () => { throw safeOpenError; }, }), }); mocks.rootWrite.mockRejectedValue(safeOpenError); } function mockInWorkspaceSymlinkAlias() { const safeOpenError = new FsSafeError("invalid-path", "path is not a regular file under root"); agentsTesting.setDepsForTests({ root: makeRootForTest({ open: async () => { throw safeOpenError; }, read: async () => { throw safeOpenError; }, }), }); mocks.rootWrite.mockRejectedValue(safeOpenError); } it.each([ { method: "agents.files.get" as const, expectNoOpen: false }, { method: "agents.files.set" as const, expectNoOpen: true }, ])( "rejects $method when allowlisted file symlink escapes workspace", async ({ method, expectNoOpen }) => { mockWorkspaceEscapeSymlink(); await expectUnsafeWorkspaceFile(method); if (expectNoOpen) { expect(mocks.fsOpen).not.toHaveBeenCalled(); } }, ); it.each(["agents.files.get", "agents.files.set"] as const)( "rejects %s when allowlisted file is an in-workspace symlink alias", async (method) => { mockInWorkspaceSymlinkAlias(); await expectUnsafeWorkspaceFile(method); }, ); function mockHardlinkedWorkspaceAlias() { const safeOpenError = new FsSafeError("invalid-path", "hardlinked path not allowed"); agentsTesting.setDepsForTests({ root: makeRootForTest({ open: async () => { throw safeOpenError; }, read: async () => { throw safeOpenError; }, }), }); mocks.rootWrite.mockRejectedValue(safeOpenError); } it.each([ { method: "agents.files.get" as const, expectNoOpen: false }, { method: "agents.files.set" as const, expectNoOpen: true }, ])( "rejects $method when allowlisted file is a hardlinked alias", async ({ method, expectNoOpen }) => { mockHardlinkedWorkspaceAlias(); await expectUnsafeWorkspaceFile(method); if (expectNoOpen) { expect(mocks.fsOpen).not.toHaveBeenCalled(); } }, ); it("uses non-blocking safe reads for agents.files.get", async () => { const rootRead = vi.fn(async () => ({ buffer: Buffer.from("hello"), realPath: "/workspace/test-agent/AGENTS.md", stat: makeFileStat({ size: 5 }), })); agentsTesting.setDepsForTests({ root: makeRootForTest({ read: rootRead }) }); const { respond, promise } = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md", }); await promise; expectRecordFields(mockCallArg(rootRead), { rootDir: "/workspace/test-agent", relativePath: "AGENTS.md", hardlinks: "reject", nonBlockingRead: true, }); const payload = expectRespondOk(respond, {}); expectRecordFields(payload.file, { name: "AGENTS.md", content: "hello", }); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */