import { mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, mock, test } from "bun:test"; import type { ToolContext } from "../types.js"; // --------------------------------------------------------------------------- // Singleton mocks — must precede the tool import so bun's module mock applies. // --------------------------------------------------------------------------- let mockProxyAvailable = false; mock.module("../../daemon/host-file-proxy.js", () => ({ HostFileProxy: { get instance() { return { isAvailable: () => mockProxyAvailable, request: () => Promise.resolve({ content: "ok", isError: false }), }; }, }, })); mock.module("../../runtime/assistant-event-hub.js", () => ({ assistantEventHub: { listClientsByCapability: () => [], }, broadcastMessage: async () => {}, })); const { hostFileReadTool } = await import("./read.js"); const testDirs: string[] = []; afterEach(() => { mockProxyAvailable = false; for (const dir of testDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); function makeTempDir(): string { const dir = realpathSync(mkdtempSync(join(tmpdir(), "host-read-test-"))); testDirs.push(dir); return dir; } function makeContext( workingDir: string, transportInterface: ToolContext["transportInterface"], ): ToolContext { return { workingDir, conversationId: "test-conv", trustClass: "guardian", transportInterface, }; } describe("host_file_read cross-client guards", () => { test("returns 'no client' error on web transport when proxy unavailable and no targetClientId", async () => { const workingDir = makeTempDir(); const result = await hostFileReadTool.execute( { path: "/some/host/path.txt" }, makeContext(workingDir, "web"), ); expect(result.isError).toBe(true); expect(result.content).toContain( "no client with host_file capability is connected", ); }); test("returns 'specified client disconnected' error when targetClientId set but proxy unavailable on web", async () => { const workingDir = makeTempDir(); const result = await hostFileReadTool.execute( { path: "/some/host/path.txt", target_client_id: "abc-123" }, makeContext(workingDir, "web"), ); expect(result.isError).toBe(true); expect(result.content).toContain( 'target client "abc-123" is no longer connected', ); }); test("falls through to local fs on macos transport when proxy unavailable and path is non-image", async () => { const workingDir = makeTempDir(); const result = await hostFileReadTool.execute( { path: "/nonexistent/x.txt" }, makeContext(workingDir, "macos"), ); // Proves the guard did NOT fire on macOS — instead we got the // local FileSystemOps NOT_FOUND error. expect(result.isError).toBe(true); expect(result.content).toContain("File not found"); }); test("does NOT reject on macos transport with a stale target_client_id when proxy unavailable (regression: P2 fix)", async () => { const workingDir = makeTempDir(); const result = await hostFileReadTool.execute( { path: "/nonexistent/x.txt", target_client_id: "stale-mac" }, makeContext(workingDir, "macos"), ); // The disconnected-target guard is scoped to non-host-proxy transports // (!supportsHostProxy). On macos, a stale target_client_id auto-filled // from a prior cross-client turn must be silently ignored and the call // must fall through to local FileSystemOps (NOT_FOUND for a fake path), // NOT reject with "target client ... is no longer connected". expect(result.isError).toBe(true); expect(result.content).toContain("File not found"); expect(result.content).not.toContain("is no longer connected"); }); test("rejects when target_client_id is set but transport metadata is missing (legacy/backwards-compat path)", async () => { const workingDir = makeTempDir(); const result = await hostFileReadTool.execute( { path: "/some/host/path.txt", target_client_id: "abc-123" }, // transportInterface intentionally undefined (legacy callers). makeContext(workingDir, undefined), ); // When transport metadata is missing we cannot rule out a non-host-proxy // turn, so falling through to local fs would silently target the daemon // container. The guard fires for both undefined transport AND // non-host-proxy transports — only macos turns skip it. expect(result.isError).toBe(true); expect(result.content).toContain( 'target client "abc-123" is no longer connected', ); }); });