Skip to content

fix(browse): capture skill subprocess output via temp files, not pipes - #2559

Closed
frederik-kaster-noygear wants to merge 19 commits into
garrytan:mainfrom
frederik-kaster-noygear:claude/peaceful-hoover-77c0d4
Closed

frederik-kaster-noygear wants to merge 19 commits into
garrytan:mainfrom
frederik-kaster-noygear:claude/peaceful-hoover-77c0d4

Conversation

@frederik-kaster-noygear

Copy link
Copy Markdown
Contributor

Problem

$B skill test is flaky under load. In a full bun test browse/test/ run it intermittently returns only bun's version banner:

Expected substring or pattern: /13 pass|0 fail|tests passed/
Received: "bun test v1.3.12 (700fc117)\n"

The obvious reading is a read-ordering bug: handleTest awaited proc.exited before attaching readers, so it looked like it was reading a stream snapshot. That is not the cause. Fixing the ordering did not fix the flake.

Root cause

Under a loaded parent, the first piped spawn in a process intermittently yields an empty stderr even though the child wrote it and exited 0. Instrumenting a full suite run, spawning the same child three times in a row:

pipe#0 exit=0 outLen=28 errLen=0    <- lost
pipe#1 exit=0 outLen=28 errLen=76
pipe#2 exit=0 outLen=28 errLen=76

Reproduced in 4 of 4 instrumented runs. Two candidate workarounds were measured losing the same bytes in the same position, so neither is a fix:

  • attaching readers before awaiting proc.exited and settling with Promise.all
  • a manual stream.getReader() drain loop (what readCapped did)

Bun.spawnSync captures reliably, but blocking is not an option in this module: a spawned skill calls back into the same daemon on GSTACK_PORT, so a synchronous wait would deadlock it.

It surfaced in $B skill test because bun test splits its report across streams (banner to stdout, pass/fail summary to stderr) and handleTest returned stderr || stdout, so a dropped stderr degraded silently to the banner. Worth noting the capture was lossy on every run, not just failing ones: the banner was missing from successful captures too.

Fix

runToFiles() points the child's stdout/stderr at temp files via Bun.file(path), awaits exit, then reads the files. The kernel has flushed everything by the time the child exits, so the post-exit read is always complete. It also removes the pipe-buffer stall risk on chatty children.

spawnSkill had the same defect and now shares the helper rather than carrying a second copy of the fix. There the loss is worse than a test flake: a dropped read blanks a skill's JSON result on $B skill run while still reporting success.

Uses Bun.file(path) rather than raw fds from fs.openSync. Opening descriptors and closing them around a spawn puts the caller into Bun's fd bookkeeping and produced an intermittent EBADF: bad file descriptor, epoll_ctl on a later spawn, observed once in a full-suite run.

The tests passed for "<name>" fallback is removed. A passing bun test always prints a summary, so exit 0 with no output means the run was not captured; it now throws rather than returning an unverifiable success string. The old assertion's tests passed alternative matched that string, so it would have passed vacuously on an empty capture.

Tests

  • The E2E assertion now checks both halves of the report (banner from stdout, summary from stderr) instead of a loose alternation.
  • A static tripwire asserts runToFiles owns the module's only Bun.spawn, and that no site reads child output via stdout: 'pipe', new Response(proc.stdout), or getReader(). The data loss is load-dependent and silent, so a behavioural test cannot catch it reliably; the structure is what is pinned.

Verification

5 consecutive full runs of bun test browse/test/ (1970 tests each) with the target test passing in all five, and no EBADF.

The terminal-agent: PTY round-trip via real WebSocket failures in that suite are pre-existing and unrelated, confirmed by reproducing them on a clean tree (2 of 3 baseline runs).

Not addressed here

14 other stdout: 'pipe' capture sites share this pattern across browse/src/. The highest-value one is cookie-import-browser.ts, where getMacKeychainPassword returns stdout.trim() as a keychain password and the DPAPI path base64-decodes stdout into a decryption key, so a lost read yields a silent empty credential rather than a loud error. Left out to keep this reviewable.

Caveat

This establishes that the first piped spawn can drop a stream and that file-based capture is immune. It does not explain why Bun drops it. The fix is verified empirically against the reproduction, not derived from Bun internals.

🤖 Generated with Claude Code

