diff --git a/packages/server/agent-terminal-runtime.test.ts b/packages/server/agent-terminal-runtime.test.ts index c9fdfd837..72cdf1cfc 100644 --- a/packages/server/agent-terminal-runtime.test.ts +++ b/packages/server/agent-terminal-runtime.test.ts @@ -6,8 +6,10 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { AGENT_TERMINAL_WEBTUI_VERSION, + buildAgentTerminalRuntimePackageJson, installAgentTerminalRuntime, resolveBundledAgentTerminalSidecarPath, + verifyAgentTerminalNativeBinary, } from "./agent-terminal-runtime"; let tmp = ""; @@ -64,6 +66,51 @@ describe("agent terminal runtime", () => { } }); + test("runtime manifest approves node-pty install scripts by name", () => { + const manifest = buildAgentTerminalRuntimePackageJson() as { + allowScripts?: Record; + }; + + // npm 12 blocks dependency install scripts unless the installing project + // names the package (#1409). Name-only is load-bearing: npm matches these + // keys as exact strings, so `node-pty@1.1.0` would stop matching as soon + // as webtui's `^1.1.0` range resolved to a newer patch. + expect(manifest.allowScripts?.["node-pty"]).toBe(true); + expect(Object.keys(manifest.allowScripts ?? {})).toEqual(["node-pty"]); + }); + + test("native binary check accepts either a compiled build or a platform prebuild", () => { + const packageDir = join(tmp, "node_modules", "node-pty"); + + const compiled = join(packageDir, "build", "Release"); + mkdirSync(compiled, { recursive: true }); + writeFileSync(join(compiled, "pty.node"), ""); + expect(verifyAgentTerminalNativeBinary(tmp).ok).toBe(true); + rmSync(join(packageDir, "build"), { recursive: true, force: true }); + + const prebuild = join(packageDir, "prebuilds", `${process.platform}-${process.arch}`); + mkdirSync(prebuild, { recursive: true }); + writeFileSync(join(prebuild, "pty.node"), ""); + expect(verifyAgentTerminalNativeBinary(tmp).ok).toBe(true); + }); + + test("native binary check reports blocked install scripts when node-pty was not built", () => { + mkdirSync(join(tmp, "node_modules", "node-pty"), { recursive: true }); + + const result = verifyAgentTerminalNativeBinary(tmp); + expect(result.ok).toBe(false); + // The remedy has to be in the message: npm's blocking is silent, so this + // is the only place a user learns why an install that exited 0 cannot run. + const message = result.ok ? "" : result.message; + expect(message).toContain("install scripts"); + expect(message).toContain("npm rebuild node-pty"); + expect(message).toContain(tmp); + }); + + test("native binary check stays out of the way when node-pty is not in the tree", () => { + expect(verifyAgentTerminalNativeBinary(tmp).ok).toBe(true); + }); + test("WebTUI vendor version is pinned consistently", () => { const repoRoot = join(import.meta.dir, "..", ".."); const manifests = [ diff --git a/packages/server/agent-terminal-runtime.ts b/packages/server/agent-terminal-runtime.ts index 9e4af2431..03c92a8e7 100644 --- a/packages/server/agent-terminal-runtime.ts +++ b/packages/server/agent-terminal-runtime.ts @@ -27,9 +27,17 @@ import nodeAgentTerminalSidecarSource from "./agent-terminal-node-sidecar.mjs" w export const AGENT_TERMINAL_WEBTUI_VERSION = "0.1.0"; +/** + * The one native dependency in the WebTUI tree. `@plannotator/webtui` pulls it + * in transitively, and it is the only package in that tree with lifecycle + * scripts, which is what makes a single-entry `allowScripts` approval enough. + */ +const AGENT_TERMINAL_NATIVE_PACKAGE = "node-pty"; + const NODE_VERSION_TIMEOUT_MS = 3_000; const NODE_IMPORT_TIMEOUT_MS = 5_000; const NPM_INSTALL_TIMEOUT_MS = 120_000; +const NPM_REBUILD_TIMEOUT_MS = 120_000; export type ResolvedAgentTerminalRuntime = { ok: true; @@ -235,6 +243,21 @@ export async function installAgentTerminalRuntime(): Promise { + return { private: true, type: "module", dependencies: { "@plannotator/webtui": AGENT_TERMINAL_WEBTUI_VERSION, }, + allowScripts: { + [AGENT_TERMINAL_NATIVE_PACKAGE]: true, + }, }; +} + +function writeRuntimePackageJson(runtimeDir: string): void { + const packageJsonPath = join(runtimeDir, "package.json"); + const packageJson = buildAgentTerminalRuntimePackageJson(); writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); } +/** + * Post-install check that the native PTY addon actually exists. + * + * npm's script blocking is silent: the tree installs, `npm install` exits 0, + * and only a later `import` of WebTUI fails. This mirrors node-pty's own + * resolution order (`lib/utils.js`: build/Release, build/Debug, then + * prebuilds/-) so a runtime that cannot load is reported as a + * provisioning failure with the actual remedy instead of as a WebTUI import + * error surfaced hours later in the Agent tab. + * + * When node-pty is not in the tree at all the check passes: nothing to verify, + * and refusing to install a tree we do not recognize would be worse than + * letting the existing import preflight judge it. + */ +export function verifyAgentTerminalNativeBinary( + runtimeDir: string, +): { ok: true } | { ok: false; message: string } { + const packageDir = resolveNativePackageDir(runtimeDir); + if (!packageDir) return { ok: true }; + + const platformDir = `${process.platform}-${process.arch}`; + const candidates = [ + join("build", "Release", "pty.node"), + join("build", "Debug", "pty.node"), + join("prebuilds", platformDir, "pty.node"), + ]; + if (candidates.some((candidate) => existsSync(join(packageDir, candidate)))) { + return { ok: true }; + } + + return { + ok: false, + message: + `${AGENT_TERMINAL_NATIVE_PACKAGE} installed without its native binary (no pty.node under ` + + `build/Release, build/Debug, or prebuilds/${platformDir} in ${packageDir}). ` + + "npm 12 and newer block dependency install scripts unless the installing project approves " + + `them, and ${AGENT_TERMINAL_NATIVE_PACKAGE} needs its install script to compile pty.node on ` + + "this platform. Repair it with: cd " + + `${runtimeDir} && npm install-scripts approve ${AGENT_TERMINAL_NATIVE_PACKAGE} && npm rebuild ` + + `${AGENT_TERMINAL_NATIVE_PACKAGE}`, + }; +} + +function resolveNativePackageDir(runtimeDir: string): string | null { + const candidates = [ + join(runtimeDir, "node_modules", AGENT_TERMINAL_NATIVE_PACKAGE), + join( + runtimeDir, + "node_modules", + "@plannotator", + "webtui", + "node_modules", + AGENT_TERMINAL_NATIVE_PACKAGE, + ), + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? null; +} + function readInstalledWebTuiVersion(runtimeDir: string): string | null { const packageJsonPath = join(runtimeDir, "node_modules", "@plannotator", "webtui", "package.json"); if (!existsSync(packageJsonPath)) return null;