| import path from "node:path"; |
| import { describe, expect, it, vi, beforeEach } from "vitest"; |
|
|
| |
| |
| |
|
|
| const mocks = vi.hoisted(() => ({ |
| loadConfigReturn: {} as Record<string, unknown>, |
| 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<typeof import("../../agents/workspace.js")>( |
| "../../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<typeof import("../../infra/fs-safe.js")>("../../infra/fs-safe.js"); |
| return { |
| ...actual, |
| writeFileWithinRoot: mocks.writeFileWithinRoot, |
| }; |
| }); |
|
|
| |
| |
| |
| 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, |
| open: mocks.fsOpen, |
| }; |
| return { ...patched, default: patched }; |
| }); |
|
|
| |
| |
| |
|
|
| const { agentsHandlers } = await import("./agents.js"); |
|
|
| |
| |
| |
|
|
| function makeCall(method: keyof typeof agentsHandlers, params: Record<string, unknown>) { |
| 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<typeof vi.fn>) { |
| 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, |
| ); |
| }); |
|
|
| |
| |
| |
|
|
| 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(); |
| |
| 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); |
| |
| 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(); |
| } |
| }, |
| ); |
| }); |
|
|