diff --git a/bin/openpi.js b/bin/openpi.js index 5c5eeaf8..341e7434 100755 --- a/bin/openpi.js +++ b/bin/openpi.js @@ -67,7 +67,17 @@ const stop = () => { }; try { - const jiti = createJiti(import.meta.url); + const bootstrap = createJiti(import.meta.url); + const { missingPiCodingAgentDiagnostic, resolveStandaloneJitiAliases } = + await bootstrap.import("../web/host/pi-coding-agent-entry.ts"); + const aliases = resolveStandaloneJitiAliases({ + fromUrl: import.meta.url, + }); + 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 58085d29..59066cfa 100644 --- a/extensions/web/index.ts +++ b/extensions/web/index.ts @@ -5,6 +5,11 @@ import type { ExtensionAPI, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; +import { + missingPiCodingAgentDiagnostic, + PI_CODING_AGENT_ENTRY_ENV, + resolvePiCodingAgentEntry, +} from "../../web/host/pi-coding-agent-entry.ts"; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000; @@ -26,12 +31,20 @@ interface SpawnWebOptions { stdio: "inherit"; } -function webProcessEnvironment(cwd: string) { +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 +53,7 @@ export interface WebCommandDependencies { spawn(command: string, args: string[], options: SpawnWebOptions): WebProcess; clearTerminal(): void; holdParentSigint(): () => void; + resolvePiCodingAgentEntry(): string | undefined; shutdownTimeoutMs: number; } @@ -65,6 +79,8 @@ const defaultDependencies: WebCommandDependencies = { process.on("SIGINT", keepPiAlive); return () => process.removeListener("SIGINT", keepPiAlive); }, + resolvePiCodingAgentEntry: () => + resolvePiCodingAgentEntry({ source: "host" }), shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS, }; @@ -95,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; @@ -128,7 +145,7 @@ function runWebInForeground( [dependencies.entrypoint, "web", "--no-workspace"], { cwd: childCwd, - env: webProcessEnvironment(childCwd), + env: webProcessEnvironment(childCwd, piCodingAgentEntry), shell: false, stdio: "inherit", }, @@ -191,6 +208,11 @@ export default function web( ctx.ui.notify("OpenPI Web Workbench is already running.", "warning"); return; } + const piCodingAgentEntry = dependencies.resolvePiCodingAgentEntry(); + if (!piCodingAgentEntry) { + ctx.ui.notify(missingPiCodingAgentDiagnostic(), "error"); + return; + } running = true; try { @@ -201,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 3fd7fde1..e40d18d5 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 | null; + } = {}, ) { const hooks = new Map unknown>>(); let command: CommandHandler | undefined; @@ -52,10 +57,12 @@ 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 }> = []; const children: FakeWebProcess[] = []; + const spawnEnvs: NodeJS.ProcessEnv[] = []; const cwd = "/workspace/current"; const pi = { registerCommand(name: string, definition: { handler: CommandHandler }) { @@ -71,6 +78,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", @@ -87,6 +95,13 @@ function harness( ([name]) => name.toLowerCase() === "path", )?.[1]; assert.equal(childPath, process.env.PATH); + assert.equal( + spawnOptions.env.OPENPI_PI_CODING_AGENT_ENTRY, + options.piCodingAgentEntry === null + ? undefined + : (options.piCodingAgentEntry ?? + "/host/pi-coding-agent/dist/index.js"), + ); assert.equal(spawnOptions.shell, false); assert.equal(spawnOptions.stdio, "inherit"); const child = new FakeWebProcess(); @@ -102,6 +117,12 @@ function harness( activeSigint--; }; }, + resolvePiCodingAgentEntry: () => { + resolveCalls++; + return options.piCodingAgentEntry === null + ? undefined + : (options.piCodingAgentEntry ?? "/host/pi-coding-agent/dist/index.js"); + }, shutdownTimeoutMs: 20, }; @@ -155,11 +176,13 @@ function harness( emit, children, notifications, + spawnEnv: () => spawnEnvs.at(-1), customCalls: () => customCalls, stopped: () => stopped, started: () => started, rendered: () => rendered, spawnCalls: () => spawnCalls, + resolveCalls: () => resolveCalls, activeSigint: () => activeSigint, clearCalls: () => clearCalls, }; @@ -176,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); @@ -199,6 +223,39 @@ test("/web hands the terminal to the exact packaged Web CLI and restores Pi", as } }); +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/not-a-pi-package.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.resolveCalls(), 1); + assert.equal( + resolved.spawnEnv()?.OPENPI_PI_CODING_AGENT_ENTRY, + resolvedEntry, + ); + resolved.children[0]!.close(0); + await running; + + 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, + ); + assert.equal(unresolved.notifications.at(-1)?.level, "error"); + } 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 5e0dae65..8846149e 100644 --- a/tests/web/cli.test.ts +++ b/tests/web/cli.test.ts @@ -86,6 +86,40 @@ 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"), + ); +} + +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") { @@ -123,7 +157,8 @@ 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 writeOfficialPeer(temporaryRoot); await cp( staticAssetsPath, join(packageRoot, "web", "host", "static-assets.ts"), @@ -323,6 +358,239 @@ export class PiWebRuntime { } }); +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 copyStandaloneLoader(packageRoot); + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ type: "module" }), + ); + 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"), + "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 }); + } +}); + +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 peerRoot = join( + temporaryRoot, + "node_modules", + "@earendil-works", + "pi-coding-agent", + ); + 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(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(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(peerRoot, "dist", "index.js"), + 'export const PI_ENTRY_STUB = "install-peer";\n', + ); + await writeFile( + join(otherPi, "package.json"), + JSON.stringify({ + name: "@earendil-works/pi-coding-agent", + type: "module", + exports: { ".": { import: "./dist/index.js" } }, + }), + ); + await writeFile( + join(otherPi, "dist", "index.js"), + 'export const PI_ENTRY_STUB = "path-pi";\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", + ); + 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(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"), "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 }); + } +}); + test("a second SIGTERM uses default termination while stop is in flight", async () => { if (process.platform === "win32") return; const temporaryRoot = await mkdtemp(join(process.cwd(), ".openpi-cli-test-")); @@ -335,7 +603,8 @@ test("a second SIGTERM uses default termination while stop is in flight", 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 writeOfficialPeer(temporaryRoot); await writeFile( join(packageRoot, "package.json"), JSON.stringify({ type: "module" }), 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..bccb73fb --- /dev/null +++ b/tests/web/pi-coding-agent-entry.test.ts @@ -0,0 +1,336 @@ +import assert from "node:assert/strict"; +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 { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; +import { + missingPiCodingAgentDiagnostic, + PI_CODING_AGENT_ENTRY_ENV, + PI_CODING_AGENT_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; + +function officialPackageEntryTail(path: string) { + return path.split(/[\\/]/u).slice(-4).join("/"); +} + +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"); + await mkdir(join(root, "unrelated"), { 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(root, "openpi", "package.json"), + JSON.stringify({ name: "@tt-a1i/openpi", type: "module" }), + ); + await writeFile(openpiFile, ""); + return { + root, + caller, + fromUrl: pathToFileURL(openpiFile).href, + isolatedFromUrl: pathToFileURL(caller).href, + host, + peer, + openpiFile, + }; +} + +test("validated handoff must be the official package entry, not any existing file", async () => { + const layout = await isolatedLayout(); + const junk = join(layout.root, "random.js"); + try { + 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({ + 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", + env: { [PI_CODING_AGENT_ENTRY_ENV]: layout.host.cli }, + 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 { + await rm(layout.root, { recursive: true, force: true }); + } +}); + +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 { + assert.equal( + resolvePiCodingAgentEntry({ + source: "host", + env: {}, + argv1: layout.host.cli, + fromUrl: layout.fromUrl, + }), + realpathSync(layout.host.entry), + ); + } finally { + await rm(layout.root, { recursive: true, force: true }); + } +}); + +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.host.cli, + fromUrl: layout.fromUrl, + }), + 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 does not inherit an ancestor tree peer", async () => { + const root = await mkdtemp(join(tmpdir(), "openpi-pi-entry-")); + try { + 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(root, { recursive: true, force: true }); + } +}); + +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.host.cli, + fromUrl: layout.isolatedFromUrl, + }), + undefined, + ); + assert.match( + 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 }); + } +}); + +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); + 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( + "../../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 new file mode 100644 index 00000000..61e8fcc4 --- /dev/null +++ b/web/host/pi-coding-agent-entry.ts @@ -0,0 +1,162 @@ +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_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 = readManifest(manifestPath); + if (manifest?.name === packageName) return dir; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return undefined; +} + +function readManifest(manifestPath: string) { + try { + return JSON.parse(readFileSync(manifestPath, "utf8")) as PackageManifest; + } catch { + return undefined; + } +} + +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 { + return existsSync(entry) && statSync(entry).isFile() + ? realpathSync(entry) + : undefined; + } catch { + return undefined; + } +} + +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; + } +} + +function fileFromUrl(fromUrl: string) { + return fromUrl.startsWith("file:") ? fileURLToPath(fromUrl) : fromUrl; +} + +function nearestPackageRoot(file: string) { + let dir = dirname(file); + for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) { + if (existsSync(join(dir, "package.json"))) return dir; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } +} + +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.`, + "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.", + "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 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); + } + + 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; +}) { + const fromUrl = options?.fromUrl ?? import.meta.url; + const entry = resolvePiCodingAgentEntry({ + ...options, + fromUrl, + source: "standalone", + }); + return entry ? { [PI_CODING_AGENT_PACKAGE]: entry } : {}; +}