import path from "node:path"; import { describe, expect, it, vi, beforeEach } from "vitest"; /* ------------------------------------------------------------------ */ /* Mocks */ /* ------------------------------------------------------------------ */ const mocks = vi.hoisted(() => ({ loadConfigReturn: {} as Record, listAgentEntries: vi.fn(() => [] as Array<{ agentId: string }>), findAgentEntryIndex: vi.fn(() => -1), applyAgentConfig: vi.fn((_cfg: unknown, _opts: unknown) => ({})), pruneAgentConfig: vi.fn(() => ({ config: {}, removedBindings: 0 })), writeConfigFile: vi.fn(async () => {}), ensureAgentWorkspace: vi.fn(async () => {}), resolveAgentDir: vi.fn(() => "/agents/test-agent"), resolveAgentWorkspaceDir: vi.fn(() => "/workspace/test-agent"), resolveSessionTranscriptsDirForAgent: vi.fn(() => "/transcripts/test-agent"), listAgentsForGateway: vi.fn(() => ({ defaultId: "main", mainKey: "agent:main:main", scope: "global", agents: [], })), movePathToTrash: vi.fn(async () => "/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), fsOpen: vi.fn(async () => ({}) as unknown), writeFileWithinRoot: vi.fn(async () => {}), })); vi.mock("../../config/config.js", () => ({ loadConfig: () => mocks.loadConfigReturn, writeConfigFile: mocks.writeConfigFile, })); vi.mock("../../commands/agents.config.js", () => ({ applyAgentConfig: mocks.applyAgentConfig, findAgentEntryIndex: mocks.findAgentEntryIndex, listAgentEntries: mocks.listAgentEntries, pruneAgentConfig: mocks.pruneAgentConfig, })); vi.mock("../../agents/agent-scope.js", () => ({ listAgentIds: () => ["main"], resolveAgentDir: mocks.resolveAgentDir, resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, })); vi.mock("../../agents/workspace.js", async () => { const actual = await vi.importActual( "../../agents/workspace.js", ); return { ...actual, ensureAgentWorkspace: mocks.ensureAgentWorkspace, }; }); vi.mock("../../config/sessions/paths.js", () => ({ resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent, })); vi.mock("../../browser/trash.js", () => ({ movePathToTrash: mocks.movePathToTrash, })); vi.mock("../../utils.js", () => ({ resolveUserPath: (p: string) => `/resolved${p.startsWith("/") ? "" : "/"}${p}`, })); vi.mock("../session-utils.js", () => ({ listAgentsForGateway: mocks.listAgentsForGateway, })); vi.mock("../../infra/fs-safe.js", async () => { const actual = await vi.importActual("../../infra/fs-safe.js"); return { ...actual, writeFileWithinRoot: mocks.writeFileWithinRoot, }; }); // Mock node:fs/promises – agents.ts uses `import fs from "node:fs/promises"` // which resolves to the module namespace default, so we spread actual and // override the methods we need, plus set `default` explicitly. vi.mock("node:fs/promises", async () => { const actual = await vi.importActual("node:fs/promises"); const patched = { ...actual, access: mocks.fsAccess, mkdir: mocks.fsMkdir, appendFile: mocks.fsAppendFile, readFile: mocks.fsReadFile, stat: mocks.fsStat, lstat: mocks.fsLstat, realpath: mocks.fsRealpath, open: mocks.fsOpen, }; return { ...patched, default: patched }; }); /* ------------------------------------------------------------------ */ /* Import after mocks are set up */ /* ------------------------------------------------------------------ */ const { agentsHandlers } = await import("./agents.js"); /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function makeCall(method: keyof typeof agentsHandlers, params: Record) { const respond = vi.fn(); const handler = agentsHandlers[method]; const promise = handler({ params, respond, context: {} as never, req: { type: "req" as const, id: "1", method }, client: null, isWebchatConnect: () => false, }); return { respond, promise }; } 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; } function makeSymlinkStat(params?: { dev?: number; ino?: number }): import("node:fs").Stats { return { isFile: () => false, isSymbolicLink: () => true, size: 0, mtimeMs: 0, dev: params?.dev ?? 1, ino: params?.ino ?? 2, } as unknown as import("node:fs").Stats; } function mockWorkspaceStateRead(params: { onboardingCompletedAt?: string; errorCode?: string; rawContent?: string; }) { mocks.fsReadFile.mockImplementation(async (...args: unknown[]) => { const filePath = args[0]; if (String(filePath).endsWith("workspace-state.json")) { if (params.errorCode) { throw createErrnoError(params.errorCode); } if (typeof params.rawContent === "string") { return params.rawContent; } return JSON.stringify({ onboardingCompletedAt: params.onboardingCompletedAt ?? "2026-02-15T14:00:00.000Z", }); } throw createEnoentError(); }); } async function listAgentFileNames(agentId = "main") { const { respond, promise } = makeCall("agents.files.list", { agentId }); await promise; const [, result] = respond.mock.calls[0] ?? []; const files = (result as { files: Array<{ name: string }> }).files; return files.map((file) => file.name); } function expectNotFoundResponseAndNoWrite(respond: ReturnType) { expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("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; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("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 = {}; mocks.findAgentEntryIndex.mockReturnValue(-1); mocks.applyAgentConfig.mockImplementation((_cfg, _opts) => ({})); }); it("creates a new agent successfully", async () => { const { respond, promise } = makeCall("agents.create", { name: "Test Agent", workspace: "/home/user/agents/test", }); await promise; expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ ok: true, agentId: "test-agent", name: "Test Agent", }), undefined, ); expect(mocks.ensureAgentWorkspace).toHaveBeenCalled(); expect(mocks.writeConfigFile).toHaveBeenCalled(); }); it("ensures workspace is set up before writing config", async () => { const callOrder: string[] = []; mocks.ensureAgentWorkspace.mockImplementation(async () => { callOrder.push("ensureAgentWorkspace"); }); 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("rejects creating an agent with reserved 'main' id", async () => { const { respond, promise } = makeCall("agents.create", { name: "main", workspace: "/tmp/ws", }); await promise; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("reserved") }), ); }); it("rejects creating a duplicate agent", async () => { mocks.findAgentEntryIndex.mockReturnValue(0); const { respond, promise } = makeCall("agents.create", { name: "Existing", workspace: "/tmp/ws", }); await promise; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("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; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("invalid") }), ); }); it("always writes Name to IDENTITY.md even without emoji/avatar", async () => { const { promise } = makeCall("agents.create", { name: "Plain Agent", workspace: "/tmp/ws", }); await promise; expect(mocks.fsAppendFile).toHaveBeenCalledWith( expect.stringContaining("IDENTITY.md"), expect.stringContaining("- Name: Plain Agent"), "utf-8", ); }); it("writes emoji and avatar to IDENTITY.md when provided", async () => { const { promise } = makeCall("agents.create", { name: "Fancy Agent", workspace: "/tmp/ws", emoji: "🤖", avatar: "https://example.com/avatar.png", }); await promise; expect(mocks.fsAppendFile).toHaveBeenCalledWith( expect.stringContaining("IDENTITY.md"), expect.stringMatching(/- Name: Fancy Agent[\s\S]*- Emoji: 🤖[\s\S]*- Avatar:/), "utf-8", ); }); }); describe("agents.update", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = {}; mocks.findAgentEntryIndex.mockReturnValue(0); mocks.applyAgentConfig.mockImplementation((_cfg, _opts) => ({})); }); 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("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(); }); }); describe("agents.delete", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = {}; mocks.findAgentEntryIndex.mockReturnValue(0); mocks.pruneAgentConfig.mockReturnValue({ config: {}, removedBindings: 2 }); }); it("deletes an existing agent and trashes files by default", async () => { const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", }); await promise; expect(respond).toHaveBeenCalledWith( true, { ok: true, agentId: "test-agent", removedBindings: 2 }, undefined, ); expect(mocks.writeConfigFile).toHaveBeenCalled(); // moveToTrashBestEffort calls fs.access then movePathToTrash for each dir expect(mocks.movePathToTrash).toHaveBeenCalled(); }); it("skips file deletion when deleteFiles is false", async () => { mocks.fsAccess.mockClear(); const { respond, promise } = makeCall("agents.delete", { agentId: "test-agent", deleteFiles: false, }); await promise; expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({ ok: true }), undefined); // moveToTrashBestEffort should not be called at all expect(mocks.fsAccess).not.toHaveBeenCalled(); }); it("rejects deleting the main agent", async () => { const { respond, promise } = makeCall("agents.delete", { agentId: "main", }); await promise; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("cannot be deleted") }), ); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("rejects deleting a nonexistent agent", async () => { mocks.findAgentEntryIndex.mockReturnValue(-1); const { respond, promise } = makeCall("agents.delete", { agentId: "ghost", }); await promise; expectNotFoundResponseAndNoWrite(respond); }); it("rejects invalid params (missing agentId)", async () => { const { respond, promise } = makeCall("agents.delete", {}); await promise; expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("invalid") }), ); }); }); describe("agents.files.list", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = {}; }); it("includes BOOTSTRAP.md when onboarding has not completed", async () => { const names = await listAgentFileNames(); expect(names).toContain("BOOTSTRAP.md"); }); it("hides BOOTSTRAP.md when workspace onboarding is complete", async () => { mockWorkspaceStateRead({ onboardingCompletedAt: "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"); }); }); describe("agents.files.get/set symlink safety", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadConfigReturn = {}; mocks.fsMkdir.mockResolvedValue(undefined); }); function mockWorkspaceEscapeSymlink() { const workspace = "/workspace/test-agent"; const candidate = path.resolve(workspace, "AGENTS.md"); mocks.fsRealpath.mockImplementation(async (p: string) => { if (p === workspace) { return workspace; } if (p === candidate) { return "/outside/secret.txt"; } return p; }); mocks.fsLstat.mockImplementation(async (...args: unknown[]) => { const p = typeof args[0] === "string" ? args[0] : ""; if (p === candidate) { return makeSymlinkStat(); } throw createEnoentError(); }); } 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("allows in-workspace symlink reads but rejects writes through symlink aliases", async () => { const workspace = "/workspace/test-agent"; const candidate = path.resolve(workspace, "AGENTS.md"); const target = path.resolve(workspace, "policies", "AGENTS.md"); const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 }); mocks.fsRealpath.mockImplementation(async (p: string) => { if (p === workspace) { return workspace; } if (p === candidate) { return target; } return p; }); mocks.fsLstat.mockImplementation(async (...args: unknown[]) => { const p = typeof args[0] === "string" ? args[0] : ""; if (p === candidate) { return makeSymlinkStat({ dev: 9, ino: 41 }); } if (p === target) { return targetStat; } throw createEnoentError(); }); mocks.fsStat.mockImplementation(async (...args: unknown[]) => { const p = typeof args[0] === "string" ? args[0] : ""; if (p === target) { return targetStat; } throw createEnoentError(); }); mocks.fsOpen.mockImplementation( async () => ({ stat: async () => targetStat, readFile: async () => Buffer.from("inside\n"), truncate: async () => {}, writeFile: async () => {}, close: async () => {}, }) as unknown, ); const getCall = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md" }); await getCall.promise; expect(getCall.respond).toHaveBeenCalledWith( true, expect.objectContaining({ file: expect.objectContaining({ missing: false, content: "inside\n" }), }), undefined, ); const setCall = makeCall("agents.files.set", { agentId: "main", name: "AGENTS.md", content: "updated\n", }); await setCall.promise; expect(setCall.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining('unsafe workspace file "AGENTS.md"'), }), ); }); function mockHardlinkedWorkspaceAlias() { const workspace = "/workspace/test-agent"; const candidate = path.resolve(workspace, "AGENTS.md"); mocks.fsRealpath.mockImplementation(async (p: string) => { if (p === workspace) { return workspace; } return p; }); mocks.fsLstat.mockImplementation(async (...args: unknown[]) => { const p = typeof args[0] === "string" ? args[0] : ""; if (p === candidate) { return makeFileStat({ nlink: 2 }); } throw createEnoentError(); }); } 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(); } }, ); });