v1.91.11.0 refactor: one state-root rule, browse route table, shared shard engine, PTY harness split, MECE review resolvers (#3002)

* refactor(resolvers): split review.ts into MECE resolver modules (pure move)

Move every function from scripts/resolvers/review.ts, unchanged, into:
- review-dashboard.ts: review dashboard, plan-file review report
- plan-gates.ts: approval check, exit-plan-mode gate, plan-file discovery,
  plan-completion audit/gate (ship + review), plan verification exec
- spec-review.ts: both spec review loops, benefits-from, anti-shortcut clause
- outside-voice-steps.ts: Codex second opinion, adversarial step, Codex plan
  review, Codex doc review, disabled-outside record
- review-scope.ts: scope drift, cross-review dedup, shared-code reuse

review.ts is deleted; index.ts imports the new modules. gen-skill-docs
output is byte-identical for every host (--host all). Test imports and
source-path references are re-pointed; the two source-text report/gate
tests in gen-skill-docs.test.ts become behavioral renders across every
consuming skill and host. All 46 touchfile entries that named review.ts
now name all five modules, guarded by a recorded selection golden.

* test(browse): black-box auth matrix for every server route and both surfaces

Drives buildFetchHandler fetchLocal/fetchTunnel with no token, wrong token,
root token, scoped token and the SSE cookie for all 33 routes, plus unmatched
paths and wrong methods. Denials assert today's exact status, body and content
type; allowed credentials assert the handler was reached. Written against the
unchanged if-chain server so the W3 route-table refactor must keep it green.

* refactor(shard-engine): move scripts/test-strict-output.ts to scripts/lib/shard-engine.ts

The shared shard engine grows from the existing strict-output module
(runShardChild, killProcessGroup, signal forwarding, strict classifier).
scripts/test-strict-output.ts stays as a re-export so existing importers,
mock.module paths and the strict-output/run-shard-child tests are unchanged.
The engine inherits the global touchfile entry; the free runner's CLI-routing
fixture copies the new module.

* refactor(resolvers): decompose the three >150-line review resolvers (output-neutral)

Split generateAdversarialStep, generateCodexPlanReview and
generatePlanCompletionAuditInner into per-section helpers whose template
literals are copied verbatim, so every function in the new modules is at
or under 150 lines. gen-skill-docs output is byte-identical for every host
(--host all, compared against 96764e80 with a fixed --link-root).

* refactor(resolvers): one outside-voice failure policy (deliberate prose unification)

outsideVoiceFailurePolicy(ctx, opts) in outside-voice.ts now renders the
auth / timeout / empty-response bullets for all four call sites that
hand-typed them (Codex second opinion, adversarial step, Codex plan
review, design outside voices). Options are explicit per site
(timeoutMinutes, onTimeout, stderrOnEmpty, fallback, escape) with no
defaults.

Deliberate generated-prose changes (every host):
- office-hours: 'Fall back to <native> subagent.' becomes
  'Fall back to the <native> subagent below.'
- plan-devex-review: the plain 'Auth failure (stderr contains ...)'
  bullets become the canonical bold bullets; auth also triggers on
  'API key'; 'auth failed' becomes 'authentication failed'.
- review/ship adversarial: 'exceeded 9 minutes and was terminated'
  becomes 'timed out after 9 minutes and was terminated'; the timeout
  is still MISSING COVERAGE.
- design outside voices: unchanged.

Adds ratchet (d) (test/outside-voice-failure-policy.test.ts) with a
reasoned allowlist for /codex's own CLI errors, the MISSING COVERAGE
retention test, refreshed codex/factory ship goldens, and outside-voice.ts
in every touchfile entry of review.ts and design.ts (selection golden
extended).

* test(pty): fake PTY session driver with an injectable clock through the runner launch seam

The three plan-skill runners take an optional PtyDriver (launch, now,
monotonic, sleep); omitted, they use the real launcher and clocks exactly as
before. test/helpers/pty/fake-session.ts feeds scripted frames through that
seam, and claude-pty-runner.runners.unit.test.ts runs observation, counting
and floor for success, deadline timeout, permission prompt and plan-ready
outcomes with no CLI or real timers. These cases must stay green unchanged
through the W4 split and the runPtySession extraction.

Touchfiles: every entry that lists claude-pty-runner.ts or pty-screen.ts now
also lists test/helpers/pty/**.

* refactor(shard-engine): run both lanes on the shared engine; lane policy injected

Engine (scripts/lib/shard-engine.ts) gains the W2 primitives: per-shard
tmp/Chromium sandbox + async cleanup backstop, log-path allocation and
full-stream log capture, one duration-seed reader/writer with a lane
predicate, LanePolicy (seed predicate + zero-execution verdict),
strictShardStatus, and the shared CLI flag loop. runShardChild takes an
optional companion (signal/settle) and waits a bounded 250ms to reap a
wall-killed child.

Free lane stops spawning shards itself: runFreeShard uses runShardChild
with trackShardBrowser as the companion (win32 path unchanged: no process
group, no negative-pid kill). Its sync state-dir removal stays lane policy.
Paid lane uses the sandbox, log, seed, verdict and flag primitives; the
hollow-shard guard applies PAID_LANE_POLICY. Lane outcomes are unchanged
(free keeps >= 0 seeds and file-count zero-exec rule; paid keeps > 0 seeds,
warning under selection and passed-empty under EVALS_ALL).

paid-free-boundary's closure assertion now names the engine module, where
the strict classifier lives.

* test(shard-engine): engine unit tests, fixture-corpus equivalence, per-lane CLI parity

- test/shard-engine.test.ts: failing/unhandled/module-load output fails both
  lanes, per-lane zero-execution and seed rules, whole-group kill on a wall
  timeout (both lanes), mocked-win32 path with no negative-pid kill,
  companion settle order, log capture, sandbox isolation, flag loop.
- test/shard-engine-equivalence.test.ts + test/fixtures/shard-equivalence:
  seven outcome fixtures plus one real shard, run through both lanes and
  compared with classifications recorded from the base runners (96764e80).
- test/shard-cli-parity.test.ts + test/fixtures/shard-cli-parity: flag set,
  defaults, validation errors and the Unknown argument error per lane match
  the base runners.

* refactor(shard-engine): decompose runFreeShard and runPaidShard to <= 150 lines

Output-neutral extraction under the fixture-corpus equivalence and runner
tests: captureFreeStream, explainFreeVerdict and logFreeRecovery (free);
paidShardCommand, settleShardSpool, settleBootstrapRetention and
printLogTail (paid). The bootstrap scope-creation block that
bootstrap-retention.test.ts evaluates stays verbatim.

* refactor(pty): split claude-pty-runner.ts into test/helpers/pty/* behind a barrel

Pure move: every line of the former 5,047-line runner lands verbatim in one
module (four private helpers gain `export` for cross-module use):
binary, screen (absorbs test/helpers/pty-screen.ts, which now re-exports it),
launch, session (PtyDriver), judge, classify, auq, plan-native, boundaries,
runners/{observation,counting,floor}. claude-pty-runner.ts re-exports the
original public surface by name; pty/ modules import siblings directly.

Tests that read the runner's source text:
- rewritten as behavioral: the unit test's model-pin tripwire (fake CLI argv:
  fallback chain, --model before extraArgs, hermetic --strict-mcp-config),
  pty-skill-seeding-wiring (runners through the fake driver; launcher through
  a fake CLI reporting CLAUDE_CONFIG_DIR). The "three wrappers forward model"
  grep is replaced by the runners' fake-driver launch assertions.
- pty-screen-session / pty-screen-supervision: stop copying runner source;
  they mock.module the real pty/screen.ts (and the fixture cleanup) instead.
- re-pointed to the owning module (they execute a sliced runner body with
  injected boundaries; no seam exists for those boundaries yet):
  eng-seeded-completion-ai, plan-floor-permission, plan-create-prepublication,
  plan-count-completion; hermetic-wiring's source guard now reads pty/launch.ts
  and scans every pty/ module for raw process.env spreads.
- plan-count-timeout and pty-output-wake mock the viewport at pty/screen.ts.

* test(ratchet-c): enforcing module/function size ratchet and moved-code touchfile coverage

Ratchet (c) ships enforcing: test/helpers/module-size.ts counts file and
top-level function lengths by brace matching over masked source (strings,
comments, regex literals and template text masked; ${} expressions kept),
covering function declarations, arrow functions assigned to consts and
route-table handler properties, with no parser dependency. Its self-test
uses template literals and code-fence braces copied from
scripts/resolvers/review.ts and design.ts. test/fixtures/module-size-ratchet.json
binds scripts/lib/shard-engine.ts (<= 800 lines, <= 150 per function) and
records the residual runner sizes (free 2352, paid 1921) as non-growth caps;
allowlist entries are keyed on file plus matched text and need a reason.
Failure output lists file:line, the rule, Fix: and the allowlist path.

touchfiles.test.ts gains the moved-code superset check over
test/fixtures/touchfile-move-goldens/ (W2 golden recorded at 96764e80:
test-strict-output.ts and test-paid-shards.ts global, test-free-shards.ts none).

* refactor(browse): declared route table replaces the buildFetchHandler if-chain

The ~1,300-line if-chain in buildFetchHandler becomes a route table:
each entry declares method, path, auth kind and surfaces, and one auth
gate in browse/src/routes/table.ts returns the per-kind denial (root-bearer,
scoped, root-or-sse-cookie: 401 Unauthorized; root-token: 403 Root token
required; extension-origin: 403 Forbidden). Unmatched requests take the
declared fallthrough (root-bearer check, then plain-text 404). Handlers move
to browse/src/routes/{core,pairing,pty,tokens,tunnel,activity,commands,files,
inspector}.ts and receive a RouteContext with auth checks as functions
instead of closing over factory locals. Dispatch order is unchanged:
tunnel filter, beforeRoute overlay, gate, handler. TUNNEL_PATHS stays a
literal in server.ts.

Behavior-preserving: the black-box auth matrix from the previous commit
passes unchanged. /memory and /inspector/events are declared root-bearer
because the blanket check always ran before their SSE-cookie branch.

Source-text route tests are rewritten as behavioral tests through
buildFetchHandler or a route's real handler with a stub RouteContext
(browse/test/route-test-harness.ts). Checks with no runtime seam are
re-pointed to the route modules: Surface type, /inspector/events SSE
helper, sanitizeReplacer imports, /pty-inject-scan sidecar-client import,
and the ngrok config lookup and startTunnel wiring that stay in server.ts.

* test(browse): stubbed-handler auth matrix and route inventory for the route table

Every ROUTES entry runs through the real dispatcher and gate with stub
handlers on each declared surface and six credentials; denials assert the
exact status and body each auth kind returned at 96764e8, admitted
credentials assert the handler ran (with the gate's TokenInfo for scoped
routes). Also pins the reviewed route inventory (method, path, auth kind,
surfaces), that every entry declares auth and surfaces, that the table's
tunnel paths equal the TUNNEL_PATHS literal with GET /connect admitted, the
unmatched fallthrough, and that the root token is rejected on every tunnel
route through buildFetchHandler.

* test(browse): ratchet (b) keeps route dispatch inside the route table

Scans browse/src/server.ts and browse/src/routes/*.ts for pathname
comparisons; only the table matcher and the tunnel-surface filter are
allowed, listed with reasons in browse/test/fixtures/route-dispatch-allowlist.json
(keyed on file plus line text). Also checks every entry declares auth and
surfaces and that gstack registers no beforeRoute overlay itself. Self-tests
plant a violation and assert the file:line, Fix: and allowlist path in the
message, that a shifted line stays allowlisted, and that a reasonless entry
is rejected.

* test: touchfile superset check for modules moved out of browse/src/server.ts

Records the paid evals selected by touching browse/src/server.ts at 96764e80
(17 E2E, 1 LLM judge) and asserts every browse/src/routes/*.ts module selects
a superset. The test reads every golden in test/fixtures/moved-module-selection/
so other moved-code goldens can sit beside it.

* test(shard-engine): give non-timeout corpus fixtures CI headroom; keep the POSIX golden off the Windows lane

Only the wall-timeout fixture keeps a 3s wall; the rest get 60s so a loaded
host cannot turn a pass into a timeout. Base and branch runners still agree
on every classification under the new walls. The Windows exclusion entry
moves the free runner's ratchet (c) residual cap to 2356 lines.

* refactor(pty): one runPtySession loop drives observation, counting and floor

test/helpers/pty/session.ts owns launch -> start -> (poll -> tick)* ->
timeout and the failure contract the three runners each hand-rolled: the
run's own error wins over capture and close errors, close always runs, owned
fixture cleanup runs last (also when launch fails). Each runner now supplies a
PtySessionPlan: its boot/command step, poll cadence (2s observation/floor
sleep; counting's output wake + 250ms coalesce), tick policy (permission
handling, native identity, terminal rules stay per runner because they differ)
and capture hooks. The runner bodies are decomposed into top-level steps so no
function exceeds 150 lines; behavior is unchanged and the fake-driver cases
from the first W4 commit pass unmodified.

The counting capture step and the native completion-summary predicate are now
named functions (countingCapture, isNativeCompletionSummary), so
plan-create-prepublication and plan-count-completion call them directly
instead of executing sliced source. The two harnesses that still execute a
sliced runner body with injected boundaries (eng-seeded-completion-ai,
plan-floor-permission) pass the PtyDriver seam instead of overriding
Date/Bun.sleep.

* test(ratchet-c): register route modules, review resolver modules and server.ts residual cap

* refactor(pty): decompose launchClaudePty and engNumberedFindingAUQ under 150 lines

launchClaudePty (349 lines) becomes launch preparation (args, hermetic
child env, owned state roots), recorder creation, spawn, the trust-dialog
watcher, close, and the session handle over one PtyProcess state object. The
failure order is unchanged: abort the viewport, dispose any recorders created
so far, dispose the viewport, rethrow. The --model / --strict-mcp-config
ordering and seedSkills wiring stay pinned by the behavioral fake-CLI tests.

engNumberedFindingAUQ (345 lines) keeps its guards and dispatch; each
self-contained issue family (declared cache, library retry hooks, cache
owner, injected singleton, shared writers, injected export) moves verbatim
into its own function. Every pty/ module is now <= 800 lines and every
top-level function <= 150 lines.

* test(pty): split claude-pty-runner.unit.test.ts along the pty/ module seams

The 188 unit tests move verbatim into claude-pty-runner.{screen,classify,
auq,launch,plan-native,boundaries}.unit.test.ts (test names unchanged; each
file imports only what it uses from the barrel). The five files that no longer
read a SKILL.md template join the test-of-test ratchet baseline with a reason.

* test(touchfiles): moved PTY modules keep their paid-eval selection

test/fixtures/touchfile-selection/w4-pty.json records, at 96764e8, the paid
evals selected by touching test/helpers/claude-pty-runner.ts (20) and
test/helpers/pty-screen.ts (20). touchfiles.test.ts now asserts every .ts file
under test/helpers/pty/ (and pty/screen.ts for both sources) selects a
superset, reading every golden in that directory so later moves can add one;
a planted-violation case pins the report and its Fix line.

* fix(browse): unexchanged pair setup keys no longer authenticate bearer requests

validateToken accepted a gsk_setup_ key as a bearer on /command, /batch and
/file (found while building the W3 auth matrix). A setup key now only
authenticates the /connect exchange.

* W1: one state-root owner (lib/state-root.ts + bin/gstack-state-root.sh), gstack-paths --explain and fail-stop, parity tests

* W1: guarded migration of every executable state-root site; uninstall deletes only ~/.gstack

Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design,
ios-qa daemon, lib and scripts resolve the state root through
bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the
twin and stop with a reinstall message when it is missing; hooks source it
and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts
delegate to resolveStateRoot. Analytics writers and readers move together
so the usage log stays one file. gstack-uninstall deletes state only at
~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor,
the checkout or the git root, and leaves any other resolved root in place
with the removal command. Fixtures that copy single bins now copy the twin.

* W1: privacy keys and trust-policy deny tiers merge across state roots; gstack-config reporting; test hermeticity

readConfigKey / gstack_read_config_key return the most restrictive
telemetry, memorable_recall, codex_reviews and update_check across the
resolved root and ~/.gstack; other keys read the resolved root only.
gstack-config set reports an overriding root with the exact override
command, list shows the winning root and a root-variable disagreement line.
gstack-gbrain-repo-policy get merges deny/read-only tiers. gstack-egress
reads through readConfigKey. test-setup.ts strips inherited
GSTACK_STATE_ROOT/GSTACK_STATE_DIR and redirects the legacy root.

* W1: shared hook logging helper (hosts/claude/hooks/hook-log.ts)

One hook-errors.log writer: root from resolveStateRoot, 0600 on every
append, opt-in rate limit used only by memorable-user-prompt. The five
hooks route through it.

* W1: docs/state-root.md and README troubleshooting pointer

Precedence table, a real --explain example, the move-your-state recipe,
merged privacy keys, the uninstall rule, the resolver-failure fix, and the
plugin-mode note (evidence gate: no official plugin distribution).

* W1b: template and resolver prose resolve state through guarded gstack-paths; ratchet (a)

Every gstack-paths eval in templates and resolvers carries the fail-stop
guard; executable ~/.gstack paths in bash blocks (context recovery preamble,
eureka log, analytics, project artifacts, upgrade snooze, setup-gbrain lock,
retro snapshots, ship consent marker) use $GSTACK_STATE_ROOT, and the writer
prose that pairs with them points at the printed PROJECT_DIR / RETRO_FILE.
ship drops export GSTACK_STATE_ROOT. SKILL.md regenerated (claude + codex),
ship goldens re-pinned, parity and context-budget caps raised to the measured
sizes with notes. test/state-root-ratchet.test.ts enforces the rule with a
reasoned allowlist; W1 touchfile entries plus a superset golden.

* refactor: apply W1 state-root edits in W2/W3/W5-owned files; one moved-code touchfile golden for all workstreams

* test: fold the moved-code touchfile golden into touchfiles.test.ts; fix integration fixture closure and caps

* v1.91.11.0: CHANGELOG, TODOS, docs and conventions for the refactor wave

* test: re-measure plan-ceo/design-consultation caps and ship goldens after the guarded plan-discovery and spec-review blocks; add the state-root twin to the workflow-boundaries fixture

* fix(windows): migrations resolve their directory with either path separator; state-root parity compares under the HOME Git Bash actually sees

* fix(review,ship): state plan-check timing after smoke expiry and test_stub Skip semantics (review workflow judge clarity)

* test(qa-eval): webhook fix eval asks for the fix loop's post-repair probes; eight-scenario coverage stays in the report-only case and the harness recheck

* test(qa-eval): re-pin the webhook prompt contract to the fix-loop stage; R29 coverage omissions stay bound by the report-only case

* fix(review,ship): plan checks publish a checkpoint before each probe; only the smoke expiry stop is skipped

* fix(qa): carry #2999's checkpoint receipt link, report-template line and full-revision placeholder (identical hunks)

* test(qa-callers): disable git auto maintenance in the caller fixture

Git 2.47+ runs auto maintenance detached after commit; on the CI runner's git
2.55 it rewrote .git/objects fan-out directories while the write observer was
running, which surfaced as unauthorized mutations. Same gc.auto=0 /
maintenance.auto=false guard the shared-libs fixture already uses.

* test(plan-mode-no-op): require prose evidence for the prose-fallback members so a spinner-frame judge verdict cannot end the run as asked

* test(ship-docsync): carry #2999's seeded-attempt docsync harness (identical files)

The doc-sync fault cases replayed attempt 1 before reaching their gate and ran
out of their 285s budget. The fixture now seeds attempt 1 and the parent starts
at the gate under test. Taken byte-identical from origin/capy/audit-fix-wave
(fb526898, e6ac813d, 6ce10ff7, d0c53577, 77cce3be). Local: stale-before,
recovery and late-result 6/6 PASS (97-164s); the whole file 12/12 PASS.
This commit is contained in:
Garry Tan authored and GitHub committed 2026-10-01 11:57:48 -07:00
1 parent 96764e80a6
commit df89475b17
383 files changed
+19348 -12625

No files matched your search

+5 -1
View File
@@ -270,5 +270,9 @@ bun run skill:check # health dashboard for all skills
- Run `bun run gen:skill-docs --host codex` to regenerate Codex-specific output. - Run `bun run gen:skill-docs --host codex` to regenerate Codex-specific output.
- Browser steps in skills are `aside repl` scripts per `scripts/resolvers/aside.ts` (Aside first), each with a `$B` equivalent for the fallback engine — `$B <command>` is the browse binary and is a legitimate tool when the Aside probe does not print `READY`. Local HTML renders through `bin/gstack-render.ts`, which picks the same way. - Browser steps in skills are `aside repl` scripts per `scripts/resolvers/aside.ts` (Aside first), each with a `$B` equivalent for the fallback engine — `$B <command>` is the browse binary and is a legitimate tool when the Aside probe does not print `READY`. Local HTML renders through `bin/gstack-render.ts`, which picks the same way.
- Safety skills (careful, freeze, guard) use inline advisory prose — always confirm before destructive operations. - Safety skills (careful, freeze, guard) use inline advisory prose — always confirm before destructive operations.
- State paths resolve via `bin/gstack-paths` (sourced via `eval "$(...)"`). Honors `GSTACK_HOME`, `CLAUDE_PLUGIN_DATA`, `CLAUDE_PLANS_DIR`. - State paths resolve through one chain owned by `lib/state-root.ts` and its sourced bash twin `bin/gstack-state-root.sh` (`GSTACK_STATE_ROOT` → `GSTACK_HOME` → `GSTACK_STATE_DIR` → gstack's `CLAUDE_PLUGIN_DATA` → `~/.gstack`; see docs/state-root.md). Skill prose uses `eval "$(bin/gstack-paths)"` with its `${GSTACK_STATE_ROOT:?…}` guard; `test/state-root-ratchet.test.ts` rejects hand-rolled chains.
- Browse daemon HTTP routes are entries in `browse/src/routes/table.ts` (its header shows how to add one); never dispatch on `url.pathname` in `server.ts`.
- Both test lanes run shards through `scripts/lib/shard-engine.ts`; the free and paid runners hold lane policy only. PTY harness code lives in `test/helpers/pty/*` behind the `claude-pty-runner.ts` barrel.
- Outside-voice failure prose (auth, timeout, empty, fallback) comes only from `outsideVoiceFailurePolicy()` in `scripts/resolvers/outside-voice.ts`.
- `test/module-size-ratchet.test.ts` keeps refactored owner modules at or under 800 lines (150 per function) and residual files from growing.
- The `claude` CLI binary resolves via `lib/claude-bin.ts` (re-exported from `browse/src/claude-bin.ts` for browse internals; `Bun.which()` + `GSTACK_CLAUDE_BIN` override). Set `GSTACK_CLAUDE_BIN=wsl` plus `GSTACK_CLAUDE_BIN_ARGS='["claude"]'` to run Claude through WSL on Windows. - The `claude` CLI binary resolves via `lib/claude-bin.ts` (re-exported from `browse/src/claude-bin.ts` for browse internals; `Bun.which()` + `GSTACK_CLAUDE_BIN` override). Set `GSTACK_CLAUDE_BIN=wsl` plus `GSTACK_CLAUDE_BIN_ARGS='["claude"]'` to run Claude through WSL on Windows.
+7 -7
View File
@@ -67,7 +67,7 @@ Node.js would work. Bun is better here for three reasons:
3. **Native TypeScript.** The server runs as `bun run server.ts` during development. No compilation step, no `ts-node`, no source maps to debug. The compiled binary is for deployment; source files are for development. 3. **Native TypeScript.** The server runs as `bun run server.ts` during development. No compilation step, no `ts-node`, no source maps to debug. The compiled binary is for deployment; source files are for development.
4. **Built-in HTTP server.** `Bun.serve()` is fast, simple, and doesn't need Express or Fastify. The server handles ~10 routes total. A framework would be overhead. 4. **Built-in HTTP server.** `Bun.serve()` is fast, simple, and doesn't need Express or Fastify. The server handles ~30 routes, declared in one route table (`browse/src/routes/table.ts`; its header shows how to add one). A framework would be overhead.
The bottleneck is always Chromium, not the CLI or server. Bun's startup speed (~1ms for the compiled binary vs ~100ms for Node) is nice but not the reason we chose it. The compiled binary and native SQLite are. The bottleneck is always Chromium, not the CLI or server. Bun's startup speed (~1ms for the compiled binary vs ~100ms for Node) is nice but not the reason we chose it. The compiled binary and native SQLite are.
@@ -215,14 +215,14 @@ Page content harvested by CDP can contain lone UTF-16 surrogate halves (orphaned
| Egress path | Module | Sanitization point | | Egress path | Module | Sanitization point |
|---|---|---| |---|---|---|
| `POST /command` (HTTP) | `browse/src/server.ts` | `handleCommandInternal` wrapper (sanitizes the result of `handleCommandInternalImpl`) | | `POST /command` (HTTP) | `browse/src/routes/commands.ts` (wrapper in `browse/src/server.ts`) | `handleCommandInternal` wrapper (sanitizes the result of `handleCommandInternalImpl`) |
| `POST /command/batch` | `browse/src/server.ts` | Same wrapper — batch consumers inherit it | | `POST /batch` | `browse/src/routes/commands.ts` | Same wrapper — batch consumers inherit it |
| `GET /activity/stream` (SSE) | `browse/src/server.ts` | `sanitizeReplacer` passed to `JSON.stringify` | | `GET /activity/stream` (SSE) | `browse/src/routes/activity.ts` | `sanitizeReplacer` applied inside `createSseEndpoint` |
| `GET /inspector/events` (SSE) | `browse/src/server.ts` | `sanitizeReplacer` passed to `JSON.stringify` | | `GET /inspector/events` (SSE) | `browse/src/routes/inspector.ts` | `sanitizeReplacer` applied inside `createSseEndpoint` |
`sanitizeReplacer` is a `JSON.stringify` replacer function that cleans every string value during encoding. Post-stringify regex doesn't work here — `JSON.stringify` has already converted `\uD800` into the literal escape sequence `"\\ud800"` before the regex could match, so the replacer must run inside the encoding pipeline. The pure-string helper `sanitizeLoneSurrogates` is used directly for `text/plain` responses. `sanitizeReplacer` is a `JSON.stringify` replacer function that cleans every string value during encoding. Post-stringify regex doesn't work here — `JSON.stringify` has already converted `\uD800` into the literal escape sequence `"\\ud800"` before the regex could match, so the replacer must run inside the encoding pipeline. The pure-string helper `sanitizeLoneSurrogates` is used directly for `text/plain` responses.
**Architectural invariant.** Every new SSE/WebSocket writer or HTTP response that ships page-content-derived strings MUST go through one of two paths: `JSON.stringify(payload, sanitizeReplacer)` for object payloads, or `sanitizeLoneSurrogates(body)` for text bodies. New surfaces that bypass both will desync the system. Inline comments at both SSE producers in `server.ts` say so; `browse/test/server-sanitize-surrogates.test.ts` pins wiring with bug-repro + invariant tests (`handleCommandInternalImpl` rename, central sanitization line, replacer existence, SSE producers stringify with replacer). **Architectural invariant.** Every new SSE/WebSocket writer or HTTP response that ships page-content-derived strings MUST go through one of two paths: `JSON.stringify(payload, sanitizeReplacer)` for object payloads, or `sanitizeLoneSurrogates(body)` for text bodies. New surfaces that bypass both will desync the system. Inline comments at both SSE producers (`routes/activity.ts`, `routes/inspector.ts`) say so; `browse/test/server-sanitize-surrogates.test.ts` pins wiring with bug-repro + invariant tests (`handleCommandInternalImpl` rename, central sanitization line, replacer existence, SSE producers stringify with replacer).
### Prompt injection defense (sidebar agent) ### Prompt injection defense (sidebar agent)
@@ -350,7 +350,7 @@ Templates contain the workflows, tips, and examples that require human judgment.
| `{{TEST_VALUE_BAR:<mode>}}` | `resolvers/test-value.ts` | Shared test value bar (authoring gate, value card, X/Y coverage, red-first proof, low-value catalog) for /qa and /qa-only (`qa`) and /test-audit (`audit`); /plan-eng-review and /ship embed it through the coverage audit | | `{{TEST_VALUE_BAR:<mode>}}` | `resolvers/test-value.ts` | Shared test value bar (authoring gate, value card, X/Y coverage, red-first proof, low-value catalog) for /qa and /qa-only (`qa`) and /test-audit (`audit`); /plan-eng-review and /ship embed it through the coverage audit |
| `{{TEST_VALUE_MESSAGE:<key>}}` | `resolvers/test-value.ts` | One degraded-mode message (problem, consequence, fix, docs anchor) from the shared constants | | `{{TEST_VALUE_MESSAGE:<key>}}` | `resolvers/test-value.ts` | One degraded-mode message (problem, consequence, fix, docs anchor) from the shared constants |
| `{{TEST_BOOTSTRAP}}` | `resolvers/testing.ts` | Test framework detection, bootstrap, CI/CD setup for /ship and /design-review | | `{{TEST_BOOTSTRAP}}` | `resolvers/testing.ts` | Test framework detection, bootstrap, CI/CD setup for /ship and /design-review |
| `{{CODEX_PLAN_REVIEW}}` | `resolvers/review.ts` | Optional outside plan review for /plan-ceo-review and /plan-eng-review: Claude Code on Codex, Codex on other supported harnesses, with the caller's native subagent fallback | | `{{CODEX_PLAN_REVIEW}}` | `resolvers/outside-voice-steps.ts` | Optional outside plan review for /plan-ceo-review and /plan-eng-review: Claude Code on Codex, Codex on other supported harnesses, with the caller's native subagent fallback |
| `{{DESIGN_SETUP}}` | `resolvers/design.ts` | Discovery pattern for `$D` design binary, mirrors `{{BROWSE_SETUP}}` | | `{{DESIGN_SETUP}}` | `resolvers/design.ts` | Discovery pattern for `$D` design binary, mirrors `{{BROWSE_SETUP}}` |
| `{{DESIGN_DETECTOR}}` | `resolvers/design.ts` | Probe block + sentinel reading for the user-installed impeccable engine (`bin/gstack-design-detect.ts`); `:phase0` renders design-review's mechanical scan, `:gate` design-html's bounded slop gate | | `{{DESIGN_DETECTOR}}` | `resolvers/design.ts` | Probe block + sentinel reading for the user-installed impeccable engine (`bin/gstack-design-detect.ts`); `:phase0` renders design-review's mechanical scan, `:gate` design-html's bounded slop gate |
| `{{DESIGN_MD_CHECK}}` | `resolvers/design.ts` | Open DESIGN.md format check through `bin/gstack-design-md.ts`, with the one-time conversion offer persisted in the file; `:calibrate` renders the tokens-as-calibration form for /design-review | | `{{DESIGN_MD_CHECK}}` | `resolvers/design.ts` | Open DESIGN.md format check through `bin/gstack-design-md.ts`, with the one-time conversion offer persisted in the file; `:calibrate` renders the tokens-as-calibration form for /design-review |
+37
View File
@@ -1,5 +1,42 @@
# Changelog # Changelog
## [1.91.11.0] - 2026-09-30
gstack now looks up its state folder one way everywhere, and the five most copy-pasted or oversized parts of the codebase each have a single owner. Before, about 50 scripts, hooks and libraries each resolved the state folder with their own rule, and the rules disagreed. If you set `GSTACK_HOME`, `GSTACK_STATE_DIR` or `GSTACK_STATE_ROOT`, telemetry, analytics, update-check snoozes, the egress ledger and hook logs now all land in the folder you chose. Nothing is moved for you. Run `~/.claude/skills/gstack/bin/gstack-paths --explain` to see the active folder and whether `~/.gstack` still holds older state; [docs/state-root.md](docs/state-root.md) has the move recipe.
| Hotspot | Before | After |
| --- | ---: | ---: |
| Code files with a hand-rolled `${GSTACK_*:-…}` chain | 48 | 2 (the bash owner and one allowlisted partial-upgrade fallback) |
| `browse/src/server.ts` lines (`buildFetchHandler` alone) | 3,464 (1,560) | 2,224 (~350) |
| `test/helpers/claude-pty-runner.ts` lines | 5,047 | 30 (barrel over `test/helpers/pty/*`) |
| `scripts/resolvers/review.ts` lines | 1,921 | removed (5 modules, largest 771) |
| Shard spawn/kill/sandbox implementations | 2 | 1 (`scripts/lib/shard-engine.ts`) |
### Changed
- **One state-root rule.** Every script, hook and skill resolves state as `GSTACK_STATE_ROOT` → `GSTACK_HOME` → `GSTACK_STATE_DIR` → `CLAUDE_PLUGIN_DATA` (only for the gstack plugin) → `~/.gstack`. Skill bash blocks stop with a reinstall message if the resolver is missing, instead of writing under `/`.
- **Privacy opt-outs never get looser.** `telemetry`, `memorable_recall`, `codex_reviews`, `update_check` and trust-policy denies take the most restrictive value across the active folder and `~/.gstack`. `gstack-config set` says when another folder still overrides you and prints the command that fixes it, and `gstack-config list` shows which folder each merged key came from.
- **Uninstall deletes state only at `~/.gstack`.** `gstack-uninstall` refuses (exit 2) when that path resolves to `/`, your home, the gstack checkout or the current repository. For a relocated folder it leaves the folder in place and prints the exact removal command.
- **Outside-voice fallbacks read the same in every skill.** `/plan-devex-review` now also treats an "API key" error as an authentication failure, `/office-hours` names its fallback subagent like the other skills, and the `/review` and `/ship` adversarial pass says "timed out after 9 minutes" (a timed-out pass is still missing coverage).
- `/review` and `/ship` exploratory QA now say how required plan checks behave once the 5-minute smoke clock expires: they and their revalidation keep running on a per-command `--timeout-ms` and still publish checkpoints, while a smoke recheck after expiry is reported not-run. In `/review`, skipping a finding that carries a proposed test skips both the test and the fix, and the defect stays unresolved.
- After a revert of this release, state written to a non-default folder while it was live stays in that folder.
### Fixed
- A pair-agent setup key that had not been exchanged yet was accepted as a bearer token on the browse daemon's `/command`, `/batch` and `/file`. A setup key now authenticates only the `/connect` exchange.
### For contributors
- **State root:** `lib/state-root.ts` (`resolveStateRoot`, `readConfigKey`) and its bash twin `bin/gstack-state-root.sh` (builtins only) own the chain. A parity table runs every row through both with `PATH` empty, Windows rows included. `test/state-root-ratchet.test.ts` rejects new hand-rolled chains, and `test-setup.ts` strips inherited `GSTACK_STATE_ROOT` / `GSTACK_STATE_DIR` so ambient variables cannot leak into tests. `hosts/claude/hooks/hook-log.ts` is the five hooks' one error-log writer (0600).
- **Browse routes:** the daemon's HTTP routes are one declared table (`browse/src/routes/table.ts`: method, path, auth kind, listener surface), with one auth gate, one denial per auth kind, and handlers in `browse/src/routes/*.ts`. A black-box matrix over every route, both listeners and five credential types passed on the old server and passes unchanged on the new one. The route tests now send real requests instead of grepping `server.ts`, and `browse/test/server-route-dispatch-ratchet.test.ts` keeps dispatch inside the table.
- **Shard engine:** `scripts/test-strict-output.ts` grew into `scripts/lib/shard-engine.ts` (process-group spawn, wall timeout and group kill, strict Bun verdicts, per-shard tmp and Chromium sandbox, logs, duration seeds, flag loop), and both runners use it. Lane policy stays per lane. A seven-outcome fixture corpus recorded from the old runners pins identical verdicts, and paid `--list` output is byte-identical. A timed-out free shard now stops reading at its deadline, as the paid lane already did.
- **PTY harness:** `test/helpers/claude-pty-runner.ts` is a barrel over `test/helpers/pty/*` (screen, launch, classify, auq, plan-native, boundaries, judge). One `runPtySession` loop drives the observation, counting and floor runners. A scripted fake PTY driver (`pty/fake-session.ts`) with an injectable clock runs each runner deterministically, and the unit test is split along the same modules. Tests keep importing the barrel.
- **Review resolvers:** `scripts/resolvers/review.ts` is split into `review-dashboard.ts`, `plan-gates.ts`, `spec-review.ts`, `outside-voice-steps.ts` and `review-scope.ts`. Generated output is byte-identical except the fallback wording above, which now comes from `outsideVoiceFailurePolicy()` in `outside-voice.ts`; `test/outside-voice-failure-policy.test.ts` rejects hand-written copies.
- **Ratchets:** `test/module-size-ratchet.test.ts` keeps the new owner modules at or under 800 lines and 150 lines per function, and stops the residual files (`server.ts`, both runners) from growing. `test/touchfiles.test.ts` checks that every moved module still selects the paid evals its source file selected (goldens in `test/fixtures/touchfile-moved-code/`, recorded before the move).
- **Budgets:** the guarded `gstack-paths` line in always-loaded preambles moved a few budgets to their measured values, each with its derivation recorded: carve-guards for ship (1.397 → 1.404), plan-ceo-review skeleton (80,150 → 80,850 bytes), plan-eng-review (1.169 → 1.174), design-consultation (1.08 → 1.085) and qa (1.095 → 1.102), and the `unfreeze` eager ceiling (393 → 448 tokens).
- **Webhook fix eval:** `qa-functional-webhook-fix` no longer asks the model to rerun all eight webhook scenarios after its repair; the harness already reruns all eight on the repaired source, and the report-only webhook eval still requires eight-scenario coverage. The fix eval now asks for the same post-repair probes as the CLI fix eval, which brings a passing run from about 244s to 133-214s of its 285s budget (3/3 local passes).
- **Deferred:** `TODOS.md` "P3: next refactor wave" lists the hotspots this wave did not touch and the behavior bugs it found and left alone.
## [1.91.9.0] - 2026-09-29 ## [1.91.9.0] - 2026-09-29
Every gstack workflow that proposes, writes, reviews or ships tests now applies one test value bar: a test earns its place by protecting behavior a real regression would break, and test count is not a goal. `/ship`'s coverage gate counts only tests that clear that bar, and the new `/test-audit` sweeps existing tests for ones that cost more than they protect. Every gstack workflow that proposes, writes, reviews or ships tests now applies one test value bar: a test earns its place by protecting behavior a real regression would break, and test count is not a goal. `/ship`'s coverage gate counts only tests that clear that bar, and the new `/test-audit` sweeps existing tests for ones that cost more than they protect.
+2
View File
@@ -621,6 +621,8 @@ Data is stored in [Supabase](https://supabase.com) (open source Firebase alterna
**Stale install?** Run `/gstack-upgrade` — or set `auto_upgrade: true` in `~/.gstack/config.yaml` **Stale install?** Run `/gstack-upgrade` — or set `auto_upgrade: true` in `~/.gstack/config.yaml`
**State in the wrong place, or a setting that won't stick?** `~/.claude/skills/gstack/bin/gstack-paths --explain` shows which directory gstack uses for its state and why. See [docs/state-root.md](docs/state-root.md).
**Want shorter commands?** `cd ~/.claude/skills/gstack && ./setup --no-prefix` — switches from `/gstack-qa` to `/qa`. Your choice is remembered for future upgrades. **Want shorter commands?** `cd ~/.claude/skills/gstack && ./setup --no-prefix` — switches from `/gstack-qa` to `/qa`. Your choice is remembered for future upgrades.
**Want namespaced commands?** `cd ~/.claude/skills/gstack && ./setup --prefix` — switches from `/qa` to `/gstack-qa`. Useful if you run other skill packs alongside gstack. **Want namespaced commands?** `cd ~/.claude/skills/gstack && ./setup --prefix` — switches from `/qa` to `/gstack-qa`. Useful if you run other skill packs alongside gstack.
+24 -33
View File
@@ -641,41 +641,32 @@ identity-based answer.
**Effort:** S. **Priority:** P3. **Depends on:** none. **Effort:** S. **Priority:** P3. **Depends on:** none.
### P3: one state-root rule for the bridge's four stores ### P3: next refactor wave (from the 2026-09 refactor wave survey)
**What:** The hook, `bin/gstack-config` and `bin/gstack-memorable` resolve **What:** Hotspots the 1.91.11.0 wave surveyed but did not refactor, plus
their root as `GSTACK_STATE_ROOT` > `GSTACK_HOME` > `GSTACK_STATE_DIR`; the bugs it found and left alone because fixing them changes behavior:
egress ledger (`lib/egress-receipt.ts`) honors `GSTACK_HOME` > - `bin/gstack-memory-ingest.ts` (2,674 lines; `ingestPass` is ~600 lines).
`GSTACK_STATE_DIR`; the trust-policy store (`lib/gbrain-repo-policy-client.ts`) - `scripts/resolvers/design.ts` `generateDesignMethodology` (503 lines).
only `GSTACK_HOME`; `bin/gstack-uninstall` deletes only - `browse/src/browser-manager.ts` (2,143 lines) and `browse/src/cli.ts` (2,018 lines).
`${GSTACK_STATE_DIR:-$HOME/.gstack}`. Extract one shared rule (a - `lib/cso/*` dense one-line style.
`lib/state-root.ts` plus its bash twin) and use it everywhere. - Browse root-token denials disagree: some routes answer 401 `Unauthorized`,
others 403 `Root token required`. The route table's per-kind denial map
(`browse/src/routes/table.ts`) pins today's split.
- The sidebar's inspector live updates never arrive: `/inspector/events` is
`root-bearer` (it sat behind the old blanket root check), but
`extension/sidepanel.js` opens it with a cookie-only EventSource, and it
listens for `inspectResult` while the server emits `state` / `inspector`.
Fix both together, then flip the cookie rows in
`browse/test/server-route-auth-blackbox.test.ts`.
- Claude Code plugin-mode state (a pointer from `~/.gstack` to
`CLAUDE_PLUGIN_DATA`, plus merge, `--explain` and uninstall participation),
deferred by the W1 evidence gate: no official plugin distribution exists.
- The CSO native launchers (`lib/cso/launcher*.c`) pass only `HOME`,
`GSTACK_HOME` and `CLAUDE_PLUGIN_*` to the core, so `/cso` ignores an
exported `GSTACK_STATE_ROOT` / `GSTACK_STATE_DIR`. Needs a native rebuild.
- TODOS.md itself (4,500+ lines) needs restructuring.
**Why:** With `GSTACK_STATE_ROOT` set, the gate lives under one directory and **Effort:** M per item. **Priority:** P3.
the receipts under another; the tests pin all three variables to one temp dir,
so the drift is invisible to them. Found by the /ship red team.
**Context:** Uninstall already flips `memorable_recall` off whenever it reads
`on`, kept state or not (through gstack-config's own resolution), so no config
can say `on` after the hook is gone; the remaining drift is observability, not
consent.
**Effort:** S (human ~3 h / CC+gstack ~20 min). **Priority:** P3.
**Depends on:** none.
### P3: shared hook logging helper
**What:** `stateRoot()` and the `hook-errors.log` appender now exist in five
hooks (`question-log`, `question-preference`, `auq-error-fallback`,
`timeline-stop`, `memorable-user-prompt`), with drifting env-var precedence.
Extract `hosts/claude/hooks/hook-log.ts` (root resolution, 0600 append, the
rate limiter the memorable hook added) and migrate the five.
**Why:** One place to fix precedence and file modes; the memorable hook's
rate limiter belongs to every hook that can fail on every prompt.
**Effort:** S (human ~2 h / CC+gstack ~15 min). **Priority:** P3.
**Depends on:** the state-root rule above.
## Aside integration follow-ups (filed via /plan-ceo-review + /plan-eng-review on the third-party-actions Aside plan) ## Aside integration follow-ups (filed via /plan-ceo-review + /plan-eng-review on the third-party-actions Aside plan)
+1 -1
View File
@@ -1 +1 @@
1.91.9.0 1.91.11.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.9.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.91.11.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+5 -3
View File
@@ -255,7 +255,8 @@ At session start or after compaction, recover recent project context.
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
_BRANCH=$(git branch --show-current 2>/dev/null | tr -cd 'a-zA-Z0-9._/-') || :; _BRANCH=${_BRANCH:-unknown} _BRANCH=$(git branch --show-current 2>/dev/null | tr -cd 'a-zA-Z0-9._/-') || :; _BRANCH=${_BRANCH:-unknown}
_PROJ="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}" eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
_PROJ="$GSTACK_STATE_ROOT/projects/${SLUG:-unknown}"
if [ -d "$_PROJ" ]; then if [ -d "$_PROJ" ]; then
echo "--- RECENT ARTIFACTS ---" echo "--- RECENT ARTIFACTS ---"
find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs -r ls -t 2>/dev/null | head -3 find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs -r ls -t 2>/dev/null | head -3
@@ -365,7 +366,8 @@ Then build the complete version of what remains.
**Eureka:** When first-principles reasoning contradicts conventional wisdom, name it and log: **Eureka:** When first-principles reasoning contradicts conventional wisdom, name it and log:
```bash ```bash
jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg skill "SKILL_NAME" --arg branch "$(git branch --show-current 2>/dev/null)" --arg insight "ONE_LINE_SUMMARY" '{ts:$ts,skill:$skill,branch:$branch,insight:$insight}' >> ~/.gstack/analytics/eureka.jsonl 2>/dev/null || true eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg skill "SKILL_NAME" --arg branch "$(git branch --show-current 2>/dev/null)" --arg insight "ONE_LINE_SUMMARY" '{ts:$ts,skill:$skill,branch:$branch,insight:$insight}' >> "$GSTACK_STATE_ROOT/analytics/eureka.jsonl" 2>/dev/null || true
``` ```
## Completion Status Protocol ## Completion Status Protocol
@@ -725,7 +727,7 @@ bun -e 'console.log(require("fs").realpathSync(process.argv[1]))' "$HOME/.claude
Fresh external RESTORE_PATH: Fresh external RESTORE_PATH:
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
eval "$(~/.claude/skills/gstack/bin/gstack-paths)" eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG"
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-') BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-')
DATETIME=$(date +%Y%m%d-%H%M%S) DATETIME=$(date +%Y%m%d-%H%M%S)
+1 -1
View File
@@ -204,7 +204,7 @@ Resolve SNAPSHOT_TOOL once:
Fresh external RESTORE_PATH: Fresh external RESTORE_PATH:
```bash ```bash
{{SLUG_EVAL}} {{SLUG_EVAL}}
eval "$(~/.claude/skills/gstack/bin/gstack-paths)" eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG"
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-') BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-')
DATETIME=$(date +%Y%m%d-%H%M%S) DATETIME=$(date +%Y%m%d-%H%M%S)
+2 -1
View File
@@ -6,8 +6,9 @@ Before rendering the Final Approval Gate output block below, aggregate the
per-phase task lists each review skill wrote. per-phase task lists each review skill wrote.
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
TASKS_DIR="${HOME}/.gstack/projects/${SLUG:-unknown}" TASKS_DIR="$GSTACK_STATE_ROOT/projects/${SLUG:-unknown}"
BRANCH=$(git branch --show-current 2>/dev/null || echo unknown) BRANCH=$(git branch --show-current 2>/dev/null || echo unknown)
# Commit window: last 5 commits on this branch. Drops stale standalone reviews. # Commit window: last 5 commits on this branch. Drops stale standalone reviews.
COMMITS_RECENT=$(git log --format=%H -n 5 2>/dev/null | tr '\n' '|' | sed 's/|$//') COMMITS_RECENT=$(git log --format=%H -n 5 2>/dev/null | tr '\n' '|' | sed 's/|$//')
+4 -2
View File
@@ -8,10 +8,12 @@
# gstack-analytics all # all time # gstack-analytics all # all time
# #
# Env overrides (for testing): # Env overrides (for testing):
# GSTACK_STATE_DIR — override ~/.gstack state directory # GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
set -uo pipefail set -uo pipefail
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
JSONL_FILE="$STATE_DIR/analytics/skill-usage.jsonl" JSONL_FILE="$STATE_DIR/analytics/skill-usage.jsonl"
# ─── Parse time window ─────────────────────────────────────── # ─── Parse time window ───────────────────────────────────────
+3 -1
View File
@@ -46,7 +46,9 @@ BASH_COMPAT=50
set -euo pipefail set -euo pipefail
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
URL_BIN="$SCRIPT_DIR/gstack-artifacts-url" URL_BIN="$SCRIPT_DIR/gstack-artifacts-url"
REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt"
+2 -1
View File
@@ -23,6 +23,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSyn
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { homedir, hostname } from 'os'; import { homedir, hostname } from 'os';
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import { resolveStateRoot } from '../lib/state-root';
import { execGbrainJson, spawnGbrain } from '../lib/gbrain-exec'; import { execGbrainJson, spawnGbrain } from '../lib/gbrain-exec';
import { import {
BRAIN_CACHE_ENTITIES, BRAIN_CACHE_ENTITIES,
@@ -37,7 +38,7 @@ import {
// Paths + meta // Paths + meta
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
const GSTACK_HOME = process.env.GSTACK_HOME || join(homedir(), '.gstack'); const GSTACK_HOME = resolveStateRoot();
interface CacheMeta { interface CacheMeta {
/** Version of the schema pack the cache was built against. Mismatch → full rebuild. */ /** Version of the schema pack the cache was built against. Mismatch → full rebuild. */
+2 -1
View File
@@ -38,6 +38,7 @@ import { existsSync, readFileSync, statSync, readdirSync, accessSync, constants
import { join, dirname, basename, resolve, delimiter } from "path"; import { join, dirname, basename, resolve, delimiter } from "path";
import { spawnSync } from "child_process"; import { spawnSync } from "child_process";
import { homedir } from "os"; import { homedir } from "os";
import { resolveStateRoot } from "../lib/state-root";
import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers"; import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers";
@@ -67,7 +68,7 @@ interface QueryResult {
// ── Constants ────────────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────────────
const HOME = homedir(); const HOME = homedir();
const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); const GSTACK_HOME = resolveStateRoot();
// 500ms hard cap per Section 1C; overridable for slow/loaded environments // 500ms hard cap per Section 1C; overridable for slow/loaded environments
// (test harnesses under CI load, cold CLI starts). // (test harnesses under CI load, cold CLI starts).
const MCP_TIMEOUT_MS = Math.max(1, parseInt(process.env.GSTACK_BRAIN_TIMEOUT_MS || "", 10) || 500); const MCP_TIMEOUT_MS = Math.max(1, parseInt(process.env.GSTACK_BRAIN_TIMEOUT_MS || "", 10) || 500);
+3 -1
View File
@@ -31,7 +31,9 @@ set -uo pipefail
FILE="${1:-}" FILE="${1:-}"
[ -z "$FILE" ] && exit 0 [ -z "$FILE" ] && exit 0
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
SPOOL="$GSTACK_HOME/.brain-queue.d" SPOOL="$GSTACK_HOME/.brain-queue.d"
SKIP_FILE="$GSTACK_HOME/.brain-skip.txt" SKIP_FILE="$GSTACK_HOME/.brain-skip.txt"
+3 -1
View File
@@ -37,7 +37,9 @@ BASH_COMPAT=50
set -euo pipefail set -euo pipefail
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_BIN="$SCRIPT_DIR/gstack-config" CONFIG_BIN="$SCRIPT_DIR/gstack-config"
+3 -1
View File
@@ -29,7 +29,9 @@ BASH_COMPAT=50
set -uo pipefail set -uo pipefail
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
# Maildir-style spool: one FILE per record, <epoch>-<pid>-<uniq>.json. # Maildir-style spool: one FILE per record, <epoch>-<pid>-<uniq>.json.
# Writers (gstack-brain-enqueue, --discover-new) create records via tmp-file # Writers (gstack-brain-enqueue, --discover-new) create records via tmp-file
# + atomic rename; the drain deletes exactly the files it snapshotted. No # + atomic rename; the drain deletes exactly the files it snapshotted. No
+3 -1
View File
@@ -41,7 +41,9 @@
set -euo pipefail set -euo pipefail
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_BIN="$SCRIPT_DIR/gstack-config" CONFIG_BIN="$SCRIPT_DIR/gstack-config"
# v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration. # v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration.
+13 -9
View File
@@ -63,8 +63,11 @@ _gstack_codex_model_probe() {
# Only call this AFTER _gstack_codex_auth_probe passes — probing without # Only call this AFTER _gstack_codex_auth_probe passes — probing without
# auth just measures the auth failure again. # auth just measures the auth failure again.
local _codex_home="${CODEX_HOME:-$HOME/.codex}" local _codex_home="${CODEX_HOME:-$HOME/.codex}"
local _gstack_home="${GSTACK_HOME:-$HOME/.gstack}" # State root from the shared twin (sourced in a subshell, so the caller's
local _cache="$_gstack_home/.codex-model-probe" # shell is untouched). A missing twin only disables the cache.
local _gstack_home _cache=""
_gstack_home="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || _gstack_home=""
[ -n "$_gstack_home" ] && _cache="$_gstack_home/.codex-model-probe"
local _model="${GSTACK_CODEX_MODEL:-gpt-6-astra}" local _model="${GSTACK_CODEX_MODEL:-gpt-6-astra}"
# Cache signature: config.toml + auth.json mtimes + gstack model selection. # Cache signature: config.toml + auth.json mtimes + gstack model selection.
# Editing the model env/config or re-logging-in invalidates the cached result # Editing the model env/config or re-logging-in invalidates the cached result
@@ -83,7 +86,7 @@ _gstack_codex_model_probe() {
_sig="${_cfg_m}-${_auth_m}-${_model_sig}" _sig="${_cfg_m}-${_auth_m}-${_model_sig}"
local _now local _now
_now=$(date +%s 2>/dev/null || echo 0) _now=$(date +%s 2>/dev/null || echo 0)
if [ -f "$_cache" ]; then if [ -n "$_cache" ] && [ -f "$_cache" ]; then
local _c_line _c_status _c_ts _c_sig local _c_line _c_status _c_ts _c_sig
_c_line=$(head -1 "$_cache" 2>/dev/null) _c_line=$(head -1 "$_cache" 2>/dev/null)
_c_status=$(printf '%s' "$_c_line" | cut -d' ' -f1) _c_status=$(printf '%s' "$_c_line" | cut -d' ' -f1)
@@ -105,14 +108,12 @@ _gstack_codex_model_probe() {
_out=$(_gstack_codex_timeout_wrapper 30 codex exec --skip-git-repo-check -s read-only -c "model=\"$_model\"" "reply OK" </dev/null 2>&1) _out=$(_gstack_codex_timeout_wrapper 30 codex exec --skip-git-repo-check -s read-only -c "model=\"$_model\"" "reply OK" </dev/null 2>&1)
_code=$? _code=$?
if [ "$_code" -eq 0 ]; then if [ "$_code" -eq 0 ]; then
mkdir -p "$_gstack_home" 2>/dev/null || true [ -n "$_cache" ] && { mkdir -p "$_gstack_home" 2>/dev/null; printf 'MODEL_OK %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null; }
printf 'MODEL_OK %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true
echo "MODEL_OK" echo "MODEL_OK"
return 0 return 0
fi fi
if printf '%s' "$_out" | grep -qiE 'model.{0,40}is not supported|"status":[[:space:]]*400'; then if printf '%s' "$_out" | grep -qiE 'model.{0,40}is not supported|"status":[[:space:]]*400'; then
mkdir -p "$_gstack_home" 2>/dev/null || true [ -n "$_cache" ] && { mkdir -p "$_gstack_home" 2>/dev/null; printf 'MODEL_UNUSABLE %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null; }
printf 'MODEL_UNUSABLE %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true
echo "MODEL_UNUSABLE" echo "MODEL_UNUSABLE"
printf '%s\n' "$_out" | grep -i "model" | head -3 printf '%s\n' "$_out" | grep -i "model" | head -3
echo "HINT: gstack requested model '$_model'." echo "HINT: gstack requested model '$_model'."
@@ -221,12 +222,15 @@ _gstack_codex_log_event() {
local _event="$1" local _event="$1"
local _duration="${2:-0}" local _duration="${2:-0}"
[ "${_TEL:-off}" = "off" ] && return 0 [ "${_TEL:-off}" = "off" ] && return 0
mkdir -p "$HOME/.gstack/analytics" 2>/dev/null || return 0 local _root
_root="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || return 0
[ -n "$_root" ] || return 0
mkdir -p "$_root/analytics" 2>/dev/null || return 0
local _ts local _ts
_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown) _ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)
printf '{"skill":"codex","event":"%s","duration_s":"%s","ts":"%s"}\n' \ printf '{"skill":"codex","event":"%s","duration_s":"%s","ts":"%s"}\n' \
"$_event" "$_duration" "$_ts" \ "$_event" "$_duration" "$_ts" \
>> "$HOME/.gstack/analytics/skill-usage.jsonl" 2>/dev/null || true >> "$_root/analytics/skill-usage.jsonl" 2>/dev/null || true
} }
# --- Learnings log on hang -------------------------------------------------- # --- Learnings log on hang --------------------------------------------------
+3 -1
View File
@@ -21,7 +21,9 @@
# derive all apply uniformly. # derive all apply uniformly.
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
CODEX_SESSIONS_ROOT="${CODEX_SESSIONS_ROOT:-$HOME/.codex/sessions}" CODEX_SESSIONS_ROOT="${CODEX_SESSIONS_ROOT:-$HOME/.codex/sessions}"
MODE="latest" MODE="latest"
+45 -16
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gstack-config — read/write ~/.gstack/config.yaml # gstack-config — read/write <state root>/config.yaml (default ~/.gstack/config.yaml)
# #
# Usage: # Usage:
# gstack-config get <key> — read a config value (falls back to DEFAULTS) # gstack-config get <key> — read a config value (falls back to DEFAULTS)
@@ -10,14 +10,20 @@
# gstack-config list — show all config (values + defaults) # gstack-config list — show all config (values + defaults)
# gstack-config defaults — show just the defaults table # gstack-config defaults — show just the defaults table
# #
# Env overrides (for testing): # State root: GSTACK_STATE_ROOT → GSTACK_HOME → GSTACK_STATE_DIR →
# GSTACK_STATE_ROOT — override ~/.gstack state directory (highest priority, # CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT contains "gstack") →
# matches D16 cathedral isolation convention) # ~/.gstack (bin/gstack-state-root.sh). Set GSTACK_HOME to relocate state;
# GSTACK_HOME — override ~/.gstack state directory (aligns with writer scripts) # GSTACK_STATE_ROOT is gstack-paths' output, also honored as input;
# GSTACK_STATE_DIR — legacy alias for GSTACK_HOME (kept for backwards compat) # GSTACK_STATE_DIR is a legacy alias.
# Privacy keys (telemetry, memorable_recall, codex_reviews, update_check) take
# the most restrictive value across the resolved root and ~/.gstack; `set`
# reports which root received the value and which root still overrides it.
# Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md
set -euo pipefail set -euo pipefail
STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
CONFIG_FILE="$STATE_DIR/config.yaml" CONFIG_FILE="$STATE_DIR/config.yaml"
# Swap a freshly-rendered tmp dir into the live render location (#2569 # Swap a freshly-rendered tmp dir into the live render location (#2569
@@ -329,14 +335,27 @@ resolve_user_slug() {
printf '%s' "$_slug" printf '%s' "$_slug"
} }
# The one reader (bin/gstack-state-root.sh): last `key:` line in the resolved
# root, except privacy keys, which take the most restrictive value across roots.
read_config_value() { read_config_value() {
local key="$1" gstack_read_config_key "$1"
if [ ! -f "$CONFIG_FILE" ]; then }
return 0
fi _STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md"
grep -E "^${key}:" "$CONFIG_FILE" 2>/dev/null \ _SELF="$(cd "$(dirname "$0")" && pwd)/gstack-config"
| tail -1 \
| sed -E "s/^${key}:[[:space:]]*//; s/[[:space:]]+$//" # One line when the root-selecting variables name different directories.
report_root_disagreement() {
local _v _val _set="" _distinct=""
for _v in GSTACK_STATE_ROOT GSTACK_HOME GSTACK_STATE_DIR; do
eval "_val=\${$_v:-}"
[ -n "$_val" ] || continue
_set="$_set $_v=$_val"
case " $_distinct " in *" $_val "*) ;; *) _distinct="$_distinct $_val" ;; esac
done
set -- $_distinct
[ "$#" -gt 1 ] || return 0
echo "# note: root-selecting variables disagree:$_set; using $STATE_DIR ($_gstack_sr_var wins). Explain: $(dirname "$_SELF")/gstack-paths --explain ($_STATE_DOC)"
} }
case "${1:-}" in case "${1:-}" in
@@ -470,6 +489,12 @@ case "${1:-}" in
else else
echo "${KEY}: ${SAFE_VALUE}" >> "$CONFIG_FILE" echo "${KEY}: ${SAFE_VALUE}" >> "$CONFIG_FILE"
fi fi
# Report only when another root still overrides the value just written.
gstack_config_select "$KEY"
if [ -n "$_gstack_cfg_root" ] && [ "$_gstack_cfg_root" != "$STATE_DIR" ] && [ "$_gstack_cfg_value" != "$SAFE_VALUE" ]; then
echo "gstack-config: set $KEY in $CONFIG_FILE, but $KEY is still '$_gstack_cfg_value': $_gstack_cfg_root/config.yaml sets that more restrictive value, and for privacy keys the most restrictive value across state roots wins." >&2
echo "fix: GSTACK_STATE_ROOT='$_gstack_cfg_root' $_SELF set $KEY $SAFE_VALUE ($_STATE_DOC)" >&2
fi
# Auto-relink skills when prefix setting changes (skip during setup to avoid recursive call) # Auto-relink skills when prefix setting changes (skip during setup to avoid recursive call)
if [ "$KEY" = "skill_prefix" ] && [ -z "${GSTACK_SETUP_RUNNING:-}" ]; then if [ "$KEY" = "skill_prefix" ] && [ -z "${GSTACK_SETUP_RUNNING:-}" ]; then
GSTACK_RELINK="$(dirname "$0")/gstack-relink" GSTACK_RELINK="$(dirname "$0")/gstack-relink"
@@ -482,15 +507,19 @@ case "${1:-}" in
fi fi
echo "" echo ""
echo "# ─── Active values (including defaults for unset keys) ───" echo "# ─── Active values (including defaults for unset keys) ───"
report_root_disagreement
for KEY in proactive routing_declined telemetry auto_upgrade update_check \ for KEY in proactive routing_declined telemetry auto_upgrade update_check \
skill_prefix explain_level \ skill_prefix explain_level \
codex_reviews gstack_contributor skip_eng_review workspace_root \ codex_reviews gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \ artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall; do timeline_stop_hook design_detector design_detector_install_prompted memorable_recall; do
VALUE=$(read_config_value "$KEY" || true) gstack_config_select "$KEY"
VALUE="$_gstack_cfg_value"
SOURCE="default" SOURCE="default"
if [ -n "$VALUE" ]; then if [ -n "$VALUE" ]; then
SOURCE="set" SOURCE="set"
_gstack_config_rank "$KEY" x
[ -n "$_gstack_cfg_rank" ] && SOURCE="set, $_gstack_cfg_root"
else else
VALUE=$(lookup_default "$KEY") VALUE=$(lookup_default "$KEY")
fi fi
@@ -569,7 +598,7 @@ case "${1:-}" in
# worktree — bin/dev-setup owns that flow), and look like a real # worktree — bin/dev-setup owns that flow), and look like a real
# gstack clone. # gstack clone.
INSTALL_DIR="$HOME/.claude/skills/gstack" INSTALL_DIR="$HOME/.claude/skills/gstack"
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$STATE_DIR/render/claude}"
if [ ! -d "$INSTALL_DIR" ]; then if [ ! -d "$INSTALL_DIR" ]; then
echo "No global install at $INSTALL_DIR — nothing to render. (Dev workspaces get blocks via bin/dev-setup.)" echo "No global install at $INSTALL_DIR — nothing to render. (Dev workspaces get blocks via bin/dev-setup.)"
elif [ -L "$INSTALL_DIR" ]; then elif [ -L "$INSTALL_DIR" ]; then
+9 -31
View File
@@ -57,7 +57,7 @@
* *
* Scan hardening: every target, explicit or derived from `--changed`, must be an * Scan hardening: every target, explicit or derived from `--changed`, must be an
* existing regular file or directory whose realpath lies under the repo root (or * existing regular file or directory whose realpath lies under the repo root (or
* cwd) or under ${GSTACK_HOME:-~/.gstack}/projects/<slug>/designs/ (where design- * cwd) or under <state root>/projects/<slug>/designs/ (where design-
* review keeps rendered-DOM dumps: `designs/<audit>/dom/**` are page dumps and * review keeps rendered-DOM dumps: `designs/<audit>/dom/**` are page dumps and
* scan with --no-inline-ignores, because an `impeccable-disable` comment there is * scan with --no-inline-ignores, because an `impeccable-disable` comment there is
* page-controlled; other designs/ files are gstack-authored artifacts and keep * page-controlled; other designs/ files are gstack-authored artifacts and keep
@@ -77,7 +77,7 @@
* whose realpath lies inside the repo or cwd are ignored (IMPECCABLE_ENV_IGNORED). * whose realpath lies inside the repo or cwd are ignored (IMPECCABLE_ENV_IGNORED).
* *
* Observability: one content-free JSON line per probe/scan appended to * Observability: one content-free JSON line per probe/scan appended to
* ${GSTACK_HOME:-~/.gstack}/analytics/design-detector.jsonl (local file, no egress). * <state root>/analytics/design-detector.jsonl (local file, no egress).
* *
* Non-sink: this spawns a third-party binary the user installed over local * Non-sink: this spawns a third-party binary the user installed over local
* paths; gstack does not audit that engine's network behavior (NOTICE.md). * paths; gstack does not audit that engine's network behavior (NOTICE.md).
@@ -94,6 +94,7 @@ import {
ENGINE_RELEASE_BASE, ENGINE_ASSETS, ENGINE_PINS, ENGINE_RELEASE_BASE, ENGINE_ASSETS, ENGINE_PINS,
} from '../lib/design-detect-contract'; } from '../lib/design-detect-contract';
import { writeReceipt, writeOutcome } from '../lib/egress-receipt'; import { writeReceipt, writeOutcome } from '../lib/egress-receipt';
import { readConfigKey, resolveStateRoot } from '../lib/state-root';
import { DESIGN_SLOP_CATALOG, entryForImpeccableId } from '../lib/design-catalog'; import { DESIGN_SLOP_CATALOG, entryForImpeccableId } from '../lib/design-catalog';
import { isFrontendPath } from '../lib/frontend-scope'; import { isFrontendPath } from '../lib/frontend-scope';
@@ -104,14 +105,9 @@ const HOME = os.homedir();
const REAL_HOME = realpathOrNull(HOME) ?? HOME; const REAL_HOME = realpathOrNull(HOME) ?? HOME;
const ENV = process.env; const ENV = process.env;
/** Where config.yaml lives: the same precedence bin/gstack-config uses. */ /** Config, analytics and design artifacts share the one state root (lib/state-root.ts). */
function gstackStateDir(): string {
return ENV.GSTACK_STATE_ROOT || ENV.GSTACK_HOME || ENV.GSTACK_STATE_DIR || path.join(HOME, '.gstack');
}
/** Existing local analytics location; design artifacts resolve separately below. */
function gstackHome(): string { function gstackHome(): string {
return ENV.GSTACK_HOME || path.join(HOME, '.gstack'); return resolveStateRoot(ENV);
} }
function realpathOrNull(p: string): string | null { function realpathOrNull(p: string): string | null {
@@ -130,23 +126,10 @@ function gitTopLevel(cwd: string): string | null {
return top ? realpathOrNull(top) : null; return top ? realpathOrNull(top) : null;
} }
/** One flat key from config.yaml, read the way bin/gstack-config resolves it (same STATE_DIR precedence); '' when unset. */ /** One flat key from config.yaml via readConfigKey (the bin/gstack-config reader); '' when unset. */
function configValue(key: string): string { function configValue(key: string): string {
const file = path.join(gstackStateDir(), 'config.yaml'); // flat YAML: drop a trailing comment and surrounding quotes
try { return (readConfigKey(key, ENV) ?? '').replace(/\s+#.*$/, '').trim().replace(/^["'](.*)["']$/, '$1');
const text = fs.readFileSync(file, 'utf-8');
let value = '';
const re = new RegExp(`^${key}:\\s*(.*?)\\s*$`);
for (const line of text.split('\n')) {
const m = line.match(re);
if (!m) continue;
// flat YAML: drop a trailing comment and surrounding quotes
value = m[1].replace(/\s+#.*$/, '').trim().replace(/^["'](.*)["']$/, '$1');
}
return value;
} catch {
return '';
}
} }
/** design_detector: `Off` by hand must not silently re-enable a third-party binary. */ /** design_detector: `Off` by hand must not silently re-enable a third-party binary. */
@@ -785,12 +768,7 @@ function refuse(target: string, why: string) {
} }
function designsRoot(): string { function designsRoot(): string {
// Match the artifact producer's bin/gstack-paths precedence without changing return path.join(resolveStateRoot(ENV), 'projects');
// config or analytics roots. Plugin data belongs to gstack only with its marker.
const stateRoot = ENV.GSTACK_HOME
|| (ENV.CLAUDE_PLUGIN_DATA && /gstack/i.test(ENV.CLAUDE_PLUGIN_ROOT || '') ? ENV.CLAUDE_PLUGIN_DATA : '')
|| (ENV.HOME ? path.join(ENV.HOME, '.gstack') : '.gstack');
return path.join(stateRoot, 'projects');
} }
type TargetClass = 'project' | 'artifact' | 'dom-dump'; type TargetClass = 'project' | 'artifact' | 'dom-dump';
+3 -1
View File
@@ -29,7 +29,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). # GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
# Windows git-bash: GSTACK_HOME resolves to an MSYS path (/c/Users/...), which # Windows git-bash: GSTACK_HOME resolves to an MSYS path (/c/Users/...), which
# Bun on Windows cannot open as a filesystem path (bites --derive). Normalize # Bun on Windows cannot open as a filesystem path (bites --derive). Normalize
# once, here, before PROFILE_FILE/LEGACY_FILE are derived from it below — # once, here, before PROFILE_FILE/LEGACY_FILE are derived from it below —
+3 -1
View File
@@ -24,7 +24,9 @@
# gstack-distill-apply --list # show pending proposals # gstack-distill-apply --list # show pending proposals
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
SLUG="${SLUG:-unknown}" SLUG="${SLUG:-unknown}"
PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" PROJECT_DIR="$GSTACK_HOME/projects/$SLUG"
+3 -1
View File
@@ -32,7 +32,9 @@ set -euo pipefail
BASH_COMPAT=50 BASH_COMPAT=50
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
SLUG="${SLUG:-unknown}" SLUG="${SLUG:-unknown}"
PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" PROJECT_DIR="$GSTACK_HOME/projects/$SLUG"
+3 -11
View File
@@ -24,19 +24,13 @@
*/ */
import path from 'node:path'; import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { import {
egressLedgerPath, egressLedgerPath,
listReceipts, listReceipts,
resolveEgressHome, resolveEgressHome,
verifyLedger, verifyLedger,
} from '../lib/egress-receipt'; } from '../lib/egress-receipt';
import { readConfigKey } from '../lib/state-root';
// import.meta.dir is Windows-safe; new URL(import.meta.url).pathname yields
// '/C:/...' with percent-encoded spaces there, which would make the
// gstack-config spawn silently fail and grants report every default. Matches
// the sibling bin/*.ts convention.
const BIN_DIR = import.meta.dir;
/** /**
* Strip control characters (incl. ANSI escapes) from ledger-derived strings * Strip control characters (incl. ANSI escapes) from ledger-derived strings
@@ -64,11 +58,9 @@ function usage(message: string): never {
process.exit(2); process.exit(2);
} }
/** The shared config reader (lib/state-root.ts); '' when unset (callers apply gstack-config's defaults). */
function configGet(key: string): string { function configGet(key: string): string {
const result = spawnSync(path.join(BIN_DIR, 'gstack-config'), ['get', key], { return readConfigKey(key) ?? '';
encoding: 'utf-8',
});
return (result.stdout || '').trim();
} }
function egressList(args: string[], home: string): number { function egressList(args: string[], home: string): number {
+5 -5
View File
@@ -43,13 +43,13 @@ case "${BASH_SOURCE[0]}" in
*) _gstack_egress_lib_dir="$(pwd)" ;; *) _gstack_egress_lib_dir="$(pwd)" ;;
esac esac
# State root from the shared twin (bin/gstack-state-root.sh, builtins only),
# sourced lazily on first use.
_gstack_egress_home() { _gstack_egress_home() {
if [ -n "${GSTACK_HOME:-}" ]; then if command -v gstack_state_root >/dev/null 2>&1 || { [ -f "$_gstack_egress_lib_dir/gstack-state-root.sh" ] && . "$_gstack_egress_lib_dir/gstack-state-root.sh"; }; then
printf '%s' "$GSTACK_HOME" gstack_state_root
elif [ -n "${GSTACK_STATE_DIR:-}" ]; then
printf '%s' "$GSTACK_STATE_DIR"
else else
printf '%s' "$HOME/.gstack" printf '%s' "<gstack state root: $_gstack_egress_lib_dir/gstack-state-root.sh is missing; reinstall with ./setup or /gstack-upgrade>"
fi fi
} }
+5 -4
View File
@@ -39,6 +39,7 @@ import { mkdirpSync } from "../lib/fs-utils";
import { join, dirname } from "path"; import { join, dirname } from "path";
import { spawnSync } from "child_process"; import { spawnSync } from "child_process";
import { appendJsonl, readJsonl } from "../lib/jsonl-store"; import { appendJsonl, readJsonl } from "../lib/jsonl-store";
import { resolveStateRoot } from "../lib/state-root";
import { scan, applyRedactions } from "../lib/redact-engine"; import { scan, applyRedactions } from "../lib/redact-engine";
const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url)); const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url));
@@ -93,10 +94,10 @@ function currentWtree(): string | undefined {
} }
function ledgerPath(): { dir: string; file: string; logsDir: string } { function ledgerPath(): { dir: string; file: string; logsDir: string } {
const home = process.env.GSTACK_HOME || (process.env.HOME ? join(process.env.HOME, ".gstack") : undefined); const home = resolveStateRoot();
// No resolvable home: skip bookkeeping (a literal "~" dir in cwd would land // No resolvable home: skip bookkeeping (the relative ".gstack" fallback would
// inside the repo and perturb the fingerprint it exists to compute). // land inside the repo and perturb the fingerprint it exists to compute).
if (!home) throw new Error("no GSTACK_HOME/HOME — bookkeeping skipped"); if (home === ".gstack") throw new Error("no GSTACK_HOME/HOME — bookkeeping skipped");
// ONE gstack-slug spawn: its output carries both SLUG= and BRANCH= lines // ONE gstack-slug spawn: its output carries both SLUG= and BRANCH= lines
// (same branch→filename sanitization as reviews.jsonl). // (same branch→filename sanitization as reviews.jsonl).
const slugOut = spawnSync(join(BIN_DIR, "gstack-slug"), { encoding: "utf-8" }); const slugOut = spawnSync(join(BIN_DIR, "gstack-slug"), { encoding: "utf-8" });
+2 -1
View File
@@ -44,8 +44,9 @@ import {
readGbrainVersion, readGbrainVersion,
} from "../lib/gbrain-local-status"; } from "../lib/gbrain-local-status";
import { gbrainConfigDir, isTransactionModePooler } from "../lib/gbrain-exec"; import { gbrainConfigDir, isTransactionModePooler } from "../lib/gbrain-exec";
import { resolveStateRoot } from "../lib/state-root";
const STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack"); const STATE_DIR = resolveStateRoot();
const SCRIPT_DIR = __dirname; const SCRIPT_DIR = __dirname;
const CONFIG_BIN = join(SCRIPT_DIR, "gstack-config"); const CONFIG_BIN = join(SCRIPT_DIR, "gstack-config");
// Honors GBRAIN_HOME with gbrain's own configDir() semantics (#2521: // Honors GBRAIN_HOME with gbrain's own configDir() semantics (#2521:
+2 -2
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { readFileSync, realpathSync, statSync } from 'node:fs'; import { readFileSync, realpathSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { gbrainInvocation, buildGbrainEnv } from '../lib/gbrain-exec'; import { gbrainInvocation, buildGbrainEnv } from '../lib/gbrain-exec';
import { parseSourcesList } from '../lib/gbrain-sources'; import { parseSourcesList } from '../lib/gbrain-sources';
import { resolveStateRoot } from '../lib/state-root';
type Verdict = { status: 'ready' | 'unknown' | 'skipped' | 'source'; reason: string; source_id?: string; page_count?: number }; type Verdict = { status: 'ready' | 'unknown' | 'skipped' | 'source'; reason: string; source_id?: string; page_count?: number };
@@ -20,7 +20,7 @@ function readCapability(): Verdict {
try { try {
root = realpathSync(repo.stdout.trim()); root = realpathSync(repo.stdout.trim());
const pinPath = join(root, '.gbrain-source'); const pinPath = join(root, '.gbrain-source');
const statePath = join(process.env.GSTACK_HOME || join(homedir(), '.gstack'), '.gbrain-sync-state.json'); const statePath = join(resolveStateRoot(), '.gbrain-sync-state.json');
if (statSync(pinPath).size > 512 || statSync(statePath).size > 64 * 1024) if (statSync(pinPath).size > 512 || statSync(statePath).size > 64 * 1024)
return unknown('sync state or source pin exceeds the read limit'); return unknown('sync state or source pin exceeds the read limit');
pin = readFileSync(pinPath, 'utf8').trim(); pin = readFileSync(pinPath, 'utf8').trim();
+50 -5
View File
@@ -50,12 +50,22 @@
# migration actually changes anything. Idempotent: running twice is safe. # migration actually changes anything. Idempotent: running twice is safe.
# #
# Env: # Env:
# GSTACK_HOME — override ~/.gstack state directory (aligns with other # GSTACK_HOME — override the state root (the shared chain in
# gstack-* bins; used heavily in tests). # bin/gstack-state-root.sh; used heavily in tests).
#
# Deny tiers merge across state roots: when the resolved root is not
# ~/.gstack, `get` also reads ~/.gstack/gbrain-repo-policy.json and returns
# its deny or read-only tier when that is more restrictive. Nothing is written
# there. Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md
set -euo pipefail set -euo pipefail
STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
POLICY_FILE="$STATE_DIR/gbrain-repo-policy.json" POLICY_FILE="$STATE_DIR/gbrain-repo-policy.json"
gstack_legacy_root_select
LEGACY_POLICY_FILE=""
[ -n "$_gstack_legacy_root" ] && [ -f "$_gstack_legacy_root/gbrain-repo-policy.json" ] && LEGACY_POLICY_FILE="$_gstack_legacy_root/gbrain-repo-policy.json"
SCHEMA_VERSION=2 SCHEMA_VERSION=2
die() { echo "gstack-gbrain-repo-policy: $*" >&2; exit 2; } die() { echo "gstack-gbrain-repo-policy: $*" >&2; exit 2; }
@@ -177,6 +187,23 @@ ensure_file() {
fi fi
} }
_tier_rank() {
case "$1" in deny) echo 0 ;; read-only) echo 1 ;; read-write) echo 2 ;; *) echo 3 ;; esac
}
# merge_legacy_tier KEY TIER — TIER, or the other root's deny/read-only tier
# when that is more restrictive. Never more permissive than TIER.
merge_legacy_tier() {
local key="$1" tier="$2" other
if [ -z "$LEGACY_POLICY_FILE" ]; then printf '%s\n' "$tier"; return 0; fi
other=$(jq -r --arg key "$key" '.[$key] // "none"' "$LEGACY_POLICY_FILE" 2>/dev/null) || other="none"
case "$other" in
deny|read-only)
if [ "$(_tier_rank "$other")" -lt "$(_tier_rank "$tier")" ]; then tier="$other"; fi ;;
esac
printf '%s\n' "$tier"
}
# get --batch — bulk lookup for ingest gates. One URL per stdin line, one # get --batch — bulk lookup for ingest gates. One URL per stdin line, one
# tier per stdout line, input order preserved. Reuses normalize() (the same # tier per stdout line, input order preserved. Reuses normalize() (the same
# code path single `get` uses) per line. Prints `none` where single `get` # code path single `get` uses) per line. Prints `none` where single `get`
@@ -190,6 +217,18 @@ ensure_file() {
# fails hard (exit 2) instead and names the recovery path. # fails hard (exit 2) instead and names the recovery path.
cmd_get_batch() { cmd_get_batch() {
require_jq require_jq
if [ -n "$LEGACY_POLICY_FILE" ] && ! jq empty "$LEGACY_POLICY_FILE" 2>/dev/null; then
die "policy store $LEGACY_POLICY_FILE is corrupt (invalid JSON) — refusing batch read. Inspect or remove it; its deny tiers apply to every state root."
fi
if [ ! -f "$POLICY_FILE" ] && [ -n "$LEGACY_POLICY_FILE" ]; then
local url key
while IFS= read -r url || [ -n "$url" ]; do
key=$(normalize "$url")
if [ -z "$key" ]; then printf 'none\n'; continue; fi
merge_legacy_tier "$key" none
done
return 0
fi
if [ ! -f "$POLICY_FILE" ]; then if [ ! -f "$POLICY_FILE" ]; then
# No store = no policy was ever set. Every URL is `none`; don't create # No store = no policy was ever set. Every URL is `none`; don't create
# the file just for a read (matches cmd_list). # the file just for a read (matches cmd_list).
@@ -211,7 +250,7 @@ cmd_get_batch() {
printf 'none\n' printf 'none\n'
continue continue
fi fi
jq -r --arg key "$key" '.[$key] // "none"' "$POLICY_FILE" merge_legacy_tier "$key" "$(jq -r --arg key "$key" '.[$key] // "none"' "$POLICY_FILE")"
done done
} }
@@ -235,7 +274,13 @@ cmd_get() {
return 0 return 0
fi fi
ensure_file ensure_file
jq -r --arg key "$key" '.[$key] // "unset"' "$POLICY_FILE" local tier
tier=$(jq -r --arg key "$key" '.[$key] // "unset"' "$POLICY_FILE")
if [ -n "$LEGACY_POLICY_FILE" ] && ! jq empty "$LEGACY_POLICY_FILE" 2>/dev/null; then
echo "gstack-gbrain-repo-policy: ignoring corrupt $LEGACY_POLICY_FILE (inspect or remove it)" >&2
LEGACY_POLICY_FILE=""
fi
merge_legacy_tier "$key" "$tier"
} }
cmd_set() { cmd_set() {
+3 -1
View File
@@ -43,7 +43,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_BIN="$SCRIPT_DIR/gstack-config" CONFIG_BIN="$SCRIPT_DIR/gstack-config"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
WORKTREE="${GSTACK_BRAIN_WORKTREE:-$HOME/.gstack-brain-worktree}" WORKTREE="${GSTACK_BRAIN_WORKTREE:-$HOME/.gstack-brain-worktree}"
# v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration. # v1.27.0.0+ canonical name; brain-remote is the legacy fallback during migration.
if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then
+3 -2
View File
@@ -44,6 +44,7 @@ import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-s
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS, bashScriptInvocation } from "../lib/gbrain-exec"; import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS, bashScriptInvocation } from "../lib/gbrain-exec";
import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client"; import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client";
import { checkOwnedStagingDir } from "../lib/staging-guard"; import { checkOwnedStagingDir } from "../lib/staging-guard";
import { resolveStateRoot } from "../lib/state-root";
// ── Types ────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────
@@ -98,7 +99,7 @@ interface StageResult {
// ── Constants ────────────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────────────
const HOME = homedir(); const HOME = homedir();
const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); const GSTACK_HOME = resolveStateRoot();
const STATE_PATH = join(GSTACK_HOME, ".gbrain-sync-state.json"); const STATE_PATH = join(GSTACK_HOME, ".gbrain-sync-state.json");
const LOCK_PATH = join(GSTACK_HOME, ".sync-gbrain.lock"); const LOCK_PATH = join(GSTACK_HOME, ".sync-gbrain.lock");
const STALE_LOCK_MS = 5 * 60 * 1000; const STALE_LOCK_MS = 5 * 60 * 1000;
@@ -118,7 +119,7 @@ const DREAM_MARKER_STALE_MS = DEFAULT_DREAM_TIMEOUT_MS;
* module-load-time const captures the real ~/.gstack before a test can redirect. * module-load-time const captures the real ~/.gstack before a test can redirect.
*/ */
export function dreamMarkerPath(): string { export function dreamMarkerPath(): string {
return join(process.env.GSTACK_HOME || join(homedir(), ".gstack"), ".dream-in-progress"); return join(resolveStateRoot(), ".dream-in-progress");
} }
// Default 35-minute timeout for code-walk + memory-ingest stages. Override via // Default 35-minute timeout for code-walk + memory-ingest stages. Override via
+3 -1
View File
@@ -14,7 +14,9 @@ case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
esac esac
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
mkdir -p "$GSTACK_HOME/projects/$SLUG" mkdir -p "$GSTACK_HOME/projects/$SLUG"
INPUT="$1" INPUT="$1"
+3 -1
View File
@@ -8,7 +8,9 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
TYPE="" TYPE=""
QUERY="" QUERY=""
+5 -3
View File
@@ -36,7 +36,9 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
GSTACK_CONFIG="$SCRIPT_DIR/gstack-config" GSTACK_CONFIG="$SCRIPT_DIR/gstack-config"
STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}"
HOOK_SOURCE="gstack-memorable" HOOK_SOURCE="gstack-memorable"
@@ -380,9 +382,9 @@ status_bridge() {
n="unknown (gstack-egress list failed; run it yourself)" n="unknown (gstack-egress list failed; run it yourself)"
fi fi
echo "receipts: $n for sink $SINK (gstack-egress list --sink $SINK)" echo "receipts: $n for sink $SINK (gstack-egress list --sink $SINK)"
# Same resolution as lib/egress-receipt.ts resolveEgressHome: GSTACK_HOME, GSTACK_STATE_DIR, ~/.gstack. # Same root lib/egress-receipt.ts resolveEgressHome uses (the shared state-root chain).
local ledger size local ledger size
ledger="${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}/security/egress.jsonl" ledger="$STATE_DIR/security/egress.jsonl"
if [ -f "$ledger" ]; then if [ -f "$ledger" ]; then
size="$(wc -c < "$ledger" | tr -d ' ')" size="$(wc -c < "$ledger" | tr -d ' ')"
if [ "${size:-0}" -gt 26214400 ]; then if [ "${size:-0}" -gt 26214400 ]; then
+2 -1
View File
@@ -72,6 +72,7 @@ import { execGbrainText, spawnGbrainAsync } from "../lib/gbrain-exec";
import { writeReceipt } from "../lib/egress-receipt"; import { writeReceipt } from "../lib/egress-receipt";
import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard"; import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard";
import { hasRepoPolicyStore, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client"; import { hasRepoPolicyStore, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client";
import { resolveStateRoot } from "../lib/state-root";
// ── Types ────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────
@@ -186,7 +187,7 @@ interface BulkResult {
// ── Constants ────────────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────────────
const HOME = homedir(); const HOME = homedir();
const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); const GSTACK_HOME = resolveStateRoot();
const STATE_PATH = join(GSTACK_HOME, ".transcript-ingest-state.json"); const STATE_PATH = join(GSTACK_HOME, ".transcript-ingest-state.json");
const DEFAULT_INCREMENTAL_BUDGET_MS = 50; const DEFAULT_INCREMENTAL_BUDGET_MS = 50;
+71 -13
View File
@@ -2,6 +2,7 @@
# gstack-paths — output portable state-root paths for skill bash blocks # gstack-paths — output portable state-root paths for skill bash blocks
# Usage: eval "$(gstack-paths)" → sets GSTACK_STATE_ROOT, PLAN_ROOT, TMP_ROOT # Usage: eval "$(gstack-paths)" → sets GSTACK_STATE_ROOT, PLAN_ROOT, TMP_ROOT
# Or: gstack-paths → prints GSTACK_STATE_ROOT=... etc. # Or: gstack-paths → prints GSTACK_STATE_ROOT=... etc.
# gstack-paths --explain → which variable selected the state root, and why
# #
# Resolves three roots with explicit fallback chains so skills work the same # Resolves three roots with explicit fallback chains so skills work the same
# whether installed as a Claude Code plugin (CLAUDE_PLUGIN_DATA / CLAUDE_PLANS_DIR # whether installed as a Claude Code plugin (CLAUDE_PLUGIN_DATA / CLAUDE_PLANS_DIR
@@ -9,9 +10,18 @@
# CI / container env where HOME may be unset. # CI / container env where HOME may be unset.
# #
# Chains: # Chains:
# GSTACK_STATE_ROOT: GSTACK_HOME -> CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT=*gstack*) -> $HOME/.gstack -> .gstack # GSTACK_STATE_ROOT: GSTACK_STATE_ROOT -> GSTACK_HOME -> GSTACK_STATE_DIR
# -> CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT=*gstack*)
# -> $HOME/.gstack -> .gstack
# (one rule, owned by bin/gstack-state-root.sh; set GSTACK_HOME
# to relocate state; GSTACK_STATE_ROOT is this script's output,
# also honored as input; GSTACK_STATE_DIR is a legacy alias)
# PLAN_ROOT: GSTACK_PLAN_DIR -> CLAUDE_PLANS_DIR -> $HOME/.claude/plans -> .claude/plans # PLAN_ROOT: GSTACK_PLAN_DIR -> CLAUDE_PLANS_DIR -> $HOME/.claude/plans -> .claude/plans
# TMP_ROOT: TMPDIR -> TMP -> .gstack/tmp (and mkdir -p, best-effort) # TMP_ROOT: TMPDIR -> TMP -> .gstack/tmp (and mkdir -p, best-effort)
# Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md
#
# Callers guard the eval, because eval "$(f)" succeeds even when f fails:
# eval "$(gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
# #
# Output: values are emitted shell-quoted (printf %q) so `eval` round-trips them # Output: values are emitted shell-quoted (printf %q) so `eval` round-trips them
# byte-for-byte. This matters on Windows, where $TMP is a backslash path like # byte-for-byte. This matters on Windows, where $TMP is a backslash path like
@@ -24,19 +34,67 @@
# reason any path variable needs quoting. # reason any path variable needs quoting.
set -u set -u
_GSTACK_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md"
_GSTACK_TWIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/gstack-state-root.sh"
# Fail stop: never print a payload that would set an empty GSTACK_STATE_ROOT.
_gstack_paths_fail() {
echo "gstack-paths: cannot resolve the gstack state root: $1" >&2
echo "fix: reinstall gstack with ./setup (in the gstack checkout) or /gstack-upgrade. Docs: $_GSTACK_DOC" >&2
exit 1
}
if [ ! -f "$_GSTACK_TWIN" ] || ! . "$_GSTACK_TWIN" 2>/dev/null || ! command -v gstack_state_root_select >/dev/null 2>&1; then
_gstack_paths_fail "missing or broken $_GSTACK_TWIN"
fi
# State root: where gstack writes projects/, sessions/, analytics/. # State root: where gstack writes projects/, sessions/, analytics/.
if [ -n "${GSTACK_HOME:-}" ]; then gstack_state_root_select
_state_root="$GSTACK_HOME" _state_root="$_gstack_sr_root"
elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ] && echo "${CLAUDE_PLUGIN_ROOT:-}" | grep -qi "gstack"; then [ -n "$_state_root" ] || _gstack_paths_fail "the resolver in $_GSTACK_TWIN returned an empty root"
# Guard: only trust CLAUDE_PLUGIN_DATA when CLAUDE_PLUGIN_ROOT confirms we are
# running as the gstack plugin. Without this, a CLAUDE_PLUGIN_DATA from another if [ "${1:-}" = "--explain" ]; then
# plugin (e.g. codex) that leaked into the session env via CLAUDE_ENV_FILE would echo "state root: $_state_root (selected by $_gstack_sr_var)"
# be picked up, writing all gstack state into the wrong directory. echo "chain (first non-empty wins):"
_state_root="$CLAUDE_PLUGIN_DATA" _gstack_sel_seen=0
elif [ -n "${HOME:-}" ]; then for _v in GSTACK_STATE_ROOT GSTACK_HOME GSTACK_STATE_DIR CLAUDE_PLUGIN_DATA; do
_state_root="$HOME/.gstack" eval "_val=\${$_v:-}"
else if [ -z "$_val" ]; then
_state_root=".gstack" printf ' %-20s unset\n' "$_v"
elif [ "$_v" = "$_gstack_sr_var" ]; then
printf ' %-20s %s selected\n' "$_v" "$_val"
elif [ "$_v" = CLAUDE_PLUGIN_DATA ] && [ "$_gstack_sr_var" = default ]; then
printf ' %-20s %s ignored (CLAUDE_PLUGIN_ROOT does not contain "gstack")\n' "$_v" "$_val"
else
printf ' %-20s %s ignored\n' "$_v" "$_val"
fi
done
_gstack_user_home
if [ -n "$_gstack_home_val" ]; then
_gstack_default="$_gstack_home_val/.gstack"
else
_gstack_default=".gstack"
fi
if [ "$_gstack_sr_var" = default ]; then
printf ' %-20s %s selected\n' "default" "$_gstack_default"
else
printf ' %-20s %s ignored\n' "default" "$_gstack_default"
_gstack_has_state=no
for _e in "$_gstack_default"/* "$_gstack_default"/.[!.]*; do
[ -e "$_e" ] && { _gstack_has_state=yes; break; }
done
echo "default root $_gstack_default also holds gstack state: $_gstack_has_state"
fi
echo "merged privacy keys (most restrictive value across roots wins):"
for _k in telemetry memorable_recall codex_reviews update_check; do
gstack_config_select "$_k"
if [ -n "$_gstack_cfg_value" ]; then
printf ' %-17s %s (from %s/config.yaml)\n' "$_k:" "$_gstack_cfg_value" "$_gstack_cfg_root"
else
printf ' %-17s not set (default applies)\n' "$_k:"
fi
done
echo "docs: $_GSTACK_DOC"
exit 0
fi fi
# Plan root: where /context-save and /codex consult write plan files. # Plan root: where /context-save and /codex consult write plan files.
+3 -1
View File
@@ -35,7 +35,9 @@ case "$(uname -s)" in
esac esac
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). # GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
mkdir -p "$GSTACK_HOME/projects/$SLUG" mkdir -p "$GSTACK_HOME/projects/$SLUG"
INPUT="$1" INPUT="$1"
+3 -1
View File
@@ -32,7 +32,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16). # GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
SLUG="${SLUG:-unknown}" SLUG="${SLUG:-unknown}"
PREF_FILE="$GSTACK_HOME/projects/$SLUG/question-preferences.json" PREF_FILE="$GSTACK_HOME/projects/$SLUG/question-preferences.json"
+2 -2
View File
@@ -27,10 +27,10 @@
*/ */
import { spawnSync } from "child_process"; import { spawnSync } from "child_process";
import * as fs from "fs"; import * as fs from "fs";
import * as os from "os";
import * as path from "path"; import * as path from "path";
import { normalizeWithMap, scan, type Finding } from "../lib/redact-engine"; import { normalizeWithMap, scan, type Finding } from "../lib/redact-engine";
import { mkdirpSync } from "../lib/fs-utils"; import { mkdirpSync } from "../lib/fs-utils";
import { resolveStateRoot } from "../lib/state-root";
const ZERO = /^0+$/; const ZERO = /^0+$/;
let emptyTree: string | undefined; let emptyTree: string | undefined;
@@ -416,7 +416,7 @@ function scanAddedLines(added: string, opts: Parameters<typeof scan>[1]): Findin
function logSkip(reason: string): void { function logSkip(reason: string): void {
try { try {
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack"); const home = resolveStateRoot();
const dir = path.join(home, "security"); const dir = path.join(home, "security");
// mkdirpSync, not bare mkdirSync: bun-on-Windows EEXIST (#2635). This site // mkdirpSync, not bare mkdirSync: bun-on-Windows EEXIST (#2635). This site
// is try-wrapped by the caller, so the old failure was a silent skip-log // is try-wrapped by the caller, so the old failure was a silent skip-log
+6 -4
View File
@@ -5,7 +5,7 @@
# gstack-relink # gstack-relink
# #
# Env overrides (for testing): # Env overrides (for testing):
# GSTACK_STATE_DIR — override ~/.gstack state directory # GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
# GSTACK_INSTALL_DIR — override gstack install directory # GSTACK_INSTALL_DIR — override gstack install directory
# GSTACK_SKILLS_DIR — override target skills directory # GSTACK_SKILLS_DIR — override target skills directory
set -euo pipefail set -euo pipefail
@@ -40,7 +40,9 @@ PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false")
# out-dir instead of the tracked install checkout. When a render exists for a # out-dir instead of the tracked install checkout. When a render exists for a
# skill, relink serves it — otherwise a config change would silently flip # skill, relink serves it — otherwise a config change would silently flip
# every skill back to the canonical (blockless) source. # every skill back to the canonical (blockless) source.
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" . "$SCRIPT_DIR/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SCRIPT_DIR/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}"
# ─── Ownership gate ─────────────────────────────────────────────────────────── # ─── Ownership gate ───────────────────────────────────────────────────────────
# relink runs on every ./setup and used to `rm -rf` any same-name entry with a # relink runs on every ./setup and used to `rm -rf` any same-name entry with a
@@ -58,7 +60,7 @@ RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claud
# refreshed in place. WEAK (byte-identity with our source, or gen-skill-docs' # refreshed in place. WEAK (byte-identity with our source, or gen-skill-docs'
# two-line banner on a real file) proves only that the SKILL.md came from us: # two-line banner on a real file) proves only that the SKILL.md came from us:
# it authorizes touching that one file, never deleting the directory, and a # it authorizes touching that one file, never deleting the directory, and a
# differing file is moved to ${GSTACK_HOME:-~/.gstack}/backups/skills/<ts>/ # differing file is moved to $GSTACK_STATE_ROOT/backups/skills/<ts>/
# before we link over it (a user who started their own skill from a gstack # before we link over it (a user who started their own skill from a gstack
# SKILL.md looks exactly like a pre-marker legacy copy). # SKILL.md looks exactly like a pre-marker legacy copy).
# #
@@ -183,7 +185,7 @@ _report_foreign() {
# Weakly-proven real files we would otherwise overwrite go here, one summary # Weakly-proven real files we would otherwise overwrite go here, one summary
# line at the end. mv, not cp: the link that follows needs the path free. # line at the end. mv, not cp: the link that follows needs the path free.
BACKUP_ROOT="${GSTACK_HOME:-$HOME/.gstack}/backups/skills/$(date +%Y%m%dT%H%M%S)" BACKUP_ROOT="$GSTACK_STATE_ROOT/backups/skills/$(date +%Y%m%dT%H%M%S)"
BACKED_UP=() BACKED_UP=()
_backup_skill_md() { _backup_skill_md() {
# Non-zero when the file could NOT be moved: the caller leaves the entry alone. # Non-zero when the file could NOT be moved: the caller leaves the entry alone.
+3 -1
View File
@@ -41,7 +41,9 @@ if [ -n "$OVERRIDE" ] && [ "$OVERRIDE" != "null" ]; then
fi fi
# Check cache (7-day TTL) # Check cache (7-day TTL)
CACHE_DIR="$HOME/.gstack/projects/$SLUG" . "$SCRIPT_DIR/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SCRIPT_DIR/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
CACHE_DIR="$GSTACK_STATE_ROOT/projects/$SLUG"
CACHE_FILE="$CACHE_DIR/repo-mode.json" CACHE_FILE="$CACHE_DIR/repo-mode.json"
if [ -f "$CACHE_FILE" ]; then if [ -f "$CACHE_FILE" ]; then
# GNU first (#2195): on GNU coreutils `stat -f` SUCCEEDS with filesystem # GNU first (#2195): on GNU coreutils `stat -f` SUCCEEDS with filesystem
+4 -2
View File
@@ -12,7 +12,7 @@
# #
# Conventions mirror bin/gstack-skill-start: # Conventions mirror bin/gstack-skill-start:
# - Paths resolve $0-relative (works for every host + install layout). # - Paths resolve $0-relative (works for every host + install layout).
# - State paths honor ${GSTACK_HOME:-$HOME/.gstack}. # - State paths honor the shared state root (bin/gstack-paths).
# - Error style: per-line `|| true`, never `set -e` — a mid-script failure # - Error style: per-line `|| true`, never `set -e` — a mid-script failure
# must not drop later METRIC lines. # must not drop later METRIC lines.
# - No heredocs (nothing to BASH_COMPAT-guard; see # - No heredocs (nothing to BASH_COMPAT-guard; see
@@ -45,7 +45,9 @@ done
# sibling-bin call must go through $_BIN, never bare PATH lookup). # sibling-bin call must go through $_BIN, never bare PATH lookup).
_SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) _SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd)
_BIN="$_SCRIPT_DIR" _BIN="$_SCRIPT_DIR"
_GH="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
_GH="$_gstack_sr_root"
echo "RETRO_METRICS_PROTO: 1" echo "RETRO_METRICS_PROTO: 1"
+3 -1
View File
@@ -13,7 +13,9 @@ export GIT_OPTIONAL_LOCKS=0
export GIT_CONFIG_PARAMETERS="${GIT_CONFIG_PARAMETERS:+$GIT_CONFIG_PARAMETERS }'core.fsmonitor=false' 'core.untrackedCache=false'" export GIT_CONFIG_PARAMETERS="${GIT_CONFIG_PARAMETERS:+$GIT_CONFIG_PARAMETERS }'core.fsmonitor=false' 'core.untrackedCache=false'"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
mkdir -p "$GSTACK_HOME/projects/$SLUG" mkdir -p "$GSTACK_HOME/projects/$SLUG"
INPUT="${1:-}" INPUT="${1:-}"
+3 -1
View File
@@ -14,7 +14,9 @@ case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
esac esac
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || echo "unknown") WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || echo "unknown")
if [ -f "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" ]; then if [ -f "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" ]; then
GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun -e ' GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun -e '
+4 -1
View File
@@ -10,7 +10,10 @@
set +e set +e
GSTACK_DIR="${GSTACK_DIR:-$HOME/.claude/skills/gstack}" GSTACK_DIR="${GSTACK_DIR:-$HOME/.claude/skills/gstack}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" # Hook: source the state-root twin (never spawn gstack-paths); a broken
# install exits 0 silently, like every other error here.
. "$(cd "$(dirname "$0")" && pwd)/gstack-state-root.sh" 2>/dev/null || exit 0
STATE_DIR="$(gstack_state_root; printf x)"; STATE_DIR="${STATE_DIR%x}"
# Egress receipt helpers (_receipted_git): fail-open — an update pull must # Egress receipt helpers (_receipted_git): fail-open — an update pull must
# never block a session over a receipt hiccup. # never block a session over a receipt hiccup.
+3 -1
View File
@@ -30,7 +30,9 @@ done
_SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) _SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd)
_BIN="$_SCRIPT_DIR" _BIN="$_SCRIPT_DIR"
_GH="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
_GH="$_gstack_sr_root"
_TEL=$("$_BIN/gstack-config" get telemetry 2>/dev/null || echo off) _TEL=$("$_BIN/gstack-config" get telemetry 2>/dev/null || echo off)
_TEL_END=$(date +%s) _TEL_END=$(date +%s)
+3 -1
View File
@@ -51,7 +51,9 @@ done
_SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) _SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd)
_BIN="$_SCRIPT_DIR" _BIN="$_SCRIPT_DIR"
_GH="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
_GH="$_gstack_sr_root"
# OV4: strip instruction markers from any passthrough text before echoing it # OV4: strip instruction markers from any passthrough text before echoing it
# into the blessed tool result. Prior-session/repo content must not be able to # into the blessed tool result. Prior-session/repo content must not be able to
+4 -2
View File
@@ -46,11 +46,13 @@
# injection when consumed via source or eval. # injection when consumed via source or eval.
set -euo pipefail set -euo pipefail
# GSTACK_HOME-aware, matching lib/bin-context.ts's native port (#2561): the # State-root aware (bin/gstack-state-root.sh), matching lib/bin-context.ts (#2561): the
# bash writer and the TS reader must key the SAME cache, and a test running # bash writer and the TS reader must key the SAME cache, and a test running
# with GSTACK_HOME=<temp> must write its cache junk there, not into the real # with GSTACK_HOME=<temp> must write its cache junk there, not into the real
# home (observed: 2,528 stale temp-cwd entries accumulated in ~/.gstack). # home (observed: 2,528 stale temp-cwd entries accumulated in ~/.gstack).
CACHE_DIR="${GSTACK_HOME:-$HOME/.gstack}/slug-cache" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
CACHE_DIR="$GSTACK_STATE_ROOT/slug-cache"
PROJECT_DIR="$(pwd)" PROJECT_DIR="$(pwd)"
# Encode absolute path as cache key: /Users/j/foo → _Users_j_foo # Encode absolute path as cache key: /Users/j/foo → _Users_j_foo
CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_') CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_')
+3 -1
View File
@@ -8,7 +8,9 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" PROJECT_DIR="$GSTACK_HOME/projects/$SLUG"
if [ ! -d "$PROJECT_DIR" ]; then if [ ! -d "$PROJECT_DIR" ]; then
+135
View File
@@ -0,0 +1,135 @@
# shellcheck shell=bash
# gstack-state-root.sh — the one bash owner of where gstack keeps its state
# (twin of lib/state-root.ts; test/state-root-parity.test.ts keeps them equal).
# Sourced, never executed: bin/gstack-paths and the careful/freeze hooks source
# it; other executables run `eval "$(<their dir>/gstack-paths)"` plus the
# `: "${GSTACK_STATE_ROOT:?...}"` guard. Chain: GSTACK_STATE_ROOT → GSTACK_HOME →
# GSTACK_STATE_DIR → CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT contains
# "gstack") → $HOME/.gstack → .gstack. Bash builtins only (bash 3.2, no
# subprocess): hooks run it on every tool call. Enforced by
# test/state-root-ratchet.test.ts. Generalizes careful/bin/hook-extract.sh's
# former gstack_hook_state_root. Docs: docs/state-root.md.
# _gstack_user_home — HOME, or USERPROFILE on Windows shells when HOME is unset.
_gstack_user_home() {
_gstack_home_val="${HOME:-}"
if [ -z "$_gstack_home_val" ]; then
case "${OSTYPE:-}" in
msys*|cygwin*|win32*) _gstack_home_val="${USERPROFILE:-}" ;;
esac
fi
}
# gstack_state_root_select — sets _gstack_sr_root and _gstack_sr_var (the
# variable that selected it, or "default") without printing.
gstack_state_root_select() {
_gstack_user_home
if [ -n "${GSTACK_STATE_ROOT:-}" ]; then
_gstack_sr_root="$GSTACK_STATE_ROOT"; _gstack_sr_var="GSTACK_STATE_ROOT"
elif [ -n "${GSTACK_HOME:-}" ]; then
_gstack_sr_root="$GSTACK_HOME"; _gstack_sr_var="GSTACK_HOME"
elif [ -n "${GSTACK_STATE_DIR:-}" ]; then
_gstack_sr_root="$GSTACK_STATE_DIR"; _gstack_sr_var="GSTACK_STATE_DIR"
else
_gstack_sr_root=""
if [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then
case "${CLAUDE_PLUGIN_ROOT:-}" in
*[gG][sS][tT][aA][cC][kK]*) _gstack_sr_root="$CLAUDE_PLUGIN_DATA"; _gstack_sr_var="CLAUDE_PLUGIN_DATA" ;;
esac
fi
if [ -z "$_gstack_sr_root" ]; then
if [ -n "$_gstack_home_val" ]; then
_gstack_sr_root="$_gstack_home_val/.gstack"; _gstack_sr_var="default"
else
_gstack_sr_root=".gstack"; _gstack_sr_var="default"
fi
fi
fi
}
# gstack_state_root — print the state root WITHOUT a trailing newline. Capture
# with a sentinel so a root ending in a newline round-trips exactly:
# r="$(gstack_state_root; printf x)"; r="${r%x}"
gstack_state_root() {
gstack_state_root_select
printf '%s' "$_gstack_sr_root"
}
# _gstack_config_from_root ROOT KEY — sets _gstack_cfg_one to the value of the
# last `KEY:` line in ROOT/config.yaml, trimmed ("" when absent). Same parse as
# `gstack-config get`.
_gstack_config_from_root() {
_gstack_cfg_one=""
[ -f "$1/config.yaml" ] || return 0
while IFS= read -r _gstack_cfg_line || [ -n "$_gstack_cfg_line" ]; do
case "$_gstack_cfg_line" in
"$2":*)
_gstack_cfg_one="${_gstack_cfg_line#"$2":}"
_gstack_cfg_one="${_gstack_cfg_one#"${_gstack_cfg_one%%[![:space:]]*}"}"
_gstack_cfg_one="${_gstack_cfg_one%"${_gstack_cfg_one##*[![:space:]]}"}"
;;
esac
done < "$1/config.yaml"
}
# _gstack_config_rank KEY VALUE — sets _gstack_cfg_rank: position in the key's
# most-restrictive-first order, -1 for an unrecognized value, "" when KEY is
# not a merged key.
_gstack_config_rank() {
case "$1" in
telemetry) case "$2" in off) _gstack_cfg_rank=0 ;; anonymous) _gstack_cfg_rank=1 ;; community) _gstack_cfg_rank=2 ;; *) _gstack_cfg_rank=-1 ;; esac ;;
memorable_recall) case "$2" in off) _gstack_cfg_rank=0 ;; on) _gstack_cfg_rank=1 ;; *) _gstack_cfg_rank=-1 ;; esac ;;
codex_reviews) case "$2" in disabled) _gstack_cfg_rank=0 ;; enabled) _gstack_cfg_rank=1 ;; *) _gstack_cfg_rank=-1 ;; esac ;;
update_check) case "$2" in false) _gstack_cfg_rank=0 ;; true) _gstack_cfg_rank=1 ;; *) _gstack_cfg_rank=-1 ;; esac ;;
*) _gstack_cfg_rank="" ;;
esac
}
# gstack_legacy_root_select — sets _gstack_legacy_root to the second candidate
# root merged privacy settings are also read from: $HOME/.gstack
# (GSTACK_TEST_LEGACY_ROOT in tests), or "" when there is none or it is the
# resolved root. Call after gstack_state_root_select.
gstack_legacy_root_select() {
_gstack_legacy_root=""
if [ -n "${GSTACK_TEST_LEGACY_ROOT:-}" ]; then
_gstack_legacy_root="$GSTACK_TEST_LEGACY_ROOT"
elif [ -n "$_gstack_home_val" ]; then
_gstack_legacy_root="$_gstack_home_val/.gstack"
fi
[ "$_gstack_legacy_root" = "$_gstack_sr_root" ] && _gstack_legacy_root=""
return 0
}
# gstack_config_select KEY — sets _gstack_cfg_value and _gstack_cfg_root (the
# root that supplied it; both "" when unset). Merged privacy keys (telemetry,
# memorable_recall, codex_reviews, update_check) take the most restrictive
# value across the resolved root and $HOME/.gstack; every other key reads the
# resolved root only. GSTACK_TEST_LEGACY_ROOT replaces $HOME/.gstack in tests.
gstack_config_select() {
gstack_state_root_select
_gstack_config_from_root "$_gstack_sr_root" "$1"
_gstack_cfg_value="$_gstack_cfg_one"; _gstack_cfg_root=""
[ -n "$_gstack_cfg_value" ] && _gstack_cfg_root="$_gstack_sr_root"
_gstack_config_rank "$1" "x"
[ -n "$_gstack_cfg_rank" ] || return 0
gstack_legacy_root_select
[ -n "$_gstack_legacy_root" ] || return 0
_gstack_cfg_legacy="$_gstack_legacy_root"
_gstack_config_from_root "$_gstack_cfg_legacy" "$1"
[ -n "$_gstack_cfg_one" ] || return 0
if [ -z "$_gstack_cfg_value" ]; then
_gstack_cfg_value="$_gstack_cfg_one"; _gstack_cfg_root="$_gstack_cfg_legacy"
return 0
fi
_gstack_config_rank "$1" "$_gstack_cfg_value"; _gstack_cfg_best="$_gstack_cfg_rank"
_gstack_config_rank "$1" "$_gstack_cfg_one"
if [ "$_gstack_cfg_rank" -lt "$_gstack_cfg_best" ]; then
_gstack_cfg_value="$_gstack_cfg_one"; _gstack_cfg_root="$_gstack_cfg_legacy"
fi
}
# gstack_read_config_key KEY — print the (merged) value, no trailing newline.
gstack_read_config_key() {
gstack_config_select "$1"
printf '%s' "$_gstack_cfg_value"
}
+2 -1
View File
@@ -32,8 +32,9 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { resolveStateRoot } from '../lib/state-root';
const STATE_DIR = process.env.GSTACK_STATE_DIR || path.join(process.env.HOME || '/', '.gstack'); const STATE_DIR = resolveStateRoot();
const SCHEMA_VERSION = 1; const SCHEMA_VERSION = 1;
const SESSION_CAP = 50; const SESSION_CAP = 50;
const DECAY_PER_WEEK = 0.05; const DECAY_PER_WEEK = 0.05;
+5 -3
View File
@@ -16,7 +16,7 @@
# epilogue may sweep. # epilogue may sweep.
# #
# Env overrides (for testing): # Env overrides (for testing):
# GSTACK_STATE_DIR — override ~/.gstack state directory # GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
# GSTACK_DIR — override auto-detected gstack root # GSTACK_DIR — override auto-detected gstack root
# #
# NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero # NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero
@@ -29,7 +29,9 @@ SCRIPT_DIR="$GSTACK_DIR/bin"
case "$(uname -s)" in case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;;
esac esac
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
ANALYTICS_DIR="$STATE_DIR/analytics" ANALYTICS_DIR="$STATE_DIR/analytics"
JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl" JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl"
PENDING_DIR="$ANALYTICS_DIR" # .pending-* files live here PENDING_DIR="$ANALYTICS_DIR" # .pending-* files live here
@@ -148,7 +150,7 @@ fi
# can't be guessed or correlated by someone who knows your machine identity. # can't be guessed or correlated by someone who knows your machine identity.
INSTALL_ID="" INSTALL_ID=""
if [ "$TIER" = "community" ]; then if [ "$TIER" = "community" ]; then
ID_FILE="$HOME/.gstack/installation-id" ID_FILE="$STATE_DIR/installation-id"
if [ -f "$ID_FILE" ]; then if [ -f "$ID_FILE" ]; then
INSTALL_ID="$(cat "$ID_FILE" 2>/dev/null)" INSTALL_ID="$(cat "$ID_FILE" 2>/dev/null)"
fi fi
+4 -2
View File
@@ -6,13 +6,15 @@
# Posts to the telemetry-ingest edge function (not PostgREST directly). # Posts to the telemetry-ingest edge function (not PostgREST directly).
# #
# Env overrides (for testing): # Env overrides (for testing):
# GSTACK_STATE_DIR — override ~/.gstack state directory # GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
# GSTACK_DIR — override auto-detected gstack root # GSTACK_DIR — override auto-detected gstack root
# GSTACK_SUPABASE_URL — override Supabase project URL # GSTACK_SUPABASE_URL — override Supabase project URL
set -uo pipefail set -uo pipefail
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
# Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed. # Egress receipt helpers (_receipted_curl): receipt-before-send, fail-closed.
. "$GSTACK_DIR/bin/gstack-egress-lib.sh" . "$GSTACK_DIR/bin/gstack-egress-lib.sh"
+3 -1
View File
@@ -12,7 +12,9 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
mkdir -p "$GSTACK_HOME/projects/$SLUG" mkdir -p "$GSTACK_HOME/projects/$SLUG"
INPUT="$1" INPUT="$1"
+3 -1
View File
@@ -8,7 +8,9 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
SINCE="" SINCE=""
LIMIT=20 LIMIT=20
+75 -7
View File
@@ -4,7 +4,15 @@
# Usage: # Usage:
# gstack-uninstall — interactive uninstall (prompts before removing) # gstack-uninstall — interactive uninstall (prompts before removing)
# gstack-uninstall --force — remove everything without prompting # gstack-uninstall --force — remove everything without prompting
# gstack-uninstall --keep-state — remove skills but keep ~/.gstack/ data # gstack-uninstall --keep-state — remove skills but keep all gstack state
#
# State: only the default root ~/.gstack/ is ever deleted. A state root
# selected elsewhere (GSTACK_HOME, GSTACK_STATE_ROOT, GSTACK_STATE_DIR or
# plugin data; chain in bin/gstack-state-root.sh) is left in place and the
# exact removal command is printed. Uninstall refuses (exit 2) when ~/.gstack
# resolves to /, $HOME or an ancestor, the gstack checkout, the current git
# repository, or an ancestor of either.
# Docs: https://github.com/garrytan/gstack/blob/main/docs/state-root.md
# #
# What gets REMOVED: # What gets REMOVED:
# ~/.claude/skills/gstack — global Claude skill install (git clone or vendored) # ~/.claude/skills/gstack — global Claude skill install (git clone or vendored)
@@ -28,7 +36,6 @@
# #
# Env overrides (for testing): # Env overrides (for testing):
# GSTACK_DIR — override auto-detected gstack root # GSTACK_DIR — override auto-detected gstack root
# GSTACK_STATE_DIR — override ~/.gstack state directory
# #
# NOTE: Uses set -uo pipefail (no -e) — uninstall must never abort partway. # NOTE: Uses set -uo pipefail (no -e) — uninstall must never abort partway.
set -uo pipefail set -uo pipefail
@@ -39,7 +46,14 @@ if [ -z "${HOME:-}" ]; then
fi fi
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" # Only the default root is ever deleted; RESOLVED_ROOT (the full chain) is
# used read-only (daemon discovery) and reported when it is elsewhere.
STATE_DIR="$HOME/.gstack"
_STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md"
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade ($_STATE_DOC)" >&2; exit 1; }
gstack_state_root_select
RESOLVED_ROOT="$_gstack_sr_root"
RESOLVED_VAR="$_gstack_sr_var"
_GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" _GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
# ─── Parse flags ───────────────────────────────────────────── # ─── Parse flags ─────────────────────────────────────────────
@@ -61,6 +75,53 @@ while [ $# -gt 0 ]; do
esac esac
done done
# ─── State-root safety ───────────────────────────────────────
_real() { (cd -P -- "$1" 2>/dev/null && pwd -P); }
# _is_at_or_above TARGET PATH — TARGET is PATH or an ancestor of it.
_is_at_or_above() {
[ -n "$2" ] || return 1
[ "$1" = "$2" ] && return 0
case "$2/" in "${1%/}/"*) return 0 ;; esac
return 1
}
_refuse() {
echo "gstack-uninstall: refusing to delete $STATE_DIR: $1" >&2
echo "fix: $2 (docs: $_STATE_DOC)" >&2
exit 2
}
if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then
_STATE_REAL="$(_real "$STATE_DIR")"
_HOME_REAL="$(_real "$HOME")"
_CHECKOUT_REAL="$(_real "$GSTACK_DIR")"
_GIT_REAL=""
[ -n "$_GIT_ROOT" ] && _GIT_REAL="$(_real "$_GIT_ROOT")"
if [ -z "$_STATE_REAL" ] || [ "$_STATE_REAL" = "/" ]; then
_refuse "it resolves to the filesystem root /" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state"
elif _is_at_or_above "$_STATE_REAL" "$_HOME_REAL"; then
_refuse "it resolves to $_STATE_REAL, which is your home directory or an ancestor of it" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state"
elif _is_at_or_above "$_STATE_REAL" "$_CHECKOUT_REAL"; then
_refuse "it resolves to $_STATE_REAL, which is the gstack checkout $_CHECKOUT_REAL or an ancestor of it" "remove the link with rm -- '$STATE_DIR', or re-run with --keep-state"
elif _is_at_or_above "$_STATE_REAL" "$_GIT_REAL"; then
_refuse "it resolves to $_STATE_REAL, which is the current git repository $_GIT_REAL or an ancestor of it" "run gstack-uninstall from outside $_GIT_REAL, or re-run with --keep-state"
fi
fi
# The resolved root is left in place when it is not the default root.
_LEAVE_ROOT=""
if [ "$KEEP_STATE" -eq 0 ] && [ -e "$RESOLVED_ROOT" ]; then
_RESOLVED_REAL="$(_real "$RESOLVED_ROOT")"
_DEFAULT_REAL="$(_real "$STATE_DIR")"
if [ "$RESOLVED_ROOT" != "$STATE_DIR" ] && { [ -z "$_RESOLVED_REAL" ] || [ "$_RESOLVED_REAL" != "$_DEFAULT_REAL" ]; }; then
_LEAVE_ROOT="$RESOLVED_ROOT"
fi
fi
_report_left_root() {
[ -n "$_LEAVE_ROOT" ] || return 0
local _val
eval "_val=\${$RESOLVED_VAR:-}"
echo "left in place: $_LEAVE_ROOT (selected by $RESOLVED_VAR=$_val)" >&2
echo "fix: after checking it, remove it with rm -rf -- '$_LEAVE_ROOT' (docs: $_STATE_DOC)" >&2
}
# ─── Confirmation ──────────────────────────────────────────── # ─── Confirmation ────────────────────────────────────────────
if [ "$FORCE" -eq 0 ]; then if [ "$FORCE" -eq 0 ]; then
echo "This will remove gstack from your system:" echo "This will remove gstack from your system:"
@@ -70,6 +131,7 @@ if [ "$FORCE" -eq 0 ]; then
[ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*" [ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*"
[ -d "$HOME/.cursor/skills" ] && echo " ~/.cursor/skills/gstack*" [ -d "$HOME/.cursor/skills" ] && echo " ~/.cursor/skills/gstack*"
[ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR" [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR"
[ -n "$_LEAVE_ROOT" ] && echo " (kept) $_LEAVE_ROOT: state root selected by $RESOLVED_VAR; left in place"
if [ -n "$_GIT_ROOT" ]; then if [ -n "$_GIT_ROOT" ]; then
[ -d "$_GIT_ROOT/.claude/skills/gstack" ] && echo " $_GIT_ROOT/.claude/skills/gstack (project-local)" [ -d "$_GIT_ROOT/.claude/skills/gstack" ] && echo " $_GIT_ROOT/.claude/skills/gstack (project-local)"
@@ -125,12 +187,16 @@ if [ -n "$_GIT_ROOT" ] && [ -f "$_GIT_ROOT/.gstack/browse.json" ]; then
stop_browse_daemon "$_GIT_ROOT/.gstack/browse.json" stop_browse_daemon "$_GIT_ROOT/.gstack/browse.json"
fi fi
# Stop daemons tracked in global projects directory # Stop daemons tracked in global projects directories (the resolved root is
if [ -d "$STATE_DIR/projects" ]; then # only read here, never deleted).
_PROJ_ROOTS=("$STATE_DIR")
[ "$RESOLVED_ROOT" != "$STATE_DIR" ] && _PROJ_ROOTS+=("$RESOLVED_ROOT")
for _PROJ_ROOT in "${_PROJ_ROOTS[@]}"; do
[ -d "$_PROJ_ROOT/projects" ] || continue
while IFS= read -r _BJ; do while IFS= read -r _BJ; do
stop_browse_daemon "$_BJ" stop_browse_daemon "$_BJ"
done < <(find "$STATE_DIR/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true) done < <(find "$_PROJ_ROOT/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true)
fi done
# ─── Remove gstack hooks from Claude Code settings ────────── # ─── Remove gstack hooks from Claude Code settings ──────────
# MUST run BEFORE any install-root deletion: SETTINGS_HOOK resolves inside the # MUST run BEFORE any install-root deletion: SETTINGS_HOOK resolves inside the
@@ -400,6 +466,8 @@ for _TMP in /tmp/gstack-latest-version /tmp/gstack-sketch-*.html /tmp/gstack-ske
fi fi
done done
_report_left_root
# ─── Skipped-entry report ─────────────────────────────────── # ─── Skipped-entry report ───────────────────────────────────
# Everything any provenance gate refused to delete (Claude shapes 2/3, # Everything any provenance gate refused to delete (Claude shapes 2/3,
# cursor real dirs) — listed once, at the end, so nothing is silent. # cursor real dirs) — listed once, at the end, so nothing is silent.
+4 -2
View File
@@ -10,7 +10,7 @@
# GSTACK_DIR — override auto-detected gstack root # GSTACK_DIR — override auto-detected gstack root
# GSTACK_REMOTE_URL — override remote VERSION URL (branch-pinned fallback) # GSTACK_REMOTE_URL — override remote VERSION URL (branch-pinned fallback)
# GSTACK_REMOTE_REPO — override remote git URL for ls-remote SHA resolution # GSTACK_REMOTE_REPO — override remote git URL for ls-remote SHA resolution
# GSTACK_STATE_DIR — override ~/.gstack state directory # GSTACK_HOME — relocate the state root (chain: bin/gstack-state-root.sh)
set -euo pipefail set -euo pipefail
# A crash must not read as "up to date" (#1974). With set -e, any unguarded # A crash must not read as "up to date" (#1974). With set -e, any unguarded
@@ -22,7 +22,9 @@ set -E
trap 'rc=$?; echo "CHECK_FAILED gstack-update-check crashed (line $LINENO, rc=$rc) — update status UNKNOWN, not up-to-date"; exit 0' ERR trap 'rc=$?; echo "CHECK_FAILED gstack-update-check crashed (line $LINENO, rc=$rc) — update status UNKNOWN, not up-to-date"; exit 0' ERR
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
STATE_DIR="$_gstack_sr_root"
# Egress receipt helpers (_receipted_curl / _receipted_git). Update checks # Egress receipt helpers (_receipted_curl / _receipted_git). Update checks
# are fail-OPEN: a receipt hiccup warns but never blocks the version check. # are fail-OPEN: a receipt hiccup warns but never blocks the version check.
+12 -4
View File
@@ -12,7 +12,7 @@
# NEVER runs until the user records it in the per-repo trust store: # NEVER runs until the user records it in the per-repo trust store:
# gstack-verify-gate --trust (run from inside the repo) # gstack-verify-gate --trust (run from inside the repo)
# The store maps realpath(repo root) -> sha256(command) at # The store maps realpath(repo root) -> sha256(command) at
# ${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust (flat "path<TAB>hash", # $GSTACK_STATE_ROOT/verify-gate-trust (flat "path<TAB>hash",
# 0600, atomic rewrite). Any edit to the declared command invalidates trust # 0600, atomic rewrite). Any edit to the declared command invalidates trust
# until --trust is run again. Untrusted commands never block the turn. # until --trust is run again. Untrusted commands never block the turn.
# #
@@ -23,7 +23,15 @@
set -uo pipefail set -uo pipefail
TAB="$(printf '\t')" TAB="$(printf '\t')"
STORE="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust" # Hook: source the state-root twin (never spawn gstack-paths). A broken
# install fails open, like every other absence.
_VG_TWIN="$(cd "$(dirname "$0")" && pwd)/gstack-state-root.sh"
if [ ! -f "$_VG_TWIN" ] || ! . "$_VG_TWIN" 2>/dev/null; then
echo "verify-gate: $_VG_TWIN missing; gate skipped (reinstall with ./setup or /gstack-upgrade)"
exit 0
fi
GSTACK_STATE_ROOT="$(gstack_state_root; printf x)"; GSTACK_STATE_ROOT="${GSTACK_STATE_ROOT%x}"
STORE="$GSTACK_STATE_ROOT/verify-gate-trust"
_sha256() { _sha256() {
if command -v shasum >/dev/null 2>&1; then if command -v shasum >/dev/null 2>&1; then
@@ -100,7 +108,7 @@ _json_escape() {
# Args: $1 = root key, $2 = cmd sha256, $3 = cmd verbatim. # Args: $1 = root key, $2 = cmd sha256, $3 = cmd verbatim.
_log_trust_grant() { _log_trust_grant() {
local sec_dir log tty ts local sec_dir log tty ts
sec_dir="${GSTACK_HOME:-$HOME/.gstack}/security" sec_dir="$GSTACK_STATE_ROOT/security"
log="$sec_dir/verify-gate-trust-grants.jsonl" log="$sec_dir/verify-gate-trust-grants.jsonl"
tty=false tty=false
[ -t 0 ] && tty=true [ -t 0 ] && tty=true
@@ -175,7 +183,7 @@ _session_key() {
[ -n "$sid" ] || sid="ppid-$PPID" [ -n "$sid" ] || sid="ppid-$PPID"
_sha256 "$sid|$(_trust_key)" _sha256 "$sid|$(_trust_key)"
} }
ATTEMPTS_DIR="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-attempts" ATTEMPTS_DIR="$GSTACK_STATE_ROOT/verify-gate-attempts"
COUNTER="$ATTEMPTS_DIR/$(_session_key)" COUNTER="$ATTEMPTS_DIR/$(_session_key)"
# A first entry (stop_hook_active=false) starts a fresh blocking episode. # A first entry (stop_hook_active=false) starts a fresh blocking episode.
+2 -2
View File
@@ -18,11 +18,11 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import { mkdirSecure } from './file-permissions'; import { mkdirSecure } from './file-permissions';
import { isPathWithin } from './platform'; import { isPathWithin } from './platform';
import type { TierPaths } from './browser-skills'; import type { TierPaths } from './browser-skills';
import { defaultTierPaths } from './browser-skills'; import { defaultTierPaths } from './browser-skills';
import { resolveStateRoot } from '../../lib/state-root';
// ─── Naming validation ────────────────────────────────────────── // ─── Naming validation ──────────────────────────────────────────
@@ -71,7 +71,7 @@ export function stageSkill(opts: StageSkillOptions): string {
} }
const spawnId = opts.spawnId ?? generateSpawnId(); const spawnId = opts.spawnId ?? generateSpawnId();
const tmpRoot = opts.tmpRoot ?? path.join(os.homedir(), '.gstack', '.tmp'); const tmpRoot = opts.tmpRoot ?? path.join(resolveStateRoot(), '.tmp');
const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`); const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`);
const stagedDir = path.join(wrapperDir, opts.name); const stagedDir = path.join(wrapperDir, opts.name);
+3 -3
View File
@@ -22,8 +22,8 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import * as cp from 'child_process'; import * as cp from 'child_process';
import { resolveStateRoot } from '../../lib/state-root';
// ─── Types ────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────
@@ -85,13 +85,13 @@ export interface TierPaths {
* Project tier requires git or a project hint; returns null when neither resolves. * Project tier requires git or a project hint; returns null when neither resolves.
*/ */
export function defaultTierPaths(opts: { projectRoot?: string; home?: string; bundledRoot?: string } = {}): TierPaths { export function defaultTierPaths(opts: { projectRoot?: string; home?: string; bundledRoot?: string } = {}): TierPaths {
const home = opts.home ?? os.homedir(); const stateRoot = opts.home !== undefined ? path.join(opts.home, '.gstack') : resolveStateRoot();
const projectRoot = opts.projectRoot ?? detectProjectRoot(); const projectRoot = opts.projectRoot ?? detectProjectRoot();
const bundledRoot = opts.bundledRoot ?? detectBundledRoot(); const bundledRoot = opts.bundledRoot ?? detectBundledRoot();
return { return {
project: projectRoot ? path.join(projectRoot, '.gstack', 'browser-skills') : null, project: projectRoot ? path.join(projectRoot, '.gstack', 'browser-skills') : null,
global: path.join(home, '.gstack', 'browser-skills'), global: path.join(stateRoot, 'browser-skills'),
bundled: path.join(bundledRoot, 'browser-skills'), bundled: path.join(bundledRoot, 'browser-skills'),
}; };
} }
+11 -24
View File
@@ -11,10 +11,10 @@
*/ */
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { mkdirSecure } from './file-permissions'; import { mkdirSecure } from './file-permissions';
import { safeUnlinkQuiet } from './error-handling'; import { safeUnlinkQuiet } from './error-handling';
import { readConfigKey, resolveStateRoot } from '../../lib/state-root';
export interface BrowseConfig { export interface BrowseConfig {
projectDir: string; projectDir: string;
@@ -193,39 +193,26 @@ export function readVersionHash(execPath: string = process.execPath): string | n
} }
} }
/** /** The gstack state root: delegates to the shared chain in lib/state-root.ts. */
* Resolve the gstack home directory.
*
* Honors the existing convention used by telemetry.ts and domain-skills.ts:
* 1. GSTACK_HOME env (explicit override)
* 2. $HOME/.gstack (default)
*/
export function resolveGstackHome(): string { export function resolveGstackHome(): string {
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); return resolveStateRoot();
} }
/** /**
* Read one key from the flat-YAML config store at <gstack home>/config.yaml * Read one key from the flat-YAML config store via readConfigKey (the same
* (the shape bin/gstack-config writes: `key: value` lines). Tolerates * reader bin/gstack-config uses, including the most-restrictive merge for
* optional single/double quotes around the value and a trailing `# comment`. * privacy keys such as telemetry). Tolerates optional single/double quotes
* Returns the unquoted value string, or null when the file is missing or * around the value and a trailing `# comment`. Returns the unquoted value
* unreadable or the key is absent. * string, or null when the file is missing or unreadable or the key is absent.
* *
* Single source of truth for flat-YAML key reads — isPairAgentEnabled * Single source of truth for flat-YAML key reads — isPairAgentEnabled
* (pair_agent) and telemetry.ts (telemetry tier) both route through it so * (pair_agent) and telemetry.ts (telemetry tier) both route through it so
* the two consent gates can never drift on parsing semantics. * the two consent gates can never drift on parsing semantics.
*/ */
export function readGstackConfigYamlKey(key: string): string | null { export function readGstackConfigYamlKey(key: string): string | null {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const raw = readConfigKey(key);
try { if (raw === null) return null;
const yaml = fs.readFileSync(path.join(resolveGstackHome(), 'config.yaml'), 'utf-8'); return raw.replace(/\s*#.*$/, '').trim().replace(/^(['"])(.*)\1$/, '$2');
// Last match wins: bin/gstack-config's `get` reads duplicates with
// `tail -1`, and both surfaces must agree on the same line.
const all = [...yaml.matchAll(new RegExp(`^\\s*${escaped}\\s*:\\s*['"]?([^'"#\\n]*?)['"]?\\s*(?:#.*)?$`, 'gm'))];
return all.length > 0 ? all[all.length - 1][1] : null;
} catch {
return null;
}
} }
/** /**
+2 -2
View File
@@ -35,9 +35,9 @@
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { open as fsOpen, constants as fsConstants } from 'fs'; import { open as fsOpen, constants as fsConstants } from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import type { Page } from 'playwright'; import type { Page } from 'playwright';
import { resolveStateRoot } from '../../lib/state-root';
export type SkillState = 'quarantined' | 'active' | 'global'; export type SkillState = 'quarantined' | 'active' | 'global';
export type SkillScope = 'project' | 'global'; export type SkillScope = 'project' | 'global';
@@ -63,7 +63,7 @@ export interface DomainSkillRow {
const PROMOTE_THRESHOLD = 3; const PROMOTE_THRESHOLD = 3;
function gstackHome(): string { function gstackHome(): string {
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); return resolveStateRoot();
} }
function globalFile(): string { function globalFile(): string {
+63
View File
@@ -0,0 +1,63 @@
/**
* Activity feed routes: the SSE session cookie mint, the activity SSE stream
* and the REST history. None reset the idle timer.
*/
import { json, type RouteEntry } from './table';
import { subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from '../activity';
import { createSseEndpoint } from '../sse-helpers';
import { mintSseSessionToken, buildSseSetCookie, SSE_COOKIE_NAME } from '../sse-session-cookie';
export const activityRoutes: RouteEntry[] = [
// ─── SSE session cookie mint (auth required) ──────────────────
//
// Issues a short-lived view-only token in an HttpOnly SameSite=Strict
// cookie so EventSource calls can authenticate without putting the
// root token in a URL. It is not a scoped token and cannot be used
// against /command. The extension calls this once at bootstrap with the
// root Bearer header, then opens EventSource with `withCredentials: true`
// which sends the cookie back automatically.
{
method: 'POST', path: '/sse-session', auth: 'root-bearer', surfaces: ['local'],
handler: () => {
const minted = mintSseSessionToken();
return json({
expiresAt: minted.expiresAt,
cookie: SSE_COOKIE_NAME,
}, { headers: { 'Set-Cookie': buildSseSetCookie(minted.token) } });
},
},
// Activity stream — SSE. Auth: Bearer header OR view-only SSE session
// cookie (EventSource can't send Authorization headers). The ?token= query
// param is NO LONGER accepted — URLs leak to logs/referer/history.
{
method: '*', path: '/activity/stream', auth: 'root-or-sse-cookie', surfaces: ['local'],
handler: (req, { url }) => {
const afterId = parseInt(url.searchParams.get('after') || '0', 10);
// Cleanup contract (abort + enqueue-fail + heartbeat-fail, all
// idempotent) lives in createSseEndpoint; sanitizeReplacer is applied
// to every JSON.stringify inside the helper, so page-content-derived
// fields stay surrogate-safe per the CLAUDE.md egress invariant.
return createSseEndpoint(req, {
initialReplay: (send) => {
const { entries, gap, gapFrom, availableFrom } = getActivityAfter(afterId);
if (gap) send('gap', { gapFrom, availableFrom });
for (const entry of entries) send('activity', entry);
},
subscribe,
liveEventName: 'activity',
});
},
},
// Activity history — REST
{
method: '*', path: '/activity/history', auth: 'root-bearer', surfaces: ['local'],
handler: (_req, { url }) => {
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const { entries, totalAdded } = getActivityHistory(limit);
return json({ entries, totalAdded, subscribers: getSubscriberCount() });
},
},
];
+154
View File
@@ -0,0 +1,154 @@
/**
* Command routes: POST /command (one command; the only non-/connect tunnel
* route) and POST /batch (N commands in one round trip). Both accept root and
* scoped tokens and run through the full command security pipeline. Also owns
* the tunnel command allowlist.
*/
import { type RouteEntry, jsonError } from './table';
import { canonicalizeCommand } from '../commands';
import { hasOutArg } from '../read-commands';
import { emitActivity } from '../activity';
import { logTunnelDenial } from '../tunnel-denial-log';
import { sanitizeBody, stripLoneSurrogateEscapes } from '../sanitize';
/**
* Commands reachable via POST /command over the tunnel surface. A paired
* remote agent can drive the browser (goto, click, text, etc.) but cannot
* configure the daemon, bootstrap new sessions, import cookies, or reach
* extension-inspector state. This allowlist maps to the eng-review decision
* logged in the CEO plan for sec-wave v1.6.0.0.
*/
export const TUNNEL_COMMANDS = new Set<string>([
// Original 17
'goto', 'click', 'text', 'screenshot',
'html', 'links', 'forms', 'accessibility',
'attrs', 'media', 'data',
'scroll', 'press', 'type', 'select', 'wait', 'eval',
// Tab + navigation primitives operator docs and CLI hints already promised
'newtab', 'tabs', 'back', 'forward', 'reload',
// Read/inspect/write operators paired agents need to be useful
'snapshot', 'fill', 'url', 'closetab',
]);
/**
* Pure gate: returns true iff the command is reachable over the tunnel surface.
* Canonicalizes the command (so aliases hit the same set) and returns false
* for null/undefined input.
*
* `args` is consulted so an `--out` invocation (e.g. `eval --out <file>`) is
* NEVER tunnel-dispatchable: `--out` turns an otherwise-readable command into a
* local-disk WRITE, and the tunnel surface never grants disk-write capability to
* remote paired agents. Omitting `args` preserves the old command-only behavior.
*/
export function canDispatchOverTunnel(command: string | undefined | null, args?: string[]): boolean {
if (typeof command !== 'string' || command.length === 0) return false;
if (Array.isArray(args) && hasOutArg(args)) return false;
const cmd = canonicalizeCommand(command);
return TUNNEL_COMMANDS.has(cmd);
}
export const commandRoutes: RouteEntry[] = [
// ─── Batch endpoint — N commands, 1 HTTP round-trip ─────────────
// Executes commands sequentially through the full security pipeline.
// Designed for remote agents where tunnel latency dominates.
{
method: 'POST', path: '/batch', auth: 'scoped', surfaces: ['local'],
handler: async (req, { tokenInfo }, ctx) => {
const { browserManager } = ctx;
ctx.resetIdleTimer();
const body = await req.json();
const { commands } = body;
if (!Array.isArray(commands) || commands.length === 0) return jsonError(400, '"commands" must be a non-empty array');
if (commands.length > 50) return jsonError(400, 'Max 50 commands per batch');
const startTime = Date.now();
emitActivity({
type: 'command_start',
command: 'batch',
args: [`${commands.length} commands`],
url: browserManager.getCurrentUrl(),
tabs: browserManager.getTabCount(),
mode: browserManager.getConnectionMode(),
clientId: tokenInfo?.clientId,
});
const results: Array<{ index: number; status: number; result: string; command: string; tabId?: number }> = [];
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
if (!cmd || typeof cmd.command !== 'string') {
results.push({ index: i, status: 400, result: JSON.stringify({ error: 'Missing "command" field' }), command: '' });
continue;
}
// Reject nested batches
if (cmd.command === 'batch') {
results.push({ index: i, status: 400, result: JSON.stringify({ error: 'Nested batch commands are not allowed' }), command: 'batch' });
continue;
}
const cr = await ctx.commands.handleInternal(
{ command: cmd.command, args: cmd.args, tabId: cmd.tabId },
tokenInfo,
{ skipRateCheck: true, skipActivity: true },
);
// Sanitize lone surrogates per-result (#1440 — /batch bypasses the
// handleCommand chokepoint, so it needs its own sanitization).
const safeResult = typeof cr.result === 'string' ? sanitizeBody(cr.result, !!cr.json) : cr.result;
results.push({
index: i,
status: cr.status,
result: safeResult,
command: cmd.command,
tabId: cmd.tabId,
});
}
const duration = Date.now() - startTime;
emitActivity({
type: 'command_end',
command: 'batch',
args: [`${commands.length} commands`],
url: browserManager.getCurrentUrl(),
duration,
status: 'ok',
result: `${results.filter(r => r.status === 200).length}/${commands.length} succeeded`,
tabs: browserManager.getTabCount(),
mode: browserManager.getConnectionMode(),
clientId: tokenInfo?.clientId,
});
// Sanitize the JSON envelope a second time (defense in depth) — catches
// any \uXXXX escape sequences for lone surrogates that survived the
// per-result pass.
const batchBody = stripLoneSurrogateEscapes(JSON.stringify({
results,
duration,
total: commands.length,
succeeded: results.filter(r => r.status === 200).length,
failed: results.filter(r => r.status !== 200).length,
}));
return new Response(batchBody, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
},
},
// ─── Command endpoint ───────────────────────────────────────────
{
method: 'POST', path: '/command', auth: 'scoped', surfaces: ['local', 'tunnel'],
handler: async (req, { url, surface, tokenInfo }, ctx) => {
ctx.resetIdleTimer();
const body = await req.json() as any;
// Tunnel surface: only commands in TUNNEL_COMMANDS are allowed.
// Paired remote agents drive the browser but cannot configure the
// daemon, launch new browsers, import cookies, or rotate tokens.
if (surface === 'tunnel' && !canDispatchOverTunnel(body?.command, body?.args)) {
logTunnelDenial(req, url, `disallowed_command:${body?.command}`);
return jsonError(403, `Command '${body?.command}' is not allowed over the tunnel surface`, {
hint: `Tunnel commands: ${[...TUNNEL_COMMANDS].sort().join(', ')}. Note: --out (disk write) is never allowed over the tunnel.`,
});
}
return ctx.commands.handle(body, tokenInfo);
},
},
];
+123
View File
@@ -0,0 +1,123 @@
/**
* Daemon routes outside the named areas: the cookie-picker sub-router, the
* welcome page, the pinned-origin token bootstrap, liveness, refs and the
* memory diagnostic.
*/
import * as fs from 'fs';
import * as path from 'path';
import { resolveStateRoot } from '../../../lib/state-root';
import { json, type RouteEntry } from './table';
import { handleCookiePickerRoute } from '../cookie-picker-routes';
import { sanitizeReplacer } from '../sanitize';
function resolveWelcomePath(): string | null {
// Gate GSTACK_SLUG on a strict regex BEFORE interpolating it into the
// filesystem path. Without this, a slug like "../../etc/passwd" would
// resolve to ~/.gstack/projects/../../etc/passwd/... — path traversal.
const rawSlug = process.env.GSTACK_SLUG || 'unknown';
const slug = /^[a-z0-9_-]+$/.test(rawSlug) ? rawSlug : 'unknown';
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
const projectWelcome = path.join(resolveStateRoot(), 'projects', slug, 'designs', 'welcome-page-20260331', 'finalized.html');
if (fs.existsSync(projectWelcome)) return projectWelcome;
// Fallback: built-in welcome page from gstack install. Reject SKILL_ROOT
// values containing '..' for the same defense-in-depth reason.
const rawSkillRoot = process.env.GSTACK_SKILL_ROOT || `${homeDir}/.claude/skills/gstack`;
if (rawSkillRoot.includes('..')) return null;
const builtinWelcome = `${rawSkillRoot}/browse/src/welcome.html`;
if (fs.existsSync(builtinWelcome)) return builtinWelcome;
return null;
}
export const coreRoutes: RouteEntry[] = [
// Cookie picker sub-router — HTML page unauthenticated, data/action routes require auth
{
method: '*', path: '/cookie-picker', prefix: true, auth: 'handler', surfaces: ['local'],
handlerAuth: 'sub-router in cookie-picker-routes.ts: OPTIONS preflight open; GET /cookie-picker needs a one-time code or picker session cookie (403 text); every other /cookie-picker/* needs the root bearer or a picker session (401 Unauthorized)',
handler: (req, { url }, ctx) => handleCookiePickerRoute(url, req, ctx.browserManager, ctx.bootstrapRootToken),
},
// Welcome page — served when GStack Browser launches in headed mode
{
method: '*', path: '/welcome', auth: 'none', surfaces: ['local'],
handler: () => {
const welcomePath = resolveWelcomePath();
if (welcomePath) {
try {
const html = fs.readFileSync(welcomePath, 'utf-8');
return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
} catch (err: any) {
console.error('[browse] Failed to read welcome page:', welcomePath, err.message);
}
}
// No welcome page found — serve a simple fallback (avoid ERR_UNSAFE_REDIRECT on Windows)
return new Response(
`<!DOCTYPE html><html><head><title>GStack Browser</title>
<style>body{background:#111;color:#fff;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;}
.msg{text-align:center;opacity:.7;}.gold{color:#f5a623;font-size:2em;margin-bottom:12px;}</style></head>
<body><div class="msg"><div class="gold">◈</div><p>GStack Browser ready.</p><p style="font-size:.85em">Waiting for commands from Claude Code.</p></div></body></html>`,
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
},
},
// ─── POST /extension-token — pinned-origin token bootstrap ──────
//
// The ONLY endpoint that hands out AUTH_TOKEN. The token is released only
// to the one extension identity we ship: the Origin header must be exactly
// `chrome-extension://<GSTACK_EXTENSION_ID>` and the Host must be loopback
// (the extension-origin gate). Chrome sets Origin on cross-origin POSTs
// from extension contexts and web pages cannot forge a chrome-extension://
// Origin. Local listener only: NEVER added to TUNNEL_PATHS.
{
method: 'POST', path: '/extension-token', auth: 'extension-origin', surfaces: ['local'],
handler: (_req, _r, ctx) => json({ token: ctx.bootstrapRootToken }),
},
// Health check — no auth required, does NOT reset idle timer. NEVER carries
// a token in any mode: token bootstrap is POST /extension-token and shell
// auth is POST /pty-session. Liveness/status only.
{
method: '*', path: '/health', auth: 'none', surfaces: ['local'],
handler: async (_req, _r, ctx) => {
const { browserManager } = ctx;
const healthy = await browserManager.isHealthy();
return json({
status: healthy ? 'healthy' : 'unhealthy',
mode: browserManager.getConnectionMode(),
uptime: Math.floor((Date.now() - ctx.startTime) / 1000),
tabs: browserManager.getTabCount(),
// No `security` field (#2557): the live defenses report through
// their own call sites, not through /health.
// Terminal-agent discovery. ONLY a port number — never a token.
// Tokens flow via the /pty-session HttpOnly cookie path.
terminalPort: ctx.terminal.readPort(),
});
},
},
// Refs endpoint — does NOT reset idle timer
{
method: '*', path: '/refs', auth: 'root-bearer', surfaces: ['local'],
handler: (_req, _r, { browserManager }) => json({
refs: browserManager.getRefMap(),
url: browserManager.getCurrentUrl(),
mode: browserManager.getConnectionMode(),
}),
},
// GET /memory — diagnostic snapshot, does NOT reset idle. Root-bearer: it
// sat behind the if-chain's blanket root-bearer check, so the SSE cookie
// its handler also accepted never reached it.
{
method: 'GET', path: '/memory', auth: 'root-bearer', surfaces: ['local'],
handler: async (_req, _r, ctx) => {
const { buildMemorySnapshotJson } = await import('../memory-command');
const snapshot = await buildMemorySnapshotJson(ctx.browserManager);
// sanitizeReplacer is required at every JSON egress that ships
// page-content-derived strings — tab.url and tab.title come from page
// content.
return json(snapshot, { replacer: sanitizeReplacer });
},
},
];
+47
View File
@@ -0,0 +1,47 @@
/**
* GET /file — serve a downloaded file from the temp roots so remote agents
* can retrieve screenshots, PDFs and media. Accepts root and scoped tokens.
*/
import * as fs from 'fs';
import * as path from 'path';
import { jsonError, type RouteEntry } from './table';
import { validateTempPath } from '../path-security';
const MIME_MAP: Record<string, string> = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
'.avif': 'image/avif',
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
'.pdf': 'application/pdf', '.json': 'application/json',
'.html': 'text/html', '.txt': 'text/plain', '.mhtml': 'message/rfc822',
};
export const fileRoutes: RouteEntry[] = [
{
method: 'GET', path: '/file', auth: 'scoped', surfaces: ['local'],
handler: (_req, { url }, ctx) => {
const filePath = url.searchParams.get('path');
if (!filePath) return jsonError(400, 'Missing "path" query parameter');
try {
validateTempPath(filePath);
} catch (err: any) {
return jsonError(403, err.message);
}
if (!fs.existsSync(filePath)) return jsonError(404, 'File not found');
const stat = fs.statSync(filePath);
if (stat.size > 200 * 1024 * 1024) return jsonError(413, 'File too large (max 200MB)');
const contentType = MIME_MAP[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
ctx.resetIdleTimer();
return new Response(Bun.file(filePath), {
headers: {
'Content-Type': contentType,
'Content-Length': String(stat.size),
'Content-Disposition': `inline; filename="${path.basename(filePath)}"`,
'Cache-Control': 'no-cache',
},
});
},
},
];
+27
View File
@@ -0,0 +1,27 @@
/**
* The browse daemon's route table, in dispatch order. See ./table.ts for the
* entry shape, auth kinds and the unmatched fallthrough.
*/
import type { RouteEntry } from './table';
import { pairingRoutes } from './pairing';
import { coreRoutes } from './core';
import { ptyRoutes } from './pty';
import { tokenRoutes } from './tokens';
import { tunnelRoutes } from './tunnel';
import { activityRoutes } from './activity';
import { commandRoutes } from './commands';
import { fileRoutes } from './files';
import { inspectorRoutes } from './inspector';
export const ROUTES: readonly RouteEntry[] = [
...pairingRoutes,
...coreRoutes,
...ptyRoutes,
...tokenRoutes,
...tunnelRoutes,
...activityRoutes,
...commandRoutes,
...fileRoutes,
...inspectorRoutes,
];
+132
View File
@@ -0,0 +1,132 @@
/**
* CSS inspector routes and the in-memory inspector state they share: pick,
* read, apply, reset, history and the inspector SSE stream.
*
* GET /inspector/events is root-bearer: it sat behind the if-chain's blanket
* root-bearer check, so the SSE cookie its handler also accepted never reached
* it. The declaration keeps that behavior.
*/
import { json, jsonError, type RouteEntry } from './table';
import { inspectElement, modifyStyle, resetModifications, getModificationHistory, type InspectorResult } from '../cdp-inspector';
import { createSseEndpoint } from '../sse-helpers';
let inspectorData: InspectorResult | null = null;
let inspectorTimestamp = 0;
type InspectorSubscriber = (event: any) => void;
const inspectorSubscribers = new Set<InspectorSubscriber>();
/** Diagnostic accessor used by the $B memory snapshot. */
export function getInspectorSubscriberCount(): number {
return inspectorSubscribers.size;
}
/** Drops every inspector SSE subscriber (daemon shutdown). */
export function clearInspectorSubscribers(): void {
inspectorSubscribers.clear();
}
function emitInspectorEvent(event: any): void {
for (const notify of inspectorSubscribers) {
queueMicrotask(() => {
try { notify(event); } catch (err: any) {
console.error('[browse] Inspector event subscriber threw:', err.message);
}
});
}
}
export const inspectorRoutes: RouteEntry[] = [
// POST /inspector/pick — receive element pick from extension, run CDP inspection
{
method: 'POST', path: '/inspector/pick', auth: 'root-bearer', surfaces: ['local'],
handler: async (req, _r, ctx) => {
const body = await req.json();
const { selector, activeTabUrl } = body;
if (!selector) return jsonError(400, 'Missing selector');
try {
const page = ctx.browserManager.getPage();
const result = await inspectElement(page, selector);
inspectorData = result;
inspectorTimestamp = Date.now();
// Also store on browserManager for CLI access
(ctx.browserManager as any)._inspectorData = result;
(ctx.browserManager as any)._inspectorTimestamp = inspectorTimestamp;
emitInspectorEvent({ type: 'pick', selector, timestamp: inspectorTimestamp });
return json(result);
} catch (err: any) {
return jsonError(500, err.message);
}
},
},
// GET /inspector — return latest inspector data
{
method: 'GET', path: '/inspector', auth: 'root-bearer', surfaces: ['local'],
handler: () => {
if (!inspectorData) return json({ data: null });
const stale = inspectorTimestamp > 0 && (Date.now() - inspectorTimestamp > 60000);
return json({ data: inspectorData, timestamp: inspectorTimestamp, stale });
},
},
// POST /inspector/apply — apply a CSS modification
{
method: 'POST', path: '/inspector/apply', auth: 'root-bearer', surfaces: ['local'],
handler: async (req, _r, ctx) => {
const body = await req.json();
const { selector, property, value } = body;
if (!selector || !property || value === undefined) return jsonError(400, 'Missing selector, property, or value');
try {
const page = ctx.browserManager.getPage();
const mod = await modifyStyle(page, selector, property, value);
emitInspectorEvent({ type: 'apply', modification: mod, timestamp: Date.now() });
return json(mod);
} catch (err: any) {
return jsonError(500, err.message);
}
},
},
// POST /inspector/reset — clear all modifications
{
method: 'POST', path: '/inspector/reset', auth: 'root-bearer', surfaces: ['local'],
handler: async (_req, _r, ctx) => {
try {
const page = ctx.browserManager.getPage();
await resetModifications(page);
emitInspectorEvent({ type: 'reset', timestamp: Date.now() });
return json({ ok: true });
} catch (err: any) {
return jsonError(500, err.message);
}
},
},
// GET /inspector/history — return modification list
{
method: 'GET', path: '/inspector/history', auth: 'root-bearer', surfaces: ['local'],
handler: () => json({ history: getModificationHistory() }),
},
// GET /inspector/events — SSE for inspector state changes
{
method: 'GET', path: '/inspector/events', auth: 'root-bearer', surfaces: ['local'],
handler: (req) => {
// Cleanup contract (abort + enqueue-fail + heartbeat-fail, idempotent)
// lives in createSseEndpoint; sanitizeReplacer is applied to every
// JSON.stringify inside the helper.
return createSseEndpoint(req, {
initialReplay: inspectorData
? (send) => send('state', { data: inspectorData, timestamp: inspectorTimestamp })
: undefined,
subscribe: (notify) => {
inspectorSubscribers.add(notify);
return () => inspectorSubscribers.delete(notify);
},
liveEventName: 'inspector',
});
},
},
];
+158
View File
@@ -0,0 +1,158 @@
/**
* Pair-agent ceremony routes: the /connect alive probe and setup-key
* exchange (the only unauthenticated tunnel endpoints) and root-only /pair.
*/
import { json, jsonError, type RouteEntry } from './table';
import {
checkConnectRateLimit, exchangeSetupKey, createSetupKey, revokeToken, revokeSetupKeys,
getClientSession, grantReducesAccess, assertValidClientId, assertValidTokenOptions,
DEFAULT_PAIR_SCOPES, InvalidScopeError, ReservedClientIdError, type ScopeCategory,
} from '../token-registry';
export const pairingRoutes: RouteEntry[] = [
// GET /connect — alive probe. Unauth on both surfaces. Used by /pair and
// /tunnel/start to detect dead ngrok tunnels via the tunnel URL, since
// /health is not tunnel-reachable under the dual-listener design.
//
// Shares the same rate limit as POST /connect — otherwise a tunnel caller
// can probe unlimited GETs, which makes the endpoint a free
// daemon-enumeration surface.
{
method: 'GET', path: '/connect', auth: 'none', surfaces: ['local', 'tunnel'],
handler: () => {
if (!checkConnectRateLimit()) return jsonError(429, 'Rate limited');
return json({ alive: true });
},
},
// ─── /connect — setup key exchange for /pair-agent ceremony ────
{
method: 'POST', path: '/connect', auth: 'none', surfaces: ['local', 'tunnel'],
handler: async (req) => {
if (!checkConnectRateLimit()) return jsonError(429, 'Too many connection attempts. Wait 1 minute.');
try {
const connectBody = await req.json() as { setup_key?: string };
if (!connectBody.setup_key) return jsonError(400, 'Missing setup_key');
const session = exchangeSetupKey(connectBody.setup_key);
if (!session) return jsonError(401, 'Invalid, expired, or already-used setup key');
console.log(`[browse] Remote agent connected: ${session.clientId} (scopes: ${session.scopes.join(',')})`);
return json({
token: session.token,
expires: session.expiresAt,
scopes: session.scopes,
agent: session.clientId,
});
} catch {
return jsonError(400, 'Invalid request body');
}
},
},
// ─── /pair — create setup key for pair-agent ceremony (root-only) ───
{
method: 'POST', path: '/pair', auth: 'root-token', surfaces: ['local'],
handler: async (req, _r, ctx) => {
try {
const pairBody = await req.json() as any;
// Reject a reserved/invalid clientId up front (createSetupKey enforces
// it too, but this makes the 400 unambiguous and skips the teardown).
if (pairBody.clientId !== undefined) assertValidClientId(pairBody.clientId);
// Default: DEFAULT_PAIR_SCOPES (full page access). The trust boundary
// is the pairing ceremony itself, not the scope. --control adds
// browser-wide destructive commands (stop, restart, disconnect).
// --restrict limits scope — but can never grant control: that scope
// stays behind the explicit control flag.
if (!pairBody.control && !pairBody.admin
&& Array.isArray(pairBody.scopes) && pairBody.scopes.includes('control')) {
return jsonError(400, 'The control scope requires the control flag (--control); it cannot be granted via a scopes list.');
}
const scopes = pairBody.control || pairBody.admin
? [...DEFAULT_PAIR_SCOPES, 'control' as const]
: ((pairBody.scopes || [...DEFAULT_PAIR_SCOPES]) as ScopeCategory[]);
// D1: a re-pair supersedes prior grants. ALWAYS drop stale setup keys
// so a superseded broad key can never be exchanged — this closes the
// shadow-key hole where a narrowing re-pair before the agent connects
// would otherwise leave the old broad key live. Revoke the live
// SESSION only when the new grant actually reduces access, so a
// broaden/refresh never strands a working agent mid-task. Compare
// against the resolved grant (not raw pairBody) so dropping 'control'
// or a default re-pair is classified correctly. Revoke runs BEFORE
// createSetupKey — revokeToken deletes all of a clientId's tokens, so
// minting first would nuke the fresh key.
const grant = {
scopes: [...scopes] as ScopeCategory[],
domains: pairBody.domains as string[] | undefined,
rateLimit: pairBody.rateLimit ?? 10,
tabPolicy: 'own-only' as const,
};
// Validate BEFORE any revoke (createSetupKey validates too, but that
// runs after the teardown below). A bad scope or negative rateLimit
// must 400 without knocking a live session offline — otherwise a
// reducing re-pair with a typo (--restrict red) destroys the session
// and mints no replacement.
assertValidTokenOptions(grant.scopes, grant.rateLimit);
const priorSession = pairBody.clientId ? getClientSession(pairBody.clientId) : null;
let superseded: { tokens_deleted: number; tabs_released: number } | undefined;
if (priorSession && grantReducesAccess(priorSession, grant)) {
const tokensDeleted = revokeToken(pairBody.clientId);
const tabsReleased = ctx.browserManager.releaseClientTabs(pairBody.clientId).length;
superseded = { tokens_deleted: tokensDeleted, tabs_released: tabsReleased };
console.log(`[browse] Superseded ${tokensDeleted} token(s), released ${tabsReleased} tab(s) for reducing re-pair: ${pairBody.clientId}`);
} else if (pairBody.clientId) {
revokeSetupKeys(pairBody.clientId);
// No live session, but tab ownership outlives token expiry: free any
// tabs orphaned by an expired session so this re-pair can't inherit
// an earlier incarnation's authenticated pages (mirrors DELETE
// /token's unconditional release). A live-session broaden keeps its
// tabs — the working agent still owns them.
if (!priorSession) ctx.browserManager.releaseClientTabs(pairBody.clientId);
}
const setupKey = createSetupKey({
clientId: pairBody.clientId,
scopes: [...scopes],
domains: pairBody.domains,
rateLimit: pairBody.rateLimit,
});
// Verify tunnel is actually alive before reporting it (ngrok may have died externally).
// Probe via GET /connect — under dual-listener /health is NOT on the tunnel allowlist,
// so the old probe would return 404 and always mark the tunnel as dead.
let verifiedTunnelUrl: string | null = null;
const tunnel = ctx.tunnel.state();
if (tunnel.active && tunnel.url) {
try {
const probe = await fetch(`${tunnel.url}/connect`, {
method: 'GET',
headers: { 'ngrok-skip-browser-warning': 'true' },
signal: AbortSignal.timeout(5000),
});
if (probe.ok) {
verifiedTunnelUrl = tunnel.url;
} else {
console.warn(`[browse] Tunnel probe failed (HTTP ${probe.status}), marking tunnel as dead`);
await ctx.tunnel.close();
}
} catch {
console.warn('[browse] Tunnel probe timed out or unreachable, marking tunnel as dead');
await ctx.tunnel.close();
}
}
return json({
setup_key: setupKey.token,
expires_at: setupKey.expiresAt,
scopes: setupKey.scopes,
tunnel_url: verifiedTunnelUrl,
server_url: `http://127.0.0.1:${ctx.browsePort}`,
...(superseded ? { superseded } : {}),
});
} catch (err) {
// Name the caller's typo (bad scope, negative rateLimit, reserved
// clientId) instead of hiding it behind the generic body error.
if (err instanceof InvalidScopeError || err instanceof ReservedClientIdError) {
return jsonError(400, err.message);
}
return jsonError(400, 'Invalid request body');
}
},
},
];
+254
View File
@@ -0,0 +1,254 @@
/**
* Terminal (PTY) routes: session mint, re-attach, restart, dispose, lease
* refresh from terminal-agent, and the pre-inject prompt-injection scan.
* All are local-only: the tunnel surface 404s them by default-deny.
*/
import { json, jsonError, type RouteEntry } from './table';
import { mintPtySessionToken, buildPtySetCookie, revokePtySessionToken } from '../pty-session-cookie';
import { mintLease, validateLease, refreshLease, revokeLease } from '../pty-session-lease';
import { isSidecarAvailable, scanWithSidecar } from '../security-sidecar-client';
import { sanitizeReplacer } from '../sanitize';
async function readJsonOrNull(req: Request): Promise<any> {
try { return await req.json(); } catch { return null; }
}
export const ptyRoutes: RouteEntry[] = [
// ─── /pty-session — mint sessionId + lease + attachToken ─────────
//
// v1.44+ four-tuple shape:
// { terminalPort, sessionId, attachToken, leaseExpiresAt }
//
// - sessionId : stable, non-secret. Safe to log. Identifies "this
// terminal" across re-attaches.
// - attachToken : short-lived (30 min wall, single attach in practice
// since the agent revokes on WS close). Bearer for
// the /ws upgrade.
// - leaseExpiresAt: client-visible deadline for the lease. Re-attach
// only works inside this window.
//
// The lease + attachToken are minted together so a successful
// /pty-session is one round trip. Re-attach mints a fresh attachToken
// for the SAME sessionId via /pty-session/reattach.
{
method: 'POST', path: '/pty-session', auth: 'root-bearer', surfaces: ['local'],
handler: async (_req, _r, ctx) => {
const port = ctx.terminal.readPort();
if (!port) return jsonError(503, 'terminal-agent not ready');
const lease = mintLease();
const minted = mintPtySessionToken();
const granted = await ctx.terminal.grantToken(minted.token, lease.sessionId);
if (!granted) {
revokePtySessionToken(minted.token);
revokeLease(lease.sessionId);
return jsonError(503, 'failed to grant terminal session');
}
return json({
terminalPort: port,
sessionId: lease.sessionId,
attachToken: minted.token,
leaseExpiresAt: lease.expiresAt,
// Legacy alias — extensions still on the v1.43 wire shape keep
// working. Drop after one minor release once dogfood confirms.
ptySessionToken: minted.token,
expiresAt: minted.expiresAt,
}, { headers: { 'Set-Cookie': buildPtySetCookie(minted.token) } });
},
},
// ─── /pty-session/reattach — mint fresh attachToken for existing sessionId
//
// Validates the lease (rejects unknown/expired sessionId with 410 Gone),
// mints a fresh short-lived attachToken bound to the same sessionId, and
// pushes it to the agent. The client opens a new WS with the new token;
// the agent matches the sessionId binding and re-attaches to the existing
// PtySession (kept alive for the 60s detach window).
{
method: 'POST', path: '/pty-session/reattach', auth: 'root-bearer', surfaces: ['local'],
handler: async (req, _r, ctx) => {
const port = ctx.terminal.readPort();
if (!port) return jsonError(503, 'terminal-agent not ready');
const body = await readJsonOrNull(req);
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
const v = sessionId ? validateLease(sessionId) : { ok: false as const };
// 410 Gone — session window has closed (lease expired or never
// existed). Client must fall back to /pty-session for a brand-new
// session.
if (!v.ok) return jsonError(410, 'lease expired or unknown');
const minted = mintPtySessionToken();
const granted = await ctx.terminal.grantToken(minted.token, sessionId!);
if (!granted) {
revokePtySessionToken(minted.token);
return jsonError(503, 'failed to grant attach token');
}
return json({
terminalPort: port,
sessionId,
attachToken: minted.token,
leaseExpiresAt: v.ok ? v.expiresAt : 0,
});
},
},
// ─── /pty-restart — one-transaction kill + fresh mint ────────────
//
// The Restart button. Synchronously disposes the caller's existing
// PtySession on the agent, revokes the old lease, mints a fresh
// sessionId + lease + attachToken, and returns the new 4-tuple in
// one response. Zero race window between kill and mint.
{
method: 'POST', path: '/pty-restart', auth: 'root-bearer', surfaces: ['local'],
handler: async (req, _r, ctx) => {
const port = ctx.terminal.readPort();
if (!port) return jsonError(503, 'terminal-agent not ready');
const body = await readJsonOrNull(req);
const oldSessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
// Best-effort dispose. Missing/unknown sessionId is non-fatal —
// the client may be doing a "restart from scratch" with no prior
// session (e.g. ENDED state). The fresh mint always proceeds.
if (oldSessionId) {
await ctx.terminal.restartSession(oldSessionId);
revokeLease(oldSessionId);
}
const lease = mintLease();
const minted = mintPtySessionToken();
const granted = await ctx.terminal.grantToken(minted.token, lease.sessionId);
if (!granted) {
revokePtySessionToken(minted.token);
revokeLease(lease.sessionId);
return jsonError(503, 'failed to grant terminal session');
}
return json({
terminalPort: port,
sessionId: lease.sessionId,
attachToken: minted.token,
leaseExpiresAt: lease.expiresAt,
});
},
},
// ─── /pty-dispose — explicit teardown (pagehide / browser quit) ──
//
// sendBeacon-compatible: accepts the auth token in the BODY so the
// extension's pagehide handler can fire it without setting headers
// (sendBeacon doesn't support custom headers). Without this, every
// browser quit + sidebar close leaves a zombie PTY alive for the 60s
// detach window.
{
method: 'POST', path: '/pty-dispose', auth: 'handler', surfaces: ['local'],
handlerAuth: 'root token as Authorization: Bearer or as the JSON body authToken (sendBeacon); 401 Unauthorized otherwise',
handler: async (req, _r, ctx) => {
const body = await readJsonOrNull(req);
const authTokenFromBody = typeof body?.authToken === 'string' ? body.authToken : null;
const header = req.headers.get('authorization');
const headerToken = header?.startsWith('Bearer ') ? header.slice(7) : null;
if (!ctx.isRootTokenValue(headerToken) && !ctx.isRootTokenValue(authTokenFromBody)) {
return jsonError(401, 'Unauthorized');
}
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
if (sessionId) {
await ctx.terminal.restartSession(sessionId);
revokeLease(sessionId);
}
return json({ ok: true });
},
},
// ─── /internal/lease-refresh — loopback from terminal-agent on keepalive
//
// PTY-only idle reset: the headless daemon's idle timer must reset only
// on active PTY usage, not on every passive SSE consumer. Terminal-agent
// calls this endpoint (lazily, only when its cached lease is within 5 min
// of expiry) on its 25s keepalive cycle. Refreshing the lease here also
// bumps lastActivity so the daemon stays alive while a sidebar terminal
// is actively in use. Bound to the root authToken so an external caller
// can't refresh another user's lease. Body: {sessionId}.
{
method: 'POST', path: '/internal/lease-refresh', auth: 'root-bearer', surfaces: ['local'],
handler: async (req, _r, ctx) => {
const body = await readJsonOrNull(req);
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
const r = sessionId ? refreshLease(sessionId) : { ok: false as const };
if (!r.ok) return jsonError(410, 'lease expired or unknown');
ctx.resetIdleTimer();
return json({ ok: true, expiresAt: r.expiresAt });
},
},
// ─── /pty-inject-scan — pre-inject prompt-injection scan for the
// extension's gstackInjectToTerminal callers. The extension routes
// every page-derived text through this endpoint BEFORE writing to
// the PTY (#1370). Sidecar absence degrades to L4 unavailable
// (extension shows WARN + user confirm per D7).
{
method: 'POST', path: '/pty-inject-scan', auth: 'root-bearer', surfaces: ['local'],
handler: async (req) => {
const reply = (body: unknown, status: number) => json(body, { status, replacer: sanitizeReplacer });
// 64KB request cap. Defense against accidentally posting an
// entire page DOM into the PTY path.
const contentLength = Number(req.headers.get('content-length') || '0');
if (contentLength > 64 * 1024) return reply({ error: 'payload-too-large', limit: 65536 }, 413);
let body: { text?: unknown; origin?: unknown } = {};
try {
body = (await req.json()) as { text?: unknown; origin?: unknown };
} catch {
return reply({ error: 'malformed-json' }, 400);
}
const text = typeof body.text === 'string' ? body.text : '';
if (text.length === 0) return reply({ error: 'missing-text' }, 400);
// L1-L3 honest accounting:
// - URL blocklist forced to BLOCK in PTY context (override
// BROWSE_CONTENT_FILTER default — page-derived text in the
// REPL is a higher-risk surface than ordinary tool output).
// - L4 ML classifier via the sidecar when available.
// - L1-L3 envelope/datamarking is INFORMATIONAL only; the
// verdict is driven by the URL blocklist + L4.
// See CLAUDE.md "Sidebar security stack".
let verdict: 'PASS' | 'WARN' | 'BLOCK' = 'PASS';
const reasons: string[] = [];
// Quick URL-blocklist check: text containing a known bad-actor
// domain → BLOCK.
if (/(\bbit\.ly|\btinyurl\.com|\bdiscord\.gg)/i.test(text)) {
verdict = 'BLOCK';
reasons.push('url-blocklist');
}
const sidecarAvail = isSidecarAvailable();
let l4: { available: boolean; verdict?: unknown; error?: string } = {
available: sidecarAvail.available,
};
if (sidecarAvail.available && verdict !== 'BLOCK') {
try {
const { verdict: layerVerdict } = await scanWithSidecar(text, { timeoutMs: 5000 });
l4 = { available: true, verdict: layerVerdict };
// LayerSignal shape: { verdict: 'safe'|'suspicious'|'unsafe', ... }
const lv = (layerVerdict as { verdict?: string })?.verdict;
if (lv === 'unsafe') {
verdict = 'BLOCK';
reasons.push('l4-unsafe');
} else if (lv === 'suspicious') {
verdict = 'WARN';
reasons.push('l4-suspicious');
}
} catch (err) {
l4 = { available: false, error: err instanceof Error ? err.message : String(err) };
// L4 failure during scan: degrade to WARN per D7.
if (verdict === 'PASS') {
verdict = 'WARN';
reasons.push('l4-unavailable');
}
}
} else if (!sidecarAvail.available && verdict === 'PASS') {
verdict = 'WARN';
reasons.push(`l4-unavailable:${sidecarAvail.reason ?? 'unknown'}`);
}
// BLOCK decisions are surfaced in the response shape; the extension
// logs the BLOCK event into its own activity feed on receipt.
return reply({ verdict, reasons, l4, datamark: '<untrusted-page-content>' }, 200);
},
},
];
+182
View File
@@ -0,0 +1,182 @@
/**
* Browse route table: owns route entries, the one auth gate, the per-kind
* denials, json()/jsonError() and the unmatched fallthrough. Moved from the
* if-chain in server.ts buildFetchHandler; handlers live in routes/<area>.ts
* and the ordered list is ROUTES in routes/index.ts. Add a route:
* { method: 'GET', path: '/thing', auth: 'root-bearer', surfaces: ['local'],
* handler: (req, r, ctx) => json({ ok: true }) }
* A 'tunnel' surface also needs the TUNNEL_PATHS literal in server.ts.
* Enforced by browse/test/server-route-dispatch-ratchet.test.ts.
*/
import type { BrowserManager } from '../browser-manager';
import type { TokenInfo } from '../token-registry';
/** Which HTTP listener accepted this request. */
export type Surface = 'local' | 'tunnel';
/**
* The check the gate runs before a handler. `handler` means the gate admits
* the request and the handler authenticates it itself; the entry's
* `handlerAuth` says how (used only where today's denial differs from every
* gate kind: POST /token, POST /pty-dispose and the /cookie-picker sub-router).
*/
export type AuthKind =
| 'none'
| 'root-bearer'
| 'root-token'
| 'scoped'
| 'extension-origin'
| 'root-or-sse-cookie'
| 'handler';
type GatedKind = Exclude<AuthKind, 'none' | 'handler'>;
/** Every gate-level denial, defined once per auth kind. */
export const AUTH_DENIALS: Record<GatedKind, { status: number; error: string }> = {
'root-bearer': { status: 401, error: 'Unauthorized' },
'scoped': { status: 401, error: 'Unauthorized' },
'root-or-sse-cookie': { status: 401, error: 'Unauthorized' },
'root-token': { status: 403, error: 'Root token required' },
'extension-origin': { status: 403, error: 'Forbidden' },
};
/**
* What handlers may use instead of closing over buildFetchHandler locals.
* Auth arrives as check functions; the raw root token is only
* `bootstrapRootToken`, read by POST /extension-token (returns it) and
* /cookie-picker* (passes it to its sub-router).
*/
export interface RouteContext {
browserManager: BrowserManager;
startTime: number;
browsePort: number;
/** Constant-time `Authorization: Bearer <cfg.authToken>` check. */
validateAuth(req: Request): boolean;
/** Bearer token equals the token registry's root token. */
isRootRequest(req: Request): boolean;
/** Root or scoped TokenInfo for the bearer token, or null. */
getTokenInfo(req: Request): TokenInfo | null;
/** The request carries a live view-only SSE session cookie. */
hasSseCookie(req: Request): boolean;
/** Origin is the pinned gstack extension and Host is loopback. */
isPinnedExtensionRequest(req: Request): boolean;
/** A token string (header or sendBeacon body) equals cfg.authToken. */
isRootTokenValue(token: string | null): boolean;
bootstrapRootToken: string;
resetIdleTimer(): void;
terminal: {
readPort(): number | null;
grantToken(token: string, sessionId?: string): Promise<boolean>;
restartSession(sessionId: string): Promise<boolean>;
};
tunnel: {
state(): { active: boolean; url: string | null; hasListener: boolean };
close(): Promise<void>;
resolveAuthtoken(): string | null;
start(authtoken: string): Promise<{ ok: true; url: string } | { ok: false; stage: 'bind' | 'ngrok'; error: Error }>;
};
commands: {
handle(body: any, tokenInfo: TokenInfo | null): Promise<Response>;
handleInternal(
body: any,
tokenInfo: TokenInfo | null,
opts: { skipRateCheck?: boolean; skipActivity?: boolean },
): Promise<{ status: number; result: string; json?: boolean }>;
};
}
/** Per-request facts the dispatcher hands a handler. */
export interface RouteRequest {
url: URL;
surface: Surface;
/** TokenInfo resolved by a 'scoped' gate; null for every other kind. */
tokenInfo: TokenInfo | null;
}
export type RouteHandler = (req: Request, r: RouteRequest, ctx: RouteContext) => Promise<Response> | Response;
export interface RouteEntry {
/** HTTP method, or '*' for any method. */
method: string;
path: string;
/** Match every pathname starting with `path` (sub-routers). */
prefix?: true;
auth: AuthKind;
surfaces: readonly Surface[];
/** Required when auth is 'handler': where and how the handler authenticates. */
handlerAuth?: string;
handler: RouteHandler;
}
export function json(
body: unknown,
init: { status?: number; headers?: Record<string, string>; replacer?: (key: string, value: unknown) => unknown } = {},
): Response {
return new Response(JSON.stringify(body, init.replacer as any), {
status: init.status ?? 200,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
}
export function jsonError(status: number, error: string, extra: Record<string, unknown> = {}): Response {
return json({ error, ...extra }, { status });
}
/** Runs an entry's auth kind. Returns the resolved TokenInfo, or the kind's denial. */
export function checkRouteAuth(auth: AuthKind, req: Request, ctx: RouteContext): { tokenInfo: TokenInfo | null } | Response {
const admitted = { tokenInfo: null };
switch (auth) {
case 'none':
case 'handler':
return admitted;
case 'root-bearer':
return ctx.validateAuth(req) ? admitted : deny(auth);
case 'root-or-sse-cookie':
return ctx.validateAuth(req) || ctx.hasSseCookie(req) ? admitted : deny(auth);
case 'root-token':
return ctx.isRootRequest(req) ? admitted : deny(auth);
case 'extension-origin':
return ctx.isPinnedExtensionRequest(req) ? admitted : deny(auth);
case 'scoped': {
const tokenInfo = ctx.getTokenInfo(req);
return tokenInfo ? { tokenInfo } : deny(auth);
}
}
}
function deny(auth: GatedKind): Response {
const { status, error } = AUTH_DENIALS[auth];
return jsonError(status, error);
}
/**
* The declared fallthrough for a request no entry matches (unknown path, or a
* known path with a method no entry accepts): the root-bearer check, then a
* plain-text 404.
*/
export const UNMATCHED_ROUTE = {
auth: 'root-bearer',
handler: () => new Response('Not found', { status: 404 }),
} as const satisfies Pick<RouteEntry, 'auth' | 'handler'>;
export function findRoute(routes: readonly RouteEntry[], method: string, pathname: string, surface: Surface): RouteEntry | null {
return routes.find(r =>
r.surfaces.includes(surface)
&& (r.method === '*' || r.method === method)
&& (r.prefix ? pathname.startsWith(r.path) : pathname === r.path),
) ?? null;
}
export async function dispatchRoute(
routes: readonly RouteEntry[],
req: Request,
url: URL,
surface: Surface,
ctx: RouteContext,
): Promise<Response> {
const entry = findRoute(routes, req.method, url.pathname, surface) ?? UNMATCHED_ROUTE;
const gate = checkRouteAuth(entry.auth, req, ctx);
if (gate instanceof Response) return gate;
return entry.handler(req, { url, surface, tokenInfo: gate.tokenInfo }, ctx);
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Scoped-token administration: mint (POST /token), revoke (the /token/*
* sub-router, DELETE /token/:clientId) and list (GET /agents). Root-only.
*/
import { json, jsonError, type RouteEntry } from './table';
import { createToken, revokeToken, listTokens, InvalidScopeError, ReservedClientIdError } from '../token-registry';
export const tokenRoutes: RouteEntry[] = [
// ─── /token — mint scoped tokens (root-only) ──────────────────
{
method: 'POST', path: '/token', auth: 'handler', surfaces: ['local'],
handlerAuth: 'root token (token registry); 403 "Only the root token can mint sub-tokens" otherwise',
handler: async (req, _r, ctx) => {
if (!ctx.isRootRequest(req)) return jsonError(403, 'Only the root token can mint sub-tokens');
try {
const tokenBody = await req.json() as any;
if (!tokenBody.clientId) return jsonError(400, 'Missing clientId');
const session = createToken({
clientId: tokenBody.clientId,
scopes: tokenBody.scopes,
domains: tokenBody.domains,
tabPolicy: tokenBody.tabPolicy,
rateLimit: tokenBody.rateLimit,
expiresSeconds: tokenBody.expiresSeconds,
});
return json({
token: session.token,
expires: session.expiresAt,
scopes: session.scopes,
agent: session.clientId,
});
} catch (err) {
// Name the caller's typo (bad scope, negative rateLimit, reserved
// clientId) instead of hiding it behind the generic body error.
if (err instanceof InvalidScopeError || err instanceof ReservedClientIdError) {
return jsonError(400, err.message);
}
return jsonError(400, 'Invalid request body');
}
},
},
// ─── /token/:clientId — revoke a scoped token (root-only) ─────
{
method: 'DELETE', path: '/token/', prefix: true, auth: 'root-token', surfaces: ['local'],
handler: (_req, { url }, ctx) => {
// decodeURIComponent so CLI-encoded names (spaces, UTF-8) round-trip.
let clientId: string;
try {
clientId = decodeURIComponent(url.pathname.slice('/token/'.length));
} catch {
return jsonError(400, 'Malformed client ID encoding');
}
const revoked = revokeToken(clientId);
// Release tabs UNCONDITIONALLY: ownership outlives the token (it clears
// only on tab close), so a client whose token already expired can still
// own tabs. Gating release on a revoke hit would orphan that ownership
// and let a same-name re-pair inherit an authenticated tab.
const tabsReleased = ctx.browserManager.releaseClientTabs(clientId).length;
if (!revoked && tabsReleased === 0) return jsonError(404, `Agent "${clientId}" not found`);
console.log(`[browse] Revoked ${revoked} token(s), released ${tabsReleased} tab(s) for: ${clientId}`);
return json({ revoked: clientId, tokens_deleted: revoked, tabs_released: tabsReleased });
},
},
// ─── /agents — list connected agents (root-only) ──────────────
{
method: 'GET', path: '/agents', auth: 'root-token', surfaces: ['local'],
handler: () => {
// includeSetup: pending (unexchanged) setup keys are live grants the
// operator must be able to see — without them, revoking a paired-but-
// never-connected agent "works" while the list shows nothing.
const agents = listTokens({ includeSetup: true }).map(t => ({
clientId: t.clientId,
scopes: t.scopes,
domains: t.domains,
expiresAt: t.expiresAt,
commandCount: t.commandCount,
createdAt: t.createdAt,
pending: t.type === 'setup',
}));
return json({ agents });
},
},
];
+62
View File
@@ -0,0 +1,62 @@
/**
* POST /tunnel/start — start the ngrok tunnel on demand (root-only).
*
* Dual-listener model: binds a SECOND Bun.serve listener on an ephemeral
* 127.0.0.1 port dedicated to tunnel traffic, then points ngrok.forward() at
* THAT port. The local listener (which serves /extension-token,
* /cookie-picker, /inspector/*, welcome, etc.) is never exposed to ngrok.
* Hard fail if the tunnel listener bind fails — NEVER fall back to the local
* port, which would silently defeat the whole security property.
*/
import { json, jsonError, type RouteEntry } from './table';
import { isPairAgentEnabled } from '../config';
export const tunnelRoutes: RouteEntry[] = [
{
method: 'POST', path: '/tunnel/start', auth: 'root-token', surfaces: ['local'],
handler: async (_req, _r, ctx) => {
if (!isPairAgentEnabled()) {
// Consent-on-first-use: the /pair-agent skill asks once and sets the
// key; a direct API caller gets the same hint instead of a tunnel.
return jsonError(403, 'pair-agent is off (tunnel exposes this browser beyond the machine)', {
hint: 'enable once with: gstack-config set pair_agent on — or run /pair-agent, which asks for consent and sets it',
});
}
const tunnel = ctx.tunnel.state();
if (tunnel.active && tunnel.url && tunnel.hasListener) {
// Verify tunnel is still alive before returning cached URL.
// Probe GET /connect (the only unauth-reachable path on the tunnel
// surface); /health is NOT tunnel-reachable under dual-listener.
try {
const probe = await fetch(`${tunnel.url}/connect`, {
method: 'GET',
headers: { 'ngrok-skip-browser-warning': 'true' },
signal: AbortSignal.timeout(5000),
});
if (probe.ok) return json({ url: tunnel.url, already_active: true });
} catch {}
// Tunnel is dead — tear down cleanly before restarting
console.warn('[browse] Cached tunnel is dead, restarting...');
await ctx.tunnel.close();
}
// 1) Resolve ngrok authtoken from env / .gstack / native config
const authtoken = ctx.tunnel.resolveAuthtoken();
if (!authtoken) {
return jsonError(400, 'No ngrok authtoken found', { hint: 'Run: ngrok config add-authtoken YOUR_TOKEN' });
}
// 2) Bind the tunnel listener + open ngrok via the shared startTunnel
// helper (hard-fails the bind, cleans up both ngrok and the Bun
// listener on any post-bind failure).
const started = await ctx.tunnel.start(authtoken);
if (!started.ok) {
return jsonError(500, started.stage === 'bind'
? `Failed to bind tunnel listener: ${started.error.message}`
: `Failed to open ngrok tunnel: ${started.error.message}`);
}
return json({ url: started.url });
},
},
];
+2 -2
View File
@@ -24,9 +24,9 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import { mkdirSecure } from './file-permissions'; import { mkdirSecure } from './file-permissions';
import { type LayerSignal } from './security'; import { type LayerSignal } from './security';
import { resolveStateRoot } from '../../lib/state-root';
// ─── Model location + packaging ────────────────────────────── // ─── Model location + packaging ──────────────────────────────
@@ -45,7 +45,7 @@ import { type LayerSignal } from './security';
* vocab.txt * vocab.txt
* onnx/model.onnx (~112MB) * onnx/model.onnx (~112MB)
*/ */
const MODELS_DIR = path.join(os.homedir(), '.gstack', 'models'); const MODELS_DIR = path.join(resolveStateRoot(), 'models');
const TESTSAVANT_DIR = path.join(MODELS_DIR, 'testsavant-small'); const TESTSAVANT_DIR = path.join(MODELS_DIR, 'testsavant-small');
const TESTSAVANT_HF_URL = 'https://huggingface.co/testsavantai/prompt-injection-defender-small-v0-onnx/resolve/main'; const TESTSAVANT_HF_URL = 'https://huggingface.co/testsavantai/prompt-injection-defender-small-v0-onnx/resolve/main';
const TESTSAVANT_FILES = [ const TESTSAVANT_FILES = [
+70 -1310
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -20,11 +20,11 @@
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import { readGstackConfigYamlKey } from './config'; import { readGstackConfigYamlKey } from './config';
import { resolveStateRoot } from '../../lib/state-root';
function gstackHome(): string { function gstackHome(): string {
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); return resolveStateRoot();
} }
function analyticsDir(): string { function analyticsDir(): string {
+4 -2
View File
@@ -377,7 +377,9 @@ export function exchangeSetupKey(setupKey: string, sessionExpiresSeconds?: numbe
/** /**
* Validate a token and return its info if valid. * Validate a token and return its info if valid.
* Returns null for expired, revoked, or unknown tokens. * Returns null for expired, revoked, or unknown tokens, and for unexchanged
* setup keys: a setup key authenticates only the /connect exchange, never a
* bearer request.
* Root token returns a special root info object. * Root token returns a special root info object.
*/ */
export function validateToken(token: string): TokenInfo | null { export function validateToken(token: string): TokenInfo | null {
@@ -396,7 +398,7 @@ export function validateToken(token: string): TokenInfo | null {
} }
const info = tokens.get(token); const info = tokens.get(token);
if (!info) return null; if (!info || info.type !== 'session') return null;
// Check expiry // Check expiry
if (info.expiresAt && new Date(info.expiresAt) < new Date()) { if (info.expiresAt && new Date(info.expiresAt) < new Date()) {
+2 -2
View File
@@ -17,10 +17,10 @@
*/ */
import { promises as fsp } from 'fs'; import { promises as fsp } from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import { mkdirSecure } from './file-permissions'; import { mkdirSecure } from './file-permissions';
import { resolveStateRoot } from '../../lib/state-root';
const LOG_DIR = path.join(os.homedir(), '.gstack', 'security'); const LOG_DIR = path.join(resolveStateRoot(), 'security');
const LOG_PATH = path.join(LOG_DIR, 'attempts.jsonl'); const LOG_PATH = path.join(LOG_DIR, 'attempts.jsonl');
const RATE_CAP = 60; // writes per minute const RATE_CAP = 60; // writes per minute
const WINDOW_MS = 60_000; const WINDOW_MS = 60_000;
+139 -110
View File
@@ -1,22 +1,45 @@
/** /**
* Dual-listener source-level guards. * Dual-listener guards.
* *
* Verifies the F1 refactor: the server binds TWO Bun.serve listeners (local * Verifies the F1 refactor: the server binds TWO Bun.serve listeners (local
* bootstrap + tunnel surface), the tunnel surface has a closed path allowlist, * bootstrap + tunnel surface), the tunnel surface has a closed path allowlist,
* root tokens are rejected on the tunnel, and the command allowlist restricts * root tokens are rejected on the tunnel, and the command allowlist restricts
* which browser operations remote paired agents can invoke. * which browser operations remote paired agents can invoke.
* *
* These are source-level assertions — they keep future contributors from * Tunnel-surface behavior is asserted through a real buildFetchHandler() and
* silently widening the tunnel surface during a routine refactor. Behavioral * the route handlers; the remaining source-level assertions cover listener
* integration tests live in the E2E suite (browse/test/pair-agent-e2e.test.ts, * wiring in start() and the tunnel helpers, which have no seam without ngrok.
* added in a later wave commit). * Real-HTTP integration lives in browse/test/pair-agent-e2e.test.ts.
*/ */
import { describe, test, expect } from 'bun:test'; import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { TUNNEL_COMMANDS } from '../src/server';
import { __resetConnectRateLimit } from '../src/token-registry';
import { makeServer, stubRouteContext, callRoute, fakeTunnel, type TestServer } from './route-test-harness';
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
const TABLE_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/routes/table.ts'), 'utf-8');
let server: TestServer;
let scoped = '';
const overlayCalls: string[] = [];
beforeAll(() => {
server = makeServer({ beforeRoute: async (req, surface) => { overlayCalls.push(`${surface} ${new URL(req.url).pathname}`); return null; } });
scoped = server.scopedToken('dual-listener-agent');
});
const savedPairAgent = process.env.GSTACK_PAIR_AGENT;
const restorePairAgent = () => {
if (savedPairAgent === undefined) delete process.env.GSTACK_PAIR_AGENT;
else process.env.GSTACK_PAIR_AGENT = savedPairAgent;
};
afterAll(() => server.cleanup());
const bearer = (token: string) => ({ Authorization: `Bearer ${token}` });
async function statusAndJson(resp: Response): Promise<{ status: number; body: any }> {
return { status: resp.status, body: await resp.json() };
}
function sliceBetween(source: string, start: string, end: string): string { function sliceBetween(source: string, start: string, end: string): string {
const s = source.indexOf(start); const s = source.indexOf(start);
@@ -37,7 +60,10 @@ function extractSetContents(source: string, constName: string): Set<string> {
describe('Dual-listener surface types', () => { describe('Dual-listener surface types', () => {
test('Surface type is a union of local and tunnel', () => { test('Surface type is a union of local and tunnel', () => {
expect(SERVER_SRC).toContain("export type Surface = 'local' | 'tunnel'"); // Types have no runtime seam; the owner moved to the route table and
// server.ts re-exports it.
expect(TABLE_SRC).toContain("export type Surface = 'local' | 'tunnel'");
expect(SERVER_SRC).toContain('export type { Surface };');
}); });
test('tunnelServer state variable exists alongside tunnelActive/tunnelUrl/tunnelListener', () => { test('tunnelServer state variable exists alongside tunnelActive/tunnelUrl/tunnelListener', () => {
@@ -92,7 +118,7 @@ describe('Tunnel command allowlist', () => {
]); ]);
test('TUNNEL_COMMANDS literal matches the closed allowlist exactly (catches additions/removals without test update)', () => { test('TUNNEL_COMMANDS literal matches the closed allowlist exactly (catches additions/removals without test update)', () => {
const cmds = extractSetContents(SERVER_SRC, 'TUNNEL_COMMANDS'); const cmds = new Set(TUNNEL_COMMANDS);
// Both directions: anything in the source must be expected, and anything // Both directions: anything in the source must be expected, and anything
// expected must be in the source. The intersection-only style of the old // expected must be in the source. The intersection-only style of the old
// must-include / must-exclude tests let new commands sneak into the source // must-include / must-exclude tests let new commands sneak into the source
@@ -107,7 +133,7 @@ describe('Tunnel command allowlist', () => {
}); });
test('TUNNEL_COMMANDS does NOT include daemon-configuration or bootstrap commands', () => { test('TUNNEL_COMMANDS does NOT include daemon-configuration or bootstrap commands', () => {
const cmds = extractSetContents(SERVER_SRC, 'TUNNEL_COMMANDS'); const cmds = TUNNEL_COMMANDS;
const forbidden = [ const forbidden = [
'launch', 'launch-browser', 'connect', 'disconnect', 'launch', 'launch-browser', 'connect', 'disconnect',
'restart', 'stop', 'tunnel-start', 'tunnel-stop', 'restart', 'stop', 'tunnel-start', 'tunnel-stop',
@@ -156,80 +182,67 @@ describe('Request handler factory', () => {
}); });
describe('Tunnel surface filter', () => { describe('Tunnel surface filter', () => {
test('tunnel surface filter runs before route dispatch', () => { const NOT_FOUND = { status: 404, body: { error: 'Not found' } };
// The filter must appear inside makeFetchHandler BEFORE the first route
// handler (/cookie-picker is the earliest route). test('tunnel surface filter runs before route dispatch', async () => {
const fetchBody = sliceBetween( // Denied tunnel requests never reach the beforeRoute overlay or a route.
SERVER_SRC, overlayCalls.length = 0;
'makeFetchHandler = (surface: Surface)', expect(await statusAndJson(await server.tunnel('/health'))).toEqual(NOT_FOUND);
"url.pathname.startsWith('/cookie-picker')" expect((await server.tunnel('/command', { method: 'POST', headers: bearer(server.rootToken), body: '{}' })).status).toBe(403);
); expect((await server.tunnel('/command', { method: 'POST', body: '{}' })).status).toBe(401);
expect(fetchBody).toContain("surface === 'tunnel'"); expect(overlayCalls).toEqual([]);
expect(fetchBody).toContain('path_not_on_tunnel'); __resetConnectRateLimit();
expect(fetchBody).toContain('root_token_on_tunnel'); await server.tunnel('/connect');
expect(fetchBody).toContain('missing_scoped_token'); expect(overlayCalls).toEqual(['tunnel /connect']);
}); });
test('tunnel surface 404s paths not on allowlist', () => { test('tunnel surface 404s paths not on allowlist', async () => {
const filterBlock = sliceBetween( for (const p of ['/health', '/pty-session', '/extension-token', '/inspector', '/token', '/no-such-route']) {
SERVER_SRC, for (const headers of [{}, bearer(scoped)]) {
"surface === 'tunnel'", expect(await statusAndJson(await server.tunnel(p, { method: 'POST', headers }))).toEqual(NOT_FOUND);
"if (url.pathname === '/connect' && req.method === 'GET')" }
); }
expect(filterBlock).toContain('TUNNEL_PATHS.has');
expect(filterBlock).toContain('status: 404');
}); });
test('tunnel surface 403s root token bearers with clear hint', () => { test('tunnel surface 403s root token bearers with clear hint', async () => {
const filterBlock = sliceBetween( for (const p of ['/connect', '/command']) {
SERVER_SRC, const resp = await statusAndJson(await server.tunnel(p, { method: 'POST', headers: bearer(server.rootToken), body: '{}' }));
"surface === 'tunnel'", expect(resp.status).toBe(403);
"if (url.pathname === '/connect' && req.method === 'GET')" expect(resp.body.error).toBe('Root token rejected on tunnel surface');
); expect(resp.body.hint).toContain('pair via /connect');
expect(filterBlock).toContain('isRootRequest(req)'); }
expect(filterBlock).toContain('Root token rejected on tunnel surface');
expect(filterBlock).toContain('pair via /connect');
expect(filterBlock).toContain('status: 403');
}); });
test('tunnel surface 401s when non-/connect request lacks scoped token', () => { test('tunnel surface 401s when non-/connect request lacks scoped token', async () => {
const filterBlock = sliceBetween( const denied = await statusAndJson(await server.tunnel('/command', { method: 'POST', body: '{}' }));
SERVER_SRC, expect(denied).toEqual({ status: 401, body: { error: 'Unauthorized' } });
"surface === 'tunnel'", __resetConnectRateLimit();
"if (url.pathname === '/connect' && req.method === 'GET')" const connect = await statusAndJson(await server.tunnel('/connect', { method: 'POST', body: '{}' }));
); expect(connect).toEqual({ status: 400, body: { error: 'Missing setup_key' } });
expect(filterBlock).toContain("url.pathname !== '/connect'");
expect(filterBlock).toContain('getTokenInfo(req)');
expect(filterBlock).toContain('status: 401');
}); });
}); });
describe('GET /connect alive probe', () => { describe('GET /connect alive probe', () => {
test('GET /connect returns {alive: true} unauth on both surfaces', () => { test('GET /connect returns {alive: true} unauth on both surfaces', async () => {
const getConnect = sliceBetween( for (const call of [server.local, server.tunnel]) {
SERVER_SRC, __resetConnectRateLimit();
"if (url.pathname === '/connect' && req.method === 'GET')", expect(await statusAndJson(await call('/connect'))).toEqual({ status: 200, body: { alive: true } });
"// Cookie picker routes" }
);
expect(getConnect).toContain('alive: true');
expect(getConnect).toContain('status: 200');
}); });
}); });
describe('/command tunnel command allowlist', () => { describe('/command tunnel command allowlist', () => {
test('/command handler delegates to canDispatchOverTunnel when surface is tunnel', () => { test('/command handler delegates to canDispatchOverTunnel when surface is tunnel', async () => {
const commandBlock = sliceBetween(
SERVER_SRC,
"url.pathname === '/command' && req.method === 'POST'",
'return handleCommand(body, tokenInfo)'
);
expect(commandBlock).toContain("surface === 'tunnel'");
// Args-aware since the --out (disk write) tunnel ban: the dispatch gate // Args-aware since the --out (disk write) tunnel ban: the dispatch gate
// takes both the command and its args. // takes both the command and its args.
expect(commandBlock).toContain('canDispatchOverTunnel(body?.command, body?.args)'); for (const body of [{ command: 'launch' }, { command: 'eval', args: ['--out', '/tmp/dual-listener-out', '1'] }]) {
expect(commandBlock).toContain('disallowed_command'); const resp = await statusAndJson(await server.tunnel('/command', {
expect(commandBlock).toContain('is not allowed over the tunnel surface'); method: 'POST', headers: bearer(scoped), body: JSON.stringify(body),
expect(commandBlock).toContain('status: 403'); }));
expect(resp.status).toBe(403);
expect(resp.body.error).toBe(`Command '${body.command}' is not allowed over the tunnel surface`);
expect(resp.body.hint).toContain('Tunnel commands: ');
}
}); });
}); });
@@ -245,14 +258,10 @@ describe('Tunnel listener lifecycle', () => {
}); });
test('/tunnel/start binds the tunnel listener on an ephemeral port (via startTunnel)', () => { test('/tunnel/start binds the tunnel listener on an ephemeral port (via startTunnel)', () => {
const startBlock = sliceBetween( // The route calls ctx.tunnel.start (server-auth.test.ts pins that call);
SERVER_SRC, // the factory wires it to the shared startTunnel() helper with the
"url.pathname === '/tunnel/start' && req.method === 'POST'",
"url.pathname === '/refs'"
);
// The route delegates to the shared startTunnel() helper, passing the
// factory-scoped tunnel-surface handler. // factory-scoped tunnel-surface handler.
expect(startBlock).toContain('startTunnel('); const startBlock = sliceBetween(SERVER_SRC, 'start: (authtoken) => startTunnel({', 'commands: {');
expect(startBlock).toContain("makeFetchHandler('tunnel')"); expect(startBlock).toContain("makeFetchHandler('tunnel')");
// The helper owns the ephemeral bind and points ngrok at the TUNNEL // The helper owns the ephemeral bind and points ngrok at the TUNNEL
// port — never the local daemon port. // port — never the local daemon port.
@@ -266,30 +275,38 @@ describe('Tunnel listener lifecycle', () => {
expect(helperBlock).toContain("addr: tunnelPort"); expect(helperBlock).toContain("addr: tunnelPort");
}); });
test('/tunnel/start hard-fails on tunnel listener bind error (no local fallback)', () => { function startCtx(result: { ok: false; stage: 'bind' | 'ngrok'; error: Error }) {
const startBlock = sliceBetween( return stubRouteContext({
SERVER_SRC, tunnel: {
"url.pathname === '/tunnel/start' && req.method === 'POST'", state: () => ({ active: false, url: null, hasListener: false }),
"url.pathname === '/refs'" close: async () => {}, resolveAuthtoken: () => 'ngrok-token', start: async () => result,
); },
// Must return 500 on bind failure, not silently continue });
expect(startBlock).toContain('Failed to bind tunnel listener'); }
expect(startBlock).toContain('status: 500');
test('/tunnel/start hard-fails on tunnel listener bind error (no local fallback)', async () => {
process.env.GSTACK_PAIR_AGENT = 'on';
try {
const resp = await callRoute('POST', '/tunnel/start', startCtx({ ok: false, stage: 'bind', error: new Error('EADDRINUSE') }));
expect(await statusAndJson(resp)).toEqual({ status: 500, body: { error: 'Failed to bind tunnel listener: EADDRINUSE' } });
} finally { restorePairAgent(); }
}); });
test('/tunnel/start probes the cached tunnel via GET /connect, not /health', () => { test('/tunnel/start probes the cached tunnel via GET /connect, not /health', async () => {
const startBlock = sliceBetween( process.env.GSTACK_PAIR_AGENT = 'on';
SERVER_SRC, const tunnel = fakeTunnel(200);
"url.pathname === '/tunnel/start' && req.method === 'POST'", try {
"url.pathname === '/refs'" await callRoute('POST', '/tunnel/start', stubRouteContext({
); tunnel: {
expect(startBlock).toContain('${tunnelUrl}/connect'); state: () => ({ active: true, url: tunnel.url, hasListener: true }),
expect(startBlock).toContain("method: 'GET'"); close: async () => {}, resolveAuthtoken: () => null, start: async () => { throw new Error('unused'); },
// The old /health probe must NOT reappear },
expect(startBlock).not.toContain('${tunnelUrl}/health'); }));
expect(tunnel.hits).toEqual(['GET /connect']);
} finally { tunnel.stop(); restorePairAgent(); }
}); });
test('/tunnel/start tears down tunnel listener when ngrok.forward fails', () => { test('/tunnel/start tears down tunnel listener when ngrok.forward fails', async () => {
// startTunnel owns the error-path teardown: boundTunnel.stop(true) plus // startTunnel owns the error-path teardown: boundTunnel.stop(true) plus
// the ngrok listener close must both run on any post-bind failure, so a // the ngrok listener close must both run on any post-bind failure, so a
// failed start can't leak sockets or an active ngrok session. // failed start can't leak sockets or an active ngrok session.
@@ -301,12 +318,11 @@ describe('Tunnel listener lifecycle', () => {
expect(helperBlock).toContain('boundTunnel.stop(true)'); expect(helperBlock).toContain('boundTunnel.stop(true)');
expect(helperBlock).toContain('tunnelListener.close()'); expect(helperBlock).toContain('tunnelListener.close()');
// ...and the route maps that failure to the 500 response. // ...and the route maps that failure to the 500 response.
const startBlock = sliceBetween( process.env.GSTACK_PAIR_AGENT = 'on';
SERVER_SRC, try {
"url.pathname === '/tunnel/start' && req.method === 'POST'", const resp = await callRoute('POST', '/tunnel/start', startCtx({ ok: false, stage: 'ngrok', error: new Error('forward refused') }));
"url.pathname === '/refs'" expect(await statusAndJson(resp)).toEqual({ status: 500, body: { error: 'Failed to open ngrok tunnel: forward refused' } });
); } finally { restorePairAgent(); }
expect(startBlock).toContain('Failed to open ngrok tunnel');
}); });
test('BROWSE_TUNNEL=1 startup uses dual-listener pattern', () => { test('BROWSE_TUNNEL=1 startup uses dual-listener pattern', () => {
@@ -354,15 +370,28 @@ describe('Rate limit + denial log wiring', () => {
}); });
describe('E3: /welcome GSTACK_SLUG path traversal gate', () => { describe('E3: /welcome GSTACK_SLUG path traversal gate', () => {
test('/welcome validates GSTACK_SLUG against ^[a-z0-9_-]+$ before interpolating into path', () => { test('/welcome validates GSTACK_SLUG against ^[a-z0-9_-]+$ before interpolating into path', async () => {
const welcomeBlock = sliceBetween( const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-welcome-slug-'));
SERVER_SRC, const page = (slug: string) => path.join(home, '.gstack/projects', slug, 'designs/welcome-page-20260331/finalized.html');
"url.pathname === '/welcome'", for (const [slug, body] of [['unknown', 'UNKNOWN-SLUG-PAGE'], ['good-slug_1', 'GOOD-SLUG-PAGE']]) {
'if (fs.existsSync(projectWelcome)) return projectWelcome;' fs.mkdirSync(path.dirname(page(slug)), { recursive: true });
); fs.writeFileSync(page(slug), body);
// Must validate the slug before using it in a path }
expect(welcomeBlock).toMatch(/\/\^\[a-z0-9_-\]\+\$\/\.test\(rawSlug\)/); const saved = { HOME: process.env.HOME, GSTACK_SLUG: process.env.GSTACK_SLUG };
// Must fall back to a safe default when the slug fails validation try {
expect(welcomeBlock).toContain("'unknown'"); process.env.HOME = home;
process.env.GSTACK_SLUG = 'good-slug_1';
expect(await (await server.local('/welcome')).text()).toBe('GOOD-SLUG-PAGE');
// A traversal slug (and any slug outside the charset) falls back to 'unknown'.
for (const slug of ['../../good-slug_1', 'Good-Slug', 'a/b']) {
process.env.GSTACK_SLUG = slug;
expect(await (await server.local('/welcome')).text()).toBe('UNKNOWN-SLUG-PAGE');
}
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
fs.rmSync(home, { recursive: true, force: true });
}
}); });
}); });
+22
View File
@@ -0,0 +1,22 @@
[
{
"file": "browse/src/server.ts",
"match": "const isGetConnect = req.method === 'GET' && url.pathname === '/connect';",
"reason": "Tunnel-surface filter: admits the GET /connect liveness probe before any route dispatch."
},
{
"file": "browse/src/server.ts",
"match": "const allowed = TUNNEL_PATHS.has(url.pathname);",
"reason": "Tunnel-surface filter: default-deny path allowlist (the TUNNEL_PATHS literal)."
},
{
"file": "browse/src/server.ts",
"match": "if (url.pathname !== '/connect' && !getTokenInfo(req)) {",
"reason": "Tunnel-surface filter: every tunnel path except /connect needs a scoped token."
},
{
"file": "browse/src/routes/table.ts",
"match": "&& (r.prefix ? pathname.startsWith(r.path) : pathname === r.path),",
"reason": "The route table's own matcher (findRoute)."
}
]
+5
View File
@@ -58,6 +58,11 @@ beforeEach(() => {
join(import.meta.dir, '..', '..', 'bin', 'gstack-egress-lib.sh'), join(import.meta.dir, '..', '..', 'bin', 'gstack-egress-lib.sh'),
join(binDir, 'gstack-egress-lib.sh'), join(binDir, 'gstack-egress-lib.sh'),
); );
// Same for the state-root twin every migrated bin sources (docs/state-root.md).
symlinkSync(
join(import.meta.dir, '..', '..', 'bin', 'gstack-state-root.sh'),
join(binDir, 'gstack-state-root.sh'),
);
}); });
afterEach(() => { afterEach(() => {
+15 -5
View File
@@ -13,6 +13,7 @@ import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { isPairAgentEnabled } from '../src/config'; import { isPairAgentEnabled } from '../src/config';
import { stubRouteContext, callRoute } from './route-test-harness';
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8'); const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8');
@@ -111,11 +112,20 @@ describe('gate wiring — every tunnel activation point consults the guard', ()
expect(branch).not.toContain('install ngrok'); expect(branch).not.toContain('install ngrok');
}); });
test('/tunnel/start refuses with the enable hint when disabled', () => { test('/tunnel/start refuses with the enable hint when disabled', async () => {
const startIdx = SERVER_SRC.indexOf("url.pathname === '/tunnel/start'"); const neverTunnel = () => { throw new Error('a disabled gate must not touch the tunnel'); };
const block = SERVER_SRC.slice(startIdx, startIdx + 1200); const ctx = stubRouteContext({
expect(block).toContain('if (!isPairAgentEnabled())'); tunnel: { state: neverTunnel, close: neverTunnel, resolveAuthtoken: neverTunnel, start: neverTunnel },
expect(block).toContain('gstack-config set pair_agent on'); });
tmpHomeWith({ pair_agent: 'off' });
const resp = await callRoute('POST', '/tunnel/start', ctx);
expect(resp.status).toBe(403);
expect(await resp.json()).toEqual({
error: 'pair-agent is off (tunnel exposes this browser beyond the machine)',
hint: 'enable once with: gstack-config set pair_agent on — or run /pair-agent, which asks for consent and sets it',
});
tmpHomeWith(null);
expect((await callRoute('POST', '/tunnel/start', ctx)).status).toBe(403);
}); });
test('BROWSE_TUNNEL=1 startup skips tunnel bind when disabled', () => { test('BROWSE_TUNNEL=1 startup skips tunnel bind when disabled', () => {
+37 -31
View File
@@ -10,40 +10,43 @@
* invariants codex's plan review specifically called out. * invariants codex's plan review specifically called out.
*/ */
import { describe, test, expect } from 'bun:test'; import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { makeServer, routeEntry, type TestServer } from './route-test-harness';
const SERVER_SRC = readFileSync( const SERVER_SRC = readFileSync(
join(import.meta.dir, '..', 'src', 'server.ts'), join(import.meta.dir, '..', 'src', 'server.ts'),
'utf-8', 'utf-8',
); );
const PTY_ROUTES_SRC = readFileSync(join(import.meta.dir, '..', 'src', 'routes', 'pty.ts'), 'utf-8');
describe('/pty-inject-scan — server.ts static invariants', () => { describe('/pty-inject-scan — route invariants', () => {
test('endpoint is defined as a POST handler', () => { let server: TestServer;
expect(SERVER_SRC).toContain( beforeAll(() => { server = makeServer(); });
"url.pathname === '/pty-inject-scan' && req.method === 'POST'", afterAll(() => server.cleanup());
); const post = (headers: Record<string, string>, body?: string) =>
server.local('/pty-inject-scan', { method: 'POST', headers, body });
test('endpoint is defined as a local-only POST route', () => {
expect(routeEntry('POST', '/pty-inject-scan')).toMatchObject({ method: 'POST', surfaces: ['local'] });
}); });
test('endpoint requires auth (validateAuth gate)', () => { test('endpoint requires auth (root bearer gate)', async () => {
// Find the endpoint block, verify it calls validateAuth before doing for (const headers of [{}, { Authorization: 'Bearer not-the-root-token-0123456789' }]) {
// any work. const resp = await post(headers, JSON.stringify({ text: 'hi' }));
const start = SERVER_SRC.indexOf("'/pty-inject-scan'"); expect(resp.status).toBe(401);
expect(start).toBeGreaterThan(-1); expect(await resp.json()).toEqual({ error: 'Unauthorized' });
const blockEnd = SERVER_SRC.indexOf("\n // ─", start); }
const block = SERVER_SRC.slice(start, blockEnd > start ? blockEnd : start + 5000); expect(routeEntry('POST', '/pty-inject-scan').auth).toBe('root-bearer');
expect(block).toContain('validateAuth(req)');
expect(block).toContain('401');
}); });
test('endpoint caps payload at 64KB', () => { test('endpoint caps payload at 64KB', async () => {
const start = SERVER_SRC.indexOf("'/pty-inject-scan'"); const auth = { Authorization: `Bearer ${server.rootToken}`, 'Content-Length': String(64 * 1024 + 1) };
const block = SERVER_SRC.slice(start, start + 5000); const resp = await post(auth, JSON.stringify({ text: 'x'.repeat(64 * 1024) }));
expect(block).toContain('64 * 1024'); expect(resp.status).toBe(413);
expect(block).toContain('payload-too-large'); expect(await resp.json()).toEqual({ error: 'payload-too-large', limit: 65536 });
expect(block).toContain('413');
}); });
test('endpoint is NOT in the tunnel listener allowlist', () => { test('endpoint is NOT in the tunnel listener allowlist', () => {
@@ -54,24 +57,27 @@ describe('/pty-inject-scan — server.ts static invariants', () => {
expect(tunnelAllowlist).not.toContain('/pty-inject-scan'); expect(tunnelAllowlist).not.toContain('/pty-inject-scan');
}); });
// Source checks re-pointed to routes/pty.ts: a lone surrogate cannot reach
// this response through its public inputs without the mocked sidecar below,
// and module-import rules have no runtime seam.
test('response goes through sanitizeReplacer (Unicode egress hardening)', () => { test('response goes through sanitizeReplacer (Unicode egress hardening)', () => {
const start = SERVER_SRC.indexOf("'/pty-inject-scan'"); const block = PTY_ROUTES_SRC.slice(PTY_ROUTES_SRC.indexOf("path: '/pty-inject-scan'"));
const block = SERVER_SRC.slice(start, start + 5000); expect(block).toContain('replacer: sanitizeReplacer');
expect(block).toContain('sanitizeReplacer');
}); });
test('endpoint surfaces l4 availability shape for D7 degrade-to-WARN path', () => { test('endpoint surfaces l4 availability shape for D7 degrade-to-WARN path', () => {
const start = SERVER_SRC.indexOf("'/pty-inject-scan'"); const block = PTY_ROUTES_SRC.slice(PTY_ROUTES_SRC.indexOf("path: '/pty-inject-scan'"));
const block = SERVER_SRC.slice(start, start + 5000);
expect(block).toContain('isSidecarAvailable'); expect(block).toContain('isSidecarAvailable');
expect(block).toContain('available'); expect(block).toContain('available');
}); });
test('endpoint uses the sidecar client, not direct security-classifier import', () => { test('endpoint uses the sidecar client, not direct security-classifier import', () => {
// Static check that server.ts imports from security-sidecar-client.ts, // The route imports security-sidecar-client.ts, NOT security-classifier.ts
// NOT from security-classifier.ts directly (would brick the compiled // directly (would brick the compiled binary per CLAUDE.md).
// binary per CLAUDE.md). expect(PTY_ROUTES_SRC).toContain("from '../security-sidecar-client'");
expect(SERVER_SRC).toContain("from './security-sidecar-client'"); for (const file of readdirSync(join(import.meta.dir, '..', 'src', 'routes'))) {
expect(readFileSync(join(import.meta.dir, '..', 'src', 'routes', file), 'utf-8')).not.toContain('security-classifier');
}
expect(SERVER_SRC).not.toContain("from './security-classifier'"); expect(SERVER_SRC).not.toContain("from './security-classifier'");
}); });
}); });
+121
View File
@@ -0,0 +1,121 @@
/**
* Shared seams for behavioral browse route tests.
*
* makeServer() builds a real buildFetchHandler() instance over a temp state
* dir; callRoute() runs one route-table entry's real handler against a stub
* RouteContext, so a test can observe what the handler asks of the daemon
* (terminal grants, tunnel probes, command dispatch) without a live
* terminal-agent, ngrok or browser.
*/
import * as crypto from 'crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { buildFetchHandler, type ServerConfig, type ServerHandle } from '../src/server';
import { ROUTES } from '../src/routes';
import type { RouteContext, RouteEntry, Surface } from '../src/routes/table';
import { __resetRegistry, createToken, type ScopeCategory, type TokenInfo } from '../src/token-registry';
import { BrowserManager } from '../src/browser-manager';
import { resolveConfig } from '../src/config';
export interface TestServer {
handle: ServerHandle;
rootToken: string;
local(urlPath: string, init?: RequestInit): Promise<Response>;
tunnel(urlPath: string, init?: RequestInit): Promise<Response>;
scopedToken(clientId?: string, scopes?: ScopeCategory[]): string;
cleanup(): void;
}
export function makeServer(opts: { browserManager?: BrowserManager; beforeRoute?: ServerConfig['beforeRoute'] } = {}): TestServer {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-route-harness-'));
__resetRegistry();
const rootToken = 'route-harness-' + crypto.randomBytes(16).toString('hex');
const handle = buildFetchHandler({
authToken: rootToken,
browsePort: 34567,
config: resolveConfig({ BROWSE_STATE_FILE: path.join(dir, 'state/browse.json') }),
browserManager: opts.browserManager ?? new BrowserManager(),
ownsTerminalAgent: false,
startTime: Date.now(),
beforeRoute: opts.beforeRoute,
});
const request = (urlPath: string, init?: RequestInit) => new Request(`http://127.0.0.1:34567${urlPath}`, init);
return {
handle,
rootToken,
local: (urlPath, init) => handle.fetchLocal(request(urlPath, init), null),
tunnel: (urlPath, init) => handle.fetchTunnel(request(urlPath, init), null),
scopedToken: (clientId = 'harness-agent', scopes = ['read', 'write']) => createToken({ clientId, scopes }).token,
cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
};
}
const unexpected = (name: string) => () => { throw new Error(`route handler unexpectedly called ctx.${name}`); };
/** A RouteContext whose every dependency throws unless the test supplies it. */
export function stubRouteContext(overrides: Partial<RouteContext> = {}): RouteContext {
return {
browserManager: {} as BrowserManager,
startTime: Date.now(),
browsePort: 34567,
validateAuth: () => true,
isRootRequest: () => true,
getTokenInfo: () => null,
hasSseCookie: () => false,
isPinnedExtensionRequest: () => false,
isRootTokenValue: () => false,
bootstrapRootToken: 'stub-root-token-0123456789',
resetIdleTimer: unexpected('resetIdleTimer'),
terminal: {
readPort: unexpected('terminal.readPort'),
grantToken: unexpected('terminal.grantToken'),
restartSession: unexpected('terminal.restartSession'),
},
tunnel: {
state: unexpected('tunnel.state'),
close: unexpected('tunnel.close'),
resolveAuthtoken: unexpected('tunnel.resolveAuthtoken'),
start: unexpected('tunnel.start'),
},
commands: { handle: unexpected('commands.handle'), handleInternal: unexpected('commands.handleInternal') },
...overrides,
};
}
export function routeEntry(method: string, urlPath: string, surface: Surface = 'local'): RouteEntry {
const entry = ROUTES.find(r =>
r.surfaces.includes(surface)
&& (r.method === '*' || r.method === method)
&& (r.prefix ? urlPath.startsWith(r.path) : urlPath === r.path));
if (!entry) throw new Error(`no route-table entry for ${method} ${urlPath}`);
return entry;
}
/** Runs one entry's real handler (the auth gate is not involved). */
export async function callRoute(
method: string,
urlPathAndQuery: string,
ctx: RouteContext,
init: { headers?: Record<string, string>; body?: unknown; surface?: Surface; tokenInfo?: TokenInfo | null } = {},
): Promise<Response> {
const url = new URL(`http://127.0.0.1:34567${urlPathAndQuery}`);
const body = init.body === undefined ? undefined : typeof init.body === 'string' ? init.body : JSON.stringify(init.body);
const req = new Request(url, { method, headers: init.headers, body });
const surface = init.surface ?? 'local';
return routeEntry(method, url.pathname, surface).handler(req, { url, surface, tokenInfo: init.tokenInfo ?? null }, ctx);
}
/** A throwaway HTTP server standing in for an ngrok tunnel URL; records each request. */
export function fakeTunnel(connectStatus: number): { url: string; hits: string[]; stop(): void } {
const hits: string[] = [];
const srv = Bun.serve({
port: 0, hostname: '127.0.0.1',
fetch: (req) => {
hits.push(`${req.method} ${new URL(req.url).pathname}`);
return new Response('{}', { status: connectStatus });
},
});
return { url: `http://127.0.0.1:${srv.port}`, hits, stop: () => srv.stop(true) };
}
+337 -159
View File
@@ -1,13 +1,21 @@
/** /**
* Server auth security tests — verify security remediation in server.ts * Server auth security tests.
* *
* Tests are source-level: they read server.ts and verify that auth checks, * Route auth is asserted behaviorally: requests go through a real
* CORS restrictions, and token removal are correctly in place. * buildFetchHandler() (makeServer) or through one route's real handler with a
* stub RouteContext (callRoute), so the tests pin what each route does, not
* where its code sits. The remaining source-level checks cover code with no
* behavioral seam (command-pipeline internals, cli.ts, the cookie-picker UI,
* the constant-time compare, the ngrok authtoken file lookup).
*/ */
import { describe, test, expect } from 'bun:test'; import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { GSTACK_EXTENSION_ID } from '../src/server';
import { DEFAULT_PAIR_SCOPES, createToken } from '../src/token-registry';
import { getActivityHistory } from '../src/activity';
import { makeServer, stubRouteContext, callRoute, fakeTunnel, type TestServer } from './route-test-harness';
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8'); const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8');
@@ -21,19 +29,51 @@ function sliceBetween(source: string, startMarker: string, endMarker: string): s
return source.slice(startIdx, endIdx); return source.slice(startIdx, endIdx);
} }
const UNAUTHORIZED = { status: 401, body: { error: 'Unauthorized' } };
const ROOT_REQUIRED = { status: 403, body: { error: 'Root token required' } };
async function statusAndJson(resp: Response): Promise<{ status: number; body: any }> {
const text = await resp.text();
let body: any = text;
try { body = JSON.parse(text); } catch {}
return { status: resp.status, body };
}
let server: TestServer;
let scoped = '';
const bearer = (token: string) => ({ Authorization: `Bearer ${token}` });
const savedPairAgent = process.env.GSTACK_PAIR_AGENT;
beforeAll(() => {
server = makeServer();
scoped = server.scopedToken('auth-suite-agent');
});
afterAll(() => {
server.cleanup();
if (savedPairAgent === undefined) delete process.env.GSTACK_PAIR_AGENT;
else process.env.GSTACK_PAIR_AGENT = savedPairAgent;
});
describe('Server auth security', () => { describe('Server auth security', () => {
// Test 1a: the pinned-origin bootstrap endpoint exists and gates on both // Test 1a: the pinned-origin bootstrap endpoint releases the token only to
// the exact extension Origin and a loopback Host. // the exact extension Origin with a loopback Host (parsed from host:port,
test('POST /extension-token gates on pinned Origin and loopback Host', () => { // never compared literally against the raw header).
const tokenBlock = sliceBetween(SERVER_SRC, "url.pathname === '/extension-token'", "url.pathname === '/health'"); test('POST /extension-token gates on pinned Origin and loopback Host', async () => {
expect(tokenBlock).toContain('GSTACK_EXTENSION_ID'); const pinned = `chrome-extension://${GSTACK_EXTENSION_ID}`;
expect(tokenBlock).toContain('token: authToken'); const post = (headers: Record<string, string>) => server.local('/extension-token', { method: 'POST', headers });
// Host is parsed to a hostname (arrives as '127.0.0.1:34567'), never for (const host of ['127.0.0.1:34567', 'localhost:34567']) {
// compared literally against the raw header. const ok = await statusAndJson(await post({ Origin: pinned, Host: host }));
expect(tokenBlock).toContain('.hostname'); expect(ok).toEqual({ status: 200, body: { token: server.rootToken } });
expect(tokenBlock).toContain("'127.0.0.1'"); }
expect(tokenBlock).toContain("'localhost'"); for (const headers of [
expect(tokenBlock).toContain('403'); { Origin: pinned, Host: 'evil.example:34567' },
{ Origin: pinned },
{ Origin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', Host: '127.0.0.1:34567' },
{ Host: '127.0.0.1:34567', ...bearer(server.rootToken) },
]) {
const denied = await statusAndJson(await post(headers));
expect(denied).toEqual({ status: 403, body: { error: 'Forbidden' } });
}
}); });
// Test 1c: newtab must check domain restrictions (CSO finding #5) // Test 1c: newtab must check domain restrictions (CSO finding #5)
@@ -62,94 +102,140 @@ describe('Server auth security', () => {
expect(authBlock).not.toContain('header === `Bearer ${authToken}`'); expect(authBlock).not.toContain('header === `Bearer ${authToken}`');
}); });
// Test 2: /refs endpoint requires auth via validateAuth // Tests 2-5: /refs and /activity/history require the root bearer and never
test('/refs endpoint requires authentication', () => { // send a wildcard CORS header, on the denial or the success path.
const refsBlock = sliceBetween(SERVER_SRC, "url.pathname === '/refs'", "url.pathname === '/activity/stream'"); for (const route of ['/refs', '/activity/history']) {
expect(refsBlock).toContain('validateAuth'); test(`${route} endpoint requires authentication`, async () => {
}); expect(await statusAndJson(await server.local(route))).toEqual(UNAUTHORIZED);
expect(await statusAndJson(await server.local(route, { headers: bearer(scoped) }))).toEqual(UNAUTHORIZED);
expect((await server.local(route, { headers: bearer(server.rootToken) })).status).toBe(200);
});
// Test 3: /refs has no wildcard CORS header test(`${route} has no wildcard CORS header`, async () => {
test('/refs has no wildcard CORS header', () => { for (const headers of [{}, bearer(server.rootToken)]) {
const refsBlock = sliceBetween(SERVER_SRC, "url.pathname === '/refs'", "url.pathname === '/activity/stream'"); const resp = await server.local(route, { headers: { Origin: 'https://evil.example', ...headers } });
expect(refsBlock).not.toContain("'*'"); expect(resp.headers.get('access-control-allow-origin')).toBeNull();
}); }
});
// Test 4: /activity/history requires auth via validateAuth }
test('/activity/history requires authentication', () => {
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Batch endpoint');
expect(historyBlock).toContain('validateAuth');
});
// Test 5: /activity/history has no wildcard CORS header
test('/activity/history has no wildcard CORS header', () => {
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Batch endpoint');
expect(historyBlock).not.toContain("'*'");
});
// Test 6: /activity/stream requires auth via Bearer OR view-only session cookie // Test 6: /activity/stream requires auth via Bearer OR view-only session cookie
// (N1: ?token= query param was dropped in v1.6.0.0 — URLs leak to logs/referer) // (N1: ?token= query param was dropped in v1.6.0.0 — URLs leak to logs/referer)
test('/activity/stream requires authentication with inline token check', () => { test('/activity/stream requires authentication with inline token check', async () => {
const streamBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/stream'", "url.pathname === '/activity/history'"); expect(await statusAndJson(await server.local('/activity/stream'))).toEqual(UNAUTHORIZED);
expect(streamBlock).toContain('validateAuth'); const viaQuery = await server.local(`/activity/stream?token=${server.rootToken}`);
expect(streamBlock).toContain('validateSseSessionToken'); expect(await statusAndJson(viaQuery)).toEqual(UNAUTHORIZED);
// Should not have wildcard CORS for the SSE stream
expect(streamBlock).not.toContain("Access-Control-Allow-Origin': '*'"); const viaBearer = await server.local('/activity/stream', { headers: bearer(server.rootToken) });
// ?token= query param must NOT be accepted anymore expect(viaBearer.status).toBe(200);
expect(streamBlock).not.toContain("searchParams.get('token')"); expect(viaBearer.headers.get('access-control-allow-origin')).toBeNull();
await viaBearer.body?.cancel();
const minted = await server.local('/sse-session', { method: 'POST', headers: bearer(server.rootToken) });
const cookie = (minted.headers.get('set-cookie') ?? '').split(';')[0];
expect(cookie).toStartWith('gstack_sse=');
const viaCookie = await server.local('/activity/stream', { headers: { Cookie: cookie } });
expect(viaCookie.status).toBe(200);
expect(viaCookie.headers.get('access-control-allow-origin')).toBeNull();
await viaCookie.body?.cancel();
}); });
// Test 7: /command accepts scoped tokens (not just root) // Test 7: /command accepts scoped tokens (not just root). This was the
// This was the Wintermute bug — /command was BELOW the blanket validateAuth gate // Wintermute bug — /command sat below the blanket root-only check, so scoped
// which only accepts root tokens. Scoped tokens got 401'd before reaching getTokenInfo. // tokens got 401'd before reaching getTokenInfo.
test('/command endpoint sits ABOVE the blanket root-only auth gate', () => { test('/command endpoint sits ABOVE the blanket root-only auth gate', async () => {
const commandIdx = SERVER_SRC.indexOf("url.pathname === '/command'"); const resp = await statusAndJson(await server.local('/command', {
const blanketGateIdx = SERVER_SRC.indexOf("Auth-required endpoints (root token only)"); method: 'POST', headers: bearer(scoped), body: JSON.stringify({ command: '__auth_suite_unknown__' }),
// /command must appear BEFORE the blanket gate in source order }));
expect(commandIdx).toBeGreaterThan(0); expect(resp.status).not.toBe(401);
expect(blanketGateIdx).toBeGreaterThan(0); expect(resp.body).not.toEqual({ error: 'Unauthorized' });
expect(commandIdx).toBeLessThan(blanketGateIdx);
}); });
// Test 7b: /command uses getTokenInfo (accepts scoped tokens), not validateAuth (root-only) // Test 7b: /command authenticates with getTokenInfo (root or scoped), so an
test('/command uses getTokenInfo for auth, not validateAuth', () => { // unknown bearer is still rejected.
const commandBlock = sliceBetween(SERVER_SRC, "url.pathname === '/command'", "Auth-required endpoints"); test('/command uses getTokenInfo for auth, not validateAuth', async () => {
expect(commandBlock).toContain('getTokenInfo'); const body = JSON.stringify({ command: '__auth_suite_unknown__' });
expect(commandBlock).not.toContain('validateAuth'); expect(await statusAndJson(await server.local('/command', { method: 'POST', body }))).toEqual(UNAUTHORIZED);
expect(await statusAndJson(await server.local('/command', {
method: 'POST', body, headers: bearer('gsk_sess_not-a-real-token'),
}))).toEqual(UNAUTHORIZED);
expect((await server.local('/command', { method: 'POST', body, headers: bearer(server.rootToken) })).status).not.toBe(401);
}); });
// Test 8: /tunnel/start requires root token // Test 8: /tunnel/start requires root token
test('/tunnel/start requires root token', () => { test('/tunnel/start requires root token', async () => {
const tunnelBlock = sliceBetween(SERVER_SRC, "/tunnel/start", "Refs endpoint"); for (const headers of [{}, bearer(scoped)]) {
expect(tunnelBlock).toContain('isRootRequest'); expect(await statusAndJson(await server.local('/tunnel/start', { method: 'POST', headers }))).toEqual(ROOT_REQUIRED);
expect(tunnelBlock).toContain('Root token required'); }
}); });
// Test 8b: /tunnel/start checks ngrok native config paths // Test 8b: the ngrok authtoken lookup reads ngrok's native config files, and
test('/tunnel/start reads ngrok native config files', () => { // /tunnel/start asks for it only after the cached-tunnel check. The file
const tunnelBlock = sliceBetween(SERVER_SRC, "/tunnel/start", "Refs endpoint"); // lookup (resolveNgrokAuthtoken in server.ts) has no seam without a real
expect(tunnelBlock).toContain("'ngrok.yml'"); // ngrok config, so that half stays a source check.
expect(tunnelBlock).toContain('authtoken'); test('/tunnel/start reads ngrok native config files', async () => {
const lookup = sliceBetween(SERVER_SRC, 'function resolveNgrokAuthtoken', 'async function closeTunnel');
expect(lookup).toContain("'ngrok.yml'");
expect(lookup).toContain('authtoken');
process.env.GSTACK_PAIR_AGENT = 'on';
const inactive = { active: false, url: null, hasListener: false };
const missing = await statusAndJson(await callRoute('POST', '/tunnel/start', stubRouteContext({
tunnel: { state: () => inactive, close: async () => {}, resolveAuthtoken: () => null, start: async () => { throw new Error('must not start'); } },
})));
expect(missing).toEqual({ status: 400, body: { error: 'No ngrok authtoken found', hint: 'Run: ngrok config add-authtoken YOUR_TOKEN' } });
const started: string[] = [];
const ok = await statusAndJson(await callRoute('POST', '/tunnel/start', stubRouteContext({
tunnel: {
state: () => inactive, close: async () => {}, resolveAuthtoken: () => 'ngrok-token-from-config',
start: async (authtoken) => { started.push(authtoken); return { ok: true, url: 'https://fresh.ngrok.example' }; },
},
})));
expect(ok).toEqual({ status: 200, body: { url: 'https://fresh.ngrok.example' } });
expect(started).toEqual(['ngrok-token-from-config']);
}); });
// Test 8c: /tunnel/start returns already_active if tunnel is running // Test 8c: /tunnel/start returns already_active if the cached tunnel answers
test('/tunnel/start returns already_active when tunnel exists', () => { test('/tunnel/start returns already_active when tunnel exists', async () => {
const tunnelBlock = sliceBetween(SERVER_SRC, "/tunnel/start", "Refs endpoint"); process.env.GSTACK_PAIR_AGENT = 'on';
expect(tunnelBlock).toContain('already_active'); const tunnel = fakeTunnel(200);
expect(tunnelBlock).toContain('tunnelActive'); try {
const resp = await statusAndJson(await callRoute('POST', '/tunnel/start', stubRouteContext({
tunnel: {
state: () => ({ active: true, url: tunnel.url, hasListener: true }),
close: async () => { throw new Error('a live tunnel must not be closed'); },
resolveAuthtoken: () => { throw new Error('a live tunnel must not be restarted'); },
start: async () => { throw new Error('a live tunnel must not be restarted'); },
},
})));
expect(resp).toEqual({ status: 200, body: { url: tunnel.url, already_active: true } });
expect(tunnel.hits).toEqual(['GET /connect']);
} finally { tunnel.stop(); }
}); });
// Test 9: /pair requires root token // Test 9: /pair requires root token
test('/pair requires root token', () => { test('/pair requires root token', async () => {
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "/tunnel/start"); for (const headers of [{}, bearer(scoped)]) {
expect(pairBlock).toContain('isRootRequest'); expect(await statusAndJson(await server.local('/pair', { method: 'POST', headers, body: '{}' }))).toEqual(ROOT_REQUIRED);
expect(pairBlock).toContain('Root token required'); }
}); });
// Test 9b: /pair calls createSetupKey (not createToken) // Test 9b: /pair mints a one-time setup key (pending until /connect), never a
test('/pair creates setup keys, not session tokens', () => { // session token a caller could use directly.
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "/tunnel/start"); test('/pair creates setup keys, not session tokens', async () => {
expect(pairBlock).toContain('createSetupKey'); const pair = await statusAndJson(await server.local('/pair', {
expect(pairBlock).not.toContain('createToken'); method: 'POST', headers: bearer(server.rootToken), body: JSON.stringify({ clientId: 'pair-setup-check' }),
}));
expect(pair.status).toBe(200);
expect(pair.body.setup_key).toStartWith('gsk_setup_');
const agents = await statusAndJson(await server.local('/agents', { headers: bearer(server.rootToken) }));
expect(agents.body.agents.find((a: any) => a.clientId === 'pair-setup-check')?.pending).toBe(true);
const exchanged = await statusAndJson(await server.local('/connect', {
method: 'POST', body: JSON.stringify({ setup_key: pair.body.setup_key }),
}));
expect(exchanged.status).toBe(200);
expect(exchanged.body.token).toStartWith('gsk_sess_');
}); });
// Test 10: tab ownership check happens before command dispatch // Test 10: tab ownership check happens before command dispatch
@@ -220,36 +306,68 @@ describe('Server auth security', () => {
// ─── Tunnel liveness verification ───────────────────────────── // ─── Tunnel liveness verification ─────────────────────────────
// Test 11a: /pair endpoint probes tunnel before returning tunnel_url // Tests 11a/11b: /pair probes the tunnel before reporting tunnel_url, tears
test('/pair verifies tunnel is alive before returning tunnel_url', () => { // the tunnel down when the probe fails, and never reports a raw
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "url.pathname === '/tunnel/start'"); // tunnelActive flag.
// Must probe the tunnel URL test('/pair verifies tunnel is alive before returning tunnel_url', async () => {
expect(pairBlock).toContain('verifiedTunnelUrl'); for (const [status, expectUrl] of [[200, true], [503, false]] as const) {
expect(pairBlock).toContain('Tunnel probe failed'); const tunnel = fakeTunnel(status);
expect(pairBlock).toContain('marking tunnel as dead'); let closed = 0;
// Must tear down tunnel state on failure (via closeTunnel helper — clears try {
// tunnelActive, tunnelUrl, tunnelListener, and the tunnel Bun.serve listener) const resp = await statusAndJson(await callRoute('POST', '/pair', stubRouteContext({
expect(pairBlock).toContain('closeTunnel()'); tunnel: {
state: () => ({ active: true, url: tunnel.url, hasListener: true }),
close: async () => { closed++; },
resolveAuthtoken: () => null, start: async () => { throw new Error('unused'); },
},
}), { body: {} }));
expect(resp.status).toBe(200);
expect(resp.body.tunnel_url).toBe(expectUrl ? tunnel.url : null);
expect(tunnel.hits).toEqual(['GET /connect']);
expect(closed).toBe(expectUrl ? 0 : 1);
} finally { tunnel.stop(); }
}
}); });
// Test 11b: /pair returns null tunnel_url when tunnel is dead test('/pair returns verified tunnel URL, not raw tunnelActive flag', async () => {
test('/pair returns verified tunnel URL, not raw tunnelActive flag', () => { const resp = await statusAndJson(await callRoute('POST', '/pair', stubRouteContext({
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "url.pathname === '/tunnel/start'"); tunnel: {
// Should use verifiedTunnelUrl (probe result), not raw tunnelUrl state: () => ({ active: true, url: 'http://127.0.0.1:1', hasListener: true }),
expect(pairBlock).toContain('tunnel_url: verifiedTunnelUrl'); close: async () => {}, resolveAuthtoken: () => null, start: async () => { throw new Error('unused'); },
// Must NOT use raw tunnelActive check for the response },
expect(pairBlock).not.toContain('tunnel_url: tunnelActive ? tunnelUrl'); }), { body: {} }));
expect(resp.status).toBe(200);
expect(resp.body.tunnel_url).toBeNull();
const inactive = await statusAndJson(await callRoute('POST', '/pair', stubRouteContext({
tunnel: {
state: () => ({ active: false, url: null, hasListener: false }),
close: async () => { throw new Error('nothing to close'); },
resolveAuthtoken: () => null, start: async () => { throw new Error('unused'); },
},
}), { body: {} }));
expect(inactive.body.tunnel_url).toBeNull();
expect(inactive.body.server_url).toBe('http://127.0.0.1:34567');
}); });
// Test 11c: /tunnel/start probes cached tunnel before returning already_active // Test 11c: /tunnel/start probes the cached tunnel before returning
test('/tunnel/start verifies cached tunnel is alive before returning already_active', () => { // already_active; a dead one is torn down and restarted.
const tunnelBlock = sliceBetween(SERVER_SRC, "url.pathname === '/tunnel/start'", "url.pathname === '/refs'"); test('/tunnel/start verifies cached tunnel is alive before returning already_active', async () => {
// Must probe before returning cached URL process.env.GSTACK_PAIR_AGENT = 'on';
expect(tunnelBlock).toContain('Cached tunnel is dead'); const tunnel = fakeTunnel(502);
// Must tear down tunnel state on stale detection (via closeTunnel helper) const calls: string[] = [];
expect(tunnelBlock).toContain('closeTunnel()'); try {
// Must fall through to restart when dead const resp = await statusAndJson(await callRoute('POST', '/tunnel/start', stubRouteContext({
expect(tunnelBlock).toContain('restarting'); tunnel: {
state: () => ({ active: true, url: tunnel.url, hasListener: true }),
close: async () => { calls.push('close'); },
resolveAuthtoken: () => { calls.push('resolve'); return 'ngrok-token'; },
start: async () => { calls.push('start'); return { ok: true, url: 'https://restarted.ngrok.example' }; },
},
})));
expect(resp).toEqual({ status: 200, body: { url: 'https://restarted.ngrok.example' } });
expect(calls).toEqual(['close', 'resolve', 'start']);
expect(tunnel.hits).toEqual(['GET /connect']);
} finally { tunnel.stop(); }
}); });
// Test 11d: CLI verifies tunnel_url from server before printing instruction block // Test 11d: CLI verifies tunnel_url from server before printing instruction block
@@ -264,62 +382,104 @@ describe('Server auth security', () => {
// ─── Batch endpoint security ───────────────────────────────── // ─── Batch endpoint security ─────────────────────────────────
// Test 12a: /batch endpoint sits ABOVE the blanket root-only auth gate (same as /command) // Tests 12a/12b: /batch accepts root and scoped tokens (like /command) and
test('/batch endpoint sits ABOVE the blanket root-only auth gate', () => { // rejects an unknown bearer.
const batchIdx = SERVER_SRC.indexOf("url.pathname === '/batch'"); test('/batch endpoint sits ABOVE the blanket root-only auth gate', async () => {
const blanketGateIdx = SERVER_SRC.indexOf("Auth-required endpoints (root token only)"); const resp = await statusAndJson(await server.local('/batch', {
expect(batchIdx).toBeGreaterThan(0); method: 'POST', headers: bearer(scoped), body: JSON.stringify({ commands: [] }),
expect(blanketGateIdx).toBeGreaterThan(0); }));
expect(batchIdx).toBeLessThan(blanketGateIdx); expect(resp).toEqual({ status: 400, body: { error: '"commands" must be a non-empty array' } });
}); });
// Test 12b: /batch uses getTokenInfo (accepts scoped tokens), not validateAuth (root-only) test('/batch uses getTokenInfo for auth, not validateAuth', async () => {
test('/batch uses getTokenInfo for auth, not validateAuth', () => { const body = JSON.stringify({ commands: [] });
const batchBlock = sliceBetween(SERVER_SRC, "url.pathname === '/batch'", "url.pathname === '/command'"); expect(await statusAndJson(await server.local('/batch', { method: 'POST', body }))).toEqual(UNAUTHORIZED);
expect(batchBlock).toContain('getTokenInfo'); expect(await statusAndJson(await server.local('/batch', {
expect(batchBlock).not.toContain('validateAuth'); method: 'POST', body, headers: bearer('gsk_sess_not-a-real-token'),
}))).toEqual(UNAUTHORIZED);
expect((await server.local('/batch', { method: 'POST', body, headers: bearer(server.rootToken) })).status).toBe(400);
}); });
function batchContext(calls: Array<{ body: any; opts: any }>) {
return stubRouteContext({
browserManager: { getCurrentUrl: () => 'about:blank', getTabCount: () => 1, getConnectionMode: () => 'launched' } as any,
resetIdleTimer: () => {},
commands: {
handle: async () => { throw new Error('/batch must not use the single-command wrapper'); },
handleInternal: async (body, _tokenInfo, opts) => { calls.push({ body, opts }); return { status: 200, result: 'ok' }; },
},
});
}
// Test 12c: /batch enforces max command limit // Test 12c: /batch enforces max command limit
test('/batch enforces max 50 commands per batch', () => { test('/batch enforces max 50 commands per batch', async () => {
const batchBlock = sliceBetween(SERVER_SRC, "url.pathname === '/batch'", "url.pathname === '/command'"); const calls: Array<{ body: any; opts: any }> = [];
expect(batchBlock).toContain('commands.length > 50'); const commands = Array.from({ length: 51 }, () => ({ command: 'url' }));
expect(batchBlock).toContain('Max 50 commands per batch'); const resp = await statusAndJson(await callRoute('POST', '/batch', batchContext(calls), { body: { commands } }));
expect(resp).toEqual({ status: 400, body: { error: 'Max 50 commands per batch' } });
expect(calls).toEqual([]);
}); });
// Test 12d: /batch rejects nested batches // Test 12d: /batch rejects nested batches
test('/batch rejects nested batch commands', () => { test('/batch rejects nested batch commands', async () => {
const batchBlock = sliceBetween(SERVER_SRC, "url.pathname === '/batch'", "url.pathname === '/command'"); const calls: Array<{ body: any; opts: any }> = [];
expect(batchBlock).toContain("cmd.command === 'batch'"); const resp = await statusAndJson(await callRoute('POST', '/batch', batchContext(calls), {
expect(batchBlock).toContain('Nested batch commands are not allowed'); body: { commands: [{ command: 'batch', args: [] }, { command: 'url' }] },
}));
expect(resp.status).toBe(200);
expect(resp.body.results[0]).toMatchObject({ index: 0, status: 400, command: 'batch' });
expect(JSON.parse(resp.body.results[0].result)).toEqual({ error: 'Nested batch commands are not allowed' });
expect(calls.map(c => c.body.command)).toEqual(['url']);
}); });
// Test 12e: /batch skips per-command rate limiting (batch counts as 1 request) // Tests 12e/12f/12h: each sub-command runs through handleCommandInternal
test('/batch skips per-command rate limiting', () => { // with per-command rate limiting and activity suppressed, tabId passed
const batchBlock = sliceBetween(SERVER_SRC, "url.pathname === '/batch'", "url.pathname === '/command'"); // through, and one batch-level command_start/command_end pair emitted.
expect(batchBlock).toContain('skipRateCheck: true'); test('/batch skips per-command rate limiting', async () => {
const calls: Array<{ body: any; opts: any }> = [];
await callRoute('POST', '/batch', batchContext(calls), { body: { commands: [{ command: 'url' }, { command: 'text' }] } });
expect(calls.map(c => c.opts.skipRateCheck)).toEqual([true, true]);
}); });
// Test 12f: /batch skips per-command activity events (emits batch-level events) test('/batch emits batch-level activity, not per-command', async () => {
test('/batch emits batch-level activity, not per-command', () => { const calls: Array<{ body: any; opts: any }> = [];
const batchBlock = sliceBetween(SERVER_SRC, "url.pathname === '/batch'", "url.pathname === '/command'"); const before = getActivityHistory(1000).totalAdded;
expect(batchBlock).toContain('skipActivity: true'); await callRoute('POST', '/batch', batchContext(calls), {
// Should emit batch-level start and end events body: { commands: [{ command: 'url' }, { command: 'text' }] },
expect(batchBlock).toContain("command: 'batch'"); tokenInfo: { clientId: 'batch-activity-agent' } as any,
});
expect(calls.map(c => c.opts.skipActivity)).toEqual([true, true]);
const { entries, totalAdded } = getActivityHistory(1000);
const added = entries.slice(-(totalAdded - before));
expect(added.map(e => [e.type, e.command, e.clientId])).toEqual([
['command_start', 'batch', 'batch-activity-agent'],
['command_end', 'batch', 'batch-activity-agent'],
]);
}); });
// Test 12g: /batch validates command field in each command // Test 12g: /batch validates command field in each command
test('/batch validates each command has a command field', () => { test('/batch validates each command has a command field', async () => {
const batchBlock = sliceBetween(SERVER_SRC, "url.pathname === '/batch'", "url.pathname === '/command'"); const calls: Array<{ body: any; opts: any }> = [];
expect(batchBlock).toContain("typeof cmd.command !== 'string'"); const resp = await statusAndJson(await callRoute('POST', '/batch', batchContext(calls), {
expect(batchBlock).toContain('Missing "command" field'); body: { commands: [{}, { command: 42 }] },
}));
expect(resp.body.results.map((r: any) => [r.status, JSON.parse(r.result).error])).toEqual([
[400, 'Missing "command" field'],
[400, 'Missing "command" field'],
]);
expect(calls).toEqual([]);
}); });
// Test 12h: /batch passes tabId through to handleCommandInternal test('/batch passes tabId to handleCommandInternal for multi-tab support', async () => {
test('/batch passes tabId to handleCommandInternal for multi-tab support', () => { const calls: Array<{ body: any; opts: any }> = [];
const batchBlock = sliceBetween(SERVER_SRC, "url.pathname === '/batch'", "url.pathname === '/command'"); const resp = await statusAndJson(await callRoute('POST', '/batch', batchContext(calls), {
expect(batchBlock).toContain('tabId: cmd.tabId'); body: { commands: [{ command: 'url', tabId: 7 }, { command: 'text', args: ['x'], tabId: 9 }] },
expect(batchBlock).toContain('handleCommandInternal'); }));
expect(calls.map(c => c.body)).toEqual([
{ command: 'url', args: undefined, tabId: 7 },
{ command: 'text', args: ['x'], tabId: 9 },
]);
expect(resp.body.results.map((r: any) => r.tabId)).toEqual([7, 9]);
}); });
// ─── Pair-agent regression tests ────────────────────────── // ─── Pair-agent regression tests ──────────────────────────
@@ -395,12 +555,15 @@ describe('Server auth security', () => {
describe('Pair scope defaults and revocation surface', () => { describe('Pair scope defaults and revocation surface', () => {
// Regression: the CLI only sent scopes when --restrict was passed, so the // Regression: the CLI only sent scopes when --restrict was passed, so the
// effective pairing default lived in two places (CLI omission + server // effective pairing default lived in two places (CLI omission + server
// fallback) and could silently drift. Both sides must reference the shared // fallback) and could silently drift. Both sides must use the shared
// DEFAULT_PAIR_SCOPES constant, and the CLI must send scopes // DEFAULT_PAIR_SCOPES constant, and the CLI must send scopes
// unconditionally (the old conditional-spread shape is banned). // unconditionally (the old conditional-spread shape is banned).
test('/pair default and CLI pairing body share DEFAULT_PAIR_SCOPES', () => { test('/pair default and CLI pairing body share DEFAULT_PAIR_SCOPES', async () => {
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "url.pathname === '/tunnel/start'"); const pair = await statusAndJson(await server.local('/pair', {
expect(pairBlock).toContain('DEFAULT_PAIR_SCOPES'); method: 'POST', headers: bearer(server.rootToken), body: '{}',
}));
expect(pair.status).toBe(200);
expect(pair.body.scopes).toEqual([...DEFAULT_PAIR_SCOPES]);
const cliBlock = sliceBetween(CLI_SRC, 'async function handlePairAgent', 'Determine the URL to use'); const cliBlock = sliceBetween(CLI_SRC, 'async function handlePairAgent', 'Determine the URL to use');
// Match the CODE shape, not a comment: a bare toContain('DEFAULT_PAIR_SCOPES') // Match the CODE shape, not a comment: a bare toContain('DEFAULT_PAIR_SCOPES')
// is satisfied by the explanatory comment and passes vacuously on a revert. // is satisfied by the explanatory comment and passes vacuously on a revert.
@@ -410,15 +573,30 @@ describe('Pair scope defaults and revocation surface', () => {
// control is the only scope behind an explicit flag; a scopes list must // control is the only scope behind an explicit flag; a scopes list must
// not be able to smuggle it into a pairing grant. // not be able to smuggle it into a pairing grant.
test('/pair rejects control inside a scopes list without the control flag', () => { test('/pair rejects control inside a scopes list without the control flag', async () => {
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "url.pathname === '/tunnel/start'"); const pair = (body: unknown) => server.local('/pair', {
expect(pairBlock).toContain("pairBody.scopes.includes('control')"); method: 'POST', headers: bearer(server.rootToken), body: JSON.stringify(body),
});
expect(await statusAndJson(await pair({ scopes: ['read', 'control'] }))).toEqual({
status: 400,
body: { error: 'The control scope requires the control flag (--control); it cannot be granted via a scopes list.' },
});
const flagged = await statusAndJson(await pair({ control: true }));
expect(flagged.status).toBe(200);
expect(flagged.body.scopes).toContain('control');
}); });
// CLI-encoded clientIds (spaces, UTF-8) must round-trip through the revoke // CLI-encoded clientIds (spaces, UTF-8) must round-trip through the revoke
// route; slicing the raw pathname 404s on every encoded name. // route; slicing the raw pathname 404s on every encoded name.
test('DELETE /token decodes the clientId path segment', () => { test('DELETE /token decodes the clientId path segment', async () => {
const revokeBlock = sliceBetween(SERVER_SRC, "url.pathname.startsWith('/token/')", "url.pathname === '/agents'"); createToken({ clientId: 'encoded agent é', scopes: ['read'] });
expect(revokeBlock).toContain('decodeURIComponent'); const resp = await statusAndJson(await server.local(`/token/${encodeURIComponent('encoded agent é')}`, {
method: 'DELETE', headers: bearer(server.rootToken),
}));
expect(resp).toEqual({ status: 200, body: { revoked: 'encoded agent é', tokens_deleted: 1, tabs_released: 0 } });
const malformed = await statusAndJson(await server.local('/token/%E0%A4%A', {
method: 'DELETE', headers: bearer(server.rootToken),
}));
expect(malformed).toEqual({ status: 400, body: { error: 'Malformed client ID encoding' } });
}); });
}); });
+112 -50
View File
@@ -1,74 +1,136 @@
import { describe, test, expect } from 'bun:test'; import { describe, test, expect } from 'bun:test';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { stubRouteContext, callRoute } from './route-test-harness';
import { mintLease, validateLease } from '../src/pty-session-lease';
import { validatePtySessionToken } from '../src/pty-session-cookie';
import type { RouteContext } from '../src/routes/table';
// Server-side route shape for the v1.44 lease + restart + dispose + // Server-side route behavior for the v1.44 lease + restart + dispose +
// lease-refresh wiring. Live route exercises require the terminal-agent // lease-refresh wiring. The routes reach the terminal-agent only through
// loopback to be live (e2e-tier); these static-grep tripwires pin the // RouteContext.terminal, so a stub records every grant and restart the
// load-bearing protocol invariants. // daemon would send over loopback. The loopback helpers themselves
// (grantPtyToken / restartPtySession in server.ts) keep source tripwires.
const SERVER_TS = path.resolve(import.meta.path, '..', '..', 'src', 'server.ts'); const SERVER_TS = path.resolve(import.meta.path, '..', '..', 'src', 'server.ts');
const ROOT = 'lease-routes-root-token-0123456789';
function terminalContext(port: number | null = 4242, granted = true) {
const grants: Array<{ token: string; sessionId?: string }> = [];
const restarts: string[] = [];
let idleResets = 0;
const ctx = stubRouteContext({
isRootTokenValue: (token) => token === ROOT,
resetIdleTimer: () => { idleResets++; },
terminal: {
readPort: () => port,
grantToken: async (token, sessionId) => { grants.push({ token, sessionId }); return granted; },
restartSession: async (sessionId) => { restarts.push(sessionId); return true; },
},
});
return { ctx, grants, restarts, idleResets: () => idleResets };
}
async function post(ctx: RouteContext, route: string, body?: unknown, headers?: Record<string, string>) {
const resp = await callRoute('POST', route, ctx, { body, headers });
return { status: resp.status, body: await resp.json() as any, headers: resp.headers };
}
describe('server: PTY lease routes (v1.44+ Commit 2)', () => { describe('server: PTY lease routes (v1.44+ Commit 2)', () => {
test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', () => { test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', async () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8'); const t = terminalContext();
const block = sliceBetween(src, "url.pathname === '/pty-session' &&", "url.pathname === '/pty-session/reattach'"); const resp = await post(t.ctx, '/pty-session');
expect(block).toContain('mintLease()'); expect(resp.status).toBe(200);
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)'); expect(resp.body.terminalPort).toBe(4242);
expect(block).toContain('sessionId: lease.sessionId'); expect(validateLease(resp.body.sessionId).ok).toBe(true);
expect(block).toContain('attachToken: minted.token'); expect(resp.body.leaseExpiresAt).toBeGreaterThan(Date.now());
expect(block).toContain('leaseExpiresAt: lease.expiresAt'); // The attach token is granted to the agent bound to the new sessionId.
expect(t.grants).toEqual([{ token: resp.body.attachToken, sessionId: resp.body.sessionId }]);
expect(validatePtySessionToken(resp.body.attachToken)).toBe(true);
// Backward compat: legacy ptySessionToken alias preserved for one release. // Backward compat: legacy ptySessionToken alias preserved for one release.
expect(block).toContain('ptySessionToken: minted.token'); expect(resp.body.ptySessionToken).toBe(resp.body.attachToken);
expect(resp.headers.get('set-cookie')).toContain(resp.body.attachToken);
const notReady = await post(terminalContext(null).ctx, '/pty-session');
expect(notReady).toMatchObject({ status: 503, body: { error: 'terminal-agent not ready' } });
const refused = terminalContext(4242, false);
const failed = await post(refused.ctx, '/pty-session');
expect(failed).toMatchObject({ status: 503, body: { error: 'failed to grant terminal session' } });
// A refused grant revokes both the token and the lease it minted.
expect(validatePtySessionToken(refused.grants[0].token)).toBe(false);
expect(validateLease(refused.grants[0].sessionId!).ok).toBe(false);
}); });
test('2. /pty-session/reattach validates lease + mints fresh attachToken', () => { test('2. /pty-session/reattach validates lease + mints fresh attachToken', async () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8'); const t = terminalContext();
const block = sliceBetween(src, "url.pathname === '/pty-session/reattach'", "url.pathname === '/pty-restart'");
// Validate-first: rejects unknown/expired sessionId with 410 Gone so // Validate-first: rejects unknown/expired sessionId with 410 Gone so
// the client knows to fall back to a fresh /pty-session. // the client knows to fall back to a fresh /pty-session.
expect(block).toContain('validateLease(sessionId)'); expect(await post(t.ctx, '/pty-session/reattach', { sessionId: 'no-such-session' }))
expect(block).toContain('status: 410'); .toMatchObject({ status: 410, body: { error: 'lease expired or unknown' } });
expect(await post(t.ctx, '/pty-session/reattach', {})).toMatchObject({ status: 410 });
expect(t.grants).toEqual([]);
// Mint fresh token bound to SAME sessionId. // Mint fresh token bound to SAME sessionId.
expect(block).toContain('grantPtyToken(minted.token, sessionId!)'); const lease = mintLease();
const resp = await post(t.ctx, '/pty-session/reattach', { sessionId: lease.sessionId });
expect(resp.status).toBe(200);
expect(resp.body.sessionId).toBe(lease.sessionId);
expect(t.grants).toEqual([{ token: resp.body.attachToken, sessionId: lease.sessionId }]);
}); });
test('3. /pty-restart is one transaction — dispose + revoke + fresh mint', () => { test('3. /pty-restart is one transaction — dispose + revoke + fresh mint', async () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8'); const t = terminalContext();
const block = sliceBetween(src, "url.pathname === '/pty-restart'", "url.pathname === '/pty-dispose'"); const old = mintLease();
// Disposes old session (best-effort — missing sessionId is non-fatal). const resp = await post(t.ctx, '/pty-restart', { sessionId: old.sessionId });
expect(block).toContain('restartPtySession(oldSessionId)'); // Disposes the old session on the agent and revokes its lease...
expect(block).toContain('revokeLease(oldSessionId)'); expect(t.restarts).toEqual([old.sessionId]);
// Then mints fresh sessionId + lease + attachToken in the same handler. expect(validateLease(old.sessionId).ok).toBe(false);
expect(block).toContain('mintLease()'); // ...then returns a fresh 4-tuple from the same handler, so the client
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)'); // doesn't need a separate /pty-session round-trip.
// Returns the same 4-tuple shape so the client doesn't need a expect(resp.status).toBe(200);
// separate /pty-session round-trip. expect(resp.body.sessionId).not.toBe(old.sessionId);
expect(block).toContain('attachToken: minted.token'); expect(validateLease(resp.body.sessionId).ok).toBe(true);
expect(block).toContain('leaseExpiresAt: lease.expiresAt'); expect(t.grants).toEqual([{ token: resp.body.attachToken, sessionId: resp.body.sessionId }]);
expect(resp.body.leaseExpiresAt).toBeGreaterThan(Date.now());
// Missing sessionId is non-fatal: no dispose, fresh mint still happens.
const fresh = terminalContext();
expect((await post(fresh.ctx, '/pty-restart', {})).status).toBe(200);
expect(fresh.restarts).toEqual([]);
}); });
test('4. /pty-dispose accepts body-token (sendBeacon-compatible)', () => { test('4. /pty-dispose accepts body-token (sendBeacon-compatible)', async () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-dispose'", "url.pathname === '/internal/lease-refresh'");
// sendBeacon can't set custom headers, so the route MUST accept the // sendBeacon can't set custom headers, so the route MUST accept the
// auth token in the request body. Otherwise pagehide cleanup fails // auth token in the request body — and both paths must match the root
// silently every time the user closes the browser. // token, never just trust a body-supplied value.
expect(block).toContain('body?.authToken'); const lease = mintLease();
expect(block).toContain('authedByBody'); const t = terminalContext();
// Both auth paths must validate against authToken — never just trust expect(await post(t.ctx, '/pty-dispose', { authToken: ROOT, sessionId: lease.sessionId }))
// a body-supplied token without the equality check. .toMatchObject({ status: 200, body: { ok: true } });
expect(block).toContain('authTokenFromBody === authToken'); expect(t.restarts).toEqual([lease.sessionId]);
expect(validateLease(lease.sessionId).ok).toBe(false);
expect(await post(t.ctx, '/pty-dispose', {}, { Authorization: `Bearer ${ROOT}` })).toMatchObject({ status: 200 });
for (const [body, headers] of [
[{ authToken: 'not-the-root-token', sessionId: 'x' }, undefined],
[{ sessionId: 'x' }, { Authorization: 'Bearer not-the-root-token' }],
[{ sessionId: 'x' }, undefined],
] as const) {
expect(await post(t.ctx, '/pty-dispose', body, headers as any)).toMatchObject({ status: 401, body: { error: 'Unauthorized' } });
}
expect(t.restarts).toEqual([lease.sessionId]);
}); });
test('5. /internal/lease-refresh resets the daemon idle timer (T6)', () => { test('5. /internal/lease-refresh resets the daemon idle timer (T6)', async () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8'); const t = terminalContext();
const block = sliceBetween(src, "url.pathname === '/internal/lease-refresh'", '─── /pty-inject-scan'); // Refresh failure (unknown / expired) MUST 410, not 200, so the agent
expect(block).toContain('refreshLease(sessionId)'); // knows to close the WS and force a clean re-auth.
expect(block).toContain('resetIdleTimer()'); expect(await post(t.ctx, '/internal/lease-refresh', { sessionId: 'no-such-session' }))
// Refresh failure (unknown / expired) MUST 410, not 200, so the .toMatchObject({ status: 410, body: { error: 'lease expired or unknown' } });
// agent knows to close the WS and force a clean re-auth. expect(t.idleResets()).toBe(0);
expect(block).toContain('status: 410'); const lease = mintLease();
const resp = await post(t.ctx, '/internal/lease-refresh', { sessionId: lease.sessionId });
expect(resp.status).toBe(200);
expect(resp.body.ok).toBe(true);
expect(resp.body.expiresAt).toBeGreaterThanOrEqual(lease.expiresAt);
expect(t.idleResets()).toBe(1);
}); });
test('6. grantPtyToken loopback carries sessionId binding', () => { test('6. grantPtyToken loopback carries sessionId binding', () => {
@@ -0,0 +1,267 @@
/**
* Black-box auth matrix for the browse daemon's HTTP surface.
*
* Drives buildFetchHandler(...).fetchLocal / fetchTunnel for every route the
* daemon serves, on both surfaces, with no token, a wrong token, the root
* token, a scoped token and the view-only SSE cookie. Denials assert the exact
* status, body and content type the server returns; allowed credentials assert
* that the response is not one of the gate denials (the handler was reached).
*
* This file is deliberately independent of how server.ts is organized: it was
* written against the if-chain server and must pass unchanged against the
* route-table server. Do not edit it to follow a refactor; a failing row here
* means a route's observable auth behavior changed.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as crypto from 'crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { buildFetchHandler, GSTACK_EXTENSION_ID, type ServerConfig, type ServerHandle } from '../src/server';
import { __resetRegistry, __resetConnectRateLimit, createToken } from '../src/token-registry';
import { mintSseSessionToken, SSE_COOKIE_NAME } from '../src/sse-session-cookie';
import { BrowserManager } from '../src/browser-manager';
import { resolveConfig } from '../src/config';
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-route-blackbox-'));
const fixtureConfig = resolveConfig({ BROWSE_STATE_FILE: path.join(fixtureDir, 'state/browse.json') });
type Cred = 'none' | 'wrong' | 'root' | 'scoped' | 'sse-cookie';
const CREDS: Cred[] = ['none', 'wrong', 'root', 'scoped', 'sse-cookie'];
interface Denial { status: number; body: string; contentType: string }
const JSON_CT = 'application/json';
const json = (status: number, body: unknown): Denial => ({ status, body: JSON.stringify(body), contentType: JSON_CT });
const UNAUTHORIZED = json(401, { error: 'Unauthorized' });
const ROOT_REQUIRED = json(403, { error: 'Root token required' });
const FORBIDDEN = json(403, { error: 'Forbidden' });
const MINT_ROOT_ONLY = json(403, { error: 'Only the root token can mint sub-tokens' });
const TUNNEL_NOT_FOUND = json(404, { error: 'Not found' });
const TUNNEL_ROOT_REJECTED = json(403, {
error: 'Root token rejected on tunnel surface',
hint: 'Remote agents must pair via /connect to receive a scoped token.',
});
// A bare string Response carries no explicit Content-Type header until Bun serializes it.
const LOCAL_NOT_FOUND: Denial = { status: 404, body: 'Not found', contentType: '' };
const PICKER_ACCESS_DENIED: Denial = {
status: 403, body: 'Access denied. Open the cookie picker from gstack.', contentType: 'text/plain',
};
const GATE_DENIALS = [UNAUTHORIZED, ROOT_REQUIRED, FORBIDDEN, MINT_ROOT_ONLY, TUNNEL_NOT_FOUND, TUNNEL_ROOT_REJECTED];
/**
* How a route authenticates today, as observed from outside. `open` routes
* admit every credential; the rest deny every credential except the listed
* ones with the listed denial.
*/
type Policy =
| { kind: 'open' }
| { kind: 'deny'; allow: Cred[]; denial: Denial };
const OPEN: Policy = { kind: 'open' };
const ROOT_BEARER: Policy = { kind: 'deny', allow: ['root'], denial: UNAUTHORIZED };
const ROOT_TOKEN: Policy = { kind: 'deny', allow: ['root'], denial: ROOT_REQUIRED };
const SCOPED: Policy = { kind: 'deny', allow: ['root', 'scoped'], denial: UNAUTHORIZED };
const ROOT_OR_SSE_COOKIE: Policy = { kind: 'deny', allow: ['root', 'sse-cookie'], denial: UNAUTHORIZED };
interface RouteRow {
method: string;
path: string;
/** Request body for every credential; keeps allowed calls on a cheap, side-effect-free branch. */
body?: string;
local: Policy;
/** True for the two paths the tunnel surface admits (TUNNEL_PATHS). */
tunnel?: 'connect' | 'command';
/** SSE responses stream forever; the test cancels them after the status line. */
stream?: boolean;
}
const ROUTES: RouteRow[] = [
{ method: 'GET', path: '/connect', local: OPEN, tunnel: 'connect' },
{ method: 'POST', path: '/connect', body: '{}', local: OPEN, tunnel: 'connect' },
{ method: 'GET', path: '/cookie-picker', local: { kind: 'deny', allow: [], denial: PICKER_ACCESS_DENIED } },
{ method: 'GET', path: '/cookie-picker/imported', local: ROOT_BEARER },
{ method: 'OPTIONS', path: '/cookie-picker/imported', local: OPEN },
{ method: 'GET', path: '/welcome', local: OPEN },
{ method: 'POST', path: '/extension-token', local: { kind: 'deny', allow: [], denial: FORBIDDEN } },
{ method: 'GET', path: '/health', local: OPEN },
{ method: 'POST', path: '/pty-session', local: ROOT_BEARER },
{ method: 'POST', path: '/pty-session/reattach', body: '{}', local: ROOT_BEARER },
{ method: 'POST', path: '/pty-restart', body: '{}', local: ROOT_BEARER },
{ method: 'POST', path: '/pty-dispose', body: '{}', local: ROOT_BEARER },
{ method: 'POST', path: '/internal/lease-refresh', body: '{}', local: ROOT_BEARER },
{ method: 'POST', path: '/pty-inject-scan', local: ROOT_BEARER },
{ method: 'POST', path: '/token', body: 'not json', local: { kind: 'deny', allow: ['root'], denial: MINT_ROOT_ONLY } },
{ method: 'DELETE', path: '/token/matrix-nobody', local: ROOT_TOKEN },
{ method: 'GET', path: '/agents', local: ROOT_TOKEN },
{ method: 'POST', path: '/pair', body: 'not json', local: ROOT_TOKEN },
{ method: 'POST', path: '/tunnel/start', local: ROOT_TOKEN },
{ method: 'POST', path: '/sse-session', local: ROOT_BEARER },
{ method: 'GET', path: '/refs', local: ROOT_BEARER },
{ method: 'GET', path: '/activity/stream', local: ROOT_OR_SSE_COOKIE, stream: true },
{ method: 'GET', path: '/activity/history', local: ROOT_BEARER },
{ method: 'POST', path: '/batch', body: '{"commands":[]}', local: SCOPED },
{ method: 'GET', path: '/file', local: SCOPED },
{ method: 'POST', path: '/command', body: '{"command":"__matrix_unknown__"}', local: SCOPED, tunnel: 'command' },
{ method: 'POST', path: '/inspector/pick', body: '{}', local: ROOT_BEARER },
{ method: 'GET', path: '/inspector', local: ROOT_BEARER },
{ method: 'POST', path: '/inspector/apply', body: '{}', local: ROOT_BEARER },
{ method: 'POST', path: '/inspector/reset', local: ROOT_BEARER },
{ method: 'GET', path: '/inspector/history', local: ROOT_BEARER },
// /memory and /inspector/events sit behind the blanket root-bearer check, so
// the SSE cookie their handlers mention never reaches them today.
{ method: 'GET', path: '/memory', local: ROOT_BEARER },
{ method: 'GET', path: '/inspector/events', local: ROOT_BEARER, stream: true },
];
/** Requests no route accepts: unknown paths and known paths with a method no route takes. */
const UNMATCHED: Array<{ method: string; path: string }> = [
{ method: 'GET', path: '/no-such-route' },
{ method: 'POST', path: '/no-such-route' },
{ method: 'GET', path: '/command' },
{ method: 'GET', path: '/pty-session' },
{ method: 'PUT', path: '/token' },
{ method: 'GET', path: '/token/someone' },
{ method: 'GET', path: '/inspector/pick' },
{ method: 'POST', path: '/inspector' },
{ method: 'PUT', path: '/connect' },
{ method: 'DELETE', path: '/command' },
];
let handle: ServerHandle;
let rootToken = '';
let scopedToken = '';
let sseCookie = '';
const savedPairAgent = process.env.GSTACK_PAIR_AGENT;
beforeAll(() => {
// /tunnel/start's allowed branch must stop at the consent gate, never ngrok.
process.env.GSTACK_PAIR_AGENT = 'off';
__resetRegistry();
rootToken = 'route-blackbox-' + crypto.randomBytes(16).toString('hex');
const cfg: ServerConfig = {
authToken: rootToken,
browsePort: 34567,
config: fixtureConfig,
browserManager: new BrowserManager(),
ownsTerminalAgent: false,
startTime: Date.now(),
};
handle = buildFetchHandler(cfg);
scopedToken = createToken({ clientId: 'matrix-agent', scopes: ['read', 'write'] }).token;
sseCookie = mintSseSessionToken().token;
});
afterAll(() => {
if (savedPairAgent === undefined) delete process.env.GSTACK_PAIR_AGENT;
else process.env.GSTACK_PAIR_AGENT = savedPairAgent;
fs.rmSync(fixtureDir, { recursive: true, force: true });
});
function credHeaders(cred: Cred): Record<string, string> {
switch (cred) {
case 'none': return {};
case 'wrong': return { Authorization: `Bearer ${'w'.repeat(rootToken.length)}` };
case 'root': return { Authorization: `Bearer ${rootToken}` };
case 'scoped': return { Authorization: `Bearer ${scopedToken}` };
case 'sse-cookie': return { Cookie: `${SSE_COOKIE_NAME}=${sseCookie}` };
}
}
async function call(
surface: 'local' | 'tunnel',
method: string,
urlPath: string,
headers: Record<string, string>,
body?: string,
stream = false,
): Promise<{ status: number; body: string; contentType: string }> {
__resetConnectRateLimit();
const req = new Request(`http://127.0.0.1:34567${urlPath}`, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json', ...headers } : headers,
body: method === 'GET' || method === 'HEAD' ? undefined : body,
});
const resp = surface === 'local' ? await handle.fetchLocal(req, null) : await handle.fetchTunnel(req, null);
const contentType = resp.headers.get('content-type') ?? '';
if (stream && resp.status === 200) {
await resp.body?.cancel();
return { status: resp.status, body: '<stream>', contentType };
}
return { status: resp.status, body: await resp.text(), contentType };
}
function expectDenial(got: { status: number; body: string; contentType: string }, want: Denial): void {
expect({ status: got.status, body: got.body }).toEqual({ status: want.status, body: want.body });
expect(got.contentType).toBe(want.contentType);
}
function expectReached(got: { status: number; body: string }): void {
for (const d of GATE_DENIALS) {
expect(got.status === d.status && got.body === d.body, `gate denial ${d.status} ${d.body}`).toBe(false);
}
}
function tunnelPolicy(route: RouteRow): Policy | Denial {
if (route.tunnel === 'connect') return OPEN;
if (route.tunnel === 'command') return { kind: 'deny', allow: ['scoped'], denial: UNAUTHORIZED };
return TUNNEL_NOT_FOUND;
}
describe('browse route auth matrix (black-box)', () => {
for (const route of ROUTES) {
for (const cred of CREDS) {
test(`local ${route.method} ${route.path} with ${cred}`, async () => {
const got = await call('local', route.method, route.path, credHeaders(cred), route.body, route.stream);
const p = route.local;
if (p.kind === 'open' || p.allow.includes(cred)) expectReached(got);
else expectDenial(got, p.denial);
});
test(`tunnel ${route.method} ${route.path} with ${cred}`, async () => {
const got = await call('tunnel', route.method, route.path, credHeaders(cred), route.body, route.stream);
const p = tunnelPolicy(route);
if ('status' in p) return expectDenial(got, p);
if (cred === 'root') return expectDenial(got, TUNNEL_ROOT_REJECTED);
if (p.kind === 'open' || p.allow.includes(cred)) expectReached(got);
else expectDenial(got, p.denial);
});
}
}
test('POST /pty-dispose accepts the root token in the body (sendBeacon path)', async () => {
const got = await call('local', 'POST', '/pty-dispose', {}, JSON.stringify({ authToken: rootToken }));
expect(got.status).toBe(200);
expect(JSON.parse(got.body)).toEqual({ ok: true });
const wrong = await call('local', 'POST', '/pty-dispose', {}, JSON.stringify({ authToken: 'w'.repeat(rootToken.length) }));
expectDenial(wrong, UNAUTHORIZED);
});
test('POST /extension-token releases the token only to the pinned Origin on a loopback Host', async () => {
const origin = `chrome-extension://${GSTACK_EXTENSION_ID}`;
const ok = await call('local', 'POST', '/extension-token', { Origin: origin, Host: '127.0.0.1:34567' });
expect(ok.status).toBe(200);
expect(JSON.parse(ok.body)).toEqual({ token: rootToken });
expectDenial(await call('local', 'POST', '/extension-token', { Origin: 'chrome-extension://someone-else', Host: '127.0.0.1:34567' }), FORBIDDEN);
expectDenial(await call('local', 'POST', '/extension-token', { Origin: origin, Host: 'evil.example:34567' }), FORBIDDEN);
expectDenial(await call('tunnel', 'POST', '/extension-token', { Origin: origin, Host: '127.0.0.1:34567' }), TUNNEL_NOT_FOUND);
});
for (const row of UNMATCHED) {
for (const cred of CREDS) {
test(`unmatched local ${row.method} ${row.path} with ${cred}`, async () => {
const got = await call('local', row.method, row.path, credHeaders(cred), row.method === 'GET' ? undefined : '{}');
expectDenial(got, cred === 'root' ? LOCAL_NOT_FOUND : UNAUTHORIZED);
});
test(`unmatched tunnel ${row.method} ${row.path} with ${cred}`, async () => {
const got = await call('tunnel', row.method, row.path, credHeaders(cred), row.method === 'GET' ? undefined : '{}');
const onTunnelPath = row.path === '/connect' || row.path === '/command';
if (!onTunnelPath) return expectDenial(got, TUNNEL_NOT_FOUND);
if (cred === 'root') return expectDenial(got, TUNNEL_ROOT_REJECTED);
expectDenial(got, UNAUTHORIZED);
});
}
}
});
@@ -0,0 +1,129 @@
/**
* Ratchet (b): browse routes are dispatched only by the route table.
*
* Scans buildFetchHandler's listener code (browse/src/server.ts) and the
* route modules (browse/src/routes/*.ts) for pathname comparisons. Every one
* must be the table's matcher or the tunnel-surface filter, listed with a
* reason in browse/test/fixtures/route-dispatch-allowlist.json (keyed on file
* plus matched line text, never line numbers). terminal-agent.ts, cli.ts and
* memory-command.ts are separate listeners and out of scope. Also checks that
* every table entry declares auth and surfaces and that gstack registers no
* beforeRoute overlay of its own (overlays are for embedders).
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ROUTES } from '../src/routes';
const ROOT = path.resolve(import.meta.dir, '..', '..');
const ALLOWLIST_PATH = 'browse/test/fixtures/route-dispatch-allowlist.json';
interface AllowEntry { file: string; match: string; reason?: string }
interface Violation { file: string; line: number; text: string }
const PATHNAME_DISPATCH = /\bpathname\s*(?:===|!==|==|!=)|(?:===|!==|==|!=)\s*(?:url\.)?pathname\b|\bpathname\.startsWith\(|\.has\(\s*(?:url\.)?pathname\s*\)/;
const COMMENT_LINE = /^\s*(?:\/\/|\*|\/\*)/;
export function scanRouteDispatch(files: Array<{ file: string; source: string }>, allowlist: AllowEntry[]): Violation[] {
const allowed = new Set(allowlist.map(e => `${e.file}\0${e.match}`));
const violations: Violation[] = [];
for (const { file, source } of files) {
source.split('\n').forEach((raw, i) => {
if (COMMENT_LINE.test(raw) || !PATHNAME_DISPATCH.test(raw)) return;
const text = raw.trim();
if (!allowed.has(`${file}\0${text}`)) violations.push({ file, line: i + 1, text });
});
}
return violations;
}
export function formatViolations(violations: Violation[]): string {
return [
'Route dispatch outside the browse route table:',
...violations.map(v => ` ${v.file}:${v.line} ${v.text}`),
'Rule: every browse HTTP route is dispatched by the route table, because an inline pathname check bypasses the one auth gate and the declared surfaces.',
'Fix: add a route-table entry with auth and surfaces (shape in browse/src/routes/table.ts) instead of comparing url.pathname.',
`Allowlist: ${ALLOWLIST_PATH} — only for the tunnel-surface filter and the table's own matcher; every entry needs a reason.`,
].join('\n');
}
function entriesWithoutReason(allowlist: AllowEntry[]): AllowEntry[] {
return allowlist.filter(e => typeof e.reason !== 'string' || e.reason.trim().length === 0);
}
function listenerSources(): Array<{ file: string; source: string }> {
const routeDir = path.join(ROOT, 'browse/src/routes');
const files = ['browse/src/server.ts', ...fs.readdirSync(routeDir).filter(f => f.endsWith('.ts')).map(f => `browse/src/routes/${f}`)];
return files.map(file => ({ file, source: fs.readFileSync(path.join(ROOT, file), 'utf-8') }));
}
const ALLOWLIST: AllowEntry[] = JSON.parse(fs.readFileSync(path.join(ROOT, ALLOWLIST_PATH), 'utf-8'));
describe('ratchet (b): route dispatch goes through the table', () => {
test('no pathname comparison outside the table matcher and the tunnel filter', () => {
const violations = scanRouteDispatch(listenerSources(), ALLOWLIST);
if (violations.length) throw new Error(formatViolations(violations));
});
test('every allowlist entry carries a reason and still matches a line', () => {
const missing = entriesWithoutReason(ALLOWLIST);
if (missing.length) throw new Error(`Allowlist entries without a reason in ${ALLOWLIST_PATH}:\n${missing.map(e => ` ${e.file} ${e.match}`).join('\n')}`);
const sources = new Map(listenerSources().map(s => [s.file, s.source.split('\n').map(l => l.trim())]));
for (const e of ALLOWLIST) expect(sources.get(e.file)?.includes(e.match), `${e.file} ${e.match}`).toBe(true);
});
test('every table entry declares auth and surfaces', () => {
for (const r of ROUTES) {
expect(typeof r.auth, `${r.method} ${r.path}`).toBe('string');
expect(Array.isArray(r.surfaces) && r.surfaces.length > 0, `${r.method} ${r.path}`).toBe(true);
}
});
test('gstack registers no beforeRoute overlay of its own', () => {
const code = fs.readFileSync(path.join(ROOT, 'browse/src/server.ts'), 'utf-8')
.split('\n').filter(l => !COMMENT_LINE.test(l)).join('\n');
expect(code).not.toMatch(/\bbeforeRoute\s*:/);
});
});
describe('ratchet (b) self-test', () => {
const planted = {
file: 'browse/src/routes/planted.ts',
source: [
"import { json } from './table';",
'export function sneaky(url: URL) {',
" if (url.pathname === '/backdoor') return json({ ok: true });",
'}',
].join('\n'),
};
test('a planted inline route fails with file:line, the Fix, and the allowlist path', () => {
const violations = scanRouteDispatch([planted], ALLOWLIST);
expect(violations).toEqual([{ file: planted.file, line: 3, text: "if (url.pathname === '/backdoor') return json({ ok: true });" }]);
const message = formatViolations(violations);
expect(message).toContain("browse/src/routes/planted.ts:3 if (url.pathname === '/backdoor')");
expect(message).toContain('Fix: add a route-table entry with auth and surfaces');
expect(message).toContain(ALLOWLIST_PATH);
});
test('startsWith and Set.has dispatch are caught too; comments are not', () => {
const source = [
"// url.pathname === '/documented' in a comment",
"if (url.pathname.startsWith('/prefix')) {}",
'if (PATHS.has(url.pathname)) {}',
].join('\n');
expect(scanRouteDispatch([{ file: 'x.ts', source }], []).map(v => v.line)).toEqual([2, 3]);
});
test('allowlist entries are keyed on text, so inserting a line above one still passes', () => {
const entry = { file: 'browse/src/server.ts', match: 'const allowed = TUNNEL_PATHS.has(url.pathname);', reason: 'tunnel filter' };
const shifted = { file: entry.file, source: `// new line\nconst x = 1;\n ${entry.match}\n` };
expect(scanRouteDispatch([shifted], [entry])).toEqual([]);
});
test('an allowlist entry without a reason is rejected', () => {
expect(entriesWithoutReason([{ file: 'a.ts', match: 'x' }, { file: 'b.ts', match: 'y', reason: ' ' }, { file: 'c.ts', match: 'z', reason: 'ok' }]))
.toEqual([{ file: 'a.ts', match: 'x' }, { file: 'b.ts', match: 'y', reason: ' ' }]);
});
});
+235
View File
@@ -0,0 +1,235 @@
/**
* Route-table contract tests (stubbed handlers).
*
* Every entry in ROUTES runs through the real dispatcher and auth gate with a
* stub RouteContext and stub handlers, on every surface it declares, with no
* token, a wrong token, the root token, a scoped token, the SSE cookie and the
* pinned extension Origin. Denials assert the exact status and body each auth
* kind returned at 96764e8; admitted credentials assert the handler ran.
*
* Real-handler coverage for the same routes lives in
* server-route-auth-blackbox.test.ts (every route, both surfaces, through
* buildFetchHandler), plus the route-specific suites (extension-token,
* pair-agent-e2e, server-pty-lease-routes, pty-inject-scan, dual-listener).
*/
import { describe, test, expect } from 'bun:test';
import * as crypto from 'crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ROUTES } from '../src/routes';
import {
dispatchRoute, findRoute, UNMATCHED_ROUTE,
type AuthKind, type RouteContext, type RouteEntry, type Surface,
} from '../src/routes/table';
import { buildFetchHandler, __testInternals__ } from '../src/server';
import { __resetRegistry } from '../src/token-registry';
import { BrowserManager } from '../src/browser-manager';
import { resolveConfig } from '../src/config';
type Cred = 'none' | 'wrong' | 'root' | 'scoped' | 'sse-cookie' | 'extension-origin';
const CREDS: Cred[] = ['none', 'wrong', 'root', 'scoped', 'sse-cookie', 'extension-origin'];
const ROOT = 'stub-root-token-0123456789';
const SCOPED = 'stub-scoped-token-0123456789';
const COOKIE = 'stub-sse-cookie';
function credHeaders(cred: Cred): Record<string, string> {
switch (cred) {
case 'none': return {};
case 'wrong': return { Authorization: 'Bearer stub-wrong-token-0123456789' };
case 'root': return { Authorization: `Bearer ${ROOT}` };
case 'scoped': return { Authorization: `Bearer ${SCOPED}` };
case 'sse-cookie': return { Cookie: `gstack_sse=${COOKIE}` };
case 'extension-origin': return { Origin: 'pinned-extension', Host: '127.0.0.1:34567' };
}
}
const ROOT_INFO = { clientId: 'root', scopes: ['admin'] } as any;
const SCOPED_INFO = { clientId: 'stub-agent', scopes: ['read'] } as any;
function stubContext(): RouteContext {
const bearer = (req: Request) => req.headers.get('authorization');
const unused = () => { throw new Error('stub handlers never reach the context'); };
return {
browserManager: {} as any,
startTime: 0,
browsePort: 34567,
validateAuth: (req) => bearer(req) === `Bearer ${ROOT}`,
isRootRequest: (req) => bearer(req) === `Bearer ${ROOT}`,
getTokenInfo: (req) => bearer(req) === `Bearer ${ROOT}` ? ROOT_INFO : bearer(req) === `Bearer ${SCOPED}` ? SCOPED_INFO : null,
hasSseCookie: (req) => req.headers.get('cookie') === `gstack_sse=${COOKIE}`,
isPinnedExtensionRequest: (req) => req.headers.get('origin') === 'pinned-extension',
isRootTokenValue: (token) => token === ROOT,
bootstrapRootToken: ROOT,
resetIdleTimer: unused,
terminal: { readPort: unused, grantToken: unused, restartSession: unused },
tunnel: { state: unused, close: unused, resolveAuthtoken: unused, start: unused },
commands: { handle: unused, handleInternal: unused },
};
}
const REACHED = 'stub-handler-reached';
function stubbed(routes: readonly RouteEntry[]): RouteEntry[] {
return routes.map(r => ({
...r,
handler: (_req, { tokenInfo }) => new Response(JSON.stringify({ reached: REACHED, tokenInfo }), { status: 299 }),
}));
}
/** Denials exactly as the if-chain server returned them for each gate-level check at 96764e8. */
const EXPECTED_DENIAL: Record<Exclude<AuthKind, 'none' | 'handler'>, { status: number; body: string }> = {
'root-bearer': { status: 401, body: '{"error":"Unauthorized"}' },
'scoped': { status: 401, body: '{"error":"Unauthorized"}' },
'root-or-sse-cookie': { status: 401, body: '{"error":"Unauthorized"}' },
'root-token': { status: 403, body: '{"error":"Root token required"}' },
'extension-origin': { status: 403, body: '{"error":"Forbidden"}' },
};
const ADMITTED: Record<AuthKind, Cred[]> = {
'none': CREDS,
'handler': CREDS,
'root-bearer': ['root'],
'root-token': ['root'],
'scoped': ['root', 'scoped'],
'root-or-sse-cookie': ['root', 'sse-cookie'],
'extension-origin': ['extension-origin'],
};
/** Every route the daemon serves, with the auth kind and surfaces a security review signed off on. */
const EXPECTED_ROUTES: Array<[method: string, path: string, auth: AuthKind, surfaces: Surface[]]> = [
['GET', '/connect', 'none', ['local', 'tunnel']],
['POST', '/connect', 'none', ['local', 'tunnel']],
['*', '/cookie-picker*', 'handler', ['local']],
['*', '/welcome', 'none', ['local']],
['POST', '/extension-token', 'extension-origin', ['local']],
['*', '/health', 'none', ['local']],
['POST', '/pty-session', 'root-bearer', ['local']],
['POST', '/pty-session/reattach', 'root-bearer', ['local']],
['POST', '/pty-restart', 'root-bearer', ['local']],
['POST', '/pty-dispose', 'handler', ['local']],
['POST', '/internal/lease-refresh', 'root-bearer', ['local']],
['POST', '/pty-inject-scan', 'root-bearer', ['local']],
['POST', '/token', 'handler', ['local']],
['DELETE', '/token/*', 'root-token', ['local']],
['GET', '/agents', 'root-token', ['local']],
['POST', '/pair', 'root-token', ['local']],
['POST', '/tunnel/start', 'root-token', ['local']],
['POST', '/sse-session', 'root-bearer', ['local']],
['*', '/refs', 'root-bearer', ['local']],
['*', '/activity/stream', 'root-or-sse-cookie', ['local']],
['*', '/activity/history', 'root-bearer', ['local']],
['POST', '/batch', 'scoped', ['local']],
['GET', '/file', 'scoped', ['local']],
['POST', '/command', 'scoped', ['local', 'tunnel']],
['POST', '/inspector/pick', 'root-bearer', ['local']],
['GET', '/inspector', 'root-bearer', ['local']],
['POST', '/inspector/apply', 'root-bearer', ['local']],
['POST', '/inspector/reset', 'root-bearer', ['local']],
['GET', '/inspector/history', 'root-bearer', ['local']],
['GET', '/memory', 'root-bearer', ['local']],
['GET', '/inspector/events', 'root-bearer', ['local']],
];
const routeKey = (r: RouteEntry) => `${r.method} ${r.path}${r.prefix ? '*' : ''}`;
const requestPath = (r: RouteEntry) => r.prefix ? `${r.path}${r.path.endsWith('/') ? 'probe' : '/probe'}` : r.path;
const requestMethod = (r: RouteEntry) => r.method === '*' ? 'GET' : r.method;
describe('route table declarations', () => {
test('the table is exactly the reviewed route inventory, auth kinds and surfaces', () => {
const describe = (method: string, p: string, auth: AuthKind, surfaces: readonly Surface[]) =>
`${method} ${p} auth=${auth} surfaces=${surfaces.join(',')}`;
const actual = ROUTES.map(r => describe(r.method, `${r.path}${r.prefix ? '*' : ''}`, r.auth, r.surfaces)).sort();
const expected = EXPECTED_ROUTES.map(([m, p, a, s]) => describe(m, p, a, s)).sort();
expect(actual).toEqual(expected);
});
test('every route declares an auth kind and at least one surface; only handler-auth routes carry handlerAuth', () => {
const kinds = new Set<AuthKind>(['none', 'root-bearer', 'root-token', 'scoped', 'extension-origin', 'root-or-sse-cookie', 'handler']);
for (const r of ROUTES) {
expect(kinds.has(r.auth), routeKey(r)).toBe(true);
expect(r.surfaces.length, routeKey(r)).toBeGreaterThan(0);
expect(typeof r.handler, routeKey(r)).toBe('function');
if (r.auth === 'handler') expect(r.handlerAuth?.length ?? 0, routeKey(r)).toBeGreaterThan(20);
else expect(r.handlerAuth, routeKey(r)).toBeUndefined();
}
});
test('no two entries claim the same method and path', () => {
const keys = ROUTES.map(routeKey);
expect(new Set(keys).size).toBe(keys.length);
});
test('the tunnel-surface paths in the table equal the TUNNEL_PATHS literal', () => {
const tableTunnelPaths = new Set(ROUTES.filter(r => r.surfaces.includes('tunnel')).map(r => r.path));
expect([...tableTunnelPaths].sort()).toEqual([...__testInternals__.tunnelPaths].sort());
expect(findRoute(ROUTES, 'GET', '/connect', 'tunnel')?.auth).toBe('none');
});
});
describe('route table auth matrix (stubbed handlers)', () => {
const ctx = stubContext();
const routes = stubbed(ROUTES);
for (const route of routes) {
for (const surface of route.surfaces) {
for (const cred of CREDS) {
test(`${surface} ${routeKey(route)} with ${cred}`, async () => {
const url = new URL(`http://127.0.0.1:34567${requestPath(route)}`);
const req = new Request(url, { method: requestMethod(route), headers: credHeaders(cred) });
const resp = await dispatchRoute(routes, req, url, surface, ctx);
const body = await resp.text();
if (ADMITTED[route.auth].includes(cred)) {
expect(resp.status).toBe(299);
const parsed = JSON.parse(body);
expect(parsed.reached).toBe(REACHED);
if (route.auth === 'scoped') expect(parsed.tokenInfo).toEqual(cred === 'root' ? ROOT_INFO : SCOPED_INFO);
return;
}
const denial = EXPECTED_DENIAL[route.auth as keyof typeof EXPECTED_DENIAL];
expect({ status: resp.status, body }).toEqual(denial);
expect(resp.headers.get('content-type')).toBe('application/json');
});
}
}
}
test('a local-only entry is not matched on the tunnel surface', () => {
for (const r of ROUTES.filter(r => !r.surfaces.includes('tunnel'))) {
expect(findRoute(ROUTES, requestMethod(r), requestPath(r), 'tunnel'), routeKey(r)).toBeNull();
}
});
for (const [method, pathname] of [['GET', '/no-such-route'], ['GET', '/command'], ['PUT', '/token'], ['GET', '/token/x']]) {
test(`unmatched ${method} ${pathname}: root-bearer check, then plain-text 404`, async () => {
expect(UNMATCHED_ROUTE.auth).toBe('root-bearer');
const url = new URL(`http://127.0.0.1:34567${pathname}`);
for (const cred of CREDS) {
const resp = await dispatchRoute(routes, new Request(url, { method, headers: credHeaders(cred) }), url, 'local', ctx);
const body = await resp.text();
if (cred === 'root') expect({ status: resp.status, body }).toEqual({ status: 404, body: 'Not found' });
else expect({ status: resp.status, body }).toEqual(EXPECTED_DENIAL['root-bearer']);
}
});
}
});
describe('tunnel surface rejects the root token on every tunnel route', () => {
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-route-table-'));
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(fixtureDir, 'state/browse.json') });
for (const route of ROUTES.filter(r => r.surfaces.includes('tunnel'))) {
test(`${routeKey(route)}`, async () => {
__resetRegistry();
const authToken = 'route-table-' + crypto.randomBytes(16).toString('hex');
const handle = buildFetchHandler({
authToken, browsePort: 34567, config, browserManager: new BrowserManager(),
ownsTerminalAgent: false, startTime: Date.now(),
});
const resp = await handle.fetchTunnel(new Request(`http://127.0.0.1:34567${route.path}`, {
method: route.method, headers: { Authorization: `Bearer ${authToken}` },
}), null);
expect(resp.status).toBe(403);
expect((await resp.json() as { error: string }).error).toBe('Root token rejected on tunnel surface');
});
}
});
+38 -20
View File
@@ -1,4 +1,6 @@
import { describe, test, expect } from 'bun:test'; import { describe, test, expect } from 'bun:test';
import { emitActivity } from '../src/activity';
import { stubRouteContext, callRoute } from './route-test-harness';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@@ -10,6 +12,8 @@ import { stripLoneSurrogates as sanitizeLoneSurrogates } from '../src/sanitize';
const SERVER_PATH = path.resolve(import.meta.dir, '..', 'src', 'server.ts'); const SERVER_PATH = path.resolve(import.meta.dir, '..', 'src', 'server.ts');
const SERVER_SRC = fs.readFileSync(SERVER_PATH, 'utf-8'); const SERVER_SRC = fs.readFileSync(SERVER_PATH, 'utf-8');
const ROUTE_SRC = (file: string) => fs.readFileSync(path.resolve(import.meta.dir, '..', 'src', 'routes', file), 'utf-8');
const LONE = 'bad \uD800 value';
describe('sanitizeLoneSurrogates — unit cases', () => { describe('sanitizeLoneSurrogates — unit cases', () => {
test('passthrough ASCII', () => { test('passthrough ASCII', () => {
@@ -105,22 +109,27 @@ describe('sanitizeLoneSurrogates — wiring invariants', () => {
expect(SERVER_SRC).toContain('result: stripLoneSurrogates(cr.result)'); expect(SERVER_SRC).toContain('result: stripLoneSurrogates(cr.result)');
}); });
test('SSE activity feed routes outbound frames through createSseEndpoint', () => { test('SSE activity feed routes outbound frames through createSseEndpoint', async () => {
// v1.51 refactor: /activity/stream no longer inlines its own // v1.51 refactor: /activity/stream routes through createSseEndpoint,
// ReadableStream/sanitizer wiring; it routes through createSseEndpoint // which applies sanitizeReplacer to every JSON.stringify. Replaying an
// which applies sanitizeReplacer to every JSON.stringify. The grep // activity entry that carries a lone surrogate must emit U+FFFD, never
// pins both halves of the contract: the endpoint uses the helper, // the lone code unit or its \uXXXX escape.
// and the helper does the sanitization. const entry = emitActivity({ type: 'command_start', command: 'goto', url: `https://example.com/${LONE}` });
const activityBlock = SERVER_SRC.match( const resp = await callRoute('GET', `/activity/stream?after=${entry.id - 1}`, stubRouteContext());
/if \(url\.pathname === '\/activity\/stream'\)[\s\S]*?createSseEndpoint\(/, const reader = resp.body!.getReader();
); let text = '';
expect(activityBlock).not.toBeNull(); while (!text.includes(`"id":${entry.id}`)) text += new TextDecoder().decode((await reader.read()).value);
await reader.cancel();
expect(text).toContain('bad \uFFFD value');
expect(text).not.toMatch(/\\ud800/i);
}); });
test('SSE inspector stream routes outbound frames through createSseEndpoint', () => { test('SSE inspector stream routes outbound frames through createSseEndpoint', () => {
// Same v1.51 refactor invariant for /inspector/events. // Same v1.51 invariant for /inspector/events. Inspector state only fills
const inspectorBlock = SERVER_SRC.match( // from a live CDP pick, so this stays a source check, re-pointed to the
/if \(url\.pathname === '\/inspector\/events'[\s\S]*?createSseEndpoint\(/, // route module.
const inspectorBlock = ROUTE_SRC('inspector.ts').match(
/path: '\/inspector\/events'[\s\S]*?createSseEndpoint\(/,
); );
expect(inspectorBlock).not.toBeNull(); expect(inspectorBlock).not.toBeNull();
}); });
@@ -152,13 +161,22 @@ describe('sanitizeLoneSurrogates — wiring invariants', () => {
); );
}); });
test('server.ts imports sanitizeReplacer for non-SSE JSON egress and still uses it', () => { test('non-SSE JSON egress routes use the canonical sanitizeReplacer', async () => {
// server.ts used to define its own private sanitizeReplacer for the // The non-SSE JSON egress paths (/memory snapshot, /pty-inject-scan)
// non-SSE JSON egress paths (/pty-inject-scan, /memory snapshot, etc.). // moved to route modules; they pass the canonical replacer from
// It now imports the canonical one — and must still pass it at those // sanitize.ts through json(). /memory is exercised end to end with a
// JSON.stringify egress sites. // page-derived tab title carrying a lone surrogate.
expect(SERVER_SRC).toMatch(/import \{[^}]*sanitizeReplacer[^}]*\} from '\.\/sanitize'/); const ctx = stubRouteContext({
browserManager: { getMemorySnapshot: async () => ({ tabs: [{ title: LONE }] }) } as any,
});
const text = await (await callRoute('GET', '/memory', ctx)).text();
expect(JSON.parse(text).tabs[0].title).toBe('bad \uFFFD value');
expect(text).not.toMatch(/\\ud800/i);
for (const file of ['core.ts', 'pty.ts']) {
const src = ROUTE_SRC(file);
expect(src).toMatch(/import \{[^}]*sanitizeReplacer[^}]*\} from '\.\.\/sanitize'/);
expect(src).toContain('replacer: sanitizeReplacer');
}
expect(SERVER_SRC).not.toContain('function sanitizeReplacer('); expect(SERVER_SRC).not.toContain('function sanitizeReplacer(');
expect(SERVER_SRC).toContain(', sanitizeReplacer)');
}); });
}); });
+18 -7
View File
@@ -15,6 +15,8 @@
import { describe, test, expect } from 'bun:test'; import { describe, test, expect } from 'bun:test';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { ROUTES } from '../src/routes';
import { makeServer } from './route-test-harness';
const HTML = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.html'), 'utf-8'); const HTML = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.html'), 'utf-8');
const JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.js'), 'utf-8'); const JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.js'), 'utf-8');
@@ -174,13 +176,22 @@ describe('sidepanel-terminal.js: eager auto-connect + injection API', () => {
describe('server.ts: chat / sidebar-agent endpoints are gone', () => { describe('server.ts: chat / sidebar-agent endpoints are gone', () => {
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
test('No /sidebar-command, /sidebar-chat, /sidebar-agent/* routes', () => { test('No /sidebar-command, /sidebar-chat, /sidebar-agent/* routes', async () => {
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-command['"]/); // Routes are dispatched only through the route table, so absence from
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-chat['"]/); // the table plus the unmatched 404 with the root token is the contract.
expect(SERVER_SRC).not.toMatch(/url\.pathname\.startsWith\(['"]\/sidebar-agent\//); const gone = ['/sidebar-command', '/sidebar-chat', '/sidebar-agent/event', '/sidebar-agent/x', '/sidebar-tabs', '/sidebar-session'];
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-agent\/event['"]/); for (const p of gone) {
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-tabs['"]/); expect(ROUTES.some(r => r.prefix ? p.startsWith(r.path) : r.path === p), p).toBe(false);
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-session['"]/); }
const server = makeServer();
try {
for (const p of gone) {
for (const method of ['GET', 'POST']) {
const resp = await server.local(p, { method, headers: { Authorization: `Bearer ${server.rootToken}` } });
expect([p, method, resp.status, await resp.text()]).toEqual([p, method, 404, 'Not found']);
}
}
} finally { server.cleanup(); }
}); });
test('No chat-related state declarations or helpers', () => { test('No chat-related state declarations or helpers', () => {
+26 -16
View File
@@ -23,6 +23,8 @@
import { describe, test, expect } from 'bun:test'; import { describe, test, expect } from 'bun:test';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import { stubRouteContext, callRoute, routeEntry } from './route-test-harness';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { BrowserManager } from '../src/browser-manager'; import { BrowserManager } from '../src/browser-manager';
@@ -676,29 +678,37 @@ describe('welcome page', () => {
}); });
describe('server /welcome endpoint', () => { describe('server /welcome endpoint', () => {
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); // Resolve against empty HOME / skill-root dirs so neither the project
// welcome page nor the installed one exists.
async function welcomeWithoutPages(): Promise<Response> {
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-welcome-empty-'));
const saved = { HOME: process.env.HOME, GSTACK_SKILL_ROOT: process.env.GSTACK_SKILL_ROOT };
try {
process.env.HOME = empty;
process.env.GSTACK_SKILL_ROOT = empty;
return await callRoute('GET', '/welcome', stubRouteContext());
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
fs.rmSync(empty, { recursive: true, force: true });
}
}
test('/welcome endpoint exists in server.ts', () => { test('/welcome endpoint exists in the route table', () => {
expect(serverSrc).toContain("url.pathname === '/welcome'"); expect(routeEntry('GET', '/welcome')).toMatchObject({ path: '/welcome', auth: 'none', surfaces: ['local'] });
}); });
test('/welcome serves HTML content type', () => { test('/welcome serves HTML content type', async () => {
const welcomeSection = serverSrc.slice( expect((await welcomeWithoutPages()).headers.get('content-type')).toBe('text/html; charset=utf-8');
serverSrc.indexOf("url.pathname === '/welcome'"),
serverSrc.indexOf("url.pathname === '/health'"),
);
expect(welcomeSection).toContain("'Content-Type': 'text/html");
}); });
test('/welcome serves fallback HTML if no welcome file found', () => { test('/welcome serves fallback HTML if no welcome file found', async () => {
const welcomeSection = serverSrc.slice(
serverSrc.indexOf("url.pathname === '/welcome'"),
serverSrc.indexOf("url.pathname === '/health'"),
);
// Changed from 302 redirect to about:blank (ERR_UNSAFE_REDIRECT on Windows) // Changed from 302 redirect to about:blank (ERR_UNSAFE_REDIRECT on Windows)
// to inline HTML fallback page (PR #822) // to inline HTML fallback page (PR #822)
expect(welcomeSection).toContain('GStack Browser ready'); const resp = await welcomeWithoutPages();
expect(welcomeSection).toContain('status: 200'); expect(resp.status).toBe(200);
expect(await resp.text()).toContain('GStack Browser ready');
}); });
}); });
+35 -25
View File
@@ -23,6 +23,7 @@ import {
extractPtyCookie, buildPtySetCookie, extractPtyCookie, buildPtySetCookie,
PTY_COOKIE_NAME, __resetPtySessions, PTY_COOKIE_NAME, __resetPtySessions,
} from '../src/pty-session-cookie'; } from '../src/pty-session-cookie';
import { makeServer, stubRouteContext, callRoute, routeEntry } from './route-test-harness';
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
const AGENT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/terminal-agent.ts'), 'utf-8'); const AGENT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/terminal-agent.ts'), 'utf-8');
@@ -89,17 +90,19 @@ describe('Source-level guard: /pty-session is not on the tunnel surface', () =>
}); });
}); });
describe('Source-level guard: /health does NOT surface ptyToken', () => { describe('/health does NOT surface ptyToken', () => {
test('/health response body does not include ptyToken', () => { test('/health response body does not include ptyToken', async () => {
const healthIdx = SERVER_SRC.indexOf("url.pathname === '/health'");
expect(healthIdx).toBeGreaterThan(-1);
// Slice from /health through the response close-bracket.
const slice = SERVER_SRC.slice(healthIdx, healthIdx + 2000);
// The /health JSON.stringify body must not mention the cookie token.
// It's allowed to include `terminalPort` (a port number, not auth). // It's allowed to include `terminalPort` (a port number, not auth).
expect(slice).not.toContain('ptyToken'); const ctx = stubRouteContext({
expect(slice).not.toContain('gstack_pty'); browserManager: { isHealthy: async () => true, getConnectionMode: () => 'launched', getTabCount: () => 1 } as any,
expect(slice).toContain('terminalPort'); terminal: { readPort: () => 4242, grantToken: async () => true, restartSession: async () => true },
});
const text = await (await callRoute('GET', '/health', ctx)).text();
const body = JSON.parse(text);
expect(body.terminalPort).toBe(4242);
expect(Object.keys(body).sort()).toEqual(['mode', 'status', 'tabs', 'terminalPort', 'uptime']);
expect(text).not.toContain('ptyToken');
expect(text).not.toContain('gstack_pty');
}); });
}); });
@@ -229,21 +232,28 @@ describe('Source-level guard: terminal-agent', () => {
}); });
}); });
describe('Source-level guard: server.ts /pty-session route', () => { describe('/pty-session route', () => {
test('validates AUTH_TOKEN, grants over loopback, returns token + Set-Cookie', () => { test('validates AUTH_TOKEN, grants over loopback, returns token + Set-Cookie', async () => {
const route = SERVER_SRC.slice(SERVER_SRC.indexOf("url.pathname === '/pty-session'"));
// Must check auth before minting. // Must check auth before minting.
const beforeMint = route.slice(0, route.indexOf('mintPtySessionToken')); expect(routeEntry('POST', '/pty-session').auth).toBe('root-bearer');
expect(beforeMint).toContain('validateAuth'); const server = makeServer();
// Must call the loopback grant before responding (otherwise the try {
// agent's validTokens Set never sees the token and /ws would 401). const denied = await server.local('/pty-session', { method: 'POST' });
expect(route).toContain('grantPtyToken'); expect(denied.status).toBe(401);
// Must return the token in the JSON body for the } finally { server.cleanup(); }
// Sec-WebSocket-Protocol auth path (cross-port cookies don't survive // Must call the loopback grant before responding (otherwise the agent's
// SameSite=Strict from a chrome-extension origin). // validTokens Set never sees the token and /ws would 401), return the
expect(route).toContain('ptySessionToken'); // token in the JSON body for the Sec-WebSocket-Protocol auth path
// Set-Cookie is kept as a fallback for non-browser callers. // (cross-port cookies don't survive SameSite=Strict from a
expect(route).toContain('Set-Cookie'); // chrome-extension origin), and keep Set-Cookie as a fallback for
expect(route).toContain('buildPtySetCookie'); // non-browser callers.
const granted: string[] = [];
const ctx = stubRouteContext({
terminal: { readPort: () => 4242, grantToken: async (token) => { granted.push(token); return true; }, restartSession: async () => true },
});
const resp = await callRoute('POST', '/pty-session', ctx);
const body = await resp.json() as any;
expect(granted).toEqual([body.ptySessionToken]);
expect(resp.headers.get('set-cookie')).toBe(buildPtySetCookie(body.ptySessionToken));
}); });
}); });
+9 -5
View File
@@ -233,15 +233,19 @@ describe('token-registry', () => {
it('rejects expired setup key', () => { it('rejects expired setup key', () => {
const setup = createSetupKey({}); const setup = createSetupKey({});
// Manually expire it // Manually expire it (createSetupKey returns the registry's own record)
const info = validateToken(setup.token); setup.expiresAt = new Date(Date.now() - 1000).toISOString();
if (info) {
(info as any).expiresAt = new Date(Date.now() - 1000).toISOString();
}
const session = exchangeSetupKey(setup.token); const session = exchangeSetupKey(setup.token);
expect(session).toBeNull(); expect(session).toBeNull();
}); });
it('does not accept an unexchanged setup key as a bearer token', () => {
const setup = createSetupKey({ scopes: ['read', 'write'] });
expect(validateToken(setup.token)).toBeNull();
const session = exchangeSetupKey(setup.token);
expect(validateToken(session!.token)).not.toBeNull();
});
it('rejects unknown setup key', () => { it('rejects unknown setup key', () => {
expect(exchangeSetupKey('gsk_setup_nonexistent')).toBeNull(); expect(exchangeSetupKey('gsk_setup_nonexistent')).toBeNull();
}); });
+4 -2
View File
@@ -236,7 +236,8 @@ At session start or after compaction, recover recent project context.
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
_BRANCH=$(git branch --show-current 2>/dev/null | tr -cd 'a-zA-Z0-9._/-') || :; _BRANCH=${_BRANCH:-unknown} _BRANCH=$(git branch --show-current 2>/dev/null | tr -cd 'a-zA-Z0-9._/-') || :; _BRANCH=${_BRANCH:-unknown}
_PROJ="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}" eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
_PROJ="$GSTACK_STATE_ROOT/projects/${SLUG:-unknown}"
if [ -d "$_PROJ" ]; then if [ -d "$_PROJ" ]; then
echo "--- RECENT ARTIFACTS ---" echo "--- RECENT ARTIFACTS ---"
find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs -r ls -t 2>/dev/null | head -3 find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs -r ls -t 2>/dev/null | head -3
@@ -694,8 +695,9 @@ Per-page and overall status: BROKEN if any confirmed CRITICAL alert occurred; ot
Log the result for the review dashboard: Log the result for the review dashboard:
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
mkdir -p ~/.gstack/projects/$SLUG mkdir -p "$GSTACK_STATE_ROOT"/projects/$SLUG
``` ```
Write a JSONL entry: `{"skill":"canary","timestamp":"<ISO>","status":"<HEALTHY/DEGRADED/BROKEN>","url":"<url>","duration_min":<N>,"alerts":<N>}` Write a JSONL entry: `{"skill":"canary","timestamp":"<ISO>","status":"<HEALTHY/DEGRADED/BROKEN>","url":"<url>","duration_min":<N>,"alerts":<N>}`
+2 -1
View File
@@ -224,8 +224,9 @@ Per-page and overall status: BROKEN if any confirmed CRITICAL alert occurred; ot
Log the result for the review dashboard: Log the result for the review dashboard:
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
{{SLUG_EVAL}} {{SLUG_EVAL}}
mkdir -p ~/.gstack/projects/$SLUG mkdir -p "$GSTACK_STATE_ROOT"/projects/$SLUG
``` ```
Write a JSONL entry: `{"skill":"canary","timestamp":"<ISO>","status":"<HEALTHY/DEGRADED/BROKEN>","url":"<url>","duration_min":<N>,"alerts":<N>}` Write a JSONL entry: `{"skill":"canary","timestamp":"<ISO>","status":"<HEALTHY/DEGRADED/BROKEN>","url":"<url>","duration_min":<N>,"alerts":<N>}`
+3 -2
View File
@@ -36,8 +36,9 @@ patterns before running. If a destructive command is detected, you'll be warned
and can choose to proceed or cancel. and can choose to proceed or cancel.
```bash ```bash
mkdir -p ~/.gstack/analytics eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
echo '{"skill":"careful","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true mkdir -p "$GSTACK_STATE_ROOT"/analytics
echo '{"skill":"careful","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> "$GSTACK_STATE_ROOT"/analytics/skill-usage.jsonl 2>/dev/null || true
``` ```
## What's protected ## What's protected
+3 -2
View File
@@ -31,8 +31,9 @@ patterns before running. If a destructive command is detected, you'll be warned
and can choose to proceed or cancel. and can choose to proceed or cancel.
```bash ```bash
mkdir -p ~/.gstack/analytics eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
echo '{"skill":"careful","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true mkdir -p "$GSTACK_STATE_ROOT"/analytics
echo '{"skill":"careful","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> "$GSTACK_STATE_ROOT"/analytics/skill-usage.jsonl 2>/dev/null || true
``` ```
## What's protected ## What's protected
Loaded 100 of 383 files, more files were not shown because too many files have changed in this diff. Show more