fix: use prebuilt node-pty for minimal Linux installs - #3608
fix: use prebuilt node-pty for minimal Linux installs#3608just-cameron wants to merge 8 commits into
Conversation
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>
|
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. |
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
left a comment
There was a problem hiding this comment.
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.14shipsprebuilds/forlinux-x64,linux-arm64,darwin-{x64,arm64},win32-{x64,arm64}— the fix is real. - ✅
prebuilds/darwin-arm64/spawn-helperstill exists, so the existingpostinstallchmod hack inpackage.jsondoesn't silently break. - ✅ API surface used by the repo (
spawn/onData/onExit/write/resize/kill) is unchanged. Only diffs vs 1.1.0:useConptyis now deprecated/ignored (not used anywhere insrc/) andresize()gained an optional third param. Noenginesconstraint 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:
- Wrap both requires in try/catch with an actionable message (
reinstall with npm_config_build_from_source=true) and, forexec-command, degrade to the non-TTY path. - Extend the
postinstallto verifyrequire("node-pty")loads and re-install from source if not. - 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.
platformis hardcoded"linux/amd64"and the CI step is gated onmatrix.name == 'Linux x64 (ubuntu-24.04)', yet the Dockerfile'scase "$arch"already handles arm64 and the prebuild exists. Parameterize platform (arg/env, default fromprocess.arch) and drop the step'siftorunner.os == 'Linux'— you get the arm64 leg free onubuntu-24.04-arm. -
The Windows check can't catch a missing prebuild. Windows runners have MSVC, so
node-gyp rebuildsucceeds 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, assertexistsSync(join(dirname(require.resolve("node-pty/package.json")), "prebuilds", \${process.platform}-${process.arch}`))` — one line, and it works on macOS too. -
letta --helpis 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.txtcloses 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 ofecho letta-node-pty-windows-okalone — even if cmd never executed it. Write something the shell must evaluate:set /a 6*7and assert42. (The Linux side'stest -t 0 && printf tty-okis a genuine TTY proof — good.) - Windows 5s timeout + immediate write.
term.write()fires synchronously afterspawn, before the console is attached; ConPTY can drop early input, and cold Windows runners are slow. Wait for the firstonDatachunk (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.rawdoesn't help — it preserves\$literally rather than escaping the substitution). Cleaner: keepnodeVersionas a JS const, interpolate it directly into the curl URL, and drop the DockerfileENV. 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 opaqueCommand 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 assumebun run checkcovers 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.
…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>
|
Pushed two commits addressing the review ( One correction to my review: I claimed the musl/old-glibc case fails at
So the fix is a refusal, not just a catch: 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
|
Summary
node-ptyto1.2.0-beta.14, the upstream release that includes Linux x64 prebuilds, so public npx installs do not fall back tonode-gypon minimal Ubuntu images without build tools.exec_commandwithtty=true,write_stdin, Node terminal handling, and Bun test coverage still usenode-ptyrather than a lazy or optional workaround.check:minimal-linux-npm-artifact, which builds the publish artifact, installs it with npm in Ubuntu 24.04 withoutmake, runsletta --help, and verifiesnode-ptyallocates an actual TTY.Root cause
@letta-ai/letta-codepublishednode-pty@1.1.0as a normal dependency. That release does not shipprebuilds/linux-x64, so npm runsnode scripts/prebuild.js || node-gyp rebuild. On a minimal Ubuntu 24.04 npx environment, the rebuild reachesnode-gypand fails becausemakeis not installed.Risk / rollback
node-ptydependency version. The call sites and terminal semantics are unchanged.node-pty@1.1.0and reintroduce the minimal Linux npx install failure.Test plan
bun test src/tools/exec-command.test.tsbun run checkbun run check:minimal-linux-npm-artifact@letta-ai/letta-code@0.29.12install in Ubuntu 24.04 + Node 22.19 + python3 but nomakefails innode-pty@1.1.0withError: not found: make👾 Generated with Letta Code