aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPhysicistLeon <physicistleon@gmail.com>2026-10-11 19:36:56 +0300
committerGitHub <noreply@github.com>2026-10-11 22:06:56 +0530
commitff449d9f807bc2aa9b6d13aaa8602c37a6a72352 (patch)
treecec8af0acff219105562b1f5dde4c7fa1f14c273
parent8d7a067c8d82a592bfb3dcacf05305c39550cbc6 (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.md4
-rw-r--r--README.md1
-rw-r--r--src/daemon/spawn.ts70
-rw-r--r--test/util/daemon-socket-test.ts81
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
diff --git a/README.md b/README.md
index 60d7d7f..fb7d072 100644
--- a/README.md
+++ b/README.md
@@ -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> => {