Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/provider-pi/PLUGIN_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ The plugin starts `pi` in RPC mode on the host and loads a small bb extension in

## Requirements

- Install the Pi coding agent on the host machine: `npm install -g @earendil-works/pi-coding-agent`. Version 0.84.0 or newer is required.
- Install the Pi coding agent on the host machine: `npm install -g @earendil-works/pi-coding-agent`. Version 0.84.0 or newer is required. On a machine without npm, Setup's Install puts Pi in bb's own data directory using the Node.js runtime bundled with bb; no admin rights are needed.
- Sign in with `pi` on that machine.
- Optional: set `BB_PI_BRIDGE_COMMAND` and `BB_PI_BRIDGE_ARGS` on the host to use a Pi executable that is not on `PATH`.
4 changes: 4 additions & 0 deletions plugins/provider-pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ What lives here:
- `src/delta-translation.ts` — pi's session events become bb's thread deltas.
- `src/bridge/provider-maintenance.ts` — the install gate (`pi --version`
≥ 0.84.0) and the npm install/update actions.
- `src/bridge/bundled-runtime.ts` — when the host has no `npm`, Install runs
bb's bundled npm on the runtime the bridge runs on (`process.execPath`, in
Node mode under Electron) into `<bridge-data>/pi-npm-global`. A `pi` on
`PATH` still wins; otherwise that copy launches on the same runtime.
- `src/bridge/extension-ui.ts` — pi extension dialogs reach the user:
`ctx.ui.select/confirm/input/editor` inside a pi extension arrive as pi RPC
`extension_ui_request` lines, the bridge forwards each dialog as a
Expand Down
8 changes: 8 additions & 0 deletions plugins/provider-pi/src/bridge/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ import {
type PiSessionParams,
} from "../session-params.js";
import { piSessionNeedsRelocation } from "./session-cwd.js";
import {
configurePiBundledRuntime,
resolveBundledNpmCliFrom,
} from "./bundled-runtime.js";
import { BB_PI_EXTENSION_SOURCE } from "./bb-pi-extension.js";
import {
createExtensionUiCoordinator,
Expand Down Expand Up @@ -1239,6 +1243,10 @@ export async function experimental_closeAllForTests(): Promise<void> {
export const experimental_providerBridge = experimental_defineProviderBridge({
handleLine,
start: (context) => {
configurePiBundledRuntime({
dataDir: context.dataDir,
npmCli: resolveBundledNpmCliFrom(process.argv[1]),
});
scratchDir = join(context.tempDir, "pi");
scratchDirIsPrivate = false;
mkdirSync(scratchDir, { recursive: true });
Expand Down
79 changes: 79 additions & 0 deletions plugins/provider-pi/src/bridge/bundled-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
bundledPiInstallCommand,
configurePiBundledRuntime,
resetPiBundledRuntimeForTests,
} from "./bundled-runtime.js";

describe("Pi install on bb's own runtime", () => {
let root: string;

beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "bb-pi-node-shim-"));
});

afterEach(async () => {
resetPiBundledRuntimeForTests();
await fs.rm(root, { force: true, recursive: true });
});

it("lets dependency install scripts run `node` when no Node.js is on PATH", async () => {
const fakeNpm = path.join(root, "fake-npm-cli.js");
await fs.writeFile(
fakeNpm,
[
'const { spawnSync } = require("node:child_process");',
'const result = spawnSync("node", ["-e", "process.stdout.write(\'from-node\')"], { shell: process.platform === "win32", encoding: "utf8" });',
'process.stdout.write(`status=${result.status} out=${result.stdout ?? ""} argv=${process.argv.slice(2).join(",")}`);',
].join("\n"),
);
const emptyPath = path.join(root, "empty-path");
await fs.mkdir(emptyPath);
configurePiBundledRuntime({ dataDir: root, npmCli: fakeNpm });

const install = bundledPiInstallCommand();
expect(install).not.toBeNull();
if (install === null) return;
const run = spawnSync(install.command, install.args, {
encoding: "utf8",
env: { ...process.env, PATH: emptyPath, Path: emptyPath },
});

expect(run.stdout).toContain("status=0 out=from-node");
expect(run.stdout).toContain("argv=install,--global");
});

it("keeps `node` working when bb's runtime lives under a non-ASCII path with %", async () => {
const runtimeDir = path.join(root, "Jösé 100%");
await fs.mkdir(runtimeDir);
const runtime = path.join(runtimeDir, path.basename(process.execPath));
await fs.copyFile(process.execPath, runtime);
await fs.chmod(runtime, 0o755);
const fakeNpm = path.join(root, "fake-npm-cli.js");
await fs.writeFile(
fakeNpm,
[
'const { spawnSync } = require("node:child_process");',
'const result = spawnSync("node", ["-e", "process.stdout.write(\'from-node\')"], { shell: process.platform === "win32", encoding: "utf8" });',
'process.stdout.write(`status=${result.status} out=${result.stdout ?? ""}`);',
].join("\n"),
);
const emptyPath = path.join(root, "empty-path");
await fs.mkdir(emptyPath);
configurePiBundledRuntime({ dataDir: root, npmCli: fakeNpm });

const install = bundledPiInstallCommand();
expect(install).not.toBeNull();
if (install === null) return;
const run = spawnSync(runtime, install.args, {
encoding: "utf8",
env: { ...process.env, PATH: emptyPath, Path: emptyPath },
});

expect(run.stdout).toContain("status=0 out=from-node");
});
});
176 changes: 176 additions & 0 deletions plugins/provider-pi/src/bridge/bundled-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { execFile } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { promisify } from "node:util";
import {
type ProviderInstallationCommand,
experimental_compareVersions as compareVersions,
experimental_formatCommand as formatCommand,
experimental_versionFrom as versionFrom,
} from "@get-bb/plugin-sdk/provider-bridge";
import { z } from "zod";

