import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; /** * Integration test: verifies that every Claude CLI invocation includes * --bare (prevents CLAUDE.md walk-up through parent directories) and * --append-system-prompt-file pointing to the workspace CLAUDE.md. * * We mock cross-spawn so no real process is launched, and capture the exact * args array that runner.ts would pass to the CLI. */ // Capture spawned args via a mock before any module imports runner let capturedArgs: string[] = []; vi.mock("cross-spawn", () => { // Minimal EventEmitter-like child mock that immediately closes with code 0 function makeMockChild(args: string[]) { capturedArgs = args; const listeners: Record void>> = {}; const stdioStream = { setEncoding: () => {}, on: (event: string, cb: (...a: unknown[]) => void) => { if (!listeners[event]) listeners[event] = []; listeners[event].push(cb); // emit 'data' with a JSON payload that looks like our fake-claude output if (event === "data") { Promise.resolve().then(() => cb( JSON.stringify({ args, hasBare: args.includes("--bare"), systemPromptFile: args.indexOf("--append-system-prompt-file") >= 0 ? args[args.indexOf("--append-system-prompt-file") + 1] : null, }) ) ); } }, }; const child = { stdout: { ...stdioStream }, stderr: { setEncoding: () => {}, on: () => {}, }, stdin: { end: () => {} }, on: (event: string, cb: (...a: unknown[]) => void) => { if (!listeners[event]) listeners[event] = []; listeners[event].push(cb); if (event === "close") { // Fire 'close' after stdout data has been emitted Promise.resolve() .then(() => Promise.resolve()) .then(() => cb(0)); } }, kill: () => {}, }; return child; } return { default: (_cmd: string, args: string[]) => makeMockChild(args), }; }); // Import after mock registration import { runAgent } from "../../src/agent/runner"; import { _resetResolveClaudeCache } from "../../src/runtime/resolve-claude"; describe("no-parent-claude-md-leak", () => { let workspaceDir: string; const originalEnv = { ...process.env }; beforeAll(() => { // Create a minimal temp workspace with agent.yaml and CLAUDE.md workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-test-")); fs.writeFileSync( path.join(workspaceDir, "agent.yaml"), `name: test-agent\ndisplay_name: Test Agent\nrole: Testing\nchannel_id: "123456789"\n`, "utf-8" ); fs.writeFileSync( path.join(workspaceDir, "CLAUDE.md"), "# Test Agent\nYou are a test agent.\n", "utf-8" ); }); beforeEach(() => { capturedArgs = []; // Reset the resolve-claude cache and set a dummy CLAUDE_PATH _resetResolveClaudeCache(); process.env.CLAUDE_PATH = "/fake/claude"; }); afterEach(() => { process.env = { ...originalEnv }; _resetResolveClaudeCache(); }); afterAll(() => { try { fs.rmSync(workspaceDir, { recursive: true, force: true }); } catch { // Best-effort cleanup } }); it("passes --bare flag to the Claude CLI", async () => { await runAgent({ workspacePath: workspaceDir, channelName: "test-channel", userMessage: "hello", conversationHistory: [], }); expect(capturedArgs).toContain("--bare"); }); it("passes --append-system-prompt-file pointing to workspace CLAUDE.md", async () => { await runAgent({ workspacePath: workspaceDir, channelName: "test-channel", userMessage: "hello", conversationHistory: [], }); const idx = capturedArgs.indexOf("--append-system-prompt-file"); expect(idx).toBeGreaterThanOrEqual(0); const systemPromptFile = capturedArgs[idx + 1]; expect(systemPromptFile).toMatch(/CLAUDE\.md$/); // Must point to the workspace-specific CLAUDE.md, not a parent directory expect(systemPromptFile).toBe(path.join(workspaceDir, "CLAUDE.md")); }); });