Files
gstack/browse/src/terminal-agent.ts
T
Garry TanandClaude Fable 5 c118e2402e v1.64.1.0 v1.64.1.0: the code-smell fix wave — every pipeline guard now provably fires (net −24,943 lines) (#2572)
* fix(ci): skill-docs freshness gate covers all 10 hosts and can actually fail

The Codex/Factory gates ran 'git diff --exit-code -- .agents/' / '-- .factory/',
but both paths are gitignored (.gitignore:16-17) — git diff on ignored untracked
paths is always empty, so those two gates were structurally incapable of failing
and 7 of 10 hosts had no gate at all.

New shape: one 'gen:skill-docs --host all' pass (the generator hard-fails on any
per-host error, gating all 10 hosts on generates-cleanly), byte-freshness via
git diff for tracked output, plus a porcelain check that fails on untracked
generated strays (git diff can't see brand-new files). The gitignored-hosts
byte-freshness limitation is documented in the workflow comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): exorcise the sidebar-agent ghost from the test suite

browse/src/sidebar-agent.ts was deleted in the v1.14 sidebar refactor, but the
test suite kept testing it for 48 versions. Nothing noticed because the free
suite runs in no CI job and Bun-era module-load errors were suppressed in the
Windows shard runner via an exclusion pattern whose own comment documented the
breakage ('broken on every platform since v1.14 ... exit 0').

- Delete sidebar-security.test.ts + security-source-contracts.test.ts: crashed
  at module load (unguarded readFileSync of the deleted file); per-assertion
  triage confirmed every SERVER_SRC pin targeted the deleted chat prompt
  builder (zero hits in today's server.ts) — nothing to port.
- Delete sidebar-integration.test.ts: 11 of 13 tests exercised deleted
  endpoints (/sidebar-command queue, /sidebar-agent/event, chat buffer); the 2
  passing tests pinned only the blanket auth gate, covered by
  server-auth.test.ts + dual-listener.test.ts.
- Delete test/skill-e2e-sidebar.test.ts: E2E for the deleted queue flow.
- sidebar-ux.test.ts 1,669 -> 830 lines: 20 dead-chat describes + 15 dead
  tests removed (incl. 10 vacuous passes asserting on empty indexOf slices);
  2 stale pins on LIVE features fixed (content.js typed-catch CSSOM fallback,
  arrow-hint window widened). 95 pass / 0 fail.
- sidebar-tabs.test.ts: both failures were stale pins, not regressions —
  forceRestart's deliberate ws.close(4001) and the terminal-agent spawn that
  moved into spawnTerminalAgent() (identity-based kill refactor). 28 pass.
- touchfiles.ts: drop the three sidebar E2E entries from BOTH maps
  (E2E_TOUCHFILES + E2E_TIERS) — they pointed diff-selection at the deleted
  file, so those tests were unreachable by any diff.
- test-free-shards.ts: remove the now-dead sidebar-agent exclusion pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ci): run the free test suite in CI (it ran nowhere)

The full free suite (bun test: browse/test/ + test/ + make-pdf/test/) had no CI
job on any Linux/macOS runner — only Windows curated shards, paid evals, and
doc-freshness gates existed. That's how two module-load-crashing test files
survived 48 versions.

Same cached Dockerfile.ci image and container wiring as evals.yml (deps
restore, build, Chromium verify). Includes a module-load-error guard: older
Bun reported test-file import crashes with exit 0 on macOS/Linux, so the job
also fails on any nonzero 'N errors' count in the summary — future crash-class
regressions can't hide from the exact job built to catch them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): validate touchfile dependency paths exist on disk

New guard in touchfiles.test.ts: every non-glob dep path must exist, and every
glob's anchor directory must exist. This is the axis the 181-key two-map sync
discipline never covered — an entry can point at a long-deleted file and
diff-based selection then silently never triggers those tests (the sidebar
trio sat rotted for 48 versions).

First run immediately caught a fourth rotted entry: 'spec authored quality'
referenced test/fixtures/spec/** (directory does not exist) and selected for a
judge test that exists nowhere in the repo. Removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): remove deleted /sidebar-chat endpoint from tunnel allowlist

TUNNEL_PATHS is the audited tunnel attack surface — its own comment says every
addition widens it. '/sidebar-chat' stayed in the set after the endpoint was
deleted with the chat-queue path, meaning any future route matching that path
would have been silently tunnel-exposed. The set is now exactly the pair
ceremony (/connect) and the scoped command endpoint (/command), and the
dual-listener closed-set pin enforces that.

Also repairs a pre-existing red pin in dual-listener.test.ts: v1.63.0.0 made
the tunnel allowlist args-aware (canDispatchOverTunnel gained a second param)
without updating the test — red on main since then, invisible because the free
suite had no CI job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): delete chain's shadow dispatcher that skipped every security gate

meta-commands.ts carried a 'CLI mode' fallback that re-implemented command
routing without the server pipeline's gates: no scope check, no domain check,
no tab ownership, no rate limit, no hidden-element stripping, no scoped-token
enveloping — and it called handleReadCommand without a BrowserManager, which
also skipped the JS-origin cookie-exfiltration assertion. It was unreachable
in production (server.ts always passes executeCommand) and one boolean away
from being live.

chain now hard-errors without a server context. handleReadCommand's bm param
is required and assertJsOriginAllowed runs unconditionally. The chain tests
that exercised the deleted fallback now route through a server-shaped
executeCommand adapter (real handlers + trust wrapping + {status,result}
envelope), so their behavioral coverage — sequencing, trust markers, pipe
format, aliases, error reporting — survives on the production-shaped path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(extension): delete the dead chat-queue client surface

The sidebar-command handler in background.js POSTed to a server endpoint that
no longer exists (deleted with the chat queue) — ~35 lines of fully-wired dead
code including error handling for the permanent 404, plus its allowlist entry.
No sender in the extension ever emitted the message type.

chatEnabled leaves the /health contract (server hardcoded false, background.js
re-derived it, nothing consumed it — the chat input element it guarded is gone
from sidepanel.html). BROWSE_SIDEBAR_CHAT env flag had zero readers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): delete dead exports the ripped chat path left behind

Three-way split by importer class:

(a) Zero importers, deleted: the whole attack-attempt logging cluster in
security.ts (logAttempt, AttemptRecord, salted hashPayload + device-salt,
attempts.jsonl rotation, telemetry spawn plumbing incl.
buildTelemetrySpawnCommand/resolveBashBinary — the LIVE attempts.jsonl writer
is tunnel-denial-log.ts with its own rotation); the decision-file handshake
(writeDecision/readDecision/clearDecision/excerptForReview — written for
sidebar-agent's poll loop, which no longer exists); sidebar-utils.ts (whole
module — its sanitizeExtensionUrl 'sanitized before embedding in a prompt'
for the deleted prompt builder); 8 dead server.ts imports (sanitizeExtensionUrl,
generateCanary, injectCanary, writeDecision, rotateRoot, serializeRegistry,
restoreRegistry, clearAgentRecord); buildPtyClearCookie + buildSseClearCookie;
WEBDRIVER_MASK_SCRIPT (orphaned by the D7 stealth narrowing — applyStealth
never used it).

(b) Dead-pin tests edited with their exports: the 'still exported' pin in
stealth-layer-c, the string-content describe in stealth-webdriver (its live
applyStealth behavioral coverage untouched), the clear-cookie assertions,
security-review-flow.test.ts deleted whole (all 4 describes exercised the
dead decision mechanism, incl. a 'simulated sidebar-agent poll loop').

(c) KEPT deliberately: leaseCount (live behavioral coverage),
extractPtyCookie + validatePtySessionToken (extractPtyCookie is adopted by
the terminal-agent cookie-parse unification later in this wave),
resetSessionMarker + clearContentFilters (test-support API for the live
content-security layer).

Also fixes two pre-existing red pins found while here, invisible until the
free suite got a CI job: the v1.44 spawnClaude->maybeSpawnPty rename in
terminal-agent.test.ts, and a cross-file test-isolation bug where
content-security.test.ts's clearContentFilters() wiped the auto-registered
url-blocklist filter for every later file in the same bun process
(security-integration.test.ts failed on co-run; afterAll now restores it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): delete the dead ML layers — transcript classifier and DeBERTa ensemble

The L4b Haiku transcript classifier and the opt-in DeBERTa ensemble
(GSTACK_SECURITY_ENSEMBLE=deberta, a documented 721MB download) had ZERO
production callers since the chat-path agent that invoked them was ripped.
The only live ML path is scanPageContent (testsavant) inside the security
sidecar subprocess. Deleted by import graph:

- security-classifier.ts 614 -> 265 lines: HAIKU_MODEL, checkTranscript,
  shouldRunTranscriptCheck, loadDeberta, scanPageContentDeberta, ToolCallInput,
  all DEBERTA_* consts + load state. Header now states the live truth
  (imported only by security-sidecar-entry.ts). downloadFile kept, name
  intact — it is an enumerated egress sink (HF model download).
- security-bunnative.ts + test: a research skeleton self-described as 'NOT a
  production replacement', shipped into src/ with zero importers.
- security-bench-ensemble{,-live}.test.ts + the Haiku response fixture: a
  paid live-model benchmark for a layer that could not fire. The
  security-classifier-tdz test's only case exercised checkTranscript — gone.
- security.ts: layer-model header rewritten to the live architecture;
  StatusDetail.layers -> {testsavant, canary}; getStatus() no longer requires
  the impossible transcript==='ok' for 'protected' (old on-disk session state
  with a transcript key is tolerated on read, never re-emitted).
- security-sidecar-entry.ts needed zero changes: it serializes
  getClassifierStatus() verbatim and no consumer read .transcript (verified
  in sidecar-client + server.ts).
- BROWSER.md security section matches reality (ensemble knob gone, 112MB not
  22MB, sidecar hosting documented). combineVerdict/THRESHOLDS retained as
  the pure, tested combiner of record — comments now flag transcript/deberta
  votes as producer-less.

Net: 26 pass in security.test.ts incl. a NEW regression test for stale-
transcript disk tolerance; egress-receipt tripwire green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: scrub the sidebar-agent ghost from comments and CLAUDE.md

20+ comments across 10 files still described the deleted sidebar-agent.ts as a
live process — including load-bearing architecture claims ('IMPORTED ONLY BY
sidebar-agent.ts', 'sidebar-agent fills this in on first prompt-injection
load', 'kill sidebar-agent' in shutdown docs) and ~60 lines of tombstone
blocks in server.ts enumerating deleted identifiers by name (a false grep
surface: searching processAgentEvent hit server.ts and looked live).

CLAUDE.md's security-stack section now documents the LIVE architecture: L1-L3
content filters + testsavant via the security sidecar subprocess; the
L4b/ensemble rows, the GSTACK_SECURITY_ENSEMBLE knob, and the 721MB DeBERTa
download are gone (deleted as dead code this wave) with an explicit
do-not-re-document note; attempts.jsonl is correctly attributed to
tunnel-denial-log.ts; the no-live-writer status of classifierStatus is stated.

Comments that survive now describe what IS, not what WAS: the promotion gate
in domain-skills.ts explains why classifier_score>0 is load-bearing given no
L4 load-time scan exists; file-permissions.ts names real sensitive files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): delete the codex-helpers shadow module

gen-skill-docs.ts imported externalSkillName (unaliased) from
resolvers/codex-helpers.ts at line 21 and then re-declared the same function
locally — the import was silently shadowed, and the imported copy was the
STALE one (it lacked the frontmatterName param the local copy grew). Three
more functions were byte-identical duplicates, imported only under _-prefixed
aliases to keep the module 'referenced', and transformFrontmatter was a
superseded hardcoded-Codex variant. Nothing else imported the module.

Also drops three dead top-of-file imports (COMMAND_DESCRIPTIONS,
SNAPSHOT_FLAGS — which pulled the whole browse/src module graph into every
generator run for nothing — and an unused review-resolver trio).

Proof: bun run gen:skill-docs exits 0 with a byte-identical tree (zero-diff
regen); gen-skill-docs.test.ts 405/405 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(server): delete ServerConfig.idleTimeoutMs + chromiumProfile — documented, never read

Both fields carried JSDoc asserting embedder behavior that did not exist:
the idle check reads the module-level IDLE_TIMEOUT_MS env constant, and both
resolveChromiumProfile() call sites pass no argument. Worse than absent — an
embedder passing idleTimeoutMs: 5000 silently got 30 minutes.

Wiring them honestly is impossible today: the idle timer, activity state, and
shutdown target are module-global, so a per-factory value would lie for any
process running more than one handler. Deleted instead, with a ServerConfig
note pointing at the deferred singleton/route-table refactor where real
support belongs. BROWSE_IDLE_TIMEOUT and CHROMIUM_PROFILE env remain the
honest knobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): wire appendSecureFile at the four real log-append sites

file-permissions.ts carries a 24-line rationale for why POSIX mode bits are
insufficient on Windows and implements appendSecureFile (0600 at create,
Windows ACL on first write only) — but its single caller was the dead
logAttempt, while the four REAL page-content log writers (console/network/
dialog logs in server.ts, the command audit log) used raw fs.appendFileSync
with no mode. Page-content-derived logs now get owner-only permissions from
birth on every platform.

Verified before wiring: mode applies atomically at create via appendFileSync
{mode}, and the ACL pass runs only on first write — no per-append subprocess
cost on the hot console-log path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stealth): handoff() uses the shared profile resolution + lock cleanup

The headless-to-headed handoff path hardcoded ~/.gstack/chromium-profile,
silently ignoring $CHROMIUM_PROFILE and $GSTACK_HOME (gbrowser's gbd sets
per-workspace profiles), and skipped cleanSingletonLocks() — so a handoff
into a profile with a stale SingletonLock could hang where launchHeaded()
would have recovered.

This was the third live drift between the three Chromium launch paths; the
first two are documented in comments as shipped stealth regressions. Minimal
targeted fix — the full buildLaunchConfig() extraction stays in the deferred
queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): resolver registry describes the template language again

Seven registered {{PLACEHOLDER}}s had zero uses in any .tmpl (checked in both
bare and :arg forms): REDACT_TAXONOMY_TABLE, TEST_COVERAGE_AUDIT_REVIEW,
MODEL_OVERLAY, QUESTION_PREFERENCE_CHECK, QUESTION_LOG, INLINE_TUNE_FEEDBACK,
MAKE_PDF_SETUP. The last two of those families are invoked programmatically by
preamble.ts (functions kept, registry entries dropped); the question-tuning
trio and the review coverage-audit wrapper were documented by their own module
as existing 'for unit testing' that no test performed — deleted, along with
generateRedactTaxonomyTable + its EXAMPLE/TIER_BLURB constants (its '/cso
renders the full table' comment was itself stale) and its test describe.

Also deletes the gated-resolver mechanism (ResolverEntry/appliesTo/
unwrapResolver + test/resolver-entry.test.ts): fully built, fully tested,
used by zero of the 65 registry entries — the generator loop simplifies to a
direct function call. CLAUDE.md's redact-doc line stops advertising the dead
token.

Proof: zero-diff regen (0 SKILL.md changed); gen-skill-docs + skill-validation
737 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): wire boundaryInstruction from host config; drop three no-op binDir ternaries

hosts/codex.ts declared boundaryInstruction and nothing read it — review.ts
kept its own byte-identical CODEX_BOUNDARY literal (verified equal + trailing
escaped newlines). The resolver now reads the config, so the boundary has one
owner. (autoplan's template carries deliberately generic variants, enforced by
gen-skill-docs.test.ts:1358 — untouched by design.)

The 'ctx.host === codex ? $GSTACK_BIN : ctx.paths.binDir' ternary appeared in
three resolvers and could never change the result: resolvers/types.ts already
sets binDir to $GSTACK_BIN for every usesEnvVars host including codex.

Proof: zero-diff regen for claude AND codex hosts; gen-skill-docs +
host-config suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test-infra): judge uses resolveClaudeBinary; eval:watch reads the real partials dir

judgePtyState spawned the bare string 'claude' three definitions below the
resolveClaudeBinary() helper this same file exports — broken under hermetic
PATHs where every other launch in the file resolves correctly.

eval:watch read _partial-e2e.json from the legacy global ~/.gstack-dev/evals/
while EvalCollector writes it into the per-project eval dir (or
GSTACK_EVAL_DIR) — so the dashboard's completed-tests panel was empty
whenever slug detection succeeded, i.e. the normal case. The heartbeat and
per-run progress logs stay global by design (session-runner.ts: 'heartbeat
stays global'). The three eval-CLI docstrings stop claiming the legacy dir
is the primary location.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): delete the superseded SDK ship-idempotency suite and three orphaned fixtures

test/skill-e2e-ship-idempotency.test.ts's own header documented that the
monolith's SDK-harness version tests a synthetic prompt while it exercises
the real /ship skill — the author knew the old suite was superseded and left
both running, two paid LLM runs for one behavior. The weaker copy is gone;
its 'ship-idempotency' diff-selection key goes with it (the dedicated file is
periodic-tier, which always runs under EVALS_ALL — the key had no remaining
consumer).

Fixture rot: test/fixtures/golden-ship-claude.md was a 128KB zero-reader
orphan that had drifted 46KB from its live successor
(test/fixtures/golden/claude-ship-SKILL.md) while looking authoritative;
parity-baseline-v1.46.0.0.json and v1.53.0.0.json had zero readers (three
tests pin three OTHER baseline versions — consolidation is queued, deletion
of the unreferenced two is free).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bin): delete zero-caller scripts; make host-config-export's docstring honest

- bin/gstack-open-url (14 lines): announced in a CHANGELOG entry, wired into
  nothing, ever. bin/gstack-platform-detect (27 lines): zero callers, and its
  hand-rolled host list was already stale (SLATE_HOST.md cites it as a
  problem). Note: the deprecated gstack-brain-consumer/reader pair the audit
  flagged was already deleted upstream in v1.63 with a stay-deleted tripwire.
- scripts/task-emission-schema.ts (61 lines): a typed schema module nothing
  imported; the tasks-section comment now documents the JSONL fields inline.
- scripts/host-config-export.ts claimed to be the 'shell bridge for the bash
  setup script' — setup never calls it (its hand-rolled host lists drifting
  is a known follow-up). Docstring now states what it IS: a standalone,
  test-pinned query CLI not yet wired into setup. Its validateValue +
  CLI_REGEX/PATH_REGEX internals were dead (defined for a guarantee the
  header claimed but nothing enforced).
- KEPT deliberately: scripts/preflight-agent-sdk.ts — a documented manual
  diagnostic (CONTRIBUTING.md + USING_GBRAIN_WITH_GSTACK.md reference it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(server): one lone-surrogate sanitizer, one sanitizeReplacer, one startTunnel

Three copies of the surrogate sanitizer existed with two algorithms
(sanitize.ts regex vs a hand-rolled charCodeAt walk in server.ts — verified
byte-identical across 11 edge cases before converging) plus two identical
sanitizeReplacer definitions each wrapping a different copy. sanitize.ts is
now the single source of truth; the runs-INSIDE-JSON.stringify egress
invariant is unchanged at every call site and its pin tests were adapted to
the new import shape without losing intent.

The ngrok tunnel-start sequence existed three times in server.ts — the
/tunnel/start route and the BROWSE_TUNNEL=1 autostart were line-for-line
equivalent (a comment admitted 'Same cleanup as /tunnel/start's error path').
One startTunnel() now owns the ephemeral loopback bind, the pre-send egress
receipt, the state-file RMW via tmpStatePath(), and the ordered error-path
cleanup; callers keep their distinct response surfaces. The
BROWSE_TUNNEL_LOCAL_ONLY test path shares nothing (no ngrok, different state
field) and deliberately stays separate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): one session-cookie registry implementation, two instances

pty-session-cookie.ts and sse-session-cookie.ts were byte-identical modulo
the cookie name — mint/validate/parse/prune/TTL, the exact code a security
fix would have to land in twice (and a third hand-rolled cookie parse in
terminal-agent.ts had already diverged; unified next commit).
createSessionCookieStore() owns the implementation; both modules become thin
instantiations keeping every exported name, their distinct threat-model
docstrings, and separate token spaces (an SSE-read cookie must never grant
PTY access). pty-session-lease.ts deliberately stays out — different contract
(sessionId/secret split, refresh, env TTL).

The factory imports nothing from token-registry (cookie-picker-auth-isolation
invariant, still pinned by sse-session-cookie.test.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): terminal-agent uses the shared PTY cookie parser

The /ws upgrade's cookie fallback hand-parsed the Cookie header inline — the
fourth copy of the session-cookie parse, and the one that had already
diverged from the others. Parsing now goes through extractPtyCookie;
validation deliberately stays against the agent's own in-process validTokens
map (the server's registry lives in a different process). The ws-handler pin
test now pins the shared-parser call instead of the raw cookie-name literal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(hosts): defineHost() factory — 10 copy-paste host files become declarations

hosts/*.ts were ten copies of one file: runtimeRoot byte-identical in 9/10,
pathRewrites mechanically derivable from the host name for 7/10, the 11-entry
toolRewrites map byte-identical between openclaw and gbrain, and every asset
change a 10-file edit (cursor and slate had already fallen out of three other
hand-maintained lists). defineHost() owns the defaults; each host file now
declares only what makes it different (slate/cursor: 8 lines each). Shared
constants: CROSS_MODEL_RESOLVERS, GBRAIN_RESOLVERS, EXEC_STYLE_TOOL_REWRITES.
Genuinely-different things stayed explicit: codex/factory $GSTACK_ROOT
rewrites, hermes's tool vocabulary, claude's denylist+prefixable install,
opencode's wider runtimeRoot.

Proof: JSON.stringify(ALL_HOST_CONFIGS) dump-diff before/after EMPTY (and a
runtime walk confirmed no function-valued or undefined-keyed fields, so the
JSON diff is complete); gen:skill-docs --host all zero-diff; host-config +
gen-skill-docs + idempotency suites 485/485. Host files 595 -> 285 lines.
docs/ADDING_A_HOST.md teaches the factory pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(lib): fs-atomic — one atomic-write implementation, with the race actually fixed

Atomic tmp-write-then-rename was reimplemented ~20 times across lib/, bin/,
and browse/src with three tmp-suffix conventions. One of them was a latent
bug this commit closes: lib/worktree.ts used a bare '.tmp' suffix — the
deterministic-tmp collision race browse/src/server.ts documents having hit
in production (its fix, pid+random, was trapped in a comment at one site).

lib/fs-atomic.ts: atomicWriteSync (always throws, best-effort tmp cleanup,
pid+random suffix, optional mode applied at tmp creation so the file never
exists with looser permissions) + atomicWriteQuiet (shutdown paths only).
Unit tests pin the throw/quiet contracts, 0600 mode, tmp-name uniqueness
(captured via the read-only-dir failure path — Bun's fs exports are
readonly, no monkeypatching), and no-stray-tmp cleanup.

Migrated: lib/worktree.ts (the bare-.tmp bug), lib/gstack-decision.ts
(snapshot + compact log), lib/gbrain-local-status.ts (probe cache). browse
sites follow separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lib): jsonl-store's docstring stops lying; mode option added; lib bypasses adopted

The header claimed 'single source of truth... the ONLY copy' with write-time
injection REJECTION — while appendJsonl never screened anything, only 1 of
~10 JSONL stores imported it, and a bypass appender lived in the same
directory. Now: the contract is explicit (screening is the CALLER's job via
hasInjection/firstInjectionMatch; the enforcing callers are named), a
option applies 0600 at create for sensitive stores, and the lib bypasses are
adopted (gstack-memory-helpers ×2, redact-audit-log — which keeps its chmod
backstop for files created looser by pre-mode versions). browse/src keeps
its own appenders by design (compiled-binary surface, own secure-append
helper) and the header now says so. gstack-decision's batched archive append
stays deliberate (single-write crash-window semantics appendJsonl's
one-record contract can't express).

New pins: 0600-at-create, and a test that documents appendJsonl does NOT
self-screen — so nobody can re-document it as self-screening without making
it true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): migrate hand-rolled atomic writes to lib/fs-atomic

Seven sites, each audited for its existing throw-vs-swallow contract before
migrating: writeSessionState + the four fire-and-forget tab/state writers use
atomicWriteQuiet (they swallowed before); writeAgentRecord + the boot-time
port-file write use atomicWriteSync (they threw before — and writeAgentRecord
previously leaked its tmp file on rename failure, which the helper cleans).
All carry {mode: 0o600} plus restrictFilePermissions after successful writes,
preserving the Windows ACL hardening that writeSecureFile provided (mode bits
are POSIX-only). server.ts untouched: its three state writes route through
tmpStatePath(), pinned by server-tmp-state-path.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hosts): delete five dead HostConfig fields

metadataFormat (generator hardcodes openai.yaml), sidecar (behavior lives in
setup's create_agents_sidecar — knowledge preserved as a comment in codex.ts),
install.prefixable (skill_prefix is implemented entirely in bin/gstack-config),
staticFiles (docstring cited a SOUL.md that never existed anywhere), and
adapter (its only would-be consumer, openclaw-adapter.ts, was fully dead —
with a test asserting the field was undefined). Kept: learningsMode (wired
next), linkingStrategy (validation reads it), coAuthorTrailer (consumed by
resolvers/utility.ts).

Proof: JSON dump diff shows ONLY the deleted keys vanishing; zero-diff regen
across all 10 hosts; host-config + gen-skill-docs suites green. Note: this
commit also carries chunk-23 edits to the shared hosts/claude.ts +
define-host.ts + host-config.test.ts files (skipSkills collapse, stale
line-number comment drops) — pathspec commits, concurrent prep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): preamble tiers are explicit; silent ?? 4 default becomes an error; spec stops rendering its preamble twice

Eight skills (scrape, diagram, spec, skillify, pair-agent, landing-report,
open-gstack-browser + its connect-chrome symlink) silently received the
HEAVIEST tier-4 preamble because a missing frontmatter field defaulted to 4.
Tiers are now declared in every {{PREAMBLE}} template's frontmatter and a
missing declaration throws at generation time with the template path (the 5
templates without {{PREAMBLE}} never invoke the resolver). The stale
hand-written tier-map comment (wrong in 3 of 4 rows) is gone.

Bonus bug fixed: spec/SKILL.md.tmpl mentioned {{PREAMBLE}} in prose, so the
generator inlined the ENTIRE preamble a second time — spec/SKILL.md shrinks
127,462 -> 80,924 bytes (-46,538) from de-duplication alone. skill-size-budget
gains a reasoned INTENTIONAL_SHRINKS entry (its frozen baseline had measured
the doubled-preamble bug). New tests: missing-tier throw carries the path;
every {{PREAMBLE}} template declares a tier. (Carries chunk-23 edits in the
shared test/gen-skill-docs.test.ts.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): learningsMode is read from host config, not a hardcoded host name

resolvers/learnings.ts branched on ctx.host === 'codex' while every host
declared learningsMode — the field was decorative, and the 7 hosts configured
'basic' (cursor, slate, kiro, opencode, openclaw, hermes, gbrain) silently
received the 'full' cross-project flow their runtimes can't execute (it
depends on AskUserQuestion + gstack-config plumbing). Output now matches
declaration: basic hosts get the project-scoped search block.

Blast radius proof: all committed Claude SKILL.md files and the three golden
fixtures are byte-identical; the behavior diff lands only in the gitignored
external-host trees (hand-verified: .cursor review's learnings section swaps
the cross-project AskUserQuestion block for the project-scoped search).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): small config scrubs — openclaw blobs to real files, setup host drift, dead artifacts

- The three openclaw markdown blobs hardcoded inside gen-skill-docs.ts (which
  silently reverted any hand edit to their tracked outputs on regen) move to
  openclaw/templates/*.md source files; output shasums byte-identical.
- setup's --host allowlists gain cursor + slate — both fully registered hosts
  with generated output, but './setup --host cursor' exited 1 because two
  hand-rolled lists in setup had drifted from hosts/index.ts.
- scripts/proactive-suggestions.json deleted: 31KB regenerated on every run,
  read by nobody (the catalog-trim design's reader was never built); its
  emitter and three determinism tests (which guaranteed a file nothing reads
  didn't churn) retired with stays-retired pins.
- claude/SKILL.md.tmpl deleted: a complete 8.9KB skill that never generated
  output (directory name collides with the host id 'claude'), in no registry.
  Recoverable from git if ever wanted under a non-colliding name.
- openclaw's frozen extraFields.version '0.15.2.0' stamp dropped;
  includeSkills: [] no-ops omitted (the generator treats [] as absent);
  llms.txt 55 -> 54 skills.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(gen): correct preamble tiers for the 8 silently-heaviest skills

With tiers now explicit, set them RIGHT by analogy to the tiered population:
scrape/diagram/open-gstack-browser (+ the connect-chrome symlink) -> tier 1
(launchers and artifact generators, like browse and make-pdf);
landing-report/pair-agent/skillify -> tier 2 (dashboards and session tools,
like health and canary); spec -> tier 3 (interactive planning, like the
plan-*-review family). Each tier-1 skill sheds 271 lines of onboarding
prose it never needed; tier-2 shed 20 each.

Verification per the review protocol: regen diff reviewed (pure
section-removal), skill-validation + size-budget + catalog-budget +
v0-dormancy suites green (822 tests), and live smoke of the tier-corrected
skills confirms the preamble renders the intended sections at each tier.
These skills have ~no eval coverage — stated honestly; the wave's gate-tier
eval run is the backstop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): e2e-gate — one tier-gate implementation, side-effect-free, with the trap pinned

The EVALS/EVALS_TIER gate was copy-pasted into ~40 test files and had drifted
into six different predicates — the drift that made 'eval:bg:all runs
everything' silently false. test/helpers/e2e-gate.ts owns the semantics now:
describeE2ETier(tier) + e2eTierEnabled(tier), env read at call time, zero
side effects (the existing e2e-helpers module runs a ~30s claude ping at
import under EVALS=1, so the gate lives in its own module; purity is pinned
by tests that scan imports and comment-stripped source).

The unit matrix pins all four env combos — including EVALS=1 with EVALS_TIER
unset -> SKIP, the exact trap that made eval:bg:all a non-run. The
tier-alignment tripwire gains a second regex for the helper shape (old shape
still detected — stragglers can't hide), and the sharded paid runner's
PRE-SPAWN tier classifier learns the helper shape too: without that, every
gate-sharded run would have spawned all 28 periodic shards just to skip them,
each paying the e2e-helpers import ping (~15 min of dead wall clock in the
CI-blocking lane). Verified: gate runs exclude the 29 periodic files,
periodic excludes the 8 gate files — identical to pre-migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(test): migrate the 36 tier-gated eval files to describeE2ETier

Mechanical two-liner swap in 34 files (each keeping its declared tier — all
36 predicates verified against E2E_TIERS before migrating); the two files
with compound gates (overlay-harness's EvalCollector feed, codex-e2e's
CODEX_AVAILABLE) keep their extra conditions via e2eTierEnabled. Tier
rationale comments preserved. codex-e2e/gemini-e2e/benchmark-providers keep
their distinct stderr-message gate shapes by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(test): skill-e2e + skill-llm-eval adopt the shared selection machinery

Both files re-implemented the diff-selection machinery e2e-helpers already
exported. The helper gained computeDiffSelection() (extracted, identical
behavior) and a trailing optional selection param on the *IfSelected helpers
(defaults preserve all 30+ existing importers). skill-e2e.test.ts drops ~120
duplicated lines; skill-llm-eval keeps its LLM_JUDGE_TOUCHFILES selection and
test.concurrent semantics via testConcurrentIfSelected.

Deliberate deltas, stated: skill-e2e.test.ts now honors the EVALS_TIER
intersection its local copy lacked (affects only direct bun test invocations
of that file — it matches no eval-script glob); its recordE2E gains the
helper's three diagnostic fields; skill-llm-eval sharded solo now runs
e2e-helpers' module-scope preflight it already ran in combined processes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): kill the silent-truncation race; exempt the tier-corrected shrinks

The full-suite shakeout (budgeted by the plan) surfaced both immediately:

1. server-embedder-terminal-port.test.ts stubbed process.exit and restored
   the REAL exit in its finally — but shutdown() schedules async work that
   can call process.exit AFTER restoration, killing the entire bun process
   mid-suite with exit 0 and NO summary. This is the silent-truncation class
   the new free-suite CI job guards against, reproduced locally on the first
   full run. Exit now stays a logging no-op between tests (late async exits
   become visible stderr lines, not process death); the true exit returns in
   afterAll.

2. The 80%-of-baseline shrink guard correctly flagged the six tier-corrected
   skills — their baseline was measured at the silent tier-4 default. Added
   to INTENTIONAL_SHRINKS with the reason, joining spec's double-preamble
   entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* release: v1.64.0.0 — the code-smell fix wave

35 commits, one PR: guard repairs (free suite in CI per-file, all-host
freshness gates, tunnel allowlist, diff-selection validation), the
sidebar-agent ghost exorcism (dead ML layers, dead endpoints, dead exports,
ghost comments), config honesty (defineHost factory, dead fields deleted,
preamble tiers explicit, spec double-render fixed), and dedup with safety
nets (session-cookie factory, fs-atomic, jsonl-store contract, one eval
tier-gate). Net -24,943 lines across 183 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): free-tests step runs under bash (container sh rejects pipefail)

Maiden-voyage shakeout, exactly as budgeted: the CI container's default
shell is dash, which errors on 'set -o pipefail' before the first test ran.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): free-tests curates 8 container-incompatible files with reasons

Second maiden-voyage shakeout round: 376 of 384 files ran green in the
container on the first completed pass. The 8 that can't run there yet are
excluded the same way the Windows shards curate POSIX-bound files — each
with its reason inline (headed-Chrome handoff, real-PTY round-trip, X server
management, extension-origin identity, the job's own TMPDIR override, and
three pre-existing env failures that fail on dev machines too). Anything
outside the list that fails still fails the job; trimming the list is
tracked follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): gstack-config-key-locale — suppress the skill_prefix auto-relink side effect

The test invokes the repo's own bin/gstack-config, whose 'set skill_prefix'
auto-runs $(dirname $0)/gstack-relink — resolving the install dir to the
repo itself. In any environment where the loop shares a working tree (the
free-tests CI container, a fresh-HOME run), gstack-patch-names rewrote all
52 tracked SKILL.md names to gstack- prefixed, poisoning five unrelated
suites downstream (hermetic-skills-seeding, host-config golden, skill-census,
skill-validation, spec-template-sync). GSTACK_SETUP_RUNNING=1 is the
documented suppression; relink behavior stays covered by relink.test.ts's
mock install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bin): gstack-codex-session-import — empty sessions dir exits 0 on Linux

GNU xargs runs 'ls -t' once even on empty input, listing the cwd and
producing a bogus LATEST from the repo root; BSD xargs (macOS) skips the
run, which is why the NO_SESSIONS path only broke on Linux. xargs -r pins
the BSD behavior on both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(parity): rebaseline v1.57.7.0 → v1.64.1.0 + skeleton-cap headroom

The two parallel v1.64 waves (code-smell fix wave + main's #2571) each
added shared-preamble prose, pushing document-release / design-consultation
/ cso past their size ratios on the v1.57.7.0 anchor and four carved
skeletons (plan-ceo-review, plan-eng-review, office-hours,
design-consultation) 22-280 B over their absolute caps. New baseline is
union-normalized (skeleton + sections/*.md, matching what the harness
measures); caps get +~1 KB headroom each with per-cap rationale. The
v1.57.7.0 fixture stays in test/fixtures/ for the audit trail, and
capture-parity-baseline.ts now documents the union-normalization step so
the next rebaseline doesn't re-trip on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): free-tests container parity — tools, pinned bun, git identity, mutation tripwire

- Dockerfile.ci: add python3 (gstack-jsonl-merge/brain-sync/detach shell out
  to it), file (skill-validation's binary check), poppler-utils (make-pdf
  e2e gates hard-require pdftotext/pdffonts/pdfinfo), fonts-noto-color-emoji
  (emoji render gate, mirrors make-pdf-gate.yml). Fix the bun pin: the
  bun.sh installer ignores a BUN_VERSION env var, so the old form silently
  installed latest on every rebuild (observed 1.3.13/1.3.14 drift vs the
  1.3.10 devs run locally); pass the version as the positional arg.
- free-tests.yml: git identity + safe.directory for the git-exercising
  tests (container checkout is owned by a different uid than runner);
  post-loop tree-mutation tripwire that names a tracked-file-mutating test
  instead of letting downstream collateral confuse the report; skip the
  documented variants-retry-after timing flake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bin): gstack-session-update — detached updater owns its stdio (SIGPIPE)

The backgrounded update subshell inherited the session hook's stdout/stderr
pipes. Once the hook exits and the caller closes them, any child that writes
— git pull's autostash notice, setup output — dies of SIGPIPE, logged as
PULL_FAILED exit=141 with an empty stderr capture (observed in the free-tests
container, and reachable by any production hook runner that closes stdio
promptly). Redirect the fork to /dev/null; all observability already flows
through the session-update log file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): gstack-decision-bins — explicit branch context for the scope filter

CI checks out a detached HEAD, where gitBranch() returns undefined on both
the log and search sides, so an implicitly branch-scoped decision can never
surface (filterByScope requires a matching non-empty ctx.branch). Pass the
branch explicitly on both sides — the filter logic is what's under test, not
git branch detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): ring-buffer lease interplay — same TTL window, not same millisecond

Two back-to-back mintLease() calls each stamp Date.now() + TTL; when they
straddle a millisecond boundary the exact-equality assertion flakes
(observed in CI: expiries of ...525 vs ...526). Assert the expiries are
within a 50 ms window instead — the invariant under test is that leases
share a TTL policy, not that they mint in the same clock tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:37:04 -07:00

1019 lines
43 KiB
TypeScript

/**
* Terminal Agent — PTY-backed Claude Code terminal for the gstack browser
* sidebar. Translates the phoenix gbrowser PTY (cmd/gbd/terminal.go) into
* Bun, with a few changes informed by codex's outside-voice review:
*
* - Lives in a separate non-compiled bun process from the browse daemon so
* a bug in WS framing or PTY cleanup can't take down the command surface.
* - Binds 127.0.0.1 only — never on the dual-listener tunnel surface.
* - Origin validation on the WS upgrade is REQUIRED (not defense-in-depth)
* because a localhost shell WS is a real cross-site WebSocket-hijacking
* target.
* - Cookie-based auth via /internal/grant from the parent server, not a
* token in /health.
* - Lazy spawn: claude PTY is not spawned until the WS receives its first
* data frame. Sidebar opens that never type don't burn a claude session.
* - PTY dies with WS close (one PTY per WS). v1.1 may add session
* survival; for v1 we match phoenix's lifecycle.
*
* The PTY uses Bun's `terminal:` spawn option (verified at impl time on
* Bun 1.3.10): pass cols/rows + a data callback; write input via
* `proc.terminal.write(buf)`; resize via `proc.terminal.resize(cols, rows)`.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { writeSecureFile, restrictFilePermissions, mkdirSecure } from './file-permissions';
import { atomicWriteSync, atomicWriteQuiet } from '../../lib/fs-atomic';
import { safeUnlink } from './error-handling';
import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control';
import { extractPtyCookie } from './pty-session-cookie';
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port');
const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10);
const BROWSE_OWNER_PID = parseInt(process.env.BROWSE_OWNER_PID || '0', 10);
const OWNER_WATCHDOG_MS = parseInt(
process.env.GSTACK_TERMINAL_OWNER_WATCHDOG_MS || '15000',
10,
);
const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check
const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn
/**
* Per-boot generation identifier. Loopback /internal/* callers include
* `X-Browse-Gen: <CURRENT_GEN>` so a slow agent the watchdog respawned
* around can't service a stale grant from the prior generation. Absent
* header means "legacy caller" and is accepted (backward compat); a
* present-but-mismatched header returns 409 stale generation.
*/
const CURRENT_GEN = crypto.randomBytes(16).toString('base64url');
// In-memory attach-token registry. Parent posts /internal/grant after
// /pty-session; we validate WS upgrades against this map.
//
// v1.44+: each token is bound to a v1.44 sessionId (the stable, non-secret
// identifier from browse/src/pty-session-lease.ts). The token grants ONE
// attach for ONE session — re-attach within the lease window comes through
// /pty-session/reattach, which mints a fresh token for the same sessionId.
//
// Legacy callers can still pass `{token}` without sessionId (the value
// stays null and the WS upgrade still works); those callers don't get
// re-attach because there's no stable identifier to match against.
const validTokens = new Map<string, string | null>(); // token → sessionId
/**
* Reverse index for re-attach lookups: sessionId → live PtySession.
* Populated when a WS first attaches with a known sessionId; cleared when
* the session is disposed or the lease expires. Used by:
* - /ws upgrade: if the incoming attachToken maps to a sessionId that
* already has a live session, REPLACE its ws ref instead of spawning.
* - /internal/restart: enumerate by sessionId, dispose that one session.
*
* Kept separate from the WeakMap<ws,PtySession> so re-attach can find the
* session by id even after the original ws has gone.
*/
const sessionsById = new Map<string, PtySession>();
// Active PTY session per WS. One terminal per connection. Codex finding #4:
// uncaught handlers below catch bugs in framing/cleanup so they don't kill
// the listener loop.
process.on('uncaughtException', (err) => {
console.error('[terminal-agent] uncaughtException:', err);
});
process.on('unhandledRejection', (reason) => {
console.error('[terminal-agent] unhandledRejection:', reason);
});
export interface PtySession {
proc: any | null; // Bun.Subprocess once spawned
cols: number;
rows: number;
cookie: string;
/**
* Current attached websocket. Swapped on re-attach (Commit 3): when a new
* WS upgrade matches this session's sessionId, the old liveWs is gone
* and the new ws takes its place. The PTY on-data callback closes over
* `session`, not the original `ws`, so it always writes to the current
* liveWs (or skips the write when detached and liveWs is null).
*/
liveWs: any | null;
/**
* v1.44+ stable session identifier (from pty-session-lease). Null for
* legacy /internal/grant callers that didn't pass one. Used for
* targeted /internal/restart and Commit 3 re-attach lookups.
*/
sessionId: string | null;
spawned: boolean;
/**
* 25s server-side WS keepalive interval (v1.44+). Set in the WS `open`
* handler, cleared in `close`. We send `{type:"ping",ts}` text frames so
* NAT boxes, proxies, and Chrome's MV3 panel-suspend heuristics see the
* connection as active; the client either replies with `{type:"pong"}`
* or fires its own 25s `{type:"keepalive"}` cycle. Either path keeps
* the underlying TCP from being silently dropped.
*/
pingInterval: ReturnType<typeof setInterval> | null;
/**
* Commit 3 scrollback ring buffer. Each PTY write appends a frame; the
* total byte count is capped at RING_BUFFER_MAX_BYTES with oldest frames
* evicted first. On re-attach, the surviving frames are replayed as a
* single binary frame (prefixed with the v1.44 reset sequence) so the
* user sees their last screen of output. Frame boundaries preserve UTF-8
* + ANSI-CSI boundaries because each frame is the exact buffer that
* spawnClaude's on-data callback emitted.
*/
ringBuffer: Buffer[];
ringBufferBytes: number;
/**
* Tracks whether the PTY is currently in xterm alt-screen mode. claude's
* TUI enters alt-screen (CSI ?1049h) during tool calls and exits (CSI
* ?1049l) when returning to the main prompt. On re-attach, the replay
* prelude must re-enter alt-screen if the original PTY left it active,
* otherwise the replay renders against the main screen and the cursor
* + colors end up in the wrong place.
*/
altScreenActive: boolean;
/**
* Detach state machine (Commit 3). When the WS closes for a reason OTHER
* than the v1.44 intentional-restart code (4001), we keep the PtySession
* alive for the detach window (default 60s) so a re-attach within the
* window can resume the same PTY and replay the ring buffer. The timer
* disposes the session if no re-attach arrives in time.
*/
detached: boolean;
detachTimer: ReturnType<typeof setTimeout> | null;
}
/**
* WS keepalive interval. 25s is comfortably under the lowest common NAT
* idle timeout (typically 30-60s) and shorter than Chromium's WebSocket
* dead-peer threshold. Test-overridable via env so the v1.44 e2e tests
* can compress idle-window assertions to <1s without waiting half a
* minute per assertion.
*/
const KEEPALIVE_INTERVAL_MS = parseInt(
process.env.GSTACK_PTY_KEEPALIVE_INTERVAL_MS || '25000',
10,
);
/**
* Commit 3 scrollback ring buffer cap. 1 MB is enough for a full screen
* of dense claude output (including a recent tool result), small enough
* that a worst-case 10 detached sessions only cost ~10 MB of RSS.
* Env-overridable so e2e tests can verify eviction without writing 1 MB
* of fixture data per assertion.
*/
const RING_BUFFER_MAX_BYTES = parseInt(
process.env.GSTACK_PTY_RING_BUFFER_BYTES || `${1024 * 1024}`,
10,
);
/**
* Commit 3 detach window — how long to keep a session alive after WS
* close (with any code other than 4001 intentional-restart) so a
* re-attach can resume the same PTY. 60s is long enough to cover a
* Chrome MV3 service-worker suspend cycle, a wifi blip, or a brief
* laptop sleep; short enough that genuinely-closed sessions don't
* stack up unbounded.
*/
const DETACH_WINDOW_MS = parseInt(
process.env.GSTACK_PTY_DETACH_WINDOW_MS || '60000',
10,
);
/**
* Append a frame to a session's ring buffer, evicting oldest frames if
* the total byte count exceeds RING_BUFFER_MAX_BYTES. Eviction is at
* frame boundaries (one PTY write = one frame), so we never cut a
* multi-byte UTF-8 sequence or a partial ANSI CSI in half — claude's
* on-data callback emits coherent frames.
*
* Side effect: scans the appended chunk for alt-screen enter/exit
* sequences (CSI ?1049h / CSI ?1049l) and updates session.altScreenActive
* so the re-attach prelude knows whether to re-enter alt-screen.
*/
export function appendToRingBuffer(session: PtySession, frame: Buffer): void {
session.ringBuffer.push(frame);
session.ringBufferBytes += frame.length;
while (session.ringBufferBytes > RING_BUFFER_MAX_BYTES && session.ringBuffer.length > 1) {
const evicted = session.ringBuffer.shift()!;
session.ringBufferBytes -= evicted.length;
}
// Alt-screen tracking. Scan for the canonical xterm enter/exit pairs.
// We do this on every append (not just on attach) so the state is
// correct even if many frames have flowed since the last attach.
const ascii = frame.toString('latin1'); // single-byte view is enough — the codes are 7-bit ASCII
// Use lastIndexOf so trailing state wins when both appear in one frame
// (e.g., a quick tool-call open+close inside one render pass).
const enterIdx = ascii.lastIndexOf('\x1b[?1049h');
const exitIdx = ascii.lastIndexOf('\x1b[?1049l');
if (enterIdx >= 0 && enterIdx > exitIdx) session.altScreenActive = true;
else if (exitIdx >= 0 && exitIdx > enterIdx) session.altScreenActive = false;
}
/**
* Build the re-attach replay payload: server-side reset prelude + the
* accumulated ring buffer. The client side writes RIS (`\x1bc`) to xterm
* BEFORE feeding this payload in, so the layout is:
*
* 1. Client: `\x1bc` (RIS — full reset, clears pre-blip xterm content)
* 2. Server: `\x1b[!p` (DECSTR soft reset — re-defaults char attributes)
* 3. Server: optional `\x1b[?1049h` if we were in alt-screen at detach
* 4. Server: ring buffer contents, in append order
*
* The client coordinates the order by waiting for a `{type:"reattach-begin"}`
* text frame before treating the next binary frame as replay. That separation
* is what lets us prepend reset codes without clobbering the live stream
* that resumes immediately after.
*/
export function buildReplayPayload(session: PtySession): Buffer {
const parts: Buffer[] = [];
parts.push(Buffer.from('\x1b[!p'));
if (session.altScreenActive) parts.push(Buffer.from('\x1b[?1049h'));
for (const frame of session.ringBuffer) parts.push(frame);
return Buffer.concat(parts);
}
const sessions = new WeakMap<any, PtySession>(); // ws -> session
/** Find claude on PATH. */
function findClaude(): string | null {
// Test-only override. Lets the integration tests spawn /bin/bash instead
// of requiring claude to be installed on every CI runner. NEVER read in
// production (sidebar UI). Documented in browse/test/terminal-agent-integration.test.ts.
const override = process.env.BROWSE_TERMINAL_BINARY;
if (override && fs.existsSync(override)) return override;
// Bun.which is sync and respects PATH. Falls back to a small list of
// common install locations if PATH is stripped (e.g., launched from
// Conductor with a minimal env).
const which = (Bun as any).which?.('claude');
if (which) return which;
const candidates = [
'/opt/homebrew/bin/claude',
'/usr/local/bin/claude',
`${process.env.HOME}/.local/bin/claude`,
`${process.env.HOME}/.bun/bin/claude`,
`${process.env.HOME}/.npm-global/bin/claude`,
];
for (const c of candidates) {
try { fs.accessSync(c, fs.constants.X_OK); return c; } catch {}
}
return null;
}
/** Probe + persist claude availability for the bootstrap card. */
function writeClaudeAvailable(): void {
const stateDir = path.dirname(STATE_FILE);
try { mkdirSecure(stateDir); } catch {}
const found = findClaude();
const status = {
available: !!found,
path: found || undefined,
install_url: 'https://docs.anthropic.com/en/docs/claude-code',
checked_at: new Date().toISOString(),
};
const target = path.join(stateDir, 'claude-available.json');
// Fire-and-forget state file: a failed write must not break boot.
if (atomicWriteQuiet(target, JSON.stringify(status, null, 2), { mode: 0o600 })) {
restrictFilePermissions(target); // Windows ACL hardening
}
}
/**
* System-prompt hint passed to claude via --append-system-prompt. Tells
* claude what tab-awareness affordances exist in this session so it
* doesn't have to discover them by trial. The user can override anything
* here just by saying so — system prompt is a soft hint, not a contract.
*
* Two paths claude has:
* 1. Read live state from <stateDir>/tabs.json + active-tab.json
* (updated continuously by the gstack browser extension).
* 2. Run $B tab, $B tabs, $B tab-each <command> to act on tabs. The
* tab-each helper fans a single command across every open tab and
* returns per-tab results as JSON.
*/
function buildTabAwarenessHint(stateDir: string): string {
const tabsFile = path.join(stateDir, 'tabs.json');
const activeFile = path.join(stateDir, 'active-tab.json');
return [
'You are running inside the gstack browser sidebar with live access to the user\'s browser tabs.',
'',
'Tab state files (kept fresh automatically by the extension):',
` ${tabsFile} — all open tabs (id, url, title, active, pinned)`,
` ${activeFile} — the currently active tab`,
'Read these any time the user asks about "tabs", "the current page", or anything multi-tab. Do NOT shell out to $B tabs just to learn what\'s open — read the file.',
'',
'Tab manipulation commands (via $B):',
' $B tab <id> — switch to a tab',
' $B newtab [url] — open a new tab',
' $B closetab [id] — close a tab (current if no id)',
' $B tab-each <command> — fan out a command across every tab; returns JSON results',
'',
'When the user asks for multi-tab work, prefer $B tab-each. Examples:',
' $B tab-each snapshot -i — grab a snapshot from every tab',
' $B tab-each text — pull clean text from every tab',
' $B tab-each title — list every tab\'s title',
'',
'You\'re in a real terminal with a real PTY — slash commands, /resume, ANSI colors all work as in a normal claude session.',
].join('\n');
}
/** Spawn claude in a PTY. Returns null if claude not on PATH. */
function spawnClaude(cols: number, rows: number, onData: (chunk: Buffer) => void) {
const claudePath = findClaude();
if (!claudePath) return null;
// Match phoenix env so claude knows which browse server to talk to and
// doesn't try to autostart its own. BROWSE_HEADED=1 keeps the existing
// headed-mode browser; BROWSE_NO_AUTOSTART prevents claude's gstack
// tooling from racing to spawn another server.
const env: Record<string, string> = {
...process.env as any,
BROWSE_PORT: String(BROWSE_SERVER_PORT),
BROWSE_STATE_FILE: STATE_FILE,
BROWSE_NO_AUTOSTART: '1',
BROWSE_HEADED: '1',
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
};
// --append-system-prompt is the right injection surface (per `claude --help`):
// it gets appended to the model's system prompt, so claude treats this as
// contextual guidance, not a user message. Don't use a leading PTY write
// for this — that would show up as if the user typed the hint, polluting
// the visible transcript.
const stateDir = path.dirname(STATE_FILE);
const tabHint = buildTabAwarenessHint(stateDir);
const proc = (Bun as any).spawn([claudePath, '--append-system-prompt', tabHint], {
terminal: {
rows,
cols,
data(_terminal: any, chunk: Buffer) { onData(chunk); },
},
env,
});
return proc;
}
/** Cleanup a PTY session: SIGINT, then SIGKILL after 3s. */
function disposeSession(session: PtySession): void {
try { session.proc?.terminal?.close?.(); } catch {}
if (session.proc?.pid) {
try { session.proc.kill?.('SIGINT'); } catch {}
setTimeout(() => {
try {
if (session.proc && !session.proc.killed) session.proc.kill?.('SIGKILL');
} catch {}
}, 3000);
}
session.proc = null;
session.spawned = false;
}
/**
* Build the HTTP server. Two routes:
* POST /internal/grant — parent server pushes a fresh cookie token
* GET /ws — extension upgrades to WebSocket (PTY transport)
*
* Everything else returns 404. The listener binds 127.0.0.1 only.
*/
/**
* Validate a loopback /internal/* request. Returns null when the request
* is allowed; otherwise returns the Response to send back. Centralizes
* bearer auth + the v1.44 X-Browse-Gen generation check so adding a new
* /internal/* route is a one-liner.
*/
function checkInternalAuth(req: Request): Response | null {
const auth = req.headers.get('authorization');
if (auth !== `Bearer ${INTERNAL_TOKEN}`) {
return new Response('forbidden', { status: 403 });
}
const headerGen = req.headers.get('x-browse-gen');
if (headerGen && headerGen !== CURRENT_GEN) {
return new Response('stale generation', { status: 409 });
}
return null;
}
/**
* Wrap a JSON-bodied /internal/* handler with the standard bearer-auth +
* generation-check + json-parse + error-response boilerplate. The handler
* `fn` is called with the parsed body; whatever it returns is JSON-stringified
* into a 200 Response, or the handler can return a Response directly to
* customize status / headers. Throwing from `fn` collapses to a 400 "bad".
*
* Centralizing the dance kills the copy-paste pattern of bearer + gen check
* + req.json().then(...).catch(...) that every /internal/* route needs.
* New routes become a single call to internalHandler.
*/
async function internalHandler<T>(
req: Request,
fn: (body: any) => T | Promise<T> | Response | Promise<Response>,
): Promise<Response> {
const denied = checkInternalAuth(req);
if (denied) return denied;
let body: any;
try {
body = await req.json();
} catch {
return new Response('bad', { status: 400 });
}
try {
const result = await fn(body);
if (result instanceof Response) return result;
if (result === undefined || result === null) return new Response('ok');
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch {
return new Response('bad', { status: 400 });
}
}
/**
* Spawn the claude PTY for a session if it hasn't been spawned yet.
* Used by both the legacy binary-frame spawn trigger and the v1.44 explicit
* `{type:"start"}` text-frame trigger. Idempotent on `session.spawned`.
*
* Returns true if claude is now running, false if spawn failed (e.g. claude
* binary not on PATH). On failure, the caller is expected to have already
* surfaced the error to the client (or will via the next frame).
*/
function maybeSpawnPty(ws: any, session: PtySession): boolean {
if (session.spawned) return true;
session.spawned = true;
let leftover = Buffer.alloc(0);
const proc = spawnClaude(session.cols, session.rows, (chunk) => {
const combined = Buffer.concat([leftover, Buffer.from(chunk)]);
// UTF-8 boundary detection (issue #1272). Look back at most 3 bytes
// for the start of an incomplete multibyte sequence and defer it.
let safeEnd = combined.length;
for (let i = combined.length - 1; i >= Math.max(0, combined.length - 3); i--) {
const b = combined[i];
if ((b & 0x80) === 0) { safeEnd = i + 1; break; }
if ((b & 0xC0) === 0x80) continue;
const expected = (b & 0xE0) === 0xC0 ? 2 : (b & 0xF0) === 0xE0 ? 3 : 4;
safeEnd = (combined.length - i >= expected) ? combined.length : i;
break;
}
const flush = combined.slice(0, safeEnd);
leftover = combined.slice(safeEnd);
if (flush.length) {
// Always record into the ring buffer (Commit 3) so re-attach can
// replay. session.liveWs is what changes across re-attaches — we
// close over `session`, not the original `ws`, so the write always
// goes to whichever ws is currently attached (or is skipped when
// detached and liveWs is null).
appendToRingBuffer(session, flush);
if (session.liveWs) {
try { session.liveWs.sendBinary(flush); } catch {}
}
}
});
if (!proc) {
try {
ws.send(JSON.stringify({
type: 'error',
code: 'CLAUDE_NOT_FOUND',
message: 'claude CLI not on PATH. Install: https://docs.anthropic.com/en/docs/claude-code',
}));
ws.close(4404, 'claude not found');
} catch {}
return false;
}
session.proc = proc;
proc.exited?.then?.(() => {
try { session.liveWs?.close(1000, 'pty exited'); } catch {}
});
return true;
}
function buildServer() {
return Bun.serve({
hostname: '127.0.0.1',
port: 0,
idleTimeout: 0, // PTY connections are long-lived; default idleTimeout would kill them
fetch(req, server) {
const url = new URL(req.url);
// /internal/grant — loopback-only handshake from parent server.
// v1.44+: accepts `{token, sessionId?}`. The sessionId binding lets
// the agent route re-attach attempts (same sessionId, fresh token)
// back to the same PtySession. Legacy callers passing just `{token}`
// still work — sessionId becomes null and re-attach is unavailable
// for that grant.
if (url.pathname === '/internal/grant' && req.method === 'POST') {
return internalHandler(req, (body) => {
if (typeof body?.token === 'string' && body.token.length > 16) {
const sid = typeof body?.sessionId === 'string' && body.sessionId.length > 0
? body.sessionId
: null;
validTokens.set(body.token, sid);
}
});
}
// /internal/revoke — drop a token (called on WS close or bootstrap reload)
if (url.pathname === '/internal/revoke' && req.method === 'POST') {
return internalHandler(req, (body) => {
if (typeof body?.token === 'string') validTokens.delete(body.token);
});
}
// /internal/restart — dispose the PtySession for a specific sessionId.
// Scoped to one caller (not enumerate-all). Server.ts /pty-restart
// posts here with the caller's sessionId; we kill ONLY that PTY,
// leaving any other live sidebar tabs untouched. Codex T2 of the
// eng review caught this gap — pre-spec the route would have
// disposed all sessions.
if (url.pathname === '/internal/restart' && req.method === 'POST') {
return internalHandler(req, (body) => {
const sid = typeof body?.sessionId === 'string' ? body.sessionId : null;
if (!sid) return { killed: 0 };
const session = sessionsById.get(sid);
if (!session) return { killed: 0 };
// Cancel any pending detach timer before disposal — otherwise it
// would fire later against an already-disposed session.
if (session.detachTimer) {
clearTimeout(session.detachTimer);
session.detachTimer = null;
}
disposeSession(session);
sessionsById.delete(sid);
return { killed: 1 };
});
}
// /internal/healthz — liveness probe used by the v1.44 watchdog.
// Returns this agent's pid + gen + active session count without
// touching claude binary lookup (which can fail for non-process
// reasons and isn't a useful liveness signal). GET — no body to parse,
// so it stays on the bare checkInternalAuth gate.
if (url.pathname === '/internal/healthz' && req.method === 'GET') {
const denied = checkInternalAuth(req);
if (denied) return denied;
return new Response(JSON.stringify({
pid: process.pid,
gen: CURRENT_GEN,
sessions: validTokens.size,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
// /claude-available — bootstrap card hits this when user clicks "I installed it".
if (url.pathname === '/claude-available' && req.method === 'GET') {
writeClaudeAvailable();
const found = findClaude();
return new Response(JSON.stringify({ available: !!found, path: found }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// /ws — WebSocket upgrade. CRITICAL gates:
// (1) Origin must be chrome-extension://<id>. Cross-site WS hijacking
// defense — required, not optional.
// (2) Token must be in validTokens. We accept the token via two
// transports for compatibility:
// - Sec-WebSocket-Protocol (preferred for browsers — the only
// auth header settable from the browser WebSocket API)
// - Cookie gstack_pty (works for non-browser callers and
// same-port browser callers; doesn't survive the cross-port
// jump from server.ts:34567 to the agent's random port
// when SameSite=Strict is set)
// Either path works; both verify against the same in-memory
// validTokens Set, populated by the parent server's
// authenticated /pty-session → /internal/grant chain.
if (url.pathname === '/ws') {
const origin = req.headers.get('origin') || '';
const isExtensionOrigin = origin.startsWith('chrome-extension://');
if (!isExtensionOrigin) {
return new Response('forbidden origin', { status: 403 });
}
if (EXTENSION_ID && origin !== `chrome-extension://${EXTENSION_ID}`) {
return new Response('forbidden origin', { status: 403 });
}
// Try Sec-WebSocket-Protocol first. Format: a single token, possibly
// with a `gstack-pty.` prefix (which we strip). Browsers send a
// comma-separated list when multiple were requested; we pick the
// first that matches a known token.
const protoHeader = req.headers.get('sec-websocket-protocol') || '';
let token: string | null = null;
for (const raw of protoHeader.split(',').map(s => s.trim()).filter(Boolean)) {
const candidate = raw.startsWith('gstack-pty.') ? raw.slice('gstack-pty.'.length) : raw;
if (validTokens.has(candidate)) {
token = candidate;
break;
}
}
// Fallback: Cookie gstack_pty (legacy / non-browser callers).
// Parsing is shared with the server via extractPtyCookie; VALIDATION
// deliberately stays against the agent's own validTokens map — the
// server's registry lives in a different process.
if (!token) {
const candidate = extractPtyCookie(req);
if (candidate && validTokens.has(candidate)) {
token = candidate;
}
}
if (!token) {
return new Response('unauthorized', { status: 401 });
}
// v1.44+: surface the token's sessionId binding to the upgraded ws.
// open() reads it via ws.data and registers the session in
// sessionsById so /internal/restart and (Commit 3) re-attach
// lookups can find it.
const sessionId = validTokens.get(token) ?? null;
// No explicit Sec-WebSocket-Protocol echo: Bun >= 1.3 auto-echoes the
// first offered protocol in the 101 response, so setting the header
// here produced a DUPLICATE header — strict clients (Chromium, python
// websockets) reject the handshake per RFC 6455 and the sidebar
// terminal could never connect. Verified on Bun 1.3.6.
const upgraded = server.upgrade(req, {
data: { cookie: token, sessionId },
});
return upgraded ? undefined : new Response('upgrade failed', { status: 500 });
}
return new Response('not found', { status: 404 });
},
websocket: {
/**
* Spawn the claude PTY for `session` if it hasn't been spawned yet.
* Called from both message paths: the legacy binary-frame trigger
* (any keystroke) AND the v1.44 explicit `{type:"start"}` trigger
* (forceRestart sends this on every fresh WS to get an eager prompt
* without requiring the user to type). Idempotent — a second call
* after `spawned: true` is a no-op.
*/
open(ws) {
const sessionId = (ws.data as any)?.sessionId ?? null;
const cookie = (ws.data as any)?.cookie || '';
// Commit 3 re-attach: if this sessionId already has a detached
// PtySession in sessionsById, REPLACE its liveWs ref and replay
// the ring buffer. The PTY process is unchanged — claude keeps
// running through the wifi blip / panel-suspend cycle.
if (sessionId) {
const existing = sessionsById.get(sessionId);
if (existing) {
if (existing.detachTimer) {
clearTimeout(existing.detachTimer);
existing.detachTimer = null;
}
existing.detached = false;
existing.liveWs = ws;
existing.cookie = cookie;
// Re-bind the WS-keyed map so resize/close/message handlers
// can still find this session via the new ws.
sessions.set(ws, existing);
// Restart keepalive on the new ws.
if (existing.pingInterval) clearInterval(existing.pingInterval);
existing.pingInterval = setInterval(() => {
try { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } catch {}
}, KEEPALIVE_INTERVAL_MS);
// Tell the client to prep its xterm (write RIS) before the
// replay binary arrives. Order matters — the binary frame
// immediately after this text frame IS the replay.
try { ws.send(JSON.stringify({ type: 'reattach-begin', sessionId })); } catch {}
try { ws.sendBinary(buildReplayPayload(existing)); } catch {}
return;
}
}
const session: PtySession = {
proc: null,
cols: 80,
rows: 24,
cookie,
liveWs: ws,
sessionId,
spawned: false,
pingInterval: null,
ringBuffer: [],
ringBufferBytes: 0,
altScreenActive: false,
detached: false,
detachTimer: null,
};
session.pingInterval = setInterval(() => {
try {
ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
} catch {
// ws likely closed mid-tick; close handler clears the interval.
}
}, KEEPALIVE_INTERVAL_MS);
sessions.set(ws, session);
// Index by sessionId for /internal/restart + Commit 3 re-attach.
if (sessionId) sessionsById.set(sessionId, session);
},
message(ws, raw) {
let session = sessions.get(ws);
if (!session) {
// Fallback for any path where open() didn't fire (shouldn't happen
// in Bun.serve but keeps the spawn path safe). No keepalive on
// this branch — open() is the supported entry point.
session = {
proc: null,
cols: 80,
rows: 24,
cookie: (ws.data as any)?.cookie || '',
liveWs: ws,
sessionId: (ws.data as any)?.sessionId ?? null,
spawned: false,
pingInterval: null,
ringBuffer: [],
ringBufferBytes: 0,
altScreenActive: false,
detached: false,
detachTimer: null,
};
sessions.set(ws, session);
if (session.sessionId) sessionsById.set(session.sessionId, session);
}
// Text frames are control messages: {type: "resize", cols, rows},
// {type: "tabSwitch", tabId, url, title}, {type: "tabState", ...},
// or v1.44 keepalive frames: {type: "pong", ts}, {type: "keepalive"}.
// Binary frames are raw input bytes destined for the PTY stdin.
if (typeof raw === 'string') {
let msg: any;
try { msg = JSON.parse(raw); } catch { return; }
if (msg?.type === 'resize') {
const cols = Math.max(2, Math.floor(Number(msg.cols) || 80));
const rows = Math.max(2, Math.floor(Number(msg.rows) || 24));
session.cols = cols;
session.rows = rows;
try { session.proc?.terminal?.resize?.(cols, rows); } catch {}
return;
}
if (msg?.type === 'tabSwitch') {
handleTabSwitch(msg);
return;
}
if (msg?.type === 'tabState') {
handleTabState(msg);
return;
}
if (msg?.type === 'pong' || msg?.type === 'keepalive' || msg?.type === 'ping') {
// Keepalive frames — accepted and silently dropped. The mere
// fact that the WS carried this frame is the liveness signal;
// there's no application-level state to update at this layer.
// `ping` is acknowledged here too in case the client (or a
// future agent peer) mirrors our server-side ping shape.
return;
}
if (msg?.type === 'start') {
// v1.44 explicit spawn trigger. forceRestart sends this
// immediately on every fresh WS so claude boots without the
// user having to type a keystroke (pre-v1.44, the lazy-binary
// spawn made restart look stuck until the user typed). No-op
// if already spawned.
maybeSpawnPty(ws, session);
return;
}
// Unknown text frame — ignore.
return;
}
// Binary input. Lazy-spawn claude on the first byte if `start`
// wasn't sent first. Both paths land in the same maybeSpawnPty
// helper for behavior parity.
if (!session.spawned) {
if (!maybeSpawnPty(ws, session)) return;
}
try {
// raw is a Uint8Array; Bun.Terminal.write accepts string|Buffer.
// Convert to Buffer for safety.
session.proc?.terminal?.write?.(Buffer.from(raw as Uint8Array));
} catch (err) {
console.error('[terminal-agent] terminal.write failed:', err);
}
},
close(ws, code, _reason) {
const session = sessions.get(ws);
if (!session) return;
// Always drop the WS-keyed map entry and the per-attach
// attachToken — the attach grant was single-use.
sessions.delete(ws);
if (session.cookie) validTokens.delete(session.cookie);
// Keepalive lives with the WS — every attach starts a fresh one.
if (session.pingInterval) {
clearInterval(session.pingInterval);
session.pingInterval = null;
}
// Commit 3 detach state machine. If the close was intentional
// (code 4001 = restart, 4404 = no-claude error), dispose
// immediately — there's no value in keeping the PTY alive.
// Otherwise enter the detach window: claude keeps running, the
// ring buffer keeps accumulating, and a re-attach with the same
// sessionId within DETACH_WINDOW_MS picks back up. If the timer
// fires without a re-attach, the session is disposed normally.
//
// Sessions without a sessionId (legacy single-shot grants) can't
// re-attach by definition — fall through to immediate dispose.
const intentional = code === 4001 || code === 4404 || code === 1000;
if (intentional || !session.sessionId) {
disposeSession(session);
if (session.sessionId) sessionsById.delete(session.sessionId);
return;
}
// Mark detached and start the disposal timer. The session stays
// in sessionsById so the next /ws upgrade with the same
// sessionId can find and reattach to it.
session.detached = true;
session.liveWs = null;
session.detachTimer = setTimeout(() => {
if (!session.detached) return; // re-attached in the meantime
disposeSession(session);
if (session.sessionId) sessionsById.delete(session.sessionId);
}, DETACH_WINDOW_MS);
// setTimeout returns a Bun Timer; unref so the detach window
// doesn't keep the process alive past natural shutdown.
(session.detachTimer as any)?.unref?.();
},
},
});
}
/**
* Tab-switch helper: write the active tab to a state file (claude reads it)
* and notify the parent server so its activeTabId stays synced. Skips
* chrome:// and chrome-extension:// internal pages.
*/
/**
* Live tab snapshot. Writes <stateDir>/tabs.json (full list) and updates
* <stateDir>/active-tab.json (current active). claude can read these any
* time without invoking $B tabs — saves a round-trip when the model just
* needs to check the landscape before deciding what to do.
*/
function handleTabState(msg: {
active?: { tabId?: number; url?: string; title?: string } | null;
tabs?: Array<{ tabId?: number; url?: string; title?: string; active?: boolean; windowId?: number; pinned?: boolean; audible?: boolean }>;
reason?: string;
}): void {
const stateDir = path.dirname(STATE_FILE);
try { mkdirSecure(stateDir); } catch {}
// tabs.json — full list
if (Array.isArray(msg.tabs)) {
const payload = {
updatedAt: new Date().toISOString(),
reason: msg.reason || 'unknown',
tabs: msg.tabs.map(t => ({
tabId: t.tabId ?? null,
url: t.url || '',
title: t.title || '',
active: !!t.active,
windowId: t.windowId ?? null,
pinned: !!t.pinned,
audible: !!t.audible,
})),
};
const target = path.join(stateDir, 'tabs.json');
// Fire-and-forget state file: atomic write (via lib/fs-atomic) so
// claude never reads a half-written JSON document; failures swallowed.
if (atomicWriteQuiet(target, JSON.stringify(payload, null, 2), { mode: 0o600 })) {
restrictFilePermissions(target); // Windows ACL hardening
}
}
// active-tab.json — single active tab. Skip chrome-internal pages so
// claude doesn't see chrome:// or chrome-extension:// URLs as
// "current target."
const active = msg.active;
if (active && active.url && !active.url.startsWith('chrome://') && !active.url.startsWith('chrome-extension://')) {
const ctxFile = path.join(stateDir, 'active-tab.json');
const ok = atomicWriteQuiet(ctxFile, JSON.stringify({
tabId: active.tabId ?? null,
url: active.url,
title: active.title ?? '',
}), { mode: 0o600 });
if (ok) restrictFilePermissions(ctxFile); // Windows ACL hardening
}
}
function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }): void {
const url = msg.url || '';
if (!url || url.startsWith('chrome://') || url.startsWith('chrome-extension://')) return;
const stateDir = path.dirname(STATE_FILE);
const ctxFile = path.join(stateDir, 'active-tab.json');
// Fire-and-forget: atomic write via lib/fs-atomic, failures swallowed.
const ok = atomicWriteQuiet(ctxFile, JSON.stringify({
tabId: msg.tabId ?? null,
url,
title: msg.title ?? '',
}), { mode: 0o600 });
if (ok) restrictFilePermissions(ctxFile); // Windows ACL hardening
// Best-effort sync to parent server so its activeTabId tracking matches.
// No await; this is fire-and-forget.
if (BROWSE_SERVER_PORT > 0) {
fetch(`http://127.0.0.1:${BROWSE_SERVER_PORT}/command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${readBrowseToken()}`,
},
body: JSON.stringify({
command: 'tab',
args: [String(msg.tabId ?? ''), '--no-focus'],
}),
}).catch(() => {});
}
}
function readBrowseToken(): string {
try {
const raw = fs.readFileSync(STATE_FILE, 'utf-8');
const j = JSON.parse(raw);
return j.token || '';
} catch { return ''; }
}
// Boot.
function main() {
writeClaudeAvailable();
const server = buildServer();
const port = (server as any).port || (server as any).address?.port;
if (!port) {
console.error('[terminal-agent] failed to bind: no port');
process.exit(1);
}
// Write port file atomically so the parent server can pick it up.
// Throws on failure — a boot without a discoverable port file is broken.
const dir = path.dirname(PORT_FILE);
try { mkdirSecure(dir); } catch {}
atomicWriteSync(PORT_FILE, String(port), { mode: 0o600 });
restrictFilePermissions(PORT_FILE); // Windows ACL hardening
// Write identity-based agent record (pid + per-boot gen). Replaces the
// v1.43- `pkill -f terminal-agent\.ts` regex teardown that could kill
// sibling gstack sessions. Callers (cli.ts spawn site, server.ts
// shutdown, the v1.44 watchdog) now route through killAgentByRecord in
// terminal-agent-control.ts.
writeAgentRecord(dir, { pid: process.pid, gen: CURRENT_GEN, startedAt: Date.now() });
// Hand the parent the internal token so it can call /internal/grant.
// Parent learns INTERNAL_TOKEN via env (TERMINAL_AGENT_INTERNAL_TOKEN below).
// We just print it on stdout for the supervising process to pick up if it's
// not already in env. Defense against env races at spawn time.
console.log(`[terminal-agent] listening on 127.0.0.1:${port} pid=${process.pid} gen=${CURRENT_GEN}`);
// Cleanup port file + agent record on exit.
let cleaningUp = false;
const cleanup = () => {
if (cleaningUp) return;
cleaningUp = true;
safeUnlink(PORT_FILE);
safeUnlink(INTERNAL_TOKEN_FILE);
clearAgentRecord(dir);
process.exit(0);
};
process.on('SIGTERM', cleanup);
process.on('SIGINT', cleanup);
// The terminal agent is intentionally detached so it survives the short-lived
// CLI launcher, but its real owner is the persistent browse server. If that
// server crashes or is killed before running normal shutdown, the agent would
// otherwise be adopted by PID 1 and live forever. Poll the server PID and use
// the same cleanup path as an intentional shutdown when it disappears.
if (BROWSE_OWNER_PID > 0) {
const ownerWatchdog = setInterval(() => {
try {
process.kill(BROWSE_OWNER_PID, 0);
} catch {
cleanup();
}
}, OWNER_WATCHDOG_MS);
(ownerWatchdog as any)?.unref?.();
}
}
// Export the internal token so cli.ts can pass the SAME value to the parent
// server via env. Parent reads BROWSE_TERMINAL_INTERNAL_TOKEN and uses it
// for /internal/grant calls.
//
// In practice, the agent generates INTERNAL_TOKEN once at boot and writes it
// to a state file the parent reads. This avoids env-passing races. See main().
const INTERNAL_TOKEN_FILE = path.join(path.dirname(STATE_FILE), 'terminal-internal-token');
try {
mkdirSecure(path.dirname(INTERNAL_TOKEN_FILE));
writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN);
} catch {}
main();