Skip to content

fix: use prebuilt node-pty for minimal Linux installs - #3608

Open
just-cameron wants to merge 8 commits into
mainfrom
fix/minimal-linux-npx-install-a1322
Open

fix: use prebuilt node-pty for minimal Linux installs#3608
just-cameron wants to merge 8 commits into
mainfrom
fix/minimal-linux-npx-install-a1322

Conversation

@just-cameron

Copy link
Copy Markdown
Contributor

Summary

  • Fixes npx install requires a Linux build toolchain for node-pty letta-acp#50 by pinning node-pty to 1.2.0-beta.14, the upstream release that includes Linux x64 prebuilds, so public npx installs do not fall back to node-gyp on minimal Ubuntu images without build tools.
  • Leaves the real PTY execution paths intact. exec_command with tty=true, write_stdin, Node terminal handling, and Bun test coverage still use node-pty rather than a lazy or optional workaround.
  • Adds check:minimal-linux-npm-artifact, which builds the publish artifact, installs it with npm in Ubuntu 24.04 without make, runs letta --help, and verifies node-pty allocates an actual TTY.

Root cause

@letta-ai/letta-code published node-pty@1.1.0 as a normal dependency. That release does not ship prebuilds/linux-x64, so npm runs node scripts/prebuild.js || node-gyp rebuild. On a minimal Ubuntu 24.04 npx environment, the rebuild reaches node-gyp and fails because make is not installed.

Risk / rollback

  • Runtime risk is limited to the node-pty dependency version. The call sites and terminal semantics are unchanged.
  • Rollback would restore node-pty@1.1.0 and reintroduce the minimal Linux npx install failure.
  • The new Docker check is Linux x64 only, matching the reported public failure. Existing focused tests cover the Bun-side PTY/write_stdin behavior.

Test plan

  • bun test src/tools/exec-command.test.ts
  • bun run check
  • bun run check:minimal-linux-npm-artifact
  • Manual baseline reproduction: @letta-ai/letta-code@0.29.12 install in Ubuntu 24.04 + Node 22.19 + python3 but no make fails in node-pty@1.1.0 with Error: not found: make

👾 Generated with Letta Code

Pin node-pty to the upstream beta that ships Linux prebuilds so npx installs do not fall back to node-gyp on slim Ubuntu images without build tools. Add a packed-artifact Docker check that proves the published-style tarball installs and can allocate a PTY in minimal Linux.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
@cursor

cursor Bot commented Jul 31, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

just-cameron and others added 5 commits July 31, 2026 16:00
Run the packed-artifact minimal Linux install check in the Linux x64 CI lane without rebuilding the bundle twice. Add a Windows npm-artifact node-pty smoke so the beta upgrade proves a real PTY spawn on Windows after global install.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Use the Windows npm command shim when the artifact smoke resolves the globally installed package, matching how the workflow installs the packed tarball under PowerShell.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Pass the resolved global node_modules path from PowerShell and keep a shell-based fallback so the Windows PTY smoke does not trip over npm.cmd execution rules before it reaches node-pty.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Refresh the branch against current main so the minimal Linux and Windows artifact checks rerun on the latest repository state.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

The real PTY proof completed successfully, but ConPTY kept a native handle open until the 30-minute CI job timeout. Exit explicitly after the child exits and the marker is verified.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

