openclaw / src /gateway /server-methods /agents-mutate.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
78d0c37 verified
Raw History Blame Contribute Delete
128 kB
// 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<string, unknown>,
listAgentEntries: vi.fn((_cfg?: unknown) => [] as Array<Record<string, unknown>>),
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<unknown>) => await commit(),
),
assertAgentDeletionCurrent: vi.fn(),
beginAgentDeletionRollback: vi.fn(),
beginAgentDeletionFinish: vi.fn(),
runAgentDatabaseCleanup: vi.fn(
async (_target: unknown, run: () => Promise<unknown>) => await run(),
),
claimCompletedAgentDeletion: vi.fn(() => true),
readAgentDeletionJournal: vi.fn(() => undefined as Record<string, unknown> | 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<Record<string, unknown>>),
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<unknown>) => 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<typeof import("../../config/config.js")>("../../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<string, unknown>, 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<string, unknown>,
context: unknown,
) => Promise<{ nextConfig: Record<string, unknown>; 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<unknown>) =>
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<typeof import("../../agents/auth-profiles/path-resolve.js")>(
"../../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<typeof import("../../config/sessions.js")>()),
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<string, unknown>) => unknown) => Promise<unknown>,
) =>
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<typeof import("../../agents/workspace.js")>(
"../../agents/workspace.js",
);
return {
...actual,
ensureAgentWorkspace: mocks.ensureAgentWorkspace,
isWorkspaceSetupCompleted: mocks.isWorkspaceSetupCompleted,
};
});
vi.mock("../../agents/workspace-state-store.js", async () => ({
...(await vi.importActual<typeof import("../../agents/workspace-state-store.js")>(
"../../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<typeof import("../../utils.js")>("../../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<typeof import("../../infra/fs-safe.js")>("../../infra/fs-safe.js");
return {
...actual,
root: vi.fn(async (rootDir: string) => ({
rootReal: rootDir,
open: async (relativePath: string, options?: Record<string, unknown>) =>
await mocks.rootOpen({ rootDir, relativePath, ...options }),
stat: async (relativePath: string) => await mocks.rootStat({ rootDir, relativePath }),
read: async (relativePath: string, options?: Record<string, unknown>) =>
await mocks.rootRead({ rootDir, relativePath, ...options }),
write: async (
relativePath: string,
data: string | Buffer,
options?: Record<string, unknown>,
) =>
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<typeof import("node:fs/promises")>("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<unknown>) => 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<unknown>) => 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<string, unknown>) => Promise<unknown>;
read?: (params: Record<string, unknown>) => Promise<unknown>;
stat?: (params: Record<string, unknown>) => Promise<unknown>;
write?: (params: Record<string, unknown>) => Promise<unknown>;
}) {
return async (rootDir: string) =>
({
rootReal: rootDir,
open: async (relativePath: string, options?: Record<string, unknown>) =>
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<string, unknown>) =>
await (overrides?.read ?? mocks.rootRead)({ rootDir, relativePath, ...options }),
write: async (
relativePath: string,
data: string | Buffer,
options?: Record<string, unknown>,
) =>
await (overrides?.write ?? mocks.rootWrite)({
rootDir,
relativePath,
data,
...options,
}),
}) as never;
}
function makeCall(method: keyof typeof agentsHandlers, params: Record<string, unknown>) {
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<string, unknown>) {
if (!record || typeof record !== "object") {
throw new Error("Expected record");
}
const actual = record as Record<string, unknown>;
for (const [key, value] of Object.entries(expected)) {
expect(actual[key]).toEqual(value);
}
return actual;
}
function mockCallArg(mock: ReturnType<typeof vi.fn>, 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<typeof vi.fn>, expected: Record<string, unknown>) {
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<typeof vi.fn>, 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<typeof vi.fn>): 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<typeof vi.fn>) {
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<unknown>) => {
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<unknown>) => {
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<unknown>) => {
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<string, unknown>;
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<string>();
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<string>();
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<string, string>();
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<string, { dev: number; ino: number; file: boolean }>([
[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<unknown>) => {
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<typeof import("node:fs/promises")>("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<string, unknown>) => {
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<string, unknown>) => {
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. */