From 38ab24a4b5be6bb49b3be257dcc47cfc2c41000f Mon Sep 17 00:00:00 2001 From: cachewraith Date: Tue, 27 Oct 2026 10:01:01 +0700 Subject: [PATCH] feat(host)!: remove the Node host and node-pty (#76 phase 4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust host is the only pty host now. - removed src/host/index.ts, the host.js build entry, settings.nodeHost (Settings → Advanced "Use the Node host") and node-pty with its rebuild (postinstall, trustedDependencies, asarUnpack; npmRebuild off): bun install builds no native module - host-start: hostBinary() finds wraithgrid-host for the app and for `wraithgrid host-stdio`; without it terminal panes can't start, so `bun run dev` and test:e2e build it first - the Node host's server, screen and pty manager moved to tests/support/node-host/ as the in-process host of the pane-host, router and CLI unit tests (fake ptys, a local pty interface) - parity suite: the Rust host only, headless xterm as the reference for resizes; test:host no longer builds the Electron app - Linux package checks run `wraithgrid-host --version`; README build requirements are Rust and a C linker; ADR 0001/0004, CONTEXT updated BREAKING CHANGE: settings.nodeHost is gone; a build without the Rust host binary can't run terminal panes. Closes #76 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 19 +- bun.lock | 10 +- docs/CONTEXT.md | 16 +- docs/DECISIONS.md | 1 + docs/TODO.md | 2 +- docs/adr/0001-background-pty-host.md | 4 +- docs/adr/0004-rust-pty-host.md | 13 +- electron-builder.yml | 7 +- electron.vite.config.ts | 4 +- package.json | 15 +- scripts/dist-linux-docker.sh | 6 +- scripts/test-linux-packages.sh | 7 +- src/host/index.ts | 143 --------------- src/host/protocol.ts | 15 ++ src/main/app.ts | 40 +---- src/main/cli-entry.ts | 5 +- src/main/host-start.ts | 40 +++-- src/main/ipc.ts | 5 +- src/main/pane-host.ts | 2 +- src/main/pane-router.ts | 2 +- src/main/spawn-resolve.ts | 2 +- src/renderer/app/store.ts | 3 +- src/renderer/components/SettingsView.tsx | 37 ---- src/shared/ipc-contract.ts | 2 - src/shared/schema.ts | 5 - src/shared/types.ts | 5 - tests/e2e/machines.spec.ts | 9 +- tests/e2e/rust-host.spec.ts | 115 ++++++------ tests/host/harness.ts | 20 +-- tests/host/parity.test.ts | 87 +++++---- .../support/node-host}/pty-manager.ts | 19 +- tests/support/node-host/pty-types.ts | 22 +++ .../support/node-host}/screen.ts | 0 .../support/node-host}/server.ts | 5 +- tests/unit/cli.test.ts | 6 +- tests/unit/host-binary.test.ts | 35 ++++ tests/unit/machines.test.ts | 7 +- tests/unit/pty-host.test.ts | 6 +- tests/unit/pty-manager.test.ts | 170 ------------------ 40 files changed, 282 insertions(+), 630 deletions(-) delete mode 100644 src/host/index.ts rename {src/host => tests/support/node-host}/pty-manager.ts (94%) create mode 100644 tests/support/node-host/pty-types.ts rename {src/host => tests/support/node-host}/screen.ts (100%) rename {src/host => tests/support/node-host}/server.ts (98%) create mode 100644 tests/unit/host-binary.test.ts delete mode 100644 tests/unit/pty-manager.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a770ac2..eb8a466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-10-27 +- [host] Rust host phase 4b (#76, ADR 0004): the Node host and node-pty are gone. Removed `src/host/index.ts`, the `host.js` build entry, `settings.nodeHost` (Settings → Advanced "Use the Node host") and `node-pty` with its rebuild (`postinstall`, `trustedDependencies`, `asarUnpack`, `npmRebuild: false`); `bun install` builds no native module. `hostBinary()` finds the binary for the app and `wraithgrid host-stdio`; a build without it can't start terminal panes, so `bun run dev`/`test:e2e` run cargo first. The Node host's server/screen/pty manager moved to `tests/support/node-host/` as the in-process host of the client unit tests (fake ptys); the parity suite runs the Rust host only, with headless xterm as the reference for resizes (30 tests). Linux package checks test `wraithgrid-host --version` instead of node-pty; README build requirements are Rust + a C linker (files: src/host/_, src/main/{host-start,app,cli-entry,ipc,pane-host,pane-router,spawn-resolve}.ts, src/shared/{types,schema,ipc-contract}.ts, src/renderer/{app/store.ts,components/SettingsView.tsx}, tests/support/node-host/_, tests/host/_, tests/unit/_, tests/e2e/{rust-host,machines}.spec.ts, package.json, bun.lock, electron.vite.config.ts, electron-builder.yml, scripts/_, README.md, docs/_) - [cli] `wraithgrid send` marks the pane as having a conversation, as Enter typed in it does, so a restart resumes it (H6 follow-up): a new gui relay action `sent` (no host protocol change; no window open, nothing to mark); docs/TODO.md cleared of six items that had already shipped (#39–#41, file attach, delete chat confirm, sign-in dialog) (files: src/shared/ipc-contract.ts, src/main/{cli,ipc}.ts, src/renderer/app/store.ts, tests/unit/cli.test.ts, tests/e2e/cli.spec.ts, docs/TODO.md) - [host] Old Rust hosts no longer pile up on ssh machines (#76): each connect touches this version's `~/.wraithgrid/bin/wraithgrid-host-` (its date says when it was last used) and deletes other versions unused for 30 days, so two Wraithgrid versions sharing a machine don't copy theirs over again on every connect; a host still running a deleted one keeps running. `command find`, since a fish config can shadow `find` (this machine's does); tested for real in sh and fish (files: src/main/remote-bin.ts, tests/unit/remote-host.test.ts) - [host] Rust host phase 4a (#76, ADR 0004 Accepted): terminal panes run in the Rust host by default on Linux, macOS and Windows. `settings.nodeHost` (Settings → Advanced "Use the Node host", off; `WRAITHGRID_NODE_HOST=1` in tests) replaces `rustHost`: every config saved that as false, so it is dropped on load and everyone moves to Rust; a build without the binary runs the Node host. `bun run test:e2e` builds the Rust host first, so e2e runs the default; `rust-host.spec` checks both ways (files: src/shared/{types,schema}.ts, src/main/app.ts, src/renderer/components/SettingsView.tsx, tests/e2e/{rust-host,machines}.spec.ts, package.json, docs/adr/0004-rust-pty-host.md, docs/CONTEXT.md) diff --git a/README.md b/README.md index 04d56c4..3c15216 100644 --- a/README.md +++ b/README.md @@ -448,32 +448,33 @@ bar, or the command palette) covers the rest: - Node.js 22.12 or newer, and Bun 1.4 or newer (); Bun installs and runs the scripts, the tools themselves still run on Node -- A C++ toolchain for the `node-pty` native module: - - Arch: `sudo pacman -S --needed python make gcc` - - Ubuntu, Debian, Kali: `sudo apt install python3 make g++` - - Fedora: `sudo dnf install python3 make gcc-c++` - - Windows: Visual Studio Build Tools with "Desktop development with C++", and Python 3 +- Rust (stable, ) for the background host in `host-rs/`, with the C + linker it uses: + - Arch: `sudo pacman -S --needed gcc` + - Ubuntu, Debian, Kali: `sudo apt install gcc` + - Fedora: `sudo dnf install gcc` + - Windows: Visual Studio Build Tools with "Desktop development with C++" - macOS: Xcode Command Line Tools (`xcode-select --install`) - To build a Fedora `.rpm` on another distro: `rpmbuild` (`rpm-tools` on Arch, `rpm` on Ubuntu). The pacman package needs `bsdtar` (`libarchive-tools` on Ubuntu). ```sh -bun install # installs deps and rebuilds node-pty for Electron -bun run dev # run with hot reload +bun install # installs deps (no native Node modules to build) +bun run dev # builds the Rust host, then runs with hot reload bun run dist:linux:portable # all Linux packages, built in Docker on Ubuntu 22.04 bun run dist:win # Windows installer (run on Windows) bun run dist:mac # macOS .dmg and .zip, Apple Silicon and Intel (run on a Mac) ``` Bun runs install scripts only for the packages in `trustedDependencies` in `package.json` -(`electron`, `esbuild`, `node-pty`); `bunfig.toml` keeps a flat `node_modules` for electron-builder. +(`electron`, `esbuild`); `bunfig.toml` keeps a flat `node_modules` for electron-builder.
Why the portable build?
-`node-pty` compiles against the build machine's glibc. A package built on a rolling distro +The Rust host links against the build machine's glibc. A package built on a rolling distro (Arch, current Fedora) only runs on distros at least as new. `dist:linux:portable` builds on Ubuntu 22.04 (glibc 2.35) inside Docker, so one set of packages runs on Ubuntu 22.04+, Debian 12+, Kali, Fedora and Arch. `scripts/test-linux-packages.sh` then installs each package diff --git a/bun.lock b/bun.lock index db2bda6..3ef1764 100644 --- a/bun.lock +++ b/bun.lock @@ -9,14 +9,11 @@ "@fontsource/geist": "5.3.0", "@fontsource/jetbrains-mono": "5.3.0", "@xterm/addon-fit": "0.11.0", - "@xterm/addon-serialize": "0.14.0", "@xterm/addon-unicode11": "0.9.0", "@xterm/addon-web-links": "0.12.0", "@xterm/addon-webgl": "0.19.0", - "@xterm/headless": "6.0.0", "@xterm/xterm": "6.0.0", "electron-updater": "6.8.9", - "node-pty": "1.1.0", "react": "19.3.0", "react-dom": "19.3.0", "react-resizable-panels": "4.14.1", @@ -33,6 +30,8 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "5.2.0", + "@xterm/addon-serialize": "0.14.0", + "@xterm/headless": "6.0.0", "electron": "44.4.5", "electron-builder": "26.15.3", "electron-vite": "5.0.0", @@ -50,7 +49,6 @@ "trustedDependencies": [ "electron", "esbuild", - "node-pty", ], "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "7.29.7", "js-tokens": "4.0.0", "picocolors": "1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -755,16 +753,12 @@ "node-abi": ["node-abi@4.35.0", "", { "dependencies": { "semver": "7.8.5" } }, "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q=="], - "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "7.8.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "2.2.1", "exponential-backoff": "3.1.3", "graceful-fs": "4.2.11", "nopt": "9.0.0", "proc-log": "6.1.0", "semver": "7.8.5", "tar": "7.5.22", "tinyglobby": "0.2.17", "undici": "6.29.0", "which": "6.0.1" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], - "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "7.1.1" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], - "node-releases": ["node-releases@2.0.57", "", {}, "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw=="], "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 7eb2500..f478afa 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -6,16 +6,16 @@ each with its own account and project folder: `claude`, `gemini`, `agy`, `codex` `cursor-agent`, `copilot` (config-dir variables and sign-in isolation in `src/shared/agent-clis.ts`). Windows, Linux and macOS. v1.23.0. ## Stack -Electron 44, electron-vite 5, React 19, TypeScript 6, zustand, zod 4, @xterm/xterm 6, node-pty, +Electron 44, electron-vite 5, React 19, TypeScript 6, zustand, zod 4, @xterm/xterm 6, the Rust pty host (`host-rs`: tokio, portable-pty, vt100), react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e). Bun 1.4 (package manager/script runner; tools run on Node), Node ≥22.12. ## Commands -- `bun install` — install; rebuilds node-pty for Electron (`trustedDependencies`: electron/esbuild/node-pty; `bunfig.toml`: hoisted linker, 1-day minimumReleaseAge) -- `bun run dev` — run with hot reload +- `bun install` — install; no native Node modules (`trustedDependencies`: electron/esbuild; `bunfig.toml`: hoisted linker, 1-day minimumReleaseAge) +- `bun run dev` — `cargo build --release` of the host, then run with hot reload - `bun run typecheck` — tsc over node + web projects - `bun run lint` — ESLint + Prettier check (`bun run format` to fix) - `bun run test` — Vitest unit tests (`tests/unit`) -- `bun run test:host` — build both pty hosts (electron-vite + `cargo build --release` in host-rs) and run the parity suite (`tests/host`, Linux/macOS); `cargo test`/`cargo clippy` in `host-rs/` +- `bun run test:host` — `cargo build --release` in host-rs, then the parity suite (`tests/host`, Linux/macOS; xterm is the reference); `cargo test`/`cargo clippy` in `host-rs/` - `bun run test:e2e` — build, then Playwright (`tests/e2e`; needs a display, CI uses xvfb-run) - `bun run build` — production build into `out/` - `bun run dist:linux:portable` — Linux packages in Ubuntu 22.04 Docker; `bun run dist:win` on Windows; `bun run dist:mac` on a Mac (builds a universal Rust host first, `scripts/build-mac-host.sh`) @@ -27,8 +27,8 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - `src/main/host-start.ts`: start a detached host (fd masking) + socket connect; used by app and bridge `src/main/pane-router.ts`: routes pane ops to the local host or a machine's (by workspace `machineId`) - `src/main/spawn-resolve.ts`: pane spec → exact process (binary, args, cwd, env); local in ipc, remote in the bridge `src/main/ssh.ts`: `~/.ssh/config` hosts, ssh args, ssh child as a duplex stream - `src/main/ipc.ts`: all IPC handlers; validates every payload with zod contracts `src/main/pane-host.ts`: GUI side of the pty host: find/start it, hello + token, create/attach/write/kill, version mismatch -- `host-rs/`: the Rust pty host (ADR 0004; phase 4a: the default on Linux, macOS and Windows; Settings → Advanced "Use the Node host" (`settings.nodeHost`, `WRAITHGRID_NODE_HOST=1` in tests) falls back (own ConPTY spawn + named pipe there; `host-rs/tests/smoke.rs` runs the built host on every OS); `settings.rustHost` or `WRAITHGRID_RUST_HOST=1` → app.ts `startHost` runs the binary from resources (packaged) or `host-rs/target/release` (dev) instead of host.js; Linux packages bundle it via `extraResources`): `main.rs` lock/token/socket/signals, `server.rs` clients+panes (same frames as `src/host/protocol.ts`), `pty.rs` portable-pty + waitpid, `screen.rs` vt100 snapshot/text, `protocol.rs` frame validation; phase 5: `bridge.rs` = `wraithgrid-host stdio `, the ssh end on a Linux machine (starts/joins a host under `~/.wraithgrid/remote`, injects the token, turns `plan` frames into `create`), `plan.rs` = login-shell PATH, binary, folder, env on that machine; GUI side `src/main/remote-plan.ts` (spec → plan: the rules) + `remote-bin.ts` (preflight `uname -sm`, upload once per version to `~/.wraithgrid/bin`, fallback `wraithgrid host-stdio`), static musl binaries from `scripts/build-remote-hosts.sh` → `host-rs/target/remote` → resources `remote/`; `tests/host/` = parity suite vs the Node host (`bun run test:host`) -- `src/host/`: background pty host (ADR 0001), built as `out/main/host.js`, never imports electron: `index.ts` entry (lock, token, socket), `server.ts` panes + clients, `protocol.ts` NDJSON frames (zod), `paths.ts` socket/token paths per user-data dir, `screen.ts` headless xterm + serialize, `fds.ts` inherited-fd masking, `pty-manager.ts` spawns/kills pane processes (per-OS KillPolicy) `src/main/chat/`: chat panes (ADR 0002): `sessions.ts` runs one headless CLI per pane (pipes, JSON lines), `adapter.ts` registry, `claude.ts` stream-json ↔ ChatEvent (+ approvals), `agy.ts` agy's stream-json (no approvals/interrupt: `controls` event → the view restarts it), `codex.ts` `codex app-server` JSON-RPC, `images.ts` pasted images → temp files, `spawn.ts` masked fds/process group, `attach.ts` files from the attach picker, `transcript.ts` copies a conversation to another account's dir (claude transcript, or codex rollout under `sessions/YYYY/MM/DD`, #60) (switch account, #29); add a CLI = one adapter + `chat: true` in agent-clis +- `host-rs/`: the pty host (ADR 0004; the only one since phase 4b, Linux/macOS/Windows: own ConPTY spawn + named pipe there; `host-rs/tests/smoke.rs` runs the built host on every OS); `host-start.ts` `hostBinary()` finds it in resources (packaged) or `host-rs/target/release` (dev), for the app and `wraithgrid host-stdio`: `main.rs` lock/token/socket/signals, `server.rs` clients+panes (frames of `src/host/protocol.ts`), `pty.rs` portable-pty + waitpid (Unix) / ConPTY (Windows), `screen.rs` vt100 snapshot/text, `protocol.rs` frame validation; phase 5: `bridge.rs` = `wraithgrid-host stdio `, the ssh end on a Linux machine (starts/joins a host under `~/.wraithgrid/remote`, injects the token, turns `plan` frames into `create`), `plan.rs` = login-shell PATH, binary, folder, env on that machine; GUI side `src/main/remote-plan.ts` (spec → plan) + `remote-bin.ts` (preflight `uname -sm`, upload once per version to `~/.wraithgrid/bin`, sweep of versions unused 30 days, fallback `wraithgrid host-stdio`), static musl binaries from `scripts/build-remote-hosts.sh` → `host-rs/target/remote` → resources `remote/`; `tests/host/` = parity suite (`bun run test:host`) +- `src/host/`: what the window shares with the host (ADR 0001): `protocol.ts` NDJSON frames (zod) + `SpawnRequest`, `paths.ts` socket/token paths per user-data dir, `fds.ts` inherited-fd masking. `tests/support/node-host/`: the old Node host (server, headless-xterm screen, pty manager with fake ptys), test-only, the in-process host of the pane-host/router/CLI unit tests `src/main/chat/`: chat panes (ADR 0002): `sessions.ts` runs one headless CLI per pane (pipes, JSON lines), `adapter.ts` registry, `claude.ts` stream-json ↔ ChatEvent (+ approvals), `agy.ts` agy's stream-json (no approvals/interrupt: `controls` event → the view restarts it), `codex.ts` `codex app-server` JSON-RPC, `images.ts` pasted images → temp files, `spawn.ts` masked fds/process group, `attach.ts` files from the attach picker, `transcript.ts` copies a conversation to another account's dir (claude transcript, or codex rollout under `sessions/YYYY/MM/DD`, #60) (switch account, #29); add a CLI = one adapter + `chat: true` in agent-clis - `src/main/login.ts`: sign-in dialog backend, per CLI (`LOGIN_FLOWS`): claude `auth login` + `auth status --json`; codex `login --device-auth` (one-time code) + `login status`; `DIALOG_LOGIN_CLIS` in agent-clis says who gets the dialog `src/main/clipboard-image.ts`: clipboard image → temp file → text to paste (path, or `@path` for gemini/agy) - `src/main/pane-env.ts`: builds a pane's env (per-CLI config dir var for agent panes; mod folders as `CLAUDE_CODE_PLUGIN_DIRS` for claude; strips the launching terminal's vars; UTF-8 locale fallback) `src/main/config-store.ts`: config.json load/save, atomic write, bad-file quarantine - `src/main/claude-detect.ts`: finds the `claude` binary and version `src/main/platform.ts`: OS/desktop differences as pure functions (chrome, shell pick/detection, login-shell PATH probe per shell, spawn) @@ -81,7 +81,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - Unpackaged (e2e), `app.getVersion()` is Electron's version (44.x), so the remote Rust host is copied as `wraithgrid-host-44.x`. A process the host starts detached must not keep inherited fds (Playwright's pipes, sshd's): app.close/ssh then never finish (host-start.ts masks them; bridge.rs marks them CLOEXEC). - e2e fixtures with claude panes saved as terminals (`view` omitted = terminal) turn into chats on load unless `settings.chatOnlyMigrated: true` (#110); suites driving claude's own screen (background, cli, keys, mods, smoke, git-palette) set it. - A reattached screen goes out as several `replay` data frames (`chunkReplay`, 512K chars each, #134), never one over `MAX_FRAME` (4 MB, a client drops the link); clients must treat consecutive replay frames as one screen (`wraithgrid attach` clears only before the first). -- Panes live in the host, not main: it renames itself `wraithgrid-host` (pgrep for `host.js` finds nothing); socket dir `$XDG_RUNTIME_DIR/wraithgrid--` (or tmpdir), removed on clean exit, kept with `host.log` after a failure. A dev host keeps running old `src/host` code while the protocol matches: stop it (Quit and stop agents, or SIGTERM) after host changes. Unix socket paths max ~104 bytes. +- Panes live in the host, not main: the `wraithgrid-host` process; socket dir `$XDG_RUNTIME_DIR/wraithgrid--` (or tmpdir), removed on clean exit, kept with `host.log` after a failure. A dev host keeps running an old build while the protocol matches: stop it (Quit and stop agents, or SIGTERM) after host changes. Unix socket paths max ~104 bytes. - Never spawn a long-lived child from main without masking fds (`stdioMasking`): Chromium fds lack CLOEXEC. - Electron 44 has no postinstall: `node_modules/electron/dist` appears on the first `require('electron')`; never spawn that path directly (tests/host harness did, CI failed). - Playwright: a crashed page object stays dead; read the reloaded page via `app.evaluate(... executeJavaScript)`. Launch crash tests with `cwd` in a temp dir (core dumps). Wait for a fake CLI's last banner line before typing (echo races its output). @@ -89,7 +89,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - Never run long work in Electron main without a window system: it aborts without a display (SIGTRAP) once JS yields. CLI = `spawnSync` + ELECTRON_RUN_AS_NODE. Crash experiments leave multi-GB `core.*` files in the cwd: delete them. - `WRAITHGRID_SSH` replaces `ssh` (tests: `tests/fixtures/fake-ssh.sh` runs "the remote" with WG_REMOTE_HOME/DATA/PATH). A remote agent's cmdline is its own PATH's `claude`, not the fixture path. - `wraithgrid` CLI is refused until `settings.cliAccess`; the GUI pushes it to the host on connect/change (the host keeps the last value while detached). Windows: GUI-subsystem binary, CLI output not shown in a console yet. -- node-pty compiles against the host glibc: ship Linux builds from Ubuntu 22.04 (portable script / CI). +- The Rust host links against the build machine's glibc: ship Linux builds from Ubuntu 22.04 (portable script / CI). Without its binary (`cargo build` not run) terminal panes can't start: `bun run dev`/`test:e2e` build it. - Launcher-started app lacks shell PATH: main asks the login shell once with flags per shell kind (csh/tcsh can't take `-l -c`), then `/bin/sh -l` (skipped when `TERM` set). - claude panes never go through the user's shell; their screen breaks only via inherited env (COLUMNS, TERM_PROGRAM, TMUX…): add new terminal vars to `TERMINAL_VARS` in pane-env.ts. - Windows `claude.cmd` runs through cmd.exe: args with `& | < > ^ % "` are refused. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 96019ae..0d15ce8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -143,3 +143,4 @@ Entries dated 2026-09-29 are reconstructed from the code and commit history duri - 2026-10-26 | A chat's live view is capped at 1000 items, dropping to 800 (#141) | Bounds memory and per-render work of a pane left open for days; the saved view already keeps only 400, so nothing a restart would show is lost | No cap (memory grows until the pane closes); virtualized list (bigger change, breaks find-in-page and follow) - 2026-10-27 | Rust host phase 3 split: macOS first (3a), Windows after (3b) with a ConPTY spawn of our own | portable-pty always quotes each argument on Windows, so the ready `cmd.exe /d /s /c "…"` line for npm `.cmd` CLIs (platform.ts) can't pass through it; macOS needs no code change. One universal mac binary (lipo) serves both the arm64 and x64 apps | Splitting the line into argv (cmd.exe doesn't read `\"` escapes); a per-arch mac binary (two extraResources configs for one value) - 2026-10-27 | Rust host default (#76 phase 4a) via a new `nodeHost` fallback setting, not a changed `rustHost` default; Node host and node-pty kept one release | Every config already stores `rustHost: false`, so a new default alone would move nobody; the unknown key is dropped on load. Windows and macOS were only checked in CI, so a one-switch way back stays until a release has run on Rust | Migrating `rustHost` false→true (overrides a deliberate opt-out that never existed); removing node-pty now (no fallback) +- 2026-10-27 | Phase 4b keeps the Node host's server, screen and pty manager as a test-only in-process host (`tests/support/node-host/`), and headless xterm as the parity reference | 58 client-side unit tests (pane-host, router, CLI) need a protocol-faithful host they can run in-process with fake ptys; the Node host's screen is exactly xterm's, the right reference for the Rust screen | Rewriting those tests against the Rust binary (unit runs would need cargo and real processes); dropping the resize parity tests (no reference left) diff --git a/docs/TODO.md b/docs/TODO.md index 8ce22fb..638bd76 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,7 +1,7 @@ # TODO ## Now -- #76 Rust host: 3a macOS done (never run on a real Mac: only CI's parity suite); 3b Windows done on feat/rust-host-windows-20261027 (only CI's smoke test: never run in the real app on Windows, nor with a real npm claude.cmd); 4a default on done (fallback: "Use the Node host"); 4b (remove the Node host and node-pty) after a release on 4a, ask the user first; phase 5 arm64 binary never run on real arm64 +- #76 Rust host: 3a macOS done (never run on a real Mac: only CI's parity suite); 3b Windows done on feat/rust-host-windows-20261027 (only CI's smoke test: never run in the real app on Windows, nor with a real npm claude.cmd); 4a default on and 4b Node host + node-pty removed (all phases done); left: try on real Windows/macOS/arm64, sign the binary (needs certificates); phase 5 arm64 binary never run on real arm64 - CI flakes fixed 2026-10-27 (PR #152): chat.spec.ts:299 (sideways-wheel noise paused follow), chat-history.spec.ts (caret placed a frame late collapsed a select-all). Still seen once locally: responsive.spec.ts:131 (Settings nav heading not hidden in a small window), passed 3/3 on rerun - Audit (2026-10-26) bugs: all fixed (#135–#138 PR #148, #139 PR #150, #140 PR #151). Not seen live: #138 cancelled pkexec prompt, #140 renderer-crash reattach, codex cancel/stop paths, agy first-start effort - Audit (2026-10-26) perf: #141 done (PR #149); not measured live yet (a DevTools profile of a long streaming chat before/after would confirm) diff --git a/docs/adr/0001-background-pty-host.md b/docs/adr/0001-background-pty-host.md index cb982c8..0f9e8bd 100644 --- a/docs/adr/0001-background-pty-host.md +++ b/docs/adr/0001-background-pty-host.md @@ -1,7 +1,9 @@ # ADR 0001: Agents keep running after Wraithgrid closes (background pty host) - Status: **Accepted with amendments** (reviewed 2026-10-19; see "Review" at the end, which - overrides the sections above where they differ) + overrides the sections above where they differ). The host itself is now the Rust binary of + ADR 0004 (2026-10-27, phase 4b): the Node host, `out/main/host.js` and node-pty below are + gone; the design (detached host, lock, token, socket, frames) is unchanged. - Plan item: H1 in `docs/TODO.md`; H5 (SSH machines), H6 (CLI + local API) and H7 (attach from a terminal) build on it diff --git a/docs/adr/0004-rust-pty-host.md b/docs/adr/0004-rust-pty-host.md index a75cd75..63906b5 100644 --- a/docs/adr/0004-rust-pty-host.md +++ b/docs/adr/0004-rust-pty-host.md @@ -1,8 +1,7 @@ # ADR 0004: A Rust pty host -- Status: **Accepted** (2026-10-27), issue #76. Phases 1–3 and 5 done; phase 4a makes it the - default on Linux, macOS and Windows, with the Node host kept as a fallback setting until 4b - removes it and `node-pty`. +- Status: **Accepted** (2026-10-27), issue #76. All phases done: the Rust host is the only + pty host on Linux, macOS and Windows; the Node host and `node-pty` are gone (phase 4b). ## Context @@ -87,6 +86,11 @@ multi-webview (needed for the web preview, ADR 0003) is still unstable in Tauri - Phase 4a: `settings.nodeHost` (Settings → Advanced "Use the Node host", off; `WRAITHGRID_NODE_HOST=1` in tests) replaces `rustHost`, which configs saved as false and is dropped on load, so everyone moves to Rust; a build without the binary runs the Node host. +- Phase 4b: `src/host/index.ts`, the `host.js` build entry, `settings.nodeHost` and + `node-pty` are removed; `bun install` builds no native module. The Node host's server, screen + and pty manager live on in `tests/support/node-host/` as the in-process host of the client + unit tests (fake ptys only), and its headless-xterm screen is the parity suite's reference + for resizes. `@xterm/headless` and the serialize addon are dev dependencies now. - Phase 5: with the Rust host in use, a Linux machine reached over ssh gets this version's static musl binary (x64; arm64 built in CI, not yet run on hardware) in `~/.wraithgrid/bin`, copied once and checked with `--version`; `wraithgrid-host stdio ` there joins (or starts) @@ -101,8 +105,7 @@ multi-webview (needed for the web preview, ADR 0003) is still unstable in Tauri 2. **Behind a setting**: Settings → Advanced "Use the Rust host" (off), the binary in the Linux bundle; dogfood. 3. **macOS + Windows**: ConPTY, named pipe, signing/notarization; x64 + arm64 builds. -4. **Default on** (4a, done), then remove the Node host and `node-pty` (4b) once a release has - run on it. +4. **Default on** (4a), then remove the Node host and `node-pty` (4b): both done. 5. **Remote** (done behind the setting): the static binary on Linux ssh machines, so they need no Wraithgrid installed. diff --git a/electron-builder.yml b/electron-builder.yml index 26d3145..63e0a85 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -10,9 +10,8 @@ files: - docs/licenses/** - docs/PROVIDER-LOGOS.md - '!**/*.map' -asarUnpack: - - node_modules/node-pty/** -npmRebuild: true +# No native Node modules since the Node pty host went (ADR 0004 phase 4b): nothing to rebuild. +npmRebuild: false linux: target: - AppImage # any distro @@ -121,7 +120,7 @@ appImage: # macOS: one dmg (to install) and one zip (what electron-updater reads) per CPU. # Ad-hoc signed ("-"): Apple Silicon refuses to start unsigned code at all, and there is no # Developer ID yet, so the app is not notarized and Gatekeeper asks once (see README). -# Hardened runtime stays off: with ad-hoc signing it would block loading node-pty. +# Hardened runtime stays off until there is a Developer ID to sign with. mac: target: - target: dmg diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 3fa5337..ce07eeb 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -7,7 +7,6 @@ const alias = { '@shared': resolve(__dirname, 'src/shared') } export default defineConfig({ main: { resolve: { alias }, - // The pty host is a second entry: same Node target and externals, its own process. build: { rollupOptions: { input: { @@ -15,8 +14,7 @@ export default defineConfig({ // An entry of its own so it stays in out/main: it resolves files from __dirname. app: resolve(__dirname, 'src/main/app.ts'), // `wraithgrid `, run as plain Node by index.ts. - cli: resolve(__dirname, 'src/main/cli-entry.ts'), - host: resolve(__dirname, 'src/host/index.ts') + cli: resolve(__dirname, 'src/main/cli-entry.ts') } } } diff --git a/package.json b/package.json index c01924f..bf72c22 100644 --- a/package.json +++ b/package.json @@ -20,21 +20,20 @@ }, "private": true, "scripts": { - "dev": "electron-vite dev", + "dev": "cargo build --release --locked --manifest-path host-rs/Cargo.toml && electron-vite dev", "build": "electron-vite build", "preview": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "lint": "eslint . && prettier --check .", "format": "prettier --write .", "test": "vitest run", - "test:host": "electron-vite build && cargo build --release --manifest-path host-rs/Cargo.toml && vitest run --config vitest.host.config.ts", + "test:host": "cargo build --release --manifest-path host-rs/Cargo.toml && vitest run --config vitest.host.config.ts", "test:e2e": "electron-vite build && cargo build --release --locked --manifest-path host-rs/Cargo.toml && playwright test", "dist": "electron-vite build && electron-builder --publish never", "dist:linux": "electron-vite build && cargo build --release --manifest-path host-rs/Cargo.toml && bash scripts/build-remote-hosts.sh && electron-builder --linux --publish never", "dist:linux:portable": "bash scripts/dist-linux-docker.sh", "dist:win": "electron-vite build && cargo build --release --locked --manifest-path host-rs/Cargo.toml && electron-builder --win --publish never", - "dist:mac": "electron-vite build && bash scripts/build-mac-host.sh && electron-builder --mac --publish never", - "postinstall": "electron-builder install-app-deps" + "dist:mac": "electron-vite build && bash scripts/build-mac-host.sh && electron-builder --mac --publish never" }, "devDependencies": { "@eslint/js": "10.0.1", @@ -46,6 +45,8 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "5.2.0", + "@xterm/addon-serialize": "0.14.0", + "@xterm/headless": "6.0.0", "electron": "44.4.5", "electron-builder": "26.15.3", "electron-vite": "5.0.0", @@ -63,14 +64,11 @@ "@fontsource/geist": "5.3.0", "@fontsource/jetbrains-mono": "5.3.0", "@xterm/addon-fit": "0.11.0", - "@xterm/addon-serialize": "0.14.0", "@xterm/addon-unicode11": "0.9.0", "@xterm/addon-web-links": "0.12.0", "@xterm/addon-webgl": "0.19.0", - "@xterm/headless": "6.0.0", "@xterm/xterm": "6.0.0", "electron-updater": "6.8.9", - "node-pty": "1.1.0", "react": "19.3.0", "react-dom": "19.3.0", "react-resizable-panels": "4.14.1", @@ -80,7 +78,6 @@ "packageManager": "bun@1.4.3", "trustedDependencies": [ "electron", - "esbuild", - "node-pty" + "esbuild" ] } diff --git a/scripts/dist-linux-docker.sh b/scripts/dist-linux-docker.sh index 4145744..1fd85de 100755 --- a/scripts/dist-linux-docker.sh +++ b/scripts/dist-linux-docker.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Builds every Linux package inside Ubuntu 22.04. node-pty's native module links against -# the build machine's glibc; building on 2.35 keeps the packages working on Ubuntu 22.04+, +# Builds every Linux package inside Ubuntu 22.04. The Rust host (host-rs) links against the +# build machine's glibc; building on 2.35 keeps the packages working on Ubuntu 22.04+, # Debian 12+, Kali, Fedora and Arch. A build on a rolling distro only runs on that distro. set -euo pipefail cd "$(dirname "$0")/.." @@ -20,7 +20,7 @@ tar --exclude=./node_modules --exclude=./dist --exclude=./out --exclude=./.git \ export DEBIAN_FRONTEND=noninteractive CI=true apt-get update -qq apt-get install -y -qq --no-install-recommends \ - ca-certificates curl xz-utils unzip git python3 make g++ rpm libarchive-tools fakeroot gcc-aarch64-linux-gnu >/dev/null + ca-certificates curl xz-utils unzip git make g++ rpm libarchive-tools fakeroot gcc-aarch64-linux-gnu >/dev/null curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" | tar -xJ -C /usr/local --strip-components=1 curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null diff --git a/scripts/test-linux-packages.sh b/scripts/test-linux-packages.sh index d3e00d9..229966e 100755 --- a/scripts/test-linux-packages.sh +++ b/scripts/test-linux-packages.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Installs the built packages in clean distro containers and checks that # 1. the package manager resolves the declared dependencies, -# 2. the packaged node-pty opens a pty and passes CLAUDE_CONFIG_DIR through, +# 2. the packaged Rust host (wraithgrid-host) runs there, # 3. the app starts and its window loads (under Xvfb). # Usage: scripts/test-linux-packages.sh [distro...] (default: all) set -uo pipefail @@ -18,10 +18,7 @@ set -u BIN="$1" RES="$(dirname "$BIN")/resources" [ -d "$RES" ] || RES="$(dirname "$BIN")/../resources" -PTY_JS="const pty = require('$RES/app.asar.unpacked/node_modules/node-pty'); -const p = pty.spawn('/bin/sh', ['-c', 'echo PTY_OK CFG=\$CLAUDE_CONFIG_DIR'], { env: { ...process.env, CLAUDE_CONFIG_DIR: '/tmp/acct' } }); -let out = ''; p.onData((d) => (out += d)); p.onExit(() => { console.log(out.trim()); process.exit(out.includes('PTY_OK CFG=/tmp/acct') ? 0 : 1) });" -if ELECTRON_RUN_AS_NODE=1 "$BIN" -e "$PTY_JS"; then echo "CHECK pty: ok"; else echo "CHECK pty: FAILED"; fi +if "$RES/wraithgrid-host" --version; then echo "CHECK host: ok"; else echo "CHECK host: FAILED"; fi Xvfb :99 -screen 0 1440x900x24 >/dev/null 2>&1 & sleep 1 diff --git a/src/host/index.ts b/src/host/index.ts deleted file mode 100644 index 63a3782..0000000 --- a/src/host/index.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Entry of the background pty host (ADR 0001): started by the GUI with its own Electron -// binary as plain Node (ELECTRON_RUN_AS_NODE), detached, so panes outlive the window. -// Never import `electron` here. Settings arrive as JSON in WRAITHGRID_HOST. -import { randomBytes } from 'node:crypto' -import { - closeSync, - openSync, - readFileSync, - renameSync, - rmdirSync, - unlinkSync, - writeFileSync -} from 'node:fs' -import { chmod } from 'node:fs/promises' -import net from 'node:net' -import { spawn } from 'node-pty' -import { closeMaskedFds } from './fds' -import { ensureHostDir, type HostPaths } from './paths' -import { HostServer } from './server' - -interface HostSettings { - paths: HostPaths - appVersion: string - /** fds 3..maskedFds are /dev/null copies standing in for the GUI's own (fds.ts). */ - maskedFds?: number -} - -const settings = JSON.parse(process.env.WRAITHGRID_HOST ?? 'null') as HostSettings | null -// Pane envs are built by the GUI; nothing of the host's own launch leaks into them anyway. -delete process.env.WRAITHGRID_HOST -delete process.env.ELECTRON_RUN_AS_NODE -process.title = 'wraithgrid-host' - -const log = (msg: string): void => console.error(`[${new Date().toISOString()}] ${msg}`) - -function alive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (err) { - return (err as NodeJS.ErrnoException).code === 'EPERM' - } -} - -/** One host per socket: an exclusive lock file with our pid; a dead owner's lock is taken over. */ -function takeLock(file: string): boolean { - for (let attempt = 0; attempt < 2; attempt++) { - try { - const fd = openSync(file, 'wx', 0o600) - writeFileSync(fd, String(process.pid)) - closeSync(fd) - return true - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err - const owner = Number(readFileSync(file, 'utf8').trim()) - if (owner && owner !== process.pid && alive(owner)) return false - unlinkSync(file) - } - } - return false -} - -const quietUnlink = (file: string): void => { - try { - unlinkSync(file) - } catch { - // Already gone. - } -} - -async function main(s: HostSettings): Promise { - const { paths } = s - const posix = process.platform !== 'win32' - if (posix && s.maskedFds) closeMaskedFds(s.maskedFds) - await ensureHostDir(paths.dir, process.platform, process.getuid?.() ?? -1) - if (!takeLock(paths.lock)) { - log('another host owns this socket; exiting') - process.exit(0) - } - // The lock is ours, so any socket file left is from a host that died. - if (posix) quietUnlink(paths.socket) - - const token = randomBytes(32).toString('hex') - const tmp = `${paths.token}.${process.pid}` - writeFileSync(tmp, token, { mode: 0o600 }) - renameSync(tmp, paths.token) - - let exiting = false - const exit = (code: number): void => { - if (exiting) return - exiting = true - server.close() - if (posix) quietUnlink(paths.socket) - quietUnlink(paths.token) - quietUnlink(paths.lock) - log(`host exiting (${code})`) - // A clean exit leaves nothing behind; after a failure the log stays for a bug report. - if (code === 0) { - quietUnlink(paths.log) - try { - rmdirSync(paths.dir) - } catch { - // Not empty, or already gone. - } - } - process.exit(code) - } - - const host = new HostServer({ - spawn, - token, - appVersion: s.appVersion, - pid: process.pid, - onIdle: () => exit(0), - onShutdown: () => exit(0), - log - }) - const server = net.createServer((sock) => host.accept(sock)) - server.on('error', (err) => { - log(`listen failed: ${err.message}`) - exit(1) - }) - server.listen(paths.socket, () => { - if (posix) void chmod(paths.socket, 0o600).catch(() => {}) - log(`host ${process.pid} listening (app ${s.appVersion})`) - }) - - for (const sig of ['SIGTERM', 'SIGINT'] as const) { - process.on(sig, () => void host.stopAll().finally(() => exit(0))) - } - // Detached from any terminal; a hang-up is not ours to act on. - process.on('SIGHUP', () => {}) - process.on('uncaughtException', (err) => log(`uncaught: ${err.stack ?? err.message}`)) -} - -if (!settings) { - console.error('wraithgrid-host: started without settings') - process.exit(2) -} -main(settings).catch((err: Error) => { - log(`host failed: ${err.stack ?? err.message}`) - process.exit(1) -}) diff --git a/src/host/protocol.ts b/src/host/protocol.ts index e616e5f..40ec41d 100644 --- a/src/host/protocol.ts +++ b/src/host/protocol.ts @@ -190,3 +190,18 @@ export class FrameReader { } } } + +/** What a host runs for a pane, resolved by the window (spawn-resolve.ts). */ +export interface SpawnRequest { + paneId: string + file: string + args: string[] | string + cwd: string + env: Record + cols: number + rows: number + /** For the host's pane status only; the pty itself ignores it. */ + approval?: string +} + +export type SpawnResult = { ok: true; pid: number } | { ok: false; error: string } diff --git a/src/main/app.ts b/src/main/app.ts index 17e83e8..9e5bd99 100644 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -1,5 +1,4 @@ import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -55,7 +54,7 @@ import { titleBarOverlayFor } from './platform' import { hostPaths } from '../host/paths' -import { connectSocket, startHostProcess } from './host-start' +import { connectSocket, hostBinary, startHostProcess } from './host-start' import { PaneHost, type MismatchChoice } from './pane-host' import { CommunityCatalog } from './mod-community' import { UpdateChecker } from './update-check' @@ -176,26 +175,8 @@ const hostFiles = hostPaths({ userName: os.userInfo().username }) -/** - * The Rust host binary of this build (ADR 0004 phase 2, Linux only): in the app's resources - * when packaged, the cargo release build next to `out/` in development; null when there is none. - */ -const rustHostBin = ((): string | null => { - // Linux, macOS and Windows (ADR 0004 phases 2, 3a, 3b). - const name = process.platform === 'win32' ? 'wraithgrid-host.exe' : 'wraithgrid-host' - const bin = app.isPackaged - ? path.join(process.resourcesPath, name) - : path.join(__dirname, '../../host-rs/target/release', name) - return existsSync(bin) ? bin : null -})() - -/** - * The Rust host, the default since ADR 0004 phase 4, unless Settings → Advanced "Use the Node - * host" (WRAITHGRID_NODE_HOST=1 in tests) asks for the old one. A build without the binary - * runs the Node host anyway. - */ -const wantRustHost = (): boolean => - !store.get().settings.nodeHost && process.env.WRAITHGRID_NODE_HOST !== '1' +/** The background host binary of this build (ADR 0004), or null when it has none. */ +const rustHostBin = hostBinary(__dirname, process.platform) /** A static Rust host this build can copy to a Linux machine (scripts/build-remote-hosts.sh). */ async function remoteHostBinary(target: string): Promise { @@ -206,20 +187,13 @@ async function remoteHostBinary(target: string): Promise { return readFile(file).catch(() => null) } -/** - * Starts the host detached: this same Electron binary running as plain Node, or the Rust host - * when Settings → Advanced asks for it and this build has it. - */ +/** Starts the host detached: the Rust `wraithgrid-host` of this build (ADR 0004). */ async function startHost(): Promise { await envReady - const wantRust = wantRustHost() - if (wantRust && !rustHostBin) console.warn('[wraithgrid] no Rust host in this build; using Node') await startHostProcess({ paths: hostFiles, appVersion: app.getVersion(), - execPath: process.execPath, - outDir: __dirname, - rustBin: wantRust ? rustHostBin : null, + bin: rustHostBin, cwd: homeDir, env: process.env, uid @@ -267,8 +241,7 @@ function remoteHost(m: Machine): PaneHost { return new PaneHost({ connect: async () => { const ssh = process.env.WRAITHGRID_SSH || 'ssh' - if (!wantRustHost()) bridge = null - else if (!bridge) { + if (!bridge) { bridge = await prepareRemote({ version: app.getVersion(), run: (command, input) => runSsh(ssh, sshArgs(m.sshHost, [command]), input), @@ -833,7 +806,6 @@ void app.whenReady().then(() => { ptys: router, homeDir, desktop, - rustHostBin, envReady, updates, community: new CommunityCatalog((url, init) => net.fetch(url, init)), diff --git a/src/main/cli-entry.ts b/src/main/cli-entry.ts index 9e37f51..7036518 100644 --- a/src/main/cli-entry.ts +++ b/src/main/cli-entry.ts @@ -9,7 +9,7 @@ import { parseConfig } from '@shared/schema' import { hostPaths } from '../host/paths' import { HOST_STDIO, runCli, type Tty } from './cli' import { runBridge } from './host-bridge' -import { connectSocket, startHostProcess } from './host-start' +import { connectSocket, hostBinary, startHostProcess } from './host-start' import { configFilePath } from './paths' import { extraBinDirs, loginShellPath, mergePath } from './platform' import { resolveSpawn } from './spawn-resolve' @@ -52,8 +52,7 @@ async function bridge(): Promise { startHostProcess({ paths: files, appVersion, - execPath: process.execPath, - outDir: __dirname, + bin: hostBinary(__dirname, process.platform), cwd: os.homedir(), env: process.env, uid diff --git a/src/main/host-start.ts b/src/main/host-start.ts index 06b4107..b31a784 100644 --- a/src/main/host-start.ts +++ b/src/main/host-start.ts @@ -1,8 +1,8 @@ -// Starts the background pty host (ADR 0001) detached, with this same binary running as plain -// Node. Used by the window and by `wraithgrid host-stdio` on a remote machine (H5), so it -// must not import `electron`. +// Starts the background pty host (ADR 0001) detached: the Rust `wraithgrid-host` binary +// (ADR 0004; the Node host is gone since phase 4b). Used by the window and by +// `wraithgrid host-stdio` on a remote machine (H5), so it must not import `electron`. import { spawn } from 'node:child_process' -import { closeSync, openSync, statSync } from 'node:fs' +import { closeSync, existsSync, openSync, statSync } from 'node:fs' import { createConnection } from 'node:net' import type { Socket } from 'node:net' import path from 'node:path' @@ -12,18 +12,30 @@ import { ensureHostDir, type HostPaths } from '../host/paths' export interface HostStart { paths: HostPaths appVersion: string - /** The app's binary (Electron); it runs `host.js` with ELECTRON_RUN_AS_NODE. */ - execPath: string - /** Folder holding the built `host.js`. */ - outDir: string - /** The Rust host binary to start instead (ADR 0004 phase 2); same settings and socket. */ - rustBin?: string | null + /** The host binary (`hostBinary`); null when this build has none. */ + bin: string | null cwd: string env: NodeJS.ProcessEnv uid: number } +/** + * This build's host binary, from the folder holding the built main code (`out/main`): in the + * app's resources when packaged (`resources/app.asar/out/main` → `resources`), the cargo + * release build beside `out/` in development; null when there is neither. + */ +export function hostBinary(outDir: string, platform: NodeJS.Platform): string | null { + const name = platform === 'win32' ? 'wraithgrid-host.exe' : 'wraithgrid-host' + const candidates = [ + path.join(outDir, '../../..', name), + path.join(outDir, '../../host-rs/target/release', name) + ] + return candidates.find((f) => existsSync(f)) ?? null +} + export async function startHostProcess(s: HostStart): Promise { + const bin = s.bin + if (!bin) throw new Error('This build has no background host (wraithgrid-host).') await ensureHostDir(s.paths.dir, process.platform, s.uid) let size = 0 try { @@ -37,17 +49,13 @@ export async function startHostProcess(s: HostStart): Promise { // Every fd this process has open would leak into the long-lived host: mask them (fds.ts). const maskUpTo = posix ? highestOpenFd(process.platform) : 2 try { - const [file, args] = s.rustBin - ? [s.rustBin, []] - : [s.execPath, [path.join(s.outDir, 'host.js')]] - const child = spawn(file, args, { + const child = spawn(bin, [], { detached: true, stdio: posix ? stdioMasking(out, nullFd, maskUpTo) : ['ignore', out, out], windowsHide: true, cwd: s.cwd, env: { ...s.env, - ...(s.rustBin ? {} : { ELECTRON_RUN_AS_NODE: '1' }), WRAITHGRID_HOST: JSON.stringify({ paths: s.paths, appVersion: s.appVersion, @@ -55,7 +63,7 @@ export async function startHostProcess(s: HostStart): Promise { }) } }) - // A spawn that fails later (ENOENT, EACCES on the Rust binary) must not throw in main: the + // A spawn that fails later (ENOENT, EACCES on the binary) must not throw in main: the // connect that follows finds no host and says it did not start (#140). child.once('error', (err) => console.warn(`[wraithgrid] host spawn failed: ${err.message}`)) child.unref() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index fe3ea2a..1b81460 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -197,8 +197,6 @@ export interface IpcDeps { ptys: Pick homeDir: string desktop: DesktopInfo - /** The Rust host binary of this build (ADR 0004), or null when it has none. */ - rustHostBin: string | null /** Resolves once PATH from the login shell is merged in (launcher-started sessions). */ envReady: Promise updates: UpdateChecker @@ -1693,8 +1691,7 @@ export function registerIpc(deps: IpcDeps): IpcHandle { configPath: contractHome(store.file, homeDir), version: app.getVersion(), chrome: deps.desktop.chrome, - desktop: deps.desktop.desktop, - rustHost: !!deps.rustHostBin + desktop: deps.desktop.desktop } : null ) diff --git a/src/main/pane-host.ts b/src/main/pane-host.ts index 5b88964..4d88b8a 100644 --- a/src/main/pane-host.ts +++ b/src/main/pane-host.ts @@ -13,7 +13,7 @@ import { type HostReply, type PaneSummary } from '../host/protocol' -import type { SpawnRequest, SpawnResult } from '../host/pty-manager' +import type { SpawnRequest, SpawnResult } from '../host/protocol' import type { SpawnSpec } from './spawn-resolve' type Request = ClientFrame extends infer F diff --git a/src/main/pane-router.ts b/src/main/pane-router.ts index f2ce3eb..33134e4 100644 --- a/src/main/pane-router.ts +++ b/src/main/pane-router.ts @@ -1,7 +1,7 @@ // Sends each pane's process work to the host that runs it: this computer's, or a remote // machine's over ssh (H5). Which one comes from the config: the pane's workspace machine. import type { Machine } from '@shared/types' -import type { SpawnRequest, SpawnResult } from '../host/pty-manager' +import type { SpawnRequest, SpawnResult } from '../host/protocol' import type { AttachResult, PaneHost, ProbeResult } from './pane-host' import type { SpawnSpec } from './spawn-resolve' diff --git a/src/main/spawn-resolve.ts b/src/main/spawn-resolve.ts index 3bc4a63..61c7c4a 100644 --- a/src/main/spawn-resolve.ts +++ b/src/main/spawn-resolve.ts @@ -13,7 +13,7 @@ import { withProjectDocArgs, withResumeArgs } from '@shared/agent-clis' -import type { SpawnRequest } from '../host/pty-manager' +import type { SpawnRequest } from '../host/protocol' import { findOnPath, resolveClaudePath } from './claude-detect' import { buildPaneEnv } from './pane-env' import { resolveUserPath } from './paths' diff --git a/src/renderer/app/store.ts b/src/renderer/app/store.ts index 72ceed7..3217578 100644 --- a/src/renderer/app/store.ts +++ b/src/renderer/app/store.ts @@ -1169,8 +1169,7 @@ export function createAppStore({ configPath: '', version: '', chrome: 'custom', - desktop: null, - rustHost: false + desktop: null }, claude: null, sharedReport: null, diff --git a/src/renderer/components/SettingsView.tsx b/src/renderer/components/SettingsView.tsx index 5e5c142..d2e9710 100644 --- a/src/renderer/components/SettingsView.tsx +++ b/src/renderer/components/SettingsView.tsx @@ -631,42 +631,6 @@ function CliRow() { ) } -/** The background host (ADR 0004 phase 4): Rust by default, the Node one as a fallback. */ -function RustHostRow() { - const actions = useActions() - const on = useApp((s) => s.config.settings.nodeHost) - const available = useApp((s) => s.info.rustHost) - return ( -
-
-

