diff --git a/packages/adapters/pi/src/command.ts b/packages/adapters/pi/src/command.ts index 112708acd..14e574138 100644 --- a/packages/adapters/pi/src/command.ts +++ b/packages/adapters/pi/src/command.ts @@ -10,6 +10,9 @@ import { export { withNodeRuntimeOnPath } from "@codexhost/harness-discovery"; +const PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent"; +const PI_PACKAGE_PATH = path.join("node_modules", "@earendil-works", "pi-coding-agent"); + export interface PiExecutableDependencies { platform: NodeJS.Platform; homeDirectory: string; @@ -52,6 +55,64 @@ export function resolvePiExecutable( return resolution?.executable ?? input.command ?? input.environment.PI_COMMAND ?? "pi"; } +/** Find the package backing the executable selected by the Pi Harness resolver. */ +export function findPiPackageDirectory(executable: string): string | undefined { + let directory = path.dirname(executable); + while (true) { + for (const candidate of [directory, path.join(directory, PI_PACKAGE_PATH)]) { + try { + const manifest: unknown = JSON.parse( + fs.readFileSync(path.join(candidate, "package.json"), "utf8"), + ); + if ( + typeof manifest === "object" && + manifest !== null && + "name" in manifest && + manifest.name === PI_PACKAGE_NAME + ) { + return candidate; + } + } catch { + // Keep looking through the executable's install roots and npm shim directories. + } + } + const parent = path.dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +} + +/** Resolve Pi's declared JavaScript CLI entry instead of trying to execute a Windows .cmd shim via Node. */ +export function findPiCliEntry(packageDirectory: string): string | undefined { + try { + const manifest: unknown = JSON.parse( + fs.readFileSync(path.join(packageDirectory, "package.json"), "utf8"), + ); + if (typeof manifest !== "object" || manifest === null || !("bin" in manifest)) return undefined; + const bin = manifest.bin; + const entry = + typeof bin === "string" + ? bin + : typeof bin === "object" && bin !== null && "pi" in bin + ? bin.pi + : undefined; + if (typeof entry !== "string" || !entry.trim()) return undefined; + const resolved = path.resolve(packageDirectory, entry); + const relative = path.relative(packageDirectory, resolved); + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + !fs.statSync(resolved).isFile() + ) { + return undefined; + } + return resolved; + } catch { + return undefined; + } +} + function isFilesystemRoot(cwd: string): boolean { const resolved = path.resolve(cwd); return resolved === path.parse(resolved).root; diff --git a/packages/adapters/pi/src/pi-credential-imports.ts b/packages/adapters/pi/src/pi-credential-imports.ts index ff2e1eb59..bf094527b 100644 --- a/packages/adapters/pi/src/pi-credential-imports.ts +++ b/packages/adapters/pi/src/pi-credential-imports.ts @@ -26,7 +26,7 @@ import { type CredentialImportRecord, type CredentialOtherLogin, } from "@codexhost/shared-contracts"; -import { resolvePiExecutable } from "./command.js"; +import { findPiCliEntry, findPiPackageDirectory, resolvePiExecutable } from "./command.js"; type LockBackend = { withLock(fn: (current: string | undefined) => { result: T; next?: string }): T; @@ -118,18 +118,10 @@ async function installation( const executable = await realpath( resolvePiExecutable({ ...(command ? { command } : {}), environment }), ); - let directory = path.dirname(executable); - while (true) { - const manifest = path.join(directory, "package.json"); - if (existsSync(manifest)) { - const pkg = JSON.parse(readFileSync(manifest, "utf8")); - if (pkg.name === "@earendil-works/pi-coding-agent") break; - } - const parent = path.dirname(directory); - if (parent === directory) - throw new Error("Pi credential import requires a supported npm Pi installation"); - directory = parent; - } + const directory = findPiPackageDirectory(executable); + const cliEntry = directory ? findPiCliEntry(directory) : undefined; + if (!directory || !cliEntry) + throw new Error("Pi credential import requires a supported npm Pi installation"); // Native lock implementation coordinates with Pi's own auth.json writers. const storage = await import( pathToFileURL(path.join(directory, "dist/core/auth-storage.js")).href @@ -158,7 +150,7 @@ async function installation( // This creates no Turn and passes no credential material on the command line. const { stdout } = await promisify(execFile)( process.execPath, - [executable, "--offline", "--no-approve", "--list-models"], + [cliEntry, "--offline", "--no-approve", "--list-models"], { cwd: existsSync(path.dirname(authPath)) ? path.dirname(authPath) : os.homedir(), env: { ...environment, PI_CODING_AGENT_DIR: path.dirname(authPath), PI_OFFLINE: "1" }, diff --git a/packages/adapters/pi/test/command.test.ts b/packages/adapters/pi/test/command.test.ts new file mode 100644 index 000000000..1f0584cb0 --- /dev/null +++ b/packages/adapters/pi/test/command.test.ts @@ -0,0 +1,85 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { findPiCliEntry, findPiPackageDirectory } from "../src/command.js"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function temporaryRoot(): string { + const root = mkdtempSync(path.join(os.tmpdir(), "pi-discovery-test-")); + roots.push(root); + return root; +} + +function writePiPackage(directory: string): void { + mkdirSync(directory, { recursive: true }); + writeFileSync( + path.join(directory, "package.json"), + JSON.stringify({ name: "@earendil-works/pi-coding-agent", bin: { pi: "dist/bundle/cli.js" } }), + ); +} + +describe("Pi package discovery", () => { + it("finds the package next to a version-manager npm shim", () => { + const versionRoot = path.join(temporaryRoot(), "nvm", "v24.13.1"); + mkdirSync(versionRoot, { recursive: true }); + const shim = path.join(versionRoot, "pi.cmd"); + writeFileSync(shim, "@echo off\r\n"); + const packageDirectory = path.join( + versionRoot, + "node_modules", + "@earendil-works", + "pi-coding-agent", + ); + writePiPackage(packageDirectory); + + expect(findPiPackageDirectory(shim)).toBe(packageDirectory); + const cliEntry = path.join(packageDirectory, "dist", "bundle", "cli.js"); + mkdirSync(path.dirname(cliEntry), { recursive: true }); + writeFileSync(cliEntry, ""); + expect(findPiCliEntry(packageDirectory)).toBe(cliEntry); + }); + + it("finds the package when the executable is inside its package directory", () => { + const packageDirectory = path.join( + temporaryRoot(), + "node_modules", + "@earendil-works", + "pi-coding-agent", + ); + const executable = path.join(packageDirectory, "dist", "cli.js"); + mkdirSync(path.dirname(executable), { recursive: true }); + writeFileSync(executable, ""); + writePiPackage(packageDirectory); + + expect(findPiPackageDirectory(executable)).toBe(packageDirectory); + }); + + it("rejects a CLI entry that escapes the Pi package directory", () => { + const packageDirectory = path.join(temporaryRoot(), "pi-package"); + mkdirSync(packageDirectory, { recursive: true }); + writeFileSync( + path.join(packageDirectory, "package.json"), + JSON.stringify({ name: "@earendil-works/pi-coding-agent", bin: { pi: "../outside.js" } }), + ); + writeFileSync(path.join(path.dirname(packageDirectory), "outside.js"), ""); + + expect(findPiCliEntry(packageDirectory)).toBeUndefined(); + }); + + it("does not treat an unrelated npm package as Pi", () => { + const executable = path.join(temporaryRoot(), "bin", "pi.cmd"); + mkdirSync(path.dirname(executable), { recursive: true }); + writeFileSync(executable, "@echo off\r\n"); + writeFileSync( + path.join(path.dirname(executable), "package.json"), + JSON.stringify({ name: "some-other-package" }), + ); + + expect(findPiPackageDirectory(executable)).toBeUndefined(); + }); +});