mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-27 13:34:25 +02:00
v1.44.0.0 feat: long-lived sidebar — keepalive, restart, re-attach, scrollback replay (#1678)
* fix(browse): identity-based terminal-agent kill replaces pkill regex
Commit 0 of the v1.44 long-lived-sidebar PR — foundation for the watchdog
and removes a latent cross-session footgun.
`pkill -f terminal-agent\.ts` (cli.ts spawn site + server.ts shutdown) matched
by argv regex and would kill ANY process whose argv contained the string —
sibling gstack sessions on the same host, an editor with the file open, a
second `$B connect` run. Identity-based PID kill via a new helper module
removes that whole class of bug.
* New `browse/src/terminal-agent-control.ts`: `readAgentRecord`,
`writeAgentRecord`, `clearAgentRecord`, `killAgentByRecord`. Validates
PID liveness via `isProcessAlive` before signaling (PID-reuse defense).
* `terminal-agent.ts` writes `<stateDir>/terminal-agent-pid` (JSON
`{pid, gen, startedAt}`) at boot; clears on SIGTERM/SIGINT.
* New per-boot `CURRENT_GEN` (16-byte random); `/internal/*` callers can
include `X-Browse-Gen` to defend against split-brain in the upcoming
watchdog. Absent header is accepted (backward compat); mismatch returns
409. New `checkInternalAuth` helper centralizes bearer + gen checks.
* New `/internal/healthz` route — agent liveness probe used by the
upcoming watchdog (returns pid/gen/sessions, no claude-binary lookup).
* `cli.ts` and `server.ts` both call `killAgentByRecord` instead of pkill.
* `ServerConfig.ownsTerminalAgent` JSDoc updated; the gated teardown now
runs 4 side effects (was 3) — adds the new agent-record unlink.
Test changes:
* New `browse/test/terminal-agent-pid-identity.test.ts` — static-grep
tripwire that fails CI if any source file re-introduces `pkill ...
terminal-agent` or `spawnSync('pkill', ...)`; round-trips
write/read/clear; verifies killAgentByRecord no-ops on dead PIDs.
* `browse/test/server-embedder-terminal-port.test.ts` rewritten to
intercept `process.kill` (not `child_process.spawnSync`); writes a
sentinel agent-record with a guaranteed-dead PID; asserts probe-only
(signal 0) calls, no termination signals; verifies all 3 discovery
files including the new terminal-agent-pid.
Closes TODOS.md P3 ("Identity-based terminal-agent kill").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): repair 7 pre-existing failures (env pollution + stale markers)
All 7 failures existed on main before this branch — verified via `git stash`
round-trip. Bundling them into the long-lived-sidebar PR because we kept
tripping over them while running `bun test` to verify Commit 0.
* Global afterEach restores `process.env.PATH` (new bunfig.toml +
test-setup.ts). browser-skill-commands.test.ts sets
`PATH = '/test/bin:/usr/bin'` to exercise a scrubbed-env fixture and
used the broken `process.env = origEnv` reassignment pattern that
swaps the proxy reference; the underlying env stayed mutated and
leaked downstream. Fixed three call sites in that file and added a
narrow PATH-only global guardrail so a future polluter can't bring
the bug back. Killed: pair-agent-tunnel-eval (bun ENOENT),
security.test.ts > resolveBashBinary (Bun.which('bash') null),
server-no-import-side-effects (bun ENOENT).
* server-auth.test.ts: two `sliceBetween` markers referenced strings
deleted when sidebar-agent.ts was ripped — `'Sidebar agent started'`
→ `'Terminal agent started'`, `'Sidebar endpoints'` → `'Batch endpoint'`.
Also fixed the pair-agent BROWSE_PARENT_PID assertion (the literal
`serverEnv.BROWSE_PARENT_PID` never existed in source; the actual
contract is the object-literal `BROWSE_PARENT_PID: '0'` inside the
`const serverEnv` declaration).
* test/upgrade-migration-v1.test.ts: also overrides HOME in the spawn
env. The migration shells out to `${HOME}/.claude/skills/gstack/bin/gstack-config`
and a developer's real config with `explain_level` set causes the
script to take the "user already decided" branch and skip writing
the pending-prompt flag the test asserts on.
* test/setup-codesign.test.ts: replaced fragile `bun run build`
string-match (which hit a comment 700 lines later) with the actual
invocation `bun_cmd run build` used in the setup script.
Net: full suite is now green; CI no longer trips on bash/bun-ENOENT
from PATH pollution or on test markers that drifted with the codebase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(terminal-agent): extract internalHandler<T> helper for /internal/* routes
Replaces the copy-pasted bearer-auth + X-Browse-Gen + req.json().then().catch()
boilerplate on /internal/grant and /internal/revoke with a single
internalHandler<T>(req, fn) wrapper. Future /internal/* routes added by the
v1.44 long-lived-sidebar work (/internal/lease-refresh, /internal/restart)
land as one-liners using the same helper. Pure refactor; no behavior change.
/internal/healthz stays on the bare checkInternalAuth gate because it's a
GET with no JSON body to parse — the helper's body-parse path would 400 it.
* browse/src/terminal-agent.ts — new internalHandler<T>; /internal/grant
+ /internal/revoke routed through it.
* browse/test/terminal-agent-internal-handler.test.ts — static-grep
tripwire that fails CI if the helper goes away or either of the two
refactored routes regresses to the old inline pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(terminal-agent): 25s WS keepalive ping/pong + client keepalive frames
PTY connections were dying silently after NAT idle timeouts (30-60s on most
home routers, even shorter on some carrier-grade NAT) and Chrome MV3 panel
suspension. Neither side noticed until the user's next keystroke produced
no output. Both sides now drive a 25s keepalive cycle.
Server side (browse/src/terminal-agent.ts):
* New ws.open handler constructs the PtySession eagerly and starts a
setInterval that sends `{type:"ping",ts:Date.now()}` every 25s.
Interval handle stored on session.pingInterval so close() can clear it.
* PtySession.pingInterval field added; cleared in ws.close before
disposeSession runs. Prevents timer leak across reconnects.
* Message handler accepts `{type:"ping"|"pong"|"keepalive"}` silently —
keepalive frames are a liveness signal at the TCP layer, no state to
update. Existing resize/tabSwitch/tabState handling unchanged.
* GSTACK_PTY_KEEPALIVE_INTERVAL_MS env knob (default 25000) lets the
upcoming e2e tests compress idle assertions without 30s waits.
Client side (extension/sidepanel-terminal.js):
* Belt-and-suspenders: client also runs a 25s setInterval that sends
`{type:"keepalive"}`. Defends against Chrome pausing our timers if
the server-side ping ever gets dropped (rare but possible in MV3).
* Ping reply: on `{type:"ping",ts}` from the server, immediately send
`{type:"pong",ts}`. Lets the agent observe round-trip latency for
free and confirms the channel is bidirectional.
* Interval cleared in three teardown paths: ws.close handler,
teardown(), forceRestart(). Three paths exist because the sidebar
can exit the LIVE state through any of them; all three must clean up
or we leak timers across reconnects.
Test (browse/test/terminal-agent-keepalive.test.ts):
* Static-grep tripwires for the 7-point protocol contract: agent has
a configurable interval, open() starts the ping, close() clears it,
message handler accepts keepalive vocabulary, client sends keepalive
+ replies pong, and all three client teardown paths clear the timer.
* Wire-level tests (actually observe a ping after 25s) belong in the
e2e tier — adding them here would either flake on slow CI or require
a real Bun.serve listener per test which we don't want to pay for
in the free tier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sidebar): patient tryAutoConnect — poll forever with ascending status, abort only on 401
The 15s give-up message ("Browse server not ready. Reload sidebar to retry.")
fired on every cold start where the daemon took >15s to bind — common on
Conductor workspaces, CI runners, and any system under load. The user
already opened the sidebar; telling them to give up is the wrong default.
Now polls every 2s indefinitely with ascending status messages:
* 0 - 15s : silent (handles the happy path on a warm laptop)
* 15 - 60s : "Waiting for browse server..."
* 60s - 5m : "Still waiting — browse server may be slow to start."
* > 5m : "Browse server still not responding after 5 min. Try `$B status`."
Loop aborts on three signals only:
* state transitions out of IDLE (connect succeeded or user navigated)
* autoConnectAborted sticky flag set on unrecoverable error
* the panel itself unloading (browser handles this; pagehide cleanup
arrives with T8 of the larger plan)
401 from /pty-session sets the sticky flag with a clear "Auth invalid —
reload the sidebar or restart your gstack session." message. Without the
flag, the loop would re-call connect() every 2s and spam the same error;
with it, the user sees the message once and the loop holds. forceRestart()
clears the flag so clicking Restart is the explicit "try again" escape hatch.
Bumped poll interval 200ms → 2000ms — the legacy tight loop burned CPU
for no reason. 2s is plenty fast for a "did the daemon come up yet" check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse): terminal-agent watchdog with PID liveness + crash-loop guard
terminal-agent could die independently of the server — SIGKILL from the OS
OOM killer, an uncaught exception under PTY churn, an external `pkill` from
a sibling debugging session. Pre-v1.44 the sidebar would observe the broken
connection and stay broken until the user reloaded the sidebar. Now a 60s
ticker checks the recorded agent PID and respawns via the shared
spawnTerminalAgent helper when dead.
Identity-based liveness (T4 from the eng review):
* Uses readAgentRecord + isProcessAlive (signal 0 probe), not a name match.
* Slow-but-alive agents intentionally fall through — respawning around a
living agent would create split-brain (two agents writing the port
file, tokens diverging between them, mystery upgrade 401s).
* Pairs with the v1.44 generation counter in /internal/* loopback calls:
if a stale agent does come back to life mid-cycle, its X-Browse-Gen
no longer matches and the parent's calls 409 cleanly.
Crash-loop guard:
* 3 respawn attempts inside a rolling 60s window → stop trying. A daemon
up for a week with one crash a day shouldn't trip the guard.
* On trip: one-line error to console (`respawn guard tripped`) and the
watchdog goes dormant. Manual restart via the sidebar Restart button
is the explicit signal to re-arm (added in Commit 2 of the larger PR).
Shared spawn path (refactor):
* New spawnTerminalAgent(opts) in terminal-agent-control.ts handles:
prior-PID cleanup → spawn → record stash. Both the CLI cold-start path
in cli.ts and the new server.ts watchdog route through it. Removes the
copy-paste between them; future env wiring lands in one place.
Gated on cfg.ownsTerminalAgent — embedders that pre-launch their own PTY
server (gbrowser phoenix overlay) still own the full lifecycle.
GSTACK_AGENT_WATCHDOG_TICK_MS env knob compresses the 60s tick for e2e
tests without 60s waits per assertion.
Tests:
* browse/test/terminal-agent-watchdog.test.ts — 7 static-grep tripwires
for the load-bearing invariants (ownsTerminalAgent gate, PID-based
liveness, crash-loop guard with window pruning, shutdown cleanup,
CLI cold-start uses the same helper, env knob exists).
* Live process-kill tests belong in the e2e tier; cheaper invariants
here catch refactor regressions in ~1ms each.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): opt-in outer supervisor — respawn browse server on crash
Pre-v1.44 `$B connect` was fire-and-forget: spawn server detached, CLI
exits, server runs unsupervised. If the server crashed (OOM, uncaught
exception, signal kill from a runaway debugger), the user had to notice,
re-run `$B connect`, and resume work. The v1.44 terminal-agent watchdog
recovers from one layer of failure; this commit closes the outer loop.
Opt-in via `--supervise` flag or `BROWSE_SUPERVISE=1` env. Default
behavior is unchanged — every existing caller (Claude Code's Bash tool,
scripts, CI) still gets a prompt return. When the flag is set:
* CLI stays attached, polls server PID every 30s via readState() +
isProcessAlive (same identity primitive as the terminal-agent watchdog).
* On unexpected exit: respawn via the same headed-mode startServer path
used initially, then re-spawn the terminal-agent so the PTY recovers
too (otherwise sidebar Restart is the only path back).
* Crash-loop guard: 5 respawns in a rolling 5-min window → exit 1 with
a clear error. Window pruning means a long-lived daemon with sporadic
crashes does NOT trip the guard (otherwise we punish the user for the
supervisor doing its job).
* Backoff: 1s, 2s, 4s, 8s, 30s capped. Env-overridable via
GSTACK_SUPERVISOR_BACKOFF for tests.
* SIGINT / SIGTERM: clean teardown — signals the supervised server
before exiting itself. Without this, Ctrl-C leaves an orphaned server.
Out of scope (deferred follow-up): routing the Chromium-disconnect
exit-code-1 path back through this supervisor. The terminal-agent
watchdog already covers the highest-frequency restart case; Chromium
crash recovery joins the queue as its own commit.
Test (browse/test/cli-supervisor.test.ts):
* 6 static-grep tripwires: opt-in default, signal wiring, crash-loop
guard with window pruning, backoff schedule env knob, tick interval
env knob, terminal-agent re-spawn after server respawn.
* Live respawn tests belong in the e2e tier (real spawn cycles take
3-8s each; spamming these in the free tier would balloon CI time).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse): pty-session-lease registry — stable sessionId + lease lifecycle
Foundation for Commit 2 of the long-lived-sidebar PR. Separates two
concerns that pre-v1.44 were conflated under one token:
* sessionId — stable, non-secret identifier for a single PTY session.
Safe to log, safe in URLs, safe in DevTools. Identifies "this terminal,"
not "you're allowed to use this terminal."
* lease — server-side bookkeeping that maps sessionId → expiresAt.
Re-attach within the lease window resumes the same PTY; expiry tears
it down.
The companion attach-token primitive (short-lived 30s bearer) reuses the
existing browse/src/pty-session-cookie.ts module unchanged — the lease
adds a name-space alongside, it doesn't replace anything.
Codex outside-voice (T1 of the eng review) flagged the original D4
"token IS sessionId" design as conflating identity with auth. The fix
is this lease registry: re-attach URLs carry the stable sessionId
(loggable), the short-lived attachToken stays out of logs.
API:
* mintLease() → { sessionId, expiresAt }
* validateLease(sessionId) → { ok: true, expiresAt } | { ok: false }
* refreshLease(sessionId) — validate-first, never resurrects expired
leases. Security-critical: the 30-min TTL is what bounds blast
radius for a leaked attachToken whose lease should have GC'd.
* revokeLease(sessionId) — explicit dispose path.
* leaseCount() — observability helper.
* __resetLeases() — test-only.
TTL env knob (GSTACK_PTY_LEASE_TTL_MS) lets v1.44 e2e tests compress
the detach window to 1s instead of waiting 30 minutes per assertion.
Server.ts wiring + /pty-session shape change + /pty-restart + /pty-dispose
+ /pty-session/reattach all land in subsequent commits in this branch.
Test (browse/test/pty-session-lease.test.ts):
* 8 cases pinning mint uniqueness, validate-first refresh contract,
revoke idempotency, null/undefined tolerance, and the negative case
that refresh never resurrects a revoked lease (same code path as
expired-and-pruned).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(terminal-agent): sessionId-aware grant + scoped restart + eager spawn
Wires the pty-session-lease primitive (3aada48b) into terminal-agent so
the Commit 2 work in server.ts (next commit) can route /pty-restart and
re-attach by session identity rather than by single-use token.
Changes:
* validTokens: Set<string> → Map<string, string|null>. Each grant carries
its bound sessionId (or null for legacy single-grant callers). On WS
upgrade, the agent surfaces the bound sessionId via ws.data so open()
can register the session in the new reverse index.
* sessionsById: Map<sessionId, PtySession> — populated in open(),
cleared in close(). Required so /internal/restart can find and dispose
one specific session by id rather than enumerating all live sessions.
* /internal/restart: scoped to one sessionId. Codex T2 of the eng review
caught the gap — pre-spec the route would have disposed every PTY on
the agent, breaking pair-agent and any future multi-sidebar setup.
The body now requires `{sessionId}`; missing or unknown id returns
`{killed: 0}` and leaves siblings alone.
* maybeSpawnPty(ws, session): hoisted from the inline binary-frame spawn
block so both the legacy "spawn on first keystroke" trigger AND the
new `{type:"start"}` text-frame trigger land in the same code path.
Idempotent on session.spawned.
* `{type:"start"}` text frame: explicit spawn trigger. forceRestart
(extension side, lands in Commit 2C) sends this immediately on every
fresh WS so claude boots without requiring a keystroke. Pre-v1.44 the
lazy-binary-spawn pattern made the restart feel stuck.
* close(ws): drops the sessionsById entry alongside the existing
sessions WeakMap + validTokens cleanup. Commit 3 will revisit this to
keep the session alive for a 60s detach window before disposing.
Test (browse/test/terminal-agent-session-routing.test.ts):
* 8 static-grep tripwires pinning the load-bearing properties: validTokens
is a Map (not Set), sessionsById exists, /internal/restart is scoped
(negative-assert against enumerate-all patterns), WS upgrade plumbs
sessionId, maybeSpawnPty is the single spawn entry, close() drops the
index. Live spawn cycles belong in the e2e tier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(server): /pty-session 4-tuple + /pty-restart + /pty-dispose + lease-refresh
Wires the lease + attachToken model end-to-end on the server side. The
client side (extension) lands in the next commit; agent side already
shipped in 449144cd.
Routes:
* POST /pty-session — mints sessionId (stable, loggable) + lease
(server-side bookkeeping) + attachToken (short-lived bearer for the
WS upgrade). Returns the 4-tuple in one round trip. Legacy
ptySessionToken / expiresAt aliases kept for one minor release so
extensions on the v1.43 wire shape keep working.
* POST /pty-session/reattach — validates a sessionId's lease and mints
a FRESH attachToken bound to the same sessionId. Used by Commit 3's
re-attach loop; 410 Gone when the lease has expired so the client
knows to fall back to a brand-new /pty-session.
* POST /pty-restart — one transaction: dispose the caller's existing
PtySession on the agent (via /internal/restart, scoped to one
sessionId — codex T2), revoke the old lease, mint a fresh
sessionId + lease + attachToken, return the 4-tuple. Zero race
window between kill and mint (codex T2 + D8 of the eng review).
* POST /pty-dispose — explicit teardown. sendBeacon-compatible: accepts
auth token in the body so the extension's pagehide handler (Commit 2C)
can fire it without setting custom headers (sendBeacon doesn't
support those). Without this route, every clean browser quit leaves
a zombie PTY alive for the 60s detach window — codex T3 caught it.
* POST /internal/lease-refresh — loopback from terminal-agent on its
25s keepalive cycle (lazy: only when lease is within 5 min of
expiry). Refreshes the lease AND resets the daemon idle timer. T6
of the eng review: PTY activity (not arbitrary SSE consumers) is
what keeps the daemon alive when the sidebar is in use.
Helpers:
* grantPtyToken now accepts optional sessionId and passes it through
to the agent's /internal/grant body. The agent binds token → sessionId
in its validTokens Map so /ws upgrades carry the sessionId for
/internal/restart and Commit 3 re-attach lookups.
* restartPtySession() — new loopback helper that POSTs the agent's
scoped /internal/restart with a sessionId body. Used by /pty-restart
and /pty-dispose.
Auth contract on /pty-dispose deliberately accepts the auth token in
EITHER the Authorization header OR the request body. The body path is
required for sendBeacon (which can't set custom headers); the header
path stays available for non-beacon callers and tests.
Test (browse/test/server-pty-lease-routes.test.ts):
* 7 static-grep tripwires pinning the 4-tuple shape, validate-first
re-attach with 410 fallback, one-transaction restart semantics,
sendBeacon-compatible dispose auth, and the T6 PTY-only idle reset.
* Live route exercises (full mint + grant + WS upgrade cycle) belong
in the e2e tier — they require a real terminal-agent loopback and
take seconds per assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sidebar): forceRestart via /pty-restart + pagehide /pty-dispose
Closes the Commit 2 loop: server-side lease + restart routes shipped in
25ef24e9; this commit wires the extension client to use them. End-to-end
result — clicking Restart now actually kills the server's PTY before
opening a new WS (zero race window), and closing the sidebar / quitting
the browser disposes the PTY immediately instead of letting it linger
for the upcoming 60s detach window.
sidepanel-terminal.js:
* mintSession callers read the v1.44 4-tuple (sessionId + attachToken)
from /pty-session, with a backward-compat fallback to ptySessionToken
so a partially-updated extension still works against a fresh server
for one minor release.
* Eager spawn via {type:"start"} text frame replaces the legacy
`TextEncoder().encode("\n")` newline hack. Pre-v1.44, the lazy-binary-
spawn pattern made forceRestart look stuck until the user typed —
now claude boots before the prompt renders.
* forceRestart() rewritten as an async one-transaction handler:
1. close current WS with code 4001 (intentional-restart)
2. POST /pty-restart with priorSessionId so the server can scope
the dispose, then mint fresh sessionId + lease + attachToken
in the same response
3. Open new WS with the returned attachToken, send {type:"start"}
immediately for eager spawn
4. On 401: sticky-abort the auto-connect loop (no spam)
5. On 503 / network failure: fall back to patient autoconnect
* currentSessionId tracked and exposed on window.gstackPtySession so
sidepanel.js's pagehide handler can sendBeacon the dispose.
sidepanel.js:
* New pagehide handler fires navigator.sendBeacon('/pty-dispose',
{sessionId, authToken}) on tab close, panel close, browser quit,
or extension reload. sendBeacon-compatible: auth token rides in
the body since sendBeacon can't set custom headers (server route
accepts body-auth per 25ef24e9).
* try/catch around the entire body so a sendBeacon failure can't
interfere with the browser's unload sequence — the 60s detach
window from Commit 3 catches anything we miss.
There's bounded duplication between connect() and forceRestart() (~70
lines of WS attach/handler wiring). Extracting a shared helper is a
clean follow-up but out of scope for the v1.44 ship — both paths are
exercised by the same e2e test.
Test (browse/test/sidepanel-restart-dispose.test.ts):
* 9 static-grep tripwires pinning the 4-tuple parse, eager spawn,
close-code 4001 contract, /pty-restart wire shape, sticky-abort
401 path, sessionId window plumbing, sendBeacon body contract,
and the best-effort try/catch around pagehide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(terminal-agent): scrollback ring buffer + detach state machine + re-attach
The agent side of Commit 3 — the "magic" feature. A network blip (wifi
hiccup, MV3 panel suspend, brief Chromium pause) now silently reconnects
the sidebar to the SAME claude session with scrollback intact. No more
"Session ended" message + manual Restart click + losing your tool-call
output. Server-side /pty-session/reattach (25ef24e9) and the extension
re-attach loop (next commit) close the loop end-to-end.
Ring buffer (T10):
* Per-session frames: Buffer[] capped at 1 MB (env-overridable via
GSTACK_PTY_RING_BUFFER_BYTES). Each PTY write is one frame, so
eviction is at frame boundaries and never cuts a UTF-8 sequence or
ANSI CSI in half.
* appendToRingBuffer eviction loop keeps at least one frame even at
extreme caps — a single oversized frame can't empty the buffer.
* Alt-screen tracking via canonical xterm CSI ?1049h / CSI ?1049l
sequences. lastIndexOf comparison so trailing state wins when both
appear in one render frame (quick tool-call open+close).
Replay payload (T5 — codex outside-voice):
* buildReplayPayload prefixes DECSTR soft reset (\x1b[!p) and
conditionally re-enters alt-screen if claude was in a tool call at
detach. The client writes RIS (\x1bc) FIRST to clear pre-blip xterm
content; the server's prelude resets character attributes; the ring
buffer replays cleanly on top.
* Order is enforced by the {type:"reattach-begin"} text frame the
agent sends right before the binary replay — client waits for it,
writes RIS, then treats the next binary frame as the replay payload.
Detach state machine (T9):
* PtySession.liveWs decouples the PTY callback from the original ws
closure. On re-attach, swapping session.liveWs is enough — the
on-data callback writes to the new ws automatically.
* close(ws, code, _reason): codes 4001 (intentional restart), 4404
(no-claude), and 1000 (clean exit) trigger immediate dispose.
Anything else (1006 abnormal, 1001 going-away from network blip /
panel suspend) starts a 60s detach timer instead. claude keeps
running, output keeps accumulating in the ring buffer.
* Detach timer is unref'd so the bun process can still exit cleanly
on natural shutdown.
* Sessions without a sessionId (legacy single-shot grants) can't
re-attach by definition — those fall through to immediate dispose.
Re-attach lookup (T9):
* WS open() checks sessionsById[sessionId] FIRST. If a detached
session is sitting there, cancel its detach timer, swap liveWs,
rebind the WS-keyed map, restart keepalive, send reattach-begin
+ replay payload. The PTY process is unchanged.
* /internal/restart now cancels any pending detach timer before
disposal — otherwise the timer would later try to dispose an
already-disposed session.
Env knobs for e2e:
* GSTACK_PTY_RING_BUFFER_BYTES — compress to 256 for eviction tests.
* GSTACK_PTY_DETACH_WINDOW_MS — compress to 1000 for "did the timer
fire?" tests without waiting a minute per assertion.
Tests:
* browse/test/terminal-agent-detach-reattach.test.ts — 10 static-grep
tripwires for the load-bearing properties: interface shape, env
knobs, eviction floor, alt-screen tracking, replay prelude
composition, re-attach lookup, close-code routing, detach timer
unref, /internal/restart timer cancellation, on-data through
session.liveWs.
* browse/test/terminal-agent-session-routing.test.ts test 7 widened
to match the new close(ws, code, _reason) signature.
* browse/test/terminal-agent-keepalive.test.ts test 3 widened
similarly. Both stay regressions for the prior contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sidebar): silent re-attach with scrollback replay (Commit 3 client side)
Closes the v1.44 long-lived-sidebar loop end-to-end. When the WS dies for
a transient reason (wifi blip, MV3 panel suspend, brief Chromium pause),
the sidebar now silently re-attaches to the SAME claude session inside the
server's 60s detach window. Scrollback replays cleanly; the user keeps
typing without noticing anything happened.
State machine:
* New STATE.RECONNECTING covers the in-flight re-attach window.
setState transitions out of this state reset reattachInFlight so a
concurrent user action (Restart click, panel navigate) short-circuits
cleanly.
* Backoff schedule REATTACH_BACKOFF_MS = [1000, 2000, 4000, 8000] then
8s steady until REATTACH_WINDOW_MS (60s) elapses. Past that point
the server has disposed our session and /pty-session/reattach
returns 410 Gone.
startReattachLoop(prevSessionId):
* Posts /pty-session/reattach with sessionId.
* On 200 with a valid 4-tuple, opens the post-reattach WS directly.
* On 410 (lease expired) — short-circuits to ENDED. No retry; the user
clicks Restart for a fresh session.
* On 401 — sticky-aborts the auto-connect loop. Same defense as 25ef24e9
so we don't spam "Auth invalid" every 2s.
* On network failure or other non-OK status — schedules the next
backoff tick.
openReattachWebSocket(terminalPort, attachToken, sessionId):
* Mostly a clone of connect()'s attach wiring. Reuses the live xterm
element — RIS clears the buffer cleanly when the agent's
{type:"reattach-begin"} arrives, so the visual flash is minimal.
* Handshake: on `{type:"reattach-begin"}` text frame → write `\x1bc`
(RIS) to xterm + set nextBinaryIsReplay = true. The next binary
frame IS the server-built replay payload (DECSTR soft-reset prefix
+ optional alt-screen re-enter + ring buffer contents).
* If THIS reattach WS also dies uncleanly, recurses into another
re-attach loop with the same sessionId — the server's detach window
may still be open. State guard prevents runaway recursion.
connect() + forceRestart() close handlers (existing):
* Both updated to call startReattachLoop on transient close codes
(anything other than 1000 / 4001 / 4404). Was just setState(ENDED).
* Clean codes still bypass — re-attaching to a force-restart's
pre-restart session would be the bug we're avoiding.
Test (browse/test/sidepanel-reattach.test.ts):
* 8 static-grep tripwires for the load-bearing properties: state
constant, backoff schedule, /pty-session/reattach wiring, 410
short-circuit (no retry past lease window), 401 sticky-abort,
reattach-begin → RIS handshake, all three close handlers route
through the loop, clean-code bypass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.44.0.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(terminal-agent): runtime tests for ring buffer + replay + alt-screen tracking
Companion to browse/test/terminal-agent-detach-reattach.test.ts (static-grep
tripwires) — calls appendToRingBuffer + buildReplayPayload directly to prove
behavioral correctness without spinning up a real Bun.serve listener.
* 11 runtime cases: append + byte counting, oversize eviction with
one-frame floor (the eviction loop guard that prevents an oversized
single frame from emptying the buffer), alt-screen tracking via
canonical xterm CSI ?1049h / CSI ?1049l, trailing-state-wins for
enter+exit pairs inside a single render frame, soft-reset prefix
ordering, optional alt-screen re-enter, payload length math.
* Exports appendToRingBuffer, buildReplayPayload, and the PtySession
interface from terminal-agent.ts (purely for testability — they
were module-private; the change is annotation-only).
* Lease registry sanity check: mint two sessions, verify distinct
sessionIds, both valid simultaneously. Catches future refactors
that accidentally couple lease + ring buffer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): explain_level unset returns the documented default, not empty
Pre-existing failure on main — the test expected gstack-config to return
"" for an unset explain_level (with the comment "preamble default takes
over"), but the script at bin/gstack-config:103 explicitly returns
"default" inline for that key. Earlier versions of the script may have
relied on shell-substitution fallback, but the current contract is
inline-default-on-get so callers always receive a usable value without
bash gymnastics.
Updated the test to match the actual contract. Also added GSTACK_HOME
override alongside GSTACK_STATE_DIR in the spawn env so developer-machine
config doesn't bleed into the test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -178,7 +178,17 @@ describe('buildSpawnEnv', () => {
|
||||
process.env.LANG = 'en_US.UTF-8';
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env = origEnv;
|
||||
// process.env = origEnv replaces only the reference; the underlying
|
||||
// env stays mutated and leaks to later test files in the same Bun
|
||||
// process (e.g., breaks Bun.which('bash') in security.test.ts and
|
||||
// bun-spawn in pair-agent-tunnel-eval.test.ts). Delete every current
|
||||
// key then re-assign from the snapshot — restores the actual env.
|
||||
for (const k of Object.keys(process.env)) {
|
||||
if (!(k in origEnv)) delete process.env[k];
|
||||
}
|
||||
for (const [k, v] of Object.entries(origEnv)) {
|
||||
if (v !== undefined) process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
it('untrusted: drops $HOME and secrets', () => {
|
||||
@@ -293,7 +303,15 @@ describe.skipIf(SKIP_SPAWN)('spawnSkill: lifecycle', () => {
|
||||
expect(parsed.gh).toBeNull();
|
||||
expect(parsed.gstack).toBeNull();
|
||||
} finally {
|
||||
process.env = origEnv;
|
||||
// See afterEach comment in `buildSpawnEnv` describe — direct
|
||||
// reassignment of process.env doesn't actually restore the
|
||||
// underlying env in Bun. Delete + re-assign instead.
|
||||
for (const k of Object.keys(process.env)) {
|
||||
if (!(k in origEnv)) delete process.env[k];
|
||||
}
|
||||
for (const [k, v] of Object.entries(origEnv)) {
|
||||
if (v !== undefined) process.env[k] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -312,7 +330,12 @@ describe.skipIf(SKIP_SPAWN)('spawnSkill: lifecycle', () => {
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(parsed.home).toBe('/Users/test-user');
|
||||
} finally {
|
||||
process.env = origEnv;
|
||||
for (const k of Object.keys(process.env)) {
|
||||
if (!(k in origEnv)) delete process.env[k];
|
||||
}
|
||||
for (const [k, v] of Object.entries(origEnv)) {
|
||||
if (v !== undefined) process.env[k] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 outer supervisor — static-grep invariants.
|
||||
//
|
||||
// Pre-v1.44 `$B connect` was fire-and-forget: spawn server detached, CLI
|
||||
// exits, server runs unsupervised. If the server crashed, the user had to
|
||||
// re-run `$B connect`. The opt-in supervisor (--supervise or
|
||||
// BROWSE_SUPERVISE=1) keeps the CLI attached and respawns the server on
|
||||
// unexpected exit, with the same crash-loop guard shape as the v1.44
|
||||
// terminal-agent watchdog.
|
||||
//
|
||||
// Live respawn tests belong in the e2e tier (real Bun.spawn cycles take
|
||||
// 3-8s each). These tripwires defend the load-bearing invariants:
|
||||
// opt-in by default, signal handlers wired, crash-loop guard, env knobs.
|
||||
|
||||
const CLI_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'cli.ts');
|
||||
|
||||
describe('CLI outer supervisor (v1.44+)', () => {
|
||||
test('1. supervisor is opt-in via --supervise flag or BROWSE_SUPERVISE env', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
expect(src).toContain("commandArgs.includes('--supervise')");
|
||||
expect(src).toContain("process.env.BROWSE_SUPERVISE === '1'");
|
||||
// Default path MUST still exit 0 promptly. The legacy contract is
|
||||
// that every caller of `$B connect` (Claude Code Bash tool, scripts,
|
||||
// CI) gets a prompt return.
|
||||
expect(src).toMatch(/if \(!superviseRequested\) \{\s*process\.exit\(0\);\s*\}/);
|
||||
});
|
||||
|
||||
test('2. SIGINT and SIGTERM trigger clean teardown', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
// Both signals must hit the teardown path or the user's Ctrl-C leaves
|
||||
// an orphaned server (worse than no supervisor).
|
||||
expect(src).toMatch(/process\.on\('SIGINT'.*teardownAndExit/);
|
||||
expect(src).toMatch(/process\.on\('SIGTERM'.*teardownAndExit/);
|
||||
// Teardown must signal the supervised server before exiting itself.
|
||||
expect(src).toContain("safeKill(state.pid, 'SIGTERM')");
|
||||
});
|
||||
|
||||
test('3. crash-loop guard with 5-in-5min rolling window', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
expect(src).toContain('SUPERVISOR_GUARD_WINDOW_MS = 5 * 60_000');
|
||||
expect(src).toContain('SUPERVISOR_GUARD_MAX = 5');
|
||||
// Window pruning: a long-lived daemon with sporadic crashes must NOT
|
||||
// hit the guard (otherwise we punish the user for the supervisor doing
|
||||
// its job).
|
||||
expect(src).toMatch(/respawns\.shift\(\)/);
|
||||
});
|
||||
|
||||
test('4. exponential backoff schedule, env-overridable', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_SUPERVISOR_BACKOFF');
|
||||
// Default schedule must include short waits at first (rapid recovery
|
||||
// from transient crashes) and cap at a sensible long wait.
|
||||
expect(src).toContain('1000,2000,4000,8000,30000');
|
||||
});
|
||||
|
||||
test('5. tick interval is env-overridable for tests', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_SUPERVISOR_TICK_MS');
|
||||
});
|
||||
|
||||
test('6. respawned server gets a fresh terminal-agent too', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
// After server respawn, the terminal-agent state is stale (old PID
|
||||
// record points to a dead agent that exited with its parent). The
|
||||
// supervisor must re-call spawnTerminalAgent or the PTY path stays
|
||||
// broken even though the server is back up.
|
||||
const block = sliceBetween(src, 'Supervisor mode:', '// ─── Headed Disconnect');
|
||||
expect(block).toContain('spawnTerminalAgent({');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
|
||||
// pty-session-lease registers a sessionId space distinct from the pre-v1.44
|
||||
// attach-token space (browse/src/pty-session-cookie.ts). These tests pin
|
||||
// the validate-first contract that codex outside-voice flagged as critical:
|
||||
// refreshLease MUST NOT resurrect expired leases, otherwise the 30-min TTL
|
||||
// stops bounding leaked-token blast radius.
|
||||
|
||||
import {
|
||||
mintLease,
|
||||
validateLease,
|
||||
refreshLease,
|
||||
revokeLease,
|
||||
leaseCount,
|
||||
__resetLeases,
|
||||
} from '../src/pty-session-lease';
|
||||
|
||||
beforeEach(() => {
|
||||
__resetLeases();
|
||||
});
|
||||
|
||||
describe('pty-session-lease: mint/validate/revoke', () => {
|
||||
test('mintLease returns a fresh non-secret sessionId + future expiresAt', () => {
|
||||
const a = mintLease();
|
||||
const b = mintLease();
|
||||
expect(a.sessionId).toBeTruthy();
|
||||
expect(b.sessionId).toBeTruthy();
|
||||
expect(a.sessionId).not.toBe(b.sessionId);
|
||||
expect(a.expiresAt).toBeGreaterThan(Date.now());
|
||||
// base64url alphabet: characters in [A-Za-z0-9_-].
|
||||
expect(a.sessionId).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(leaseCount()).toBe(2);
|
||||
});
|
||||
|
||||
test('validateLease ok for fresh lease, false for unknown', () => {
|
||||
const { sessionId } = mintLease();
|
||||
const ok = validateLease(sessionId);
|
||||
expect(ok.ok).toBe(true);
|
||||
if (ok.ok) expect(ok.expiresAt).toBeGreaterThan(Date.now());
|
||||
expect(validateLease('not-a-real-session-id').ok).toBe(false);
|
||||
expect(validateLease(null).ok).toBe(false);
|
||||
expect(validateLease(undefined).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('revokeLease removes the lease; subsequent validate returns false', () => {
|
||||
const { sessionId } = mintLease();
|
||||
expect(validateLease(sessionId).ok).toBe(true);
|
||||
revokeLease(sessionId);
|
||||
expect(validateLease(sessionId).ok).toBe(false);
|
||||
expect(leaseCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('revokeLease tolerates unknown sessionId without throwing', () => {
|
||||
expect(() => revokeLease('phantom')).not.toThrow();
|
||||
expect(() => revokeLease(null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pty-session-lease: refresh contract (validate-first)', () => {
|
||||
test('refreshLease extends expiresAt for a valid lease', () => {
|
||||
const { sessionId, expiresAt: initial } = mintLease();
|
||||
// Sleep micro-tick — Date.now() is ms-grain so a synchronous extend
|
||||
// may not move the integer. Use a tight async wait instead.
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
const r = refreshLease(sessionId);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.expiresAt).toBeGreaterThan(initial);
|
||||
resolve();
|
||||
}, 5);
|
||||
});
|
||||
});
|
||||
|
||||
test('refreshLease rejects unknown sessionId (validate-first invariant)', () => {
|
||||
const r = refreshLease('never-minted');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('refreshLease never resurrects an expired lease', async () => {
|
||||
// Force TTL down to 5ms for this assertion by minting + waiting past expiry.
|
||||
// Lease internals use Date.now() so the easiest way to expire one is
|
||||
// to artificially backdate via revoke+remint cycle. Simpler: mint, then
|
||||
// wait for the registry's own expiry check to trip.
|
||||
//
|
||||
// We can't backdate without breaking encapsulation, so this test exercises
|
||||
// the negative-validate path: minted lease, then prove that refresh after
|
||||
// explicit revoke still returns ok:false (same as expired-and-pruned).
|
||||
const { sessionId } = mintLease();
|
||||
revokeLease(sessionId);
|
||||
const r = refreshLease(sessionId);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('refreshLease tolerates null / undefined sessionId', () => {
|
||||
expect(refreshLease(null).ok).toBe(false);
|
||||
expect(refreshLease(undefined).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -63,13 +63,13 @@ describe('Server auth security', () => {
|
||||
|
||||
// Test 4: /activity/history requires auth via validateAuth
|
||||
test('/activity/history requires authentication', () => {
|
||||
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Sidebar endpoints');
|
||||
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Batch endpoint');
|
||||
expect(historyBlock).toContain('validateAuth');
|
||||
});
|
||||
|
||||
// Test 5: /activity/history has no wildcard CORS header
|
||||
test('/activity/history has no wildcard CORS header', () => {
|
||||
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Sidebar endpoints');
|
||||
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Batch endpoint');
|
||||
expect(historyBlock).not.toContain("'*'");
|
||||
});
|
||||
|
||||
@@ -314,7 +314,7 @@ describe('Server auth security', () => {
|
||||
// Regression: connect command crashed with "domains is not defined" because
|
||||
// a stray `domains,` variable was in the status fetch body (cli.ts:852).
|
||||
test('connect command status fetch body has no undefined variable references', () => {
|
||||
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Sidebar agent started');
|
||||
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started');
|
||||
// The status fetch should use a clean JSON body
|
||||
expect(connectBlock).toContain("command: 'status'");
|
||||
// Must NOT contain a bare `domains` reference in the fetch body
|
||||
@@ -335,10 +335,15 @@ describe('Server auth security', () => {
|
||||
// The connect subprocess env must override BROWSE_PARENT_PID
|
||||
expect(pairBlock).toContain("BROWSE_PARENT_PID");
|
||||
expect(pairBlock).toContain("'0'");
|
||||
// The connect command must propagate BROWSE_PARENT_PID=0 to serverEnv
|
||||
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Sidebar agent started');
|
||||
expect(connectBlock).toContain("BROWSE_PARENT_PID");
|
||||
expect(connectBlock).toContain("serverEnv.BROWSE_PARENT_PID");
|
||||
// The connect command must propagate BROWSE_PARENT_PID=0 via the
|
||||
// serverEnv object literal passed to startServer. The literal text
|
||||
// `serverEnv.BROWSE_PARENT_PID` is NOT in source — the value is
|
||||
// assigned via object-literal syntax (`BROWSE_PARENT_PID: '0'`)
|
||||
// inside the `const serverEnv: Record<string, string> = { ... }`
|
||||
// declaration. Assert both pieces appear in the connect block.
|
||||
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started');
|
||||
expect(connectBlock).toContain("const serverEnv");
|
||||
expect(connectBlock).toContain("BROWSE_PARENT_PID: '0'");
|
||||
});
|
||||
|
||||
// Regression: newtab returned 403 for scoped tokens because the tab ownership
|
||||
|
||||
@@ -14,21 +14,35 @@ import { resolveConfig } from '../src/config';
|
||||
// Tests for the v1.41+ ownsTerminalAgent flag.
|
||||
//
|
||||
// Embedders (gbrowser phoenix overlay) that run their own PTY server and write
|
||||
// terminal-port / terminal-internal-token themselves were getting those files
|
||||
// clobbered by gstack's shutdown(). The flag (default true) gates three side
|
||||
// effects: pkill -f terminal-agent\.ts, unlink terminal-port, unlink
|
||||
// terminal-internal-token. False = embedder owns them, gstack stays hands-off.
|
||||
// terminal-port / terminal-internal-token / terminal-agent-pid themselves were
|
||||
// getting those files clobbered by gstack's shutdown(). The flag (default true)
|
||||
// gates four side effects (v1.44+):
|
||||
// 1. identity-based kill of the PID in <stateDir>/terminal-agent-pid
|
||||
// 2. unlink terminal-port
|
||||
// 3. unlink terminal-internal-token
|
||||
// 4. unlink terminal-agent-pid
|
||||
// False = embedder owns them, gstack stays hands-off.
|
||||
//
|
||||
// CRITICAL: each test stubs BOTH process.exit (so shutdown's exit doesn't kill
|
||||
// the test runner) AND child_process.spawnSync (so pkill doesn't run real
|
||||
// `pkill -f terminal-agent\.ts` on the developer's machine — would kill any
|
||||
// sibling gstack sessions).
|
||||
// Pre-v1.44 used `pkill -f terminal-agent\.ts` which matched sibling gstack
|
||||
// sessions on the same host — see browse/src/terminal-agent-control.ts header.
|
||||
//
|
||||
// CRITICAL: each test stubs process.exit (so shutdown's exit doesn't kill
|
||||
// the test runner). The PID in the test agent-record is a guaranteed-dead
|
||||
// PID (1 = init / launchd — exists but cannot be killed by an unprivileged
|
||||
// process, so safeKill returns ESRCH-equivalent without affecting anything).
|
||||
// Use isProcessAlive's false branch by also testing with a PID that does
|
||||
// not exist (negative PID rejected by the OS).
|
||||
|
||||
const stateDir = resolveConfig().stateDir;
|
||||
const PORT_FILE = path.join(stateDir, 'terminal-port');
|
||||
const TOKEN_FILE = path.join(stateDir, 'terminal-internal-token');
|
||||
const AGENT_RECORD_FILE = path.join(stateDir, 'terminal-agent-pid');
|
||||
const SENTINEL_PORT = 'sentinel-port-65432';
|
||||
const SENTINEL_TOKEN = 'sentinel-token-abcdef1234567890';
|
||||
// PID 2^31-1 is the Linux PID_MAX_LIMIT; macOS uses 99998. Either way, no
|
||||
// real process will ever hold this PID on a developer machine. isProcessAlive
|
||||
// returns false → killAgentByRecord no-ops without sending any signal.
|
||||
const SENTINEL_DEAD_PID = 2147483646;
|
||||
|
||||
function makeMinimalConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
|
||||
const token = 'embedder-test-' + crypto.randomBytes(16).toString('hex');
|
||||
@@ -47,6 +61,10 @@ function writeSentinels(): void {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(PORT_FILE, SENTINEL_PORT);
|
||||
fs.writeFileSync(TOKEN_FILE, SENTINEL_TOKEN);
|
||||
fs.writeFileSync(
|
||||
AGENT_RECORD_FILE,
|
||||
JSON.stringify({ pid: SENTINEL_DEAD_PID, gen: 'sentinel-gen', startedAt: Date.now() }),
|
||||
);
|
||||
}
|
||||
|
||||
function readIfExists(p: string): string | null {
|
||||
@@ -54,32 +72,40 @@ function readIfExists(p: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stubs process.exit + child_process.spawnSync, runs the callback, and
|
||||
* restores both regardless of throw. Returns the captured spawnSync argv
|
||||
* list so callers can assert pkill was or wasn't invoked. The callback
|
||||
* is expected to swallow the __exit:N throw from shutdown().
|
||||
* Stubs process.exit so shutdown()'s process.exit(0) throws an __exit:N
|
||||
* marker the test can swallow instead of killing the runner. Also stubs
|
||||
* process.kill so an accidental kill (regression in killAgentByRecord
|
||||
* that bypassed isProcessAlive) cannot reach a real PID on the developer
|
||||
* machine. Returns the captured kill calls so tests can assert kill
|
||||
* scope.
|
||||
*/
|
||||
async function withStubs(
|
||||
cb: (spawnSyncCalls: any[][]) => Promise<void>
|
||||
): Promise<any[][]> {
|
||||
cb: (killCalls: Array<[number, NodeJS.Signals | number]>) => Promise<void>
|
||||
): Promise<Array<[number, NodeJS.Signals | number]>> {
|
||||
const origExit = process.exit;
|
||||
const childProcess = require('child_process');
|
||||
const origSpawnSync = childProcess.spawnSync;
|
||||
const spawnSyncCalls: any[][] = [];
|
||||
const origKill = process.kill;
|
||||
const killCalls: Array<[number, NodeJS.Signals | number]> = [];
|
||||
(process as any).exit = ((code: number) => {
|
||||
throw new Error(`__exit:${code}`);
|
||||
}) as any;
|
||||
childProcess.spawnSync = ((...args: any[]) => {
|
||||
spawnSyncCalls.push(args);
|
||||
return { status: 0, stdout: '', stderr: '', signal: null, pid: 0, output: [] };
|
||||
(process as any).kill = ((pid: number, signal: NodeJS.Signals | number) => {
|
||||
killCalls.push([pid, signal ?? 'SIGTERM']);
|
||||
// signal 0 is a liveness probe — keep the existing 'process is dead'
|
||||
// semantics so isProcessAlive(SENTINEL_DEAD_PID) returns false.
|
||||
if (signal === 0) {
|
||||
const err: any = new Error('No such process');
|
||||
err.code = 'ESRCH';
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}) as any;
|
||||
try {
|
||||
await cb(spawnSyncCalls);
|
||||
await cb(killCalls);
|
||||
} finally {
|
||||
(process as any).exit = origExit;
|
||||
childProcess.spawnSync = origSpawnSync;
|
||||
(process as any).kill = origKill;
|
||||
}
|
||||
return spawnSyncCalls;
|
||||
return killCalls;
|
||||
}
|
||||
|
||||
async function runShutdown(handle: { shutdown: (code?: number) => Promise<void> }): Promise<void> {
|
||||
@@ -90,23 +116,28 @@ async function runShutdown(handle: { shutdown: (code?: number) => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
function pkillCalls(calls: any[][]): any[][] {
|
||||
return calls.filter((call) => call[0] === 'pkill');
|
||||
// Filter out the signal=0 liveness probes; only count actual termination signals.
|
||||
function terminationCalls(
|
||||
calls: Array<[number, NodeJS.Signals | number]>,
|
||||
): Array<[number, NodeJS.Signals | number]> {
|
||||
return calls.filter(([, sig]) => sig !== 0);
|
||||
}
|
||||
|
||||
describe('buildFetchHandler ownsTerminalAgent gate', () => {
|
||||
// shutdown() reads `path.dirname(config.stateFile)` from module-level config
|
||||
// (composition gap — see TODOS T9). So unlinks target the real state dir,
|
||||
// not a per-test temp dir. If a real gstack daemon is running on this host,
|
||||
// its terminal-port + terminal-internal-token live where this test writes.
|
||||
// Save + restore real-daemon file contents around the whole suite so the
|
||||
// test never clobbers a developer's running session.
|
||||
// its terminal-port + terminal-internal-token + terminal-agent-pid live
|
||||
// where this test writes. Save + restore real-daemon file contents around
|
||||
// the whole suite so the test never clobbers a developer's running session.
|
||||
let realPortBackup: string | null = null;
|
||||
let realTokenBackup: string | null = null;
|
||||
let realAgentRecordBackup: string | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
realPortBackup = readIfExists(PORT_FILE);
|
||||
realTokenBackup = readIfExists(TOKEN_FILE);
|
||||
realAgentRecordBackup = readIfExists(AGENT_RECORD_FILE);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -122,6 +153,12 @@ describe('buildFetchHandler ownsTerminalAgent gate', () => {
|
||||
} else {
|
||||
try { fs.unlinkSync(TOKEN_FILE); } catch {}
|
||||
}
|
||||
if (realAgentRecordBackup !== null) {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(AGENT_RECORD_FILE, realAgentRecordBackup);
|
||||
} else {
|
||||
try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -131,9 +168,10 @@ describe('buildFetchHandler ownsTerminalAgent gate', () => {
|
||||
// assertion can't pass spuriously off a stale file.
|
||||
try { fs.unlinkSync(PORT_FILE); } catch {}
|
||||
try { fs.unlinkSync(TOKEN_FILE); } catch {}
|
||||
try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {}
|
||||
});
|
||||
|
||||
test('1. ownsTerminalAgent:false preserves both files and skips pkill', async () => {
|
||||
test('1. ownsTerminalAgent:false preserves all three files and sends no signal', async () => {
|
||||
writeSentinels();
|
||||
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: false }));
|
||||
const calls = await withStubs(async () => {
|
||||
@@ -141,10 +179,11 @@ describe('buildFetchHandler ownsTerminalAgent gate', () => {
|
||||
});
|
||||
expect(readIfExists(PORT_FILE)).toBe(SENTINEL_PORT);
|
||||
expect(readIfExists(TOKEN_FILE)).toBe(SENTINEL_TOKEN);
|
||||
expect(pkillCalls(calls).length).toBe(0);
|
||||
expect(readIfExists(AGENT_RECORD_FILE)).not.toBeNull();
|
||||
expect(terminationCalls(calls).length).toBe(0);
|
||||
});
|
||||
|
||||
test('2. ownsTerminalAgent:true (explicit) deletes both files and invokes pkill exactly once', async () => {
|
||||
test('2. ownsTerminalAgent:true deletes all three files; identity-based kill probes the recorded PID', async () => {
|
||||
writeSentinels();
|
||||
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: true }));
|
||||
const calls = await withStubs(async () => {
|
||||
@@ -152,13 +191,15 @@ describe('buildFetchHandler ownsTerminalAgent gate', () => {
|
||||
});
|
||||
expect(readIfExists(PORT_FILE)).toBeNull();
|
||||
expect(readIfExists(TOKEN_FILE)).toBeNull();
|
||||
const pkills = pkillCalls(calls);
|
||||
expect(pkills.length).toBe(1);
|
||||
// argv[1] is the args array passed to spawnSync.
|
||||
expect(pkills[0][1]).toEqual(['-f', 'terminal-agent\\.ts']);
|
||||
expect(readIfExists(AGENT_RECORD_FILE)).toBeNull();
|
||||
// isProcessAlive sends signal 0; PID is the sentinel-dead PID, so the
|
||||
// probe returns false and no SIGTERM is sent.
|
||||
const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0);
|
||||
expect(probes.length).toBeGreaterThan(0);
|
||||
expect(terminationCalls(calls).length).toBe(0);
|
||||
});
|
||||
|
||||
test('3. ownsTerminalAgent unset defaults to true (deletes + pkill)', async () => {
|
||||
test('3. ownsTerminalAgent unset defaults to true (deletes all three; probes recorded PID)', async () => {
|
||||
writeSentinels();
|
||||
// Note: no ownsTerminalAgent in the overrides — uses the `?? true` default.
|
||||
const handle = buildFetchHandler(makeMinimalConfig());
|
||||
@@ -167,7 +208,9 @@ describe('buildFetchHandler ownsTerminalAgent gate', () => {
|
||||
});
|
||||
expect(readIfExists(PORT_FILE)).toBeNull();
|
||||
expect(readIfExists(TOKEN_FILE)).toBeNull();
|
||||
expect(pkillCalls(calls).length).toBe(1);
|
||||
expect(readIfExists(AGENT_RECORD_FILE)).toBeNull();
|
||||
const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0);
|
||||
expect(probes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('4. CLI start() call site passes ownsTerminalAgent: true literally (static grep)', () => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Server-side route shape for the v1.44 lease + restart + dispose +
|
||||
// lease-refresh wiring. Live route exercises require the terminal-agent
|
||||
// loopback to be live (e2e-tier); these static-grep tripwires pin the
|
||||
// load-bearing protocol invariants.
|
||||
|
||||
const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts');
|
||||
|
||||
describe('server: PTY lease routes (v1.44+ Commit 2)', () => {
|
||||
test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/pty-session' &&", "url.pathname === '/pty-session/reattach'");
|
||||
expect(block).toContain('mintLease()');
|
||||
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
|
||||
expect(block).toContain('sessionId: lease.sessionId');
|
||||
expect(block).toContain('attachToken: minted.token');
|
||||
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
|
||||
// Backward compat: legacy ptySessionToken alias preserved for one release.
|
||||
expect(block).toContain('ptySessionToken: minted.token');
|
||||
});
|
||||
|
||||
test('2. /pty-session/reattach validates lease + mints fresh attachToken', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/pty-session/reattach'", "url.pathname === '/pty-restart'");
|
||||
// Validate-first: rejects unknown/expired sessionId with 410 Gone so
|
||||
// the client knows to fall back to a fresh /pty-session.
|
||||
expect(block).toContain('validateLease(sessionId)');
|
||||
expect(block).toContain('status: 410');
|
||||
// Mint fresh token bound to SAME sessionId.
|
||||
expect(block).toContain('grantPtyToken(minted.token, sessionId!)');
|
||||
});
|
||||
|
||||
test('3. /pty-restart is one transaction — dispose + revoke + fresh mint', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/pty-restart'", "url.pathname === '/pty-dispose'");
|
||||
// Disposes old session (best-effort — missing sessionId is non-fatal).
|
||||
expect(block).toContain('restartPtySession(oldSessionId)');
|
||||
expect(block).toContain('revokeLease(oldSessionId)');
|
||||
// Then mints fresh sessionId + lease + attachToken in the same handler.
|
||||
expect(block).toContain('mintLease()');
|
||||
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
|
||||
// Returns the same 4-tuple shape so the client doesn't need a
|
||||
// separate /pty-session round-trip.
|
||||
expect(block).toContain('attachToken: minted.token');
|
||||
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
|
||||
});
|
||||
|
||||
test('4. /pty-dispose accepts body-token (sendBeacon-compatible)', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/pty-dispose'", "url.pathname === '/internal/lease-refresh'");
|
||||
// sendBeacon can't set custom headers, so the route MUST accept the
|
||||
// auth token in the request body. Otherwise pagehide cleanup fails
|
||||
// silently every time the user closes the browser.
|
||||
expect(block).toContain('body?.authToken');
|
||||
expect(block).toContain('authedByBody');
|
||||
// Both auth paths must validate against authToken — never just trust
|
||||
// a body-supplied token without the equality check.
|
||||
expect(block).toContain('authTokenFromBody === authToken');
|
||||
});
|
||||
|
||||
test('5. /internal/lease-refresh resets the daemon idle timer (T6)', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/lease-refresh'", '─── /pty-inject-scan');
|
||||
expect(block).toContain('refreshLease(sessionId)');
|
||||
expect(block).toContain('resetIdleTimer()');
|
||||
// Refresh failure (unknown / expired) MUST 410, not 200, so the
|
||||
// agent knows to close the WS and force a clean re-auth.
|
||||
expect(block).toContain('status: 410');
|
||||
});
|
||||
|
||||
test('6. grantPtyToken loopback carries sessionId binding', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
expect(src).toMatch(/grantPtyToken\(token: string, sessionId\?: string\)/);
|
||||
expect(src).toContain('sessionId ? { token, sessionId } : { token }');
|
||||
});
|
||||
|
||||
test('7. restartPtySession helper exists and POSTs the agent /internal/restart', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
expect(src).toMatch(/async function restartPtySession\(sessionId: string\)/);
|
||||
expect(src).toContain('/internal/restart');
|
||||
expect(src).toContain('JSON.stringify({ sessionId })');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 patient autoConnect — static-grep invariants for the polling loop.
|
||||
//
|
||||
// Pre-v1.44 the sidebar gave up at 15s with "Browse server not ready.
|
||||
// Reload sidebar to retry." Cold-start the browse server takes ~3-8s on a
|
||||
// healthy laptop, longer on Conductor workspaces / slow CI, so the user
|
||||
// frequently saw the failure message even when nothing was wrong. The
|
||||
// fix: poll forever with ascending status messages and only abort on
|
||||
// explicit unrecoverable signals (401 auth invalid).
|
||||
|
||||
const CLIENT_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'extension',
|
||||
'sidepanel-terminal.js',
|
||||
);
|
||||
|
||||
describe('sidepanel tryAutoConnect patience (v1.44+)', () => {
|
||||
test('1. no 15s give-up message', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
// The v0.x give-up string must NOT reappear — it's the message users
|
||||
// saw on every cold start and the whole point of v1.44 was to delete it.
|
||||
expect(src).not.toContain('Browse server not ready. Reload sidebar to retry.');
|
||||
});
|
||||
|
||||
test('2. ascending status messages at 15s / 60s / 5min', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
expect(src).toContain('Waiting for browse server...');
|
||||
expect(src).toContain('Still waiting');
|
||||
expect(src).toContain('still not responding after 5 min');
|
||||
});
|
||||
|
||||
test('3. sticky abort flag prevents loop spam on 401', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
expect(src).toContain('autoConnectAborted');
|
||||
// The mint failure branch must short-circuit on 401 specifically.
|
||||
expect(src).toMatch(/minted\.error.*startsWith\('401'\)/);
|
||||
// tryAutoConnect tick must respect the flag.
|
||||
expect(src).toMatch(/if \(autoConnectAborted\) return/);
|
||||
});
|
||||
|
||||
test('4. forceRestart re-arms the loop by clearing the abort flag', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
// forceRestart is the user's "try again" escape hatch — must reset
|
||||
// the sticky flag or 401-once means stuck-forever.
|
||||
const block = sliceBetween(src, 'function forceRestart', 'function repaintIfLive');
|
||||
expect(block).toContain('autoConnectAborted = false');
|
||||
});
|
||||
|
||||
test('5. poll interval is 2s, not the legacy 200ms tight loop', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
// 200ms ticks burned CPU and made the give-up window land too fast.
|
||||
// 2s is the v1.44 cadence — verify the tight-loop literal is gone.
|
||||
expect(src).toContain('setTimeout(tick, 2000)');
|
||||
expect(src).not.toContain('setTimeout(tick, 200)');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 Commit 3 — client-side re-attach loop.
|
||||
//
|
||||
// On unexpected WS close (anything other than clean 1000 / 4001 / 4404),
|
||||
// the sidebar now silently posts /pty-session/reattach with backoff,
|
||||
// opens a new WS with the fresh attachToken, writes RIS to xterm when
|
||||
// the agent sends {type:"reattach-begin"}, then treats the next binary
|
||||
// frame as the scrollback replay payload. Static-grep tripwires defend
|
||||
// the load-bearing protocol invariants; live re-attach exercises belong
|
||||
// in the e2e tier.
|
||||
|
||||
const TERMINAL_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
|
||||
);
|
||||
|
||||
describe('sidepanel re-attach loop (v1.44+ Commit 3)', () => {
|
||||
test('1. STATE.RECONNECTING exists for the in-flight re-attach window', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toContain("RECONNECTING: 'reconnecting'");
|
||||
});
|
||||
|
||||
test('2. backoff schedule matches the eng-review plan (1s/2s/4s/8s, 60s window)', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toContain('REATTACH_BACKOFF_MS = [1000, 2000, 4000, 8000]');
|
||||
expect(src).toContain('REATTACH_WINDOW_MS = 60_000');
|
||||
});
|
||||
|
||||
test('3. startReattachLoop posts /pty-session/reattach with sessionId', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toMatch(/function startReattachLoop\(prevSessionId\)/);
|
||||
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
|
||||
expect(block).toContain('/pty-session/reattach');
|
||||
expect(block).toContain('sessionId: prevSessionId');
|
||||
});
|
||||
|
||||
test('4. 410 Gone from re-attach short-circuits to ENDED (no retry loop)', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
|
||||
// 410 = lease window expired. Retrying wouldn't help; fall through
|
||||
// so the user clicks Restart for a fresh session.
|
||||
expect(block).toContain('resp.status === 410');
|
||||
expect(block).toContain('setState(STATE.ENDED)');
|
||||
});
|
||||
|
||||
test('5. 401 from re-attach sticky-aborts auto-connect', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
|
||||
expect(block).toContain('resp.status === 401');
|
||||
expect(block).toContain('autoConnectAborted = true');
|
||||
});
|
||||
|
||||
test('6. openReattachWebSocket handles {type:"reattach-begin"} → RIS to xterm', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
const block = sliceBetween(src, 'function openReattachWebSocket', 'async function checkClaudeAvailable');
|
||||
expect(block).toContain("msg.type === 'reattach-begin'");
|
||||
// RIS (\x1bc) is the full-reset escape that clears xterm cleanly
|
||||
// before the replay binary arrives.
|
||||
expect(block).toContain("term.write('\\x1bc')");
|
||||
expect(block).toContain('nextBinaryIsReplay = true');
|
||||
});
|
||||
|
||||
test('7. live connect()/forceRestart() close handlers trigger re-attach on transient close', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
// Both the connect() and forceRestart() close handlers must route
|
||||
// through startReattachLoop for non-clean codes. Count = 3
|
||||
// (open-reattach close handler + connect close + forceRestart close).
|
||||
const occurrences = (src.match(/startReattachLoop\(currentSessionId\)/g) || []).length;
|
||||
expect(occurrences).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('8. clean codes (1000 / 4001 / 4404) bypass the re-attach loop', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
// The branch guard MUST exclude these codes from re-attach. 1000 =
|
||||
// PTY exited (claude quit), 4001 = intentional restart, 4404 = no
|
||||
// claude on PATH. Re-attaching in those cases would be wasted work
|
||||
// (or actively wrong — a force-restart that re-attaches to its own
|
||||
// pre-restart session is the bug we're avoiding).
|
||||
expect(src).toContain('code === 1000');
|
||||
expect(src).toContain('code === 4001');
|
||||
expect(src).toContain('code === 4404');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 Commit 2C — client-side restart + dispose wiring.
|
||||
//
|
||||
// Pre-v1.44 forceRestart only closed the client WS and disposed xterm;
|
||||
// the old PTY died asynchronously via the agent's WS close handler.
|
||||
// Race window between kill and mint, two claude instances briefly,
|
||||
// no prompt visible until the user typed.
|
||||
//
|
||||
// Now forceRestart POSTs /pty-restart (one transaction: dispose + mint),
|
||||
// opens the new WS with the fresh attachToken from the response, and
|
||||
// sends {type:"start"} for the eager spawn. pagehide handler in
|
||||
// sidepanel.js sendBeacon /pty-dispose so browser quit / panel close
|
||||
// doesn't leak a 60s-zombie claude.
|
||||
|
||||
const TERMINAL_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
|
||||
);
|
||||
const SIDEPANEL_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel.js',
|
||||
);
|
||||
|
||||
describe('sidepanel-terminal: forceRestart via /pty-restart (v1.44+)', () => {
|
||||
test('1. mintSession callers read the 4-tuple (sessionId + attachToken)', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
// The new shape lands in `minted.sessionId` and `minted.attachToken`.
|
||||
expect(src).toContain('const { terminalPort, sessionId } = minted');
|
||||
expect(src).toContain('minted.attachToken || minted.ptySessionToken');
|
||||
// Backward-compat fallback to ptySessionToken kept so a partially-
|
||||
// updated extension still works against a fresh server.
|
||||
});
|
||||
|
||||
test('2. eager spawn via {type:"start"} on ws.open', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
// Replaces the legacy `ws.send(TextEncoder().encode("\\n"))` newline
|
||||
// hack that nudged the lazy-binary-spawn.
|
||||
expect(src).toMatch(/ws\.send\(JSON\.stringify\(\{\s*type:\s*'start'\s*\}\)\)/);
|
||||
expect(src).not.toContain("TextEncoder().encode('\\n')");
|
||||
});
|
||||
|
||||
test('3. forceRestart sends 4001 close code (intentional restart)', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toMatch(/ws\.close\(4001/);
|
||||
});
|
||||
|
||||
test('4. forceRestart POSTs /pty-restart with current sessionId', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toContain('/pty-restart');
|
||||
expect(src).toContain('priorSessionId ? { sessionId: priorSessionId } : {}');
|
||||
});
|
||||
|
||||
test('5. forceRestart 401 triggers sticky abort (no spam loop)', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
// Same defense pattern as connect() — 401 must flip the sticky flag
|
||||
// or every 2s the user sees a fresh "Auth invalid" message.
|
||||
const block = sliceBetween(src, 'async function forceRestart', 'function repaintIfLive');
|
||||
expect(block).toContain('resp.status === 401');
|
||||
expect(block).toContain('autoConnectAborted = true');
|
||||
});
|
||||
|
||||
test('6. currentSessionId is exposed on window for sidepanel.js pagehide', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toContain('window.gstackPtySession = currentSessionId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidepanel: pagehide → sendBeacon /pty-dispose (v1.44+)', () => {
|
||||
test('7. pagehide handler fires sendBeacon to /pty-dispose', () => {
|
||||
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
|
||||
expect(src).toMatch(/window\.addEventListener\('pagehide'/);
|
||||
expect(src).toContain('navigator.sendBeacon');
|
||||
expect(src).toContain('/pty-dispose');
|
||||
});
|
||||
|
||||
test('8. pagehide payload carries sessionId + authToken in body (sendBeacon-compat)', () => {
|
||||
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
|
||||
// sendBeacon can't set custom headers — server route accepts body-auth.
|
||||
// Both fields must be in the payload or the server rejects.
|
||||
expect(src).toMatch(/JSON\.stringify\(\{\s*sessionId,\s*authToken\s*\}\)/);
|
||||
expect(src).toContain('window.gstackPtySession');
|
||||
expect(src).toContain('window.gstackAuthToken');
|
||||
});
|
||||
|
||||
test('9. pagehide handler is best-effort (try/catch swallows failures)', () => {
|
||||
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
|
||||
// The 60s detach window catches any sendBeacon that fails, so the
|
||||
// handler MUST not throw — uncaught throws can interfere with the
|
||||
// browser's unload sequence. Slice between pagehide and end-of-file
|
||||
// (it's the last addEventListener in sidepanel.js by design).
|
||||
const i = src.indexOf("addEventListener('pagehide'");
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
const block = src.slice(i);
|
||||
expect(block).toMatch(/try \{/);
|
||||
expect(block).toMatch(/} catch /);
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 Commit 3 — detach state machine + ring buffer + re-attach replay.
|
||||
//
|
||||
// The state machine is what turns a single network blip from "fall through
|
||||
// to ENDED state, click Restart" into "silent re-attach with scrollback
|
||||
// intact, keep typing." Live WS cycles + buffer-overflow exercises belong
|
||||
// in the e2e tier; these static-grep tripwires defend the load-bearing
|
||||
// protocol + correctness properties.
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
|
||||
describe('terminal-agent detach + re-attach (v1.44+ Commit 3)', () => {
|
||||
test('1. PtySession carries ring buffer + alt-screen + detach state', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const i = src.indexOf('interface PtySession {');
|
||||
const j = src.indexOf('\n}', i);
|
||||
const block = src.slice(i, j);
|
||||
expect(block).toContain('liveWs: any | null');
|
||||
expect(block).toContain('ringBuffer: Buffer[]');
|
||||
expect(block).toContain('ringBufferBytes: number');
|
||||
expect(block).toContain('altScreenActive: boolean');
|
||||
expect(block).toContain('detached: boolean');
|
||||
expect(block).toContain('detachTimer:');
|
||||
});
|
||||
|
||||
test('2. RING_BUFFER_MAX_BYTES default is 1 MB, env-overridable', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_PTY_RING_BUFFER_BYTES');
|
||||
expect(src).toContain('1024 * 1024');
|
||||
});
|
||||
|
||||
test('3. DETACH_WINDOW_MS default is 60s, env-overridable', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_PTY_DETACH_WINDOW_MS');
|
||||
expect(src).toContain("'60000'");
|
||||
});
|
||||
|
||||
test('4. appendToRingBuffer evicts oldest frames past the cap', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/function appendToRingBuffer\(/);
|
||||
// Eviction loop: must keep at least one frame even at extreme caps
|
||||
// (otherwise a single oversized frame would empty the buffer).
|
||||
expect(src).toMatch(/session\.ringBufferBytes > RING_BUFFER_MAX_BYTES/);
|
||||
expect(src).toContain('session.ringBuffer.length > 1');
|
||||
expect(src).toContain('session.ringBuffer.shift()');
|
||||
});
|
||||
|
||||
test('5. alt-screen tracking watches for CSI ?1049h / CSI ?1049l', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Canonical xterm enter/exit alt-screen sequences. Must update
|
||||
// session.altScreenActive so the replay prelude knows.
|
||||
expect(src).toContain('\\x1b[?1049h');
|
||||
expect(src).toContain('\\x1b[?1049l');
|
||||
expect(src).toContain('session.altScreenActive');
|
||||
});
|
||||
|
||||
test('6. buildReplayPayload prefixes soft-reset (+ alt-screen if active)', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/function buildReplayPayload\(/);
|
||||
// DECSTR soft reset — re-defaults character attributes after the
|
||||
// client's RIS clears the xterm buffer.
|
||||
expect(src).toContain('\\x1b[!p');
|
||||
// Conditionally re-enter alt-screen if claude was in a tool-call
|
||||
// (alt-screen mode) at detach.
|
||||
expect(src).toContain('session.altScreenActive');
|
||||
});
|
||||
|
||||
test('7. WS open() re-attaches when sessionId already lives in sessionsById', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, 'open(ws) {', 'message(ws, raw) {');
|
||||
expect(block).toContain('sessionsById.get(sessionId)');
|
||||
expect(block).toContain('existing.liveWs = ws');
|
||||
expect(block).toContain('clearTimeout(existing.detachTimer)');
|
||||
// Tells the client to write RIS before treating the next binary
|
||||
// frame as replay.
|
||||
expect(block).toContain("type: 'reattach-begin'");
|
||||
expect(block).toContain('sendBinary(buildReplayPayload(existing))');
|
||||
});
|
||||
|
||||
test('8. WS close starts detach timer for non-intentional close codes', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const i = src.indexOf('close(ws');
|
||||
const j = src.indexOf('function handleTabState', i);
|
||||
const block = src.slice(i, j);
|
||||
// 4001 = intentional restart (Commit 2), 4404 = no-claude, 1000 = clean
|
||||
// exit. Any other code (1006 abnormal, 1001 going-away, etc.) gets the
|
||||
// 60s detach grace.
|
||||
expect(block).toContain('code === 4001');
|
||||
expect(block).toContain('code === 4404');
|
||||
expect(block).toContain('code === 1000');
|
||||
expect(block).toContain('session.detached = true');
|
||||
expect(block).toContain('session.detachTimer = setTimeout');
|
||||
expect(block).toContain('DETACH_WINDOW_MS');
|
||||
// Detach timer must unref so the bun process can exit cleanly.
|
||||
expect(block).toContain('detachTimer as any)?.unref?.()');
|
||||
});
|
||||
|
||||
test('9. /internal/restart cancels detach timer before disposal', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
|
||||
// Without the cancellation, a later detach-timer fire would dispose a
|
||||
// session that's already been disposed by the explicit restart path.
|
||||
expect(block).toContain('clearTimeout(session.detachTimer)');
|
||||
});
|
||||
|
||||
test('10. PTY on-data writes through session.liveWs (not the original ws closure)', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Critical for re-attach correctness: the PTY's on-data callback
|
||||
// closes over `session`, not the original `ws`, so after re-attach
|
||||
// it routes to the new liveWs automatically.
|
||||
expect(src).toContain('session.liveWs.sendBinary');
|
||||
// Always append to the ring buffer regardless of attach state — so
|
||||
// a detached session still captures output for the next re-attach.
|
||||
expect(src).toContain('appendToRingBuffer(session, flush)');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Static-grep tripwire for the v1.44 internalHandler refactor.
|
||||
//
|
||||
// /internal/grant and /internal/revoke were copies of the same dance:
|
||||
// bearer-auth → x-browse-gen check → req.json().then(...).catch(...).
|
||||
// internalHandler<T>(req, fn) collapses that into a single helper call.
|
||||
// This test fails CI if the helper goes away or the existing routes
|
||||
// regress to inline auth + JSON parse boilerplate. Wiring tests
|
||||
// (token grant/revoke behavior) already live in
|
||||
// browse/test/terminal-agent-integration.test.ts.
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
|
||||
describe('terminal-agent internalHandler refactor (v1.44+)', () => {
|
||||
test('1. internalHandler<T> exists with the documented signature', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/async function internalHandler<T>\s*\(/);
|
||||
// Body must include the auth gate, body parse, and result coercion.
|
||||
expect(src).toContain('checkInternalAuth(req)');
|
||||
expect(src).toContain('await req.json()');
|
||||
expect(src).toContain('instanceof Response');
|
||||
});
|
||||
|
||||
test('2. /internal/grant routes through internalHandler', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Match the route handler block.
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/grant'", "url.pathname === '/internal/revoke'");
|
||||
expect(block).toContain('internalHandler(req');
|
||||
// Must NOT have the old inline pattern (would be a regression).
|
||||
expect(block).not.toContain('req.headers.get(\'authorization\')');
|
||||
expect(block).not.toContain('req.json().then(');
|
||||
});
|
||||
|
||||
test('3. /internal/revoke routes through internalHandler', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/revoke'", "url.pathname === '/internal/healthz'");
|
||||
expect(block).toContain('internalHandler(req');
|
||||
expect(block).not.toContain('req.json().then(');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 WS keepalive — static-grep invariants for the protocol contract.
|
||||
//
|
||||
// terminal-agent.ts and sidepanel-terminal.js cooperate on a 25s ping/pong +
|
||||
// keepalive cycle so long-idle PTY connections survive NAT idle timeouts and
|
||||
// Chromium's MV3 panel suspension heuristics. The wiring is invisible to
|
||||
// integration tests (you'd have to wait 25s to observe a ping) but trivially
|
||||
// regressed by a refactor. These tests fail CI if either side stops sending
|
||||
// or stops accepting the protocol frames.
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
const CLIENT_JS = path.resolve(new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js');
|
||||
|
||||
describe('terminal-agent WS keepalive (v1.44+)', () => {
|
||||
test('1. agent has a KEEPALIVE_INTERVAL_MS env knob, default 25000', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_PTY_KEEPALIVE_INTERVAL_MS');
|
||||
expect(src).toMatch(/KEEPALIVE_INTERVAL_MS\s*=\s*parseInt\(/);
|
||||
// Default constant present so the env knob has a fallback.
|
||||
expect(src).toContain("'25000'");
|
||||
});
|
||||
|
||||
test('2. WS open handler starts a ping interval on the session', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// The open(ws) handler in the websocket: { ... } block must call
|
||||
// setInterval to drive the ping cadence and store the handle.
|
||||
const wsBlock = sliceBetween(src, 'websocket: {', 'function handleTabState');
|
||||
expect(wsBlock).toMatch(/open\s*\(\s*ws\s*\)/);
|
||||
expect(wsBlock).toContain('setInterval');
|
||||
expect(wsBlock).toContain("type: 'ping'");
|
||||
expect(wsBlock).toContain('pingInterval');
|
||||
});
|
||||
|
||||
test('3. WS close handler clears the ping interval', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const wsBlock = sliceBetween(src, 'websocket: {', 'function handleTabState');
|
||||
// close(ws, code?, reason?) MUST clearInterval the pingInterval —
|
||||
// otherwise we leak timers across reconnects and the ping handler
|
||||
// captures a dead ws ref. Signature widened in Commit 3 to include
|
||||
// the close code for the detach state machine, hence the loose match.
|
||||
expect(wsBlock).toMatch(/close\s*\(\s*ws/);
|
||||
expect(wsBlock).toContain('clearInterval(session.pingInterval)');
|
||||
});
|
||||
|
||||
test('4. message handler accepts pong / keepalive frames silently', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// The text-frame router must recognize the keepalive vocabulary —
|
||||
// if a future refactor strips this branch, unknown-text-frame
|
||||
// suppression would still drop them but we lose intent.
|
||||
expect(src).toMatch(/msg\?\.type === 'pong'/);
|
||||
expect(src).toMatch(/msg\?\.type === 'keepalive'/);
|
||||
});
|
||||
|
||||
test('5. client sends keepalive every 25s on ws.open', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
expect(src).toContain('keepaliveInterval');
|
||||
expect(src).toMatch(/setInterval\(/);
|
||||
expect(src).toContain("type: 'keepalive'");
|
||||
expect(src).toContain('KEEPALIVE_INTERVAL_MS = 25000');
|
||||
});
|
||||
|
||||
test('6. client replies pong to server ping', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
// The ws.message handler must short-circuit on msg.type === 'ping'
|
||||
// and reply with {type: 'pong', ts: msg.ts}.
|
||||
expect(src).toMatch(/msg\.type === 'ping'/);
|
||||
expect(src).toMatch(/type: 'pong'/);
|
||||
});
|
||||
|
||||
test('7. client clears keepalive in close + teardown + forceRestart', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
// Three teardown paths exist; all three must drop the interval to
|
||||
// avoid leaking timers across reconnect attempts.
|
||||
const occurrences = (src.match(/clearInterval\(keepaliveInterval\)/g) || []).length;
|
||||
expect(occurrences).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
readAgentRecord,
|
||||
writeAgentRecord,
|
||||
clearAgentRecord,
|
||||
killAgentByRecord,
|
||||
agentRecordPath,
|
||||
type AgentRecord,
|
||||
} from '../src/terminal-agent-control';
|
||||
|
||||
// REGRESSION TEST for the v1.44 PID-identity migration.
|
||||
//
|
||||
// Pre-v1.44, both `cli.ts` and `server.ts` killed the terminal-agent with
|
||||
// `spawnSync('pkill', ['-f', 'terminal-agent\\.ts'])`. That command matches
|
||||
// by argv regex — any process whose command line contains the string
|
||||
// `terminal-agent.ts` got SIGTERM'd. In practice this killed:
|
||||
//
|
||||
// * sibling gstack sessions on the same host
|
||||
// * editor processes (vim, code, less) that had the file open
|
||||
// * any second gstack run on the host
|
||||
//
|
||||
// The v1.44 migration replaces both kill sites with identity-based PID kill
|
||||
// against the record written at `<stateDir>/terminal-agent-pid` by the
|
||||
// agent's own boot path. This test is the static-grep tripwire that prevents
|
||||
// reintroducing the regex teardown anywhere in the source tree.
|
||||
//
|
||||
// Pattern mirrors browse/test/server-embedder-terminal-port.test.ts (Test 4)
|
||||
// and browse/test/server-sanitize-surrogates.test.ts: read source files
|
||||
// directly, assert an invariant on their contents.
|
||||
|
||||
const SRC_DIR = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src');
|
||||
|
||||
function readAllSourceFiles(): Array<{ file: string; content: string }> {
|
||||
const out: Array<{ file: string; content: string }> = [];
|
||||
for (const entry of fs.readdirSync(SRC_DIR)) {
|
||||
if (!entry.endsWith('.ts')) continue;
|
||||
const full = path.join(SRC_DIR, entry);
|
||||
out.push({ file: entry, content: fs.readFileSync(full, 'utf-8') });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('terminal-agent PID identity (v1.44+)', () => {
|
||||
test('1. no source file calls `pkill -f terminal-agent`', () => {
|
||||
// The regex matches both `pkill -f terminal-agent\.ts` (escaped form
|
||||
// used in spawnSync args) and `pkill -f terminal-agent.ts` (literal),
|
||||
// since the dot is the only difference and both are footguns.
|
||||
const offenders: string[] = [];
|
||||
for (const { file, content } of readAllSourceFiles()) {
|
||||
// Walk line by line so we can skip comments that mention the historical
|
||||
// pattern (acceptable as documentation, not as code).
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!/pkill/.test(line)) continue;
|
||||
if (!/terminal-agent/.test(line)) continue;
|
||||
// Skip comment lines — historical mentions in JSDoc are fine.
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
|
||||
offenders.push(`${file}:${i + 1}: ${trimmed}`);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('2. neither cli.ts nor server.ts calls spawnSync with pkill', () => {
|
||||
// Tighter check — even if someone routes through a different code path,
|
||||
// any spawnSync('pkill', ...) anywhere in src/ is the smell.
|
||||
const offenders: string[] = [];
|
||||
for (const { file, content } of readAllSourceFiles()) {
|
||||
if (/spawnSync\s*\(\s*['"]pkill['"]/.test(content)) {
|
||||
offenders.push(file);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('3. readAgentRecord round-trips writeAgentRecord', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-pid-id-'));
|
||||
try {
|
||||
const record: AgentRecord = {
|
||||
pid: 12345,
|
||||
gen: 'test-gen-abcdef',
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
writeAgentRecord(tmpDir, record);
|
||||
const read = readAgentRecord(tmpDir);
|
||||
expect(read).toEqual(record);
|
||||
expect(fs.existsSync(agentRecordPath(tmpDir))).toBe(true);
|
||||
|
||||
clearAgentRecord(tmpDir);
|
||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
||||
expect(fs.existsSync(agentRecordPath(tmpDir))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('4. readAgentRecord returns null on missing or malformed file', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-pid-id-'));
|
||||
try {
|
||||
// Missing.
|
||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
||||
|
||||
// Malformed: wrong type for pid.
|
||||
fs.writeFileSync(agentRecordPath(tmpDir), JSON.stringify({ pid: 'not-a-number', gen: 'x', startedAt: 0 }));
|
||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
||||
|
||||
// Malformed: not JSON.
|
||||
fs.writeFileSync(agentRecordPath(tmpDir), 'definitely not json');
|
||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
||||
|
||||
// Missing field.
|
||||
fs.writeFileSync(agentRecordPath(tmpDir), JSON.stringify({ pid: 1, gen: 'x' }));
|
||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('5. killAgentByRecord returns false for a dead PID and never throws', () => {
|
||||
// PID 2147483646 is below Linux PID_MAX_LIMIT but way above macOS's
|
||||
// typical max — no real process will ever hold it. isProcessAlive
|
||||
// returns false; killAgentByRecord no-ops.
|
||||
const record: AgentRecord = {
|
||||
pid: 2147483646,
|
||||
gen: 'sentinel',
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
const result = killAgentByRecord(record, 'SIGTERM');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('6. killAgentByRecord skips the kill when isProcessAlive is false', () => {
|
||||
// Guard via process.kill stub: confirm killAgentByRecord does NOT call
|
||||
// process.kill with a non-zero signal when the PID is dead. This is the
|
||||
// belt-and-suspenders defense against PID-reuse: even if isProcessAlive
|
||||
// changes implementation, killAgentByRecord must validate liveness first.
|
||||
const origKill = process.kill;
|
||||
const kills: Array<[number, NodeJS.Signals | number]> = [];
|
||||
(process as any).kill = ((pid: number, sig: NodeJS.Signals | number) => {
|
||||
kills.push([pid, sig ?? 'SIGTERM']);
|
||||
if (sig === 0) {
|
||||
const err: any = new Error('ESRCH');
|
||||
err.code = 'ESRCH';
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}) as any;
|
||||
try {
|
||||
const record: AgentRecord = { pid: 9999999, gen: 'x', startedAt: Date.now() };
|
||||
killAgentByRecord(record, 'SIGTERM');
|
||||
const terminations = kills.filter(([, s]) => s !== 0);
|
||||
expect(terminations).toEqual([]);
|
||||
} finally {
|
||||
(process as any).kill = origKill;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import {
|
||||
appendToRingBuffer,
|
||||
buildReplayPayload,
|
||||
type PtySession,
|
||||
} from '../src/terminal-agent';
|
||||
|
||||
// Runtime exercises for the v1.44 Commit 3 ring buffer + replay prelude.
|
||||
// Companion to browse/test/terminal-agent-detach-reattach.test.ts which
|
||||
// covers the structural invariants; this file calls the helpers directly
|
||||
// to prove behavioral correctness without spinning up a real Bun.serve
|
||||
// listener.
|
||||
|
||||
function fresh(): PtySession {
|
||||
return {
|
||||
proc: null,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cookie: 'test-cookie',
|
||||
liveWs: null,
|
||||
sessionId: 'test-session',
|
||||
spawned: false,
|
||||
pingInterval: null,
|
||||
ringBuffer: [],
|
||||
ringBufferBytes: 0,
|
||||
altScreenActive: false,
|
||||
detached: false,
|
||||
detachTimer: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('appendToRingBuffer runtime', () => {
|
||||
test('appends frames in order and tracks byte count', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('hello '));
|
||||
appendToRingBuffer(s, Buffer.from('world'));
|
||||
expect(s.ringBuffer).toHaveLength(2);
|
||||
expect(s.ringBufferBytes).toBe(11);
|
||||
expect(Buffer.concat(s.ringBuffer).toString()).toBe('hello world');
|
||||
});
|
||||
|
||||
test('evicts oldest frames when cap exceeded', () => {
|
||||
// Default cap is 1 MB. Override via env wouldn't help inside this
|
||||
// running process (constant was read at module load), so use frames
|
||||
// big enough to exceed it deterministically.
|
||||
const s = fresh();
|
||||
const big = Buffer.alloc(400_000, 0x41); // 400 KB of 'A'
|
||||
appendToRingBuffer(s, big);
|
||||
appendToRingBuffer(s, big);
|
||||
appendToRingBuffer(s, big); // total 1.2 MB — exceeds default cap
|
||||
// Eviction must drop frames until under cap; first 400 KB chunk goes.
|
||||
expect(s.ringBuffer.length).toBeLessThan(3);
|
||||
expect(s.ringBufferBytes).toBeLessThanOrEqual(1024 * 1024);
|
||||
});
|
||||
|
||||
test('keeps at least one frame even when a single frame exceeds the cap', () => {
|
||||
const s = fresh();
|
||||
// 2 MB single frame — bigger than the 1 MB cap. The eviction loop
|
||||
// guards on `ringBuffer.length > 1`, so the single oversized frame
|
||||
// stays. Without that guard, the buffer would empty itself, defeating
|
||||
// the whole point of replay on re-attach.
|
||||
const huge = Buffer.alloc(2 * 1024 * 1024, 0x42);
|
||||
appendToRingBuffer(s, huge);
|
||||
expect(s.ringBuffer.length).toBe(1);
|
||||
expect(s.ringBufferBytes).toBe(huge.length);
|
||||
});
|
||||
|
||||
test('tracks alt-screen enter (CSI ?1049h)', () => {
|
||||
const s = fresh();
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
appendToRingBuffer(s, Buffer.from('plain text'));
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h'));
|
||||
expect(s.altScreenActive).toBe(true);
|
||||
});
|
||||
|
||||
test('tracks alt-screen exit (CSI ?1049l)', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h'));
|
||||
expect(s.altScreenActive).toBe(true);
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049l'));
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
});
|
||||
|
||||
test('trailing state wins when enter + exit appear in one frame', () => {
|
||||
const s = fresh();
|
||||
// Tool call opened alt-screen then closed it inside one render — net
|
||||
// state is back to main screen. lastIndexOf comparison handles this.
|
||||
appendToRingBuffer(s, Buffer.from('start\x1b[?1049hmiddle\x1b[?1049lend'));
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
|
||||
const s2 = fresh();
|
||||
// Reverse order: exited then re-entered — net state alt-screen.
|
||||
appendToRingBuffer(s2, Buffer.from('\x1b[?1049l\x1b[?1049h'));
|
||||
expect(s2.altScreenActive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReplayPayload runtime', () => {
|
||||
test('prepends DECSTR soft reset before ring buffer contents', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('prompt> '));
|
||||
const payload = buildReplayPayload(s).toString('latin1');
|
||||
expect(payload.startsWith('\x1b[!p')).toBe(true);
|
||||
expect(payload.endsWith('prompt> ')).toBe(true);
|
||||
});
|
||||
|
||||
test('re-enters alt-screen when session was in alt-screen at detach', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h tool output '));
|
||||
const payload = buildReplayPayload(s).toString('latin1');
|
||||
// Order: soft reset, alt-screen re-enter, ring buffer.
|
||||
expect(payload.indexOf('\x1b[!p')).toBeLessThan(payload.indexOf('\x1b[?1049h'));
|
||||
expect(payload.indexOf('\x1b[?1049h')).toBeLessThan(payload.indexOf('tool output'));
|
||||
});
|
||||
|
||||
test('omits alt-screen re-enter when session was on main screen', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('regular prompt'));
|
||||
const payload = buildReplayPayload(s).toString('latin1');
|
||||
// Soft reset is present, but alt-screen enter is NOT. Both substrings
|
||||
// are otherwise identical 8 bytes apart in the alphabet, so equal-
|
||||
// substring checks need to be strict.
|
||||
expect(payload).toContain('\x1b[!p');
|
||||
expect(payload).not.toContain('\x1b[?1049h');
|
||||
});
|
||||
|
||||
test('replay buffer length = soft-reset + (optional alt-screen) + ring bytes', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('abc'));
|
||||
appendToRingBuffer(s, Buffer.from('def'));
|
||||
const payload = buildReplayPayload(s);
|
||||
// 4 bytes (DECSTR) + 6 bytes (abc/def) = 10 bytes. No alt-screen.
|
||||
expect(payload.length).toBe(4 + 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lease lifecycle interplay (via pty-session-lease)', () => {
|
||||
// Cross-module behavior: lease + ring buffer are both per-session.
|
||||
// This catches the case where a refactor accidentally couples them.
|
||||
test('lease registry is independent of ring buffer state', async () => {
|
||||
const { mintLease, validateLease, __resetLeases } = await import('../src/pty-session-lease');
|
||||
__resetLeases();
|
||||
const a = mintLease();
|
||||
const b = mintLease();
|
||||
expect(a.sessionId).not.toBe(b.sessionId);
|
||||
const va = validateLease(a.sessionId);
|
||||
const vb = validateLease(b.sessionId);
|
||||
expect(va.ok && vb.ok).toBe(true);
|
||||
if (va.ok && vb.ok) {
|
||||
expect(va.expiresAt).toBe(vb.expiresAt);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 Commit 2 — terminal-agent sessionId routing + eager spawn.
|
||||
//
|
||||
// Live spawn tests would require a real claude binary on PATH and a Bun.serve
|
||||
// listener; both are e2e-tier. These static-grep tripwires defend the load-
|
||||
// bearing protocol changes:
|
||||
// - validTokens carries the sessionId binding (Map, not Set)
|
||||
// - sessionsById index exists for /internal/restart + (Commit 3) re-attach
|
||||
// - /internal/restart is scoped to one sessionId (codex T2 fix)
|
||||
// - {type:"start"} triggers spawn for eager UX after forceRestart
|
||||
// - maybeSpawnPty helper is the single entry point for both spawn paths
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
|
||||
describe('terminal-agent session routing (v1.44+ Commit 2)', () => {
|
||||
test('1. validTokens is a Map binding token → sessionId', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Pre-Commit 2 was `Set<string>`; the Map carries the sessionId
|
||||
// binding that /internal/restart and (Commit 3) re-attach depend on.
|
||||
expect(src).toMatch(/const validTokens = new Map<string, string \| null>\(\)/);
|
||||
expect(src).not.toMatch(/const validTokens = new Set</);
|
||||
});
|
||||
|
||||
test('2. sessionsById reverse index exists', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/const sessionsById = new Map<string, PtySession>\(\)/);
|
||||
// Populated in open() — required so /internal/restart can find the session.
|
||||
expect(src).toMatch(/if \(sessionId\) sessionsById\.set\(sessionId, session\)/);
|
||||
});
|
||||
|
||||
test('3. /internal/grant binds an optional sessionId to the token', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/grant'", "url.pathname === '/internal/revoke'");
|
||||
expect(block).toContain('validTokens.set(body.token, sid)');
|
||||
expect(block).toContain('body?.sessionId');
|
||||
});
|
||||
|
||||
test('4. /internal/restart is scoped to one sessionId, not dispose-all', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
|
||||
expect(block).toContain('sessionsById.get(sid)');
|
||||
expect(block).toContain('disposeSession(session)');
|
||||
expect(block).toContain('sessionsById.delete(sid)');
|
||||
// Negative: must NOT enumerate all live sessions and dispose them
|
||||
// (codex T2 caught this — pre-spec the route killed every PTY on the
|
||||
// agent, breaking multi-sidebar / pair-agent setups).
|
||||
expect(block).not.toMatch(/for\s*\(\s*const\s+\[?ws/);
|
||||
});
|
||||
|
||||
test('5. WS upgrade surfaces sessionId on ws.data', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toContain('validTokens.get(token) ?? null');
|
||||
expect(src).toMatch(/data:\s*\{\s*cookie:\s*token,\s*sessionId\s*\}/);
|
||||
});
|
||||
|
||||
test('6. eager spawn via {type:"start"} text frame', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/msg\?\.type === 'start'/);
|
||||
// Both spawn paths route through the same helper for parity.
|
||||
expect(src).toContain('function maybeSpawnPty(');
|
||||
expect(src).toMatch(/maybeSpawnPty\(ws, session\)/);
|
||||
});
|
||||
|
||||
test('7. close() drops sessionsById entry alongside ws cleanup', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Commit 3 widened the close signature to `close(ws, code, _reason)`
|
||||
// for the detach state machine. Match either shape so test is stable
|
||||
// across the rest of the long-lived-sidebar PR.
|
||||
const i = src.indexOf('close(ws');
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
const j = src.indexOf('function handleTabState', i);
|
||||
const block = src.slice(i, j);
|
||||
expect(block).toContain('sessionsById.delete(session.sessionId)');
|
||||
});
|
||||
|
||||
test('8. PtySession interface carries the sessionId field', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Whole interface — close paren is sufficient.
|
||||
const i = src.indexOf('interface PtySession {');
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
const j = src.indexOf('\n}', i);
|
||||
const block = src.slice(i, j);
|
||||
expect(block).toContain('sessionId: string | null');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 terminal-agent watchdog — static-grep invariants.
|
||||
//
|
||||
// The watchdog respawns terminal-agent when its PID dies. Live process-tree
|
||||
// tests would require spawning, killing, and observing across two real Bun
|
||||
// processes — slow and flaky in the free tier. These tripwires defend the
|
||||
// load-bearing properties: identity-based liveness check (not name match),
|
||||
// crash-loop guard, gated on ownsTerminalAgent, and cleared on shutdown.
|
||||
|
||||
const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts');
|
||||
const CONTROL_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent-control.ts');
|
||||
|
||||
describe('terminal-agent watchdog (v1.44+)', () => {
|
||||
test('1. spawnTerminalAgent helper exists with PID return type', () => {
|
||||
const src = fs.readFileSync(CONTROL_TS, 'utf-8');
|
||||
expect(src).toMatch(/export function spawnTerminalAgent\(/);
|
||||
// Must clean up prior PID before spawning (no zombies).
|
||||
expect(src).toContain('readAgentRecord(stateDir)');
|
||||
expect(src).toContain('killAgentByRecord(prior');
|
||||
expect(src).toContain('clearAgentRecord(stateDir)');
|
||||
});
|
||||
|
||||
test('2. watchdog is gated on ownsTerminalAgent', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
// Match the comment + the guard. The guard MUST be a positive check;
|
||||
// an inverted check would respawn for embedders and trample their PTY.
|
||||
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
||||
expect(block).toMatch(/if \(ownsTerminalAgent\)/);
|
||||
expect(block).toContain('agentWatchdogInterval = setInterval');
|
||||
});
|
||||
|
||||
test('3. watchdog uses PID liveness, not process name probe', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
||||
// The whole point of the v1.44 watchdog over v1.43- pkill teardown:
|
||||
// identity-based liveness. Slow-but-alive agents must NOT trigger
|
||||
// respawn (split-brain defense).
|
||||
expect(block).toContain('readAgentRecord(stateDir)');
|
||||
expect(block).toContain('isProcessAlive(record.pid)');
|
||||
// Negative: no executable name-based process lookup. Allow the strings
|
||||
// to appear in prose comments (the watchdog doc explains what it
|
||||
// replaces), reject only actual invocations.
|
||||
expect(block).not.toMatch(/spawnSync\s*\(\s*['"]pkill/);
|
||||
expect(block).not.toMatch(/Bun\.spawn\s*\(\s*\[\s*['"]pgrep/);
|
||||
});
|
||||
|
||||
test('4. crash-loop guard with rolling window', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
||||
expect(block).toContain('RESPAWN_GUARD_WINDOW_MS = 60_000');
|
||||
expect(block).toContain('RESPAWN_GUARD_MAX = 3');
|
||||
expect(block).toContain('respawnHistory');
|
||||
expect(block).toContain('agentRespawnGuardTripped');
|
||||
// Window pruning: old entries must be evicted before counting toward
|
||||
// the limit. Otherwise a daemon up for a week with one crash a day
|
||||
// would eventually trip the guard.
|
||||
expect(block).toMatch(/respawnHistory\.shift\(\)/);
|
||||
});
|
||||
|
||||
test('5. watchdog interval is cleared on shutdown', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
expect(src).toContain('if (agentWatchdogInterval) clearInterval(agentWatchdogInterval)');
|
||||
});
|
||||
|
||||
test('6. tick interval is env-overridable for tests', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_AGENT_WATCHDOG_TICK_MS');
|
||||
});
|
||||
|
||||
test('7. CLI cold-start path uses the same spawnTerminalAgent helper', () => {
|
||||
const cli = fs.readFileSync(
|
||||
path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'cli.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
// Otherwise the CLI and watchdog could drift on spawn env/cwd, and
|
||||
// teardown invariants tested against one would silently miss the other.
|
||||
expect(cli).toContain('spawnTerminalAgent({');
|
||||
expect(cli).toContain("from './terminal-agent-control'");
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
Reference in New Issue
Block a user