frederik-kaster-noygear and others added 19 commits August 12, 2026 16:15
Machine-local, comma-separated supplement to the committed
.gstack/redact-prepush-ignore file. Empty by default (no behavior change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Large generated data files (multi-MB CSV/Parquet exports) oversize the 1 MiB
scan cap and trip a false-positive HIGH engine.input_too_large, forcing a full
GSTACK_REDACT_PREPUSH=skip that also skips real code in the same push.

Add an opt-in, auditable path-ignore. Ignore globs merge two sources: the
committed .gstack/redact-prepush-ignore file (one glob per line, # comments —
reviewable in git history, shared across contributors) and the machine-local
redact_prepush_ignore_globs config key. Matched repo-root-relative paths (Bun.Glob,
so ** spans directories) are dropped from the scanned diff via a git :(exclude)
pathspec, and a one-line stderr notice always reports how many paths / bytes were
skipped — an exemption is never silent.

Preserves the garrytan#1946 fail-closed invariant: an ignore glob is an explicit opt-in;
anything NOT matched still goes through scan(), so a bare oversize non-ignored
file still blocks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ignored large file passes with a skip notice; non-ignored large file still
fail-closed blocks; a real secret in a non-ignored file still blocks even with
ignore rules present; an explicit ignore glob exempts its file (auditable
opt-in); the machine-local config key is an additional source; no ignore rules
leaves default behavior unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review found a reproduced fail-open: a plain `:(exclude)<name>`
git pathspec is glob-ENABLED, so a legal on-disk filename holding a `?`/`[`/`]`
metacharacter was interpreted as a PATTERN and could drop OTHER, non-ignored
files from the scan — a real credential in a sibling file (verified with a live
AKIA/PEM key) slipped past with exit 0.

Use `:(top,exclude,literal)` for excludes and `:(top,literal)` for the
skip-accounting diff. `literal` disables glob interpretation so only the exact
ignored path is exempted; `top` anchors at the repo root so excludes line up
with the root-relative names from `--name-only` regardless of cwd (the positive
pathspec is `:/`, also root-anchored). Also pass `core.quotePath=false` on the
name-only diff so non-ASCII filenames arrive as raw UTF-8 and the glob matches
(otherwise git C-quotes them and the user's ignore rule silently no-ops).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(g) a non-ASCII filename is exempted by its glob (core.quotePath=false).
(h) a `?`-bearing ignored filename does NOT drag a sibling holding a real
    secret out of the scan — the sibling credential still blocks (exit 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gstack-config reads values whitespace-delimited, so a space after a comma
silently drops the trailing globs. Document it and steer multi-glob users to
the committed .gstack/redact-prepush-ignore file (one glob per line).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds Step 3.5 to the upgrade skill: when the install uses a fork-with-carried-
patch layout (an `upstream` remote distinct from `origin`), rebase the local
commits onto the latest upstream and push to the fork BEFORE the Step 4
`reset --hard origin/main`, so one /gstack-upgrade both keeps the local patch
and advances to upstream. Gated so vanilla installs (no upstream remote, or
upstream==origin) are a strict no-op. Conflict/push-failure abort cleanly and
stop the upgrade with manual remediation instructions; offline falls through
to the current fork state.

NOT FOR UPSTREAM PR — this is personal fork-maintenance automation. Keep it on
fork main only; do not push to the redact PR branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`browse goto <url>` followed by `browse text` could return the full text of a
completely different page, wrapped in an UNTRUSTED envelope whose `source:`
banner named that other page. Content looked plausible and was attributed to a
URL that was never read. In one research session, 113 of 535 reads (21%) came
back from a different host than the goto that preceded them, with no error.

Cause: one daemon per git root with one global `activeTabId`. `goto` and `text`
are separate CLI processes making separate /command requests, and
handleCommandInternalImpl is async — it yields at every await, so requests
interleave. A concurrent agent's goto lands between this caller's goto and its
read. The comment claiming this was "safe because Bun's event loop is
single-threaded" was false and is corrected in place.

The fix is detection, not prevention:

- nav-guard.ts records, per tab, the URL the last goto committed to and how many
  navigations have landed unread. A page-content read is refused with 409 when
  the tab drifted cross-origin, when the navigation didn't load a page, or when
  two gotos landed with no read between them (the clobber signature). Provenance
  is per-tab, never daemon-global: a global "last goto" is updated by the
  clobbering client too, so it would match and still serve the wrong page.

- goto now reports the URL it actually landed on, flagging redirects, instead of
  echoing back the URL it was asked for while the tab sat elsewhere.

- The read path resolves the session and every URL from this request's own tab
  rather than through browserManager.getCurrentUrl(), which reads the global
  activeTabId. That was the mislabelling half: content from one tab stamped with
  another tab's address in the banner the model reads as provenance.

- sanitizePageUrl() is shared between the envelope and the guard's errors. Guard
  errors are returned outside the envelope, and a page picks its own URL via
  pushState, so an untruncated URL was a free text channel to the model.

back/forward/reload move the expectation without counting as a fresh request, so
`goto URL; reload; text` does not refuse itself.

This detects the collision; it does not prevent it. Agents sharing a repo still
share a tab. Per-agent tab isolation was prototyped as caller-supplied "lanes"
and pulled back out: review found a double-resolve race, lane tabs created
outside the tabOwnership model, and a lane-hijack path from unscoped low-entropy
ids. That needs to be built on the existing clientId/tabOwnership model rather
than beside it, as its own change.

Verified: `goto A; goto B; text` exits 1 naming both URLs instead of returning
B's text; `goto; reload; text` passes; full browse suite has the same two
pre-existing failures as a clean tree, no new ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven browse test files ended afterAll with
`setTimeout(() => process.exit(0), 500)`, a workaround for bm.close()
hanging. Whichever ran first tore down the bun test runner ~500ms later,
so `bun test browse/test/` stopped after ~3 of 125 files, printed no
summary, and exited 0 — including when tests had already failed.

Replace with the pattern nav-guard-e2e.test.ts already documents: fire
the close and don't await it, don't exit. These files never called
bm.close() at all, so this also stops leaking a Chromium per file.

Before: 3 files, no summary, exit 0 (with failures present).
After: 125 files, 2082 tests, real summary, exit 1 on failure / 0 on pass.

The failures this exposes are pre-existing and were merely unreachable
before; each was verified against the pre-change code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bin/gstack-config resolves GSTACK_STATE_ROOT > GSTACK_HOME >
GSTACK_STATE_DIR, but gstack-config.test.ts and gstack-update-check.test.ts
isolated themselves with GSTACK_STATE_DIR alone. telemetry.test.ts,
domain-skills-e2e.test.ts and domain-skills-storage.test.ts each set
GSTACK_HOME at module top level with no restore, and bun shares one process
across test files — so the leaked value outranked the tests' temp dir and the
spawned script read somewhere else entirely.

Pin all three precedence levels to one dir, placed after the extraEnv spread
so the two tests that deliberately override GSTACK_STATE_DIR to a nested path
still decide which dir is used.

gstack-update-check reads GSTACK_STATE_DIR directly but shells out to
gstack-config, so it inherited the same bug one level down — and failed by
asserting a wrong value rather than erroring.

Both files passed standalone and only failed in the directory run, which is
why this stayed hidden until the teardown fix let the suite run to the end.
Verified against all three leak sources pairwise; full-suite failures drop
from 107 to 93, removing exactly these 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to 4bed1c0, which pinned the state vars at the gstack-config
and gstack-update-check spawn sites. This covers the rest of the class.

Bun runs test files sequentially in one process (module-load -> tests ->
afterAll, then the next file), ordered by directory entry rather than by
name or CLI argument order. A file that writes process.env.GSTACK_HOME at
module top level without restoring it therefore poisons every file that
runs after it, and which files those are varies by checkout. That is why
this surfaces intermittently.

Exposed spawn site (1 remaining in browse/test):

  learnings-injection.test.ts isolated itself with HOME alone, but
  bin/gstack-learnings-search resolves GSTACK_HOME:-$HOME/.gstack, so a
  leaked GSTACK_HOME outranks the override. Verified directly: with the
  leak the script reads the leaked directory and runs its full bun -e
  pipeline instead of exiting early. The existing assertion (status != 99)
  passes either way, so this silently exercised the wrong code path.

Leak sources hardened (4, not the 3 first identified -- cdp-e2e.test.ts
is a fourth). Each now saves the prior value and restores it in afterAll.
Also restored alongside GSTACK_HOME: GSTACK_TELEMETRY_OFF (telemetry sets
'0' and cdp-e2e sets '1', so they were overwriting each other) and
GSTACK_PROJECT_SLUG.

afterAll rather than afterEach is deliberate. test-setup.ts is already the
central guard for this class, but it restores on afterEach, which would
wipe the module-top-level assignment these files need for the duration of
their own tests -- its own comment says as much. Per-file afterAll restore
is the complement that covers the file boundary the central guard cannot.

The spawn-site pin remains the load-order-proof guard; the restores stop
the next such test from reintroducing the hazard for new spawn sites.

Verified: 125 files / 2082 tests, 1975 pass / 92 fail / 7 errors both
before and after. Two runs of identical code produce different failure
sets (sidebar-ux flakes run to run), so the failure set was compared
against the baseline's own steady state rather than a single run. Each
touched file passes standalone, in all 12 victim x leak-source pairings,
and all seven together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes a regression introduced by b096fff. Measured against the real
~/.gstack/analytics/browse-telemetry.jsonl over a full-suite run:

  before b096fff:  delta 0 lines
  after  b096fff:  delta 1 line     <- regression
  with this fix:    delta 0 lines    (confirmed over 3 runs)

Root cause is in src/telemetry.ts, which resolves the target path twice
across an await:

    ensureDir()                                   // resolves GSTACK_HOME
      .then(() => fs.appendFile(telemetryFile(),  // resolves it AGAIN

telemetryFile() is evaluated inside the .then(), after ensureDir() has
already resolved. logTelemetry is fire-and-forget, so restoring
GSTACK_HOME in afterAll landed inside that window: ensureDir() created
the temp analytics dir, then the append re-resolved against
os.homedir()/.gstack and wrote a stray noop_test row into the developer's
real analytics file. No error, because the directory it checked did exist.

Before b096fff this was masked -- the unrestored leak meant the late
write went to some other test's temp dir instead. Closing the leak is
still correct; it just needs the in-flight writes to settle into TMP_HOME
first, which is what the 50ms drain does (the file already uses a 30ms
settle in readEvents for the same reason).

The underlying double-resolution in telemetry.ts is left alone here: it
is a real latent bug but it is production code, and any fix belongs in a
change that can be reviewed on its own terms rather than folded into a
test-isolation pass.

Suite unchanged at 125 files / 2082 tests, 1975 pass / 92 fail / 7 errors.

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

test/relink.test.ts passed standalone (24/24) but failed in the directory run
on a wrong-value assertion: it expected flat skill names and got gstack-*
prefixed ones.

Root cause is in test/gstack-memory-helpers.test.ts, not in either file named
in the original report. Both describe blocks there save the "original"
GSTACK_HOME inside beforeEach but restore it in afterAll. beforeEach re-reads
the variable after the previous test already overwrote it, so by the last
iteration the saved value is a temp dir from an earlier test, and afterAll
"restores" that instead of the real value. bun shares one process across test
files, so the stale dir then outranked the pin in every later file.

relink pins GSTACK_STATE_DIR, but gstack-relink shells out to gstack-config,
which ranks GSTACK_STATE_ROOT > GSTACK_HOME above it. The leaked GSTACK_HOME
won, so one test's `gstack-config set skill_prefix true` landed in the leaked
dir — which, unlike the per-test tmpDir, survived into the next test and was
read back there.

Fixes, each verified to close the bug on its own:
- memory-helpers: capture the saved values at collection time, before any
  beforeEach has run.
- relink, telemetry, team-mode: pin all three precedence levels to one dir,
  placed after the env spread so a deliberate per-call override still decides
  it. telemetry and team-mode target scripts that read GSTACK_STATE_DIR but
  shell out to gstack-config, the same one-level-down bug fixed in
  gstack-update-check.
- budget-override: it sets GSTACK_HOME at module load with no restore; add an
  afterAll (not afterEach — its tests need the value for their whole run).
- redact-audit-log: restore GSTACK_HOME instead of unconditionally deleting it.

test-setup.ts is left alone: adding GSTACK_HOME to RESTORE_KEYS would break the
files that legitimately set it at module load, as the comment there explains.

uninstall.test.ts needs no change — gstack-uninstall reads only
GSTACK_STATE_DIR and does not shell out, so a leaked GSTACK_HOME cannot reach
it.

Root-suite failures drop from 7 to 6 (4036 -> 4037 pass), removing exactly the
relink test and introducing no regressions. The 6 remaining also fail
standalone and are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
handoff.test.ts launched Chromium against the developer's actual
~/.gstack/chromium-profile — the profile holding real cookies, sessions
and history. Confirmed causally by sampling --user-data-dir from the live
process table during the run: 42 samples showed ephemeral, test-spawned
PIDs using the real profile, all dead once the file finished. With this
change that count is 0 for the file and 0 across the full suite.

Two coupled changes, both required — reverting either puts the count back
(38 with the src fix alone reverted):

1. browse/src/browser-manager.ts — the headless→headed handoff path
   hardcoded $HOME/.gstack/chromium-profile instead of calling
   resolveChromiumProfile() like launch() and launchHeaded() do. That
   bypassed the documented override chain, so CHROMIUM_PROFILE could not
   redirect it. This is also a real non-test bug: gbrowser's
   per-workspace CHROMIUM_PROFILE was silently dropped the moment a
   session handed off to headed mode.

2. browse/test/handoff.test.ts — pin CHROMIUM_PROFILE to a temp dir,
   restored in afterAll. Basename stays 'chromium-profile' so
   cleanSingletonLocks()' own guard still recognises it.

Beyond the profile being driven directly, cleanSingletonLocks() deletes
Singleton{Lock,Socket,Cookie} from whatever it resolves — the exact thing
its own docstring says callers must not do without external coordination
that a test run cannot provide.

Note on method: ~/.gstack/chromium-profile mtime is NOT a usable signal
here. A long-running local browse daemon writes to it on its own; an idle
control with no tests running showed the mtime advancing regardless. The
earlier mtime-based readings in this investigation were confounded, and
the process-table sampling above replaced them.

Scope: an earlier attempt also pinned CHROMIUM_PROFILE at five server/CLI
spawn sites (pair-agent-e2e, pair-agent-tunnel-eval, sidebar-integration,
watchdog, commands). Measured with the same method, those sites never
launch Chromium against the real profile — 0 samples with the pins
reverted — and the pins cost 5 tests and added 1 failure plus 2 errors
(2077/93/9 against a 2082/92/7 steady state). Dropped as unjustified.

Suite unchanged at 125 files / 2082 tests, 1975 pass / 92 fail / 7
errors.

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

652042a made `bun test browse/test/` run all 125 files instead of ~3, which
surfaced 98 failures. Triaged each as a stale assertion, an obsolete pin, or a
real regression.

One real regression — closeTab left the browser with zero tabs.
browser-manager.ts: the page's own 'close' listener (wirePageEvents) fires
during `await page.close()` and already repoints activeTabId, setting it to 0
when no tabs remain. closeTab then re-read activeTabId to decide whether to
auto-create, so `tabId === this.activeTabId` never matched and the newTab()
branch was dead code. `$B closetab` on the last tab left the session with no
page until the user ran goto/newtab. Capture wasActive before the close. The
test asserting this (snapshot.test.ts) was correct as written.

Stale assertions — behavior intact, the test drifted. Each verified against
source before touching:
  - dual-listener: canDispatchOverTunnel gained an args param so it can also
    reject --out disk writes over the tunnel.
  - sidebar-tabs: ws.close() gained (4001, 'intentional-restart'); the 4001 is
    load-bearing, the close handler uses it to skip the re-attach loop.
  - sidebar-tabs: the terminal-agent spawn moved out of cli.ts into the shared
    spawnTerminalAgent helper, so the Bun.spawn now lives in
    terminal-agent-control.ts.
  - terminal-agent: lazy spawn was refactored into the idempotent
    maybeSpawnPty helper, which is what calls spawnClaude now.
  - xvfb: the test was self-defeating rather than drifted. It used process.pid
    as its wrong-cmdline fixture, but running the file by name puts
    "xvfb.test.ts" in the runner's argv, so the substring check matched itself.
    isOurXvfb was always correct. Retargeted at pid 1. Note this one only fails
    on per-file invocation, never in a full-suite run.
  - sidebar-ux: the cleanup button survives but was rewired from a
    /sidebar-command POST to PTY injection, and its prompt was condensed.
    Retargeted at the real contract, including that gstackScanForPTYInject runs
    *before* gstackInjectToTerminal, asserted by index so the order cannot
    silently invert.

Obsolete — deleted. ed1e4be replaced the HTTP-polling chat sidebar with the
PTY terminal REPL; browse/src/sidebar-agent.ts is gone and server.ts carries an
explicit rip marker for the /sidebar-* routes. Removed four test files that
drive those dead endpoints and 25 describes in sidebar-ux.test.ts (1670 -> 790
lines). Two of the deleted files are security-named, so to be explicit: no
protection was dropped. The old banner already used textContent/createElement
throughout, so there was no escaping helper to lose, and the untrusted-content
ingress scan is still live at POST /pty-inject-scan with its own coverage. What
went away is the attack surface, not the guard.

Also dropped the sidebar CSS describe: .stop-btn, .agent-tool and
.experimental-banner have zero references in sidepanel.js and sidepanel.html,
so those tests were passing while pinning dead styling.

Suite: 1956-1957 pass, 15 skip, 0-1 fail across 121 files. The one intermittent
failure is terminal-agent-ring-buffer-runtime.test.ts asserting two
back-to-back mintLease() calls share an expiresAt — it straddles a millisecond
boundary under load. Pre-existing, untouched here, passes standalone.
browser-skills-e2e.test.ts flakes the same way; it spawns a nested `bun test`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nfed /health status

Carried on the fork only until garrytan#2557 merges. This is NOT a
"LOCAL (fork-only)" patch: it is upstream-bound, and once garrytan#2557 lands the
/gstack-upgrade rebase should drop it as already-applied. If that rebase
reports a conflict here, check whether upstream took the change first.

## Problem

The sidebar's SEC shield has been dead UI since the PTY terminal rewrite
(ed1e4be, garrytan#1216). That commit deleted `updateSecurityShield()` and both of
its call sites along with the chat poll loop, but left the markup and CSS in
place. Nothing has set the element's `data-status` since, and nothing unhides
it — it ships `style="display:none"` and the only code that ever set
`display: inline-flex` was the deleted function.

Re-wiring it to /health.security is not viable, because that field is not
live data either. `getStatus()` reads `classifierStatus` out of
~/.gstack/security/session-state.json, and the only writer of that file was
sidebar-agent.ts, which the same rewrite deleted. `writeSessionState()` now
has no production caller anywhere in the tree — its sole remaining caller is
a unit test. So /health reports either a permanent 'inactive' (no file), or
a stale 'protected' wherever an old state file survived on disk. A test run
is enough to leave one behind.

That makes the shield worse than absent if it is ever unhidden: a green
shield sourced from leftover state reads as "no threats detected" when the
real state is "not measured". This is the same fail-open class as garrytan#2026
(security dashboard reporting "0 attacks" on backend error) and the
project's own 9cc41b7 guards-failing-open wave.

## Fix

Remove the indicator and the status feed behind it, rather than wiring a
needle to a gauge that measures nothing:

  - shield markup in sidepanel.html, its CSS, and the stale comment in
    sidepanel.js that documented the indicator as "not driven yet"
  - the `security` field from /health and the getStatus import
  - getStatus, SecurityStatus, StatusDetail, and the now-unreachable
    SessionState / readSessionState / writeSessionState
  - the tests covering them, including a getStatus shape assertion that
    passed on leftover fixture data and so never caught the false green

The live defenses are untouched: the canary, content-security L1-L3, and the
L4 sidecar on /pty-inject-scan all report through their own call sites and
never through /health. Per-tab decision files are unaffected.

## browse/test/security-sidepanel-dom.test.ts

Deleted outright, because every test in it is already dead. Two assert the
shield's data-status flips off /health.security. The other four drive the
security banner, whose markup ed1e4be removed — and the whole suite stubs
/sidebar-chat, an endpoint that no longer exists. The file looks healthy only
because every case is test.skipIf(!CHROMIUM_AVAILABLE) and silently skips
wherever Playwright is absent.

garrytan#2230 independently deletes this same file. If that lands first this hunk
drops out as a delete/delete; the change is otherwise standalone.

## Note

No regression test asserting the absence of the `security` key: it would fail
on a correct future change that re-feeds the status from live signals
(isSidecarAvailable, the content filters), punishing the fix rather than the
regression. The reasoning is recorded in a comment where the field used to be.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'lease registry is independent of ring buffer state' ended by asserting
that two back-to-back mintLease() calls produced identical expiresAt
values. That only holds if both calls land in the same millisecond; under
full-suite load they sometimes straddle a tick and the test failed with a
1 ms difference. It passed standalone and in most full runs, so it read as
noise rather than a real regression.

Assert closeness (<= 5 ms) instead of equality. The property the test
exists to check is that both leases got the same TTL policy and that
neither is coupled to ring-buffer state; wall-clock coincidence was never
part of that, so nothing is lost. Injecting a clock into pty-session-lease
would have bought exact equality at the cost of a test seam in production
code for an assertion that does not need one.

Swept the rest of browse/test/ for the same shape: no other assertion
compares two separately-sampled timestamps for exact equality. The
timestamp equalities in cdp-inspector-history-cap use synthetic counters,
and the elapsed-duration budgets in sanitize and security-audit-r2 have
order-of-magnitude headroom.

Verified over 7 consecutive full `bun test browse/test/` runs: the lease
assertion never failed. One run showed 2 unrelated load flakes
(browser-skills-e2e nested `bun test` returning only its banner, and the
pair-agent tunnel eval timing out on connect); both passed in the others.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`$B skill test` flaked under full-suite load, returning only bun's version
banner. Root cause is not read ordering: under a loaded parent, the FIRST
piped spawn in a process intermittently yields an empty stderr even though
the child wrote it and exited 0. Instrumenting a full `bun test browse/test/`
run showed it in 4 of 4 runs:

    pipe#0 exit=0 outLen=28 errLen=0    <- lost
    pipe#1 exit=0 outLen=28 errLen=76
    pipe#2 exit=0 outLen=28 errLen=76

Both candidate workarounds still lose the same bytes: attaching readers
before awaiting `proc.exited`, and a manual `getReader()` drain loop. It bit
`skill test` because `bun test` splits its report across streams (banner ->
stdout, pass/fail summary -> stderr) and `handleTest` returned
`stderr || stdout`, so a dropped stderr degraded silently to the banner.

runToFiles() points the child's stdout/stderr at temp files, so the kernel
has flushed everything by the time the child exits. spawnSkill had the same
defect and now shares the helper rather than carrying a second copy of the
fix; there the loss was worse, blanking a skill's JSON result on
`$B skill run` while still reporting success.

Uses `Bun.file(path)` rather than raw fds from `fs.openSync`: opening and
closing descriptors around a spawn puts us in Bun's fd bookkeeping and
produced an intermittent `EBADF: bad file descriptor, epoll_ctl` on a later
spawn.

The `tests passed for "<name>"` fallback is gone. A passing `bun test`
always prints a summary, so exit 0 with no output means the run was not
captured; it now throws instead of returning an unverifiable success string
that the old assertion matched vacuously.

Verified with 5 consecutive full runs of `bun test browse/test/`. The
`terminal-agent` PTY WebSocket failures in that suite are pre-existing,
confirmed by reproducing them on a clean tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pipe-capture data loss it guards against is load-dependent and silent,
so a behavioural test can't catch it reliably. Assert the structure instead:
runToFiles owns the module's only Bun.spawn, and no site reads child output
through `stdout: 'pipe'`, `new Response(proc.stdout)`, or `getReader()`.

Comments are stripped before matching so the module's own prose, which names
the banned pattern in order to explain it, doesn't trip the checks.

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

trunk-io Bot commented Aug 13, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

frederik-kaster-noygear added a commit to frederik-kaster-noygear/gstack that referenced this pull request Aug 13, 2026
… temp files, not pipes

`$B skill test` flaked under full-suite load, returning only bun's version
banner. Root cause is not read ordering: under a loaded parent, the FIRST
piped spawn in a process intermittently yields an empty stderr even though
the child wrote it and exited 0. Instrumenting a full `bun test browse/test/`
run showed it in 4 of 4 runs:

    pipe#0 exit=0 outLen=28 errLen=0    <- lost
    pipe#1 exit=0 outLen=28 errLen=76
    pipe#2 exit=0 outLen=28 errLen=76

Both candidate workarounds still lose the same bytes: attaching readers
before awaiting `proc.exited`, and a manual `getReader()` drain loop. It bit
`skill test` because `bun test` splits its report across streams (banner ->
stdout, pass/fail summary -> stderr) and `handleTest` returned
`stderr || stdout`, so a dropped stderr degraded silently to the banner.

runToFiles() points the child's stdout/stderr at temp files, so the kernel
has flushed everything by the time the child exits. spawnSkill had the same
defect and now shares the helper rather than carrying a second copy of the
fix; there the loss was worse, blanking a skill's JSON result on
`$B skill run` while still reporting success.

Uses `Bun.file(path)` rather than raw fds from `fs.openSync`: opening and
closing descriptors around a spawn puts us in Bun's fd bookkeeping and
produced an intermittent `EBADF: bad file descriptor, epoll_ctl` on a later
spawn.

The `tests passed for "<name>"` fallback is gone. A passing `bun test`
always prints a summary, so exit 0 with no output means the run was not
captured; it now throws instead of returning an unverifiable success string
that the old assertion matched vacuously.

Verified with 5 consecutive full runs of `bun test browse/test/`. The
`terminal-agent` PTY WebSocket failures in that suite are pre-existing,
confirmed by reproducing them on a clean tree.

Upstream PR: garrytan#2559

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frederik-kaster-noygear added a commit to frederik-kaster-noygear/gstack that referenced this pull request Aug 13, 2026
…ith a static tripwire

The pipe-capture data loss it guards against is load-dependent and silent,
so a behavioural test can't catch it reliably. Assert the structure instead:
runToFiles owns the module's only Bun.spawn, and no site reads child output
through `stdout: 'pipe'`, `new Response(proc.stdout)`, or `getReader()`.

Comments are stripped before matching so the module's own prose, which names
the banned pattern in order to explain it, doesn't trip the checks.

Upstream PR: garrytan#2559

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frederik-kaster-noygear added a commit to frederik-kaster-noygear/gstack that referenced this pull request Aug 14, 2026
… temp files, not pipes

`$B skill test` flaked under full-suite load, returning only bun's version
banner. Root cause is not read ordering: under a loaded parent, the FIRST
piped spawn in a process intermittently yields an empty stderr even though
the child wrote it and exited 0. Instrumenting a full `bun test browse/test/`
run showed it in 4 of 4 runs:

    pipe#0 exit=0 outLen=28 errLen=0    <- lost
    pipe#1 exit=0 outLen=28 errLen=76
    pipe#2 exit=0 outLen=28 errLen=76

Both candidate workarounds still lose the same bytes: attaching readers
before awaiting `proc.exited`, and a manual `getReader()` drain loop. It bit
`skill test` because `bun test` splits its report across streams (banner ->
stdout, pass/fail summary -> stderr) and `handleTest` returned
`stderr || stdout`, so a dropped stderr degraded silently to the banner.

runToFiles() points the child's stdout/stderr at temp files, so the kernel
has flushed everything by the time the child exits. spawnSkill had the same
defect and now shares the helper rather than carrying a second copy of the
fix; there the loss was worse, blanking a skill's JSON result on
`$B skill run` while still reporting success.

Uses `Bun.file(path)` rather than raw fds from `fs.openSync`: opening and
closing descriptors around a spawn puts us in Bun's fd bookkeeping and
produced an intermittent `EBADF: bad file descriptor, epoll_ctl` on a later
spawn.

The `tests passed for "<name>"` fallback is gone. A passing `bun test`
always prints a summary, so exit 0 with no output means the run was not
captured; it now throws instead of returning an unverifiable success string
that the old assertion matched vacuously.

Verified with 5 consecutive full runs of `bun test browse/test/`. The
`terminal-agent` PTY WebSocket failures in that suite are pre-existing,
confirmed by reproducing them on a clean tree.

Upstream PR: garrytan#2559

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frederik-kaster-noygear added a commit to frederik-kaster-noygear/gstack that referenced this pull request Aug 14, 2026
…ith a static tripwire

The pipe-capture data loss it guards against is load-dependent and silent,
so a behavioural test can't catch it reliably. Assert the structure instead:
runToFiles owns the module's only Bun.spawn, and no site reads child output
through `stdout: 'pipe'`, `new Response(proc.stdout)`, or `getReader()`.

Comments are stripped before matching so the module's own prose, which names
the banned pattern in order to explain it, doesn't trip the checks.

Upstream PR: garrytan#2559

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

Copy link
Copy Markdown
Owner

Thank you — this landed in v1.67.0.0 (credited in the CHANGELOG). Closing since the fix is on main. 🙏

@garrytan garrytan closed this Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants