mirror of https://github.com/garrytan/gstack.git
2 Commits
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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>
|
|
|
|
920a13a17f
|
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 (
|