From 2c11f2014a9c530ea1f9da97f4cdc28754709c0d Mon Sep 17 00:00:00 2001 From: FHMinyi Date: Wed, 2 Sep 2026 18:17:13 +0800 Subject: [PATCH 1/5] fix(web): hand resolved pi-coding-agent entry to the /web child process npm-installed users hit exit code 1 on /web: the standalone child process cannot resolve the peer dependency @earendil-works/pi-coding-agent from the npm package location. The parent extension runs inside Pi, so resolve the package entry there: walk up from realpathSync(process.argv[1]) to the pi-coding-agent package root and hand dist/index.js to the child via OPENPI_PI_CODING_AGENT_ENTRY. The child maps the bare specifier to that absolute path through a jiti alias. Resolution is fail-soft and any inherited stale env value is dropped, preserving the previous behavior when no path is found. Closes openpi-dev/openpi#341 --- bin/openpi.js | 8 +++- extensions/web/index.ts | 59 +++++++++++++++++++++-- tests/extensions/web/index.test.ts | 47 +++++++++++++++++- tests/web/cli.test.ts | 76 ++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 5 deletions(-) diff --git a/bin/openpi.js b/bin/openpi.js index 62d9d72a..432dc854 100755 --- a/bin/openpi.js +++ b/bin/openpi.js @@ -67,7 +67,13 @@ const stop = () => { }; try { - const jiti = createJiti(import.meta.url); + const piCodingAgentEntry = process.env.OPENPI_PI_CODING_AGENT_ENTRY; + const jiti = createJiti( + import.meta.url, + piCodingAgentEntry + ? { alias: { "@earendil-works/pi-coding-agent": piCodingAgentEntry } } + : {}, + ); const [browserModule, hostModule, runtimeModule, statusModule, traceModule] = await Promise.all([ jiti.import("../web/host/browser-launcher.ts"), diff --git a/extensions/web/index.ts b/extensions/web/index.ts index 58085d29..e1413eda 100644 --- a/extensions/web/index.ts +++ b/extensions/web/index.ts @@ -1,5 +1,6 @@ import { spawn as nodeSpawn } from "node:child_process"; -import { dirname } from "node:path"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, @@ -7,6 +8,9 @@ import type { } from "@earendil-works/pi-coding-agent"; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000; +const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent"; +const PI_CODING_AGENT_ENTRY_ENV = "OPENPI_PI_CODING_AGENT_ENTRY"; +const PACKAGE_ROOT_SEARCH_DEPTH = 10; export interface WebProcess { readonly exitCode: number | null; @@ -26,12 +30,56 @@ interface SpawnWebOptions { stdio: "inherit"; } -function webProcessEnvironment(cwd: string) { +function findPackageRoot(realPath: string, packageName: string) { + let dir = dirname(realPath); + for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) { + const manifestPath = join(dir, "package.json"); + if (existsSync(manifestPath)) { + const manifest: { name?: unknown } = JSON.parse( + readFileSync(manifestPath, "utf8"), + ); + if (manifest.name === packageName) return dir; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return undefined; +} + +// Pi loads this extension through jiti aliases, so neither `import.meta.resolve` +// nor `createRequire` can locate the peer package here; the launcher path is the +// only handle that reaches the running Pi installation. +function resolvePiCodingAgentEntryFromLauncher() { + const launcher = process.argv[1]; + if (!launcher) return undefined; + try { + const packageRoot = findPackageRoot( + realpathSync(launcher), + PI_CODING_AGENT_PACKAGE, + ); + if (!packageRoot) return undefined; + const entry = join(packageRoot, "dist", "index.js"); + return existsSync(entry) ? entry : undefined; + } catch { + return undefined; + } +} + +function webProcessEnvironment( + cwd: string, + piCodingAgentEntry: string | undefined, +) { const environment: NodeJS.ProcessEnv = { ...process.env, PWD: cwd }; delete environment.OLDPWD; delete environment.INIT_CWD; delete environment.PI_SESSION_ID; delete environment.PI_SESSION_FILE; + if (piCodingAgentEntry) { + environment[PI_CODING_AGENT_ENTRY_ENV] = piCodingAgentEntry; + } else { + delete environment[PI_CODING_AGENT_ENTRY_ENV]; + } return environment; } @@ -40,6 +88,7 @@ export interface WebCommandDependencies { spawn(command: string, args: string[], options: SpawnWebOptions): WebProcess; clearTerminal(): void; holdParentSigint(): () => void; + resolvePiCodingAgentEntry(): string | undefined; shutdownTimeoutMs: number; } @@ -65,6 +114,7 @@ const defaultDependencies: WebCommandDependencies = { process.on("SIGINT", keepPiAlive); return () => process.removeListener("SIGINT", keepPiAlive); }, + resolvePiCodingAgentEntry: resolvePiCodingAgentEntryFromLauncher, shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS, }; @@ -128,7 +178,10 @@ function runWebInForeground( [dependencies.entrypoint, "web", "--no-workspace"], { cwd: childCwd, - env: webProcessEnvironment(childCwd), + env: webProcessEnvironment( + childCwd, + dependencies.resolvePiCodingAgentEntry(), + ), shell: false, stdio: "inherit", }, diff --git a/tests/extensions/web/index.test.ts b/tests/extensions/web/index.test.ts index bedfaa4d..7163cf79 100644 --- a/tests/extensions/web/index.test.ts +++ b/tests/extensions/web/index.test.ts @@ -43,7 +43,12 @@ class FakeWebProcess extends EventEmitter implements WebProcess { } function harness( - options: { mode?: "tui" | "print"; idle?: boolean; stopError?: Error } = {}, + options: { + mode?: "tui" | "print"; + idle?: boolean; + stopError?: Error; + piCodingAgentEntry?: string; + } = {}, ) { const hooks = new Map unknown>>(); let command: CommandHandler | undefined; @@ -56,6 +61,7 @@ function harness( let clearCalls = 0; const notifications: Array<{ message: string; level?: string }> = []; const children: FakeWebProcess[] = []; + const spawnEnvs: NodeJS.ProcessEnv[] = []; const cwd = "/workspace/current"; const pi = { registerCommand(name: string, definition: { handler: CommandHandler }) { @@ -71,6 +77,7 @@ function harness( entrypoint: "/package/bin/openpi.js", spawn(commandName, args, spawnOptions) { spawnCalls++; + spawnEnvs.push(spawnOptions.env); assert.equal(commandName, process.execPath); assert.deepEqual(args, [ "/package/bin/openpi.js", @@ -84,6 +91,10 @@ function harness( assert.equal(spawnOptions.env.PI_SESSION_ID, undefined); assert.equal(spawnOptions.env.PI_SESSION_FILE, undefined); assert.equal(spawnOptions.env.PATH, process.env.PATH); + assert.equal( + spawnOptions.env.OPENPI_PI_CODING_AGENT_ENTRY, + options.piCodingAgentEntry, + ); assert.equal(spawnOptions.shell, false); assert.equal(spawnOptions.stdio, "inherit"); const child = new FakeWebProcess(); @@ -99,6 +110,7 @@ function harness( activeSigint--; }; }, + resolvePiCodingAgentEntry: () => options.piCodingAgentEntry, shutdownTimeoutMs: 20, }; @@ -152,6 +164,7 @@ function harness( emit, children, notifications, + spawnEnv: () => spawnEnvs.at(-1), customCalls: () => customCalls, stopped: () => stopped, started: () => started, @@ -196,6 +209,38 @@ test("/web hands the terminal to the exact packaged Web CLI and restores Pi", as } }); +test("/web hands the child the resolved Pi entry and drops a stale one", async () => { + const previousEntry = process.env.OPENPI_PI_CODING_AGENT_ENTRY; + process.env.OPENPI_PI_CODING_AGENT_ENTRY = + "/stale/pi-coding-agent/dist/index.js"; + const resolvedEntry = + "/pi/node_modules/@earendil-works/pi-coding-agent/dist/index.js"; + try { + const resolved = harness({ piCodingAgentEntry: resolvedEntry }); + const running = resolved.run(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + resolved.spawnEnv()?.OPENPI_PI_CODING_AGENT_ENTRY, + resolvedEntry, + ); + resolved.children[0]!.close(0); + await running; + + const unresolved = harness(); + const failed = unresolved.run(); + await new Promise((resolve) => setImmediate(resolve)); + const childEnv = unresolved.spawnEnv(); + assert.ok(childEnv); + assert.equal("OPENPI_PI_CODING_AGENT_ENTRY" in childEnv, false); + unresolved.children[0]!.close(1); + await failed; + } finally { + if (previousEntry === undefined) + delete process.env.OPENPI_PI_CODING_AGENT_ENTRY; + else process.env.OPENPI_PI_CODING_AGENT_ENTRY = previousEntry; + } +}); + test("/web rejects unsupported modes, arguments, busy sessions, and duplicates", async () => { const print = harness({ mode: "print" }); await print.run(); diff --git a/tests/web/cli.test.ts b/tests/web/cli.test.ts index b0f55b96..a5fc1e59 100644 --- a/tests/web/cli.test.ts +++ b/tests/web/cli.test.ts @@ -228,3 +228,79 @@ export class PiWebRuntime { await rm(temporaryRoot, { recursive: true, force: true }); } }); + +test("installed CLI aliases the Pi peer package to the handed-over entry", async () => { + const temporaryRoot = await mkdtemp(join(process.cwd(), ".openpi-cli-test-")); + const packageRoot = join(temporaryRoot, "node_modules", "@tt-a1i", "openpi"); + try { + await mkdir(join(packageRoot, "bin"), { recursive: true }); + await mkdir(join(packageRoot, "web", "host"), { recursive: true }); + await mkdir(join(packageRoot, "web", "runtime"), { recursive: true }); + await cp(entrypointPath, join(packageRoot, "bin", "openpi.js")); + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ type: "module" }), + ); + const stubEntry = join(packageRoot, "pi-entry-stub.js"); + await writeFile(stubEntry, 'export const PI_ENTRY_STUB = "handed-over";\n'); + await writeFile( + join(packageRoot, "web", "host", "browser-launcher.ts"), + "export async function openBrowser(): Promise { return false; }\n", + ); + await writeFile( + join(packageRoot, "web", "host", "terminal-status.ts"), + "export function formatWebReadyScreen(options: { origin: string; url: string }): string { return `ready ${options.origin} ${options.url}`; }\n", + ); + await writeFile( + join(packageRoot, "web", "host", "web-host.ts"), + `export class WebHost { + origin = "http://127.0.0.1:12346"; + url = "http://127.0.0.1:12346/"; + async start(): Promise {} + async stop(): Promise {} +}\n`, + ); + await writeFile( + join(packageRoot, "web", "trace.ts"), + "export function traceWeb(): void {}\n", + ); + await writeFile( + join(packageRoot, "web", "runtime", "pi-runtime.ts"), + `import { writeFile } from "node:fs/promises"; +import { PI_ENTRY_STUB } from "@earendil-works/pi-coding-agent"; + +export class PiWebRuntime { + static async createWithoutWorkspace(): Promise<{ cwd: string; dispose(): Promise }> { + const marker = process.env.OPENPI_CLI_PI_ENTRY_MARKER; + if (marker) await writeFile(marker, PI_ENTRY_STUB); + return { + cwd: "/web-owned-bootstrap", + async dispose(): Promise {}, + }; + } +}\n`, + ); + + const entryMarker = join(temporaryRoot, "pi-entry"); + const { stdout } = await execFileAsync( + process.execPath, + [ + join(packageRoot, "bin", "openpi.js"), + "web", + "--no-workspace", + "--no-open", + ], + { + env: { + ...process.env, + OPENPI_PI_CODING_AGENT_ENTRY: stubEntry, + OPENPI_CLI_PI_ENTRY_MARKER: entryMarker, + }, + }, + ); + assert.match(stdout, /ready http:\/\/127\.0\.0\.1:12346/u); + assert.equal(await readFile(entryMarker, "utf8"), "handed-over"); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); From ae361d1c1221ada493fff92a4576ab3221234b39 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 5 Sep 2026 10:12:48 +0800 Subject: [PATCH 2/5] fix(web): resolve missing Pi peer for /web and openpi web npm installs omit @earendil-works/pi-coding-agent, so both launchers now share one resolver (env, node, argv, PATH) and keep OpenPI's existing pi-server alias instead of requiring a pre-seeded entry env var. --- bin/openpi.js | 12 +- extensions/web/index.ts | 48 +------ tests/web/cli.test.ts | 121 +++++++++++++++++- tests/web/pi-coding-agent-entry.test.ts | 131 ++++++++++++++++++++ web/host/pi-coding-agent-entry.ts | 158 ++++++++++++++++++++++++ 5 files changed, 422 insertions(+), 48 deletions(-) create mode 100644 tests/web/pi-coding-agent-entry.test.ts create mode 100644 web/host/pi-coding-agent-entry.ts diff --git a/bin/openpi.js b/bin/openpi.js index 432dc854..07ff03e7 100755 --- a/bin/openpi.js +++ b/bin/openpi.js @@ -67,12 +67,16 @@ const stop = () => { }; try { - const piCodingAgentEntry = process.env.OPENPI_PI_CODING_AGENT_ENTRY; + const bootstrap = createJiti(import.meta.url); + const { resolveStandaloneJitiAliases } = await bootstrap.import( + "../web/host/pi-coding-agent-entry.ts", + ); + const aliases = resolveStandaloneJitiAliases({ + fromUrl: import.meta.url, + }); const jiti = createJiti( import.meta.url, - piCodingAgentEntry - ? { alias: { "@earendil-works/pi-coding-agent": piCodingAgentEntry } } - : {}, + Object.keys(aliases).length > 0 ? { alias: aliases } : {}, ); const [browserModule, hostModule, runtimeModule, statusModule, traceModule] = await Promise.all([ diff --git a/extensions/web/index.ts b/extensions/web/index.ts index e1413eda..c68fd606 100644 --- a/extensions/web/index.ts +++ b/extensions/web/index.ts @@ -1,16 +1,16 @@ import { spawn as nodeSpawn } from "node:child_process"; -import { existsSync, readFileSync, realpathSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; +import { + PI_CODING_AGENT_ENTRY_ENV, + resolvePiCodingAgentEntry, +} from "../../web/host/pi-coding-agent-entry.ts"; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000; -const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent"; -const PI_CODING_AGENT_ENTRY_ENV = "OPENPI_PI_CODING_AGENT_ENTRY"; -const PACKAGE_ROOT_SEARCH_DEPTH = 10; export interface WebProcess { readonly exitCode: number | null; @@ -30,42 +30,6 @@ interface SpawnWebOptions { stdio: "inherit"; } -function findPackageRoot(realPath: string, packageName: string) { - let dir = dirname(realPath); - for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) { - const manifestPath = join(dir, "package.json"); - if (existsSync(manifestPath)) { - const manifest: { name?: unknown } = JSON.parse( - readFileSync(manifestPath, "utf8"), - ); - if (manifest.name === packageName) return dir; - } - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; - } - return undefined; -} - -// Pi loads this extension through jiti aliases, so neither `import.meta.resolve` -// nor `createRequire` can locate the peer package here; the launcher path is the -// only handle that reaches the running Pi installation. -function resolvePiCodingAgentEntryFromLauncher() { - const launcher = process.argv[1]; - if (!launcher) return undefined; - try { - const packageRoot = findPackageRoot( - realpathSync(launcher), - PI_CODING_AGENT_PACKAGE, - ); - if (!packageRoot) return undefined; - const entry = join(packageRoot, "dist", "index.js"); - return existsSync(entry) ? entry : undefined; - } catch { - return undefined; - } -} - function webProcessEnvironment( cwd: string, piCodingAgentEntry: string | undefined, @@ -114,7 +78,7 @@ const defaultDependencies: WebCommandDependencies = { process.on("SIGINT", keepPiAlive); return () => process.removeListener("SIGINT", keepPiAlive); }, - resolvePiCodingAgentEntry: resolvePiCodingAgentEntryFromLauncher, + resolvePiCodingAgentEntry, shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS, }; diff --git a/tests/web/cli.test.ts b/tests/web/cli.test.ts index e6b2ca38..232bf62f 100644 --- a/tests/web/cli.test.ts +++ b/tests/web/cli.test.ts @@ -21,6 +21,17 @@ const entrypointPath = fileURLToPath(entrypoint); const staticAssetsPath = fileURLToPath( new URL("../../web/host/static-assets.ts", import.meta.url), ); +const resolverPath = fileURLToPath( + new URL("../../web/host/pi-coding-agent-entry.ts", import.meta.url), +); + +async function copyStandaloneLoader(packageRoot: string) { + await cp(entrypointPath, join(packageRoot, "bin", "openpi.js")); + await cp( + resolverPath, + join(packageRoot, "web", "host", "pi-coding-agent-entry.ts"), + ); +} test("openpi is an executable standalone Web entrypoint", async () => { if (process.platform !== "win32") { @@ -43,7 +54,7 @@ test("installed CLI loads TypeScript Web modules through its package loader", as await mkdir(join(packageRoot, "bin"), { recursive: true }); await mkdir(join(packageRoot, "web", "host"), { recursive: true }); await mkdir(join(packageRoot, "web", "runtime"), { recursive: true }); - await cp(entrypointPath, join(packageRoot, "bin", "openpi.js")); + await copyStandaloneLoader(packageRoot); await cp( staticAssetsPath, join(packageRoot, "web", "host", "static-assets.ts"), @@ -240,7 +251,7 @@ test("installed CLI aliases the Pi peer package to the handed-over entry", async await mkdir(join(packageRoot, "bin"), { recursive: true }); await mkdir(join(packageRoot, "web", "host"), { recursive: true }); await mkdir(join(packageRoot, "web", "runtime"), { recursive: true }); - await cp(entrypointPath, join(packageRoot, "bin", "openpi.js")); + await copyStandaloneLoader(packageRoot); await writeFile( join(packageRoot, "package.json"), JSON.stringify({ type: "module" }), @@ -308,3 +319,109 @@ export class PiWebRuntime { await rm(temporaryRoot, { recursive: true, force: true }); } }); + +test("installed CLI resolves the Pi peer from PATH without a pre-seeded entry", async () => { + const temporaryRoot = await mkdtemp(join(process.cwd(), ".openpi-cli-test-")); + const packageRoot = join(temporaryRoot, "node_modules", "@tt-a1i", "openpi"); + const shadowPeer = join( + temporaryRoot, + "node_modules", + "@earendil-works", + "pi-coding-agent", + ); + const piRoot = join(temporaryRoot, "fake-pi"); + try { + await mkdir(join(packageRoot, "bin"), { recursive: true }); + await mkdir(join(packageRoot, "web", "host"), { recursive: true }); + await mkdir(join(packageRoot, "web", "runtime"), { recursive: true }); + await mkdir(shadowPeer, { recursive: true }); + await mkdir(join(piRoot, "dist", "bundle"), { recursive: true }); + await mkdir(join(piRoot, "bin"), { recursive: true }); + await copyStandaloneLoader(packageRoot); + await writeFile( + join(shadowPeer, "package.json"), + JSON.stringify({ + name: "@earendil-works/pi-coding-agent", + type: "module", + }), + ); + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ type: "module" }), + ); + await writeFile( + join(piRoot, "package.json"), + JSON.stringify({ + name: "@earendil-works/pi-coding-agent", + type: "module", + }), + ); + await writeFile( + join(piRoot, "dist", "index.js"), + 'export const PI_ENTRY_STUB = "path-resolved";\n', + ); + await writeFile(join(piRoot, "dist", "bundle", "cli.js"), ""); + await writeFile(join(piRoot, "bin", "pi"), "#!/usr/bin/env node\n"); + await writeFile( + join(packageRoot, "web", "host", "browser-launcher.ts"), + "export async function openBrowser(): Promise { return false; }\n", + ); + await writeFile( + join(packageRoot, "web", "host", "terminal-status.ts"), + "export function formatWebReadyScreen(options: { origin: string; url: string }): string { return `ready ${options.origin} ${options.url}`; }\n", + ); + await writeFile( + join(packageRoot, "web", "host", "web-host.ts"), + `export class WebHost { + origin = "http://127.0.0.1:12347"; + url = "http://127.0.0.1:12347/"; + async start(): Promise {} + async stop(): Promise {} +}\n`, + ); + await writeFile( + join(packageRoot, "web", "trace.ts"), + "export function traceWeb(): void {}\n", + ); + await writeFile( + join(packageRoot, "web", "runtime", "pi-runtime.ts"), + `import { writeFile } from "node:fs/promises"; +import { PI_ENTRY_STUB } from "@earendil-works/pi-coding-agent"; + +export class PiWebRuntime { + static async createWithoutWorkspace(): Promise<{ cwd: string; dispose(): Promise }> { + const marker = process.env.OPENPI_CLI_PI_ENTRY_MARKER; + if (marker) await writeFile(marker, PI_ENTRY_STUB); + return { + cwd: "/web-owned-bootstrap", + async dispose(): Promise {}, + }; + } +}\n`, + ); + + const entryMarker = join(temporaryRoot, "pi-entry"); + const childEnv = { ...process.env }; + delete childEnv.OPENPI_PI_CODING_AGENT_ENTRY; + const { stdout } = await execFileAsync( + process.execPath, + [ + join(packageRoot, "bin", "openpi.js"), + "web", + "--no-workspace", + "--no-open", + ], + { + env: { + ...childEnv, + PATH: join(piRoot, "bin"), + OPENPI_CLI_PI_ENTRY_MARKER: entryMarker, + }, + }, + ); + assert.match(stdout, /ready http:\/\/127\.0\.0\.1:12347/u); + assert.equal(await readFile(entryMarker, "utf8"), "path-resolved"); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/tests/web/pi-coding-agent-entry.test.ts b/tests/web/pi-coding-agent-entry.test.ts new file mode 100644 index 00000000..50781b73 --- /dev/null +++ b/tests/web/pi-coding-agent-entry.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { realpathSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import { + PI_CODING_AGENT_ENTRY_ENV, + PI_CODING_AGENT_PACKAGE, + PI_SERVER_PACKAGE, + resolvePiCodingAgentEntry, + resolveStandaloneJitiAliases, +} from "../../web/host/pi-coding-agent-entry.ts"; + +async function isolatedLayout() { + const root = await mkdtemp(join(tmpdir(), "openpi-pi-entry-")); + const caller = join(root, "unrelated", "caller.js"); + const piRoot = join(root, "fake-pi"); + await mkdir(join(root, "unrelated"), { recursive: true }); + await mkdir(join(piRoot, "dist", "bundle"), { recursive: true }); + await mkdir(join(piRoot, "bin"), { recursive: true }); + await writeFile(caller, ""); + await writeFile( + join(piRoot, "package.json"), + JSON.stringify({ + name: "@earendil-works/pi-coding-agent", + type: "module", + }), + ); + const entry = join(piRoot, "dist", "index.js"); + await writeFile(entry, "export {}\n"); + await writeFile(join(piRoot, "dist", "bundle", "cli.js"), ""); + await writeFile(join(piRoot, "bin", "pi"), "#!/usr/bin/env node\n"); + return { + root, + caller, + fromUrl: pathToFileURL(caller).href, + piRoot, + entry, + piBin: join(piRoot, "bin", "pi"), + binDir: join(piRoot, "bin"), + }; +} + +test("resolver prefers an existing handed-over entry over PATH", async () => { + const layout = await isolatedLayout(); + const handed = join(layout.root, "handed.js"); + try { + await writeFile(handed, "export {}\n"); + assert.equal( + resolvePiCodingAgentEntry({ + env: { [PI_CODING_AGENT_ENTRY_ENV]: handed }, + argv1: layout.piBin, + fromUrl: layout.fromUrl, + path: layout.binDir, + }), + handed, + ); + } finally { + await rm(layout.root, { recursive: true, force: true }); + } +}); + +test("resolver ignores a stale handed-over entry and walks the Pi launcher", async () => { + const layout = await isolatedLayout(); + try { + assert.equal( + resolvePiCodingAgentEntry({ + env: { [PI_CODING_AGENT_ENTRY_ENV]: join(layout.root, "missing.js") }, + argv1: layout.piBin, + fromUrl: layout.fromUrl, + path: "", + }), + realpathSync(layout.entry), + ); + } finally { + await rm(layout.root, { recursive: true, force: true }); + } +}); + +test("resolver walks PATH when the CLI is not launched from Pi", async () => { + const layout = await isolatedLayout(); + try { + assert.equal( + resolvePiCodingAgentEntry({ + env: {}, + argv1: layout.caller, + fromUrl: layout.fromUrl, + path: layout.binDir, + }), + realpathSync(layout.entry), + ); + } finally { + await rm(layout.root, { recursive: true, force: true }); + } +}); + +test("standalone aliases keep OpenPI pi-server when Pi is resolved from PATH", async () => { + const layout = await isolatedLayout(); + try { + const aliases = resolveStandaloneJitiAliases({ + env: {}, + argv1: layout.caller, + fromUrl: import.meta.url, + path: layout.binDir, + }); + assert.equal(aliases[PI_CODING_AGENT_PACKAGE], realpathSync(layout.entry)); + assert.match(aliases[PI_SERVER_PACKAGE] ?? "", /pi-server/u); + assert.match(aliases[`${PI_SERVER_PACKAGE}/unix`] ?? "", /pi-server/u); + } finally { + await rm(layout.root, { recursive: true, force: true }); + } +}); + +test("resolver fail-softs when no Pi install is reachable", async () => { + const layout = await isolatedLayout(); + try { + assert.equal( + resolvePiCodingAgentEntry({ + env: {}, + argv1: layout.caller, + fromUrl: layout.fromUrl, + path: "", + }), + undefined, + ); + } finally { + await rm(layout.root, { recursive: true, force: true }); + } +}); diff --git a/web/host/pi-coding-agent-entry.ts b/web/host/pi-coding-agent-entry.ts new file mode 100644 index 00000000..f0cca836 --- /dev/null +++ b/web/host/pi-coding-agent-entry.ts @@ -0,0 +1,158 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent"; +export const PI_SERVER_PACKAGE = "@earendil-works/pi-server"; +export const PI_CODING_AGENT_ENTRY_ENV = "OPENPI_PI_CODING_AGENT_ENTRY"; +const PACKAGE_ROOT_SEARCH_DEPTH = 10; + +export function findPackageRoot(realPath: string, packageName: string) { + let dir = dirname(realPath); + for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) { + const manifestPath = join(dir, "package.json"); + if (existsSync(manifestPath)) { + const manifest: { name?: unknown } = JSON.parse( + readFileSync(manifestPath, "utf8"), + ); + if (manifest.name === packageName) return dir; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return undefined; +} + +function packageEntry(root: string | undefined) { + if (!root) return undefined; + const entry = join(root, "dist", "index.js"); + return existsSync(entry) ? entry : undefined; +} + +function walkFromFile(file: string) { + try { + return packageEntry( + findPackageRoot(realpathSync(file), PI_CODING_AGENT_PACKAGE), + ); + } catch { + return undefined; + } +} + +function resolveFromNode(fromUrl: string) { + try { + const manifest = createRequire(fromUrl).resolve( + `${PI_CODING_AGENT_PACKAGE}/package.json`, + ); + return packageEntry(dirname(manifest)); + } catch { + return undefined; + } +} + +function resolveFromPath(pathValue: string | undefined) { + if (!pathValue) return undefined; + const delimiter = process.platform === "win32" ? ";" : ":"; + const names = + process.platform === "win32" ? ["pi.cmd", "pi.exe", "pi"] : ["pi"]; + for (const dir of pathValue.split(delimiter)) { + if (!dir) continue; + for (const name of names) { + const candidate = join(dir, name); + if (!existsSync(candidate)) continue; + const entry = walkFromFile(candidate); + if (entry) return entry; + } + } + return undefined; +} + +export function resolvePiCodingAgentEntry(options?: { + env?: NodeJS.ProcessEnv; + argv1?: string | undefined; + fromUrl?: string; + path?: string; +}) { + const env = options?.env ?? process.env; + const handed = env[PI_CODING_AGENT_ENTRY_ENV]; + if (handed && existsSync(handed)) return handed; + + const fromNode = resolveFromNode(options?.fromUrl ?? import.meta.url); + if (fromNode) return fromNode; + + const argv1 = options?.argv1 === undefined ? process.argv[1] : options.argv1; + if (argv1) { + const fromArgv = walkFromFile(argv1); + if (fromArgv) return fromArgv; + } + + return resolveFromPath(options?.path ?? env.PATH ?? env.Path); +} + +function fileFromUrl(fromUrl: string) { + return fromUrl.startsWith("file:") ? fileURLToPath(fromUrl) : fromUrl; +} + +function findDependencyManifest(fromUrl: string, packageName: string) { + let dir = dirname(fileFromUrl(fromUrl)); + for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) { + const manifestPath = join( + dir, + "node_modules", + ...packageName.split("/"), + "package.json", + ); + if (existsSync(manifestPath)) { + return { + root: dirname(manifestPath), + manifest: JSON.parse(readFileSync(manifestPath, "utf8")) as { + main?: unknown; + exports?: Record; + }, + }; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return undefined; +} + +function exportEntry( + resolved: ReturnType, + subpath: string, +) { + if (!resolved) return undefined; + const target = resolved.manifest.exports?.[subpath]; + const relative = + typeof target === "string" + ? target + : typeof target?.import === "string" + ? target.import + : subpath === "." && typeof resolved.manifest.main === "string" + ? resolved.manifest.main + : undefined; + if (!relative) return undefined; + const entry = join(resolved.root, relative); + return existsSync(entry) ? entry : undefined; +} + +export function resolveStandaloneJitiAliases(options?: { + env?: NodeJS.ProcessEnv; + argv1?: string | undefined; + fromUrl?: string; + path?: string; +}) { + const fromUrl = options?.fromUrl ?? import.meta.url; + const aliases: Record = {}; + const entry = resolvePiCodingAgentEntry({ ...options, fromUrl }); + if (entry) aliases[PI_CODING_AGENT_PACKAGE] = entry; + const server = findDependencyManifest(fromUrl, PI_SERVER_PACKAGE); + const serverEntry = exportEntry(server, "."); + const unixEntry = exportEntry(server, "./unix"); + if (serverEntry) aliases[PI_SERVER_PACKAGE] = serverEntry; + if (unixEntry) aliases[`${PI_SERVER_PACKAGE}/unix`] = unixEntry; + return aliases; +} From 3fa5a316a2d3efa3fa84bfb825b7da3e5aafa09c Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 5 Sep 2026 10:33:04 +0800 Subject: [PATCH 3/5] fix(web): resolve host Pi for /web and install peer for openpi web Validate handoff identity against the official package entry, prefer host argv over a local peer, and fail closed with an install diagnostic instead of PATH walks or mixed pi-server aliases. --- bin/openpi.js | 14 +- extensions/web/index.ts | 8 +- tests/extensions/web/index.test.ts | 33 ++-- tests/web/cli.test.ts | 109 ++++++++++-- tests/web/pi-coding-agent-entry.test.ts | 206 +++++++++++++++++------ web/host/pi-coding-agent-entry.ts | 211 ++++++++++++------------ 6 files changed, 387 insertions(+), 194 deletions(-) diff --git a/bin/openpi.js b/bin/openpi.js index 07ff03e7..f5b58f1d 100755 --- a/bin/openpi.js +++ b/bin/openpi.js @@ -68,16 +68,16 @@ const stop = () => { try { const bootstrap = createJiti(import.meta.url); - const { resolveStandaloneJitiAliases } = await bootstrap.import( - "../web/host/pi-coding-agent-entry.ts", - ); + const { missingPiCodingAgentDiagnostic, resolveStandaloneJitiAliases } = + await bootstrap.import("../web/host/pi-coding-agent-entry.ts"); const aliases = resolveStandaloneJitiAliases({ fromUrl: import.meta.url, }); - const jiti = createJiti( - import.meta.url, - Object.keys(aliases).length > 0 ? { alias: aliases } : {}, - ); + if (!aliases["@earendil-works/pi-coding-agent"]) { + console.error(missingPiCodingAgentDiagnostic()); + process.exit(1); + } + const jiti = createJiti(import.meta.url, { alias: aliases }); const [browserModule, hostModule, runtimeModule, statusModule, traceModule] = await Promise.all([ jiti.import("../web/host/browser-launcher.ts"), diff --git a/extensions/web/index.ts b/extensions/web/index.ts index c68fd606..0297cc26 100644 --- a/extensions/web/index.ts +++ b/extensions/web/index.ts @@ -6,6 +6,7 @@ import type { ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; import { + missingPiCodingAgentDiagnostic, PI_CODING_AGENT_ENTRY_ENV, resolvePiCodingAgentEntry, } from "../../web/host/pi-coding-agent-entry.ts"; @@ -78,7 +79,8 @@ const defaultDependencies: WebCommandDependencies = { process.on("SIGINT", keepPiAlive); return () => process.removeListener("SIGINT", keepPiAlive); }, - resolvePiCodingAgentEntry, + resolvePiCodingAgentEntry: () => + resolvePiCodingAgentEntry({ source: "host" }), shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS, }; @@ -208,6 +210,10 @@ export default function web( ctx.ui.notify("OpenPI Web Workbench is already running.", "warning"); return; } + if (!dependencies.resolvePiCodingAgentEntry()) { + ctx.ui.notify(missingPiCodingAgentDiagnostic(), "error"); + return; + } running = true; try { diff --git a/tests/extensions/web/index.test.ts b/tests/extensions/web/index.test.ts index 95285140..e84e5d87 100644 --- a/tests/extensions/web/index.test.ts +++ b/tests/extensions/web/index.test.ts @@ -47,7 +47,7 @@ function harness( mode?: "tui" | "print"; idle?: boolean; stopError?: Error; - piCodingAgentEntry?: string; + piCodingAgentEntry?: string | null; } = {}, ) { const hooks = new Map unknown>>(); @@ -96,7 +96,10 @@ function harness( assert.equal(childPath, process.env.PATH); assert.equal( spawnOptions.env.OPENPI_PI_CODING_AGENT_ENTRY, - options.piCodingAgentEntry, + options.piCodingAgentEntry === null + ? undefined + : (options.piCodingAgentEntry ?? + "/host/pi-coding-agent/dist/index.js"), ); assert.equal(spawnOptions.shell, false); assert.equal(spawnOptions.stdio, "inherit"); @@ -113,7 +116,10 @@ function harness( activeSigint--; }; }, - resolvePiCodingAgentEntry: () => options.piCodingAgentEntry, + resolvePiCodingAgentEntry: () => + options.piCodingAgentEntry === null + ? undefined + : (options.piCodingAgentEntry ?? "/host/pi-coding-agent/dist/index.js"), shutdownTimeoutMs: 20, }; @@ -212,10 +218,9 @@ test("/web hands the terminal to the exact packaged Web CLI and restores Pi", as } }); -test("/web hands the child the resolved Pi entry and drops a stale one", async () => { +test("/web hands the host Pi entry to the child and fail-closes without one", async () => { const previousEntry = process.env.OPENPI_PI_CODING_AGENT_ENTRY; - process.env.OPENPI_PI_CODING_AGENT_ENTRY = - "/stale/pi-coding-agent/dist/index.js"; + process.env.OPENPI_PI_CODING_AGENT_ENTRY = "/stale/not-a-pi-package.js"; const resolvedEntry = "/pi/node_modules/@earendil-works/pi-coding-agent/dist/index.js"; try { @@ -229,14 +234,14 @@ test("/web hands the child the resolved Pi entry and drops a stale one", async ( resolved.children[0]!.close(0); await running; - const unresolved = harness(); - const failed = unresolved.run(); - await new Promise((resolve) => setImmediate(resolve)); - const childEnv = unresolved.spawnEnv(); - assert.ok(childEnv); - assert.equal("OPENPI_PI_CODING_AGENT_ENTRY" in childEnv, false); - unresolved.children[0]!.close(1); - await failed; + const unresolved = harness({ piCodingAgentEntry: null }); + await unresolved.run(); + assert.equal(unresolved.spawnCalls(), 0); + assert.match( + unresolved.notifications.at(-1)?.message ?? "", + /could not resolve @earendil-works\/pi-coding-agent/u, + ); + assert.equal(unresolved.notifications.at(-1)?.level, "error"); } finally { if (previousEntry === undefined) delete process.env.OPENPI_PI_CODING_AGENT_ENTRY; diff --git a/tests/web/cli.test.ts b/tests/web/cli.test.ts index 232bf62f..b160f9fc 100644 --- a/tests/web/cli.test.ts +++ b/tests/web/cli.test.ts @@ -33,6 +33,29 @@ async function copyStandaloneLoader(packageRoot: string) { ); } +async function writeOfficialPeer(root: string, marker = "peer") { + const peerRoot = join( + root, + "node_modules", + "@earendil-works", + "pi-coding-agent", + ); + await mkdir(join(peerRoot, "dist"), { recursive: true }); + await writeFile( + join(peerRoot, "package.json"), + JSON.stringify({ + name: "@earendil-works/pi-coding-agent", + type: "module", + exports: { ".": { import: "./dist/index.js" } }, + }), + ); + await writeFile( + join(peerRoot, "dist", "index.js"), + `export const PI_ENTRY_STUB = ${JSON.stringify(marker)};\n`, + ); + return peerRoot; +} + test("openpi is an executable standalone Web entrypoint", async () => { if (process.platform !== "win32") { const info = await stat(entrypoint); @@ -55,6 +78,7 @@ test("installed CLI loads TypeScript Web modules through its package loader", as await mkdir(join(packageRoot, "web", "host"), { recursive: true }); await mkdir(join(packageRoot, "web", "runtime"), { recursive: true }); await copyStandaloneLoader(packageRoot); + await writeOfficialPeer(temporaryRoot); await cp( staticAssetsPath, join(packageRoot, "web", "host", "static-assets.ts"), @@ -256,7 +280,17 @@ test("installed CLI aliases the Pi peer package to the handed-over entry", async join(packageRoot, "package.json"), JSON.stringify({ type: "module" }), ); - const stubEntry = join(packageRoot, "pi-entry-stub.js"); + const handedRoot = join(temporaryRoot, "handed-pi"); + await mkdir(join(handedRoot, "dist"), { recursive: true }); + await writeFile( + join(handedRoot, "package.json"), + JSON.stringify({ + name: "@earendil-works/pi-coding-agent", + type: "module", + exports: { ".": { import: "./dist/index.js" } }, + }), + ); + const stubEntry = join(handedRoot, "dist", "index.js"); await writeFile(stubEntry, 'export const PI_ENTRY_STUB = "handed-over";\n'); await writeFile( join(packageRoot, "web", "host", "browser-launcher.ts"), @@ -320,48 +354,53 @@ export class PiWebRuntime { } }); -test("installed CLI resolves the Pi peer from PATH without a pre-seeded entry", async () => { +test("installed CLI resolves its own official-export peer and fail-closes without one", async () => { const temporaryRoot = await mkdtemp(join(process.cwd(), ".openpi-cli-test-")); const packageRoot = join(temporaryRoot, "node_modules", "@tt-a1i", "openpi"); - const shadowPeer = join( + const peerRoot = join( temporaryRoot, "node_modules", "@earendil-works", "pi-coding-agent", ); - const piRoot = join(temporaryRoot, "fake-pi"); + const otherPi = join(temporaryRoot, "other-pi"); try { await mkdir(join(packageRoot, "bin"), { recursive: true }); await mkdir(join(packageRoot, "web", "host"), { recursive: true }); await mkdir(join(packageRoot, "web", "runtime"), { recursive: true }); - await mkdir(shadowPeer, { recursive: true }); - await mkdir(join(piRoot, "dist", "bundle"), { recursive: true }); - await mkdir(join(piRoot, "bin"), { recursive: true }); + await mkdir(join(peerRoot, "dist"), { recursive: true }); + await mkdir(join(otherPi, "dist"), { recursive: true }); + await mkdir(join(otherPi, "bin"), { recursive: true }); await copyStandaloneLoader(packageRoot); await writeFile( - join(shadowPeer, "package.json"), + join(packageRoot, "package.json"), + JSON.stringify({ type: "module" }), + ); + await writeFile( + join(peerRoot, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", type: "module", + exports: { ".": { import: "./dist/index.js" } }, }), ); await writeFile( - join(packageRoot, "package.json"), - JSON.stringify({ type: "module" }), + join(peerRoot, "dist", "index.js"), + 'export const PI_ENTRY_STUB = "install-peer";\n', ); await writeFile( - join(piRoot, "package.json"), + join(otherPi, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", type: "module", + exports: { ".": { import: "./dist/index.js" } }, }), ); await writeFile( - join(piRoot, "dist", "index.js"), - 'export const PI_ENTRY_STUB = "path-resolved";\n', + join(otherPi, "dist", "index.js"), + 'export const PI_ENTRY_STUB = "path-pi";\n', ); - await writeFile(join(piRoot, "dist", "bundle", "cli.js"), ""); - await writeFile(join(piRoot, "bin", "pi"), "#!/usr/bin/env node\n"); + await writeFile(join(otherPi, "bin", "pi"), "#!/usr/bin/env node\n"); await writeFile( join(packageRoot, "web", "host", "browser-launcher.ts"), "export async function openBrowser(): Promise { return false; }\n", @@ -414,13 +453,49 @@ export class PiWebRuntime { { env: { ...childEnv, - PATH: join(piRoot, "bin"), + PATH: join(otherPi, "bin"), OPENPI_CLI_PI_ENTRY_MARKER: entryMarker, }, }, ); assert.match(stdout, /ready http:\/\/127\.0\.0\.1:12347/u); - assert.equal(await readFile(entryMarker, "utf8"), "path-resolved"); + assert.equal(await readFile(entryMarker, "utf8"), "install-peer"); + + const isolatedRoot = await mkdtemp( + join(process.cwd(), ".openpi-cli-isolated-"), + ); + await mkdir(join(isolatedRoot, "bin"), { recursive: true }); + await mkdir(join(isolatedRoot, "web", "host"), { recursive: true }); + await copyStandaloneLoader(isolatedRoot); + await writeFile( + join(isolatedRoot, "package.json"), + JSON.stringify({ type: "module" }), + ); + try { + await execFileAsync( + process.execPath, + [ + join(isolatedRoot, "bin", "openpi.js"), + "web", + "--no-workspace", + "--no-open", + ], + { + env: { + ...childEnv, + PATH: join(otherPi, "bin"), + }, + }, + ); + assert.fail("missing peer must fail closed"); + } catch (error) { + assert.match( + String((error as { stderr?: string }).stderr), + /could not resolve @earendil-works\/pi-coding-agent/u, + ); + } finally { + await rm(isolatedRoot, { recursive: true, force: true }); + } } finally { await rm(temporaryRoot, { recursive: true, force: true }); } diff --git a/tests/web/pi-coding-agent-entry.test.ts b/tests/web/pi-coding-agent-entry.test.ts index 50781b73..bc4f90e9 100644 --- a/tests/web/pi-coding-agent-entry.test.ts +++ b/tests/web/pi-coding-agent-entry.test.ts @@ -1,131 +1,235 @@ import assert from "node:assert/strict"; -import { realpathSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import test from "node:test"; import { + missingPiCodingAgentDiagnostic, PI_CODING_AGENT_ENTRY_ENV, PI_CODING_AGENT_PACKAGE, - PI_SERVER_PACKAGE, resolvePiCodingAgentEntry, resolveStandaloneJitiAliases, + validatePiCodingAgentEntry, } from "../../web/host/pi-coding-agent-entry.ts"; +const OFFICIAL_0_84_1_EXPORTS = { + ".": { + types: "./dist/index.d.ts", + import: "./dist/index.js", + }, + "./rpc-entry": { import: "./dist/rpc-entry.js" }, + "./client": { + types: "./dist/client/index.d.ts", + import: "./dist/client/index.js", + }, +} as const; + +const HOST_0_85_EXPORTS = { + ...OFFICIAL_0_84_1_EXPORTS, + "./unix": { import: "./dist/unix.js" }, +} as const; + +async function writePiPackage( + root: string, + options: { + marker: string; + exports?: Record; + }, +) { + await mkdir(join(root, "dist"), { recursive: true }); + await writeFile( + join(root, "package.json"), + JSON.stringify({ + name: PI_CODING_AGENT_PACKAGE, + type: "module", + exports: options.exports ?? OFFICIAL_0_84_1_EXPORTS, + }), + ); + const entry = join(root, "dist", "index.js"); + await writeFile( + entry, + `export const PI_ENTRY_STUB = ${JSON.stringify(options.marker)};\n`, + ); + await writeFile(join(root, "dist", "cli.js"), "#!/usr/bin/env node\n"); + return { root, entry, cli: join(root, "dist", "cli.js") }; +} + async function isolatedLayout() { const root = await mkdtemp(join(tmpdir(), "openpi-pi-entry-")); const caller = join(root, "unrelated", "caller.js"); - const piRoot = join(root, "fake-pi"); await mkdir(join(root, "unrelated"), { recursive: true }); - await mkdir(join(piRoot, "dist", "bundle"), { recursive: true }); - await mkdir(join(piRoot, "bin"), { recursive: true }); await writeFile(caller, ""); + const host = await writePiPackage(join(root, "host-pi"), { + marker: "host-0.85", + exports: HOST_0_85_EXPORTS, + }); + const peerRoot = join( + root, + "openpi", + "node_modules", + "@earendil-works", + "pi-coding-agent", + ); + const peer = await writePiPackage(peerRoot, { + marker: "local-0.84.1", + exports: OFFICIAL_0_84_1_EXPORTS, + }); + const openpiFile = join(root, "openpi", "bin", "openpi.js"); + await mkdir(join(root, "openpi", "bin"), { recursive: true }); await writeFile( - join(piRoot, "package.json"), - JSON.stringify({ - name: "@earendil-works/pi-coding-agent", - type: "module", - }), + join(root, "openpi", "package.json"), + JSON.stringify({ name: "@tt-a1i/openpi", type: "module" }), ); - const entry = join(piRoot, "dist", "index.js"); - await writeFile(entry, "export {}\n"); - await writeFile(join(piRoot, "dist", "bundle", "cli.js"), ""); - await writeFile(join(piRoot, "bin", "pi"), "#!/usr/bin/env node\n"); + await writeFile(openpiFile, ""); return { root, caller, - fromUrl: pathToFileURL(caller).href, - piRoot, - entry, - piBin: join(piRoot, "bin", "pi"), - binDir: join(piRoot, "bin"), + fromUrl: pathToFileURL(openpiFile).href, + isolatedFromUrl: pathToFileURL(caller).href, + host, + peer, + openpiFile, }; } -test("resolver prefers an existing handed-over entry over PATH", async () => { +test("validated handoff must be the official package entry, not any existing file", async () => { const layout = await isolatedLayout(); - const handed = join(layout.root, "handed.js"); + const junk = join(layout.root, "random.js"); try { - await writeFile(handed, "export {}\n"); + await writeFile(junk, "export {}\n"); + assert.equal(validatePiCodingAgentEntry(junk), undefined); + assert.equal( + resolvePiCodingAgentEntry({ + source: "host", + env: { [PI_CODING_AGENT_ENTRY_ENV]: junk }, + argv1: layout.host.cli, + fromUrl: layout.fromUrl, + }), + realpathSync(layout.host.entry), + ); assert.equal( resolvePiCodingAgentEntry({ - env: { [PI_CODING_AGENT_ENTRY_ENV]: handed }, - argv1: layout.piBin, + source: "host", + env: { [PI_CODING_AGENT_ENTRY_ENV]: layout.host.cli }, + argv1: layout.peer.cli, fromUrl: layout.fromUrl, - path: layout.binDir, }), - handed, + realpathSync(layout.host.entry), ); } finally { await rm(layout.root, { recursive: true, force: true }); } }); -test("resolver ignores a stale handed-over entry and walks the Pi launcher", async () => { +test("host source prefers argv over a local OpenPI peer", async () => { const layout = await isolatedLayout(); try { assert.equal( resolvePiCodingAgentEntry({ - env: { [PI_CODING_AGENT_ENTRY_ENV]: join(layout.root, "missing.js") }, - argv1: layout.piBin, + source: "host", + env: {}, + argv1: layout.host.cli, fromUrl: layout.fromUrl, - path: "", }), - realpathSync(layout.entry), + realpathSync(layout.host.entry), ); } finally { await rm(layout.root, { recursive: true, force: true }); } }); -test("resolver walks PATH when the CLI is not launched from Pi", async () => { +test("standalone source uses the install peer and official 0.84.1 exports", async () => { const layout = await isolatedLayout(); try { assert.equal( resolvePiCodingAgentEntry({ + source: "standalone", env: {}, - argv1: layout.caller, + argv1: layout.host.cli, fromUrl: layout.fromUrl, - path: layout.binDir, }), - realpathSync(layout.entry), + realpathSync(layout.peer.entry), ); + const aliases = resolveStandaloneJitiAliases({ + env: {}, + fromUrl: layout.fromUrl, + }); + assert.deepEqual(aliases, { + [PI_CODING_AGENT_PACKAGE]: realpathSync(layout.peer.entry), + }); } finally { await rm(layout.root, { recursive: true, force: true }); } }); -test("standalone aliases keep OpenPI pi-server when Pi is resolved from PATH", async () => { - const layout = await isolatedLayout(); +test("standalone does not inherit an ancestor tree peer", async () => { + const root = await mkdtemp(join(tmpdir(), "openpi-pi-entry-")); try { - const aliases = resolveStandaloneJitiAliases({ - env: {}, - argv1: layout.caller, - fromUrl: import.meta.url, - path: layout.binDir, - }); - assert.equal(aliases[PI_CODING_AGENT_PACKAGE], realpathSync(layout.entry)); - assert.match(aliases[PI_SERVER_PACKAGE] ?? "", /pi-server/u); - assert.match(aliases[`${PI_SERVER_PACKAGE}/unix`] ?? "", /pi-server/u); + const ancestor = await writePiPackage( + join(root, "node_modules", "@earendil-works", "pi-coding-agent"), + { marker: "ancestor" }, + ); + const isolatedFile = join(root, "isolated-openpi", "bin", "openpi.js"); + await mkdir(join(root, "isolated-openpi", "bin"), { recursive: true }); + await writeFile( + join(root, "isolated-openpi", "package.json"), + JSON.stringify({ name: "@tt-a1i/openpi", type: "module" }), + ); + await writeFile(isolatedFile, ""); + assert.equal( + resolvePiCodingAgentEntry({ + source: "standalone", + env: {}, + argv1: ancestor.cli, + fromUrl: pathToFileURL(isolatedFile).href, + }), + undefined, + ); } finally { - await rm(layout.root, { recursive: true, force: true }); + await rm(root, { recursive: true, force: true }); } }); -test("resolver fail-softs when no Pi install is reachable", async () => { +test("standalone fail-closes without a peer and does not walk PATH", async () => { const layout = await isolatedLayout(); try { assert.equal( resolvePiCodingAgentEntry({ + source: "standalone", env: {}, - argv1: layout.caller, - fromUrl: layout.fromUrl, - path: "", + argv1: layout.host.cli, + fromUrl: layout.isolatedFromUrl, }), undefined, ); + assert.match( + missingPiCodingAgentDiagnostic(), + /pi install npm:@tt-a1i\/openpi/u, + ); } finally { await rm(layout.root, { recursive: true, force: true }); } }); + +test("real checkout 0.84.1 exports resolve from this install", () => { + const aliases = resolveStandaloneJitiAliases({ + env: {}, + fromUrl: new URL("../../bin/openpi.js", import.meta.url).href, + }); + const entry = aliases[PI_CODING_AGENT_PACKAGE]; + assert.ok(entry); + assert.match(entry, /@earendil-works\/pi-coding-agent\/dist\/index\.js$/u); + const manifest = JSON.parse( + readFileSync( + new URL( + "../../node_modules/@earendil-works/pi-coding-agent/package.json", + import.meta.url, + ), + "utf8", + ), + ) as { version?: string; exports?: { "."?: { import?: string } } }; + assert.equal(manifest.version, "0.84.1"); + assert.equal(manifest.exports?.["."]?.import, "./dist/index.js"); +}); diff --git a/web/host/pi-coding-agent-entry.ts b/web/host/pi-coding-agent-entry.ts index f0cca836..157e45e2 100644 --- a/web/host/pi-coding-agent-entry.ts +++ b/web/host/pi-coding-agent-entry.ts @@ -1,22 +1,24 @@ -import { existsSync, readFileSync, realpathSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; export const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent"; -export const PI_SERVER_PACKAGE = "@earendil-works/pi-server"; export const PI_CODING_AGENT_ENTRY_ENV = "OPENPI_PI_CODING_AGENT_ENTRY"; const PACKAGE_ROOT_SEARCH_DEPTH = 10; +type PackageManifest = { + name?: unknown; + main?: unknown; + exports?: Record; +}; + export function findPackageRoot(realPath: string, packageName: string) { let dir = dirname(realPath); for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) { const manifestPath = join(dir, "package.json"); if (existsSync(manifestPath)) { - const manifest: { name?: unknown } = JSON.parse( - readFileSync(manifestPath, "utf8"), - ); - if (manifest.name === packageName) return dir; + const manifest = readManifest(manifestPath); + if (manifest?.name === packageName) return dir; } const parent = dirname(dir); if (parent === dir) break; @@ -25,134 +27,135 @@ export function findPackageRoot(realPath: string, packageName: string) { return undefined; } -function packageEntry(root: string | undefined) { - if (!root) return undefined; - const entry = join(root, "dist", "index.js"); - return existsSync(entry) ? entry : undefined; -} - -function walkFromFile(file: string) { +function readManifest(manifestPath: string) { try { - return packageEntry( - findPackageRoot(realpathSync(file), PI_CODING_AGENT_PACKAGE), - ); + return JSON.parse(readFileSync(manifestPath, "utf8")) as PackageManifest; } catch { return undefined; } } -function resolveFromNode(fromUrl: string) { +function officialEntry(root: string | undefined) { + if (!root) return undefined; + const manifest = readManifest(join(root, "package.json")); + const target = manifest?.exports?.["."]; + const relative = + typeof target === "string" + ? target + : typeof target?.import === "string" + ? target.import + : typeof manifest?.main === "string" + ? manifest.main + : "dist/index.js"; + const entry = join(root, relative); try { - const manifest = createRequire(fromUrl).resolve( - `${PI_CODING_AGENT_PACKAGE}/package.json`, - ); - return packageEntry(dirname(manifest)); + return existsSync(entry) && statSync(entry).isFile() + ? realpathSync(entry) + : undefined; } catch { return undefined; } } -function resolveFromPath(pathValue: string | undefined) { - if (!pathValue) return undefined; - const delimiter = process.platform === "win32" ? ";" : ":"; - const names = - process.platform === "win32" ? ["pi.cmd", "pi.exe", "pi"] : ["pi"]; - for (const dir of pathValue.split(delimiter)) { - if (!dir) continue; - for (const name of names) { - const candidate = join(dir, name); - if (!existsSync(candidate)) continue; - const entry = walkFromFile(candidate); - if (entry) return entry; - } - } - return undefined; -} - -export function resolvePiCodingAgentEntry(options?: { - env?: NodeJS.ProcessEnv; - argv1?: string | undefined; - fromUrl?: string; - path?: string; -}) { - const env = options?.env ?? process.env; - const handed = env[PI_CODING_AGENT_ENTRY_ENV]; - if (handed && existsSync(handed)) return handed; - - const fromNode = resolveFromNode(options?.fromUrl ?? import.meta.url); - if (fromNode) return fromNode; - - const argv1 = options?.argv1 === undefined ? process.argv[1] : options.argv1; - if (argv1) { - const fromArgv = walkFromFile(argv1); - if (fromArgv) return fromArgv; +function walkFromFile(file: string) { + try { + const real = realpathSync(file); + if (!statSync(real).isFile()) return undefined; + return officialEntry(findPackageRoot(real, PI_CODING_AGENT_PACKAGE)); + } catch { + return undefined; } - - return resolveFromPath(options?.path ?? env.PATH ?? env.Path); } function fileFromUrl(fromUrl: string) { return fromUrl.startsWith("file:") ? fileURLToPath(fromUrl) : fromUrl; } -function findDependencyManifest(fromUrl: string, packageName: string) { - let dir = dirname(fileFromUrl(fromUrl)); +function nearestPackageRoot(file: string) { + let dir = dirname(file); for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) { - const manifestPath = join( - dir, - "node_modules", - ...packageName.split("/"), - "package.json", - ); - if (existsSync(manifestPath)) { - return { - root: dirname(manifestPath), - manifest: JSON.parse(readFileSync(manifestPath, "utf8")) as { - main?: unknown; - exports?: Record; - }, - }; - } + if (existsSync(join(dir, "package.json"))) return dir; const parent = dirname(dir); if (parent === dir) break; dir = parent; } - return undefined; } -function exportEntry( - resolved: ReturnType, - subpath: string, -) { - if (!resolved) return undefined; - const target = resolved.manifest.exports?.[subpath]; - const relative = - typeof target === "string" - ? target - : typeof target?.import === "string" - ? target.import - : subpath === "." && typeof resolved.manifest.main === "string" - ? resolved.manifest.main - : undefined; - if (!relative) return undefined; - const entry = join(resolved.root, relative); - return existsSync(entry) ? entry : undefined; +function peerAt(nodeModules: string) { + const root = join(nodeModules, ...PI_CODING_AGENT_PACKAGE.split("/")); + const manifest = readManifest(join(root, "package.json")); + return manifest?.name === PI_CODING_AGENT_PACKAGE + ? officialEntry(root) + : undefined; +} + +function resolveFromInstall(fromUrl: string) { + let start = fileFromUrl(fromUrl); + try { + start = realpathSync(start); + } catch { + // Keep the unresolved path when the caller file is a test stub. + } + const packageRoot = nearestPackageRoot(start); + if (!packageRoot) return undefined; + + const nested = peerAt(join(packageRoot, "node_modules")); + if (nested) return nested; + + const parent = dirname(packageRoot); + const grandparent = dirname(parent); + const hoistedModules = + basename(parent).startsWith("@") && basename(grandparent) === "node_modules" + ? grandparent + : basename(parent) === "node_modules" + ? parent + : undefined; + return hoistedModules ? peerAt(hoistedModules) : undefined; +} + +export function validatePiCodingAgentEntry(candidate: string | undefined) { + if (!candidate) return undefined; + return walkFromFile(candidate); +} + +export function missingPiCodingAgentDiagnostic() { + return [ + `OpenPI Web could not resolve ${PI_CODING_AGENT_PACKAGE} for this process.`, + "From a running Pi session use /web, which hands over the host Pi.", + `Standalone openpi web needs that peer installed next to this package (npm install ${PI_CODING_AGENT_PACKAGE}).`, + "Supported package install is `pi install npm:@tt-a1i/openpi`.", + ].join(" "); +} + +export function resolvePiCodingAgentEntry(options?: { + source?: "host" | "standalone"; + env?: NodeJS.ProcessEnv; + argv1?: string | undefined; + fromUrl?: string; +}) { + const env = options?.env ?? process.env; + const handed = validatePiCodingAgentEntry(env[PI_CODING_AGENT_ENTRY_ENV]); + if (handed) return handed; + + const source = options?.source ?? "host"; + if (source === "standalone") { + return resolveFromInstall(options?.fromUrl ?? import.meta.url); + } + + const argv1 = options?.argv1 === undefined ? process.argv[1] : options.argv1; + return argv1 ? walkFromFile(argv1) : undefined; } export function resolveStandaloneJitiAliases(options?: { env?: NodeJS.ProcessEnv; argv1?: string | undefined; fromUrl?: string; - path?: string; }) { const fromUrl = options?.fromUrl ?? import.meta.url; - const aliases: Record = {}; - const entry = resolvePiCodingAgentEntry({ ...options, fromUrl }); - if (entry) aliases[PI_CODING_AGENT_PACKAGE] = entry; - const server = findDependencyManifest(fromUrl, PI_SERVER_PACKAGE); - const serverEntry = exportEntry(server, "."); - const unixEntry = exportEntry(server, "./unix"); - if (serverEntry) aliases[PI_SERVER_PACKAGE] = serverEntry; - if (unixEntry) aliases[`${PI_SERVER_PACKAGE}/unix`] = unixEntry; - return aliases; + const entry = resolvePiCodingAgentEntry({ + ...options, + fromUrl, + source: "standalone", + }); + return entry ? { [PI_CODING_AGENT_PACKAGE]: entry } : {}; } From 84bd5b29d48ac7202e2eaaf0c6ab69790c781b3c Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 5 Sep 2026 11:17:23 +0800 Subject: [PATCH 4/5] fix(web): keep host Pi identity on current-process argv Host resolution no longer accepts a leftover OPENPI_PI_CODING_AGENT_ENTRY from another Pi. Standalone still uses a validated explicit handoff, then its own peer. /web resolves that entry once and passes the exact path to the child. --- extensions/web/index.ts | 10 ++-- tests/extensions/web/index.test.ts | 13 +++- tests/web/pi-coding-agent-entry.test.ts | 79 +++++++++++++++++++++++++ web/host/pi-coding-agent-entry.ts | 11 ++-- 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/extensions/web/index.ts b/extensions/web/index.ts index 0297cc26..59066cfa 100644 --- a/extensions/web/index.ts +++ b/extensions/web/index.ts @@ -111,6 +111,7 @@ function runWebInForeground( dependencies: WebCommandDependencies, setActive: (active: ActiveWebProcess | undefined) => void, isShuttingDown: () => boolean, + piCodingAgentEntry: string, ) { return ctx.ui.custom((tui, _theme, _keybindings, done) => { let finished = false; @@ -144,10 +145,7 @@ function runWebInForeground( [dependencies.entrypoint, "web", "--no-workspace"], { cwd: childCwd, - env: webProcessEnvironment( - childCwd, - dependencies.resolvePiCodingAgentEntry(), - ), + env: webProcessEnvironment(childCwd, piCodingAgentEntry), shell: false, stdio: "inherit", }, @@ -210,7 +208,8 @@ export default function web( ctx.ui.notify("OpenPI Web Workbench is already running.", "warning"); return; } - if (!dependencies.resolvePiCodingAgentEntry()) { + const piCodingAgentEntry = dependencies.resolvePiCodingAgentEntry(); + if (!piCodingAgentEntry) { ctx.ui.notify(missingPiCodingAgentDiagnostic(), "error"); return; } @@ -224,6 +223,7 @@ export default function web( active = next; }, () => shuttingDown, + piCodingAgentEntry, ); if (shuttingDown) return; if (result.kind === "error") { diff --git a/tests/extensions/web/index.test.ts b/tests/extensions/web/index.test.ts index e84e5d87..e40d18d5 100644 --- a/tests/extensions/web/index.test.ts +++ b/tests/extensions/web/index.test.ts @@ -57,6 +57,7 @@ function harness( let started = 0; let rendered = 0; let spawnCalls = 0; + let resolveCalls = 0; let activeSigint = 0; let clearCalls = 0; const notifications: Array<{ message: string; level?: string }> = []; @@ -116,10 +117,12 @@ function harness( activeSigint--; }; }, - resolvePiCodingAgentEntry: () => - options.piCodingAgentEntry === null + resolvePiCodingAgentEntry: () => { + resolveCalls++; + return options.piCodingAgentEntry === null ? undefined - : (options.piCodingAgentEntry ?? "/host/pi-coding-agent/dist/index.js"), + : (options.piCodingAgentEntry ?? "/host/pi-coding-agent/dist/index.js"); + }, shutdownTimeoutMs: 20, }; @@ -179,6 +182,7 @@ function harness( started: () => started, rendered: () => rendered, spawnCalls: () => spawnCalls, + resolveCalls: () => resolveCalls, activeSigint: () => activeSigint, clearCalls: () => clearCalls, }; @@ -195,6 +199,7 @@ test("/web hands the terminal to the exact packaged Web CLI and restores Pi", as await new Promise((resolve) => setImmediate(resolve)); assert.equal(h.spawnCalls(), 1); + assert.equal(h.resolveCalls(), 1); assert.equal(h.stopped(), 1); assert.equal(h.clearCalls(), 1); assert.equal(h.activeSigint(), 1); @@ -227,6 +232,7 @@ test("/web hands the host Pi entry to the child and fail-closes without one", as const resolved = harness({ piCodingAgentEntry: resolvedEntry }); const running = resolved.run(); await new Promise((resolve) => setImmediate(resolve)); + assert.equal(resolved.resolveCalls(), 1); assert.equal( resolved.spawnEnv()?.OPENPI_PI_CODING_AGENT_ENTRY, resolvedEntry, @@ -237,6 +243,7 @@ test("/web hands the host Pi entry to the child and fail-closes without one", as const unresolved = harness({ piCodingAgentEntry: null }); await unresolved.run(); assert.equal(unresolved.spawnCalls(), 0); + assert.equal(unresolved.resolveCalls(), 1); assert.match( unresolved.notifications.at(-1)?.message ?? "", /could not resolve @earendil-works\/pi-coding-agent/u, diff --git a/tests/web/pi-coding-agent-entry.test.ts b/tests/web/pi-coding-agent-entry.test.ts index bc4f90e9..adf0395a 100644 --- a/tests/web/pi-coding-agent-entry.test.ts +++ b/tests/web/pi-coding-agent-entry.test.ts @@ -109,6 +109,23 @@ test("validated handoff must be the official package entry, not any existing fil }), realpathSync(layout.host.entry), ); + assert.equal( + resolvePiCodingAgentEntry({ + source: "standalone", + env: { [PI_CODING_AGENT_ENTRY_ENV]: junk }, + argv1: layout.host.cli, + fromUrl: layout.fromUrl, + }), + realpathSync(layout.peer.entry), + ); + } finally { + await rm(layout.root, { recursive: true, force: true }); + } +}); + +test("host uses argv Pi B even when env hands a valid Pi A", async () => { + const layout = await isolatedLayout(); + try { assert.equal( resolvePiCodingAgentEntry({ source: "host", @@ -116,6 +133,23 @@ test("validated handoff must be the official package entry, not any existing fil argv1: layout.peer.cli, fromUrl: layout.fromUrl, }), + realpathSync(layout.peer.entry), + ); + } finally { + await rm(layout.root, { recursive: true, force: true }); + } +}); + +test("standalone uses a validated explicit handoff before its own peer", async () => { + const layout = await isolatedLayout(); + try { + assert.equal( + resolvePiCodingAgentEntry({ + source: "standalone", + env: { [PI_CODING_AGENT_ENTRY_ENV]: layout.host.cli }, + argv1: layout.peer.cli, + fromUrl: layout.fromUrl, + }), realpathSync(layout.host.entry), ); } finally { @@ -123,6 +157,43 @@ test("validated handoff must be the official package entry, not any existing fil } }); +test("invalid handoff is ignored: host fail-closes without argv, standalone uses own peer", async () => { + const layout = await isolatedLayout(); + const junk = join(layout.root, "random.js"); + try { + await writeFile(junk, "export {}\n"); + assert.equal( + resolvePiCodingAgentEntry({ + source: "host", + env: { [PI_CODING_AGENT_ENTRY_ENV]: junk }, + argv1: "", + fromUrl: layout.fromUrl, + }), + undefined, + ); + assert.equal( + resolvePiCodingAgentEntry({ + source: "host", + env: { [PI_CODING_AGENT_ENTRY_ENV]: layout.host.cli }, + argv1: junk, + fromUrl: layout.fromUrl, + }), + undefined, + ); + assert.equal( + resolvePiCodingAgentEntry({ + source: "standalone", + env: { [PI_CODING_AGENT_ENTRY_ENV]: junk }, + argv1: layout.host.cli, + fromUrl: layout.fromUrl, + }), + realpathSync(layout.peer.entry), + ); + } finally { + await rm(layout.root, { recursive: true, force: true }); + } +}); + test("host source prefers argv over a local OpenPI peer", async () => { const layout = await isolatedLayout(); try { @@ -208,6 +279,14 @@ test("standalone fail-closes without a peer and does not walk PATH", async () => missingPiCodingAgentDiagnostic(), /pi install npm:@tt-a1i\/openpi/u, ); + assert.match( + missingPiCodingAgentDiagnostic(), + /current process argv identity/u, + ); + assert.match( + missingPiCodingAgentDiagnostic(), + /explicit standalone handoff, not a host fallback/u, + ); } finally { await rm(layout.root, { recursive: true, force: true }); } diff --git a/web/host/pi-coding-agent-entry.ts b/web/host/pi-coding-agent-entry.ts index 157e45e2..61e8fcc4 100644 --- a/web/host/pi-coding-agent-entry.ts +++ b/web/host/pi-coding-agent-entry.ts @@ -121,8 +121,10 @@ export function validatePiCodingAgentEntry(candidate: string | undefined) { export function missingPiCodingAgentDiagnostic() { return [ `OpenPI Web could not resolve ${PI_CODING_AGENT_PACKAGE} for this process.`, + "Host resolution uses only the current process argv identity and fail-closes if that path is not the official package.", + `${PI_CODING_AGENT_ENTRY_ENV} is an explicit standalone handoff, not a host fallback.`, + `Standalone openpi web uses that handoff when valid, then the installed nested or hoisted peer (npm install ${PI_CODING_AGENT_PACKAGE}).`, "From a running Pi session use /web, which hands over the host Pi.", - `Standalone openpi web needs that peer installed next to this package (npm install ${PI_CODING_AGENT_PACKAGE}).`, "Supported package install is `pi install npm:@tt-a1i/openpi`.", ].join(" "); } @@ -133,12 +135,11 @@ export function resolvePiCodingAgentEntry(options?: { argv1?: string | undefined; fromUrl?: string; }) { - const env = options?.env ?? process.env; - const handed = validatePiCodingAgentEntry(env[PI_CODING_AGENT_ENTRY_ENV]); - if (handed) return handed; - const source = options?.source ?? "host"; if (source === "standalone") { + const env = options?.env ?? process.env; + const handed = validatePiCodingAgentEntry(env[PI_CODING_AGENT_ENTRY_ENV]); + if (handed) return handed; return resolveFromInstall(options?.fromUrl ?? import.meta.url); } From 2e8d0f3d6e35282578b404573db12172722ca182 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 5 Sep 2026 11:55:33 +0800 Subject: [PATCH 5/5] test(web): compare official Pi entry paths without POSIX slashes Windows CI resolved the official dist/index.js but the suffix regex required forward slashes. Keep the exact install entry and official package tail, and lock the failing Windows path. --- tests/web/pi-coding-agent-entry.test.ts | 26 +++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/web/pi-coding-agent-entry.test.ts b/tests/web/pi-coding-agent-entry.test.ts index adf0395a..bccb73fb 100644 --- a/tests/web/pi-coding-agent-entry.test.ts +++ b/tests/web/pi-coding-agent-entry.test.ts @@ -3,7 +3,7 @@ import { readFileSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; import { missingPiCodingAgentDiagnostic, @@ -31,6 +31,10 @@ const HOST_0_85_EXPORTS = { "./unix": { import: "./dist/unix.js" }, } as const; +function officialPackageEntryTail(path: string) { + return path.split(/[\\/]/u).slice(-4).join("/"); +} + async function writePiPackage( root: string, options: { @@ -299,7 +303,25 @@ test("real checkout 0.84.1 exports resolve from this install", () => { }); const entry = aliases[PI_CODING_AGENT_PACKAGE]; assert.ok(entry); - assert.match(entry, /@earendil-works\/pi-coding-agent\/dist\/index\.js$/u); + const officialEntry = realpathSync( + fileURLToPath( + new URL( + "../../node_modules/@earendil-works/pi-coding-agent/dist/index.js", + import.meta.url, + ), + ), + ); + assert.equal(entry, officialEntry); + assert.equal( + officialPackageEntryTail(entry), + "@earendil-works/pi-coding-agent/dist/index.js", + ); + assert.equal( + officialPackageEntryTail( + "D:\\a\\openpi\\openpi\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\index.js", + ), + "@earendil-works/pi-coding-agent/dist/index.js", + ); const manifest = JSON.parse( readFileSync( new URL(