@cpacker cpacker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Pins node-pty from ^1.1.0 → exact 1.2.0-beta.14 so npm resolves a release that ships prebuilds/linux-x64, avoiding the node-gyp rebuild fallback that breaks npx @letta-ai/letta-code on minimal Ubuntu images (letta-ai/letta-acp#50). Adds two artifact checks: a Docker-based Ubuntu 24.04 install+PTY smoke (check:minimal-linux-npm-artifact, wired to the Linux x64 CI leg) and a Windows ConPTY smoke against the globally-installed tarball (check:windows-node-pty-artifact).

I verified the core claims against the published tarballs:

  • 1.2.0-beta.14 ships prebuilds/ for linux-x64, linux-arm64, darwin-{x64,arm64}, win32-{x64,arm64} — the fix is real.
  • prebuilds/darwin-arm64/spawn-helper still exists, so the existing postinstall chmod hack in package.json doesn't silently break.
  • ✅ API surface used by the repo (spawn/onData/onExit/write/resize/kill) is unchanged. Only diffs vs 1.1.0: useConpty is now deprecated/ignored (not used anywhere in src/) and resize() gained an optional third param. No engines constraint added.

Approach is sound. Two things I'd want addressed before merge, plus polish.

Main risk: prebuild presence now suppresses the source build

node-pty's scripts/prebuild.js only checks that prebuilds/<platform>-<arch>/ exists — no ABI or libc validation. The shipped linux-x64/pty.node requires GLIBC_2.28 and links libc.so.6 / libstdc++.so.6.

Consequence: on musl (Alpine) or glibc < 2.28 (Amazon Linux 2 ≈ 2.26, CentOS 7 ≈ 2.17), 1.1.0 fell through to node-gyp rebuild and produced a working binary when build tools were present. 1.2.0-beta.14 will copy an incompatible prebuild, install cleanly, and then fail at require("node-pty") time. You're trading a loud install-time failure for a quiet runtime one on those platforms.

The requires are lazy (src/tools/impl/exec-command.ts:573, src/websocket/terminal-handler.ts:213), so this won't crash startup — but they're unguarded, so exec_command tty=true and the terminal handler will throw a raw dyld/relocation error. Options, cheapest first:

  1. Wrap both requires in try/catch with an actionable message (reinstall with npm_config_build_from_source=true) and, for exec-command, degrade to the non-TTY path.
  2. Extend the postinstall to verify require("node-pty") loads and re-install from source if not.
  3. Add an Alpine (or debian:10) case to the new Docker check so this class of break is caught.

Coverage gaps

  • linux-arm64 is fixed but untested. platform is hardcoded "linux/amd64" and the CI step is gated on matrix.name == 'Linux x64 (ubuntu-24.04)', yet the Dockerfile's case "$arch" already handles arm64 and the prebuild exists. Parameterize platform (arg/env, default from process.arch) and drop the step's if to runner.os == 'Linux' — you get the arm64 leg free on ubuntu-24.04-arm.
  • The Windows check can't catch a missing prebuild. Windows runners have MSVC, so node-gyp rebuild succeeds and the ConPTY smoke passes either way. It's still useful as a ConPTY regression guard, but if prebuild presence is the thing you care about, assert existsSync(join(dirname(require.resolve("node-pty/package.json")), "prebuilds", \${process.platform}-${process.arch}`))` — one line, and it works on macOS too.
  • letta --help is redirected but never asserted (./node_modules/.bin/letta --help >/tmp/letta-help.txt). Exit code 0 with empty output passes. grep -q letta /tmp/letta-help.txt closes it.

Correctness / flake concerns

  • Windows marker assertion is close to tautological. ConPTY echoes written input back on the data stream, so output.includes("letta-node-pty-windows-ok") can be satisfied by the echo of echo letta-node-pty-windows-ok alone — even if cmd never executed it. Write something the shell must evaluate: set /a 6*7 and assert 42. (The Linux side's test -t 0 && printf tty-ok is a genuine TTY proof — good.)
  • Windows 5s timeout + immediate write. term.write() fires synchronously after spawn, before the console is attached; ConPTY can drop early input, and cold Windows runners are slow. Wait for the first onData chunk (the prompt) before writing, and bump the timeout to ~15–20s.

Style / maintainability

  • const dockerNodeVersion = "$" + "{NODE_VERSION}" is string-concat obfuscation to dodge template interpolation (String.raw doesn't help — it preserves \$ literally rather than escaping the substitution). Cleaner: keep nodeVersion as a JS const, interpolate it directly into the curl URL, and drop the Dockerfile ENV. One source of truth, no hack.
  • run("docker", ["version"], { stdio: "ignore" }) swallows stderr, so a dev without Docker (or with the daemon stopped) gets an opaque Command failed. Catch and print "Docker is required for check:minimal-linux-npm-artifact".
  • Docker case "$arch" is currently dead code given the hardcoded --platform; it becomes live if you parameterize per above.
  • The two new checks are deliberately outside bun run check (they need Docker / a global install) — right call, but they're now invisible in CLAUDE.md's documented check suite. Worth a line there so the next agent doesn't assume bun run check covers packaging.

CI cost

This adds, on every heavy-CI PR run: an Ubuntu image pull, a ~45MB Node tarball download, and a full registry npm install (including the @vscode/ripgrep postinstall download — a real network-flake surface). The build job is capped at timeout-minutes: 30. Since the check only regresses when package.json/bun.lock change, consider running it on push-to-main + release, or gating on those paths, rather than every PR.

Security

No new attack surface — no secrets, no network beyond nodejs.org and the npm registry, tarball mounted :ro, temp dir and image cleaned up in finally. Repo has no package-lock.json, so npm consumers resolve fresh at install time; the exact (non-caret) pin is what actually guarantees the prebuilt release ships. Correct choice here.

Note latest on npm is still 1.1.0 and the beta tag has already moved to 1.2.0-beta.15 — worth a comment next to the pin explaining why it's a prerelease and what the exit condition is (upstream 1.2.0 stable), so it doesn't get "cleaned up" back to a range later. Also flag in release notes that 1.2.0 drops winpty: Windows now requires build ≥ 18309.

Verdict

Approve with changes. The dependency bump is verified correct and API-compatible; the checks are well-built. I'd want the musl/old-glibc fail-soft (or at least a guarded require with a real error message) and the Windows assertion strengthened; the arm64 coverage and CI-cost items are follow-ups.

cpacker and others added 2 commits August 3, 2026 16:45
…usable

Pinning node-pty to a release that ships prebuilds means npm no longer falls
back to `node-gyp rebuild` — but node-pty's install script only checks that
`prebuilds/<platform>-<arch>/` exists, never that the binary inside is usable.
The linux-x64 prebuild is glibc-linked, so two environments now install cleanly
and break at runtime instead of at install time:

- glibc older than GLIBC_2.28: `require("node-pty")` throws a raw dynamic-link
  error with no indication of what the user should do about it.
- musl (Alpine): `require` *succeeds* and the first `spawn()` segfaults the
  process. Verified on node:22-alpine with node-pty@1.2.0-beta.14 — no
  try/catch can recover from that, so the load has to be refused up front.

Route both require sites through `requireNodePty()`, which refuses a glibc
prebuild on a musl runtime (allowing a locally compiled binding through) and
tags either failure so callers can handle it:

- exec_command with tty=true falls back to a pipe and records why in the
  session output, rather than failing the tool call or killing the process.
- The terminal handler's existing catch now forwards an actionable message to
  the client instead of a linker stack trace.

Verified in Alpine that the shipped bundle's guard evaluates to "refuse", and
on glibc x64/arm64 that the PTY path is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The artifact checks were narrower than the risk they guard.

Linux check:
- Derive the Docker platform from process.arch (override with --platform= or
  LETTA_CODE_MINIMAL_LINUX_ARTIFACT_PLATFORM) and run it on both Linux CI legs.
  linux-x64 and linux-arm64 prebuilds are published independently, so a missing
  arm64 prebuild breaks npx the same way the x64 one did. Verified on both.
- Assert node-pty actually used a prebuild (`prebuilds/<platform>-<arch>` present,
  `build/` absent) so a future bump that drops prebuilds fails here rather than in
  a user's npx install.
- Assert `letta --help` produced output; it was redirected to a file and only its
  exit code was checked.
- Replace the "$" + "{NODE_VERSION}" interpolation dodge with a plain JS constant,
  which also removes the now-dead Dockerfile arch case.
- Report an actionable error when Docker is missing instead of an opaque
  "Command failed" from a stdio-ignored `docker version`.

Windows check:
- Assert on `set /a 6*7` evaluating to 42. ConPTY echoes written input, so the old
  marker assertion passed on the echo alone, even if cmd never ran the command.
- Wait for the shell's first output before writing (ConPTY drops input sent before
  the console is attached) and raise the timeout to 20s for cold runners.
- Assert the Windows prebuild is present. Runners have MSVC, so a dropped prebuild
  would pass here via node-gyp while breaking users without a toolchain.

CI gating: the Docker build plus a full registry install costs minutes per leg, and
can only regress when packaging inputs change — gate on a new `packaging_changed`
classify output (package.json, bun.lock, build.js, postinstall patches, the check
scripts, ci.yml) or a push to main.

Document both checks and the node-pty pin rationale in AGENTS.md; package.json
can't carry the comment explaining why an exact prerelease is intentional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cpacker

cpacker commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Pushed two commits addressing the review (aefda14, ea4cb6c).

One correction to my review: I claimed the musl/old-glibc case fails at require time. I tested it, and it's worse than that. On node:22-alpine with the packed tarball:

make absent
npm install: OK (no gyp fallback)
require node-pty: OK, keys: native,spawn,fork,createTerminal,open
pty.spawn(...) -> Segmentation fault

require succeeds and the first spawn() segfaults the process — no try/catch can recover from that. Separately, the old-glibc half of my concern is mostly moot: Node 22 itself needs GLIBC_2.28, the same floor as the prebuild, so it only bites Node 18 users on ancient glibc (where require does throw catchably).

So the fix is a refusal, not just a catch: requireNodePty() in src/utils/node-pty-loader.ts declines to load a glibc prebuild on a musl runtime (detected via process.report's missing glibcVersionRuntime, the same signal detect-libc uses), while still allowing a locally compiled binding through. exec_command with tty: true then falls back to a pipe and records why in the session output; the terminal handler's existing catch forwards an actionable message.

Verified in Alpine that the shipped bundle's guard evaluates to "refuse", and that the PTY path is unaffected on glibc.

Also in these commits: the Docker check now derives its platform from process.arch and runs on both Linux legs (passed for linux/amd64 and linux/arm64 locally), asserts a prebuild was actually used rather than a source rebuild, and asserts letta --help output; the Windows check asserts set /a 6*742 instead of a marker that ConPTY's input echo satisfied on its own, waits for the shell's first output before writing, and asserts the Windows prebuild is present. Because the Docker build plus a registry install costs minutes per leg, it's gated on a new packaging_changed classify output (or a push to main) — this PR touches ci.yml, so it will run here.

bun run check and the full unit suite pass. Feel free to drop anything you disagree with.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

npx install requires a Linux build toolchain for node-pty

3 participants