const execFileAsync = promisify(execFile);
const PI_PACKAGE_SEGMENTS = ["@earendil-works", "pi-coding-agent"] as const;
const PI_PACKAGE_NAME = PI_PACKAGE_SEGMENTS.join("/");
const PI_MINIMUM_NODE_VERSION = "22.19.0";
const BUNDLED_PREFIX_DIR_NAME = "pi-npm-global";
const LATEST_VERSION_TIMEOUT_MS = 15_000;
const PI_BOOTSTRAP_SOURCE =
"delete process.env.ELECTRON_RUN_AS_NODE;import(require('node:url').pathToFileURL(process.argv[1]).href)";
const NPM_BOOTSTRAP_SOURCE = [
"const fs=require('node:fs'),path=require('node:path');",
"const [prefix,npmCli,...rest]=process.argv.slice(1);",
"const shim=path.join(prefix,'.bb-node-shim');",
"fs.mkdirSync(shim,{recursive:true});",
"process.env.PI_BB_NODE_RUNTIME=process.execPath;",
"if(process.platform==='win32'){fs.writeFileSync(path.join(shim,'node.cmd'),'@echo off\\r\\nsetlocal\\r\\nset \"ELECTRON_RUN_AS_NODE=1\"\\r\\n\"%PI_BB_NODE_RUNTIME%\" %*\\r\\n');}",
"else{const f=path.join(shim,'node');fs.writeFileSync(f,'#!/bin/sh\\nELECTRON_RUN_AS_NODE=1 exec \"$PI_BB_NODE_RUNTIME\" \"$@\"\\n');fs.chmodSync(f,0o755);}",
"const key=Object.keys(process.env).find(k=>k.toUpperCase()==='PATH')||'PATH';",
"process.env[key]=shim+path.delimiter+(process.env[key]||'');",
"process.argv=[process.execPath,npmCli,...rest];",
"require(npmCli);",
].join("");

const piPackageSchema = z.object({
bin: z.union([z.string().min(1), z.object({ pi: z.string().min(1) })]),
});

interface PiBundledRuntime {
prefix: string;
npmCli: string | null;
}

