From f9f51654e1e4efe6ab3dd37aace7f7a251c7b1c9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 13:07:45 +0000 Subject: [PATCH 1/4] chore: add the verify-libghostty-vt-node driving skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a project skill that verifies the package the way a user gets it: launch.sh builds and packs the checkout with a prebuild for this platform and installs the tarball into a scratch consumer project. doctor.sh checks that the bare import resolves inside the consumer and loads the packed prebuild. drive.sh runs one Node journey that imports @coder/libghostty-vt-node by name and keeps stdout, stderr and the exit code as evidence. cleanup.sh removes the instance and keeps the evidence. features/ maps five user-facing features (text, snapshot, resize, lifecycle and errors, install and native info) to 19 literal journeys that assert their own results. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ Change-Id: Ic299a2c8a99e1b9fc58835f2612873130f5532a5 Signed-off-by: Thomas Kosiewski --- .../skills/verify-libghostty-vt-node/SKILL.md | 99 +++++++++++++++ .../features/README.md | 41 ++++++ .../features/lifecycle.md | 120 ++++++++++++++++++ .../features/native-info.md | 79 ++++++++++++ .../features/resize.md | 77 +++++++++++ .../features/snapshot.md | 99 +++++++++++++++ .../features/terminal-text.md | 94 ++++++++++++++ .../scripts/cleanup.sh | 21 +++ .../scripts/doctor.sh | 71 +++++++++++ .../scripts/drive.sh | 34 +++++ .../scripts/launch.sh | 80 ++++++++++++ 11 files changed, 815 insertions(+) create mode 100644 .agents/skills/verify-libghostty-vt-node/SKILL.md create mode 100644 .agents/skills/verify-libghostty-vt-node/features/README.md create mode 100644 .agents/skills/verify-libghostty-vt-node/features/lifecycle.md create mode 100644 .agents/skills/verify-libghostty-vt-node/features/native-info.md create mode 100644 .agents/skills/verify-libghostty-vt-node/features/resize.md create mode 100644 .agents/skills/verify-libghostty-vt-node/features/snapshot.md create mode 100644 .agents/skills/verify-libghostty-vt-node/features/terminal-text.md create mode 100755 .agents/skills/verify-libghostty-vt-node/scripts/cleanup.sh create mode 100755 .agents/skills/verify-libghostty-vt-node/scripts/doctor.sh create mode 100755 .agents/skills/verify-libghostty-vt-node/scripts/drive.sh create mode 100755 .agents/skills/verify-libghostty-vt-node/scripts/launch.sh diff --git a/.agents/skills/verify-libghostty-vt-node/SKILL.md b/.agents/skills/verify-libghostty-vt-node/SKILL.md new file mode 100644 index 0000000..a84ba4c --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/SKILL.md @@ -0,0 +1,99 @@ +--- +name: verify-libghostty-vt-node +description: "Drive @coder/libghostty-vt-node the way a user does: build and pack the package from this checkout, install the tarball into a scratch consumer project, run Node journeys that import the package by name, and keep stdout, stderr, and exit codes as evidence. Use to prove a change to the public API (createTerminal, feed, snapshot, resize, dispose, getNativeInfo), the native addon, the loader, or the package contents works for a package user." +--- + +# Verify libghostty-vt-node as a package user + +The user surface is a Node library. A user runs `npm install @coder/libghostty-vt-node` and gets a prebuilt addon for the platform. Then the user imports the package by name and calls `createTerminal`, `feed`, `snapshot`, `resize`, `getVisibleText`, `formatPlain`, `formatHtml`, `dispose`, and `getNativeInfo`. This skill reproduces that path from the current checkout, uncommitted changes included. It does not import `src/` or `dist/` from the checkout. + +Other surfaces are out of scope here. The release workflows (`.github/workflows/publish.yml`, `mise run publish-dry-run`) publish to npm. Do not drive them for verification. The worker-thread and dispose lifecycle has a formal check in the `verify-native-lifecycle` skill. + +All commands run from the repo root. Set this first, and again in each new shell (agent tool calls often start a new shell, so set `S`, `RUN_ROOT`, and `PATH` in each call): + +```sh +S=.agents/skills/verify-libghostty-vt-node/scripts +``` + +## Requirements + +- The toolchain that `mise.toml` pins: Node `26.12.1` and Zig `0.15.2` on `PATH`. `mise install` installs them. If `mise exec` fails on your host, put the two install directories on `PATH` directly, for example `~/.local/share/mise/installs/node/26.12.1/bin` and `~/.local/share/mise/installs/zig/0.15.2`. +- A C++17 compiler and Python for `node-gyp`. +- A checkout that mise trusts. In an untrusted copy, a mise shim that a build step calls fails with `Config files in .../mise.toml are not trusted`, and `launch.sh` exits `1`. +- The network on the first launch: `npm ci`, the Ghostty clone in `npm run build:libghostty`, and the dependency install in the consumer project. + +## Launch + +```sh +$S/launch.sh # makes a new RUN_ROOT with mktemp +$S/launch.sh "$RUN_ROOT" # or uses a directory that you name, outside the checkout +``` + +`launch.sh` runs in the checkout: `npm ci` (only when `node_modules/` is missing), `npm run build:libghostty` (only when `vendor/libghostty-vt/lib/libghostty-vt.a` is missing; `RUN_ROOT/instance/launch.log` lists the steps that ran), `npm run build`, `npm run build:prebuild`, and `npm pack`. Then it installs the tarball into `RUN_ROOT/instance/consumer/` with `npm install`. It is ready when the last line is `READY RUN_ROOT=`. Set the shell variable `RUN_ROOT` to that path. It can differ from the path you gave, because `launch.sh` resolves symlinks. A warm launch takes about 15 seconds. The first one also builds libghostty-vt. + +`launch.sh` writes `build/`, `dist/`, and `prebuilds/` in the checkout, so only one launch can run per checkout. A second launch exits `2` and names the lock directory `vendor/.verify-launch.lock`. After launch, each `RUN_ROOT` is independent: many runs can drive their own instances at the same time. + +There is no server to tear down. Each journey is a short Node process. Teardown is `cleanup.sh`. + +## Doctor + +```sh +$S/doctor.sh "$RUN_ROOT" +``` + +It is read-only. Exit `0` means that the instance is worth driving. Each line starts with `ok`, `WARN`, or `FAIL`. It checks that: + +1. `instance.json` exists, `launch.sh` made it, and it names this checkout. +2. The checkout has the same `HEAD` and the same tracked diff as at launch. Otherwise it prints `STALE`: run `cleanup.sh`, then `launch.sh`. +3. `node` on `PATH` is the version that launch used. It warns when that is not the `mise.toml` pin. +4. The package is a real copy in the consumer's `node_modules`, not a symlink to the checkout. +5. `node-gyp-build` picks the packed prebuild (`prebuilds/-/@coder+libghostty-vt-node.node`). +6. The bare import resolves inside the consumer, and `getNativeInfo().packageVersion` matches `package.json`. + +Run it first, and again whenever a result looks wrong. + +## Drive + +A journey is an ES module that `drive.sh` reads from stdin and runs with `node` in the consumer project. It imports `@coder/libghostty-vt-node` by name and asserts its own result with `node:assert/strict`, so its exit code is the verdict. + +```sh +$S/drive.sh "$RUN_ROOT" my-journey <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 20, rows: 2 }); +t.feed("hi"); +console.log(JSON.stringify(t.getVisibleText())); +assert.equal(t.getVisibleText(), "hi\n"); +t.dispose(); +JS +``` + +Put node flags after the name, for example `$S/drive.sh "$RUN_ROOT" lifecycle-gc --expose-gc`. `drive.sh` prints the journey's stdout, then `--- exit N, evidence in ` on stderr, and it exits with the journey's code. When stdout and stderr share one stream, the exit line can show up out of order: read `exit-code.txt` for the verdict. A crash gives `128 + signal`: `134` for SIGABRT, `139` for SIGSEGV. + +The map in [`features/README.md`](features/README.md) has a literal journey for each sub-feature. Read the index, then use the matching feature file. + +## Evidence + +Each journey writes `RUN_ROOT/evidence//`: `journey.mjs`, `command.txt` (directory, node flags, time, node version), `stdout.txt`, `stderr.txt`, and `exit-code.txt`. `RUN_ROOT/evidence/instance.json` names the commit, the tracked-diff hash, and the tarball's sha256. `drive.sh` refuses to overwrite a journey's directory. + +Proof standards: + +- Drive the installed package through its public exports. Do not import `src/`, `dist/` in the checkout, the `.node` file, or the native `createTerminal` directly. +- A proof shows the action and the resulting state. Print what the journey saw (text, snapshot fields, error type and message), and assert it. +- Check side effects that a user can see: the files in the installed package (`info-package-contents`), and the addon file that `doctor.sh` reports. +- No mocks. The package has no external system to stand in for. +- A failed journey is a result. Report it with its stderr. Do not change the assertions to make it pass. + +## Cleanup + +```sh +$S/cleanup.sh "$RUN_ROOT" +``` + +It removes `RUN_ROOT/instance/` (the tarball, the consumer project, and the `no-addon` copy that `info-load-error` makes). It refuses an `instance/` without the `.verify-libghostty-vt-node` marker that `launch.sh` writes first, so it also cleans up after a failed launch. It keeps `RUN_ROOT/evidence/` and lists it. It does not touch the checkout's `build/`, `dist/`, or `prebuilds/`: those are normal build outputs, and git ignores them. Run cleanup after every run, the failed ones too. Remove `RUN_ROOT` yourself when you no longer need the evidence. + +## Gotchas + +- `launch.sh` packs whatever is in `prebuilds/`, including directories for other platforms from an earlier `mise run assemble-prebuilds`. `doctor.sh` checks that this platform's prebuild is the one that loads. +- `doctor.sh` hashes only tracked changes. A new untracked source file does not make the instance `STALE`. Relaunch after you add one. +- `RUN_ROOT` must be outside the checkout, so the instance never shows up in `git status` and no import can resolve to the checkout's own files. `launch.sh` refuses a `RUN_ROOT` inside it. diff --git a/.agents/skills/verify-libghostty-vt-node/features/README.md b/.agents/skills/verify-libghostty-vt-node/features/README.md new file mode 100644 index 0000000..e5c6c12 --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/README.md @@ -0,0 +1,41 @@ +# libghostty-vt-node verification map + +This directory is the maintained source for verifying what a user of `@coder/libghostty-vt-node` sees. Read this index before you drive the package. Then use the matching feature file as the recipe. + +## Baseline preconditions + +- Run every command from the repo root. +- `RUN_ROOT` is a directory outside the checkout that `launch.sh` made for this run. Never drive a `RUN_ROOT` that another run made. +- `doctor.sh "$RUN_ROOT"` exits `0`. Run it again after any change to the checkout: it fails with `STALE` when the build no longer matches the code. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`. + +## Driving conventions + +- Each journey is an ES module that `drive.sh` runs in the consumer project. It imports `@coder/libghostty-vt-node` by name, the way a user does. +- Each journey asserts its own result with `node:assert/strict`, so the exit code is the verdict. It also prints what it saw, so stdout shows the result. +- Copy each command literally. Give each run a new journey name: `drive.sh` refuses to overwrite evidence. Add a suffix such as `-2` for a second run. +- Each journey creates its own terminals and disposes them. No journey depends on another one. + +## Proof and skip reporting + +- A journey's proof is its directory in `$RUN_ROOT/evidence/`: `journey.mjs`, `command.txt`, `stdout.txt`, `stderr.txt`, and `exit-code.txt`. Also keep `$RUN_ROOT/evidence/instance.json`, which names the commit and tarball. +- Report the journey name and its exit code with every claim. +- Report a failed journey with its stderr. Do not change a journey's assertions to make it pass. +- When a precondition fails, report the command and the unmet precondition. Do not report a skipped sub-feature as verified through another one. + +## Feature entry contract + +Each feature file starts with an H1 title and one paragraph that describes what the user sees. It then has exactly four H2 sections in this order. + +1. `Sub-features` lists short IDs, one line for each behavior. Each ID is also the journey name. +2. `How to get to it (user POV)` lists each public API call or install step that reaches the feature. +3. `Driving it with drive.sh` starts with `Preconditions:` and pairs each action with a literal command and its observable result. +4. `Gotchas` lists traps that can waste or invalidate a run. + +## Features + +- [Feed bytes and read text](./terminal-text.md) covers `feed` input kinds, escape handling, split UTF-8, and the debug formatters. +- [Structured snapshot](./snapshot.md) covers size, cursor, styled cells, scrollback, and the alternate screen. +- [Resize](./resize.md) covers new sizes, reflow, and invalid sizes. +- [Lifecycle and errors](./lifecycle.md) covers `dispose`, garbage collection, invalid input, and worker threads. +- [Install, load, and native info](./native-info.md) covers the installed package, `getNativeInfo`, and the missing-addon error. diff --git a/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md b/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md new file mode 100644 index 0000000..64b39be --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md @@ -0,0 +1,120 @@ +# Lifecycle and errors + +A user creates terminals, disposes them, and lets the garbage collector free the ones they drop. Bad options and use after `dispose()` throw clear errors. A user can also load the package in `worker_threads`. + +## Sub-features + +- `lifecycle-dispose` makes `dispose()` idempotent, and every method throws after it. +- `lifecycle-gc` frees dropped terminals, disposed or not, without a crash. +- `lifecycle-invalid` rejects bad `createTerminal` options and bad `feed` data. +- `lifecycle-workers` creates terminals in the main thread after a Worker loaded the package. + +## How to get to it (user POV) + +- `term.dispose()`, then any method on `term`. +- Drop every reference to a terminal and let V8 collect it. +- `createTerminal(options)` with wrong options, and `term.feed(data)` with a wrong type. +- `new Worker(...)` that imports `@coder/libghostty-vt-node`. + +## Driving it with drive.sh + +Preconditions: + +- `launch.sh` printed `READY RUN_ROOT=...`, and `doctor.sh "$RUN_ROOT"` exits `0`. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`, run from the repo root. + +- **Dispose.** Dispose twice, then call each method. Run `lifecycle-dispose`. Exit `0`. Stdout lists 6 methods, each with `GhosttyVtTerminal has been disposed`. + +```sh +$S/drive.sh "$RUN_ROOT" lifecycle-dispose <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 2 }); +t.dispose(); t.dispose(); +const calls = { feed: () => t.feed("x"), resize: () => t.resize(5, 5), snapshot: () => t.snapshot(), + getVisibleText: () => t.getVisibleText(), formatPlain: () => t.formatPlain(), formatHtml: () => t.formatHtml() }; +for (const [name, call] of Object.entries(calls)) { + assert.throws(call, /GhosttyVtTerminal has been disposed/); + console.log(name, "throws: GhosttyVtTerminal has been disposed"); +} +JS +``` + +- **Garbage collection.** Create 200 terminals in a function, dispose every other one, return, and force GC. Run `lifecycle-gc` with `--expose-gc`. Exit `0`, and stdout is `collected 100 disposed and 100 open terminals`. Each count proves that the `TerminalWrap` finalizer ran on that kind of terminal without a crash. + +```sh +$S/drive.sh "$RUN_ROOT" lifecycle-gc --expose-gc <<'JS' +import { createTerminal } from "@coder/libghostty-vt-node"; +const freed = new FinalizationRegistry((kind) => { freed.counts[kind]++; }); +freed.counts = { disposed: 0, open: 0 }; +// Create in a function, so no local variable keeps a terminal alive. +function createAndDrop(n) { + for (let i = 0; i < n; i++) { + const t = createTerminal({ cols: 20, rows: 5 }); + t.feed(`t${i}`); + const kind = i % 2 === 0 ? "disposed" : "open"; + if (kind === "disposed") t.dispose(); + freed.register(t, kind); + } +} +createAndDrop(200); +for (let i = 0; i < 20 && freed.counts.disposed + freed.counts.open < 200; i++) { + globalThis.gc(); await new Promise((r) => setTimeout(r, 10)); +} +console.log(`collected ${freed.counts.disposed} disposed and ${freed.counts.open} open terminals`); +if (freed.counts.disposed !== 100 || freed.counts.open !== 100) process.exit(1); +JS +``` + +- **Invalid input.** Run `lifecycle-invalid`. Exit `0`. Stdout shows each call with its error: `null` options give `AssertionError: options must not be null`, `cols: 0` gives `TypeError: cols must be a positive integer`, `scrollbackLimit: -1` gives `TypeError: scrollbackLimit must be a non-negative integer`, `cols: 70000` gives `RangeError: cols must be a positive 16-bit integer`, and `feed(123)` and `feed(new Uint16Array(2))` give `TypeError: feed data must be a string, Buffer, or Uint8Array`. + +```sh +$S/drive.sh "$RUN_ROOT" lifecycle-invalid <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 2 }); +const cases = [ + ["createTerminal(null)", () => createTerminal(null), "AssertionError", /options must not be null/], + ["createTerminal({cols:0})", () => createTerminal({ cols: 0, rows: 2 }), "TypeError", /cols must be a positive integer/], + ["createTerminal({scrollbackLimit:-1})", () => createTerminal({ cols: 2, rows: 2, scrollbackLimit: -1 }), "TypeError", /scrollbackLimit must be a non-negative integer/], + ["createTerminal({cols:70000})", () => createTerminal({ cols: 70000, rows: 2 }), "RangeError", /positive 16-bit integer/], + ["feed(123)", () => t.feed(123), "TypeError", /string, Buffer, or Uint8Array/], + ["feed(Uint16Array)", () => t.feed(new Uint16Array(2)), "TypeError", /string, Buffer, or Uint8Array/], +]; +for (const [label, call, type, msg] of cases) { + try { call(); assert.fail(`${label} did not throw`); } + catch (e) { console.log(label, "->", e.constructor.name + ":", e.message); + assert.equal(e.constructor.name, type); assert.match(e.message, msg); } +} +t.feed("still usable"); assert.equal(t.getVisibleText(), "still usab\nle"); +t.dispose(); +JS +``` + +- **Worker threads.** The main thread creates a terminal, a Worker loads the package, and the main thread creates another terminal. Run `lifecycle-workers`. Exit `0`, and stdout ends with `main: created a terminal after the worker loaded`. A failure kills the process with a signal, so `drive.sh` exits `134` (SIGABRT, stderr has `Cannot create a handle without a HandleScope`) or `139` (SIGSEGV). That is a regression of finding F1 in the `verify-native-lifecycle` skill: report it, and do not change the journey to make it pass. + +```sh +$S/drive.sh "$RUN_ROOT" lifecycle-workers <<'JS' +import { Worker } from "node:worker_threads"; +import { once } from "node:events"; +import { createTerminal } from "@coder/libghostty-vt-node"; +createTerminal({ cols: 10, rows: 2 }).dispose(); +console.log("main: created a terminal"); +const w = new Worker(`import("@coder/libghostty-vt-node").then((m) => { + m.createTerminal({ cols: 10, rows: 2 }).dispose(); + import("node:worker_threads").then(({ parentPort }) => parentPort.postMessage("loaded")); +});`, { eval: true }); +await once(w, "message"); +console.log("worker: loaded the package and created a terminal"); +createTerminal({ cols: 10, rows: 2 }).dispose(); +console.log("main: created a terminal after the worker loaded"); +await w.terminate(); +JS +``` + +## Gotchas + +- `lifecycle-workers` checks one order of events. The `verify-native-lifecycle` skill checks every order within its bounds, and `test/workers.test.ts` covers an exited worker and two workers. +- `lifecycle-gc` needs `--expose-gc` after the journey name. Without it, `globalThis.gc` is undefined and the journey throws. +- GC timing varies. The journey retries 20 times. A count below 100 for either kind after that is a failure to report, not a reason to raise the retry count. Create terminals inside a function as the journey does: a terminal held by a top-level variable or array is never collected. +- A Worker's `eval` code resolves `@coder/libghostty-vt-node` from the consumer project, because `drive.sh` runs node in that directory. diff --git a/.agents/skills/verify-libghostty-vt-node/features/native-info.md b/.agents/skills/verify-libghostty-vt-node/features/native-info.md new file mode 100644 index 0000000..d545d80 --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/native-info.md @@ -0,0 +1,79 @@ +# Install, load, and native info + +A user installs the npm package and imports it. The package loads the prebuilt addon for the user's platform without a compiler. `getNativeInfo()` reports the package version, Node-API version, platform, and Ghostty version. When no addon fits, the import works and the first native call throws a clear setup error. + +## Sub-features + +- `info-fields` reports the package version, Node-API version, platform, arch, and Ghostty version. +- `info-package-contents` ships `dist/` and the prebuild, and leaves sources and tests out. +- `info-load-error` throws the setup error when the addon is missing. + +## How to get to it (user POV) + +- `npm install @coder/libghostty-vt-node`, then `import { getNativeInfo, createTerminal } from "@coder/libghostty-vt-node"`. +- `getNativeInfo()`. + +## Driving it with drive.sh + +Preconditions: + +- `launch.sh` printed `READY RUN_ROOT=...`, and `doctor.sh "$RUN_ROOT"` exits `0`. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`, run from the repo root. + +- **Native info.** Run `info-fields`. Exit `0`. Stdout is one JSON object with `packageVersion` equal to `package.json`'s version, `napiVersion` 8 or more, `platform` and `arch` of this machine, and a `ghosttyVersion` string. + +```sh +$S/drive.sh "$RUN_ROOT" info-fields <<'JS' +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { getNativeInfo } from "@coder/libghostty-vt-node"; +const info = getNativeInfo(); +const pkg = JSON.parse(readFileSync("node_modules/@coder/libghostty-vt-node/package.json", "utf8")); +console.log(JSON.stringify(info)); +assert.equal(info.packageVersion, pkg.version); +assert.ok(info.napiVersion >= 8); +assert.deepEqual([info.platform, info.arch], [process.platform, process.arch]); +assert.equal(typeof info.ghosttyVersion, "string"); +JS +``` + +- **Package contents.** List the installed package. Run `info-package-contents`. Exit `0`. Stdout lists `dist/index.js`, `dist/index.d.ts`, and `prebuilds/-/@coder+libghostty-vt-node.node`, and no `src/`, `test/`, `vendor/`, or `build/` entry. + +```sh +$S/drive.sh "$RUN_ROOT" info-package-contents <<'JS' +import assert from "node:assert/strict"; +import { readdirSync } from "node:fs"; +const dir = "node_modules/@coder/libghostty-vt-node"; +const files = readdirSync(dir, { recursive: true }).map(String).sort(); +console.log(files.join("\n")); +const addon = `prebuilds/${process.platform}-${process.arch}/@coder+libghostty-vt-node.node`; +for (const f of ["dist/index.js", "dist/index.d.ts", addon]) assert.ok(files.includes(f), `missing ${f}`); +for (const d of ["src", "test", "vendor", "build"]) assert.ok(!files.includes(d), `ships ${d}/`); +JS +``` + +- **Missing addon.** The journey copies the installed package to `instance/no-addon/` without `prebuilds/`, imports the copy, and calls `getNativeInfo()`. Run `info-load-error`. Exit `0`. Stdout shows that the import worked, then `Failed to load @coder/libghostty-vt-node native addon. Build it with \`npm run build:native\`...`. + +```sh +$S/drive.sh "$RUN_ROOT" info-load-error <<'JS' +import assert from "node:assert/strict"; +import { cpSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +const copy = resolve("../no-addon/node_modules/@coder/libghostty-vt-node"); +rmSync(resolve("../no-addon"), { recursive: true, force: true }); +cpSync("node_modules/@coder/libghostty-vt-node", copy, { recursive: true }); +cpSync("node_modules/node-gyp-build", resolve("../no-addon/node_modules/node-gyp-build"), { recursive: true }); +rmSync(resolve(copy, "prebuilds"), { recursive: true }); +const m = await import(pathToFileURL(resolve(copy, "dist/index.js")).href); +console.log("import worked"); +assert.throws(() => m.getNativeInfo(), (e) => { console.log(e.message); return /^Failed to load @coder\/libghostty-vt-node native addon\./.test(e.message); }); +JS +``` + +## Gotchas + +- `getNativeInfo()` has no `ghosttyCommit` with the current build (the Ghostty build reports no build string). Do not use it to check the pinned commit. +- `ghosttyVersion` is `0.1.0-dev` at the current pin. It is Ghostty's version, not this package's. +- The import itself never fails for a missing addon. The loader runs on the first native call (`createTerminal` or `getNativeInfo`). +- `info-load-error` writes `instance/no-addon/`. `cleanup.sh` removes it with the instance. diff --git a/.agents/skills/verify-libghostty-vt-node/features/resize.md b/.agents/skills/verify-libghostty-vt-node/features/resize.md new file mode 100644 index 0000000..a05174f --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/resize.md @@ -0,0 +1,77 @@ +# Resize + +A user resizes the terminal when the real terminal window changes size. The snapshot reports the new size, and wrapped text reflows to the new width. + +## Sub-features + +- `resize-dims` changes `cols` and `rows` in the snapshot. +- `resize-reflow` rewraps a long line when the width shrinks and joins it again when the width grows. +- `resize-invalid` rejects sizes that are not positive 16-bit integers. + +## How to get to it (user POV) + +- `term.resize(cols, rows)`. + +## Driving it with drive.sh + +Preconditions: + +- `launch.sh` printed `READY RUN_ROOT=...`, and `doctor.sh "$RUN_ROOT"` exits `0`. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`, run from the repo root. + +- **New size.** Resize 80x24 to 100x30. Run `resize-dims`. Exit `0`, and stdout is `100 30`. + +```sh +$S/drive.sh "$RUN_ROOT" resize-dims <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 80, rows: 24 }); +t.resize(100, 30); +const s = t.snapshot(); +console.log(s.cols, s.rows); +assert.deepEqual([s.cols, s.rows, s.visibleLines.length], [100, 30, 30]); +t.dispose(); +JS +``` + +- **Reflow.** Write `0123456789abc` into a 10-column terminal, shrink it to 5 columns, then widen it to 20. Run `resize-reflow`. Exit `0`. Stdout shows `0123456789/abc`, then `01234/56789/abc`, then `0123456789abc`. + +```sh +$S/drive.sh "$RUN_ROOT" resize-reflow <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 3 }); +const rows = () => t.snapshot().visibleLines.map((l) => l.text).filter(Boolean).join("/"); +t.feed("0123456789abc"); +const before = rows(); t.resize(5, 6); +const narrow = rows(); t.resize(20, 3); +const wide = rows(); +console.log(before); console.log(narrow); console.log(wide); +assert.deepEqual([before, narrow, wide], ["0123456789/abc", "01234/56789/abc", "0123456789abc"]); +t.dispose(); +JS +``` + +- **Invalid sizes.** Call `resize(0, 1)`, `resize(1.5, 1)`, and `resize(70000, 1)`. Run `resize-invalid`. Exit `0`. The first two throw `TypeError: cols must be a positive integer`. The third throws `RangeError: cols must be a positive 16-bit integer`. The size stays 10x3. + +```sh +$S/drive.sh "$RUN_ROOT" resize-invalid <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 3 }); +for (const [cols, Err, msg] of [[0, TypeError, /positive integer/], [1.5, TypeError, /positive integer/], + [70000, RangeError, /positive 16-bit integer/]]) { + try { t.resize(cols, 1); assert.fail(`resize(${cols}) did not throw`); } + catch (e) { console.log(`resize(${cols}, 1):`, e.constructor.name, e.message); + assert.ok(e instanceof Err); assert.match(e.message, msg); } +} +const s = t.snapshot(); +assert.deepEqual([s.cols, s.rows], [10, 3]); +t.dispose(); +JS +``` + +## Gotchas + +- Only wrapped lines reflow. A line that ended with `\r\n` stays its own row. +- The JS layer rejects non-integers and values below 1 with `TypeError`. The native layer rejects values above 65535 with `RangeError`. Both layers are on the user path, so test both. diff --git a/.agents/skills/verify-libghostty-vt-node/features/snapshot.md b/.agents/skills/verify-libghostty-vt-node/features/snapshot.md new file mode 100644 index 0000000..5a2f8f6 --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/snapshot.md @@ -0,0 +1,99 @@ +# Structured snapshot + +A user calls `term.snapshot()` to read the screen as data: size, cursor position, alternate-screen flag, one entry for each visible row and, on request, the styled cells and the scrollback rows. + +## Sub-features + +- `snapshot-shape` reports size, cursor, and one `visibleLines` entry for each row. +- `snapshot-cells` reports each cell's text, width, and style when `includeCells` is set. +- `snapshot-scrollback` reports rows that scrolled off when `includeScrollback` is set. +- `snapshot-alt-screen` reports `isAltScreen` and the alternate screen's text. + +## How to get to it (user POV) + +- `term.snapshot()`, `term.snapshot({ includeCells: true })`, `term.snapshot({ includeScrollback: true })`. +- `createTerminal({ cols, rows, scrollbackLimit })` sets how many rows scrollback keeps. + +## Driving it with drive.sh + +Preconditions: + +- `launch.sh` printed `READY RUN_ROOT=...`, and `doctor.sh "$RUN_ROOT"` exits `0`. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`, run from the repo root. + +- **Shape and cursor.** Write `hello` on the first row, move the cursor to row 3, column 5 (`ESC[3;5H`), and write `cursor`. Run `snapshot-shape`. Exit `0`. Stdout shows `cols` 80, `rows` 24, `cursorRow` 2, `cursorCol` 10 (both zero-based), the first 3 visible lines, and no `cells` or `scrollbackLines` key. The journey asserts that there are 24 visible lines. + +```sh +$S/drive.sh "$RUN_ROOT" snapshot-shape <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 80, rows: 24 }); +t.feed("hello\r\n\x1b[3;5Hcursor"); +const s = t.snapshot(); +console.log(JSON.stringify({ ...s, visibleLines: s.visibleLines.slice(0, 3) })); +assert.deepEqual([s.cols, s.rows, s.cursorRow, s.cursorCol, s.isAltScreen], [80, 24, 2, 10, false]); +assert.equal(s.visibleLines.length, 24); +assert.deepEqual(s.visibleLines[2], { row: 2, text: " cursor" }); +assert.ok(!("cells" in s) && !("scrollbackLines" in s)); +t.dispose(); +JS +``` + +- **Styled cells.** Write `S` bold, italic, underlined, red on green, then `R` in RGB `1;2;3`. Run `snapshot-cells`. Exit `0`. The `S` cell has `bold`, `italic`, `underline`, `foreground` `#cc6666`, and `background` `#b5bd68`. The `R` cell has `foreground` `#010203` and no other style keys. + +```sh +$S/drive.sh "$RUN_ROOT" snapshot-cells <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 1 }); +t.feed("\x1b[1;3;4;31;42mS\x1b[0m\x1b[38;2;1;2;3mR\x1b[0m"); +const cells = t.snapshot({ includeCells: true }).cells; +console.log(JSON.stringify(cells)); +assert.deepEqual(cells[0], { row: 0, col: 0, text: "S", width: 1, bold: true, italic: true, + underline: true, foreground: "#cc6666", background: "#b5bd68" }); +assert.deepEqual(cells[1], { row: 0, col: 1, text: "R", width: 1, foreground: "#010203" }); +t.dispose(); +JS +``` + +- **Scrollback.** Write 6 lines into a 3-row terminal with `scrollbackLimit: 100`. Run `snapshot-scrollback`. Exit `0`. `scrollbackLines` holds `l1`, `l2`, `l3` (rows 0 to 2). The visible rows are `l4`, `l5`, `l6`. + +```sh +$S/drive.sh "$RUN_ROOT" snapshot-scrollback <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 3, scrollbackLimit: 100 }); +t.feed("l1\r\nl2\r\nl3\r\nl4\r\nl5\r\nl6"); +const s = t.snapshot({ includeScrollback: true }); +console.log(JSON.stringify({ scrollbackLines: s.scrollbackLines, visibleLines: s.visibleLines })); +assert.deepEqual(s.scrollbackLines.map((l) => l.text), ["l1", "l2", "l3"]); +assert.deepEqual(s.visibleLines.map((l) => l.text), ["l4", "l5", "l6"]); +t.dispose(); +JS +``` + +- **Alternate screen.** Write `primary`, enter the alternate screen (`ESC[?1049h`), move the cursor home (`ESC[H`), write `alt`, then leave it (`ESC[?1049l`). Run `snapshot-alt-screen`. Exit `0`. Stdout shows `true` with the text `alt`, then `false` with the text `primary`. + +```sh +$S/drive.sh "$RUN_ROOT" snapshot-alt-screen <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 20, rows: 2 }); +t.feed("primary\x1b[?1049h\x1b[Halt"); +const inAlt = t.snapshot(); +t.feed("\x1b[?1049l"); +const back = t.snapshot(); +console.log(inAlt.isAltScreen, JSON.stringify(inAlt.visibleLines[0].text)); +console.log(back.isAltScreen, JSON.stringify(back.visibleLines[0].text)); +assert.deepEqual([inAlt.isAltScreen, inAlt.visibleLines[0].text], [true, "alt"]); +assert.deepEqual([back.isAltScreen, back.visibleLines[0].text], [false, "primary"]); +t.dispose(); +JS +``` + +## Gotchas + +- `cells` lists only cells that have text. Blank cells and the spacer cell after a wide character are not in it. +- Palette colors come from Ghostty's default palette (red is `#cc6666`). A change of the pinned Ghostty commit can change these values. Treat that as a change to check, not as noise. +- `scrollbackLines` is an empty array until a row scrolls off. With `scrollbackLimit: 0` it stays empty. +- Entering the alternate screen keeps the cursor column. Feed `ESC[H` first when the test expects text at column 0. diff --git a/.agents/skills/verify-libghostty-vt-node/features/terminal-text.md b/.agents/skills/verify-libghostty-vt-node/features/terminal-text.md new file mode 100644 index 0000000..ebccf5f --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/features/terminal-text.md @@ -0,0 +1,94 @@ +# Feed bytes and read text + +A user creates a terminal, feeds it the bytes a program wrote (a string, a `Buffer`, or a `Uint8Array`), and reads the screen back as plain text. Escape sequences change the screen but never show up in the text. `formatPlain()` and `formatHtml()` export the same screen for debugging. + +## Sub-features + +- `text-feed-kinds` accepts a string, a `Buffer`, and a `Uint8Array`. +- `text-sgr-hidden` keeps color escapes out of the visible text. +- `text-utf8-split` joins a UTF-8 character that arrives split across feeds. +- `text-formatters` exports the screen as plain text and as HTML. + +## How to get to it (user POV) + +- `createTerminal({ cols, rows })`, then `term.feed(data)` and `term.getVisibleText()`. +- `term.formatPlain()` and `term.formatHtml()` on the same terminal. + +## Driving it with drive.sh + +Preconditions: + +- `launch.sh` printed `READY RUN_ROOT=...`, and `doctor.sh "$RUN_ROOT"` exits `0`. +- `S=.agents/skills/verify-libghostty-vt-node/scripts`, run from the repo root. + +- **Feed each kind and read the text.** Run `text-feed-kinds`. Exit `0`, and stdout is `"hello\nbuf\nu8\n"`: one line for each feed, then the empty fourth row. + +```sh +$S/drive.sh "$RUN_ROOT" text-feed-kinds <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 20, rows: 4 }); +t.feed("hello\r\n"); +t.feed(Buffer.from("buf\r\n")); +t.feed(new Uint8Array([0x75, 0x38, 0x0d, 0x0a])); +const text = t.getVisibleText(); +console.log(JSON.stringify(text)); +assert.equal(text, "hello\nbuf\nu8\n"); +t.dispose(); +JS +``` + +- **Color escapes stay out of the text.** Run `text-sgr-hidden`. Exit `0`, and stdout is `"red text"`. + +```sh +$S/drive.sh "$RUN_ROOT" text-sgr-hidden <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 20, rows: 1 }); +t.feed("\x1b[31mred\x1b[0m \x1b[1;4mtext\x1b[0m"); +console.log(JSON.stringify(t.getVisibleText())); +assert.equal(t.getVisibleText(), "red text"); +t.dispose(); +JS +``` + +- **A split UTF-8 character.** Feed the three bytes of `界` in three calls, then `x`. Run `text-utf8-split`. Exit `0`. Stdout shows the text `"界x"` and two cells: `界` at col 0 with width 2, and `x` at col 2. + +```sh +$S/drive.sh "$RUN_ROOT" text-utf8-split <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 10, rows: 1 }); +const bytes = Buffer.from("界x"); +t.feed(bytes.subarray(0, 1)); t.feed(bytes.subarray(1, 2)); t.feed(bytes.subarray(2)); +const cells = t.snapshot({ includeCells: true }).cells; +console.log(JSON.stringify(t.getVisibleText()), JSON.stringify(cells)); +assert.equal(t.getVisibleText(), "界x"); +assert.deepEqual(cells.map((c) => [c.text, c.col, c.width]), [["界", 0, 2], ["x", 2, 1]]); +t.dispose(); +JS +``` + +- **Debug formatters.** Run `text-formatters`. Exit `0`. `formatPlain` prints `"hello\nred"`. The HTML starts with `
` and colors red with `var(--vt-palette-1)`. + +```sh +$S/drive.sh "$RUN_ROOT" text-formatters <<'JS' +import assert from "node:assert/strict"; +import { createTerminal } from "@coder/libghostty-vt-node"; +const t = createTerminal({ cols: 20, rows: 3 }); +t.feed("hello\r\n\x1b[31mred\x1b[0m"); +const plain = t.formatPlain(), html = t.formatHtml(); +console.log(JSON.stringify(plain)); console.log(html); +assert.equal(plain, "hello\nred"); +assert.ok(html.startsWith('
')); +assert.match(html, /color: var\(--vt-palette-1\);[^>]*>red&2 + exit 2 + } + rm -rf "$INSTANCE" + echo "removed $INSTANCE" +fi +if [[ -d "$RUN_ROOT/evidence" ]]; then + echo "evidence kept in $RUN_ROOT/evidence:" + ls -1 "$RUN_ROOT/evidence" +fi diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh new file mode 100755 index 0000000..98f8835 --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Read-only check: is the instance in RUN_ROOT worth driving? +# Usage: .agents/skills/verify-libghostty-vt-node/scripts/doctor.sh RUN_ROOT +# Exit: 0 = every check passed (WARN lines allowed), 1 = a check failed. +set -uo pipefail + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT="$(cd "$SKILL_DIR/../../.." && pwd -P)" +RUN_ROOT="${1:?usage: doctor.sh RUN_ROOT}" +INSTANCE="$RUN_ROOT/instance" +fail=0 +ok() { echo "ok $*"; } +bad() { echo "FAIL $*"; fail=1; } +warn() { echo "WARN $*"; } + +if [[ ! -f "$INSTANCE/instance.json" ]]; then + bad "no $INSTANCE/instance.json: run launch.sh first" + exit 1 +fi +ok "instance.json: $INSTANCE/instance.json" +# Physical path: node reports real paths, and RUN_ROOT can sit behind a symlink. +CONSUMER="$(cd "$INSTANCE/consumer" && pwd -P)" + +field() { node -e 'const i=require(process.argv[1]); process.stdout.write(String(i[process.argv[2]]))' "$INSTANCE/instance.json" "$1"; } +[[ "$(field marker)" == "verify-libghostty-vt-node" ]] && ok "made by launch.sh" || bad "instance.json was not made by launch.sh" +[[ "$(field checkout)" == "$ROOT" ]] && ok "built from this checkout" || bad "built from $(field checkout), not $ROOT" + +head_now="$(git -C "$ROOT" rev-parse HEAD)" +diff_now="$(git -C "$ROOT" diff HEAD | { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; } | cut -d' ' -f1)" +if [[ "$(field commit)" == "$head_now" && "$(field trackedDiffSha256)" == "$diff_now" ]]; then + ok "current: built from HEAD $head_now and the same uncommitted diff" +else + bad "STALE: the checkout changed after launch (HEAD or tracked diff). Run cleanup.sh, then launch.sh" +fi + +[[ "$(field node)" == "$(node --version)" ]] && ok "node $(node --version) matches launch" \ + || bad "node on PATH is $(node --version), launch used $(field node)" +pinned="$(sed -n 's/^node = "\(.*\)"/v\1/p' "$ROOT/mise.toml")" +[[ "$(node --version)" == "$pinned" ]] || warn "node $(node --version) is not the mise.toml pin $pinned" + +pkg_dir="$CONSUMER/node_modules/@coder/libghostty-vt-node" +[[ -d "$pkg_dir" && ! -L "$pkg_dir" ]] && ok "package installed as a copy in the consumer" \ + || bad "missing or symlinked $pkg_dir" + +addon="$(cd "$CONSUMER" && node -e 'process.stdout.write(require("node-gyp-build").path(process.argv[1]))' "$pkg_dir" 2>&1)" +case "$addon" in + "$pkg_dir"/prebuilds/*.node) ok "addon loads from the packed prebuild: ${addon#"$CONSUMER"/}" ;; + *) bad "node-gyp-build does not pick a packed prebuild: $addon" ;; +esac + +info="$(cd "$CONSUMER" && node --input-type=module -e ' + const url = import.meta.resolve("@coder/libghostty-vt-node"); + const { getNativeInfo } = await import(url); + console.log(JSON.stringify({ url, ...getNativeInfo() })); +' 2>&1)" +if [[ "$info" == "{"* ]]; then + ok "getNativeInfo: $info" + url="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).url)' "$info")" + case "$url" in + "file://$CONSUMER/node_modules/"*) ok "the bare import resolves inside the consumer" ;; + *) bad "the bare import resolves outside the consumer: $url" ;; + esac + version="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).packageVersion)' "$info")" + want="$(node -p 'require(process.argv[1]).version' "$ROOT/package.json")" + [[ "$version" == "$want" ]] && ok "packageVersion $version matches package.json" \ + || bad "packageVersion $version, package.json has $want" +else + bad "the package does not load in the consumer: $info" +fi + +exit $fail diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh b/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh new file mode 100755 index 0000000..f31b325 --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Run one journey: an ES module read from stdin, run with node inside the +# consumer project, so `import ... from "@coder/libghostty-vt-node"` loads the +# installed package. Saves the script, stdout, stderr, and exit code as evidence. +# +# Usage: .agents/skills/verify-libghostty-vt-node/scripts/drive.sh RUN_ROOT NAME [node flags...] <<'JS' +# import { createTerminal } from "@coder/libghostty-vt-node"; +# ... +# JS +# Writes: RUN_ROOT/evidence/NAME/{journey.mjs,command.txt,stdout.txt,stderr.txt,exit-code.txt} +# Exit: the journey's exit code. A signal gives 128 + the signal number. +set -uo pipefail + +RUN_ROOT="${1:?usage: drive.sh RUN_ROOT NAME [node flags...] < journey.mjs}" +NAME="${2:?usage: drive.sh RUN_ROOT NAME [node flags...] < journey.mjs}" +shift 2 +CONSUMER="$RUN_ROOT/instance/consumer" +OUT="$RUN_ROOT/evidence/$NAME" +[[ -f "$RUN_ROOT/instance/instance.json" ]] || { echo "error: no instance in $RUN_ROOT: run launch.sh" >&2; exit 2; } +[[ "$NAME" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || { echo "error: NAME must match [a-z0-9][a-z0-9._-]*" >&2; exit 2; } +[[ -e "$OUT" ]] && { echo "error: $OUT exists: pick a new NAME, evidence is never overwritten" >&2; exit 2; } + +mkdir -p "$OUT" "$CONSUMER/journeys" +cat > "$CONSUMER/journeys/$NAME.mjs" +cp "$CONSUMER/journeys/$NAME.mjs" "$OUT/journey.mjs" +echo "cd $CONSUMER && node $* journeys/$NAME.mjs # $(date -u +%Y-%m-%dT%H:%M:%SZ) $(node --version)" > "$OUT/command.txt" + +(cd "$CONSUMER" && node "$@" "journeys/$NAME.mjs") > "$OUT/stdout.txt" 2> "$OUT/stderr.txt" +code=$? +echo "$code" > "$OUT/exit-code.txt" +cat "$OUT/stdout.txt" +[[ -s "$OUT/stderr.txt" ]] && { echo "--- stderr" >&2; head -20 "$OUT/stderr.txt" >&2; } +echo "--- exit $code, evidence in $OUT" >&2 +exit $code diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh new file mode 100755 index 0000000..2a28d0d --- /dev/null +++ b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Build @coder/libghostty-vt-node from this checkout (with uncommitted changes), +# pack it with a prebuild for this platform, and install the tarball into a new +# consumer project, the way a user installs the package from npm. +# +# Usage: .agents/skills/verify-libghostty-vt-node/scripts/launch.sh [RUN_ROOT] +# RUN_ROOT default: a new mktemp dir. The last line is `READY RUN_ROOT=`. +# Makes: RUN_ROOT/instance/ (tarball, consumer project, instance.json, launch.log) +# RUN_ROOT/evidence/ (empty; drive.sh writes here, cleanup.sh keeps it) +set -euo pipefail + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT="$(cd "$SKILL_DIR/../../.." && pwd -P)" +RUN_ROOT="${1:-$(mktemp -d "${TMPDIR:-/tmp}/verify-libghostty-vt-node.XXXXXX")}" +# Resolve the physical path before creating anything, so the checks below see it. +RUN_ROOT="$(mkdir -p "$(dirname "$RUN_ROOT")" && cd "$(dirname "$RUN_ROOT")" && pwd -P)/$(basename "$RUN_ROOT")" +case "$RUN_ROOT/" in + "$ROOT"/*) echo "error: RUN_ROOT must be outside the checkout ($ROOT)." >&2; exit 2 ;; +esac +INSTANCE="$RUN_ROOT/instance" +EVIDENCE="$RUN_ROOT/evidence" + +if [[ -e "$INSTANCE" ]]; then + echo "error: $INSTANCE exists. Run cleanup.sh $RUN_ROOT first, or use a new RUN_ROOT." >&2 + exit 2 +fi + +# One launch at a time per checkout: launch writes build/, dist/, and prebuilds/. +mkdir -p "$ROOT/vendor" +lock="$ROOT/vendor/.verify-launch.lock" +if ! mkdir "$lock" 2>/dev/null; then + echo "error: another launch is building in this checkout ($lock)." >&2 + echo "Wait for it. Remove the directory only if no launch.sh process runs." >&2 + exit 2 +fi +trap 'rmdir "$lock"' EXIT + +mkdir -p "$INSTANCE/consumer" "$EVIDENCE" +# cleanup.sh removes only an instance with this marker, also after a failed launch. +echo "made by launch.sh $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$INSTANCE/.verify-libghostty-vt-node" +log="$INSTANCE/launch.log" +step() { echo "== $*" | tee -a "$log"; } +run() { "$@" >>"$log" 2>&1 || { echo "error: '$*' failed. Last lines of $log:" >&2; tail -30 "$log" >&2; exit 1; }; } + +cd "$ROOT" +if [[ ! -d node_modules ]]; then step "npm ci"; run npm ci; fi +if [[ ! -f vendor/libghostty-vt/lib/libghostty-vt.a ]]; then + step "npm run build:libghostty (first build: needs zig 0.15.2 and the network)" + run npm run build:libghostty +fi +step "npm run build"; run npm run build +step "npm run build:prebuild"; run npm run build:prebuild +step "npm pack" +tarball="$(npm pack --silent --pack-destination "$INSTANCE" 2>>"$log" | tail -n 1)" +[[ -f "$INSTANCE/$tarball" ]] || { echo "error: npm pack did not write $INSTANCE/$tarball" >&2; exit 1; } + +step "install $tarball into $INSTANCE/consumer" +cat > "$INSTANCE/consumer/package.json" <<'JSON' +{ "name": "verify-libghostty-vt-node-consumer", "private": true, "type": "module" } +JSON +(cd "$INSTANCE/consumer" && run npm install --no-audit --no-fund "$INSTANCE/$tarball") + +sha() { if command -v sha256sum >/dev/null; then sha256sum "$1"; else shasum -a 256 "$1"; fi | cut -d' ' -f1; } +diff_sha="$(git -C "$ROOT" diff HEAD | { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; } | cut -d' ' -f1)" +INSTANCE="$INSTANCE" TARBALL="$tarball" TARBALL_SHA="$(sha "$INSTANCE/$tarball")" \ +COMMIT="$(git -C "$ROOT" rev-parse HEAD)" DIFF_SHA="$diff_sha" CHECKOUT="$ROOT" \ +node -e ' + const fs = require("node:fs"); + const e = process.env; + const info = { + marker: "verify-libghostty-vt-node", + checkout: e.CHECKOUT, commit: e.COMMIT, trackedDiffSha256: e.DIFF_SHA, + tarball: e.TARBALL, tarballSha256: e.TARBALL_SHA, + node: process.version, platform: process.platform, arch: process.arch, + createdAt: new Date().toISOString(), + }; + fs.writeFileSync(e.INSTANCE + "/instance.json", JSON.stringify(info, null, 2) + "\n"); +' +cp "$INSTANCE/instance.json" "$EVIDENCE/instance.json" +echo "READY RUN_ROOT=$RUN_ROOT" From db70475143b4325a056e9abf8dc0e96c498975bf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 13:24:24 +0000 Subject: [PATCH 2/4] chore: harden verify-libghostty-vt-node launch.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refuse a RUN_ROOT that another account can change (a symlink, a directory not owned by you or writable by group or others, or one in a parent that others can write without the sticky bit), and check the checkout boundary before creating anything. npm install runs package scripts from the tarball in RUN_ROOT. - Rerun npm ci and build:libghostty when their inputs changed, using a stamp with the hash of package-lock.json, or of the build script and the GHOSTTY_ / LIBGHOSTTY_VT_ variables. - Build from an empty dist/, so npm pack cannot ship stale output. - Record the checkout state before the build and fail if it changes before the end. - lifecycle-gc claims only what JS can see: V8 collected the Terminal objects and the process survived. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ Change-Id: Iad83169a805a150caaf433b81588de523ffc489f Signed-off-by: Thomas Kosiewski --- .../skills/verify-libghostty-vt-node/SKILL.md | 4 +- .../features/lifecycle.md | 4 +- .../scripts/launch.sh | 74 +++++++++++++++---- 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/.agents/skills/verify-libghostty-vt-node/SKILL.md b/.agents/skills/verify-libghostty-vt-node/SKILL.md index a84ba4c..b1cbbf8 100644 --- a/.agents/skills/verify-libghostty-vt-node/SKILL.md +++ b/.agents/skills/verify-libghostty-vt-node/SKILL.md @@ -29,7 +29,9 @@ $S/launch.sh # makes a new RUN_ROOT with mktemp $S/launch.sh "$RUN_ROOT" # or uses a directory that you name, outside the checkout ``` -`launch.sh` runs in the checkout: `npm ci` (only when `node_modules/` is missing), `npm run build:libghostty` (only when `vendor/libghostty-vt/lib/libghostty-vt.a` is missing; `RUN_ROOT/instance/launch.log` lists the steps that ran), `npm run build`, `npm run build:prebuild`, and `npm pack`. Then it installs the tarball into `RUN_ROOT/instance/consumer/` with `npm install`. It is ready when the last line is `READY RUN_ROOT=`. Set the shell variable `RUN_ROOT` to that path. It can differ from the path you gave, because `launch.sh` resolves symlinks. A warm launch takes about 15 seconds. The first one also builds libghostty-vt. +`launch.sh` runs in the checkout: `npm ci` (when `node_modules/` was not built from the current `package-lock.json`), `npm run build:libghostty` (when `vendor/libghostty-vt/` was not built from the current `scripts/build-libghostty-vt.sh` and `GHOSTTY_*` / `LIBGHOSTTY_VT_*` variables), `npm run build` from an empty `dist/`, `npm run build:prebuild`, and `npm pack`. A stamp file with the hash of those inputs records each reuse decision, and `RUN_ROOT/instance/launch.log` lists the steps that ran. It records the commit and tracked diff before the build, and it fails if they change before the end. Then it installs the tarball into `RUN_ROOT/instance/consumer/` with `npm install`. It is ready when the last line is `READY RUN_ROOT=`. Set the shell variable `RUN_ROOT` to that path. It can differ from the path you gave, because `launch.sh` resolves symlinks. A warm launch takes about 15 seconds. The first one also builds libghostty-vt. + +A `RUN_ROOT` that you name must be a real directory (not a symlink) that only you can write, in a parent that others cannot write (or that has the sticky bit, like `/tmp`). `launch.sh` creates a missing one with mode `700`. It refuses anything else, because `npm install` runs package scripts from the tarball in `RUN_ROOT`. `launch.sh` writes `build/`, `dist/`, and `prebuilds/` in the checkout, so only one launch can run per checkout. A second launch exits `2` and names the lock directory `vendor/.verify-launch.lock`. After launch, each `RUN_ROOT` is independent: many runs can drive their own instances at the same time. diff --git a/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md b/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md index 64b39be..b20e21c 100644 --- a/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md +++ b/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md @@ -5,7 +5,7 @@ A user creates terminals, disposes them, and lets the garbage collector free the ## Sub-features - `lifecycle-dispose` makes `dispose()` idempotent, and every method throws after it. -- `lifecycle-gc` frees dropped terminals, disposed or not, without a crash. +- `lifecycle-gc` lets V8 collect dropped terminals, disposed or not, without a crash. - `lifecycle-invalid` rejects bad `createTerminal` options and bad `feed` data. - `lifecycle-workers` creates terminals in the main thread after a Worker loaded the package. @@ -40,7 +40,7 @@ for (const [name, call] of Object.entries(calls)) { JS ``` -- **Garbage collection.** Create 200 terminals in a function, dispose every other one, return, and force GC. Run `lifecycle-gc` with `--expose-gc`. Exit `0`, and stdout is `collected 100 disposed and 100 open terminals`. Each count proves that the `TerminalWrap` finalizer ran on that kind of terminal without a crash. +- **Garbage collection.** Create 200 terminals in a function, dispose every other one, return, and force GC. Run `lifecycle-gc` with `--expose-gc`. Exit `0`, and stdout is `collected 100 disposed and 100 open terminals`. Each count proves that V8 collected the JS `Terminal` objects of that kind and that the process survived. JS cannot see the native `TerminalWrap` finalizer or the freed handle, so this journey does not prove that the handles were freed. The `verify-native-lifecycle` skill covers that. ```sh $S/drive.sh "$RUN_ROOT" lifecycle-gc --expose-gc <<'JS' diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh index 2a28d0d..a3c33be 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh @@ -11,12 +11,40 @@ set -euo pipefail SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$SKILL_DIR/../../.." && pwd -P)" -RUN_ROOT="${1:-$(mktemp -d "${TMPDIR:-/tmp}/verify-libghostty-vt-node.XXXXXX")}" -# Resolve the physical path before creating anything, so the checks below see it. -RUN_ROOT="$(mkdir -p "$(dirname "$RUN_ROOT")" && cd "$(dirname "$RUN_ROOT")" && pwd -P)/$(basename "$RUN_ROOT")" -case "$RUN_ROOT/" in - "$ROOT"/*) echo "error: RUN_ROOT must be outside the checkout ($ROOT)." >&2; exit 2 ;; -esac +outside_checkout() { + case "$1/" in + "$ROOT"/*) echo "error: RUN_ROOT must be outside the checkout ($ROOT)." >&2; exit 2 ;; + esac +} +RUN_ROOT="${1:-}" +if [[ -z "$RUN_ROOT" ]]; then + RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/verify-libghostty-vt-node.XXXXXX")" +elif [[ ! -e "$RUN_ROOT" && ! -L "$RUN_ROOT" ]]; then + parent="$(dirname -- "$RUN_ROOT")" + if [[ -d "$parent" ]]; then outside_checkout "$(cd "$parent" && pwd -P)/$(basename -- "$RUN_ROOT")"; fi + mkdir -p -- "$parent" + mkdir -m 700 -- "$RUN_ROOT" +fi +# The build installs the tarball from RUN_ROOT with npm, which runs package +# scripts. Refuse a RUN_ROOT that another account can change: a symlink, a +# directory owned by someone else or writable by group or others, or one in +# a parent that others can write without the sticky bit. +node -e ' + const fs = require("node:fs"), path = require("node:path"); + const dir = process.argv[1], uid = process.getuid(); + const fail = (why) => { console.error(`error: RUN_ROOT ${dir} ${why}`); process.exit(2); }; + const st = fs.lstatSync(dir); + if (st.isSymbolicLink()) fail("is a symlink: pass the real path"); + if (!st.isDirectory()) fail("is not a directory"); + if (st.uid !== uid) fail("is not owned by you"); + if (st.mode & 0o022) fail("is writable by group or others: chmod 700 it, or use a new path"); + const parent = fs.statSync(path.dirname(path.resolve(dir))); + const sticky = (parent.mode & 0o1000) !== 0; + if (!sticky && (parent.mode & 0o002)) fail("is in a parent that everyone can write"); + if (!sticky && (parent.mode & 0o020) && parent.uid !== uid) fail("is in a parent that another account can write"); +' "$RUN_ROOT" +RUN_ROOT="$(cd "$RUN_ROOT" && pwd -P)" +outside_checkout "$RUN_ROOT" INSTANCE="$RUN_ROOT/instance" EVIDENCE="$RUN_ROOT/evidence" @@ -41,14 +69,32 @@ echo "made by launch.sh $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$INSTANCE/.verify-lib log="$INSTANCE/launch.log" step() { echo "== $*" | tee -a "$log"; } run() { "$@" >>"$log" 2>&1 || { echo "error: '$*' failed. Last lines of $log:" >&2; tail -30 "$log" >&2; exit 1; }; } +sha_stdin() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi | cut -d' ' -f1; } +sha() { sha_stdin < "$1"; } +# The checkout state that this launch builds. It must not change until the end. +checkout_state() { echo "$(git -C "$ROOT" rev-parse HEAD) $(git -C "$ROOT" diff HEAD | sha_stdin)"; } +state_before="$(checkout_state)" cd "$ROOT" -if [[ ! -d node_modules ]]; then step "npm ci"; run npm ci; fi -if [[ ! -f vendor/libghostty-vt/lib/libghostty-vt.a ]]; then - step "npm run build:libghostty (first build: needs zig 0.15.2 and the network)" +# Reuse node_modules and libghostty-vt only when they were built from the +# current inputs. Each stamp holds the hash of those inputs. +deps_stamp="node_modules/.verify-libghostty-vt-node.sha256" +deps_inputs="$(sha package-lock.json)" +if [[ "$(cat "$deps_stamp" 2>/dev/null)" != "$deps_inputs" ]]; then + step "npm ci (node_modules is missing or older than package-lock.json)" + run npm ci + echo "$deps_inputs" > "$deps_stamp" +fi +lib_dir="${LIBGHOSTTY_VT_PREFIX:-$ROOT/vendor/libghostty-vt}" +lib_stamp="$lib_dir/.verify-libghostty-vt-node.sha256" +lib_inputs="$( { cat scripts/build-libghostty-vt.sh; env | grep -E '^(GHOSTTY_|LIBGHOSTTY_VT_)' | sort || true; } | sha_stdin)" +if [[ ! -f "$lib_dir/lib/libghostty-vt.a" || "$(cat "$lib_stamp" 2>/dev/null)" != "$lib_inputs" ]]; then + step "npm run build:libghostty (missing, or built from other inputs: needs zig 0.15.2 and the network)" run npm run build:libghostty + echo "$lib_inputs" > "$lib_stamp" fi -step "npm run build"; run npm run build +# tsc never deletes output whose source is gone, and npm pack ships all of dist/. +step "npm run build (from an empty dist/)"; rm -rf dist; run npm run build step "npm run build:prebuild"; run npm run build:prebuild step "npm pack" tarball="$(npm pack --silent --pack-destination "$INSTANCE" 2>>"$log" | tail -n 1)" @@ -60,10 +106,12 @@ cat > "$INSTANCE/consumer/package.json" <<'JSON' JSON (cd "$INSTANCE/consumer" && run npm install --no-audit --no-fund "$INSTANCE/$tarball") -sha() { if command -v sha256sum >/dev/null; then sha256sum "$1"; else shasum -a 256 "$1"; fi | cut -d' ' -f1; } -diff_sha="$(git -C "$ROOT" diff HEAD | { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; } | cut -d' ' -f1)" +if [[ "$(checkout_state)" != "$state_before" ]]; then + echo "error: the checkout changed during the launch (HEAD or tracked diff). Run cleanup.sh, then launch again." >&2 + exit 1 +fi INSTANCE="$INSTANCE" TARBALL="$tarball" TARBALL_SHA="$(sha "$INSTANCE/$tarball")" \ -COMMIT="$(git -C "$ROOT" rev-parse HEAD)" DIFF_SHA="$diff_sha" CHECKOUT="$ROOT" \ +COMMIT="${state_before% *}" DIFF_SHA="${state_before#* }" CHECKOUT="$ROOT" \ node -e ' const fs = require("node:fs"); const e = process.env; From 0821d9253fcacb7589544d817a2b52de78c3202a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 14:12:13 +0000 Subject: [PATCH 3/4] chore: refuse a RUN_ROOT below any directory others can change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parent check exempted a group-writable parent that you own, but any group member can rename the 0700 RUN_ROOT inside it and swap the tarball. launch.sh now walks every directory above RUN_ROOT and refuses one that another account owns (root excepted) or that group or others can write without the sticky bit. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ Change-Id: I7a31693d4d2846366c411e7998a15a2a05b9ed7f Signed-off-by: Thomas Kosiewski --- .../skills/verify-libghostty-vt-node/SKILL.md | 2 +- .../verify-libghostty-vt-node/scripts/launch.sh | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.agents/skills/verify-libghostty-vt-node/SKILL.md b/.agents/skills/verify-libghostty-vt-node/SKILL.md index b1cbbf8..f6d6d67 100644 --- a/.agents/skills/verify-libghostty-vt-node/SKILL.md +++ b/.agents/skills/verify-libghostty-vt-node/SKILL.md @@ -31,7 +31,7 @@ $S/launch.sh "$RUN_ROOT" # or uses a directory that you name, outside the c `launch.sh` runs in the checkout: `npm ci` (when `node_modules/` was not built from the current `package-lock.json`), `npm run build:libghostty` (when `vendor/libghostty-vt/` was not built from the current `scripts/build-libghostty-vt.sh` and `GHOSTTY_*` / `LIBGHOSTTY_VT_*` variables), `npm run build` from an empty `dist/`, `npm run build:prebuild`, and `npm pack`. A stamp file with the hash of those inputs records each reuse decision, and `RUN_ROOT/instance/launch.log` lists the steps that ran. It records the commit and tracked diff before the build, and it fails if they change before the end. Then it installs the tarball into `RUN_ROOT/instance/consumer/` with `npm install`. It is ready when the last line is `READY RUN_ROOT=`. Set the shell variable `RUN_ROOT` to that path. It can differ from the path you gave, because `launch.sh` resolves symlinks. A warm launch takes about 15 seconds. The first one also builds libghostty-vt. -A `RUN_ROOT` that you name must be a real directory (not a symlink) that only you can write, in a parent that others cannot write (or that has the sticky bit, like `/tmp`). `launch.sh` creates a missing one with mode `700`. It refuses anything else, because `npm install` runs package scripts from the tarball in `RUN_ROOT`. +A `RUN_ROOT` that you name must be a real directory (not a symlink) that only you can write. Every directory above it must be owned by you or root, and group or others must not be able to write it, unless it has the sticky bit, like `/tmp`. `launch.sh` creates a missing one with mode `700`. It refuses anything else, because `npm install` runs package scripts from the tarball in `RUN_ROOT`. `launch.sh` writes `build/`, `dist/`, and `prebuilds/` in the checkout, so only one launch can run per checkout. A second launch exits `2` and names the lock directory `vendor/.verify-launch.lock`. After launch, each `RUN_ROOT` is independent: many runs can drive their own instances at the same time. diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh index a3c33be..e62e691 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh @@ -26,9 +26,10 @@ elif [[ ! -e "$RUN_ROOT" && ! -L "$RUN_ROOT" ]]; then mkdir -m 700 -- "$RUN_ROOT" fi # The build installs the tarball from RUN_ROOT with npm, which runs package -# scripts. Refuse a RUN_ROOT that another account can change: a symlink, a -# directory owned by someone else or writable by group or others, or one in -# a parent that others can write without the sticky bit. +# scripts. Refuse a RUN_ROOT that another account can change or move: a +# symlink, a directory owned by someone else or writable by group or others, +# or one below any directory that another account owns (root excepted) or +# that group or others can write without the sticky bit. node -e ' const fs = require("node:fs"), path = require("node:path"); const dir = process.argv[1], uid = process.getuid(); @@ -38,10 +39,12 @@ node -e ' if (!st.isDirectory()) fail("is not a directory"); if (st.uid !== uid) fail("is not owned by you"); if (st.mode & 0o022) fail("is writable by group or others: chmod 700 it, or use a new path"); - const parent = fs.statSync(path.dirname(path.resolve(dir))); - const sticky = (parent.mode & 0o1000) !== 0; - if (!sticky && (parent.mode & 0o002)) fail("is in a parent that everyone can write"); - if (!sticky && (parent.mode & 0o020) && parent.uid !== uid) fail("is in a parent that another account can write"); + for (let up = path.dirname(fs.realpathSync(dir)); ; up = path.dirname(up)) { + const a = fs.statSync(up); + if (a.uid !== uid && a.uid !== 0) fail(`is below ${up}, which another account owns`); + if ((a.mode & 0o022) && !(a.mode & 0o1000)) fail(`is below ${up}, which group or others can write`); + if (up === path.dirname(up)) break; + } ' "$RUN_ROOT" RUN_ROOT="$(cd "$RUN_ROOT" && pwd -P)" outside_checkout "$RUN_ROOT" From 676de35e9070a6dd8797b5c2886089a8b6920ffd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 14:28:11 +0000 Subject: [PATCH 4/4] chore: tighten verify-libghostty-vt-node evidence and RUN_ROOT handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launch.sh checks the ancestors of RUN_ROOT as given and of its physical path, and uses the physical path that the check returns instead of resolving RUN_ROOT again. - launch.sh refuses a RUN_ROOT whose evidence/ already holds evidence, so one RUN_ROOT holds one launch. - instance.json records the sha256 of the loaded addon and of the libghostty-vt static library, with the hash of its build inputs. - drive.sh reserves each evidence directory with one mkdir. - doctor.sh compares the resolved file path, not the file URL, so a RUN_ROOT with a space passes. - lifecycle-workers fails after 20 s instead of hanging. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ Change-Id: Ie8dc0b6f9bae72da5857608d30bf73a5d4b80977 Signed-off-by: Thomas Kosiewski --- .../skills/verify-libghostty-vt-node/SKILL.md | 4 +- .../features/lifecycle.md | 4 +- .../scripts/doctor.sh | 13 ++++--- .../scripts/drive.sh | 6 +-- .../scripts/launch.sh | 38 +++++++++++++------ 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/.agents/skills/verify-libghostty-vt-node/SKILL.md b/.agents/skills/verify-libghostty-vt-node/SKILL.md index f6d6d67..c05a4cf 100644 --- a/.agents/skills/verify-libghostty-vt-node/SKILL.md +++ b/.agents/skills/verify-libghostty-vt-node/SKILL.md @@ -46,7 +46,7 @@ $S/doctor.sh "$RUN_ROOT" It is read-only. Exit `0` means that the instance is worth driving. Each line starts with `ok`, `WARN`, or `FAIL`. It checks that: 1. `instance.json` exists, `launch.sh` made it, and it names this checkout. -2. The checkout has the same `HEAD` and the same tracked diff as at launch. Otherwise it prints `STALE`: run `cleanup.sh`, then `launch.sh`. +2. The checkout has the same `HEAD` and the same tracked diff as at launch. Otherwise it prints `STALE`: run `cleanup.sh`, then `launch.sh` with a new `RUN_ROOT`. 3. `node` on `PATH` is the version that launch used. It warns when that is not the `mise.toml` pin. 4. The package is a real copy in the consumer's `node_modules`, not a symlink to the checkout. 5. `node-gyp-build` picks the packed prebuild (`prebuilds/-/@coder+libghostty-vt-node.node`). @@ -76,7 +76,7 @@ The map in [`features/README.md`](features/README.md) has a literal journey for ## Evidence -Each journey writes `RUN_ROOT/evidence//`: `journey.mjs`, `command.txt` (directory, node flags, time, node version), `stdout.txt`, `stderr.txt`, and `exit-code.txt`. `RUN_ROOT/evidence/instance.json` names the commit, the tracked-diff hash, and the tarball's sha256. `drive.sh` refuses to overwrite a journey's directory. +Each journey writes `RUN_ROOT/evidence//`: `journey.mjs`, `command.txt` (directory, node flags, time, node version), `stdout.txt`, `stderr.txt`, and `exit-code.txt`. `RUN_ROOT/evidence/instance.json` names the commit, the tracked-diff hash, and the sha256 of the tarball, of the loaded addon, and of the libghostty-vt static library with the hash of its build inputs. One `RUN_ROOT` holds the evidence of one launch: `launch.sh` refuses a `RUN_ROOT` whose `evidence/` is not empty. `drive.sh` refuses to overwrite a journey's directory. Proof standards: diff --git a/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md b/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md index b20e21c..afebaab 100644 --- a/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md +++ b/.agents/skills/verify-libghostty-vt-node/features/lifecycle.md @@ -91,13 +91,15 @@ t.dispose(); JS ``` -- **Worker threads.** The main thread creates a terminal, a Worker loads the package, and the main thread creates another terminal. Run `lifecycle-workers`. Exit `0`, and stdout ends with `main: created a terminal after the worker loaded`. A failure kills the process with a signal, so `drive.sh` exits `134` (SIGABRT, stderr has `Cannot create a handle without a HandleScope`) or `139` (SIGSEGV). That is a regression of finding F1 in the `verify-native-lifecycle` skill: report it, and do not change the journey to make it pass. +- **Worker threads.** The main thread creates a terminal, a Worker loads the package, and the main thread creates another terminal. Run `lifecycle-workers`. Exit `0`, and stdout ends with `main: created a terminal after the worker loaded`. A failure kills the process with a signal, so `drive.sh` exits `134` (SIGABRT, stderr has `Cannot create a handle without a HandleScope`) or `139` (SIGSEGV). A hang exits `1` after 20 seconds with `timeout:` on stderr. That is a regression of finding F1 in the `verify-native-lifecycle` skill: report it, and do not change the journey to make it pass. ```sh $S/drive.sh "$RUN_ROOT" lifecycle-workers <<'JS' import { Worker } from "node:worker_threads"; import { once } from "node:events"; import { createTerminal } from "@coder/libghostty-vt-node"; +// A hang is a failure too: end the journey with evidence instead of waiting forever. +setTimeout(() => { console.error("timeout: the worker journey did not finish in 20 s"); process.exit(1); }, 20_000).unref(); createTerminal({ cols: 10, rows: 2 }).dispose(); console.log("main: created a terminal"); const w = new Worker(`import("@coder/libghostty-vt-node").then((m) => { diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh index 98f8835..f5fe563 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh @@ -30,7 +30,7 @@ diff_now="$(git -C "$ROOT" diff HEAD | { if command -v sha256sum >/dev/null; the if [[ "$(field commit)" == "$head_now" && "$(field trackedDiffSha256)" == "$diff_now" ]]; then ok "current: built from HEAD $head_now and the same uncommitted diff" else - bad "STALE: the checkout changed after launch (HEAD or tracked diff). Run cleanup.sh, then launch.sh" + bad "STALE: the checkout changed after launch (HEAD or tracked diff). Run cleanup.sh, then launch.sh with a new RUN_ROOT" fi [[ "$(field node)" == "$(node --version)" ]] && ok "node $(node --version) matches launch" \ @@ -49,16 +49,17 @@ case "$addon" in esac info="$(cd "$CONSUMER" && node --input-type=module -e ' + const { fileURLToPath } = await import("node:url"); const url = import.meta.resolve("@coder/libghostty-vt-node"); const { getNativeInfo } = await import(url); - console.log(JSON.stringify({ url, ...getNativeInfo() })); + console.log(JSON.stringify({ path: fileURLToPath(url), ...getNativeInfo() })); ' 2>&1)" if [[ "$info" == "{"* ]]; then ok "getNativeInfo: $info" - url="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).url)' "$info")" - case "$url" in - "file://$CONSUMER/node_modules/"*) ok "the bare import resolves inside the consumer" ;; - *) bad "the bare import resolves outside the consumer: $url" ;; + resolved="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).path)' "$info")" + case "$resolved" in + "$CONSUMER/node_modules/"*) ok "the bare import resolves inside the consumer" ;; + *) bad "the bare import resolves outside the consumer: $resolved" ;; esac version="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).packageVersion)' "$info")" want="$(node -p 'require(process.argv[1]).version' "$ROOT/package.json")" diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh b/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh index f31b325..9360fb2 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh @@ -18,9 +18,9 @@ CONSUMER="$RUN_ROOT/instance/consumer" OUT="$RUN_ROOT/evidence/$NAME" [[ -f "$RUN_ROOT/instance/instance.json" ]] || { echo "error: no instance in $RUN_ROOT: run launch.sh" >&2; exit 2; } [[ "$NAME" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || { echo "error: NAME must match [a-z0-9][a-z0-9._-]*" >&2; exit 2; } -[[ -e "$OUT" ]] && { echo "error: $OUT exists: pick a new NAME, evidence is never overwritten" >&2; exit 2; } - -mkdir -p "$OUT" "$CONSUMER/journeys" +# One mkdir reserves the name, so two runs with one NAME cannot share evidence. +mkdir -p "$RUN_ROOT/evidence" "$CONSUMER/journeys" +mkdir "$OUT" 2>/dev/null || { echo "error: $OUT exists: pick a new NAME, evidence is never overwritten" >&2; exit 2; } cat > "$CONSUMER/journeys/$NAME.mjs" cp "$CONSUMER/journeys/$NAME.mjs" "$OUT/journey.mjs" echo "cd $CONSUMER && node $* journeys/$NAME.mjs # $(date -u +%Y-%m-%dT%H:%M:%SZ) $(node --version)" > "$OUT/command.txt" diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh index e62e691..90d3d2c 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh @@ -29,8 +29,10 @@ fi # scripts. Refuse a RUN_ROOT that another account can change or move: a # symlink, a directory owned by someone else or writable by group or others, # or one below any directory that another account owns (root excepted) or -# that group or others can write without the sticky bit. -node -e ' +# that group or others can write without the sticky bit. Check the ancestors +# of the path as given and of its physical path, and use the physical path +# that this check returns: never resolve RUN_ROOT again after it. +RUN_ROOT="$(node -e ' const fs = require("node:fs"), path = require("node:path"); const dir = process.argv[1], uid = process.getuid(); const fail = (why) => { console.error(`error: RUN_ROOT ${dir} ${why}`); process.exit(2); }; @@ -39,20 +41,28 @@ node -e ' if (!st.isDirectory()) fail("is not a directory"); if (st.uid !== uid) fail("is not owned by you"); if (st.mode & 0o022) fail("is writable by group or others: chmod 700 it, or use a new path"); - for (let up = path.dirname(fs.realpathSync(dir)); ; up = path.dirname(up)) { - const a = fs.statSync(up); - if (a.uid !== uid && a.uid !== 0) fail(`is below ${up}, which another account owns`); - if ((a.mode & 0o022) && !(a.mode & 0o1000)) fail(`is below ${up}, which group or others can write`); - if (up === path.dirname(up)) break; + const real = fs.realpathSync(dir); + for (const start of [path.resolve(dir), real]) { + for (let up = path.dirname(start); ; up = path.dirname(up)) { + const a = fs.statSync(up); + if (a.uid !== uid && a.uid !== 0) fail(`is below ${up}, which another account owns`); + if ((a.mode & 0o022) && !(a.mode & 0o1000)) fail(`is below ${up}, which group or others can write`); + if (up === path.dirname(up)) break; + } } -' "$RUN_ROOT" -RUN_ROOT="$(cd "$RUN_ROOT" && pwd -P)" + process.stdout.write(real); +' "$RUN_ROOT")" outside_checkout "$RUN_ROOT" INSTANCE="$RUN_ROOT/instance" EVIDENCE="$RUN_ROOT/evidence" if [[ -e "$INSTANCE" ]]; then - echo "error: $INSTANCE exists. Run cleanup.sh $RUN_ROOT first, or use a new RUN_ROOT." >&2 + echo "error: $INSTANCE exists. Run cleanup.sh $RUN_ROOT first, then use a new RUN_ROOT." >&2 + exit 2 +fi +# Evidence belongs to the launch that made it: never mix two launches in one RUN_ROOT. +if [[ -d "$EVIDENCE" && -n "$(ls -A "$EVIDENCE")" ]]; then + echo "error: $EVIDENCE holds evidence from an earlier launch. Use a new RUN_ROOT." >&2 exit 2 fi @@ -110,10 +120,14 @@ JSON (cd "$INSTANCE/consumer" && run npm install --no-audit --no-fund "$INSTANCE/$tarball") if [[ "$(checkout_state)" != "$state_before" ]]; then - echo "error: the checkout changed during the launch (HEAD or tracked diff). Run cleanup.sh, then launch again." >&2 + echo "error: the checkout changed during the launch (HEAD or tracked diff). Run cleanup.sh, then launch again with a new RUN_ROOT." >&2 exit 1 fi +static_lib="${LIBGHOSTTY_VT_STATIC_LIB:-$lib_dir/lib/libghostty-vt.a}" +addon="$(cd "$INSTANCE/consumer" && node -e 'process.stdout.write(require("node-gyp-build").path(process.argv[1]))' "$INSTANCE/consumer/node_modules/@coder/libghostty-vt-node")" INSTANCE="$INSTANCE" TARBALL="$tarball" TARBALL_SHA="$(sha "$INSTANCE/$tarball")" \ +LIB_INPUTS="$lib_inputs" STATIC_LIB="$static_lib" STATIC_LIB_SHA="$(sha "$static_lib")" \ +ADDON="${addon#"$INSTANCE/consumer/"}" ADDON_SHA="$(sha "$addon")" \ COMMIT="${state_before% *}" DIFF_SHA="${state_before#* }" CHECKOUT="$ROOT" \ node -e ' const fs = require("node:fs"); @@ -122,6 +136,8 @@ node -e ' marker: "verify-libghostty-vt-node", checkout: e.CHECKOUT, commit: e.COMMIT, trackedDiffSha256: e.DIFF_SHA, tarball: e.TARBALL, tarballSha256: e.TARBALL_SHA, + addon: e.ADDON, addonSha256: e.ADDON_SHA, + libghosttyVt: { staticLib: e.STATIC_LIB, staticLibSha256: e.STATIC_LIB_SHA, buildInputsSha256: e.LIB_INPUTS }, node: process.version, platform: process.platform, arch: process.arch, createdAt: new Date().toISOString(), };