Repository navigation
fix(native): keep snapshot() from running JS while it uses the terminal - #46
Conversation
snapshot() assigned every result key with Set(), which runs a setter that Object.prototype or Array.prototype can carry, and it read its options with Get() after the disposed check. A prototype accessor that called dispose() then crashed the process: a "row" or "text" setter segfaulted, and an "includeCells" getter hit the terminal_ assertion. The builders now define own data properties, and snapshot() reads its options before RequireTerminal. _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ Change-Id: I9dd63a067695cb36538262c5bb78f2a0000f080e Signed-off-by: Thomas Kosiewski <tk@coder.com>
|
@codex review |
|
@codex security review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
|
PASS+NOTES I verified this PR independently, the way a package user runs it. I did not write this code. For each commit I ran Commits
Checks
Check 2 detailThe four call shapes are public
Every installed trap fired on a plain JS access in a positive-control step after the snapshot. A zero count therefore means that native code never touched the trap. Head has no JS-reentry path after Notes
Generated with |
## Why Ship the `snapshot()` crash fix (#46) to npm. Every earlier version, `0.1.0-beta.2` included, crashes Node when a JS accessor on `Object.prototype` disposes the terminal while `snapshot()` builds its result. ## What changed - `package.json` and `package-lock.json` go from `0.1.0-beta.2` to `0.1.0-beta.3`. The branch name matches the version, so `publish.yml` creates `v0.1.0-beta.3` after merge. - The `release-changelog` workflow adds the CHANGELOG entry to this branch. - beta.3 also ships #48 (tests only) and #49 (the raw native addon's `feed` now rejects typed arrays other than `Uint8Array`). The package exports only `createTerminal`'s module, which already rejected those values in JS, so neither gets a CHANGELOG line. - The beta.2 `## Fixed` heading, which the changelog bot had joined to the previous bullet, is restored. ## Blast Radius The merge publishes `0.1.0-beta.3` to npm under the `beta` dist-tag. `latest` stays on `0.1.0-beta.0`. Snapshot output is byte-identical to beta.2, as #46's verifier checked. ## Verification - CI (linux-x64, linux-arm64, linux-x64-ubsan, macos-arm64) passes on head `3365375`. Codex code and security reviews are clean on that head. - `prepare-release-changelog.mjs` reports `changelog-entry-exists`. `resolve-npm-publish-tag.mjs` prints `beta`. The remote has no `v0.1.0-beta.3` tag. - An independent verifier built prebuilds, ran `verify:prebuilds` (loads `prebuilds/linux-x64`), packed, and installed into a scratch consumer: `0.1.0-beta.3` loads from `prebuilds/`, and 8 accessor cases that crash beta.2 exit 0. It passed the CHANGELOG at `da96287`. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
## Why The `verify-native-lifecycle` skill keeps a sync commit. At that commit, its TLA+ model describes the covered native code. The sync commit was still `7366b75` (#12). Since then, #40 to #43, #46, and #49 changed covered code. #46 promised this follow-up. ## What changed - The sync commit moves to `c54051e`, the current main. It includes #48 and #49. - The coverage rows now name `ghostty_terminal_set` (scrollback byte limit) in the constructor and its failure path, `Resize`'s second `ghostty_terminal_resize` call, and the `GHOSTTY_TERMINAL_DATA_MODE` read in `EncodeMouse`. Ghostty v1.4.0 (#43) brought these. - The "no JS inside native methods" assumption now says how each method reads its arguments: 1. `Snapshot` reads its options before `RequireTerminal`, and it builds its result with `DefineValue` and `DefineIndex`. 2. `Feed` and `Resize` call `RequireTerminal` first. After that, they only test types and read strings, bytes, and numbers, which runs no JS. 3. `GetVisibleText`, `FormatPlain`, and `FormatHtml` read no arguments. 4. The assumption was false for `snapshot` until #46. - The bullet that lists where caller JS can run now names public `feed`: `assertFeedData` runs `data instanceof Uint8Array`, which runs a Proxy's `getPrototypeOf` trap after `#assertUsable`. The native null check covers it, as it does for `snapshot`. - `AddonEnvLifecycle.tla` changes in comments only. The `CreateFails` comment now names the `ghostty_terminal_set` failure too. The model itself does not change. #48 changes only a test. #49 changes only `Feed`'s argument checks, so the `Use(t)` step still describes `feed`. ## Verification - I checked each row against `native/terminal.cc` and `src/index.ts` at `c54051e`. - `git diff 7366b75 c54051e` on the covered paths adds no new static state, `Napi::Reference`, or new place that frees `terminal_` or `mouse_encoder_`. - `run.sh`: all 35 rows match EXPECT (`OK: every result matches EXPECT.`). The current config holds all 5 rules. Each of the 6 mutants breaks the rule it guards. The verifier's two notes on `0ff0941` (the `feed` window and the `CreateFails` comment) are fixed in `584c561`. `run.sh` still passes all 35 rows. Workspace 36beaed175 drafted the first version of this commit. I corrected the assumption bullet: it said that every method reads its arguments before `RequireTerminal`, but `Feed` and `Resize` do not. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
Why
A JS accessor on
Object.prototypecould crash the process duringsnapshot().snapshot()assigned its result keys withSet(), which runs a matching setter, and it read its options withGet()after it checked for dispose. An accessor that calleddispose()then left native code using a freed or null terminal. A setter forrowortextsegfaulted Node, and a getter forincludeCellshit theterminal_ != nullptrassertion (SIGABRT). Every published version has this. Reaching it needs JS in the same process, for example a prototype-pollution bug elsewhere in an app.What changed
BuildSnapshot,BuildLinesandBuildCellObjectdefine own data properties (DefineValue,DefineIndex), so no setter runs. Consumers get the same objects.snapshot()reads both options beforeRequireTerminal, so a dispose during that read throws "disposed".test/snapshot-safety.test.tscovers both paths, through the package and the raw addon, in child processes.Scope
getNativeInfoand module init still useSet(), because they touch no terminal.snapshot.Verification
Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:high