`H1 shell state survives a server restart` failed in the full parallel suite
and passed in isolation, which reads like a timing flake in the assertion. It
was not. Polling for the marker instead of sleeping a fixed 900ms showed the
shell replying promptly with:
echo MARK-$WEBTERM_MARK
MARK-
An EMPTY variable. The post-restart shell was fine; the variable had never been
set in the PRE-restart shell, because a fixed 500ms is not enough for the shell
to become ready and run the assignment when 81 test files are competing for the
machine. The symptom then appears at the far end of the test and reads as "the
tmux session did not survive the restart", sending whoever debugs it after
entirely the wrong bug.
So the setup now proves itself: it echoes the variable back and waits for
`SET-tmuxlives` before restarting, and fails there with "the variable was never
set in the pre-restart shell" if it did not take. The final assertion likewise
waits for its marker with a bounded timeout rather than guessing a duration.
Neither wait can mask a genuine failure: if the tmux session really did not
survive, the shell is fresh, the variable is empty, and the marker never appears
no matter how long we wait.
Added a `waitUntil` helper for this. It carries its own sleep because the
`delay` the cases use is a local const inside a test body, not module scope.
Verified: full `npx vitest run test/` -> 81 files, 2243 tests, 0 failures.
NOT fixed, and not mine to guess at: `server.test.ts` has at least one more
real-PTY case that flakes under the same parallel load (⑤ attach → attached →
shell prompt output times out in `waitForMessage`, then its afterEach hook
times out too). It passes in isolation. Same class of fixed-delay assumption,
different case.
`npm test` was failing 5-11 tests per run with the set shifting between runs,
which made it useless as a gate. Two independent causes, neither of them a
product defect.
1. Fixtures had outgrown vitest's 5 s default. The git ones spawn 6-12
sequential `git` processes (init/config/commit/clone/push); the real-server
ones boot a server and a shell. Alone they finish easily; under a full
parallel run they do not, and the failure reads `Test timed out in 5000ms`.
Gave those describes an explicit 30 s ceiling, and the real-PTY waits a
named PTY_WAIT_MS. The deliberate short bounds in the "must be rejected"
handshake tests are left alone — there a timeout IS the assertion.
(The same rebudgeting also touched the test files in the preceding commit.)
2. test/integration/server.test.ts cannot share the machine with the rest of
the suite. It asserts on real prompt output within seconds, which is not
achievable while ~8 workers saturate the box: it passed alone (27/27,
repeatedly) and flaked in the full run. Raising the numbers further only
moved the flake around, so this is fixed as a scheduling problem — `npm
test` now runs two passes, `test:unit` (everything else, parallel) then
`test:e2e` (that file on its own). `vitest run` still runs everything at
once for anyone who wants that.
Also bounded the srv.close() in H1's finally. Unbounded, it swallowed whatever
really went wrong in the body — the inner waits threw, control jumped to the
finally, close() blocked, and the case reported a bare timeout instead of its
own diagnosis. The root-causing above only became possible after making that
failure legible.
Note on the `[needs real PTY (sandbox-off)]` labels: those cases were NOT
skipping here. PTY_AVAILABLE is true on this machine, so they were running and
failing on timing, not being gated out by a sandbox.
Verified: npm test green end to end across repeated runs (unit 78 files /
2147 tests, e2e 27).
Remote one-tap approval was blind (you'd tap Approve without seeing Claude wants
to run `rm -rf` or rewrite a config). The pending tool's actual command / diff now
renders above the approval bar, on every attached device, riding the same broadcast
+ late-joiner rails as the existing `gate` field.
- src/http/approval-preview.ts (new, pure, never-throws): deriveApprovalPreview —
Bash → command; Edit/Write/MultiEdit/NotebookEdit → a synthetic DiffFile; else null.
Every line sanitized via sanitizeField (strips control/ANSI); caps 40 lines /
200 chars/line / 4KB (security limits, UTF-8-safe byte clamp).
- src/types.ts: additive optional `preview?: ApprovalPreview` on the status
ServerMessage + handleHookEvent (older clients ignore it).
- src/server.ts /hook/permission: derive preview from tool_input, store on the
PendingApproval entry, re-send to late joiners exactly like `gate`.
- manager.ts threads it; public/tabs.ts renderApprovalPreview (command → <pre>
textContent; diff → reused innerHTML-free renderDiffFile). Unknown tools /
plan gates fall back to today's name-only bar.
Attacker-influenced tool input → rendered via textContent/diff-renderer only,
never innerHTML. Verified independently: typecheck + build:web clean, 1692 pass.
Terminal output URLs and file paths (e.g. src/app.ts:42) are now tappable — the
walk-away device is a phone, so this removes soft-keyboard copy gymnastics.
- public/link-paths.ts (new, pure): findPathMatches — links tokens with a '/',
a :line suffix, or a code extension (src/app.ts:42, /abs/main.rs:10, README.md)
while rejecting example.com / v1.2.3 / 12:34 / URL tails.
- terminal-session.ts: hardened URL handler (scheme allowlist http/https/mailto +
window.open noopener,noreferrer, blocks javascript:/data:/file:) replacing the
addon default; a path link provider → openPath() POSTs {file,line} to
/open-in-editor (resolves rel paths against the OSC-7 cwd; in-flight guard).
- src/http/editor.ts: additive openFileInEditor + isGotoEditor (--goto file:line
only for goto-capable editors; execFile, no shell). openInEditor untouched.
- src/server.ts: /open-in-editor branches on body.file vs body.path (same CSRF guard).
Deviation from the "no server change" brief: an additive openFileInEditor was
required because the existing route only opens directories, not file:line (Option A
in docs/plans/w1-clickable-links.md). Backward-compatible; src/types.ts untouched.
Verified independently: typecheck + build:web clean, 1661 tests pass (link-paths
95%+ cov). Note: xterm buffer-row indexing (getLine(n-1)/range.y=n) is asserted in
jsdom but merits a real-browser smoke check.
- src/session/tmux.ts: sync tmux CLI wrappers (available/has/kill), best-effort
- config: useTmux from USE_TMUX env (1/0/auto→detect tmux on PATH)
- session: when useTmux, spawn 'tmux new-session -A -s web_<id> <shell>' (the
node-pty proc is a tmux CLIENT); createSession takes an optional id for
re-attach; Session.tmuxName; kill() runs tmux kill-session
- manager: handleAttach re-attaches to a surviving 'web_<id>' tmux session after
a restart (Case 3.5); shutdown kills only the client pty for tmux sessions so
the shell keeps running
- tests: USE_TMUX:0 in shared integration cfg (no tmux leakage); unit CFGs
useTmux:false; integration 'H1' (real tmux): set var → restart server →
re-attach → var survived. 208 tests green.
- types/protocol: ClientMessage approve/reject; ServerMessage status.pending
- server: POST /hook/permission HELD until the client decides; approve/reject
ws messages resolve it with {hookSpecificOutput.decision.behavior allow|deny};
5-min timeout + release-on-disconnect fall back to Claude's own prompt
- manager.handleHookEvent threads pending flag
- setup-hooks: PermissionRequest uses the held curl (writes decision to stdout);
status events stay fire-and-forget
- FE: terminal-session approve()/reject() + pending state; tabs approval banner
(Approve/Reject) for the active tab's held request
- Tests: protocol approve/reject; integration ⑧ held→approve→allow. 207 green.
- Browser-verified: banner 'Claude wants to use Bash' → Approve → resolves allow.
6 cases against real startServer via ws client:
①bad-origin 401 ②wrong-path destroy ③oversized-frame 1009 ④spawn-fail exit(-1)
— all pass in sandbox; ⑤attach->output ⑥reconnect-replay gated by PTY_AVAILABLE
(auto-skip where posix_spawn is blocked). Orchestrator verified ⑤⑥ sandbox-off:
6/6 pass with real shell — F5/F6 confirmed (replay incl CJK/ANSI intact).