Background host

-

- Terminal panes run in a background host written in Rust: about 5 MB of memory while idle. - If panes misbehave, go back to the older Node host (about 110 MB) and tell us. Chat panes - are not affected. It is used the next time the background host starts: quit with{' '} - Quit and stop agents, then open Wraithgrid again. -

-
-
-
-
- {available ? null : ( - This build has no Rust host: the Node host runs panes. - )} -
-
- ) -} - function MachinesRow() { const actions = useActions() const { api } = useServices() @@ -1574,7 +1538,6 @@ export function SettingsView() { {tab === 'advanced' ? ( <> - ) : null} diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts index 2051d41..83b1537 100644 --- a/src/shared/ipc-contract.ts +++ b/src/shared/ipc-contract.ts @@ -313,8 +313,6 @@ export interface AppInfo { */ chrome: 'custom' | 'overlay' | 'tiling' | 'mac' desktop: string | null - /** This build has the Rust background host (ADR 0004), so the setting can turn it on. */ - rustHost: boolean } export interface ReleaseInfo { diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 3ee3207..66aa55d 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -195,11 +195,6 @@ export const settingsSchema = z.object({ gpuTerminal: z.boolean().default(false), onWindowClose: z.enum(CLOSE_MODES).default('ask').catch('ask'), cliAccess: z.boolean().default(false), - /** - * The Node host as a fallback (ADR 0004 phase 4: Rust is the default). Replaces `rustHost`, - * which every config saved as false: dropped on load, so everyone moves to Rust. - */ - nodeHost: z.boolean().default(false), newPaneView: z.enum(PANE_VIEWS).default('chat').catch('chat'), chatOnlyMigrated: z.boolean().default(false), chatModel: z.string().regex(CHAT_MODEL_PATTERN).nullable().default(null).catch(null), diff --git a/src/shared/types.ts b/src/shared/types.ts index ae8a278..d33b7ed 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -302,11 +302,6 @@ export interface Settings { onWindowClose: CloseMode /** Let `wraithgrid list|read|send|wait|new|split|stop` reach panes (H6). Off by default. */ cliAccess: boolean - /** - * Run terminal panes in the older Node background host instead of the Rust one, the default - * since ADR 0004 phase 4; used the next time a host starts. The fallback, until it goes. - */ - nodeHost: boolean /** No longer read: chat-capable agent panes always open as chats (#110). */ newPaneView: PaneView /** Saved terminal views of chat-capable panes were turned into chats once (#110). */ diff --git a/tests/e2e/machines.spec.ts b/tests/e2e/machines.spec.ts index 74a26a3..15840bb 100644 --- a/tests/e2e/machines.spec.ts +++ b/tests/e2e/machines.spec.ts @@ -158,12 +158,9 @@ test('a workspace on another machine runs its panes there, over ssh, and they ou squash(`CLAUDE_CONFIG_DIR=${remoteHome}/.wraithgrid/accounts/personal`) ) await expect(win.locator('.ws-mach', { hasText: 'lab' })).toBeVisible() - // With the Rust host (the default) and this build's remote binaries, the lab runs the binary - // copied there, not `wraithgrid host-stdio`. - if ( - process.env.WRAITHGRID_NODE_HOST !== '1' && - fs.existsSync(path.join(ROOT, 'host-rs/target/remote')) - ) { + // With this build's remote binaries, the lab runs the binary copied there, not + // `wraithgrid host-stdio`. + if (fs.existsSync(path.join(ROOT, 'host-rs/target/remote'))) { // Named after app.getVersion(), which is Electron's own when run unpackaged like here. const binDir = path.join(remoteHome, '.wraithgrid/bin') const [bin] = fs.readdirSync(binDir).filter((f) => f.startsWith('wraithgrid-host-')) diff --git a/tests/e2e/rust-host.spec.ts b/tests/e2e/rust-host.spec.ts index e1fc0b9..8d22215 100644 --- a/tests/e2e/rust-host.spec.ts +++ b/tests/e2e/rust-host.spec.ts @@ -1,5 +1,5 @@ -// ADR 0004 phase 4: panes run in the Rust background host by default; Settings → Advanced -// "Use the Node host" goes back to the Node one. Linux here (the cargo release binary). +// ADR 0004: terminal panes run in the Rust background host, the only one since phase 4b; it +// starts with the window and stops on quit. Linux here (the cargo release binary). import { execFileSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' @@ -23,66 +23,53 @@ const rustHosts = (): number[] => { } } -for (const node of [false, true]) { - test(`${node ? '"Use the Node host" runs the Node host' : 'the default runs panes in the Rust host'}`, async () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-rusthost-')) - const home = path.join(tmp, 'home') - const data = path.join(tmp, 'data') - fs.mkdirSync(path.join(home, 'proj'), { recursive: true }) - fs.mkdirSync(data) - fs.writeFileSync( - path.join(data, 'config.json'), - JSON.stringify({ - version: 1, - claudePath: '', - accounts: [], - workspaces: [ - { - id: 'ws', - name: 'main', - panes: [ - { id: 'p-sh', accountId: null, cwd: '~/proj', args: [], shell: true, title: 'sh' } - ], - layout: null - } - ], - activeWorkspace: 'ws', - recentFolders: [], - settings: { - checkUpdatesOnLaunch: false, - shellPath: path.join(ROOT, 'tests/fixtures/fake-shell.sh'), - ...(node ? { nodeHost: true } : {}) +test('terminal panes run in the Rust host', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-rusthost-')) + const home = path.join(tmp, 'home') + const data = path.join(tmp, 'data') + fs.mkdirSync(path.join(home, 'proj'), { recursive: true }) + fs.mkdirSync(data) + fs.writeFileSync( + path.join(data, 'config.json'), + JSON.stringify({ + version: 1, + claudePath: '', + accounts: [], + workspaces: [ + { + id: 'ws', + name: 'main', + panes: [ + { id: 'p-sh', accountId: null, cwd: '~/proj', args: [], shell: true, title: 'sh' } + ], + layout: null } - }) - ) - const before = new Set(rustHosts()) - // Only the setting decides here, not a WRAITHGRID_NODE_HOST from the test run. - const { WRAITHGRID_NODE_HOST: _forced, ...rest } = process.env as Record - const env = { ...rest, HOME: home, WRAITHGRID_USER_DATA_DIR: data } - const app = await electron.launch({ args: [path.join(ROOT, 'out/main/index.js')], env }) - try { - const win = await app.firstWindow() - const rows = win.locator('section.pane .xterm-rows') - await expect(rows).toContainText('SHELL-ARGS=') - const mine = rustHosts().filter((pid) => !before.has(pid)) - expect(mine).toHaveLength(node ? 0 : 1) - // Typing reaches the pane through whichever host runs it. - await win.locator('section.pane .xterm-screen').click() - await win.keyboard.type('through the host\r') - await expect(rows).toContainText('sh: through the host') - - // Settings → Advanced shows the switch as configured. - await win.getByRole('button', { name: 'More' }).click() - await win.getByRole('menuitem', { name: 'Settings' }).click() - await win.getByRole('tab', { name: 'Advanced' }).click() - const sw = win.getByRole('switch', { name: 'Use the Node host' }) - await expect(sw).toBeEnabled() - await expect(sw).toHaveAttribute('aria-checked', String(node)) - } finally { - await app.close() - fs.rmSync(tmp, { recursive: true, force: true }) - } - // Quitting stops the host it started. - await expect.poll(() => rustHosts().filter((pid) => !before.has(pid))).toHaveLength(0) - }) -} + ], + activeWorkspace: 'ws', + recentFolders: [], + settings: { + checkUpdatesOnLaunch: false, + shellPath: path.join(ROOT, 'tests/fixtures/fake-shell.sh') + } + }) + ) + const before = new Set(rustHosts()) + const env = { ...process.env, HOME: home, WRAITHGRID_USER_DATA_DIR: data } + const app = await electron.launch({ args: [path.join(ROOT, 'out/main/index.js')], env }) + try { + const win = await app.firstWindow() + const rows = win.locator('section.pane .xterm-rows') + await expect(rows).toContainText('SHELL-ARGS=') + const mine = rustHosts().filter((pid) => !before.has(pid)) + expect(mine).toHaveLength(1) + // Typing reaches the pane through whichever host runs it. + await win.locator('section.pane .xterm-screen').click() + await win.keyboard.type('through the host\r') + await expect(rows).toContainText('sh: through the host') + } finally { + await app.close() + fs.rmSync(tmp, { recursive: true, force: true }) + } + // Quitting stops the host it started. + await expect.poll(() => rustHosts().filter((pid) => !before.has(pid))).toHaveLength(0) +}) diff --git a/tests/host/harness.ts b/tests/host/harness.ts index 69b5ba6..9408f9a 100644 --- a/tests/host/harness.ts +++ b/tests/host/harness.ts @@ -1,9 +1,7 @@ -// Starts the Node host (out/main/host.js under Electron as Node) or the Rust host -// (host-rs/target/release/wraithgrid-host) on a temporary socket and talks NDJSON to it, so the -// parity suite can run the same session against both (ADR 0004 §6). +// Starts the Rust host (host-rs/target/release/wraithgrid-host) on a temporary socket and +// talks NDJSON to it (ADR 0004 §6). The Node host it was compared with went in phase 4b. import { spawn, type ChildProcess } from 'node:child_process' import fs from 'node:fs' -import { createRequire } from 'node:module' import net from 'node:net' import os from 'node:os' import path from 'node:path' @@ -11,13 +9,9 @@ import { Terminal } from '@xterm/headless' export const ROOT = path.resolve(__dirname, '../..') export const RUST_BIN = path.join(ROOT, 'host-rs/target/release/wraithgrid-host') -// Through the package, which downloads the binary on first use (Electron 44 has no -// postinstall): a fresh CI checkout has no node_modules/electron/dist yet. -const ELECTRON = createRequire(__filename)('electron') as string -const NODE_HOST = path.join(ROOT, 'out/main/host.js') export const APP_VERSION = '9.9.9-parity' -export type Kind = 'node' | 'rust' +export type Kind = 'rust' export interface Frame { t: string @@ -84,13 +78,7 @@ export async function startHost(kind: Kind): Promise { ...process.env, WRAITHGRID_HOST: JSON.stringify({ paths, appVersion: APP_VERSION }) } - const proc = - kind === 'node' - ? spawn(ELECTRON, [NODE_HOST], { - env: { ...env, ELECTRON_RUN_AS_NODE: '1' }, - stdio: ['ignore', out, out] - }) - : spawn(RUST_BIN, [], { env, stdio: ['ignore', out, out] }) + const proc = spawn(RUST_BIN, [], { env, stdio: ['ignore', out, out] }) fs.closeSync(out) await until( () => fs.existsSync(paths.token) && fs.existsSync(paths.socket), diff --git a/tests/host/parity.test.ts b/tests/host/parity.test.ts index ef194eb..ef10795 100644 --- a/tests/host/parity.test.ts +++ b/tests/host/parity.test.ts @@ -1,7 +1,8 @@ -// The Rust host must behave like the Node host (ADR 0004 §6): every scenario here runs -// against both, with the same expectations, and every screen fixture restored on attach must -// draw the same as the screen did live. Needs `bun run build` and the release binary -// (`bun run test:host` does both). +// The Rust host's protocol and screen (ADR 0004 §6). It was written against the Node host, +// which these scenarios ran on too until that host went (phase 4b); the reference now is +// xterm itself: every screen fixture restored on attach must draw the same as it did live, and +// a resized screen must read as headless xterm (the Node host's screen, kept in tests/support) +// reads it. Needs the release binary (`bun run test:host` builds it). import fs from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -18,8 +19,9 @@ import { until, type Kind } from './harness' +import { headlessScreen } from '../support/node-host/screen' -const KINDS: Kind[] = ['node', 'rust'] +const KINDS: Kind[] = ['rust'] const FIXTURES = fs.mkdtempSync(path.join(os.tmpdir(), 'wgp-fixtures-')) describe.skipIf(process.platform === 'win32' || !fs.existsSync(RUST_BIN))('host parity', () => { @@ -262,7 +264,7 @@ describe.skipIf(process.platform === 'win32' || !fs.existsSync(RUST_BIN))('host } describe.each(Object.keys(FIXTURE_SET))('screen: %s', (name) => { - it('restores on attach like it drew live, on both hosts', async () => { + it('restores on attach like it drew live', async () => { const file = path.join(FIXTURES, `${name.replace(/\W/g, '_')}.txt`) fs.writeFileSync(file, FIXTURE_SET[name]!) const size = { cols: 40, rows: 12 } @@ -336,7 +338,7 @@ describe.skipIf(process.platform === 'win32' || !fs.existsSync(RUST_BIN))('host ] } describe.each(Object.keys(RESIZES))('resize: %s', (name) => { - it('keeps the same text, cells and cursor as the Node host', async () => { + it('keeps the same text, cells and cursor as xterm', async () => { const file = path.join(FIXTURES, 'resize.txt') fs.writeFileSync( file, @@ -344,41 +346,48 @@ describe.skipIf(process.platform === 'win32' || !fs.existsSync(RUST_BIN))('host `${ESC}[32mgreen${ESC}[0m and more\r\n$ prompt` ) const steps = RESIZES[name]! - const result: Record> }> = {} - for (const kind of KINDS) { - const c = await Client.open(host(kind)) - const paneId = `rz${kind}${Math.random().toString(36).slice(2, 6)}` - await c.request(shPane(paneId, `stty -onlcr; cat ${JSON.stringify(file)}; sleep 30`)) - await until(() => c.output.get(paneId)?.includes('prompt'), 5000, 'output') - await sleep(200) - for (const [cols, rows] of steps) { - c.send({ t: 'resize', paneId, cols, rows }) - await sleep(100) - } - const [cols, rows] = steps[steps.length - 1]! - const text = (await c.request({ t: 'read', paneId, lines: 200 })) as { text: string } - const other = await Client.open(host(kind)) - await other.request({ t: 'attach', paneId, cols, rows }) - const replay = other.frames - .filter((f) => f.t === 'data' && f.paneId === paneId && f.replay) - .map((f) => String(f.data)) - .join('') - result[kind] = { text: text.text, drawn: await draw(replay, cols, rows) } - c.send({ t: 'kill', paneId }) - c.close() - other.close() + const c = await Client.open(host('rust')) + const paneId = `rz${Math.random().toString(36).slice(2, 6)}` + await c.request(shPane(paneId, `stty -onlcr; cat ${JSON.stringify(file)}; sleep 30`)) + await until(() => c.output.get(paneId)?.includes('prompt'), 5000, 'output') + await sleep(200) + // The reference: headless xterm given the same output and the same resizes. + const ref = headlessScreen(40, 12) + ref.write(c.output.get(paneId)!) + // xterm parses writes later: all of it must be in before the first resize, as it was in + // a host that had it for a while. + await ref.text() + for (const [cols, rows] of steps) { + c.send({ t: 'resize', paneId, cols, rows }) + ref.resize(cols, rows) + await sleep(100) } - const node = result.node! - const rust = result.rust! - const nodeLines = node.text.split('\n') + const [cols, rows] = steps[steps.length - 1]! + const text = (await c.request({ t: 'read', paneId, lines: 200 })) as { text: string } + const other = await Client.open(host('rust')) + await other.request({ t: 'attach', paneId, cols, rows }) + const replay = other.frames + .filter((f) => f.t === 'data' && f.paneId === paneId && f.replay) + .map((f) => String(f.data)) + .join('') + const rust = { text: text.text, drawn: await draw(replay, cols, rows) } + const xterm = { + text: await ref.text(200), + drawn: await draw(await ref.serialize(), cols, rows) + } + ref.dispose() + c.send({ t: 'kill', paneId }) + c.close() + other.close() + const xtermLines = xterm.text.split('\n') const rustLines = rust.text.split('\n') // Every line but the cursor's is the same, row for row and cell for cell. - expect(rustLines.slice(0, -1)).toEqual(nodeLines.slice(0, -1)) - expect(rust.drawn.lines.slice(0, -1)).toEqual(node.drawn.lines.slice(0, -1)) - expect(rust.drawn.cursor[1]).toBe(node.drawn.cursor[1]) + expect(rustLines.slice(0, -1)).toEqual(xtermLines.slice(0, -1)) + expect(rust.drawn.lines.slice(0, -1)).toEqual(xterm.drawn.lines.slice(0, -1)) + expect(rust.drawn.cursor[1]).toBe(xterm.drawn.cursor[1]) // ADR 0004, known differences: xterm cuts the cursor's line when it reflows a much // narrower width; the Rust host keeps the whole line, with the cursor after its text. - expect(rustLines.at(-1)!.startsWith(nodeLines.at(-1)!)).toBe(true) + expect(rustLines.at(-1)!.startsWith(xtermLines.at(-1)!)).toBe(true) expect(rust.drawn.cursor[0]).toBe(rustLines.at(-1)!.length) }) }) @@ -433,7 +442,7 @@ describe.skipIf(process.platform === 'win32' || !fs.existsSync(RUST_BIN))('host // ADR 0004, known differences: vt100 keeps only the active buffer, so the normal buffer under // a full-screen program is not restored by the Rust host. Pinned so a change is noticed. - it('alternate screen: leaving it after a restore shows the normal buffer (Node) or a blank (Rust)', async () => { + it('alternate screen: leaving it after a restore shows a blank, not the normal buffer', async () => { const file = path.join(FIXTURES, 'alt_leave.txt') fs.writeFileSync(file, FIXTURE_SET['alternate screen']!) const size = { cols: 40, rows: 12 } @@ -456,7 +465,7 @@ describe.skipIf(process.platform === 'win32' || !fs.existsSync(RUST_BIN))('host live.close() other.close() } - expect(under.node).toContain('u|1|') + // xterm (the Node host) restored it; the Rust host doesn't. expect(under.rust).not.toContain('u|1|') }) }) diff --git a/src/host/pty-manager.ts b/tests/support/node-host/pty-manager.ts similarity index 94% rename from src/host/pty-manager.ts rename to tests/support/node-host/pty-manager.ts index 081ac4f..c40e803 100644 --- a/src/host/pty-manager.ts +++ b/tests/support/node-host/pty-manager.ts @@ -1,5 +1,8 @@ +// A test-only copy of the Node host's pty manager (ADR 0004 phase 4b): the in-process host the +// client tests talk to, always with fake ptys. The product runs the Rust host. import { execFile } from 'node:child_process' -import type { IDisposable, IPty, IPtyForkOptions } from 'node-pty' +import type { SpawnRequest, SpawnResult } from '../../../src/host/protocol' +import type { IDisposable, IPty, IPtyForkOptions } from './pty-types' import type { PtyExitEvent } from '@shared/ipc-contract' /** On Windows `args` may be a prebuilt command line (used for .cmd shims). */ @@ -74,20 +77,6 @@ export interface PtyManagerDeps { killGraceMs?: number } -export interface SpawnRequest { - paneId: string - file: string - args: string[] | string - cwd: string - env: Record - cols: number - rows: number - /** For the host's pane status only; PtyManager ignores it. */ - approval?: string -} - -export type SpawnResult = { ok: true; pid: number } | { ok: false; error: string } - interface Entry { pty: IPty buffer: string diff --git a/tests/support/node-host/pty-types.ts b/tests/support/node-host/pty-types.ts new file mode 100644 index 0000000..5548795 --- /dev/null +++ b/tests/support/node-host/pty-types.ts @@ -0,0 +1,22 @@ +// The slice of node-pty's API the test host uses. node-pty is gone (ADR 0004 phase 4b): the +// tests hand the host fake ptys (tests/unit/host-fixtures.ts) that have just these members. +export interface IDisposable { + dispose(): void +} + +export interface IPty { + readonly pid: number + onData(listener: (data: string) => void): IDisposable + onExit(listener: (e: { exitCode: number; signal?: number }) => void): IDisposable + write(data: string): void + resize(cols: number, rows: number): void + kill(signal?: string): void +} + +export interface IPtyForkOptions { + name?: string + cols?: number + rows?: number + cwd?: string + env?: Record +} diff --git a/src/host/screen.ts b/tests/support/node-host/screen.ts similarity index 100% rename from src/host/screen.ts rename to tests/support/node-host/screen.ts diff --git a/src/host/server.ts b/tests/support/node-host/server.ts similarity index 98% rename from src/host/server.ts rename to tests/support/node-host/server.ts index 000c38b..88a6762 100644 --- a/src/host/server.ts +++ b/tests/support/node-host/server.ts @@ -1,3 +1,6 @@ +// A test-only copy of the Node host's server (ADR 0004 phase 4b): the in-process host that the +// pane-host, router and CLI tests talk to, with fake ptys. The product runs the Rust host; +// tests/host checks that one. // The pty host's core, free of sockets and process setup so it can be tested over an // in-memory stream: owns every pane process and its screen, and serves any number of // clients. A client sees a pane's output only after it created or attached that pane. @@ -17,7 +20,7 @@ import { type HostReply, type PaneState, type PaneSummary -} from './protocol' +} from '../../../src/host/protocol' import { PtyManager, type KillPolicy, type SpawnFn } from './pty-manager' import { headlessScreen, type Screen, type ScreenFactory } from './screen' diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts index 2d04b9f..9990a6c 100644 --- a/tests/unit/cli.test.ts +++ b/tests/unit/cli.test.ts @@ -1,11 +1,11 @@ import path from 'node:path' import { beforeEach, describe, expect, it } from 'vitest' -import type { IPty } from 'node-pty' +import type { IPty } from '../support/node-host/pty-types' import { AGENT_CLI_SPECS } from '@shared/agent-clis' import { parseConfig } from '@shared/schema' import type { Config } from '@shared/types' -import type { KillPolicy, SpawnFn } from '../../src/host/pty-manager' -import { HostServer } from '../../src/host/server' +import type { KillPolicy, SpawnFn } from '../support/node-host/pty-manager' +import { HostServer } from '../support/node-host/server' import { CLI_HELP, DETACH_KEY, isCliCommand, runCli, type Tty } from '../../src/main/cli' import { HostConnection, PaneHost } from '../../src/main/pane-host' import { FakePty, pipePair, tick } from './host-fixtures' diff --git a/tests/unit/host-binary.test.ts b/tests/unit/host-binary.test.ts new file mode 100644 index 0000000..ce886e1 --- /dev/null +++ b/tests/unit/host-binary.test.ts @@ -0,0 +1,35 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { hostBinary } from '../../src/main/host-start' + +// Where the window and `wraithgrid host-stdio` find the host (ADR 0004 phase 4b): from the +// folder of the built main code, packaged or not. +describe('the host binary', () => { + let root = '' + afterEach(() => rmSync(root, { recursive: true, force: true })) + const make = (...parts: string[]): string => { + const f = path.join(root, ...parts) + mkdirSync(path.dirname(f), { recursive: true }) + writeFileSync(f, '') + return f + } + + it('packaged: in resources, three folders up from app.asar/out/main', () => { + root = mkdtempSync(path.join(os.tmpdir(), 'wg-bin-')) + const bin = make('resources', 'wraithgrid-host') + const outDir = path.join(root, 'resources', 'app.asar', 'out', 'main') + expect(hostBinary(outDir, 'linux')).toBe(bin) + const exe = make('resources', 'wraithgrid-host.exe') + expect(hostBinary(outDir, 'win32')).toBe(exe) + }) + + it('development: the cargo release build beside out/, or none', () => { + root = mkdtempSync(path.join(os.tmpdir(), 'wg-bin-')) + const outDir = path.join(root, 'out', 'main') + expect(hostBinary(outDir, 'darwin')).toBeNull() + const bin = make('host-rs', 'target', 'release', 'wraithgrid-host') + expect(hostBinary(outDir, 'darwin')).toBe(bin) + }) +}) diff --git a/tests/unit/machines.test.ts b/tests/unit/machines.test.ts index 678f1a9..581233a 100644 --- a/tests/unit/machines.test.ts +++ b/tests/unit/machines.test.ts @@ -1,11 +1,12 @@ import { PassThrough, Writable } from 'node:stream' import { beforeEach, describe, expect, it } from 'vitest' -import type { IPty } from 'node-pty' +import type { IPty } from '../support/node-host/pty-types' import type { PtyExitEvent } from '@shared/ipc-contract' import { parseConfig } from '@shared/schema' import type { Machine } from '@shared/types' -import type { KillPolicy, SpawnFn, SpawnRequest } from '../../src/host/pty-manager' -import { HostServer } from '../../src/host/server' +import type { SpawnRequest } from '../../src/host/protocol' +import type { KillPolicy, SpawnFn } from '../support/node-host/pty-manager' +import { HostServer } from '../support/node-host/server' import { runBridge } from '../../src/main/host-bridge' import { PaneHost } from '../../src/main/pane-host' import { PaneRouter } from '../../src/main/pane-router' diff --git a/tests/unit/pty-host.test.ts b/tests/unit/pty-host.test.ts index 836d8f2..711ab94 100644 --- a/tests/unit/pty-host.test.ts +++ b/tests/unit/pty-host.test.ts @@ -3,7 +3,7 @@ import os from 'node:os' import path from 'node:path' import type { Duplex } from 'node:stream' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { IPty } from 'node-pty' +import type { IPty } from '../support/node-host/pty-types' import { FakePty, pipePair, tick } from './host-fixtures' import type { PtyExitEvent } from '@shared/ipc-contract' import { ensureHostDir, hostPaths } from '../../src/host/paths' @@ -15,8 +15,8 @@ import { chunkReplay, encodeFrame } from '../../src/host/protocol' -import type { KillPolicy, SpawnFn } from '../../src/host/pty-manager' -import { HostServer } from '../../src/host/server' +import type { KillPolicy, SpawnFn } from '../support/node-host/pty-manager' +import { HostServer } from '../support/node-host/server' import { OlderHostKept, PaneHost } from '../../src/main/pane-host' const req = (paneId: string) => ({ diff --git a/tests/unit/pty-manager.test.ts b/tests/unit/pty-manager.test.ts deleted file mode 100644 index 2738b59..0000000 --- a/tests/unit/pty-manager.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { IPty } from 'node-pty' -import type { PtyExitEvent } from '@shared/ipc-contract' -import { PtyManager, type KillPolicy, type SpawnFn } from '../../src/host/pty-manager' - -type Listener = (e: T) => void - -class FakePty { - static nextPid = 1000 - pid = FakePty.nextPid++ - written: string[] = [] - size = { cols: 0, rows: 0 } - private dataL: Listener[] = [] - private exitL: Listener<{ exitCode: number; signal?: number }>[] = [] - onData = (l: Listener) => { - this.dataL.push(l) - return { dispose: () => (this.dataL = this.dataL.filter((x) => x !== l)) } - } - onExit = (l: Listener<{ exitCode: number; signal?: number }>) => { - this.exitL.push(l) - return { dispose: () => (this.exitL = this.exitL.filter((x) => x !== l)) } - } - write = (d: string) => this.written.push(d) - resize = (cols: number, rows: number) => (this.size = { cols, rows }) - emit(d: string) { - this.dataL.forEach((l) => l(d)) - } - exit(code: number, signal?: number) { - this.exitL.forEach((l) => l({ exitCode: code, signal })) - } -} - -const req = (paneId: string) => ({ - paneId, - file: '/bin/claude', - args: [], - cwd: '/tmp', - env: {}, - cols: 80, - rows: 24 -}) - -describe('PtyManager', () => { - let ptys: FakePty[] - let data: [string, string][] - let exits: PtyExitEvent[] - let kills: [string, number][] - let mgr: PtyManager - - beforeEach(() => { - vi.useFakeTimers() - ptys = [] - data = [] - exits = [] - kills = [] - const spawn: SpawnFn = () => { - const p = new FakePty() - ptys.push(p) - return p as unknown as IPty - } - const killPolicy: KillPolicy = { - soft: (pty) => kills.push(['soft', pty.pid]), - hard: (pid) => kills.push(['hard', pid]), - sweep: (pid) => kills.push(['sweep', pid]) - } - mgr = new PtyManager({ - spawn, - killPolicy, - onData: (id, d) => data.push([id, d]), - onExit: (e) => exits.push(e) - }) - }) - afterEach(() => vi.useRealTimers()) - - it('coalesces output per pane into one message per 16 ms', () => { - mgr.create(req('a')) - mgr.create(req('b')) - ptys[0]!.emit('he') - ptys[0]!.emit('llo') - ptys[1]!.emit('x') - expect(data).toEqual([]) - vi.advanceTimersByTime(16) - expect(data).toEqual([ - ['a', 'hello'], - ['b', 'x'] - ]) - }) - - it('flushes pending output before reporting exit', () => { - mgr.create(req('a')) - ptys[0]!.emit('bye') - ptys[0]!.exit(1) - expect(data).toEqual([['a', 'bye']]) - expect(exits).toEqual([{ paneId: 'a', exitCode: 1, signal: null }]) - expect(mgr.has('a')).toBe(false) - }) - - it('routes write and resize to the right pane', () => { - mgr.create(req('a')) - mgr.create(req('b')) - mgr.write('b', 'ls\r') - mgr.resize('a', 120, 40) - expect(ptys[1]!.written).toEqual(['ls\r']) - expect(ptys[0]!.size).toEqual({ cols: 120, rows: 40 }) - // Unknown panes are ignored rather than throwing. - expect(() => mgr.write('zzz', 'x')).not.toThrow() - }) - - it('reports a spawn failure instead of throwing', () => { - const failing = new PtyManager({ - spawn: () => { - throw new Error('ENOENT: no such file') - }, - onData: () => {}, - onExit: () => {} - }) - expect(failing.create(req('a'))).toEqual({ ok: false, error: 'ENOENT: no such file' }) - }) - - it('kill stops softly, then hard after the grace period, without an exit event', async () => { - mgr.create(req('a')) - const pid = ptys[0]!.pid - const done = mgr.kill('a') - expect(kills).toEqual([['soft', pid]]) - vi.advanceTimersByTime(2000) - await done - expect(kills).toEqual([ - ['soft', pid], - ['hard', pid] - ]) - expect(exits).toEqual([]) - }) - - it('kill resolves early when the process exits, and sweeps its group', async () => { - mgr.create(req('a')) - const pid = ptys[0]!.pid - const done = mgr.kill('a') - ptys[0]!.exit(0, 1) - await done - expect(kills).toEqual([ - ['soft', pid], - ['sweep', pid] - ]) - expect(exits).toEqual([]) - }) - - it('killAll stops every pane', async () => { - mgr.create(req('a')) - mgr.create(req('b')) - const done = mgr.killAll() - ptys.forEach((p) => p.exit(0)) - await done - expect(mgr.size).toBe(0) - expect(kills.filter(([k]) => k === 'soft').map(([, pid]) => pid)).toEqual( - ptys.map((p) => p.pid) - ) - }) - - it('a restart replaces the old process and ignores its late exit', () => { - mgr.create(req('a')) - mgr.create(req('a')) - expect(ptys).toHaveLength(2) - ptys[0]!.exit(0) - expect(exits).toEqual([]) - expect(mgr.has('a')).toBe(true) - ptys[1]!.emit('new') - vi.advanceTimersByTime(16) - expect(data).toEqual([['a', 'new']]) - }) -})