From 0aaafd334b374542f521b96170a38a45d6f8171c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 28 Aug 2026 08:16:37 -0700 Subject: [PATCH] fix(agent-terminal): approve node-pty install scripts for npm 12 npm 12 blocks dependency lifecycle scripts unless the installing project names the package in allowScripts. node-pty ships prebuilds for macOS and Windows only, so on Linux its install script is what compiles build/Release/pty.node. The generated managed-runtime package.json declared no allowScripts, so npm installed the tree, exited 0, and the Agent tab later failed with "Agent terminal runtime could not load WebTUI". Generate the runtime manifest with a name-only node-pty approval, and verify after install that pty.node actually exists (build/Release, build/Debug, or prebuilds/-, which is node-pty's own resolution order). A missing binary now triggers one targeted npm rebuild and, if that does not repair it, fails provisioning with the blocked-scripts diagnostic and the manual remedy instead of leaving a runtime that looks installed. Closes #1409 --- .../server/agent-terminal-runtime.test.ts | 47 +++++++ packages/server/agent-terminal-runtime.ts | 117 +++++++++++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) 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;