diff options
| author | PhysicistLeon <physicistleon@gmail.com> | 2026-10-11 19:36:56 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-10-11 22:06:56 +0530 |
| commit | ff449d9f807bc2aa9b6d13aaa8602c37a6a72352 (patch) | |
| tree | cec8af0acff219105562b1f5dde4c7fa1f14c273 | |
| parent | 8d7a067c8d82a592bfb3dcacf05305c39550cbc6 (diff) | |
fix(daemon): resolve tsx from the filesystem when Node's resolver cannot see node_modules (#28)
Inside a Bun-compiled pi binary (pi.exe), createRequire().resolve("tsx/cli") is rooted at the
embedded virtual filesystem and cannot see the extension's real node_modules, so browser_setup
failed instantly. The resolver result is now checked on disk and, when it fails, the tsx CLI is
found by walking up the node_modules chain. Under Bun the daemon runs on `node` from PATH
(process.execPath is the pi binary itself); PI_BROWSER_NODE overrides it.
Fixes #27
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| -rw-r--r-- | CHANGELOG.md | 4 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | src/daemon/spawn.ts | 70 | ||||
| -rw-r--r-- | test/util/daemon-socket-test.ts | 81 |
4 files changed, 146 insertions, 10 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 50fafb4..601d699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to pi-browser-harness will be documented in this file. - **Per-tab extra HTTP request headers.** `browser_set_headers` applies headers to subsequent document, subresource, fetch, and XHR requests on the current owned tab through CDP, while `browser_clear_headers` removes them. Header values are not echoed in tool output, and guidance requires clearing them before switching tabs or navigating to another origin. +### Fixed + +- **The daemon starts from a Bun-compiled pi binary.** Inside `pi.exe` (and any other `bun build --compile` host), `createRequire().resolve("tsx/cli")` is rooted at the binary's embedded virtual filesystem and cannot see the extension's real `node_modules`, so `browser_setup` failed instantly with `Could not start the browser daemon`. The resolver result is now checked on disk and, when it fails, the tsx CLI is found by walking up the `node_modules` chain the way Node does. Under Bun the daemon is launched with `node` from `PATH` instead of `process.execPath` (which is the pi binary itself); `PI_BROWSER_NODE` overrides the executable. (#27) + ## 0.11.1 — 2026-08-12 ### Fixed @@ -322,6 +322,7 @@ re-runnable. | `BH_DEBUG_CLICKS` | Same as `--browser-debug-clicks`. | | `BU_CDP_PORTS` | Extra ports to probe during CDP discovery, beyond 9222. | | `BH_SCRIPT_DIR` | Additional directory `browser_run_script` will accept paths from. | +| `PI_BROWSER_NODE` | Node executable used to run the daemon. Defaults to the one running pi, or `node` from `PATH` when pi is a Bun-compiled binary. | --- diff --git a/src/daemon/spawn.ts b/src/daemon/spawn.ts index ffba92d..cea8325 100644 --- a/src/daemon/spawn.ts +++ b/src/daemon/spawn.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from "node:child_process"; +import { accessSync } from "node:fs"; import { access, unlink } from "node:fs/promises"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; @@ -80,19 +81,67 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => { }); }; -// Node's own resolver, not a guessed `node_modules/.bin` path: pi installs every extension into -// one flat, hoisted tree, so tsx lands beside the package rather than inside it and a fixed -// `<pkg>/node_modules/.bin/tsx` lookup always misses. Resolving the CLI entry also means no shell, -// so the Windows `.cmd` shim and its argument quoting are gone with it. -export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray<string> } | null => { - const daemonScript = join(moduleDir, "index.ts"); +// Walks upward like Node's own node_modules lookup. Exported with an injectable start so the +// hoisted layout pi uses can be exercised against a fixture instead of this checkout. +export const resolveTsxCliFromFilesystem = (startDir: string = moduleDir): string | null => { + let dir = startDir; + + while (true) { + const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs"); + try { + accessSync(candidate); + return candidate; + } catch { + // Keep walking toward the filesystem root. This mirrors Node's node_modules lookup + // without relying on createRequire().resolve(), which is broken for external modules + // loaded through jiti/static inside a Bun-compiled executable such as pi.exe. + } + + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +}; + +const resolveTsxCli = (): string | null => { try { - return { command: process.execPath, args: [requireFromHere.resolve("tsx/cli"), daemonScript] }; + const resolved = requireFromHere.resolve("tsx/cli"); + // A compiled runtime may theoretically return a virtual path that a separately spawned + // Node process cannot read. Only accept a resolver result that exists on the host FS. + accessSync(resolved); + return resolved; } catch { - return null; + return resolveTsxCliFromFilesystem(); } }; +export const daemonNodeExecutable = ( + env: NodeJS.ProcessEnv = process.env, + versions: Readonly<Record<string, string | undefined>> = process.versions, + platform: NodeJS.Platform = process.platform, + execPath: string = process.execPath, +): string => { + const override = env["PI_BROWSER_NODE"]; + if (override) return override; + + // Under normal Node keep the exact executable that launched pi/the extension. Under Bun, + // process.execPath is bun/pi.exe, not Node, so run the tsx CLI with the required Node runtime. + if ("bun" in versions) return platform === "win32" ? "node.exe" : "node"; + + return execPath; +}; + +export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray<string> } | null => { + const daemonScript = join(moduleDir, "index.ts"); + const tsxCli = resolveTsxCli(); + if (!tsxCli) return null; + + return { + command: daemonNodeExecutable(), + args: [tsxCli, daemonScript], + }; +}; + export const spawnDaemon = (): ChildProcess | null => { const spec = daemonSpawnCommand(); if (!spec) return null; @@ -104,6 +153,11 @@ export const spawnDaemon = (): ChildProcess | null => { windowsHide: true, }); + // `spawn` reports a missing executable (a bare `node` that is not on PATH under a Bun host) + // asynchronously via `error`; without a listener that is an uncaught exception in pi. + // ensureDaemon's poll loop reports the failure as a timeout, which is what the user sees. + child.on("error", () => {}); + child.unref(); return child; }; diff --git a/test/util/daemon-socket-test.ts b/test/util/daemon-socket-test.ts index 80a03e4..2f9ce8d 100644 --- a/test/util/daemon-socket-test.ts +++ b/test/util/daemon-socket-test.ts @@ -1,11 +1,18 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; -import { existsSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { Type } from "typebox"; import type { ExtensionAPI, ToolDefinition } from "@mariozechner/pi-coding-agent"; import type { TSchema } from "typebox"; -import { daemonSocketMayExist, daemonSpawnCommand } from "../../src/daemon/spawn"; +import { + daemonNodeExecutable, + daemonSocketMayExist, + daemonSpawnCommand, + resolveTsxCliFromFilesystem, +} from "../../src/daemon/spawn"; import { type AnyBrowserToolDefinition, registerBrowserTool } from "../../src/util/tool"; import type { BrowserClient } from "../../src/client"; import { ok } from "../../src/util/result"; @@ -55,6 +62,76 @@ describe("daemon spawn command", () => { }); }); +describe("daemon node executable", () => { + const nodeVersions = { node: "22.0.0" }; + const bunVersions = { node: "22.0.0", bun: "1.3.14" }; + + test("a plain Node host reuses its own executable", () => { + assert.equal(daemonNodeExecutable({}, nodeVersions, "linux", "/usr/bin/node"), "/usr/bin/node"); + assert.equal(daemonNodeExecutable({}, nodeVersions, "win32", "C:\\node\\node.exe"), "C:\\node\\node.exe"); + }); + + test("a Bun-compiled host never spawns itself as node", () => { + assert.equal(daemonNodeExecutable({}, bunVersions, "linux", "/opt/pi/pi"), "node"); + assert.equal(daemonNodeExecutable({}, bunVersions, "darwin", "/opt/pi/pi"), "node"); + assert.equal(daemonNodeExecutable({}, bunVersions, "win32", "C:\\pi\\pi.exe"), "node.exe"); + }); + + test("PI_BROWSER_NODE overrides on every runtime", () => { + const env = { PI_BROWSER_NODE: "/custom/node" }; + assert.equal(daemonNodeExecutable(env, nodeVersions, "linux", "/usr/bin/node"), "/custom/node"); + assert.equal(daemonNodeExecutable(env, bunVersions, "win32", "C:\\pi\\pi.exe"), "/custom/node"); + }); + + test("an empty PI_BROWSER_NODE is ignored", () => { + assert.equal(daemonNodeExecutable({ PI_BROWSER_NODE: "" }, bunVersions, "linux", "/opt/pi/pi"), "node"); + }); +}); + +const withTree = (prefix: string, fn: (root: string) => void): void => { + const root = mkdtempSync(join(tmpdir(), prefix)); + try { + fn(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}; + +const touch = (file: string): void => { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, ""); +}; + +describe("tsx CLI filesystem fallback", () => { + // pi installs extensions into one flat tree, so tsx is a sibling of the package, not inside it. + test("finds a tsx hoisted beside the package, walking up from src/daemon", () => { + withTree("pbh-hoist-", (root) => { + const cli = join(root, "node_modules", "tsx", "dist", "cli.mjs"); + touch(cli); + const start = join(root, "node_modules", "pi-browser-harness", "src", "daemon"); + mkdirSync(start, { recursive: true }); + assert.equal(resolveTsxCliFromFilesystem(start), cli); + }); + }); + + test("prefers the package's own node_modules over a hoisted copy", () => { + withTree("pbh-local-", (root) => { + const hoisted = join(root, "node_modules", "tsx", "dist", "cli.mjs"); + const local = join(root, "node_modules", "pi-browser-harness", "node_modules", "tsx", "dist", "cli.mjs"); + touch(hoisted); + touch(local); + const start = join(root, "node_modules", "pi-browser-harness", "src", "daemon"); + mkdirSync(start, { recursive: true }); + assert.equal(resolveTsxCliFromFilesystem(start), local); + }); + }); + + test("the fallback agrees with Node's resolver in this checkout", () => { + const viaResolver = daemonSpawnCommand()?.args[0]; + assert.equal(resolveTsxCliFromFilesystem(), viaResolver); + }); +}); + type GateProbe = { readonly ran: ReadonlyArray<string>; readonly text: string }; const runGate = async (ensureAlive: boolean | undefined): Promise<GateProbe> => { |
