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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three-way split by importer class:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(hosts): delete five dead HostConfig fields

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

668 lines
27 KiB
TypeScript

/**
* Read commands — extract data from pages without side effects
*
* text, html, links, forms, accessibility, js, eval, css, attrs,
* console, network, cookies, storage, perf
*/
import type { TabSession } from './tab-session';
import type { BrowserManager } from './browser-manager';
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
import type { Page, Frame } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR } from './platform';
import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector';
import { validateReadPath, validateOutputPath } from './path-security';
import { stripLoneSurrogates } from './sanitize';
// Re-export for backward compatibility (tests import from read-commands)
export { validateReadPath } from './path-security';
// Redaction patterns for sensitive cookie/storage values — exported for test coverage
export const SENSITIVE_COOKIE_NAME = /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i;
export const SENSITIVE_COOKIE_VALUE = /^(eyJ|sk-|sk_live_|sk_test_|pk_live_|pk_test_|rk_live_|sk-ant-|ghp_|gho_|github_pat_|xox[bpsa]-|AKIA[A-Z0-9]{16}|AIza|SG\.|Bearer\s|sbp_)/;
/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */
function hasAwait(code: string): boolean {
const stripped = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
return /\bawait\b/.test(stripped);
}
/** Detect whether code needs a block wrapper {…} vs expression wrapper (…) inside an async IIFE. */
function needsBlockWrapper(code: string): boolean {
const trimmed = code.trim();
if (trimmed.split('\n').length > 1) return true;
if (/\b(const|let|var|function|class|return|throw|if|for|while|switch|try)\b/.test(trimmed)) return true;
if (trimmed.includes(';')) return true;
return false;
}
/** Wrap code for page.evaluate(), using async IIFE with block or expression body as needed. */
function wrapForEvaluate(code: string): string {
if (!hasAwait(code)) return code;
const trimmed = code.trim();
return needsBlockWrapper(trimmed)
? `(async()=>{\n${code}\n})()`
: `(async()=>(${trimmed}))()`;
}
/** Flags split out of `js`/`eval` args by parseOutArgs. */
export interface OutArgs {
outPath?: string;
raw: boolean;
rest: string[];
}
/**
* Parse `--out <path>` / `--out=<path>` and `--raw` / `--raw=true|false` out of an
* arg list, returning the flags plus the remaining positional args (`rest`).
*
* Single source of truth shared by the js/eval handlers and the write-capability
* gate in server.ts, so the two never disagree on what counts as an `--out`
* invocation. Throws on malformed usage (repeated `--out`, missing value, bad
* `--raw` value) so the user gets a clear error instead of a silent misparse.
*/
export function parseOutArgs(args: string[]): OutArgs {
let outPath: string | undefined;
let raw = false;
const rest: string[] = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--out') {
if (outPath !== undefined) throw new Error('--out specified more than once');
const val = args[i + 1];
if (val === undefined || val.startsWith('--')) throw new Error('--out requires a file path');
outPath = val;
i++;
} else if (a.startsWith('--out=')) {
if (outPath !== undefined) throw new Error('--out specified more than once');
const val = a.slice('--out='.length);
if (val === '') throw new Error('--out requires a file path');
outPath = val;
} else if (a === '--raw') {
raw = true;
} else if (a.startsWith('--raw=')) {
const v = a.slice('--raw='.length).toLowerCase();
if (v !== 'true' && v !== 'false') throw new Error('--raw must be true or false');
raw = v === 'true';
} else {
rest.push(a);
}
}
return { outPath, raw, rest };
}
/**
* True iff an arg list contains an `--out` flag in any accepted form
* (`--out <path>` or `--out=<path>`). Used by the write-capability gate to
* decide whether an otherwise-read command (`js`/`eval`) is actually a write
* invocation. Mirrors parseOutArgs's `--out` recognition exactly. Never throws —
* a malformed `--out=` still counts as an out attempt (fail safe: gate it).
*/
export function hasOutArg(args: string[]): boolean {
return args.some(a => a === '--out' || a.startsWith('--out='));
}
/**
* Convert an evaluate() result to its string form — the exact conversion `js`/`eval`
* used inline before `--out` existed. Kept byte-for-byte: `typeof === 'object'`
* (which includes `null`) goes through JSON.stringify (so `null` → `"null"`);
* everything else via `String(result ?? '')` (so `undefined` → `''`). JSON.stringify
* still throws on circular / BigInt-bearing results, same as before.
*/
export function resultToString(result: unknown): string {
return typeof result === 'object'
? JSON.stringify(result, null, 2)
: String(result ?? '');
}
/**
* Write an evaluate result string to disk for `--out`, returning bytes written.
*
* When the result is a base64 data URL (`data:<type>;...;base64,<payload>`) and
* `raw` is false, decode the payload to raw bytes — this is the Excalidraw / og-image
* path where a render function returns a PNG data URL. The header is parsed
* case-insensitively and split on the FIRST comma (data URLs can contain commas in
* the payload). The payload is validated against the base64 charset before decoding,
* because `Buffer.from(_, 'base64')` silently drops invalid characters and would
* otherwise write corrupted bytes. `--raw` forces a literal write even for data URLs.
*
* Non-base64 strings are surrogate-sanitized (matching what the stdout egress path
* did before) and written as UTF-8. Parent directories are created — validateOutputPath
* gates the location but does not mkdir.
*/
export function writeEvalResult(outPath: string, str: string, opts: { raw: boolean }): number {
validateOutputPath(outPath);
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
if (!opts.raw && str.startsWith('data:')) {
const comma = str.indexOf(',');
if (comma !== -1) {
const header = str.slice('data:'.length, comma);
const tokens = header.split(';').map(t => t.trim().toLowerCase());
if (tokens.includes('base64')) {
const payload = str.slice(comma + 1).replace(/\s+/g, '');
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) {
throw new Error('--out: malformed base64 in data URL (decode would corrupt output)');
}
const buf = Buffer.from(payload, 'base64');
fs.writeFileSync(outPath, buf);
return buf.length;
}
}
}
const buf = Buffer.from(stripLoneSurrogates(str), 'utf-8');
fs.writeFileSync(outPath, buf);
return buf.length;
}
/**
* Extract clean text from a page (strips script/style/noscript/svg).
* Exported for DRY reuse in meta-commands (diff).
*/
export async function getCleanText(page: Page | Frame): Promise<string> {
const raw = await page.evaluate(() => {
const body = document.body;
if (!body) return '';
const clone = body.cloneNode(true) as HTMLElement;
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
return clone.innerText
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('\n');
});
return stripLoneSurrogates(raw);
}
/**
* When cookies have been imported for specific domains, block JS execution
* on pages whose origin doesn't match any imported cookie domain.
* Prevents cross-origin cookie exfiltration via `js document.cookie` or
* similar when the agent navigates to an untrusted page.
*/
function assertJsOriginAllowed(bm: BrowserManager, pageUrl: string): void {
if (!bm.hasCookieImports()) return;
let hostname: string;
try {
hostname = new URL(pageUrl).hostname;
} catch {
return; // about:blank, data: URIs — allow (no cookies at risk)
}
const importedDomains = bm.getCookieImportedDomains();
const allowed = [...importedDomains].some(domain => {
// Exact match or subdomain match (e.g., ".github.com" matches "api.github.com")
const normalized = domain.startsWith('.') ? domain : '.' + domain;
return hostname === domain.replace(/^\./, '') || hostname.endsWith(normalized);
});
if (!allowed) {
throw new Error(
`JS execution blocked: current page (${hostname}) does not match any cookie-imported domain. ` +
`Imported cookies for: ${[...importedDomains].join(', ')}. ` +
`This prevents cross-origin cookie exfiltration. Navigate to an imported domain or run without imported cookies.`
);
}
}
export async function handleReadCommand(
command: string,
args: string[],
session: TabSession,
bm: BrowserManager,
): Promise<string> {
const page = session.getPage();
// Frame-aware target for content extraction
const target = session.getActiveFrameOrPage();
switch (command) {
case 'text': {
return getCleanText(target);
}
case 'html': {
const selector = args[0];
if (selector) {
const resolved = await session.resolveRef(selector);
if ('locator' in resolved) {
return stripLoneSurrogates(await resolved.locator.innerHTML({ timeout: 5000 }));
}
return stripLoneSurrogates(await target.locator(resolved.selector).innerHTML({ timeout: 5000 }));
}
// page.content() is page-only; use evaluate for frame compat
const doctype = await target.evaluate(() => {
const dt = document.doctype;
return dt ? `<!DOCTYPE ${dt.name}>` : '';
});
const html = await target.evaluate(() => document.documentElement.outerHTML);
return stripLoneSurrogates(doctype ? `${doctype}\n${html}` : html);
}
case 'links': {
const links = await target.evaluate(() =>
[...document.querySelectorAll('a[href]')].map(a => ({
text: a.textContent?.trim().slice(0, 120) || '',
href: (a as HTMLAnchorElement).href,
})).filter(l => l.text && l.href)
);
return links.map(l => `${l.text}${l.href}`).join('\n');
}
case 'forms': {
const forms = await target.evaluate(() => {
return [...document.querySelectorAll('form')].map((form, i) => {
const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
const input = el as HTMLInputElement;
return {
tag: el.tagName.toLowerCase(),
type: input.type || undefined,
name: input.name || undefined,
id: input.id || undefined,
placeholder: input.placeholder || undefined,
required: input.required || undefined,
value: input.type === 'password'
|| (input.name && /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i.test(input.name))
|| (input.id && /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i.test(input.id))
? '[redacted]' : (input.value || undefined),
options: el.tagName === 'SELECT'
? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
: undefined,
};
});
return {
index: i,
action: form.action || undefined,
method: form.method || 'get',
id: form.id || undefined,
fields,
};
});
});
return JSON.stringify(forms, null, 2);
}
case 'accessibility': {
const snapshot = await target.locator("body").ariaSnapshot();
return stripLoneSurrogates(snapshot);
}
case 'js': {
const { outPath, raw, rest } = parseOutArgs(args);
const expr = rest[0];
if (!expr) throw new Error('Usage: browse js <expression> [--out <file>] [--raw]');
assertJsOriginAllowed(bm, page.url());
const wrapped = wrapForEvaluate(expr);
const result = await target.evaluate(wrapped);
const str = resultToString(result);
if (outPath) {
const n = writeEvalResult(outPath, str, { raw });
return `JS result written: ${outPath} (${n} bytes)`;
}
return str;
}
case 'eval': {
const { outPath, raw, rest } = parseOutArgs(args);
const filePath = rest[0];
if (!filePath) throw new Error('Usage: browse eval <js-file> [--out <file>] [--raw]');
assertJsOriginAllowed(bm, page.url());
validateReadPath(filePath);
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
const code = fs.readFileSync(filePath, 'utf-8');
const wrapped = wrapForEvaluate(code);
const result = await target.evaluate(wrapped);
const str = resultToString(result);
if (outPath) {
const n = writeEvalResult(outPath, str, { raw });
return `Eval result written: ${outPath} (${n} bytes)`;
}
return str;
}
case 'css': {
const [selector, property] = args;
if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
const resolved = await session.resolveRef(selector);
if ('locator' in resolved) {
const value = await resolved.locator.evaluate(
(el, prop) => getComputedStyle(el).getPropertyValue(prop),
property
);
return value;
}
const value = await target.evaluate(
([sel, prop]) => {
const el = document.querySelector(sel);
if (!el) return `Element not found: ${sel}`;
return getComputedStyle(el).getPropertyValue(prop);
},
[resolved.selector, property]
);
return value;
}
case 'attrs': {
const selector = args[0];
if (!selector) throw new Error('Usage: browse attrs <selector>');
const resolved = await session.resolveRef(selector);
if ('locator' in resolved) {
const attrs = await resolved.locator.evaluate((el) => {
const result: Record<string, string> = {};
for (const attr of el.attributes) {
result[attr.name] = attr.value;
}
return result;
});
return JSON.stringify(attrs, null, 2);
}
const attrs = await target.evaluate((sel: string) => {
const el = document.querySelector(sel);
if (!el) return `Element not found: ${sel}`;
const result: Record<string, string> = {};
for (const attr of el.attributes) {
result[attr.name] = attr.value;
}
return result;
}, resolved.selector);
return typeof attrs === 'string' ? attrs : JSON.stringify(attrs, null, 2);
}
case 'console': {
if (args[0] === '--clear') {
consoleBuffer.clear();
return 'Console buffer cleared.';
}
const entries = args[0] === '--errors'
? consoleBuffer.toArray().filter(e => e.level === 'error' || e.level === 'warning')
: consoleBuffer.toArray();
if (entries.length === 0) return args[0] === '--errors' ? '(no console errors)' : '(no console messages)';
return entries.map(e =>
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
).join('\n');
}
case 'network': {
if (args[0] === '--clear') {
networkBuffer.clear();
return 'Network buffer cleared.';
}
// Network capture extensions
if (args[0] === '--capture') {
const {
startCapture, stopCapture, getCaptureListener, isCaptureActive,
} = await import('./network-capture');
if (args[1] === 'stop') {
// Detach listener from current page
const page = bm.getPage();
const listener = getCaptureListener();
if (listener) page.removeListener('response', listener);
const result = stopCapture();
return `Network capture stopped. ${result.count} responses captured (${result.sizeKB}KB).`;
}
// Start capture
if (isCaptureActive()) return 'Capture already active. Use --capture stop first.';
const filterIdx = args.indexOf('--filter');
const filterPattern = filterIdx >= 0 ? args[filterIdx + 1] : undefined;
const info = startCapture(filterPattern);
// Attach listener to current page
const page = bm.getPage();
const listener = getCaptureListener();
if (listener) page.on('response', listener);
return `Network capture started${info.filter ? ` (filter: ${info.filter})` : ''}. Use --capture stop to stop.`;
}
if (args[0] === '--export') {
const { exportCapture } = await import('./network-capture');
const { validateOutputPath: vop } = await import('./path-security');
const exportPath = args[1];
if (!exportPath) throw new Error('Usage: network --export <path>');
vop(exportPath);
const count = exportCapture(exportPath);
return `Exported ${count} captured responses to ${exportPath}`;
}
if (args[0] === '--bodies') {
const { getCaptureBuffer } = await import('./network-capture');
return getCaptureBuffer().summary();
}
// Default: show request metadata
if (networkBuffer.length === 0) return '(no network requests)';
return networkBuffer.toArray().map(e =>
`${e.method} ${e.url}${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
).join('\n');
}
case 'dialog': {
if (args[0] === '--clear') {
dialogBuffer.clear();
return 'Dialog buffer cleared.';
}
if (dialogBuffer.length === 0) return '(no dialogs captured)';
return dialogBuffer.toArray().map(e =>
`[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
).join('\n');
}
case 'is': {
const property = args[0];
const selector = args[1];
if (!property || !selector) throw new Error('Usage: browse is <property> <selector>\nProperties: visible, hidden, enabled, disabled, checked, editable, focused');
const resolved = await session.resolveRef(selector);
let locator;
if ('locator' in resolved) {
locator = resolved.locator;
} else {
locator = target.locator(resolved.selector);
}
switch (property) {
case 'visible': return String(await locator.isVisible());
case 'hidden': return String(await locator.isHidden());
case 'enabled': return String(await locator.isEnabled());
case 'disabled': return String(await locator.isDisabled());
case 'checked': return String(await locator.isChecked());
case 'editable': return String(await locator.isEditable());
case 'focused': {
const isFocused = await locator.evaluate(
(el) => el === document.activeElement
);
return String(isFocused);
}
default:
throw new Error(`Unknown property: ${property}. Use: visible, hidden, enabled, disabled, checked, editable, focused`);
}
}
case 'cookies': {
const cookies = await page.context().cookies();
// Redact cookie values that look like secrets (consistent with storage redaction)
const redacted = cookies.map(c => {
if (SENSITIVE_COOKIE_NAME.test(c.name) || SENSITIVE_COOKIE_VALUE.test(c.value)) {
return { ...c, value: `[REDACTED — ${c.value.length} chars]` };
}
return c;
});
return JSON.stringify(redacted, null, 2);
}
case 'storage': {
if (args[0] === 'set' && args[1]) {
const key = args[1];
const value = args[2] || '';
await target.evaluate(([k, v]: string[]) => localStorage.setItem(k, v), [key, value]);
return `Set localStorage["${key}"]`;
}
const storage = await target.evaluate(() => ({
localStorage: { ...localStorage },
sessionStorage: { ...sessionStorage },
}));
// Redact values that look like secrets (tokens, keys, passwords, JWTs)
const SENSITIVE_KEY = /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf)($|[_.-])|api.?key/i;
const SENSITIVE_VALUE = /^(eyJ|sk-|sk_live_|sk_test_|pk_live_|pk_test_|rk_live_|sk-ant-|ghp_|gho_|github_pat_|xox[bpsa]-|AKIA[A-Z0-9]{16}|AIza|SG\.|Bearer\s|sbp_)/;
const redacted = JSON.parse(JSON.stringify(storage));
for (const storeType of ['localStorage', 'sessionStorage'] as const) {
const store = redacted[storeType];
if (!store) continue;
for (const [key, value] of Object.entries(store)) {
if (typeof value !== 'string') continue;
if (SENSITIVE_KEY.test(key) || SENSITIVE_VALUE.test(value)) {
store[key] = `[REDACTED — ${value.length} chars]`;
}
}
}
return JSON.stringify(redacted, null, 2);
}
case 'perf': {
const timings = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
if (!nav) return 'No navigation timing data available.';
return {
dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
tcp: Math.round(nav.connectEnd - nav.connectStart),
ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0),
ttfb: Math.round(nav.responseStart - nav.requestStart),
download: Math.round(nav.responseEnd - nav.responseStart),
domParse: Math.round(nav.domInteractive - nav.responseEnd),
domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
load: Math.round(nav.loadEventEnd - nav.startTime),
total: Math.round(nav.loadEventEnd - nav.startTime),
};
});
if (typeof timings === 'string') return timings;
return Object.entries(timings)
.map(([k, v]) => `${k.padEnd(12)} ${v}ms`)
.join('\n');
}
case 'inspect': {
// Parse flags
let includeUA = false;
let showHistory = false;
let selector: string | undefined;
for (const arg of args) {
if (arg === '--all') {
includeUA = true;
} else if (arg === '--history') {
showHistory = true;
} else if (!selector) {
selector = arg;
}
}
// --history mode: return modification history
if (showHistory) {
const history = getModificationHistory();
if (history.length === 0) return '(no style modifications)';
return history.map((m, i) =>
`[${i}] ${m.selector} { ${m.property}: ${m.oldValue}${m.newValue} } (${m.source}, ${m.method})`
).join('\n');
}
// If no selector given, check for stored inspector data
if (!selector) {
// Access stored inspector data from the server's in-memory state
// The server stores this when the extension picks an element via POST /inspector/pick
const stored = (bm as any)._inspectorData;
const storedTs = (bm as any)._inspectorTimestamp;
if (stored) {
const stale = storedTs && (Date.now() - storedTs > 60000);
let output = formatInspectorResult(stored, { includeUA });
if (stale) output = '⚠ Data may be stale (>60s old)\n\n' + output;
return output;
}
throw new Error('Usage: browse inspect [selector] [--all] [--history]\nOr pick an element in the Chrome sidebar first.');
}
// Direct inspection by selector
const result = await inspectElement(page, selector, { includeUA });
// Store for later retrieval
(bm as any)._inspectorData = result;
(bm as any)._inspectorTimestamp = Date.now();
return formatInspectorResult(result, { includeUA });
}
case 'media': {
const { extractMedia } = await import('./media-extract');
const target = bm.getActiveFrameOrPage();
const filter = args.includes('--images') ? 'images' as const
: args.includes('--videos') ? 'videos' as const
: args.includes('--audio') ? 'audio' as const
: undefined;
const selectorArg = args.find(a => !a.startsWith('--'));
const result = await extractMedia(target, { selector: selectorArg, filter });
return JSON.stringify(result, null, 2);
}
case 'data': {
const target = bm.getActiveFrameOrPage();
const wantJsonLd = args.includes('--jsonld') || args.length === 0;
const wantOg = args.includes('--og') || args.length === 0;
const wantTwitter = args.includes('--twitter') || args.length === 0;
const wantMeta = args.includes('--meta') || args.length === 0;
const result = await target.evaluate(({ wantJsonLd, wantOg, wantTwitter, wantMeta }) => {
const data: Record<string, any> = {};
if (wantJsonLd) {
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
const jsonLd: any[] = [];
scripts.forEach(s => {
try { jsonLd.push(JSON.parse(s.textContent || '')); } catch {}
});
data.jsonLd = jsonLd;
}
if (wantOg) {
const og: Record<string, string> = {};
document.querySelectorAll('meta[property^="og:"]').forEach(m => {
const prop = m.getAttribute('property')?.replace('og:', '') || '';
og[prop] = m.getAttribute('content') || '';
});
data.openGraph = og;
}
if (wantTwitter) {
const tw: Record<string, string> = {};
document.querySelectorAll('meta[name^="twitter:"]').forEach(m => {
const name = m.getAttribute('name')?.replace('twitter:', '') || '';
tw[name] = m.getAttribute('content') || '';
});
data.twitterCards = tw;
}
if (wantMeta) {
const meta: Record<string, string> = {};
const canonical = document.querySelector('link[rel="canonical"]');
if (canonical) meta.canonical = canonical.getAttribute('href') || '';
const desc = document.querySelector('meta[name="description"]');
if (desc) meta.description = desc.getAttribute('content') || '';
const keywords = document.querySelector('meta[name="keywords"]');
if (keywords) meta.keywords = keywords.getAttribute('content') || '';
const author = document.querySelector('meta[name="author"]');
if (author) meta.author = author.getAttribute('content') || '';
const title = document.querySelector('title');
if (title) meta.title = title.textContent || '';
data.meta = meta;
}
return data;
}, { wantJsonLd, wantOg, wantTwitter, wantMeta });
return JSON.stringify(result, null, 2);
}
default:
throw new Error(`Unknown read command: ${command}`);
}
}