let bundledRuntime: PiBundledRuntime | null = null;

function isFile(file: string): boolean {
try {
return statSync(file).isFile();
} catch {
return false;
}
}

export function resolveBundledNpmCliFrom(
entryPath: string | undefined,
): string | null {
if (entryPath === undefined || !path.isAbsolute(entryPath)) return null;
try {
const npmPackageJson = createRequire(entryPath).resolve("npm/package.json");
const cli = path.join(path.dirname(npmPackageJson), "bin", "npm-cli.js");
return isFile(cli) ? cli : null;
} catch {
return null;
}
}

export function configurePiBundledRuntime(args: {
dataDir: string;
npmCli: string | null;
}): void {
bundledRuntime = {
prefix: path.join(args.dataDir, BUNDLED_PREFIX_DIR_NAME),
npmCli: args.npmCli,
};
}

export function resetPiBundledRuntimeForTests(): void {
bundledRuntime = null;
}

export function bbRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return process.versions.electron === undefined
? env
: { ...env, ELECTRON_RUN_AS_NODE: "1" };
}

function bundledPackageDir(prefix: string): string {
return process.platform === "win32"
? path.join(prefix, "node_modules", ...PI_PACKAGE_SEGMENTS)
: path.join(prefix, "lib", "node_modules", ...PI_PACKAGE_SEGMENTS);
}

export function resolveBundledPiCli(): string | null {
if (bundledRuntime === null) return null;
const packageDir = bundledPackageDir(bundledRuntime.prefix);
try {
const pkg = piPackageSchema.parse(
JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")),
);
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin.pi;
const cli = path.resolve(packageDir, bin);
const relative = path.relative(packageDir, cli);
if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
return isFile(cli) ? cli : null;
} catch {
return null;
}
}

export function bundledPiLaunch(cli: string): {
command: string;
args: string[];
} {
return {
command: process.execPath,
args: ["-e", PI_BOOTSTRAP_SOURCE, cli],
};
}

export function isBundledPiLaunch(launch: {
command: string;
args: string[];
}): boolean {
return (
launch.command === process.execPath &&
launch.args[0] === "-e" &&
launch.args[1] === PI_BOOTSTRAP_SOURCE
);
}

function usableBundledNpm(): { prefix: string; npmCli: string } | null {
if (bundledRuntime === null || bundledRuntime.npmCli === null) return null;
if (compareVersions(process.versions.node, PI_MINIMUM_NODE_VERSION) < 0) {
return null;
}
return { prefix: bundledRuntime.prefix, npmCli: bundledRuntime.npmCli };
}

export function bundledPiInstallCommand(): ProviderInstallationCommand | null {
const runtime = usableBundledNpm();
if (runtime === null) return null;
const args = [
"-e",
NPM_BOOTSTRAP_SOURCE,
runtime.prefix,
runtime.npmCli,
"install",
"--global",
"--prefix",
runtime.prefix,
"--no-audit",
"--no-fund",
`${PI_PACKAGE_NAME}@latest`,
];
return {
command: process.execPath,
args,
displayCommand: formatCommand(process.execPath, args),
};
}

export async function bundledPiLatestVersion(): Promise<string | null> {
const runtime = usableBundledNpm();
if (runtime === null) return null;
try {
const { stdout } = await execFileAsync(
process.execPath,
[runtime.npmCli, "view", PI_PACKAGE_NAME, "version"],
{ env: bbRuntimeEnv(process.env), timeout: LATEST_VERSION_TIMEOUT_MS },
);
return versionFrom(stdout);
} catch {
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ vi.mock("@get-bb/plugin-sdk/provider-bridge", async (importOriginal) => {
});

vi.mock("./rpc-child.js", () => ({
piProcessEnv: (_launch: unknown, env: NodeJS.ProcessEnv) => env,
resolveBundledPiInUse: () => null,
resolvePiLaunch: () => ({ command: probeState.executablePath, args: [] }),
resolvePiProcessLaunch: () => ({
command: probeState.executablePath,
Expand Down
Loading
Loading