From f9e0afd5cb533838a3846abe008d455104a6bca9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 30 Oct 2026 11:18:28 +0000 Subject: [PATCH 1/6] fix(verify-libghostty-vt-node): fail doctor.sh when the addon hash changed doctor.sh now hashes the addon that node-gyp-build picks and fails when it differs from addonSha256 in instance.json, so a rebuilt, swapped, or corrupted addon no longer passes as the launched one. Change-Id: Ic83ef034aa939858a9ae72cfe2ebf27bb102a001 Signed-off-by: Thomas Kosiewski --- .agents/skills/verify-libghostty-vt-node/SKILL.md | 2 +- .../skills/verify-libghostty-vt-node/scripts/doctor.sh | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.agents/skills/verify-libghostty-vt-node/SKILL.md b/.agents/skills/verify-libghostty-vt-node/SKILL.md index caee26b..289fe74 100644 --- a/.agents/skills/verify-libghostty-vt-node/SKILL.md +++ b/.agents/skills/verify-libghostty-vt-node/SKILL.md @@ -49,7 +49,7 @@ It is read-only. Exit `0` means that the instance is worth driving. Each line st 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`). +5. `node-gyp-build` picks the packed prebuild (`prebuilds/-/@coder+libghostty-vt-node.node`), and its sha256 is the `addonSha256` in `instance.json`. 6. The bare import resolves inside the consumer, and `getNativeInfo().packageVersion` matches `package.json`. Run it first, and again whenever a result looks wrong. diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh index f5fe563..aff6498 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh @@ -12,6 +12,7 @@ fail=0 ok() { echo "ok $*"; } bad() { echo "FAIL $*"; fail=1; } warn() { echo "WARN $*"; } +sha_stdin() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi | cut -d' ' -f1; } if [[ ! -f "$INSTANCE/instance.json" ]]; then bad "no $INSTANCE/instance.json: run launch.sh first" @@ -26,7 +27,7 @@ field() { node -e 'const i=require(process.argv[1]); process.stdout.write(String [[ "$(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)" +diff_now="$(git -C "$ROOT" diff HEAD | sha_stdin)" if [[ "$(field commit)" == "$head_now" && "$(field trackedDiffSha256)" == "$diff_now" ]]; then ok "current: built from HEAD $head_now and the same uncommitted diff" else @@ -47,6 +48,11 @@ 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 +addon_sha="" +[[ -f "$addon" ]] && addon_sha="$(sha_stdin < "$addon")" +want_sha="$(field addonSha256)" +[[ "$addon_sha" == "$want_sha" ]] && ok "addon sha256 $addon_sha matches instance.json" \ + || bad "addon sha256 is ${addon_sha:-unreadable}, instance.json has addonSha256 $want_sha: the installed addon changed after launch. Run cleanup.sh, then launch.sh again" info="$(cd "$CONSUMER" && node --input-type=module -e ' const { fileURLToPath } = await import("node:url"); From 767c5259766d883b3f3053b1d17bd2e0336aa6b8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 30 Oct 2026 11:18:28 +0000 Subject: [PATCH 2/6] fix(verify-libghostty-vt-node): stop drive.sh when a setup write fails drive.sh exits 2 before node runs when it cannot write the journey file, its evidence copy, or command.txt. Before, node could run a stale or partial journey and report its exit code as the verdict. Change-Id: Id057e453363d5b5fbe001323f0333ce7c4f4c987 Signed-off-by: Thomas Kosiewski --- .agents/skills/verify-libghostty-vt-node/SKILL.md | 2 +- .../skills/verify-libghostty-vt-node/scripts/drive.sh | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.agents/skills/verify-libghostty-vt-node/SKILL.md b/.agents/skills/verify-libghostty-vt-node/SKILL.md index 289fe74..1a8e614 100644 --- a/.agents/skills/verify-libghostty-vt-node/SKILL.md +++ b/.agents/skills/verify-libghostty-vt-node/SKILL.md @@ -70,7 +70,7 @@ 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. +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. When `drive.sh` cannot save the journey or `command.txt`, it exits `2` before node runs, and the journey has no `exit-code.txt`. 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. diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh b/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh index 9360fb2..bdf9a7d 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh @@ -19,11 +19,13 @@ 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; } # One mkdir reserves the name, so two runs with one NAME cannot share evidence. -mkdir -p "$RUN_ROOT/evidence" "$CONSUMER/journeys" +mkdir -p "$RUN_ROOT/evidence" "$CONSUMER/journeys" || exit 2 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" +# A failed write here would let node run a stale or partial journey. +{ 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" +} || { echo "error: could not save the journey and command.txt for $NAME: node did not run" >&2; exit 2; } (cd "$CONSUMER" && node "$@" "journeys/$NAME.mjs") > "$OUT/stdout.txt" 2> "$OUT/stderr.txt" code=$? From d6c2d4853a7beb8af2c8e5ca3cf3f4cb17c6a71e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 30 Oct 2026 11:18:28 +0000 Subject: [PATCH 3/6] fix(verify-libghostty-vt-node): refuse supplied libghostty-vt in launch.sh launch.sh now builds and links only vendor/libghostty-vt and exits 2 when LIBGHOSTTY_VT_PREFIX, LIBGHOSTTY_VT_INCLUDE_DIR, LIBGHOSTTY_VT_STATIC_LIB, or LIBGHOSTTY_VT_LIB is set. It also takes no RUN_ROOT argument: it always makes one with mktemp under TMPDIR, which removes the custom-root ownership checks. Refs #27 Change-Id: If7fb8dd7beeedf47b80f3846512a0e86ec3baa52 Signed-off-by: Thomas Kosiewski --- .../skills/verify-libghostty-vt-node/SKILL.md | 11 ++- .../scripts/launch.sh | 71 +++++-------------- 2 files changed, 22 insertions(+), 60 deletions(-) diff --git a/.agents/skills/verify-libghostty-vt-node/SKILL.md b/.agents/skills/verify-libghostty-vt-node/SKILL.md index 1a8e614..722c796 100644 --- a/.agents/skills/verify-libghostty-vt-node/SKILL.md +++ b/.agents/skills/verify-libghostty-vt-node/SKILL.md @@ -25,13 +25,12 @@ S=.agents/skills/verify-libghostty-vt-node/scripts ## 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 +$S/launch.sh # makes a new RUN_ROOT with mktemp in ${TMPDIR:-/tmp} ``` -`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. +`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. 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. 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` takes no arguments. To put `RUN_ROOT` somewhere else, set `TMPDIR` to a directory outside the checkout that other accounts cannot write, because `npm install` runs package scripts from the tarball in `RUN_ROOT`. `launch.sh` builds and links its own libghostty-vt in `vendor/libghostty-vt/`. It exits `2` when `LIBGHOSTTY_VT_PREFIX`, `LIBGHOSTTY_VT_INCLUDE_DIR`, `LIBGHOSTTY_VT_STATIC_LIB`, or `LIBGHOSTTY_VT_LIB` is set: unset them. `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. @@ -76,7 +75,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 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. +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, because each launch makes a new `RUN_ROOT`. `drive.sh` refuses to overwrite a journey's directory. Proof standards: @@ -98,4 +97,4 @@ It removes `RUN_ROOT/instance/` (the tarball, the consumer project, and the `no- - `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. +- `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` exits `2` when `TMPDIR` is inside it. diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh index 5951eb6..73dea65 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh @@ -3,68 +3,31 @@ # 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=`. +# Usage: .agents/skills/verify-libghostty-vt-node/scripts/launch.sh +# Makes a new RUN_ROOT with mktemp in ${TMPDIR:-/tmp}. 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)" -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 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. 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); }; - 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 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; - } - } - 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, then use a new RUN_ROOT." >&2 +if [[ $# -gt 0 ]]; then + echo "error: launch.sh takes no arguments. It makes a new RUN_ROOT with mktemp: set TMPDIR to choose its parent." >&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 +# instance.json records vendor/libghostty-vt, so the build must link that copy. +supplied="$(compgen -e | grep -xE 'LIBGHOSTTY_VT_(PREFIX|INCLUDE_DIR|STATIC_LIB|LIB)' || true)" +if [[ -n "$supplied" ]]; then + echo "error: launch.sh builds and links its own libghostty-vt in $ROOT/vendor/libghostty-vt. Unset ${supplied//$'\n'/ } and launch again." >&2 exit 2 fi +tmp="$(cd "${TMPDIR:-/tmp}" && pwd -P)" +case "$tmp/" in + "$ROOT"/*) echo "error: TMPDIR ($tmp) is inside the checkout ($ROOT): set it to a directory outside." >&2; exit 2 ;; +esac +RUN_ROOT="$(mktemp -d "$tmp/verify-libghostty-vt-node.XXXXXX")" +INSTANCE="$RUN_ROOT/instance" +EVIDENCE="$RUN_ROOT/evidence" # One launch at a time per checkout: launch writes build/, dist/, and prebuilds/. mkdir -p "$ROOT/vendor" @@ -98,7 +61,7 @@ if [[ "$(cat "$deps_stamp" 2>/dev/null)" != "$deps_inputs" ]]; then run npm ci echo "$deps_inputs" > "$deps_stamp" fi -lib_dir="${LIBGHOSTTY_VT_PREFIX:-$ROOT/vendor/libghostty-vt}" +lib_dir="$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 @@ -123,7 +86,7 @@ if [[ "$(checkout_state)" != "$state_before" ]]; then 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}" +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")" \ From 3ff2f3b05dcdc7ce41130bbcacf3eb7084fb143b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 30 Oct 2026 11:30:41 +0000 Subject: [PATCH 4/6] fix(verify-libghostty-vt-node): refuse a TMPDIR that another account can change launch.sh makes RUN_ROOT under TMPDIR, and the compiler writes temp files there. It now exits 2 when TMPDIR or a directory above it is owned by another account (root excepted) or writable by group or others without the sticky bit, and it exports the checked physical path to the build. Change-Id: I87777d4111860cd4da47ce3b7b049cab3c4b7c27 Signed-off-by: Thomas Kosiewski --- .../skills/verify-libghostty-vt-node/SKILL.md | 2 +- .../verify-libghostty-vt-node/scripts/launch.sh | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.agents/skills/verify-libghostty-vt-node/SKILL.md b/.agents/skills/verify-libghostty-vt-node/SKILL.md index 722c796..0cb9d70 100644 --- a/.agents/skills/verify-libghostty-vt-node/SKILL.md +++ b/.agents/skills/verify-libghostty-vt-node/SKILL.md @@ -30,7 +30,7 @@ $S/launch.sh # makes a new RUN_ROOT with mktemp in ${TMPDIR:-/t `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. A warm launch takes about 15 seconds. The first one also builds libghostty-vt. -`launch.sh` takes no arguments. To put `RUN_ROOT` somewhere else, set `TMPDIR` to a directory outside the checkout that other accounts cannot write, because `npm install` runs package scripts from the tarball in `RUN_ROOT`. `launch.sh` builds and links its own libghostty-vt in `vendor/libghostty-vt/`. It exits `2` when `LIBGHOSTTY_VT_PREFIX`, `LIBGHOSTTY_VT_INCLUDE_DIR`, `LIBGHOSTTY_VT_STATIC_LIB`, or `LIBGHOSTTY_VT_LIB` is set: unset them. +`launch.sh` takes no arguments. To put `RUN_ROOT` somewhere else, set `TMPDIR` to a directory outside the checkout. `npm install` runs package scripts from the tarball in `RUN_ROOT`, and the compiler writes temp files to `TMPDIR`, so `launch.sh` exits `2` when another account can change `TMPDIR`: it, or a directory above it, is owned by another account (root excepted) or writable by group or others without the sticky bit that `/tmp` has. `launch.sh` builds and links its own libghostty-vt in `vendor/libghostty-vt/`. It exits `2` when `LIBGHOSTTY_VT_PREFIX`, `LIBGHOSTTY_VT_INCLUDE_DIR`, `LIBGHOSTTY_VT_STATIC_LIB`, or `LIBGHOSTTY_VT_LIB` is set: unset them. `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 73dea65..dc5d51e 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh @@ -25,7 +25,22 @@ tmp="$(cd "${TMPDIR:-/tmp}" && pwd -P)" case "$tmp/" in "$ROOT"/*) echo "error: TMPDIR ($tmp) is inside the checkout ($ROOT): set it to a directory outside." >&2; exit 2 ;; esac -RUN_ROOT="$(mktemp -d "$tmp/verify-libghostty-vt-node.XXXXXX")" +# npm install runs package scripts from RUN_ROOT, and the compiler writes temp +# files to TMPDIR. Refuse a TMPDIR that another account can change: it, or a +# directory above it, is owned by another account (root excepted) or writable +# by group or others without the sticky bit. Children use the checked path. +node -e ' + const fs = require("node:fs"), path = require("node:path"); + for (let dir = process.argv[1]; ; dir = path.dirname(dir)) { + const st = fs.statSync(dir); + const why = st.uid !== process.getuid() && st.uid !== 0 ? "another account owns" + : (st.mode & 0o022) && !(st.mode & 0o1000) ? "group or others can write" : ""; + if (why) { console.error(`error: TMPDIR ${process.argv[1]} is unsafe: ${why} ${dir}`); process.exit(2); } + if (dir === path.dirname(dir)) break; + } +' "$tmp" +export TMPDIR="$tmp" +RUN_ROOT="$(mktemp -d "$TMPDIR/verify-libghostty-vt-node.XXXXXX")" INSTANCE="$RUN_ROOT/instance" EVIDENCE="$RUN_ROOT/evidence" From 3155b2d214feb55368e20020347e15cb03d798c3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 30 Oct 2026 11:45:55 +0000 Subject: [PATCH 5/6] fix(verify-libghostty-vt-node): detect supplied libghostty-vt without compgen launch.sh checked the variables with compgen, which a bash built without programmable completion lacks, and '|| true' hid that failure. It now tests each variable name directly. Change-Id: Ib6bdafdfd826ab72e46a072db98c07d6f3a4c707 Signed-off-by: Thomas Kosiewski --- .agents/skills/verify-libghostty-vt-node/scripts/launch.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh index dc5d51e..428406a 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh @@ -16,9 +16,12 @@ if [[ $# -gt 0 ]]; then exit 2 fi # instance.json records vendor/libghostty-vt, so the build must link that copy. -supplied="$(compgen -e | grep -xE 'LIBGHOSTTY_VT_(PREFIX|INCLUDE_DIR|STATIC_LIB|LIB)' || true)" +supplied="" +for v in LIBGHOSTTY_VT_PREFIX LIBGHOSTTY_VT_INCLUDE_DIR LIBGHOSTTY_VT_STATIC_LIB LIBGHOSTTY_VT_LIB; do + [[ -z "${!v+set}" ]] || supplied+=" $v" +done if [[ -n "$supplied" ]]; then - echo "error: launch.sh builds and links its own libghostty-vt in $ROOT/vendor/libghostty-vt. Unset ${supplied//$'\n'/ } and launch again." >&2 + echo "error: launch.sh builds and links its own libghostty-vt in $ROOT/vendor/libghostty-vt. Unset${supplied} and launch again." >&2 exit 2 fi tmp="$(cd "${TMPDIR:-/tmp}" && pwd -P)" From 0c17ba5a8075d362166c1459bb112404f905a58e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 30 Oct 2026 11:45:56 +0000 Subject: [PATCH 6/6] docs(verify-libghostty-vt-node): drop the RUN_ROOT argument from relaunch advice launch.sh takes no RUN_ROOT argument, so the STALE and checkout-changed messages now say to run launch.sh again. Change-Id: I4f2063338fc2fefbd7a979a6ee9633e07c35ad66 Signed-off-by: Thomas Kosiewski --- .agents/skills/verify-libghostty-vt-node/SKILL.md | 2 +- .agents/skills/verify-libghostty-vt-node/scripts/doctor.sh | 2 +- .agents/skills/verify-libghostty-vt-node/scripts/launch.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/verify-libghostty-vt-node/SKILL.md b/.agents/skills/verify-libghostty-vt-node/SKILL.md index 0cb9d70..a0c0fed 100644 --- a/.agents/skills/verify-libghostty-vt-node/SKILL.md +++ b/.agents/skills/verify-libghostty-vt-node/SKILL.md @@ -45,7 +45,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` with a new `RUN_ROOT`. +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` again. 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`), and its sha256 is the `addonSha256` in `instance.json`. diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh index aff6498..5215b43 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh @@ -31,7 +31,7 @@ diff_now="$(git -C "$ROOT" diff HEAD | sha_stdin)" 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 with a new RUN_ROOT" + bad "STALE: the checkout changed after launch (HEAD or tracked diff). Run cleanup.sh, then launch.sh again" fi [[ "$(field node)" == "$(node --version)" ]] && ok "node $(node --version) matches launch" \ diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh index 428406a..66d75f3 100755 --- a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh +++ b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh @@ -101,7 +101,7 @@ 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 with a new RUN_ROOT." >&2 + echo "error: the checkout changed during the launch (HEAD or tracked diff). Run cleanup.sh, then launch again." >&2 exit 1 fi static_lib="$lib_dir/lib/libghostty-vt.a"