aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci.yml18
-rw-r--r--CHANGELOG.md2
-rw-r--r--package-lock.json31
-rw-r--r--package.json2
-rw-r--r--src/daemon/spawn.ts34
-rw-r--r--src/domains/js.ts6
-rw-r--r--src/util/paths.ts12
-rw-r--r--test/util/daemon-socket-test.ts25
-rw-r--r--test/util/paths-test.ts50
9 files changed, 121 insertions, 59 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f307368..9550d94 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,14 +35,16 @@ jobs:
- name: Verify npm package
run: npm pack --dry-run > /dev/null
- # Profile discovery and pinning are the only parts of the harness whose
- # behavior differs per operating system: user-data-dir locations, how the
- # running browser is identified, and whether a --profile-directory launch is
- # handed to the running browser by Chromium's ProcessSingleton. Chromium's
- # source says all three behave the same everywhere; this job is what turns
- # that into evidence on real macOS and Windows machines.
+ # The parts of the harness whose behavior differs per operating system:
+ # profile discovery and pinning (user-data-dir locations, how the running
+ # browser is identified, whether a --profile-directory launch is handed to the
+ # running browser by Chromium's ProcessSingleton), script-path containment
+ # (separators and case rules), and how the daemon process is spawned.
+ # Chromium's source and node:path's docs say these behave as expected
+ # everywhere; this job is what turns that into evidence on real macOS and
+ # Windows machines.
profiles:
- name: Profiles (${{ matrix.os }})
+ name: Platform behavior (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -70,6 +72,8 @@ jobs:
npx tsx test/profile/list-test.ts
npx tsx test/profile/store-test.ts
npx tsx test/profile/browser-process-test.ts
+ npx tsx test/util/paths-test.ts
+ npx tsx test/util/daemon-socket-test.ts
shell: bash
# The runner images ship Google Chrome on all three platforms. Linux has
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 988e5bb..5810ea3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ All notable changes to pi-browser-harness will be documented in this file.
### Fixed
- **Chrome remote-debugging consent is no longer retriggered indefinitely in the background.** A cancelled, failed, or disconnected CDP connection now remains disconnected until the next explicit browser request, which makes one on-demand connection attempt instead of running an unbounded retry loop that can repeatedly interrupt normal browsing.
+- **`browser_run_script` accepts paths on Windows again.** The allowed-directory check compared against a hardcoded `/` separator, so every backslash path — including files inside `os.tmpdir()`, which the rejection message itself listed as allowed — was refused. Containment is now decided by `path.relative`, which carries each platform's own separator and case rules.
+- **The daemon starts from a production install.** `tsx` is needed to run the daemon but was declared only as a dev dependency, so `browser_setup` reported `Could not start the browser daemon` under `npm omit=dev`. It is now a runtime dependency, and the daemon is launched via the CLI entry that Node's resolver finds — a fixed `node_modules/.bin` path does not exist in a hoisted install, which is how pi lays extensions out.
## 0.11.0 — 2026-08-02
diff --git a/package-lock.json b/package-lock.json
index 9ba24fc..012ee11 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,13 +9,13 @@
"version": "0.11.0",
"license": "MIT",
"dependencies": {
+ "tsx": "^4.22.4",
"ws": "^8.20.0"
},
"devDependencies": {
"@mariozechner/pi-coding-agent": "*",
"@mariozechner/pi-tui": "*",
"@types/ws": "^8.18.1",
- "tsx": "^4.22.4",
"typebox": "*",
"typescript": "^5.0.0"
},
@@ -884,7 +884,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -901,7 +900,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -918,7 +916,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -935,7 +932,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -952,7 +948,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -969,7 +964,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -986,7 +980,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1003,7 +996,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1020,7 +1012,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1037,7 +1028,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1054,7 +1044,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1071,7 +1060,6 @@
"cpu": [
"loong64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1088,7 +1076,6 @@
"cpu": [
"mips64el"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1105,7 +1092,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1122,7 +1108,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1139,7 +1124,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1156,7 +1140,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1173,7 +1156,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1190,7 +1172,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1207,7 +1188,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1224,7 +1204,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1241,7 +1220,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1258,7 +1236,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1275,7 +1252,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1292,7 +1268,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1309,7 +1284,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3245,7 +3219,6 @@
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -3485,7 +3458,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -4556,7 +4528,6 @@
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
diff --git a/package.json b/package.json
index a0d724d..177383d 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,6 @@
"@mariozechner/pi-coding-agent": "*",
"@mariozechner/pi-tui": "*",
"@types/ws": "^8.18.1",
- "tsx": "^4.22.4",
"typebox": "*",
"typescript": "^5.0.0"
},
@@ -70,6 +69,7 @@
"sharp": "^0.33.0"
},
"dependencies": {
+ "tsx": "^4.22.4",
"ws": "^8.20.0"
}
}
diff --git a/src/daemon/spawn.ts b/src/daemon/spawn.ts
index a88855f..ffba92d 100644
--- a/src/daemon/spawn.ts
+++ b/src/daemon/spawn.ts
@@ -1,6 +1,6 @@
import { spawn, type ChildProcess } from "node:child_process";
import { access, unlink } from "node:fs/promises";
-import { existsSync } from "node:fs";
+import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { createConnection } from "node:net";
@@ -8,6 +8,8 @@ import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";
const moduleDir = dirname(fileURLToPath(import.meta.url));
+const requireFromHere = createRequire(import.meta.url);
+
const isWindows = process.platform === "win32";
// No-op on Windows: a named pipe is not a filesystem entry and unlinking a pipe path throws.
@@ -78,26 +80,28 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
});
};
-export const spawnDaemon = (): ChildProcess | null => {
+// 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");
+ try {
+ return { command: process.execPath, args: [requireFromHere.resolve("tsx/cli"), daemonScript] };
+ } catch {
+ return null;
+ }
+};
- // Windows npm binaries are `.cmd` shims, which only launch through a shell — spawning one directly yields EINVAL.
- const tsxBinBase = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
- const tsxBin = isWindows ? `${tsxBinBase}.cmd` : tsxBinBase;
- const hasLocalTsx = existsSync(tsxBin);
-
- const cmd = hasLocalTsx ? tsxBin : isWindows ? "npx.cmd" : "npx";
- const args = hasLocalTsx ? [daemonScript] : ["tsx", daemonScript];
-
- // With shell: true Node passes the command line verbatim, so a path containing spaces must be quoted or it splits into tokens.
- const quote = (s: string): string => (isWindows ? `"${s}"` : s);
+export const spawnDaemon = (): ChildProcess | null => {
+ const spec = daemonSpawnCommand();
+ if (!spec) return null;
- const child = spawn(quote(cmd), args.map(quote), {
+ const child = spawn(spec.command, [...spec.args], {
detached: true,
stdio: "ignore",
env: { ...process.env },
- shell: isWindows,
- windowsVerbatimArguments: isWindows,
+ windowsHide: true,
});
child.unref();
diff --git a/src/domains/js.ts b/src/domains/js.ts
index 6ba21cd..19c8913 100644
--- a/src/domains/js.ts
+++ b/src/domains/js.ts
@@ -1,5 +1,6 @@
import { readFile } from "node:fs/promises";
import { resolve, isAbsolute } from "node:path";
+import { isPathWithin } from "../util/paths";
import { tmpdir } from "node:os";
import { createRequire } from "node:module";
import { Type } from "typebox";
@@ -121,10 +122,7 @@ const allowedRoots = (): ReadonlyArray<string> => {
return [tmpdir(), process.cwd(), ...(env !== undefined ? [env] : [])].map((d) => resolve(d));
};
-const isPathAllowed = (p: string): boolean => {
- const abs = resolve(p);
- return allowedRoots().some((root) => abs === root || abs.startsWith(`${root}/`));
-};
+const isPathAllowed = (p: string): boolean => allowedRoots().some((root) => isPathWithin(root, p));
const MAX_SOURCE_BYTES = 1_000_000;
diff --git a/src/util/paths.ts b/src/util/paths.ts
index 30a1bbd..3aa1237 100644
--- a/src/util/paths.ts
+++ b/src/util/paths.ts
@@ -1,6 +1,16 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { isAbsolute, join, relative, resolve } from "node:path";
+
+// `relative` rather than a string prefix: it is separator-agnostic and carries each platform's
+// own case rules, so a Windows path is not rejected for its backslashes or its drive-letter case.
+export const isPathWithin = (root: string, target: string): boolean => {
+ const absRoot = resolve(root);
+ const absTarget = resolve(target);
+ if (absTarget === absRoot) return true;
+ const rel = relative(absRoot, absTarget);
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
+};
const NAMESPACE_RE = /^[a-zA-Z0-9_-]+$/;
diff --git a/test/util/daemon-socket-test.ts b/test/util/daemon-socket-test.ts
index eee0500..80a03e4 100644
--- a/test/util/daemon-socket-test.ts
+++ b/test/util/daemon-socket-test.ts
@@ -1,10 +1,11 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
+import { existsSync } from "node:fs";
import { Type } from "typebox";
import type { ExtensionAPI, ToolDefinition } from "@mariozechner/pi-coding-agent";
import type { TSchema } from "typebox";
-import { daemonSocketMayExist } from "../../src/daemon/spawn";
+import { daemonSocketMayExist, daemonSpawnCommand } from "../../src/daemon/spawn";
import { type AnyBrowserToolDefinition, registerBrowserTool } from "../../src/util/tool";
import type { BrowserClient } from "../../src/client";
import { ok } from "../../src/util/result";
@@ -32,6 +33,28 @@ describe("daemon socket presence check", () => {
});
});
+describe("daemon spawn command", () => {
+ test("the daemon runs on this process's node, not a shell", () => {
+ const spec = daemonSpawnCommand();
+ assert.notEqual(spec, null);
+ assert.equal(spec?.command, process.execPath);
+ });
+
+ test("the resolved tsx CLI is a real file on disk", () => {
+ const cli = daemonSpawnCommand()?.args[0];
+ assert.equal(typeof cli, "string");
+ // A hoisted install puts tsx beside the package, so a `<pkg>/node_modules/.bin` guess would miss it.
+ assert.equal(existsSync(String(cli)), true, `tsx CLI not found at ${String(cli)}`);
+ assert.match(String(cli), /cli\.mjs$/);
+ });
+
+ test("the daemon entrypoint is passed to tsx", () => {
+ const script = daemonSpawnCommand()?.args[1];
+ assert.match(String(script), /[\\/]daemon[\\/]index\.ts$/);
+ assert.equal(existsSync(String(script)), true);
+ });
+});
+
type GateProbe = { readonly ran: ReadonlyArray<string>; readonly text: string };
const runGate = async (ensureAlive: boolean | undefined): Promise<GateProbe> => {
diff --git a/test/util/paths-test.ts b/test/util/paths-test.ts
new file mode 100644
index 0000000..4fff247
--- /dev/null
+++ b/test/util/paths-test.ts
@@ -0,0 +1,50 @@
+// Runs on every OS in the CI matrix on purpose: `isPathWithin` delegates to `node:path`, so
+// only a real Windows run proves the backslash and drive-letter-case cases the old check failed.
+
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { tmpdir } from "node:os";
+import { join, resolve, sep } from "node:path";
+import { isPathWithin } from "../../src/util/paths";
+
+const root = resolve(tmpdir());
+
+describe("path containment", () => {
+ test("a directory contains itself", () => {
+ assert.equal(isPathWithin(root, root), true);
+ });
+
+ test("a direct child is contained", () => {
+ assert.equal(isPathWithin(root, join(root, "script.js")), true);
+ });
+
+ test("a nested child is contained", () => {
+ assert.equal(isPathWithin(root, join(root, "a", "b", "script.js")), true);
+ });
+
+ test("a native-separator path is contained", () => {
+ assert.equal(isPathWithin(root, `${root}${sep}script.js`), true);
+ });
+
+ test("an unnormalised path that still lands inside is contained", () => {
+ assert.equal(isPathWithin(root, join(root, "a", "..", "script.js")), true);
+ });
+
+ test("an unnormalised path that escapes the root is rejected", () => {
+ assert.equal(isPathWithin(root, join(root, "..", "..", "evil.js")), false);
+ });
+
+ test("a sibling directory sharing the root's name prefix is rejected", () => {
+ assert.equal(isPathWithin(root, `${root}-sibling${sep}script.js`), false);
+ });
+
+ test("the root's own parent is rejected", () => {
+ assert.equal(isPathWithin(root, join(root, "..")), false);
+ });
+
+ test("the roots the run-script tool allows accept a file written into them", () => {
+ for (const allowed of [tmpdir(), process.cwd()]) {
+ assert.equal(isPathWithin(allowed, join(allowed, "bh-script.mjs")), true, allowed);
+ }
+ });
+});