mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-20 04:57:15 +02:00
v1.65.0.0 feat: fork port wave 2 — feature fixes, session persistence, Apple releases, supply-chain CI (#2577)
* fix(memory-ingest): pass --include-gitignored to gbrain import gstack-artifacts-init writes an ignore-everything .gitignore (a bare `*`, headed "Do not edit") at the root of ~/.gstack. The memory ingest stages pages into ~/.gstack/.staging-ingest-<pid>-<ts>/, which is inside that repo, and gbrain's markdown collector honours .gitignore. The collector therefore matches every staged file against `*` and collects zero. The failure is silent. gbrain import exits 0 having imported nothing while the ingest prints `written: N` from the STAGED count rather than the imported count, so a run that indexes nothing looks identical to a healthy one and the memory corpus quietly stops growing. Reproduction, using git's own ignore machinery (no gbrain needed): git init . mkdir -p .staging-ingest-12345/learnings echo x > .staging-ingest-12345/learnings/page.md printf '*\n' > .gitignore git ls-files --others --exclude-standard # -> empty Passing --include-gitignored makes the import independent of whatever .gitignore sits above the staging directory. Adding a negation to the generated .gitignore is the alternative, but that file is gstack-owned and marked "Do not edit", so any regeneration silently reintroduces the bug. Adds a regression pin in the shape of memory-ingest-no-put_page.test.ts, plus a behavioural test for the collision itself. Both source pins fail against the unpatched file. * fix(memory-ingest): GIT_CEILING_DIRECTORIES defense-in-depth on the import child (#2144) Second layer under #2560's --include-gitignored: a realpath'd ceiling at the staging dir's parent pushes any git-enumerating collector off the git fast path (which sees zero files under ~/.gstack's ignore-everything root) onto its plain FS walk, even on gbrain builds whose flag semantics drift. Ceiling is realpath'd because git compares canonicalized directories during discovery — a staging dir reached through a symlink (macOS /var -> /private/var, symlinked $GSTACK_HOME) otherwise never matches. Behavioral tests prove discovery stops at the ceiling from the staging dir, including through a symlinked path, using git itself — no gbrain required. Mechanism ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(autoplan): Phase 4 task aggregator emitted zero tasks on every run (#2018) The branch+commit jq filter piped to the split commit array and then referenced .commit — jq rebinds context across a pipe, so .commit indexed the ARRAY with a string, every input line errored into 2>/dev/null, and || true swallowed the exit. The aggregate table has been empty for every user since the feature shipped. Bind .commit to a variable before the pipe. Functional pin extracts the ACTUAL emitted jq program from the resolver and runs it against fixture JSONL (verified RED against the broken filter), plus a source-shape guard against reintroducing a context-rebinding reference. Fix mechanism from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(codex): BSD mktemp templates broke /codex on every macOS install (#2091) macOS mktemp requires the X's to end the template; the five "codex-*-XXXXXX.txt" sites failed with "mkstemp failed ... File exists" before Codex ever ran (reproduced live on this machine). Same class fixed in claude/SKILL.md.tmpl's three sites. bin/gstack-paths now strips macOS's trailing slash from TMPDIR so TMP_ROOT-built paths stop carrying "//". Static tripwire scans every tracked .tmpl for characters after the X-run in a mktemp template (longer X-runs stay valid), plus a live portability check of the emitted shape. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(decision-log): --supersede silently discarded the replacement decision The supersede/redact branch appended the retirement event and exited before the JSON argument was ever read — a user recording a reversal WITH its replacement lost the replacement, and the payload finder's first-non-flag-arg predicate would have mistaken the target id for JSON anyway. Payloads are now identified by their leading brace, validated BEFORE any write, and appended FIRST (retirement second), so the only visible interleaving under a crash is both-active — recoverable, never lost. The replacement carries supersedes:<old-id> provenance. Bare --supersede <id> (the documented reversal-without-replacement) stays legal; --redact with a payload now refuses instead of dropping it. Ported from time-attack/gstack (GStack 2), tests included. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(brain-context): cold-start probe latency permanently disabled gbrain context gbrainAvailable() spawned gbrain --version under a 500ms budget; a cold CLI start on a loaded machine blew the timeout, misclassified gbrain as missing, and every skill session silently ran brainless — plus the per-query re-probe burned 3x the budget before any real work. Replaced with a memoized stat-based PATH scan (PATHEXT-aware on Windows) and made the query timeout overridable via GSTACK_BRAIN_TIMEOUT_MS for loaded CI environments. Also picks up the fork's manifest-filter coverage (#1687 shape) against the fake-gbrain harness — passes against our existing filter support. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(setup-gbrain): voyage-code-3 flags were silently dropped under zsh (#1798) zsh does not word-split an unquoted $VAR, so all three PGLite-init sites passed the entire flag string as ONE argv word — gbrain ignored it and silently fell back to its default embedding model, downgrading code retrieval for every zsh user (macOS default shell). Flags now ride the positional params (set -- ...; "$@"). Tests run the shape under BOTH bash and zsh against the fake-gbrain argv recorder (per-word argc log distinguishes one-blob from split), include a demonstration of the zsh collision on the old shape, and pin the template's three sites statically. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(model-benchmark): recognize macOS Keychain auth in the claude adapter (#1890) The default macOS Claude Code install stores OAuth under the Keychain generic-password service "Claude Code-credentials" and never writes ~/.claude/.credentials.json, so available()'s file-or-env sniff reported "No Claude auth found" while claude -p worked fine. On darwin the sniff now also probes the Keychain entry — metadata only (no -w, the secret is never read), 5s timeout, any security(1) failure degrades to not-found. Verified live on this machine (subscription install, no creds file, Keychain entry present). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(upgrade): v1.27 migration no longer auto-proceeds without a TTY or records a failed rename as done (#1383) Two silent-failure shapes in one script. Non-interactive runs (Claude Code Bash tool, CI) blanket-auto-proceeded into a REMOTE repo rename — now they skip-for-now by default and ask again next upgrade; unattended runs opt in with GSTACK_MIGRATE_ASSUME_YES=1. And a failed gh rename was journaled as done and the done-touchfile written, permanently stranding a half-renamed install — the failed step now stays PENDING with the manual command printed, finalize refuses the done-marker while any step is unjournaled, and the migration exits 1 with a re-run pointer while completed steps still skip on retry. Harness updated to opt in explicitly; new tests pin the default-skip and failure-stays-pending-then-retry-succeeds contracts (13/13). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ship): REST fallback when gh pr edit hits the Projects-classic GraphQL deprecation (#1079) On repos where GitHub enforces the Projects-classic sunset, gh pr edit hard-errors on repository.pullRequest.projectCards and Step 19's PR body update dies. The template now names the error shape, says it is not an auth problem, and falls back to the REST endpoint (gh api pulls/N -X PATCH) with the SAME already-redaction-scanned temp file for body and title. Generated SKILL.md regen rides the cluster regen commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ship): test-command detection was blind to Django and config-less-but-tested projects The Test Framework Bootstrap detected Python only via requirements.txt or pyproject.toml and treated missing config files as no-tests, so a green 'python manage.py test' Django app, a Go project with *_test.go beside the source, in-source Rust #[test] blocks, or a package.json with only a test script all got offered a SECOND test framework over a working one. Detection now enumerates definitive per-ecosystem markers (manage.py, tox.ini/setup.cfg, pom.xml/gradle, Makefile test targets, a tracked-file test census, in-source Rust tests) as EVIDENCE for the question it asks — never a command to run blind — preserving the read-CLAUDE.md-or-ask contract, with a marker→candidate-command table and ask-once persistence. The shared coverage-audit detection block gains the same markers. Test runs the resolver's emitted detection bash against Django / Go / Rust / Node fixtures in throwaway git repos. Ported from time-attack/gstack commit e3259078 (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate SKILL.md files for cluster A (autoplan jq, codex mktemp, setup-gbrain zsh, ship detection + REST fallback) Atomic regen of the 9 generated files whose templates/resolvers changed in the A-cluster commits. bun run gen:skill-docs, no hand edits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: refresh ship goldens + parity ratios for cluster A growth Codex/Factory hosts render single-file ship skills whose committed goldens must track template changes; refreshed from the regenerated renders. Parity size guards bumped with the growth itemized — ship (carve-guards) 1.08 -> 1.10 for the detection-evidence + REST-fallback growth measured at 1.090x, qa (parity-harness monolith invariant) 1.07 -> 1.12 for the shared coverage-audit markers measured at 1.111x. Kept tight so the next growth is a deliberate decision, not drift; the Apple adapter raises ship again with its own justification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain-sync): enforce the per-repo policy at the code-import chokepoint (#2140 sync path) The deny/read-only tiers in ~/.gstack/gbrain-repo-policy.json were stored by gstack-gbrain-repo-policy but enforced only in /sync-gbrain skill prose — a direct or cron invocation of gstack-gbrain-sync ingested repo code regardless. Worse: the code stage's egress receipt has cited 'per-repo policy chokepoint (repoPolicyTier)' as its consent since v1.63 while no such function existed. repoPolicyTier() now gates the stage before the dry-run branch: deny → refused-policy-deny (exit 1, loud), read-only → clean skipped-policy-read-only (code ingest writes pages), unreadable store → fail-closed refused-policy-unreadable, no store → unchanged fail-open. Subprocess tests pin all four paths against real git repos and a permission-blocked store (verified RED against the ungated binary). The receipt's consent string is truthful from this commit. #2140's ingest-path source-isolation ask remains open — partial-progress comment at ship. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios-qa): /auth/sessions no longer hands raw bearer tokens to any local process The loopback sessions list echoed live tokens — a harvest-and-replay primitive for anything on the machine (same class as the /health token leak fixed in v1.63). The list now returns a device-salted 16-hex token_id plus metadata; the salt is shared with the attempts log so identifiers correlate. /auth/revoke keeps the list→revoke workflow alive by accepting token_id alongside the caller's own raw token and identity. saltedHash() is exported from audit.ts and writeAttempt now reuses it (was inlined). Integration tests pin raw-token absence, the id shape/metadata, and the token_id revoke round-trip (verified RED against the leaking handler). List fix ported from time-attack/gstack (GStack 2); token_id revoke is ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios-qa): boot token out of os_log entirely; IPv4 listener pinned to loopback at the socket The StateServer's bootstrap announce logged the live boot token with privacy: .public — and nothing consumed it: the daemon has read the token from the 0600 app-container file since the devicectl copy flow landed. The log line handed a credential to anything reading the unified log during the launch window. It now announces port/build only. The IPv4 listener bound the wildcard interface and relied on the per-connection peer check alone; IPv4 has no CoreDevice tunnel path, so it now binds 127.0.0.1 via requiredLocalEndpoint at the socket level. IPv6 keeps the wildcard bind for CoreDevice ULA peers by design. Static pins cover both the template and the fixture app copy. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(make-pdf): close the offline-gate bypass via raw-HTML fetch vectors With --allow-network off, the sanitizer stripped script/iframe/link but let Chromium fetch remote resources at print time through four raw-HTML vectors: <style> @import (any form), remote url() in <style> blocks and inline style attributes (incl. protocol-relative //), srcset with a remote candidate (Chromium prefers srcset over the inlined src), and remote src/poster on video/audio/source/track. All neutralized at the sanitizer; remote <img src> is deliberately left for the image inliner so its blocked-remote placeholder still fires, and url() mentions in prose/code spans stay untouched. Fork's test suite ported verbatim (12 cases incl. the end-to-end render assertion), verified RED against the old sanitizer. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pair-agent): tunnel activation is consent-gated — and the receipt's consent claim is now real The tunnel egress receipts have claimed consent: 'pair_agent=on' since v1.63 while no such key or gate existed — ngrok installed+authed was enough for the CLI to auto-start an internet-facing tunnel. isPairAgentEnabled() (fail- closed, env-overridable) now gates all three activation points: CLI auto-start, POST /tunnel/start (refuses with the enable hint), and the BROWSE_TUNNEL=1 startup bind. Consent-on-first-use, not silent breakage: the /pair-agent skill asks once (one-way-door posture), sets pair_agent via gstack-config (registered with on|off validation, default off), and never asks again; direct API callers get the same hint in the refusal. Adapted from the fork's gate: their reader targeted config.json, which on main would have made the gate silently un-enableable — ours reads the canonical ~/.gstack/config.yaml with the JSON shape as fallback, pinned by tests either way (11 cases, gate wiring tripwires included). Ported from time-attack/gstack (GStack 2), store adaptation ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate pair-agent SKILL.md for cluster B (consent gate) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): cancel the parent watchdog when handoff promotes a daemon to headed The parent-process watchdog assumes connection mode is fixed at boot: headless daemons outlive their parent, headed ones do not. The env guards (BROWSE_PARENT_PID=0, BROWSE_HEADED=1) only cover daemons that were headed when they started. handoff breaks that assumption. It swaps in a headed context on a RUNNING daemon and sets connectionMode = 'headed' without a restart, so a daemon that legitimately registered a watchdog lands on the fatal side of the branch. The parent is usually a short-lived shell, and Claude Code's Bash tool kills one after every invocation, so the next 15s poll shuts the daemon down. The user-visible effect is that handoff destroys the thing it just created. It exists so a human can log in, solve a CAPTCHA, or clear an MFA prompt; the browser disappears about fifteen seconds later and takes the session with it. Observed while driving two registrar control panels: five daemon deaths and three logins, each one discarding the authenticated session. BrowserManager now exposes onHeadedPromotion, fired only on runtime promotion and not on a headed boot, and the server binds it to a canceller for the interval it already owned but previously discarded. Bound on both the module-level manager and any embedder-supplied one, since the watchdog reads activeBrowserManager and binding only the default would let embedders promote silently. The binding sits next to the browserManager declaration rather than next to clearParentWatchdog. Placing it with the function, which lives with the watchdog it cancels, reads better but touches browserManager in its temporal dead zone, which aborts module evaluation and leaves every later const uninitialized. findport tests catch that immediately. Tests: watchdog.test.ts already noted in its header that its three cases all fix mode via env at spawn time, so none reaches the headed branch. Driving a real handoff needs a headed Chromium, so the wiring is pinned with static tripwires instead, matching cdp-session-cleanup.test.ts and server-auth.test.ts. Verified they fail when the notification call is removed and pass when restored. Full `bun test` shows the same 6 pre-existing failures on this branch and on main (gstack-gbrain-detect, gstack-artifacts-init), which pass in isolation on both, so they are test-order pollution rather than a regression here. * fix(browse): pass windowsHide so the daemon stops popping console windows On Windows, `browse` leaves empty black console windows on top of whatever the user is doing — they pop up every few minutes for as long as any browser skill is alive, and outlive the process that created them. Cause: `bun-polyfill.cjs` maps `Bun.spawn`/`Bun.spawnSync` onto node's `child_process`, and node defaults `windowsHide` to **false**. Bun never creates these windows, so nothing in the daemon's own code looks wrong — the behaviour only appears on the node fallback path. The one users notice is `spawnTerminalAgent()`, which launches `bun run terminal-agent.ts` through this shim. The daemon respawns it on a watchdog, so closing the window is not enough — a new one arrives shortly after. Ten `bun.exe` processes were live on the machine this was diagnosed on. Why they linger after the child exits: with the default terminal application set to "Let Windows decide", the console is brokered through Windows Terminal via svchost, and WT leaves the empty frame behind when its only child exits. The frame has no child process at all, which is why it looks like a dead terminal. Setting `windowsHide: true` on both wrappers fixes every console child routed through the shim — the bun agent plus the `tasklist`, `git` and `powershell` calls elsewhere in the daemon. No behaviour change on macOS or Linux, where the option is ignored. Not covered by this commit: `chromium.launch()` goes through playwright's own process launcher rather than this shim, so it still creates one window per daemon start. Worth a follow-up. * test(browse): make bun-polyfill tests runnable on Windows, and cover windowsHide `bun test browse/test/bun-polyfill.test.ts` was **0 pass / 4 fail on Windows** before this — every test in the file, on the platform the polyfill exists to support. Each test interpolates the polyfill's absolute path into a single-quoted JS string passed to `node -e`. On Windows that path has backslashes, so JS eats them as escapes: 'C:\Users\jwilk\dev\gstack-fork\browse\src\bun-polyfill.cjs' -> C:Usersjwilkdevgstack-forkrowsesrcun-polyfill.cjs (`\b` is a real escape, so it deletes a character too.) `require()` throws, the subprocess dies, stdout is empty, and every assertion compares against "". The tests pass on macOS and Linux purely because those paths have no backslashes. Fixed by interpolating with `JSON.stringify(polyfillPath)`, which quotes and escapes correctly on all platforms. Also adds a regression test for the windowsHide fix in the previous commit. It stubs `child_process.spawn`/`spawnSync` *before* the polyfill destructures them and asserts the captured options, so it is deterministic and needs no window — it verifies the contract on macOS and Linux too, where the option is a no-op. Verified on Windows: 5 pass / 0 fail with the fix, and the new test alone fails ("VISIBLE" instead of "HIDDEN") when the previous commit is reverted. * fix(browse): forward windowsHide through the Bun polyfill spawn shims The Node fallback shim accepts a Bun.spawn options object and forwards only stdio, env and cwd to child_process.spawn. windowsHide is dropped, and because Node defaults it to false while Bun.spawn hides the console window, the omission inverts the behavior on the one platform the shim exists to support. Symptom: the terminal-agent respawn in server.ts (60s watchdog ticker) pops a visible bun.exe console window on Windows every time it fires, so the window keeps coming back with no scheduled task or startup entry behind it. stdio:'ignore' silences the child's output but does not suppress its window. Both shims now forward the option and default it to true, matching the Bun API being emulated; an explicit windowsHide:false still passes through. spawnTerminalAgent also sets it explicitly at the call site. Tests: three cases in browse/test/bun-polyfill.test.ts assert the default for spawn and spawnSync and that an explicit false is honored. Each was confirmed to fail against the unpatched shim. Drive-by, required to run the suite at all on Windows: the tests interpolated an absolute path into a JS string literal, so backslashes were consumed as escapes and every require() failed with MODULE_NOT_FOUND. The path is now normalized to forward slashes. On Windows this file went from 0/4 passing to 7/7. * fix(browse): headed mode on macOS 26 — stop mutating the signed Chromium bundle, heal the ones we already broke (#2242, #2138, #2139) The in-place rebrand rewrote the Chrome-for-Testing bundle's Info.plist (global name replace — which also renamed CFBundleExecutable to a binary that doesn't exist) and overwrote its Resources/*.icns, breaking the codesign seal: GPU process exit_code=5, headed mode dead on macOS 26. The mutation lived in the SHARED Playwright cache, so it also poisoned the user's other Playwright projects. Three layers land together: (1) the rebrand block is gone — branding lives in the GStack Browser.app wrapper via GSTACK_CHROMIUM_PATH, with a tombstone and a static tripwire (no plist/icns writes into the bundle; the tripwire allows the read-only probe below); (2) a launch-time self-heal detects an already-poisoned cache bundle, removes it, and errors with the exact re-fetch command — covering deploy paths that never run migrations; (3) migration v1.64.0.0 sweeps every cached bundle, removes poisoned ones, and re-fetches clean Chromium immediately (migrations run after ./setup, so without the re-fetch an upgrade would end with zero working browser). Functionally verified against fixture caches: poisoned removed, clean untouched, rerun no-op. Migration filename tracks the final VERSION at ship. The #2242 watchdog half is the absorbed PR #2565 (thanks @Screddyice). Tombstone/tripwire ported from time-attack/gstack (GStack 2); self-heal and migration are ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): 'browse stop' no longer restarts the daemon it was asked to stop The stop handler awaited shutdown() — which ends in process.exit — before returning, so the acknowledgement never egressed. The CLI's fetch reset, which its crash path reasonably interpreted as a dead daemon: it relaunched Chromium, re-sent stop, watched the daemon exit again, and errored 'Server crashed twice in a row'. Every stop cost a wasted Chromium launch and a nonzero exit. The ack now returns first; shutdown fires on a 25ms unref'd timer. Same fix for restart. Fork's test pins ack-before-teardown for both. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): lock acquisition reports real errors instead of phantom contention (#1084) acquireServerLock's bare catch treated EVERY failure as 'another process holds the lock' — a missing state dir, EACCES, or ENOSPC read as permanent phantom contention with nothing to debug. Now only EEXIST is contention: ENOENT self-heals with one mkdirSecure retry, everything else throws ServerLockError carrying the real errno, and the stale-lock unlink/retry loop is depth-capped so it can't livelock. Fork's five-case test ported. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(browse): integration coverage for #1781 busy-vs-dead recovery Fork's wedged-daemon fixture: first /command connection drops, daemon PID stays alive. Pins the whole contract — CLI retries the same daemon instance without a kill, state file untouched, no restart, exactly two command requests. Message-text assertion adapted: our CLI retries silently at the probe layer where the fork announces on stderr; the behavior, not the message, is the invariant. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): windowsHide on every Windows-reachable spawn (#1835) Console windows flashed (and stole focus) on every daemon relaunch, taskkill, tasklist poll, and powershell DPAPI call — node-level spawns default windowsHide to false. Covered: the node -e launcher (outer spawnSync AND the inner detached daemon spawn inside the launcher string), the dev-mode bun fallback, killServer's taskkill, isProcessAlive's tasklist, and cookie-import's powershell + tasklist. The Bun-polyfill shims were covered by absorbed PRs #2523 + #2539 (thanks @jwilk-hrep, @jerrynicholsai); this closes the sites those PRs didn't reach. The icacls sites land with the #1605 DACL commit alongside the static tripwire that pins all of them. R8's planned spawnHidden() helper is deliberately NOT built: the polyfill default plus the tripwire achieve the no-drift goal without indirection over seven heterogeneous call shapes. The polyfill + spawn-hide tests join the Windows CI shard. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): self-repair broken Windows DACLs on state dirs (#1605) icacls '/inheritance:r /grant:r' can partially fail on localized or domain accounts: inheritance strips but the user grant doesn't resolve, leaving a machine-SID-only DACL the owner can't even list — the sidebar/PTY failure chain in #1605, caused by the very hardening call meant to protect the dir. mkdirSecure now verifies listability after hardening (a real readdir — fs.accessSync doesn't consult NTFS ACLs) and repairs via icacls /reset, re-hardens, and if hardening breaks access again leaves inherited ACLs: functional-but-unhardened beats hardened-but-unusable. The icacls calls carry windowsHide (#1835's last two sites) and the fork's static spawn-hide tripwire lands here, pinning every covered site. file-permissions.test.ts is already in the windows-free-tests curated shard, so the DACL contract executes on windows-latest. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(browse): opt-in session persistence — auth survives daemon restarts (#778, #2193) BROWSE_PERSIST_STATE=1 snapshots cookies + per-tab URL/localStorage/ sessionStorage to <stateDir>/session-state.json (0600) on a 30s unref'd interval and at clean shutdown, and restores on the next launch — killing the top-complained auth-lost-on-restart class (#778, #2193, #1128, #1129). Security invariants mirror state save|load: loadedHtml and owner are never persisted and never accepted from disk; restored cookies pass the same hygiene filter (localhost/.internal/metadata domains dropped); restoreState re-validates every URL. Default OFF; headed mode excluded (the persistent profile owns that state). Hardened past the fork's shape per review R3: corrupt state quarantines to .corrupt (forensic artifact, boots fresh, one log line), snapshot failures warn once and never kill the daemon, and the boot log reports restored counts or fresh-session status. Module + 10 tests ported (MIT header retained); server wiring at launch, interval, and shutdown; skill docs section added (regen rides the cluster regen commit). Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate browse SKILL.md for cluster C (session persistence docs) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): third-party web-actions contract — offer to drive vendor-site steps, never just dump a manual list When a workflow needs something done on an external website the user controls (register an API key, create a vendor account, configure a dashboard/webhook/OAuth app), five skills (ship, spec, office-hours, setup-deploy, land-and-deploy) now follow one contract: offer to drive it in a visible browser via gstack's own stack ($B headed + handoff/resume, GStack Browser) behind ONE per-task consent question naming the exact site and actions; passwords, payment, CAPTCHA, and identity stay user-performed; captured secrets go to owner-only files or the user's secret store, never chat/logs/history; and the credential is verified with one non-mutating API call before any success claim — dashboards show masked placeholders, and a 401 catches them. Declining yields manual steps and a blocked-on-user mark; nothing new is ever installed to close the gap. New resolver token {{THIRD_PARTY_ACTIONS}} (adapted from the fork's contract — their Aside-browser detection swapped for our own driver stack; MIT portions noted). Parity guards bumped with growth itemized (ship 1.10->1.12 at measured 1.103x; office-hours skeleton 101K / 1.09 at measured 1.079x); ship goldens refreshed. Ported from time-attack/gstack (GStack 2), driver adaptation ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(office-hours): design docs land in the repo, written as decision records (#703, #2000) Office-hours Phase 5 now dual-writes the design doc: the docs/designs/ copy is what teammates and plan reviews read (committable, visible), while the ~/.gstack copy keeps memory ingest and cross-session discovery working. The repo copy leaves the private store, so it passes the redaction scan-at-sink first (HIGH blocks the repo copy, MEDIUM confirms per finding), and any failure — read-only checkout, non-git dir, unconfirmed finding — degrades to the private copy with a one-line reason, never blocking the handoff. The doc itself is now a decision record, not a transcript: one bullet per decision with its why, ruled-out approaches collapsed to a single line with the rejection reason, settled/empty template sections omitted. No page cap; extra length must come from genuinely open questions. Plan reviews (ceo/eng/devex + the shared review resolver) prefer the repo-local doc (DESIGN.md, then newest docs/designs/*.md) when it's at least as fresh as the private copy — a stale old repo doc never shadows a newer session. Parity guards bumped with measured values (three plan-review skeletons +~0.7KB each; office-hours 1.092x). Judgment ported from time-attack/gstack (GStack 2); scan-at-sink and freshness-preference adaptations ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(office-hours): 'never show me these again' for the founder-resources pitch (#538) The Phase 6 resources offer (34 PG essays + Garry/YC videos) had no permanent decline — the reporter showed memory instructions kept being overridden on every update, so people who said no got re-pitched forever. The offer now closes with a standing choice; opting out runs gstack-config set founder_resources false (new key, default true, true|false validated), the write is VERIFIED before any promise (a failed write says so and skips this session only), and every future session skips the entire section silently — no resources, no 'skipped as requested' mention. Config outlives session context, so never means never. Re-enable anytime: gstack-config set founder_resources true. The pitch stays default-ON for everyone who never opted out. Tests pin the key's default/persistence/validation through the real config bin and the generated section's gate-before-content + write-verify contract. Approved as a promo-surface change (CEO review D3.4, 2026-08-14). Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ship): the Apple App Store release journey — working tree to Submit for Review Point /ship at a repo with an .xcodeproj, .xcworkspace, or app-product Swift package and ask to release: the adapter runs the whole journey with ONE authorization moment (membership + pricing + in-session sign-in, decision- store persisted so repeat releases ask nothing) and one store-assets question only when assets are missing. fastlane is the single tool (produce/cert/ sigh/gym/pilot/deliver/frameit); credential vocabulary never reaches the user. The adapter carries 21 live releases' worth of paid-for Apple knowledge: the web session mints the permanent upload key itself (iris POST /v1/apiKeys; privateKey is base64-of-PEM, downloadable only at creation) so nobody ever types an app-specific password; error -22938 is Transporter asking for a key, not a user task; errors are CLASSIFIED before credentials are touched (validation/UnexpectedResponse = metadata, incl. Apple's expanded age-rating attributes); pricing goes through POST /v1/appPriceSchedules because fastlane's price_tier is broken against the current API; and store distribution NEVER routes through the branch gate — a clean tree on main is the solo shipper's normal case (Step 0.9 loads the adapter BEFORE the gate, pinned by test with the non-Apple gate byte-unchanged and unique). Uploads/submissions follow an idempotency-log contract (inspect App Store Connect before any re-run). Non-Mac hosts get the honest split: build legs via a macOS CI runner with the minted key as a secret, API legs local. Browser use inside the journey is banned except the named paid-app banking/tax residue. Redaction dry-run clean. Ship's parity ratio raised 1.12 -> 1.22 deliberately: the 14.8KB section is on-demand (Apple store targets only), one manifest line otherwise. Ported from time-attack/gstack (GStack 2), refined across its 21 live releases; architecture adaptation (carved section, decision-store paths, idempotency log, third-party-actions handoff) ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(code-intelligence): provider contract Phase 1 — GBrain, Sourcebot, Graphify behind one ask-once offer Open a large repo (1,000+ tracked files) and gstack can offer code intelligence ONCE, with the trade-offs stated: GBrain (semantic memory + code, sends content to YOUR gbrain DB, per-repo consent), Sourcebot (self-hosted whole-repo search, local on localhost), Graphify (local tree-sitter graph, nothing leaves the machine, user-installed), or No indexing — a decline persists machine-wide so no skill ever asks again. Small repos never see the question; grep stays the always-working default and provider-OFF degrades silently (PROVIDER_UNAVAILABLE -> file-only). Ported: lib/code-intelligence/ (contract + 3 verified adapters + picker + selection + suggest, MIT headers), the gstack-code-intelligence CLI (suggest/select/consent/index/search/status), 31 offline tests (fake CLI shims + injected fetch), and the provider-contract design doc. Verified live on this repo: suggest fires at 1,233 files with real availability detail per provider. Hardened per review: the per-remote trust store is the SINGLE consent authority — a gstack-gbrain-repo-policy deny tier vetoes any recorded code-intelligence consent (fail-closed on an unreadable store, pinned by three tests); both send-capable adapters are registered as fail-closed MODULE_SINKS in the egress tripwire so a refactor can't drop their receipts; and local-compute vs remote-send consents are never bundled. setup-gbrain gains the provider-choice Step 0. The fork's Phases 2-4 glue-collapse is explicitly NOT ported. Ported from time-attack/gstack (GStack 2); consent unification ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): supply-chain hygiene — secret gate on every PR diff, dependency review, OSV, dependabot, evidence-bar PR template The repo owned a redaction engine and had zero CI-side secret scanning. quality-gate.yml now pipes every PR diff's ADDED lines through our own bin/gstack-redact (gate-secret-scan.mjs, taken from the fork — it dogfoods the engine): HIGH findings fail the check, MEDIUM prints an advisory count only (no human in CI to confirm), planted-bug fixtures excluded by pathspec. Live-verified both directions: PEM key fails, clean diff and MEDIUM shapes pass; ShellCheck (errors) covers the setup/build shell boundary and passes today; bun audit gates critical advisories. Trigger is pull_request, never pull_request_target. dependency-review.yml adopts the hardened never-merged prior-art branch (fail-on-severity high, workflow paths watched, tight perms) — verify the dependency graph parses bun.lock with a canary bump before trusting the gate. dependabot: weekly, grouped per ecosystem, capped PR counts; and evals.yml image build/push now skips dependabot actors, whose read-only GITHUB_TOKEN made every lockfile bump a permanently red check. OSV scans weekly with a reasoned ignore file. All new workflow actions SHA-pinned. Scorecard deliberately not taken (no consumer for the score). The PR template front-loads the evidence bar (live proof, liveness screenshot, no-ETHOS/voice-changes checklist); the unenforced DCO line is dropped. bin/gstack-verify-gate ships OPT-IN (never registered by ./setup — a Stop hook running the project's verify command after every turn is the user's call), with the fork's tests adapted to pin exactly that. Ported from time-attack/gstack (GStack 2) + our own prior-art branch. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: remove dead bins; extend the stale-ref scan to docs (the 36-release gap) bin/chrome-cdp, bin/gstack-open-url, and bin/gstack-platform-detect were referenced only by an audit test and CHANGELOG history — dead weight that the stale-ref scanner should police, which required removing them FIRST. The scanner now also sweeps docs/, README.md, and USING_GBRAIN_WITH_GSTACK — the deliberate exclusion that let a dead command survive ~36 releases as a command-not-found instruction. Scan is green on the extended surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bins): detect the default branch instead of hardcoding main gstack-diff-scope fell to an empty diff (all-false SCOPE_*) and gstack-next-version mis-based its bump math on any repo whose default branch isn't main (trunk, master, local-only). Both now resolve origin/HEAD -> origin/main -> origin/master -> main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: housekeeping sweep — telemetry integrity, persistent opt-out, context-bill accuracy, setup hang, dev-server discovery, model resolution (#2136 + v1.63 polish) Seven small fixes, one theme (claims matching code): - telemetry-sync strips local-only fields with jq del() (structural) instead of quote-fragile sed regexes; unparseable lines are dropped, never forwarded unstripped. Sed survives only as a jq-less fallback. - telemetry-log rejects non-integer durations BEFORE the range caps, whose test(1) comparisons silently no-op on non-numerics — a malformed duration spliced raw text into the JSONL stream. - browse's local telemetry honors the persistent tier (config.yaml telemetry: off), not just the preamble's env hint — direct $B use and embedders now respect the opt-out. - gstack-context-bill --exact sees GSTACK_-promoted keys inside Conductor (conductor-env-shim wired at the CLI entry), and the TOTAL line no longer double-counts every nested skill through the root skill's walk (v1.63 deferred polish; the telemetry-sync HTTP-status outcome deferred alongside it turned out already shipped). - setup's Chromium probe is deadline-bounded (90s, background + poll-kill — macOS has no GNU timeout) and prefers Node for the launch probe everywhere (the bun --eval hang family behind #2136); the install is single-flight behind a lock dir with an actionable stale-lock message. Probe verified live on this Mac. - the review resolver's dev-server check reads CLAUDE.md and the plan file before falling back to an expanded port probe, and says how to make itself smarter next time. - eval/harness model IDs resolve through lib/eval-model.ts (GSTACK_EVAL_MODEL[_KIND] env overrides, per-kind defaults, tested) at the SDK-capture and PTY-warmup sites; the bash-embedded distill snippet mirrors the resolution inline. - memory-ingest's silent-zero shape (staged>0, imported+unchanged==0, errors==0) warns even under --quiet — a run that indexes nothing must never look healthy again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: wire ios-qa/daemon/test into the free suite and shard runner (E2) The daemon's 5 test files (allowlist, audit, auth-mint, cli-mint, daemon-integration — now 6 with session hardening) were invisible to every runner: not in the bun test glob, not in TEST_ROOTS. The same silent-coverage-hole class as the tracked design/test P2 — and it meant B2's auth regression tests would never have gated. All files are hermetic (stub state-servers on ephemeral ports, no devices); verified green in the shard census. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): claimed limitations now require evidence, everywhere + wave follow-ups filed Every tier-2+ skill's preamble gains one directive distilled from nine live release failures in two days on the fork: a claimed limitation or requirement ('the API can't do this', 'X requires a credential', 'impossible on this platform') is a material claim, stated only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence, and a cheap probe runs BEFORE asking the user or declaring a step blocked. ONE directive adapted into the preamble resolver; the fork's full judgment contract is deliberately not imported. Full regen (46 files), ship goldens refreshed, parity guards bumped with the measured ~0.45KB/skill (investigate, autoplan, plan-design-review, office-hours), Step 0.9 registered as an intentional sub-step. Approved deferrals filed: persona-fleet hostile-user harness + answer-key methodology in TODOS; the fork's question-budget ACCOUNTING judgment (never its 5/8/12 constants) folded into the V1.1 pacing design doc; the Apple adapter added to #1882's coverage note. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(make-pdf): close offline-gate bypasses via unquoted style attrs, CSS-escape and HTML-entity obfuscation Three live vectors found by the ship review army, all red-first tested: unquoted style attributes skipped the remote-url neutralizer entirely; CSS ident/string escapes (@\69mport, url(\68ttps://…)) defeated the literal-match patterns Chromium happily decodes; and HTML entities in style attribute values (https) decoded to fetchable schemes before CSS parsing. Style-attr values are now entity-decoded in one browser- faithful pass, escape-bearing at-rules and function tokens are dropped fail-closed, and output is re-encoded double-quoted. 21 new test rows. * fix(migrations): v1.65 Chromium re-fetch actually re-downloads, and success is verified before .done The migration (renamed from the provisional v1.64.0.0 slot, which open PR #2564 claims) deleted only the poisoned .app while Playwright's INSTALLATION_COMPLETE marker survived in the revision dir — so the advertised 'bunx playwright install chromium' re-fetch no-opped and the user finished the upgrade with no browser and a success message. Now: the whole chromium-<rev> dir goes, bunx runs cwd-pinned to the install root, .done is gated on a verified executable, and a needs-refetch sentinel makes re-runs retry a failed download. Stranded rev dirs (markers without .app) also re-trigger. 6 hermetic tests, red-first. * fix(migrations): v1.27 remediation prints a real command instead of a fictional flag Every skip/failure path referenced '/setup-gbrain --rerun-migration', which is implemented nowhere, and promised the migration 'will ask again next upgrade', which the version-window runners make false. All five sites now print the direct GSTACK_MIGRATE_ASSUME_YES=1 bash invocation. Runner-side re-offer tracking is filed in TODOS. * fix(browse): poisoned-bundle self-heal removes the revision dir, probes handoff too, and throws typed Same marker flaw as the migration: rmSync of the .app alone left INSTALLATION_COMPLETE behind, so the error message's own remediation no-opped and the user was hard-stuck. The probe is now an exported, unit-tested helper (probePoisonedChromiumBundle) that removes the whole chromium-<rev> dir, never touches GSTACK_CHROMIUM_PATH custom bundles, throws PoisonedBundleError (instanceof, not string-match), and runs on BOTH headed entry points — launchHeaded and handoff. 7 tests. * fix(browse): session snapshots are atomic and the cookie filter drops loopback IP literals A crash mid-write destroyed the previous good snapshot — the exact scenario persistence exists to survive; writes now go tmp+rename. The internal-network cookie filter gains 127.*/::1/169.254.* (a tampered state file could previously hand loopback-service cookies back to the browser), and 'state load' imports the shared filter instead of maintaining a comment-synced copy. Test cleanup made exception-safe. * fix(browse): server runtime — restore off the boot path, shutdown that cannot hang, watchdog that still reaps tunnels Four review findings on the wave's own new wiring: session restore ran before Bun.serve with sequential 15s gotos while the CLI gives up at 8s (one slow saved URL bricked every $B command) — restore now runs in the background after bind; the shutdown snapshot gets a 2s deadline so a wedged page.evaluate can't hold the port forever behind the new ack-first stop; the persistence ticker gets in-flight + shutdown gates and is cleared before the final snapshot; and the absorbed #2565 handoff fix no longer clears the whole parent watchdog — a suppress flag keeps the tunnel-orphan reaper alive (handoff→resume→tunnel is no longer an unreapable internet-exposed daemon). pair-agent with consent off now names the real remedy instead of ngrok install instructions. Lock-acquisition edge branches (garbage pidfile, vanish-race depth cap) pinned. * fix(browse): telemetry defaults to off like every other surface The persistent tier defaulted ON when the config key was absent, while gstack-config's DEFAULTS table answers 'off' for the same question — preamble-spawned daemons and direct $B daemons disagreed about consent. Absent key/file now means disabled; community/anonymous enable; env kill-switch still beats everything. Both config.yaml consumers now share one readGstackConfigYamlKey reader. 12-case consent suite. * fix(code-intelligence): consent that means what it says — polarity, receipts, read-only veto Four review findings on the wave's own Phase 1 port, all red-first: 'consent <repo> no' recorded consent GRANTED (the CLI ignored the argument and always wrote true) — yes|no is now required and garbage records nothing; Sourcebot egress receipts claimed consented=true on paths that never checked consent — the actual consent state is threaded into every receipt, search is fail-closed on non-loopback, and the liveness probe's receipt says truthfully that it sends no repo content; repoPolicyVeto only honored the deny tier while gbrain refresh writes pages — write-class ops now veto on read-only too, matching the sync chokepoint, via one shared lib/gbrain-repo-policy-client.ts (win32 bash invocation, spawn-vs-unreadable error distinction) used by both call sites. Also: source ids get a host+path hash (same-name repos no longer collide), refresh timeout raised to 120s, availability probes run concurrently at 3s, graphify status stops JSON.parsing 100MB graphs for a count, and every ported file carries the fork MIT notice. +15 tests across the two suites. * fix(verify-gate): trust before eval, re-check on re-entry, audit every grant The opt-in Stop hook eval'd whatever command the first CLAUDE.md up the tree declared — any cloned repo got arbitrary shell at turn end. Now a per-repo trust store (path+command hash, 0600) gates execution: an untrusted or changed command never runs (exit 0 with the --trust invocation printed), stop_hook_active re-entry re-runs the trusted check instead of rubber-stamping (bounded at 3 blocks per episode), and every grant appends a forensic line to ~/.gstack/security/verify-gate-trust-grants.jsonl. 20 tests, red-first. * fix(setup): EXIT traps chain instead of clobbering; timed-out probes reap their whole tree The Playwright-lock trap replaced the copied-bun cleanup trap and then cleared ALL exit handling, leaking .tmp-bun-bin on every Chromium install; and _wait_with_deadline killed only the subshell, orphaning the wedged node→Chromium tree it exists to escape — re-creating the #2136 pile-up on every timed-out re-run. Traps now chain; timeouts walk pgrep -P descendants leaves-first. * refactor(resolvers): one source for the design-doc discovery block The #703 repo-doc-preference bash was pasted byte-identically into three plan-review templates and a fourth copy embedded in review.ts — drift there means plan reviews disagree about which design doc wins. Now a {{DESIGN_DOC_DISCOVERY}} resolver; generated output is byte-identical, so no SKILL.md changes ride along. * fix(ship): finish the Apple upload idempotency sentence The durable-effect contract dropped its consequence clause mid-sentence — the instruction for what to DO when the idempotency key already exists (treat the upload as possibly-done, never re-run it) was missing from the one rule governing whether a binary uploads twice. * fix(ci): SHA-pin dependency-review; the secret gate fails closed without a report dependency-review.yml rode mutable refs (@v4 resolves to a BRANCH on that repo) inside the one workflow whose job is supply-chain hygiene — now commit-pinned like its siblings, with dependabot keeping the pins fresh. gate-secret-scan.mjs crashed with an unhandled EPIPE on oversize diffs (the designed report.oversize branch was unreachable: the scanner emits no JSON on refusal) — the pipe write now tolerates early exit and a missing report is an explicit fail-closed exit 1. Oversize + broken-scanner legs pinned. * fix(bins): Windows-safe GIT_CEILING join; next-version probes the full default-base chain GIT_CEILING_DIRECTORIES was joined with ':' — git on Windows splits on ';' and drive letters contain ':', silently disabling the #2144 second-layer defense there; now path.delimiter. next-version's default-base detection only tried origin/HEAD then 'main', diverging from the canonical 4-step chain diff-scope uses — origin/main and origin/master probes added, pinned by fixture repos. * fix(eval-model): kinds are a literal union, not string Record<string,string> widened EvalModelKind to string, so a typo'd kind only failed at runtime; as const satisfies keeps the closed set the doc comment promises. * test: coverage backfill from the ship review The telemetry-strip invariant only validated the sed FALLBACK while the live jq path went unchecked — the jq del() lists are now held to the same every-emitted-field bar, plus a behavioral pipe-through. The context-bill nested-skill double-count fix gets a regression pin (a revert shipped green before). The windowsHide tripwire gains terminal-agent-control.ts — the exact file the fix commit names. The ios-qa revoke-by-token_id branch gets its negative case: unknown ids revoke nothing and leave live sessions alone. * docs: SLATE_HOST no longer cites the deleted platform-detect bin Host detection lives in the hosts/ registry via host-config-export.ts; the doc's known-gaps list now says so instead of pointing at a bin this branch removed. * test(e2e): headroom for the two plan-ceo-review budget-edge tests Both rode their 360s runner budget at the edge (main clears at 243s of 360s), and the wave legitimately adds work to the review: the evidence directive tells the agent to probe before claiming, and the design-doc discovery block adds bash steps. Under concurrent in-file children the API queuing tipped all retry attempts past the ceiling — the runner then reports $0.00/0 turns for a timed-out child, which reads like a dead spawn but is a healthy child killed at the deadline. 540s runner / 660s test for these two only; verified 2/2 green at 228s and 315s. * fix(code-intelligence): gbrain search/export are consent-gated and receipted The Sourcebot side got this in the last round; gbrain had the same hole — search() and export() sent repo-derived query text into a possibly-remote DATABASE_URL with no consent check and no egress receipt, bypassing the deny-tier veto. Both now assert consent before any bytes move, receipts record the actual consent state (never a hardcoded true), and search receipts carry the query's sha256. gbrain stays fail-closed: the adapter cannot see where DATABASE_URL points, so every send requires consent. 7 new tests, red-first. * fix(make-pdf): SVG remote refs and image-set can no longer fetch offline <svg><image href=https://…> and <use xlink:href=…> survived the gate (only javascript: schemes were stripped from svg hrefs), and bare-string image-set("https://…" 1x) dodged the url()-shaped neutralizer. Remote svg hrefs rewrite to '#' (entity-decode-aware, unclosed-svg smuggle closed) and remote image-set args neutralize to url(#). Local fragments, local image-set, and plain <a> links pinned intact. 12 new rows, red-first. * fix(browse): duplicate config keys read last-wins, matching gstack-config readGstackConfigYamlKey took the FIRST match while gstack-config's get takes the LAST — a duplicated pair_agent or telemetry line made the two consent surfaces disagree about what the user chose. * fix(setup): stale Chromium-install lock self-heals The mkdir mutex had no owner: a SIGKILL'd setup left the lock behind and every later run exited with manual rmdir instructions. The holder pid is recorded in the lock; a dead holder is reclaimed automatically. * fix(setup-gbrain): the code-intelligence offer gate skips when the bin is absent The new Step 1.7 told the agent to run gstack-code-intelligence before the path pick — on installs predating the CLI (and hermetic E2E children) the bin doesn't exist and setup derailed before doing any setup. The gate now probes for the bin and reports offer:false reason:bin-absent, with explicit instructions to proceed: the user asked for gbrain, so set up gbrain. Never block setup on an optional gate. * test(e2e): periodic-tier repairs from the failure triage Each fix traces to a receipt: brain-privacy-gate staged config never reached the hermetic child (ambient GSTACK_HOME is scrubbed) and the operator's remote-mode gbrain suppressed the gate — both now injected per-test; ship-idempotency threw away its evidence on the timeout path and ran a 600s budget its own subject can exceed (now 900s, evidence captured); auto-decide-preserved gets the same headroom its sibling plan-ceo tests got; context-skills' hides-checks scanned bash output where an ls legitimately names old checkpoints (final-text scope now); design names the missing section instead of a bare count and learns the easing/duration/micro-interaction synonyms; qa-workflow's collector afterAll gets an explicit 60s hook timeout. * fix(eval-harness): eng-review phase boundary fires on qid-tagged questions The Step 0 boundary only matched two prose phrases, but plan-eng-review may legitimately reach the review phase without either — every per-finding AskUserQuestion then counted as pre-review and the batching regression test read 0 questions while watching the agent ask them one by one. The boundary now also fires on the first answered question carrying a gstack-qid:eng-review- marker. Additive only; 119 runner unit tests green. * chore: bump version and changelog (v1.65.0.0) Fork port wave 2: the release-summary entry credits Sina Matian (time-attack/gstack) and the four absorbed community PRs. TODOS gains three review-round follow-ups (dual-write E2E, migration runner re-offer, gbrain-adapter op coverage). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(eval-harness): eng-review qid boundary matches the real skill-name prefix Live qids render as gstack-qid:plan-eng-review-<slug> ({skill}-{slug} convention); the boundary anchored eng-review- immediately after the colon and never matched, leaving the batching counter blind while the transcript showed per-finding questions being asked one by one. * fix(setup-gbrain): never ask the provider question inside /setup-gbrain Invoking /setup-gbrain IS the provider choice. Step 1.7 now records 'select gbrain' best-effort and proceeds straight to setup; the offer ceremony is reserved for entry points where no provider was named. On machines where the code-intelligence CLI exists, the offer:true path was hijacking setup into the provider ceremony and the E2E child never reached MCP registration. * chore: file the three documented-red periodic tests as structural-repair TODOs Sidebar trio exercises endpoints removed on every tree; ship-idempotency's PTY child never receives its typed command; brain-privacy-gate has never been green anywhere. Each carries its triage receipt in the entry. * test(e2e): setup-gbrain remote — hermetic env via opts, evidence on failure, output-scoped classifier Three separate defects stacked on this one test: the ambient GBRAIN_MCP_TOKEN/GSTACK_HOME/PATH mutations never reached the child (hermetic-env scrubs them by allowlist — broken since hermetic env landed; the child correctly stopped at Step 4c with NEEDS_CONTEXT), failures discarded the in-memory transcript so every triage started blind, and the wrote-findings-before-asking classifier scanned the full event stream where the child's own Read of the skill file always contains the review-report phrase. Env now goes via opts.env, failures dump bash commands + final text, and the classifier scans assistant output only. Green in 67s with all seven asserts. * test: final coverage pass — CLI rendering, revert traps, keychain probe, gbrain doc ops The user-directed third generation pass closes the audit's remaining tail: the code-intelligence CLI's options/status/suggest surfaces get behavioral coverage through the fake-shim chain; brain-context-load gains an argv-logging trap that goes red if anyone reverts the memoized PATH scan back to the spawn probe (receipt: simulated revert failed exactly these tests); the darwin Keychain auth branch (#1890) gets its first free-tier tests via a PATH-shimmed security binary; and the gbrain add/delete/export ops are pinned (body piped byte-for-byte, receipt sha256, stdin-EOF prompt guard, PROVIDER_UNAVAILABLE degradation) — retiring their TODOS entry. * test: assemble the planted PEM at runtime so the fixture never trips the prepush guard The repo's own credential guard scans pushed diffs and correctly blocked these fixtures: the engine flags any one-line BEGIN…END spelling regardless of body. Header, body, and footer are now joined at runtime, so the file and every diff of it stay clean while the scanner under test still receives the true live shape. * docs: update project documentation for v1.65.0.0 README gains the two wave-2 CLIs (gstack-code-intelligence, gstack-verify-gate) in the standalone-binaries table, BROWSER.md documents BROWSE_PERSIST_STATE next to manual state save/load, CONTRIBUTING's CI section lists the new supply-chain gates, and CLAUDE.md's project tree reflects lib/code-intelligence/ and the added workflows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: apply cross-model doc-review fixes for v1.65.0.0 Findings from the release doc review, verified against source: verify-gate's README row gains the actual install one-liner (setup never registers the Stop hook; test/verify-gate.test.ts pins that) and the 3-blocked-re-entries yield behavior; code-intelligence's row gains the suggest subcommand and the search-side consent gate; CONTRIBUTING scopes the SHA-pin claim to the supply-chain workflows and widens the dependency-review trigger; BROWSER.md's restore-time cookie drop list matches isInternalCookieDomain; CLAUDE.md's workflows comment stops implying six workflows are all of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: CHANGELOG accuracy pass — scope the SHA-pin claim, restore-time cookie filter, exact test counts * test: env restore runs per-test, not per-suite — the leak that failed 30 strangers gstack-memory-helpers saved HOME/GSTACK_HOME/PATH in beforeEach but restored in afterAll, so the last beforeEach's snapshot won and a gstack-test-engine temp dir leaked into every later file in the same process: gstack-config read the wrong store, make-pdf's child resolved Chromium under the temp cache, update-check and artifacts-init lost their real homes. afterAll is now afterEach; the config and update-check harnesses also strip GSTACK_HOME/GSTACK_STATE_ROOT from child env as a belt. * fix(browse): restore the #1846 start-timeout resolution the merge dropped The v1.64.1.0 merge kept this branch's lock design in cli.ts and silently lost main's resolveStartTimeout + late health re-check while their test survived — ported both back in alongside the kept design. * test: adapt main's diagnostics tests to the merged designs cli-lock asserts typed ServerLockError (errno + lock path) instead of the log-and-return shape the merge didn't keep, dropping only the one duplicate of server-lock-errors coverage; the liveness tripwire exempts error-handling.ts as the sanctioned tasklist site; snapshot and compare-board wrappers pass the now-mandatory browser-manager arg; background.js's test pins that the retired sidebar-command type is rejected pre-gate with no response fields. * chore: gitignore the gen-accessors tool's SPM build output skill-e2e-ios-swift-build compiles the Swift package in place, leaving .build/ (2,800+ files) and Package.resolved untracked after every periodic run — the workspace read as ~100 dirty changes with a clean tree. Same class as the dist/ binaries: build output, never committed. * test(browse): subprocess budget for the polyfill suite on Windows CI Every test here spawnSync's a node child; cold-start on the Windows runner (AV scan, first node.exe touch) blew bun's 5s default by 7ms on a 50ms sleep test. File-level 20s default — subprocess budget, not assertion looseness. * test: make the Darwin migration path and the query-timeout SKIP deterministic on Linux CI The v1.65 migration suite relied on the host being macOS — on the ubicloud runner the script's uname gate early-exited every test with empty output; a Darwin uname shim in the shared setup runs the real path everywhere (the non-Darwin test still overrides it with Linux). The 1ms-budget brain-context test assumed 1ms is always too short; the runner's fake gbrain answered in 0ms and no SKIP printed — the fake now sleeps 300ms so the timeout is a certainty, while --version stays instant for the detection assertion. --------- Co-authored-by: Gawie van Blerk <gawievanblerk@gmail.com> Co-authored-by: Sina Matian <sina@time-attack.dev> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Shawn Reddy <19191746+Screddyice@users.noreply.github.com> Co-authored-by: Jake Wilk <jwilk@highlinerepartners.com> Co-authored-by: Jerry Nichols <jerrynicholsai@users.noreply.github.com>
This commit is contained in:
co-authored by
Gawie van Blerk
Sina Matian
Claude Fable 5
Shawn Reddy
Jake Wilk
Jerry Nichols
parent
c118e2402e
commit
2be6c06ba8
@@ -0,0 +1,44 @@
|
|||||||
|
<!--
|
||||||
|
gstack is AI-coded and proud of it. The bar is EVIDENCE OF REAL USE, not lines
|
||||||
|
of code. A PR with no proof behind it gets closed, no matter how clean it looks.
|
||||||
|
Fill every section below. See CONTRIBUTING.md → "The evidence bar".
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Why (in your own words)
|
||||||
|
|
||||||
|
<!-- One paragraph: what breaks for a user today, and what this change does about
|
||||||
|
it. Not a restatement of the diff. -->
|
||||||
|
|
||||||
|
## Live evidence
|
||||||
|
|
||||||
|
<!-- REQUIRED. Paste the command(s) you ran and their real output — before and
|
||||||
|
after. For a bug: the reproduction, failing then fixed. For a skill change: the
|
||||||
|
actual transcript / `claude -p` output. For anything visual: before/after
|
||||||
|
screenshots. "bun test passes" alone is not enough — show the behavior you
|
||||||
|
changed. -->
|
||||||
|
|
||||||
|
```
|
||||||
|
# what you ran + what it produced
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- **Changed:**
|
||||||
|
- **Verified live by:**
|
||||||
|
- **Did NOT test:**
|
||||||
|
|
||||||
|
## Liveness proof (required)
|
||||||
|
|
||||||
|
<!-- Attach a screenshot of your own machine with the text `GSTACK PR` typed LIVE
|
||||||
|
into a real surface — terminal prompt, a shell command, your browser
|
||||||
|
address/search bar, an editor buffer. It must be TYPED INTO A LIVE UI, not drawn,
|
||||||
|
overlaid, or edited onto the image. A painted-on `GSTACK PR` is an automatic
|
||||||
|
close. This confirms a human opened this PR. -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Liveness screenshot attached: `GSTACK PR` typed live into a real surface (not edited onto the image)
|
||||||
|
- [ ] This is not a generated-file-only diff (I edited the source/template and regenerated)
|
||||||
|
- [ ] No ETHOS.md edits, and no changes to voice / founder perspective / YC references
|
||||||
|
- [ ] New public command / external service / host adapter has an accepted issue linked (or N/A)
|
||||||
|
- [ ] Linked issue or reproduction: #
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "bun"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
groups:
|
||||||
|
production-dependencies:
|
||||||
|
dependency-type: "production"
|
||||||
|
development-dependencies:
|
||||||
|
dependency-type: "development"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
open-pull-requests-limit: 2
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
const child = spawn("bun", [
|
||||||
|
"bin/gstack-redact",
|
||||||
|
"--repo-visibility", "public",
|
||||||
|
"--json",
|
||||||
|
"--max-bytes", "16000000",
|
||||||
|
], { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "inherit"] });
|
||||||
|
let diff = "";
|
||||||
|
process.stdin.setEncoding("utf8");
|
||||||
|
process.stdin.on("data", (chunk) => { diff += chunk; });
|
||||||
|
process.stdin.once("end", () => {
|
||||||
|
const additions = diff
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
|
||||||
|
.map((line) => line.slice(1))
|
||||||
|
.join("\n");
|
||||||
|
// The scanner may exit before consuming an oversize payload (it refuses
|
||||||
|
// stdin over --max-bytes and reports oversize:true). EPIPE here is that
|
||||||
|
// refusal in flight, not a failure — the report + exit code carry the verdict.
|
||||||
|
child.stdin.on("error", (error) => {
|
||||||
|
if (error.code !== "EPIPE") throw error;
|
||||||
|
});
|
||||||
|
child.stdin.end(additions);
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
child.stdout.setEncoding("utf8");
|
||||||
|
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||||
|
child.once("error", (error) => { throw error; });
|
||||||
|
child.once("close", (code) => {
|
||||||
|
let report;
|
||||||
|
try {
|
||||||
|
report = JSON.parse(stdout);
|
||||||
|
} catch {
|
||||||
|
// No parseable report: the oversize refusal prints only to stderr and
|
||||||
|
// exits 3, and a crashed scanner emits nothing. Both fail closed.
|
||||||
|
console.log(`credential scan: 1 high, 0 advisory (scanner emitted no report, exit ${code} — fail-closed)`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const high = Number(report.counts?.HIGH ?? 0);
|
||||||
|
const medium = Number(report.counts?.MEDIUM ?? 0);
|
||||||
|
console.log(`credential scan: ${high} high, ${medium} advisory`);
|
||||||
|
process.exitCode = high > 0 || report.oversize || ![0, 2, 3].includes(code) ? 1 : 0;
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: Dependency Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'bun.lock'
|
||||||
|
- '**/package.json'
|
||||||
|
- '**/bun.lock'
|
||||||
|
- '.github/workflows/**'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dependency-review-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dependency-review:
|
||||||
|
runs-on: ubicloud-standard-8
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
- uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||||
|
with:
|
||||||
|
fail-on-severity: high
|
||||||
|
fail-on-scopes: runtime, development
|
||||||
|
comment-summary-in-pr: on-failure
|
||||||
@@ -15,6 +15,12 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
# Build Docker image with pre-baked toolchain (cached — only rebuilds on Dockerfile/lockfile change)
|
# Build Docker image with pre-baked toolchain (cached — only rebuilds on Dockerfile/lockfile change)
|
||||||
build-image:
|
build-image:
|
||||||
|
# Dependabot-triggered pull_request runs get a read-only GITHUB_TOKEN, so
|
||||||
|
# a lockfile bump = new hash = failed ghcr push = permanently red check
|
||||||
|
# (EV6, fork port wave 2). Skip the build for dependabot; the evals job's
|
||||||
|
# needs-chain tolerates it because no eval test selects on a lockfile-only
|
||||||
|
# diff — a maintainer's next push rebuilds the image with real perms.
|
||||||
|
if: github.actor != 'dependabot[bot]'
|
||||||
runs-on: ubicloud-standard-8
|
runs-on: ubicloud-standard-8
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: OSV Scanner
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '23 7 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: osv-scanner
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@3adb4b14a2b0623876d18d863a498b785fb3752d # v2.3.8
|
||||||
|
with:
|
||||||
|
scan-args: |-
|
||||||
|
--include-git-root
|
||||||
|
--recursive
|
||||||
|
./
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Quality gate (fork port wave 2, adapted from time-attack/gstack GStack 2).
|
||||||
|
#
|
||||||
|
# Three generic hygiene checks the repo previously had nowhere in CI:
|
||||||
|
# 1. Credential scan of the PR diff's ADDED lines through our own
|
||||||
|
# bin/gstack-redact (HIGH fails the check; MEDIUM is an advisory count —
|
||||||
|
# there is no human in CI to confirm, so it never fails here).
|
||||||
|
# 2. bun audit at critical severity.
|
||||||
|
# 3. ShellCheck (errors only) on the setup/build shell boundary.
|
||||||
|
#
|
||||||
|
# Trigger is `pull_request`, NEVER `pull_request_target`: fork PRs must not
|
||||||
|
# get secret-bearing contexts. Diff excludes cover the planted-bug fixtures
|
||||||
|
# and eval baselines that intentionally contain credential-shaped strings.
|
||||||
|
name: Quality gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: quality-gate-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubicloud-standard-8
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install frozen dependencies
|
||||||
|
run: bun install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
- name: Scan changed text for credentials (added lines, own redact engine)
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
||||||
|
BASE_SHA=$(git rev-parse HEAD^)
|
||||||
|
fi
|
||||||
|
git diff --unified=0 --no-color "$BASE_SHA" "$HEAD_SHA" -- \
|
||||||
|
. \
|
||||||
|
':(exclude)test/fixtures/**' \
|
||||||
|
':(exclude)browse/test/fixtures/**' \
|
||||||
|
':(exclude)docs/evals/**' \
|
||||||
|
':(exclude)test/helpers/security-bench*' \
|
||||||
|
| node .github/scripts/gate-secret-scan.mjs
|
||||||
|
|
||||||
|
- name: Gate critical dependency advisories
|
||||||
|
run: bun audit --audit-level=critical
|
||||||
|
|
||||||
|
- name: Install ShellCheck
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y shellcheck
|
||||||
|
shellcheck --version
|
||||||
|
|
||||||
|
- name: ShellCheck setup and build boundaries
|
||||||
|
run: >-
|
||||||
|
shellcheck --severity=error
|
||||||
|
setup
|
||||||
|
scripts/build.sh
|
||||||
|
scripts/build-app.sh
|
||||||
|
scripts/write-version-files.sh
|
||||||
|
browse/scripts/build-node-server.sh
|
||||||
@@ -111,6 +111,8 @@ jobs:
|
|||||||
browse/test/claude-bin.test.ts \
|
browse/test/claude-bin.test.ts \
|
||||||
test/test-free-shards.test.ts \
|
test/test-free-shards.test.ts \
|
||||||
browse/test/file-permissions.test.ts \
|
browse/test/file-permissions.test.ts \
|
||||||
|
browse/test/bun-polyfill.test.ts \
|
||||||
|
browse/test/windows-spawn-hide.test.ts \
|
||||||
browse/test/security.test.ts \
|
browse/test/security.test.ts \
|
||||||
browse/test/server-sanitize-surrogates.test.ts \
|
browse/test/server-sanitize-surrogates.test.ts \
|
||||||
test/setup-windows-fallback.test.ts \
|
test/setup-windows-fallback.test.ts \
|
||||||
|
|||||||
@@ -44,3 +44,8 @@ docs/throughput-*.json
|
|||||||
|
|
||||||
# gbrain local source-staging dir (capability checks, source clones) — runtime artifact
|
# gbrain local source-staging dir (capability checks, source clones) — runtime artifact
|
||||||
.sources/
|
.sources/
|
||||||
|
|
||||||
|
# SPM build output from the gen-accessors tool (built in place by
|
||||||
|
# skill-e2e-ios-swift-build; regenerates on every run — never commit)
|
||||||
|
ios-qa/scripts/gen-accessors-tool/.build/
|
||||||
|
ios-qa/scripts/gen-accessors-tool/Package.resolved
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# OSV-Scanner configuration.
|
||||||
|
# Direct/transitive dependency versions are pinned to their fixed releases via
|
||||||
|
# the `overrides` block in package.json; this file only records advisories we
|
||||||
|
# have assessed as not-reachable or not-fixable without disproportionate risk.
|
||||||
|
|
||||||
|
[[IgnoredVulns]]
|
||||||
|
id = "GHSA-frvp-7c67-39w9"
|
||||||
|
# @hono/node-server 1.19.x. Reachable only through @modelcontextprotocol/sdk,
|
||||||
|
# which is an unused transitive dependency (no source file imports it) and never
|
||||||
|
# starts a Hono HTTP server, so the advisory's request path is not exercised.
|
||||||
|
# The only fix is @hono/node-server 2.0.5, a major bump the MCP SDK pins against
|
||||||
|
# (^1.19.9); forcing it via override risks breaking the SDK at runtime for a
|
||||||
|
# vulnerability we do not expose. Re-evaluate if the MCP SDK becomes a direct,
|
||||||
|
# server-hosting dependency.
|
||||||
|
reason = "Unreachable transitive (unused @modelcontextprotocol/sdk); fix requires a risky major override on a pinned peer dep."
|
||||||
+13
@@ -1091,6 +1091,19 @@ $B state load my-session # restore
|
|||||||
In-memory `load-html` content is intentionally NOT persisted (avoid leaking
|
In-memory `load-html` content is intentionally NOT persisted (avoid leaking
|
||||||
secrets to disk).
|
secrets to disk).
|
||||||
|
|
||||||
|
Manual save/load is one-shot. For state that survives daemon restarts
|
||||||
|
automatically, opt in with `BROWSE_PERSIST_STATE=1` in the daemon's
|
||||||
|
environment: the headless daemon snapshots cookies + per-tab
|
||||||
|
URL/localStorage/sessionStorage to `<stateDir>/session-state.json` (0600,
|
||||||
|
atomic writes) every 30 seconds and at clean shutdown, then restores it off
|
||||||
|
the boot path on the next launch. Default OFF — cookies on disk are a real
|
||||||
|
cost, so the user opts in. Headless only (headed mode's persistent Chromium
|
||||||
|
profile already owns its state). Loaded HTML and tab ownership are never
|
||||||
|
persisted, cookies for localhost, `.internal`, loopback IP literals
|
||||||
|
(127.0.0.0/8, `::1`), and link-local/cloud-metadata addresses
|
||||||
|
(169.254.0.0/16) are dropped on restore, and a corrupt snapshot is quarantined to
|
||||||
|
`session-state.json.corrupt` so persistence can never block a launch.
|
||||||
|
|
||||||
### Watch
|
### Watch
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+190
@@ -1,5 +1,195 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.65.0.0] - 2026-08-14
|
||||||
|
|
||||||
|
**/autoplan, /codex on macOS, and memory ingest work again.**
|
||||||
|
**And every consent gate now means what it says.**
|
||||||
|
|
||||||
|
This is the second and final wave of the GStack 2 fork port. Wave one (v1.63.0.0)
|
||||||
|
took the audit infrastructure; this wave takes the fixes and the features. Three
|
||||||
|
skills that failed silently on every run now work: /autoplan's task aggregation
|
||||||
|
emits real tasks instead of zero, /codex creates its temp files on macOS instead
|
||||||
|
of dying on BSD mktemp, and memory ingest actually imports pages on current
|
||||||
|
gbrain builds, and prints the count so you can see it. On top of that: your
|
||||||
|
browser auth can now survive a daemon restart, /ship can take an iOS app from
|
||||||
|
working tree to Submit for Review, and four supply-chain gates now run on every
|
||||||
|
PR. Nearly all of it traces back to Sina Matian's time-attack/gstack fork, ported
|
||||||
|
with tests and attribution.
|
||||||
|
|
||||||
|
### The numbers that matter
|
||||||
|
|
||||||
|
Source: this branch (`git log 1.63.0.0..HEAD`, `git diff main...HEAD --stat`,
|
||||||
|
`bun test`), plus the GitHub issues the release closes.
|
||||||
|
|
||||||
|
| What | Before | After |
|
||||||
|
|------|--------|-------|
|
||||||
|
| /autoplan Phase 4 task output (#2018) | 0 tasks, every run | every task |
|
||||||
|
| /codex on macOS (#2091) | broken on every install | works |
|
||||||
|
| Memory ingest on gbrain 0.42+ (#2144) | 0 pages, reported success | full corpus, count printed |
|
||||||
|
| Headed browse on macOS 26 (#2242) | GPU crash, poisoned cache | launches, heals old caches |
|
||||||
|
| Auth after a browse daemon restart (#778) | logged out | restored (opt-in) |
|
||||||
|
| CI secret scanning on PR diffs | none | every PR, fail-closed |
|
||||||
|
| GitHub issues closed | | 24 |
|
||||||
|
| Community PRs landed with authorship | | 4 |
|
||||||
|
|
||||||
|
The stark one is the first three rows: those aren't degraded features, they were
|
||||||
|
features returning empty results with a green checkmark. If you ran /autoplan in
|
||||||
|
the last two months, the task list it handed off was empty and nothing told you.
|
||||||
|
|
||||||
|
### What this means for gstack users
|
||||||
|
|
||||||
|
Run /autoplan and the pipeline hands real tasks to the executor. Run /codex on a
|
||||||
|
Mac and it just works. Set `BROWSE_PERSIST_STATE=1` and a daemon restart no
|
||||||
|
longer logs you out of every site. If you ship an iOS app, `/ship` now knows the
|
||||||
|
whole App Store journey, session-minted upload keys, the price-schedule API that
|
||||||
|
replaced the broken fastlane path, error -22938 triage, one authorization moment
|
||||||
|
instead of five. Upgrade with `/gstack-upgrade`; the migration cleans any
|
||||||
|
Chromium bundle an older gstack broke and verifies the replacement download
|
||||||
|
before it claims success.
|
||||||
|
|
||||||
|
### Itemized changes
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- **Opt-in browser session persistence** (#778, #2193): `BROWSE_PERSIST_STATE=1`
|
||||||
|
snapshots cookies and tabs (atomic writes, 0600, never page HTML or
|
||||||
|
ownership), restores them off the boot path on the next start, and quarantines
|
||||||
|
a corrupt snapshot instead of crashing. Portions from time-attack/gstack.
|
||||||
|
- **Apple App Store release journey for /ship**: `ship/sections/apple-release.md`
|
||||||
|
loads before the repo-landing gates when the target is an Apple app. Encodes
|
||||||
|
session-minted App Store Connect keys, `appPriceSchedules` over the broken
|
||||||
|
fastlane `price_tier`, expanded age-rating attributes, -22938 classification,
|
||||||
|
and a one-authorization-moment flow. Refined across 21 live releases on the
|
||||||
|
fork. Portions copyright Sina Matian, MIT.
|
||||||
|
- **Code-intelligence provider contract, Phase 1**: `gstack-code-intelligence`
|
||||||
|
wraps GBrain, Sourcebot, and Graphify behind one interface with an ask-once
|
||||||
|
indexing offer for large repos (1,000+ tracked files, decline persisted).
|
||||||
|
Consent is explicit per repo (`consent <repo> yes|no`), the per-repo trust
|
||||||
|
policy's deny and read-only tiers veto write-class operations no matter what
|
||||||
|
consent was recorded, and every off-machine send writes an egress receipt that
|
||||||
|
records the consent state actually checked. Portions from time-attack/gstack.
|
||||||
|
- **Supply-chain CI**: a quality gate that runs `bin/gstack-redact` over every
|
||||||
|
PR diff (HIGH findings fail, MEDIUM annotates), dependency review on
|
||||||
|
lockfile changes, weekly OSV scans, grouped dependabot updates, and an
|
||||||
|
evidence-bar PR template. Every third-party action in the new workflows is
|
||||||
|
pinned to a commit SHA.
|
||||||
|
- **Third-party web-actions contract** in tier-2+ skills: when a workflow needs
|
||||||
|
a vendor-site step (API key signup, OAuth app), gstack offers to drive the
|
||||||
|
browser itself, hands credentials and CAPTCHAs to you, stores secrets
|
||||||
|
owner-only, and verifies with a read-only call before claiming success.
|
||||||
|
- **Design docs land in your repo** (#703, #2000): office-hours writes
|
||||||
|
`docs/designs/<topic>.md` as a concise decision record (one bullet per
|
||||||
|
decision with its why), redaction-scanned before anything touches your git
|
||||||
|
history. Plan reviews prefer the repo-local doc when both exist.
|
||||||
|
- **`gstack-verify-gate`** (opt-in Stop hook): blocks turn-end until the
|
||||||
|
CLAUDE.md-declared verify command passes. A command runs only after you trust
|
||||||
|
it once per repo (`--trust`), re-trust is required when it changes, every
|
||||||
|
grant is audit-logged, and re-entries re-run the check instead of waving it
|
||||||
|
through.
|
||||||
|
- **"Never show me these again"** for the founder-resources pitch (#538): the
|
||||||
|
opt-out verifies its own config write before promising anything. Re-enable
|
||||||
|
with `gstack-config set founder_resources true`.
|
||||||
|
- **Claimed limitations need evidence**: every tier-2+ skill now treats "the
|
||||||
|
API can't do this" as a material claim requiring the verbatim error, the
|
||||||
|
documented statement, or a live probe, and runs the ten-second check before
|
||||||
|
declaring anything blocked.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- **/autoplan Phase 4 emitted zero tasks on every run** (#2018): a jq context
|
||||||
|
rebind dropped every aggregated task; the error was hidden by stderr
|
||||||
|
suppression. Six-fixture regression suite pins it.
|
||||||
|
- **/codex was broken on every macOS install** (#2091): BSD mktemp rejects
|
||||||
|
suffixed templates; all temp files now use portable templates and a static
|
||||||
|
test bans the pattern repo-wide.
|
||||||
|
- **Memory ingest imported nothing on gbrain 0.42+** (#2144): the staging dir
|
||||||
|
sits under a gitignored tree, so git-aware collectors saw zero files. Fixed
|
||||||
|
with `--include-gitignored` (community PR #2560) plus a `GIT_CEILING_DIRECTORIES`
|
||||||
|
second layer, Windows-safe, and a loud ingested-page count.
|
||||||
|
- **Headed mode on macOS 26** (#2242, #2138, #2139): gstack no longer rewrites
|
||||||
|
the signed Chrome-for-Testing bundle (the rebrand broke its code signature;
|
||||||
|
GPU processes refused to start). Launch self-heals poisoned caches, on both
|
||||||
|
headed entry points, by removing the whole revision directory so the re-fetch
|
||||||
|
actually re-downloads, and the upgrade migration does the same for existing
|
||||||
|
installs, verifying a working Chromium exists before recording success.
|
||||||
|
Branding stays on the GStack Browser wrapper app.
|
||||||
|
- **`browse stop` restarted the daemon it was told to stop**: the CLI now gets
|
||||||
|
an acknowledgment before shutdown, and the shutdown snapshot has a hard
|
||||||
|
deadline so a wedged page can never hold the port.
|
||||||
|
- **Session cookies from internal networks never reach a restored browser**:
|
||||||
|
the restore-time hygiene filter drops loopback and link-local IP literals
|
||||||
|
(127.0.0.1, ::1, 169.254.*) alongside localhost and *.internal, shared by
|
||||||
|
both the persistence path and `state load`.
|
||||||
|
- **ios-qa stopped handing out raw bearer tokens**: `/auth/sessions` returns
|
||||||
|
salted-hash token ids with revoke-by-id support, the boot token left os_log
|
||||||
|
entirely, and the IPv4 listener pins to loopback at the socket.
|
||||||
|
- **make-pdf's no-network promise holds against obfuscation**: `<style>`
|
||||||
|
@import, inline style URLs (quoted, unquoted, CSS-escaped, and
|
||||||
|
HTML-entity-encoded), srcset, and media sources are all neutralized when
|
||||||
|
rendering untrusted HTML without `--allow-network`.
|
||||||
|
- **pair-agent tunnels are consent-gated** (`gstack-config set pair_agent on`):
|
||||||
|
the tunnel cannot start without the recorded opt-in, the receipts that always
|
||||||
|
claimed consent now reflect a check that exists, and a disabled gate tells
|
||||||
|
you the real remedy instead of ngrok install instructions.
|
||||||
|
- **The per-repo gbrain trust policy is enforced at the code-import chokepoint**
|
||||||
|
(#2140, sync path): deny refuses, read-only skips code ingest, an unreadable
|
||||||
|
policy store fails closed, and the egress receipt names the decision.
|
||||||
|
- **Handoff no longer disarms the tunnel-orphan reaper** (community PR #2565
|
||||||
|
plus hardening): promoting a daemon to headed suppresses only the
|
||||||
|
headed-shutdown branch; a daemon with an active tunnel still dies with its
|
||||||
|
parent.
|
||||||
|
- **Windows**: broken DACLs on state dirs self-repair (#1605), every
|
||||||
|
Windows-reachable spawn passes windowsHide (#1835, community PRs #2523 and
|
||||||
|
#2539), and decision files no longer land under a project named "unknown".
|
||||||
|
- **Setup no longer hangs on first run** (#2136): the Chromium probe gets a
|
||||||
|
90-second deadline that reaps the whole wedged process tree, installs are
|
||||||
|
single-flight across concurrent setups, and EXIT cleanup traps chain instead
|
||||||
|
of clobbering each other.
|
||||||
|
- **Paper cuts**: `gh pr edit` falls back to the REST API when the
|
||||||
|
Projects-classic GraphQL deprecation bites (#1079); the v1.27 migration never
|
||||||
|
auto-proceeds without a TTY and never records a failed rename as done
|
||||||
|
(#1383); model benchmarks recognize macOS Keychain auth (#1890); voyage
|
||||||
|
embedding flags survive zsh (#1798); `--supersede` keeps the replacement
|
||||||
|
decision it was given; brain context no longer disables itself after one slow
|
||||||
|
cold-start probe; lock acquisition reports the real error instead of phantom
|
||||||
|
contention (#1084); plan verification probes the dev server your project
|
||||||
|
declares instead of a hardcoded port list.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- **Telemetry defaults to off everywhere**: the browse daemon now reads the
|
||||||
|
same persistent consent the rest of gstack does; an absent key means
|
||||||
|
disabled, matching `gstack-config get telemetry`.
|
||||||
|
- **Test-command detection covers Django and config-less projects**: a working
|
||||||
|
`manage.py test` or `*_test.go` suite is recognized instead of being offered
|
||||||
|
a bootstrap it doesn't need.
|
||||||
|
- **Base-branch detection everywhere**: the bins that hardcoded `main` now
|
||||||
|
probe origin/HEAD, origin/main, and origin/master in order.
|
||||||
|
- **Eval model resolution is host-neutral**: `GSTACK_EVAL_MODEL` (and per-kind
|
||||||
|
variants) override the hardcoded model ids at all six call sites.
|
||||||
|
- Dead bins removed (`chrome-cdp`, `gstack-open-url`, `gstack-platform-detect`);
|
||||||
|
the stale-reference scan now covers docs/ so removals like these fail CI when
|
||||||
|
documentation still cites them.
|
||||||
|
|
||||||
|
#### For contributors
|
||||||
|
|
||||||
|
- 23 new test files (+5,499 test lines): behavioral suites for session
|
||||||
|
persistence, the poisoned-bundle probe, both migrations, the consent CLI,
|
||||||
|
verify-gate trust, telemetry opt-out, the offline-gate bypass corpus, the
|
||||||
|
secret-scan exit contract, and lock-acquisition edge branches. The ios-qa
|
||||||
|
daemon suite (10 files) is now wired into `bun test` and the sharded runner;
|
||||||
|
it had never run in CI.
|
||||||
|
- `lib/gbrain-repo-policy-client.ts` is the one place repo-policy tiers are
|
||||||
|
read; both enforcement points route through it.
|
||||||
|
- `lib/context-bill.ts` no longer double-counts nested skills in totalMd, and
|
||||||
|
`gstack-context-bill` works under Conductor env-shims.
|
||||||
|
- Egress receipts: `bin/gstack-egress verify` passes with the chain intact;
|
||||||
|
code-intelligence adapters registered as fail-closed sinks.
|
||||||
|
- Credits: this release ports work by **Sina Matian** (time-attack/gstack, MIT)
|
||||||
|
across nearly every cluster. Community PRs absorbed with authorship:
|
||||||
|
**Gawie van Blerk** (#2560), **Shawn Reddy** (#2565), **Jake Wilk** (#2523),
|
||||||
|
**Jerry Nichols** (#2539). Thank you all.
|
||||||
|
|
||||||
## [1.64.1.0] - 2026-08-15
|
## [1.64.1.0] - 2026-08-15
|
||||||
|
|
||||||
**Every guard in the pipeline now provably fires.**
|
**Every guard in the pipeline now provably fires.**
|
||||||
|
|||||||
@@ -157,11 +157,11 @@ gstack/
|
|||||||
│ ├── test/ # Integration tests
|
│ ├── test/ # Integration tests
|
||||||
│ └── dist/ # Compiled binary
|
│ └── dist/ # Compiled binary
|
||||||
├── extension/ # Chrome extension (side panel + activity feed + CSS inspector)
|
├── extension/ # Chrome extension (side panel + activity feed + CSS inspector)
|
||||||
├── lib/ # Shared libraries (worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts)
|
├── lib/ # Shared libraries (worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, code-intelligence/)
|
||||||
├── docs/designs/ # Design documents
|
├── docs/designs/ # Design documents
|
||||||
├── setup-deploy/ # /setup-deploy skill (one-time deploy config)
|
├── setup-deploy/ # /setup-deploy skill (one-time deploy config)
|
||||||
├── .github/ # CI workflows + Docker image
|
├── .github/ # CI workflows + Docker image
|
||||||
│ ├── workflows/ # evals.yml (E2E on Ubicloud), skill-docs.yml, actionlint.yml
|
│ ├── workflows/ # evals.yml (E2E on Ubicloud), quality-gate.yml (secret scan), dependency-review.yml, osv-scanner.yml, skill-docs.yml, actionlint.yml, and 7 more (windows, periodic evals, release gates, ci-image)
|
||||||
│ └── docker/ # Dockerfile.ci (pre-baked toolchain + Playwright/Chromium)
|
│ └── docker/ # Dockerfile.ci (pre-baked toolchain + Playwright/Chromium)
|
||||||
├── contrib/ # Contributor-only tools (never installed for users)
|
├── contrib/ # Contributor-only tools (never installed for users)
|
||||||
│ └── add-host/ # /gstack-contrib-add-host skill
|
│ └── add-host/ # /gstack-contrib-add-host skill
|
||||||
|
|||||||
@@ -271,6 +271,15 @@ Each dimension is scored 1-5. Threshold: every dimension must score **≥ 4**. T
|
|||||||
|
|
||||||
A GitHub Action (`.github/workflows/skill-docs.yml`) runs `bun run gen:skill-docs --dry-run` on every push and PR. If the generated SKILL.md files differ from what's committed, CI fails. This catches stale docs before they merge.
|
A GitHub Action (`.github/workflows/skill-docs.yml`) runs `bun run gen:skill-docs --dry-run` on every push and PR. If the generated SKILL.md files differ from what's committed, CI fails. This catches stale docs before they merge.
|
||||||
|
|
||||||
|
Supply-chain gates run alongside it:
|
||||||
|
|
||||||
|
- **Quality gate** (`.github/workflows/quality-gate.yml`, every PR and push) — scans the diff's added lines for credentials using gstack's own redact engine (`.github/scripts/gate-secret-scan.mjs`). HIGH findings fail the job; MEDIUM findings surface as an advisory count. Fails closed if the scan can't produce a report. Also gates critical dependency advisories and runs ShellCheck on the setup/build boundaries.
|
||||||
|
- **Dependency review** (`.github/workflows/dependency-review.yml`) — reviews dependency changes on PRs that touch lockfiles or workflow files.
|
||||||
|
- **OSV scanner** (`.github/workflows/osv-scanner.yml`) — weekly vulnerability scan against the OSV database (config in `.osv-scanner.toml`).
|
||||||
|
- **Dependabot** (`.github/dependabot.yml`) — grouped dependency update PRs.
|
||||||
|
|
||||||
|
The supply-chain workflows pin their third-party actions to commit SHAs. The PR template (`.github/PULL_REQUEST_TEMPLATE.md`) asks for evidence — tests run, eval output — not promises.
|
||||||
|
|
||||||
Tests run against the browse binary directly — they don't require dev mode.
|
Tests run against the browse binary directly — they don't require dev mode.
|
||||||
|
|
||||||
## Editing SKILL.md files
|
## Editing SKILL.md files
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
|
|||||||
| `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. |
|
| `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. |
|
||||||
| `gstack-egress` | **Egress receipt auditor** — every gstack-initiated off-machine send writes a tamper-evident, hash-chained receipt to `~/.gstack/security/egress.jsonl` before the send. `list` shows what gstack attempted to send and to which host, `grants` shows the standing consent settings plus the exact command that revokes each, `verify` recomputes the hash chain and exits 3 on tamper. |
|
| `gstack-egress` | **Egress receipt auditor** — every gstack-initiated off-machine send writes a tamper-evident, hash-chained receipt to `~/.gstack/security/egress.jsonl` before the send. `list` shows what gstack attempted to send and to which host, `grants` shows the standing consent settings plus the exact command that revokes each, `verify` recomputes the hash chain and exits 3 on tamper. |
|
||||||
| `gstack-context-bill` | **Token bill-of-materials** — read-only, offline audit of what an installed skills tree costs in tokens: always-on frontmatter every session pays vs per-invocation SKILL.md + forced references. `--diff` compares two trees, `--budget` enforces a ceiling, `--exact` opts into Anthropic `count_tokens` (sends file text off-machine; writes an egress receipt first, degrades to the offline estimate if the receipt can't be written). |
|
| `gstack-context-bill` | **Token bill-of-materials** — read-only, offline audit of what an installed skills tree costs in tokens: always-on frontmatter every session pays vs per-invocation SKILL.md + forced references. `--diff` compares two trees, `--budget` enforces a ceiling, `--exact` opts into Anthropic `count_tokens` (sends file text off-machine; writes an egress receipt first, degrades to the offline estimate if the receipt can't be written). |
|
||||||
|
| `gstack-code-intelligence` | **Code-intelligence provider picker** — wraps GBrain, Sourcebot, and Graphify behind one interface: `options`/`status` to see what's available, `select` to pick one, `index`/`search` to use it, `suggest` to check whether the one-time indexing offer should fire here. The offer triggers on large repos (1,000+ tracked files; a decline is persisted). Non-local providers refuse to index *or search* until you record per-repo consent (`consent <repo> yes\|no` — the query text is repo-derived content), the per-repo trust policy's deny and read-only tiers veto write-class operations regardless of consent, and every off-machine send writes an egress receipt. Fully optional — with nothing selected, gstack falls back to grep. |
|
||||||
|
| `gstack-verify-gate` | **Verification stop hook (opt-in)** — blocks a Claude Code turn from ending until the project's declared verify command passes (after 3 blocked re-entries it yields with a loud still-RED warning instead of looping forever). Declare it on one line in CLAUDE.md: `<!-- gstack:verify: bun test -->`. Hooks bypass the permission system, so a declared command never runs until you trust it once per repo (`gstack-verify-gate --trust`); editing the command invalidates trust until re-granted, and every grant is audit-logged. `./setup` never registers it for you — opt in with `gstack-settings-hook add-event --event Stop --command ~/.claude/skills/gstack/bin/gstack-verify-gate --source verify-gate`, remove with `gstack-settings-hook remove-source --source verify-gate`. |
|
||||||
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
|
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
|
||||||
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
|
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
|
||||||
| `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. |
|
| `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. |
|
||||||
|
|||||||
@@ -2,6 +2,88 @@
|
|||||||
|
|
||||||
## NEXT PRIORITY
|
## NEXT PRIORITY
|
||||||
|
|
||||||
|
### P2: Persona-fleet hostile-user harness (fork port wave 2 deferral)
|
||||||
|
|
||||||
|
**What:** Port the methodology behind time-attack/gstack's 87-hostile-user
|
||||||
|
field run (418 findings): machine-written t0 in an append-only run.jsonl
|
||||||
|
(elapsed time measured, never self-reported), every metric resolving to an
|
||||||
|
artifact, and a mandatory-quit contract with machine-checkable caps (300s to
|
||||||
|
first useful output, 900s total, 40K context tokens, 3 consecutive dead ends)
|
||||||
|
so abandonment is a computable outcome. Specs: fork `evals/fleet/METRICS.md`
|
||||||
|
+ `evals/fleet/ABANDONMENT.md` (methodology only — no runner code exists to
|
||||||
|
port; this is a build).
|
||||||
|
|
||||||
|
**Why:** A periodic hostile-user round against OUR 44-skill tree would surface
|
||||||
|
the same first-five-minutes failure class the fork closed 418 of. Fits the
|
||||||
|
existing eval-store/e2e harness as a new runner.
|
||||||
|
|
||||||
|
**Effort:** L (human ~2wk) → M with CC. **Priority:** P2.
|
||||||
|
**Depends on:** decisions on cost ceilings + journal storage.
|
||||||
|
|
||||||
|
### P3: Answer-key eval methodology (rides the persona-fleet work)
|
||||||
|
|
||||||
|
**What:** Pre-registered answer keys (fork `evals/answer-keys/` —
|
||||||
|
codex-decorrelation, health-trending) grading our /codex and /health surfaces
|
||||||
|
against planted ground truth instead of judge vibes.
|
||||||
|
|
||||||
|
**Why:** Deterministic scoring for surfaces where LLM-judge drift is the
|
||||||
|
known failure mode. **Effort:** M → S with CC. **Priority:** P3.
|
||||||
|
**Depends on:** persona-fleet harness (shared runner shape).
|
||||||
|
|
||||||
|
### P3: Quarterly Apple-journey live re-verification
|
||||||
|
|
||||||
|
**What:** Run the /ship Apple release adapter against a real (TestFlight-only)
|
||||||
|
release once a quarter, or on first user bug report, and fix drift. Apple's
|
||||||
|
APIs move (the fork caught fastlane price_tier breaking live); the adapter's
|
||||||
|
claims are evidence-backed today and must stay that way per its own
|
||||||
|
evidence-before-claimed-limitations rule.
|
||||||
|
|
||||||
|
**Effort:** S per run. **Priority:** P3. **Depends on:** a paid ADP account.
|
||||||
|
|
||||||
|
### P2: office-hours design-doc dual-write functional E2E (fork port wave 2 review shortfall)
|
||||||
|
|
||||||
|
**What:** A paid E2E (claude -p) that runs the office-hours Phase 5 handoff in
|
||||||
|
a tmp repo and asserts BOTH write paths (docs/designs/<topic>.md + the
|
||||||
|
~/.gstack copy) land and that `bin/gstack-redact` was invoked at the sink.
|
||||||
|
Today only a static prose pin exists (test/skill-validation.test.ts) — the
|
||||||
|
plan's R9 asked for the functional shape.
|
||||||
|
|
||||||
|
**Why:** The dual-write is an egress path into the user's repo; prose drift
|
||||||
|
that skips the redact scan-at-sink would ship user PII into git history with
|
||||||
|
nothing failing. **Effort:** M → S with CC. **Priority:** P2.
|
||||||
|
**Tier:** periodic (quality, non-deterministic).
|
||||||
|
|
||||||
|
### P2: migration runners honor per-migration skip state
|
||||||
|
|
||||||
|
**What:** Both migration runners (setup's post-setup block and
|
||||||
|
/gstack-upgrade Step 4.75) select migrations purely by version window, so a
|
||||||
|
migration that exits via the non-interactive default-skip (v1.27's
|
||||||
|
GSTACK_MIGRATE_ASSUME_YES gate) is never offered again — the version marker
|
||||||
|
advances past it. The remediation text now prints the honest direct
|
||||||
|
invocation, but the runners should track per-migration .done/.skipped
|
||||||
|
touchfiles and re-offer pending ones on the next interactive run.
|
||||||
|
|
||||||
|
**Why:** Every remaining pre-v1.27 user upgrading via an agent session ([ -t 0 ]
|
||||||
|
false) permanently misses the artifacts-rename migration unless they paste the
|
||||||
|
manual command. **Effort:** M. **Priority:** P2.
|
||||||
|
|
||||||
|
### P2: periodic tier — three documented-red tests need structural repair
|
||||||
|
|
||||||
|
**What:** (1) The sidebar E2E trio (navigate, url-accuracy, css-interaction)
|
||||||
|
POSTs to /sidebar-command and /sidebar-chat — endpoints removed on every tree
|
||||||
|
when the PTY terminal replaced the chat queue (server.ts tombstone ~2671);
|
||||||
|
rewrite them against the PTY surface or delete them. (2)
|
||||||
|
skill-e2e-ship-idempotency: the PTY child sits at the Claude Code welcome
|
||||||
|
screen in plan mode for the full budget — the typed /ship never lands
|
||||||
|
(readiness/typing race vs CLI v2.1.233's welcome screen); never green since
|
||||||
|
it was born in v1.63. (3) skill-e2e-brain-privacy-gate: never green anywhere;
|
||||||
|
the artifacts-sync stop-gate preconditions don't survive the hermetic env
|
||||||
|
even with per-test HOME/GSTACK_HOME injection — needs a transcript-level
|
||||||
|
debug of what the child's preamble actually echoes.
|
||||||
|
|
||||||
|
**Why:** every red periodic run costs triage time; two of these have burned
|
||||||
|
three triage passes across two releases. **Effort:** M. **Priority:** P2.
|
||||||
|
|
||||||
### P1: #1882 — portable skill-install prefix (non-`gstack` install dirs break silently)
|
### P1: #1882 — portable skill-install prefix (non-`gstack` install dirs break silently)
|
||||||
|
|
||||||
**What:** Every generated SKILL.md hardcodes the literal `~/.claude/skills/gstack/...`
|
**What:** Every generated SKILL.md hardcodes the literal `~/.claude/skills/gstack/...`
|
||||||
@@ -25,6 +107,9 @@ So #1882 is now purely the body-preamble portability work.
|
|||||||
invocation-time failures.
|
invocation-time failures.
|
||||||
**Cons:** Touches the most load-bearing bash in the repo (every skill's preamble);
|
**Cons:** Touches the most load-bearing bash in the repo (every skill's preamble);
|
||||||
a silent mistake breaks all 52 skills. High blast radius — needs its own focused PR.
|
a silent mistake breaks all 52 skills. High blast radius — needs its own focused PR.
|
||||||
|
**Note (fork port wave 2):** the Apple release adapter (ship/sections/
|
||||||
|
apple-release.md) added template surface with `~/.claude/skills/gstack/bin`
|
||||||
|
references — include it in this fix's coverage list.
|
||||||
|
|
||||||
**Context / where to start:**
|
**Context / where to start:**
|
||||||
- Rewire `ctx.paths.binDir` (and browse/design dir paths) + the ~9 resolvers that
|
- Rewire `ctx.paths.binDir` (and browse/design dir paths) + the ~9 resolvers that
|
||||||
|
|||||||
+24
-7
@@ -682,6 +682,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
@@ -900,8 +904,21 @@ After /office-hours completes, re-run the design doc check:
|
|||||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||||
SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")
|
SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")
|
||||||
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch')
|
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch')
|
||||||
DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1)
|
_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1)
|
||||||
[ -z "$DESIGN" ] && DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1)
|
[ -z "$_LOCALDOC" ] && _LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1)
|
||||||
|
# Repo-local docs win when at least as fresh (#703): office-hours dual-writes
|
||||||
|
# docs/designs/ alongside ~/.gstack, and the committed copy is what teammates
|
||||||
|
# see. A stale old repo doc never shadows a newer private session.
|
||||||
|
_REPOTOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
|
||||||
|
_REPODOC=""
|
||||||
|
if [ -n "$_REPOTOP" ]; then
|
||||||
|
[ -f "$_REPOTOP/DESIGN.md" ] && _REPODOC="$_REPOTOP/DESIGN.md"
|
||||||
|
[ -z "$_REPODOC" ] && _REPODOC=$(ls -t "$_REPOTOP"/docs/designs/*.md 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
DESIGN="$_LOCALDOC"
|
||||||
|
if [ -n "$_REPODOC" ] && { [ -z "$_LOCALDOC" ] || [ "$_REPODOC" -nt "$_LOCALDOC" ]; }; then
|
||||||
|
DESIGN="$_REPODOC"
|
||||||
|
fi
|
||||||
[ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found"
|
[ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1676,12 +1693,12 @@ if command -v jq >/dev/null 2>&1; then
|
|||||||
# Filter to current branch + recent commits, then keep records for the
|
# Filter to current branch + recent commits, then keep records for the
|
||||||
# latest run_id only. (Single phase may have multiple files if the user
|
# latest run_id only. (Single phase may have multiple files if the user
|
||||||
# re-ran the review; aggregator takes the newest.)
|
# re-ran the review; aggregator takes the newest.)
|
||||||
# NOTE: bind .commit BEFORE the split pipe. Inside ($commits | split(...))
|
# .commit must be bound BEFORE piping to the split commit array: a
|
||||||
# the "." context is the resulting ARRAY, so a bare .commit there raises
|
# pipe rebinds jq's context, so a bare .commit after it indexes the
|
||||||
# "Cannot index array with string" on every record — and the 2>/dev/null
|
# ARRAY with a string, every line errors into 2>/dev/null, and the
|
||||||
# below swallows it, so the whole aggregation silently yields zero tasks.
|
# aggregate is empty forever — the #2018 zero-tasks bug.
|
||||||
jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \
|
jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \
|
||||||
'select(.branch == $branch and ((.commit) as $c | ($commits | split("|") | index($c)) != null))' \
|
'.commit as $c | select(.branch == $branch and ($commits | split("|") | index($c) != null))' \
|
||||||
"$f" 2>/dev/null >> "$ALL_JSONL" || true
|
"$f" 2>/dev/null >> "$ALL_JSONL" || true
|
||||||
done < <(find "$TASKS_DIR" -maxdepth 1 -name "tasks-$phase-*.jsonl" 2>/dev/null | sort)
|
done < <(find "$TASKS_DIR" -maxdepth 1 -name "tasks-$phase-*.jsonl" 2>/dev/null | sort)
|
||||||
# Reduce to latest run_id per phase
|
# Reduce to latest run_id per phase
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Launch Chrome with CDP (remote debugging) enabled.
|
|
||||||
# Usage: chrome-cdp [port]
|
|
||||||
#
|
|
||||||
# Chrome refuses --remote-debugging-port on its default data directory.
|
|
||||||
# We create a separate data dir with a symlink to the user's real profile,
|
|
||||||
# so Chrome thinks it's non-default but uses the same cookies/extensions.
|
|
||||||
|
|
||||||
PORT="${1:-9222}"
|
|
||||||
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
||||||
REAL_PROFILE="$HOME/Library/Application Support/Google/Chrome"
|
|
||||||
CDP_DATA_DIR="$HOME/.gstack/cdp-profile/chrome"
|
|
||||||
|
|
||||||
if ! [ -f "$CHROME" ]; then
|
|
||||||
echo "Chrome not found at $CHROME" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if Chrome is running
|
|
||||||
if pgrep -f "Google Chrome" >/dev/null 2>&1; then
|
|
||||||
echo "Chrome is still running. Quitting..."
|
|
||||||
osascript -e 'tell application "Google Chrome" to quit' 2>/dev/null
|
|
||||||
|
|
||||||
# Wait for it to fully exit
|
|
||||||
for i in $(seq 1 20); do
|
|
||||||
pgrep -f "Google Chrome" >/dev/null 2>&1 || break
|
|
||||||
sleep 0.5
|
|
||||||
done
|
|
||||||
|
|
||||||
if pgrep -f "Google Chrome" >/dev/null 2>&1; then
|
|
||||||
echo "Chrome won't quit. Force-killing..." >&2
|
|
||||||
pkill -f "Google Chrome"
|
|
||||||
sleep 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Set up CDP data dir with symlinked profile
|
|
||||||
# Chrome requires a "non-default" data dir for --remote-debugging-port.
|
|
||||||
# We symlink the real Default profile so cookies/extensions carry over.
|
|
||||||
mkdir -p "$CDP_DATA_DIR"
|
|
||||||
if [ -d "$REAL_PROFILE/Default" ] && ! [ -e "$CDP_DATA_DIR/Default" ]; then
|
|
||||||
ln -s "$REAL_PROFILE/Default" "$CDP_DATA_DIR/Default"
|
|
||||||
echo "Linked real Chrome profile into CDP data dir"
|
|
||||||
fi
|
|
||||||
# Also link Local State (contains crypto keys for cookie decryption, etc.)
|
|
||||||
if [ -f "$REAL_PROFILE/Local State" ] && ! [ -e "$CDP_DATA_DIR/Local State" ]; then
|
|
||||||
ln -s "$REAL_PROFILE/Local State" "$CDP_DATA_DIR/Local State"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Launching Chrome with CDP on port $PORT..."
|
|
||||||
"$CHROME" \
|
|
||||||
--remote-debugging-port="$PORT" \
|
|
||||||
--remote-debugging-address=127.0.0.1 \
|
|
||||||
--remote-allow-origins="http://127.0.0.1:$PORT" \
|
|
||||||
--user-data-dir="$CDP_DATA_DIR" \
|
|
||||||
--restore-last-session &
|
|
||||||
disown
|
|
||||||
|
|
||||||
# Wait for CDP to be available
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if curl -s "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
|
|
||||||
echo "CDP ready on port $PORT"
|
|
||||||
echo "Run: \$B connect chrome"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "CDP not available after 30s." >&2
|
|
||||||
exit 1
|
|
||||||
@@ -34,9 +34,9 @@
|
|||||||
* gstack-brain-context-load --quiet
|
* gstack-brain-context-load --quiet
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync, statSync, readdirSync } from "fs";
|
import { existsSync, readFileSync, statSync, readdirSync, accessSync, constants } from "fs";
|
||||||
import { join, dirname, basename, resolve } from "path";
|
import { join, dirname, basename, resolve, delimiter } from "path";
|
||||||
import { execFileSync, spawnSync } from "child_process";
|
import { spawnSync } from "child_process";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
|
|
||||||
import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers";
|
import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers";
|
||||||
@@ -68,7 +68,9 @@ interface QueryResult {
|
|||||||
|
|
||||||
const HOME = homedir();
|
const HOME = homedir();
|
||||||
const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack");
|
const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack");
|
||||||
const MCP_TIMEOUT_MS = 500;
|
// 500ms hard cap per Section 1C; overridable for slow/loaded environments
|
||||||
|
// (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 PAGE_SIZE_CAP = 10 * 1024; // 10KB per query result before truncation
|
const PAGE_SIZE_CAP = 10 * 1024; // 10KB per query result before truncation
|
||||||
|
|
||||||
// ── CLI ────────────────────────────────────────────────────────────────────
|
// ── CLI ────────────────────────────────────────────────────────────────────
|
||||||
@@ -190,16 +192,28 @@ function resolveSkillFile(args: CliArgs): string | null {
|
|||||||
|
|
||||||
// ── Dispatchers ────────────────────────────────────────────────────────────
|
// ── Dispatchers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let gbrainOnPath: boolean | null = null;
|
||||||
|
|
||||||
function gbrainAvailable(): boolean {
|
function gbrainAvailable(): boolean {
|
||||||
try {
|
// Stat-based PATH scan, memoized. Spawning `gbrain --version` under the
|
||||||
execFileSync("gbrain", ["--version"], {
|
// 500ms budget misreported gbrain as missing whenever a cold process spawn
|
||||||
stdio: "ignore",
|
// exceeded the timeout (loaded machine, node-based CLI cold start), and
|
||||||
timeout: MCP_TIMEOUT_MS,
|
// re-probing per query burned 3x the budget before any real work.
|
||||||
});
|
if (gbrainOnPath !== null) return gbrainOnPath;
|
||||||
return true;
|
const exts = process.platform === "win32"
|
||||||
} catch {
|
? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";")
|
||||||
return false;
|
: [""];
|
||||||
}
|
gbrainOnPath = (process.env.PATH || "").split(delimiter).some((dir) =>
|
||||||
|
dir !== "" && exts.some((ext) => {
|
||||||
|
try {
|
||||||
|
accessSync(join(dir, `gbrain${ext}`), constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return gbrainOnPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchVector(q: GbrainManifestQuery, args: CliArgs): QueryResult {
|
function dispatchVector(q: GbrainManifestQuery, args: CliArgs): QueryResult {
|
||||||
|
|||||||
Executable
+284
@@ -0,0 +1,284 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* gstack-code-intelligence — pick a code-intelligence provider and use it to
|
||||||
|
* index and search this repo. OPTIONAL: with nothing selected, gstack works
|
||||||
|
* fine and callers use grep / the file-only decision store.
|
||||||
|
*
|
||||||
|
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* gstack-code-intelligence suggest [repo] [--json] # should the one-time indexing offer be made here?
|
||||||
|
* gstack-code-intelligence options # list providers (GBrain first) + availability
|
||||||
|
* gstack-code-intelligence status # current selection + availability
|
||||||
|
* gstack-code-intelligence consent [repo-path] <yes|no> # record per-repo indexing consent (value REQUIRED)
|
||||||
|
* gstack-code-intelligence select <gbrain|sourcebot|graphify|none>
|
||||||
|
* gstack-code-intelligence index [repo-path] # index the repo with the selected provider
|
||||||
|
* gstack-code-intelligence search <query...> # search via the selected provider
|
||||||
|
*
|
||||||
|
* Non-local providers (GBrain, or a Sourcebot on a remote host) refuse to index
|
||||||
|
* until you consent for that repo. Graphify and a localhost Sourcebot are local:
|
||||||
|
* nothing leaves the machine, so no consent is needed. Graphify is never
|
||||||
|
* auto-installed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { realpathSync } from "fs";
|
||||||
|
import { hostname } from "os";
|
||||||
|
import { basename, resolve } from "path";
|
||||||
|
import {
|
||||||
|
CodeProviderError,
|
||||||
|
RECOMMENDED_ORDER,
|
||||||
|
detectAvailable,
|
||||||
|
getRoot,
|
||||||
|
hasConsent,
|
||||||
|
providerById,
|
||||||
|
readSelection,
|
||||||
|
resolveSelectedProvider,
|
||||||
|
setConsent,
|
||||||
|
setProvider,
|
||||||
|
setRoot,
|
||||||
|
shouldOfferIndexing,
|
||||||
|
type CodeProviderId,
|
||||||
|
} from "../lib/code-intelligence";
|
||||||
|
|
||||||
|
const PROVIDER_IDS = new Set<CodeProviderId>(["gbrain", "sourcebot", "graphify"]);
|
||||||
|
const LABEL: Record<CodeProviderId, string> = { gbrain: "GBrain", sourcebot: "Sourcebot", graphify: "Graphify" };
|
||||||
|
const NOTE: Record<CodeProviderId, string> = {
|
||||||
|
gbrain: "recommended; federated memory + code (sends content to your GBrain DB)",
|
||||||
|
sourcebot: "self-hosted whole-repo regex search (local when on localhost)",
|
||||||
|
graphify: "local tree-sitter code graph, nothing leaves the machine (install it yourself)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function out(s: string): void {
|
||||||
|
process.stdout.write(`${s}\n`);
|
||||||
|
}
|
||||||
|
function fail(s: string): never {
|
||||||
|
process.stderr.write(`gstack-code-intelligence: ${s}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdOptions(): Promise<void> {
|
||||||
|
out("Code-intelligence providers (indexing is optional; GBrain recommended):\n");
|
||||||
|
const avail = await detectAvailable();
|
||||||
|
const byId = new Map(avail.map((a) => [a.id, a]));
|
||||||
|
for (const id of RECOMMENDED_ORDER) {
|
||||||
|
const a = byId.get(id);
|
||||||
|
const mark = a?.available ? "available" : "not available";
|
||||||
|
out(` ${id === "gbrain" ? "*" : " "} ${LABEL[id].padEnd(10)} [${mark}] — ${NOTE[id]}`);
|
||||||
|
if (a?.detail) out(` ${a.detail}`);
|
||||||
|
}
|
||||||
|
out("\nSelect one with: gstack-code-intelligence select <provider>");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdStatus(): Promise<void> {
|
||||||
|
const sel = readSelection();
|
||||||
|
out(`selected: ${sel.provider ?? "none (grep / file-only fallback)"}`);
|
||||||
|
const avail = await detectAvailable();
|
||||||
|
for (const a of avail) out(` ${LABEL[a.id]}: ${a.available ? "available" : "unavailable"} (${a.detail})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-time session-start offer gate. Prints (or emits as JSON) whether an
|
||||||
|
* agent should ask the user about indexing this repo, and when it should, the
|
||||||
|
* provider options with their reasons so the question is self-contained.
|
||||||
|
*/
|
||||||
|
async function cmdSuggest(rest: string[]): Promise<void> {
|
||||||
|
const json = rest.includes("--json");
|
||||||
|
const pathArg = rest.find((a) => !a.startsWith("--"));
|
||||||
|
const repoPath = resolve(pathArg ?? process.cwd());
|
||||||
|
const suggestion = shouldOfferIndexing(repoPath);
|
||||||
|
if (!suggestion.offer) {
|
||||||
|
if (json) {
|
||||||
|
out(JSON.stringify({ ...suggestion, repoPath }));
|
||||||
|
} else {
|
||||||
|
out(`no offer (${suggestion.reason}${suggestion.fileCount != null ? `, ${suggestion.fileCount} tracked files` : ""})`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const avail = await detectAvailable();
|
||||||
|
if (json) {
|
||||||
|
out(JSON.stringify({
|
||||||
|
...suggestion,
|
||||||
|
repoPath,
|
||||||
|
options: avail.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
label: LABEL[a.id],
|
||||||
|
reason: NOTE[a.id],
|
||||||
|
local: providerById(a.id).local,
|
||||||
|
available: a.available,
|
||||||
|
detail: a.detail,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out(`offer indexing: ${suggestion.fileCount} tracked files (threshold ${suggestion.threshold}) and no prior decision`);
|
||||||
|
await cmdOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdSelect(arg: string | undefined): void {
|
||||||
|
if (arg === "none") {
|
||||||
|
setProvider(null);
|
||||||
|
out("code-intelligence declined; gstack uses grep / file-only fallback and will not ask again");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!arg || !PROVIDER_IDS.has(arg as CodeProviderId)) {
|
||||||
|
fail("Usage: select <gbrain|sourcebot|graphify|none>");
|
||||||
|
}
|
||||||
|
const id = arg as CodeProviderId;
|
||||||
|
setProvider(id);
|
||||||
|
out(`selected ${LABEL[id]}.`);
|
||||||
|
const provider = providerById(id);
|
||||||
|
if (!provider.local) out(`${LABEL[id]} sends repo content off this machine — run \`consent\` in a repo before indexing it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record per-repo indexing consent: `consent [repo-path] <yes|no>`.
|
||||||
|
*
|
||||||
|
* The yes|no value is REQUIRED (true/false also accepted). It is never
|
||||||
|
* defaulted: an agent recording a user's "no" must persist consent DENIED,
|
||||||
|
* and a missing/unknown value must record NOTHING — a consent gate that
|
||||||
|
* assumes "yes" is a consent gate that lies.
|
||||||
|
*/
|
||||||
|
function cmdConsent(rest: string[]): void {
|
||||||
|
const positional = rest.filter((a) => !a.startsWith("--"));
|
||||||
|
const CONSENT_USAGE = "Usage: consent [repo-path] <yes|no> — the yes/no value is required; consent is never assumed";
|
||||||
|
if (positional.length < 1 || positional.length > 2) fail(CONSENT_USAGE);
|
||||||
|
const value = positional[positional.length - 1].toLowerCase();
|
||||||
|
let consented: boolean;
|
||||||
|
if (value === "yes" || value === "true") consented = true;
|
||||||
|
else if (value === "no" || value === "false") consented = false;
|
||||||
|
else fail(CONSENT_USAGE);
|
||||||
|
const repoPath = resolve(positional.length === 2 ? positional[0] : process.cwd());
|
||||||
|
setConsent(repoPath, consented);
|
||||||
|
out(consented ? `indexing consent recorded for ${repoPath}` : `indexing consent DENIED for ${repoPath} (recorded)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host+path-hashed source id for GBrain/Sourcebot — the same approach as
|
||||||
|
* deriveCodeSourceId in bin/gstack-gbrain-sync.ts. A bare basename collides:
|
||||||
|
* two repos both named "api" (or the same repo on two machines against a
|
||||||
|
* federated brain) would silently share one source. Suffix = first 8 hex of
|
||||||
|
* sha1(`${hostname}::${realpath}`); base sanitized to gbrain's source-id
|
||||||
|
* charset (lowercase alnum + interior hyphens) and capped so the whole id
|
||||||
|
* stays within gbrain's 32-char limit.
|
||||||
|
*/
|
||||||
|
function hashedSourceId(repoPath: string): string {
|
||||||
|
let real = repoPath;
|
||||||
|
try {
|
||||||
|
real = realpathSync(repoPath);
|
||||||
|
} catch {
|
||||||
|
// path may not exist yet at id-derivation time — hash the resolved form
|
||||||
|
}
|
||||||
|
const host = process.env.GSTACK_HOSTNAME || hostname();
|
||||||
|
const suffix = createHash("sha1").update(`${host}::${real}`).digest("hex").slice(0, 8);
|
||||||
|
const base =
|
||||||
|
basename(real)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 23)
|
||||||
|
.replace(/-+$/, "") || "repo";
|
||||||
|
return `${base}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdIndex(pathArg: string | undefined): Promise<void> {
|
||||||
|
const provider = resolveSelectedProvider();
|
||||||
|
if (!provider) fail("no provider selected; run `select <provider>` first");
|
||||||
|
const repoPath = resolve(pathArg ?? process.cwd());
|
||||||
|
// Indexing is write-class: hasConsent's default op class applies, so a
|
||||||
|
// `deny` OR `read-only` repo trust policy vetoes it (code indexing writes
|
||||||
|
// pages — same semantics as gstack-gbrain-sync's runCodeImport).
|
||||||
|
const consented = hasConsent(repoPath);
|
||||||
|
if (!provider!.local && !consented) {
|
||||||
|
const recorded = readSelection().consents[repoPath] === true;
|
||||||
|
fail(recorded
|
||||||
|
? `${provider!.label} indexing is blocked by the repo trust policy (deny or read-only — code indexing writes pages). Change with: gstack-gbrain-repo-policy set <origin-url> read-write`
|
||||||
|
: `${provider!.label} would send this repo's content off the machine. Run \`gstack-code-intelligence consent ${repoPath} yes\` first.`);
|
||||||
|
}
|
||||||
|
// Graphify keys sources on the repo path; GBrain/Sourcebot on a short
|
||||||
|
// host+path-hashed id (bare basenames collide across same-named repos).
|
||||||
|
const sourceId = provider!.id === "graphify" ? repoPath : hashedSourceId(repoPath);
|
||||||
|
const repo = { id: sourceId, path: repoPath };
|
||||||
|
try {
|
||||||
|
const registered = await provider!.registerSource(repo, { consented });
|
||||||
|
out(`registered ${repo.id} with ${provider!.label} (${registered.state})`);
|
||||||
|
const refreshed = await provider!.refresh({ id: registered.id }, { consented });
|
||||||
|
// Remember which repo this provider indexed so `search` reads the same graph.
|
||||||
|
setRoot(provider!.id, repoPath);
|
||||||
|
out(`indexed: ${refreshed.state}${refreshed.itemCount != null ? ` (${refreshed.itemCount} items)` : ""}`);
|
||||||
|
} catch (err) {
|
||||||
|
handleProviderError(err, provider!.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdSearch(terms: string[]): Promise<void> {
|
||||||
|
const query = terms.join(" ").trim();
|
||||||
|
if (!query) fail("Usage: search <query...>");
|
||||||
|
const provider = resolveSelectedProvider();
|
||||||
|
if (!provider) fail("no provider selected; run `select <provider>` first (or use grep)");
|
||||||
|
// Search is read-class: a read-only repo trust policy still allows it
|
||||||
|
// (mirrors gstack-gbrain-sync: search allowed, page writes never), but a
|
||||||
|
// deny tier — or no recorded consent at all — still refuses for non-local
|
||||||
|
// providers, because the query text itself is repo-derived content. The
|
||||||
|
// consent repo is the one this provider indexed (search reads that graph);
|
||||||
|
// loopback providers need no consent, so their path is unchanged.
|
||||||
|
const searchRoot = getRoot(provider!.id) ?? resolve(process.cwd());
|
||||||
|
const consented = hasConsent(searchRoot, undefined, "read");
|
||||||
|
// Honest pre-flight (mirrors cmdIndex): the adapter enforces the same gate
|
||||||
|
// (assertEgressConsent throws PROVIDER_NOT_CONSENTED before any bytes or
|
||||||
|
// receipt exist), but the CLI names WHY — missing consent vs a deny repo
|
||||||
|
// trust policy — instead of surfacing a generic provider error.
|
||||||
|
if (!provider!.local && !consented) {
|
||||||
|
const recorded = readSelection().consents[searchRoot] === true;
|
||||||
|
fail(recorded
|
||||||
|
? `${provider!.label} search is blocked by the repo trust policy (deny — the query text is repo-derived content). Change with: gstack-gbrain-repo-policy set <origin-url> read-only (search allowed) or read-write`
|
||||||
|
: `${provider!.label} would send the query text (repo-derived content) off this machine. Run \`gstack-code-intelligence consent ${searchRoot} yes\` first.`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const hits = await provider!.search(query, { limit: 10, consented });
|
||||||
|
if (!hits.length) {
|
||||||
|
out("(no results)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const h of hits) out(`${h.score != null ? `[${h.score.toFixed(2)}] ` : ""}${h.ref}${h.snippet ? ` — ${h.snippet}` : ""}`);
|
||||||
|
} catch (err) {
|
||||||
|
handleProviderError(err, provider!.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProviderError(err: unknown, label: string): never {
|
||||||
|
if (err instanceof CodeProviderError) {
|
||||||
|
if (err.code === "PROVIDER_UNAVAILABLE") {
|
||||||
|
fail(`${label} is unavailable (${err.message}). gstack still works — fall back to grep / file-only.`);
|
||||||
|
}
|
||||||
|
if (err.code === "PROVIDER_NOT_CONSENTED") {
|
||||||
|
fail(`${label} ${err.code}: ${err.message} Run \`gstack-code-intelligence consent <repo-path> yes\` first (a deny repo trust policy overrides recorded consent).`);
|
||||||
|
}
|
||||||
|
fail(`${label} ${err.code}: ${err.message}`);
|
||||||
|
}
|
||||||
|
fail(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const [action, ...rest] = process.argv.slice(2);
|
||||||
|
switch (action) {
|
||||||
|
case "suggest":
|
||||||
|
return cmdSuggest(rest);
|
||||||
|
case "options":
|
||||||
|
return cmdOptions();
|
||||||
|
case "status":
|
||||||
|
return cmdStatus();
|
||||||
|
case "select":
|
||||||
|
return cmdSelect(rest[0]);
|
||||||
|
case "consent":
|
||||||
|
return cmdConsent(rest);
|
||||||
|
case "index":
|
||||||
|
return cmdIndex(rest[0]);
|
||||||
|
case "search":
|
||||||
|
return cmdSearch(rest);
|
||||||
|
default:
|
||||||
|
fail("Usage: suggest [path] [--json] | options | status | select <provider> | consent [path] <yes|no> | index [path] | search <query...>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
||||||
@@ -133,6 +133,8 @@ lookup_default() {
|
|||||||
|
|
||||||
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
|
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
|
||||||
redact_prepush_hook) echo "false" ;;
|
redact_prepush_hook) echo "false" ;;
|
||||||
|
pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks
|
||||||
|
founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false
|
||||||
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
|
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
|
||||||
# brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
|
# brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
|
||||||
# writes 'personal' for local engines,
|
# writes 'personal' for local engines,
|
||||||
@@ -319,6 +321,14 @@ case "${1:-}" in
|
|||||||
echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2
|
echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2
|
||||||
VALUE="false"
|
VALUE="false"
|
||||||
fi
|
fi
|
||||||
|
if [ "$KEY" = "pair_agent" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
|
||||||
|
echo "Warning: pair_agent '$VALUE' not recognized. Valid values: on, off. Using off." >&2
|
||||||
|
VALUE="off"
|
||||||
|
fi
|
||||||
|
if [ "$KEY" = "founder_resources" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then
|
||||||
|
echo "Warning: founder_resources '$VALUE' not recognized. Valid values: true, false. Using true." >&2
|
||||||
|
VALUE="true"
|
||||||
|
fi
|
||||||
if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then
|
if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then
|
||||||
echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2
|
echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2
|
||||||
VALUE="prompt"
|
VALUE="prompt"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// gstack-context-bill — token bill-of-materials for an installed skills tree.
|
// gstack-context-bill — token bill-of-materials for an installed skills tree.
|
||||||
// All behavior lives in lib/context-bill.ts; this is the CLI shim.
|
// All behavior lives in lib/context-bill.ts; this is the CLI shim.
|
||||||
|
|
||||||
|
import '../lib/conductor-env-shim'; // --exact needs GSTACK_ANTHROPIC_API_KEY promotion inside Conductor
|
||||||
import { contextBillMain } from '../lib/context-bill';
|
import { contextBillMain } from '../lib/context-bill';
|
||||||
|
|
||||||
process.exit(await contextBillMain(process.argv.slice(2)));
|
process.exit(await contextBillMain(process.argv.slice(2)));
|
||||||
|
|||||||
+42
-17
@@ -51,39 +51,64 @@ if (args.includes("--compact")) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The payload is identified by its leading `{`, not by "first non-flag arg" — a
|
||||||
|
// `--supersede <id> '{...}'` call would otherwise mistake the target id for the payload.
|
||||||
|
const jsonArg = args.find((a) => a.trimStart().startsWith("{"));
|
||||||
|
|
||||||
|
/** Parse + validate a decision payload. Exits 1 (nothing persisted) when it's bad. */
|
||||||
|
function validPayload(raw: string): DecisionEvent {
|
||||||
|
let obj: Partial<DecisionEvent>;
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
process.stderr.write("gstack-decision-log: invalid JSON\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (obj.scope === "branch" && !obj.branch) obj.branch = gitBranch();
|
||||||
|
const res = validateDecide(obj);
|
||||||
|
if (!res.ok) {
|
||||||
|
process.stderr.write(`gstack-decision-log: ${res.error}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return res.event;
|
||||||
|
}
|
||||||
|
|
||||||
const supersedeId = flagValue(args, "--supersede");
|
const supersedeId = flagValue(args, "--supersede");
|
||||||
const redactId = flagValue(args, "--redact");
|
const redactId = flagValue(args, "--redact");
|
||||||
if (supersedeId || redactId) {
|
if (supersedeId || redactId) {
|
||||||
const kind = supersedeId ? "supersede" : "redact";
|
const kind = supersedeId ? "supersede" : "redact";
|
||||||
const targetId = (supersedeId || redactId) as string;
|
const targetId = (supersedeId || redactId) as string;
|
||||||
|
if (targetId.trimStart().startsWith("{")) {
|
||||||
|
process.stderr.write(`gstack-decision-log: --${kind} needs the target decision id before the replacement JSON\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (kind === "redact" && jsonArg) {
|
||||||
|
process.stderr.write(
|
||||||
|
"gstack-decision-log: --redact expunges and takes no replacement; log the replacement in its own call so it isn't dropped\n",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
// Validate the replacement BEFORE anything is written, then append it FIRST and
|
||||||
|
// retire the old one SECOND. Appends are individually atomic, so the only visible
|
||||||
|
// interleaving is "both active" (recoverable); the reverse order could retire the
|
||||||
|
// old decision and lose the replacement the user was recording.
|
||||||
|
const replacement = jsonArg ? { ...validPayload(jsonArg), supersedes: targetId } : undefined;
|
||||||
|
if (replacement) appendEvent(paths, replacement);
|
||||||
appendEvent(paths, makeRefEvent(kind, targetId, { source: "agent" }));
|
appendEvent(paths, makeRefEvent(kind, targetId, { source: "agent" }));
|
||||||
rebuildSnapshot(paths);
|
rebuildSnapshot(paths);
|
||||||
enqueue();
|
enqueue();
|
||||||
console.log(`${kind}: ${targetId}`);
|
console.log(replacement ? `${kind}: ${targetId} -> ${replacement.id}` : `${kind}: ${targetId}`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonArg = args.find((a) => !a.startsWith("--"));
|
|
||||||
if (!jsonArg) {
|
if (!jsonArg) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
"gstack-decision-log: provide a JSON decision, or --supersede/--redact <id>, or --compact\n",
|
"gstack-decision-log: provide a JSON decision, or --supersede/--redact <id>, or --compact\n",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
let obj: Partial<DecisionEvent>;
|
const event = validPayload(jsonArg);
|
||||||
try {
|
appendEvent(paths, event);
|
||||||
obj = JSON.parse(jsonArg);
|
|
||||||
} catch {
|
|
||||||
process.stderr.write("gstack-decision-log: invalid JSON\n");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (obj.scope === "branch" && !obj.branch) obj.branch = gitBranch();
|
|
||||||
const res = validateDecide(obj);
|
|
||||||
if (!res.ok) {
|
|
||||||
process.stderr.write(`gstack-decision-log: ${res.error}\n`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
appendEvent(paths, res.event);
|
|
||||||
rebuildSnapshot(paths);
|
rebuildSnapshot(paths);
|
||||||
enqueue();
|
enqueue();
|
||||||
console.log(res.event.id);
|
console.log(event.id);
|
||||||
|
|||||||
@@ -4,7 +4,15 @@
|
|||||||
# Or: gstack-diff-scope main → prints SCOPE_*=... lines
|
# Or: gstack-diff-scope main → prints SCOPE_*=... lines
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BASE="${1:-main}"
|
# Detect the repo's default branch when no arg is given (#703-class
|
||||||
|
# platform-agnostic rule): origin/HEAD -> origin/main -> origin/master -> main.
|
||||||
|
_default_base() {
|
||||||
|
git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' && return
|
||||||
|
git rev-parse --verify -q origin/main >/dev/null 2>&1 && { echo "main"; return; }
|
||||||
|
git rev-parse --verify -q origin/master >/dev/null 2>&1 && { echo "master"; return; }
|
||||||
|
echo "main"
|
||||||
|
}
|
||||||
|
BASE="${1:-$(_default_base)}"
|
||||||
|
|
||||||
# Get changed file list
|
# Get changed file list
|
||||||
FILES=$(git diff "${BASE}...HEAD" --name-only 2>/dev/null || git diff "${BASE}" --name-only 2>/dev/null || echo "")
|
FILES=$(git diff "${BASE}...HEAD" --name-only 2>/dev/null || git diff "${BASE}" --name-only 2>/dev/null || echo "")
|
||||||
|
|||||||
@@ -197,8 +197,13 @@ RESULT=$(EVENTS_JSON="$EVENTS_JSON" DISTILL_PROMPT="$DISTILL_PROMPT" \
|
|||||||
const INPUT_PER_TOKEN = 1e-6;
|
const INPUT_PER_TOKEN = 1e-6;
|
||||||
const OUTPUT_PER_TOKEN = 5e-6;
|
const OUTPUT_PER_TOKEN = 5e-6;
|
||||||
|
|
||||||
|
// Host-neutral model resolution (mirrors lib/eval-model.ts — this inline
|
||||||
|
// bun -e script cannot import repo-relative libs from an arbitrary cwd).
|
||||||
|
const distillModel = process.env.GSTACK_EVAL_MODEL_DISTILL
|
||||||
|
|| process.env.GSTACK_EVAL_MODEL
|
||||||
|
|| "claude-haiku-4-5-20251001";
|
||||||
const resp = await client.messages.create({
|
const resp = await client.messages.create({
|
||||||
model: "claude-haiku-4-5-20251001",
|
model: distillModel,
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
messages: [{ role: "user", content: prompt }],
|
messages: [{ role: "user", content: prompt }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbra
|
|||||||
import { writeReceipt } from "../lib/egress-receipt";
|
import { writeReceipt } from "../lib/egress-receipt";
|
||||||
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
|
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
|
||||||
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
|
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
|
||||||
|
import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client";
|
||||||
import { checkOwnedStagingDir } from "../lib/staging-guard";
|
import { checkOwnedStagingDir } from "../lib/staging-guard";
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
@@ -778,6 +779,40 @@ function warnProbeTimeout(stage: "code" | "memory" | "dream"): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-repo trust tier from ~/.gstack/gbrain-repo-policy.json, read through
|
||||||
|
* the bin/gstack-gbrain-repo-policy CLI (which owns URL normalization and
|
||||||
|
* schema migration — do not reimplement either here).
|
||||||
|
*
|
||||||
|
* The tier was previously enforced only in /sync-gbrain skill prose, so a
|
||||||
|
* direct or cron invocation of this script ingested repo code regardless of
|
||||||
|
* a `deny`/`read-only` setting — and the egress receipt below cited this
|
||||||
|
* chokepoint as consent before it existed (#2140 sync path). This check
|
||||||
|
* closes both gaps.
|
||||||
|
*
|
||||||
|
* Fail-open ONLY when no policy store exists (nothing was ever set — same
|
||||||
|
* behavior as before for every non-policy user, and skips the subprocess).
|
||||||
|
* Fail-closed ("error") when a store exists but can't be read: a policy the
|
||||||
|
* user set must not be silently bypassed by a broken store or missing jq.
|
||||||
|
*
|
||||||
|
* Reads through the shared lib/gbrain-repo-policy-client.ts (same client as
|
||||||
|
* the code-intelligence consent veto — the two gates can never drift, and
|
||||||
|
* win32 gets the invoke-via-bash path). A spawn failure is still fail-closed
|
||||||
|
* but says so, instead of the misleading "store could not be read".
|
||||||
|
*/
|
||||||
|
export function repoPolicyTier(url: string | null): "read-write" | "read-only" | "deny" | "unset" | "error" {
|
||||||
|
const res = sharedRepoPolicyTier(url, process.env);
|
||||||
|
if (res.error === "spawn-failed") {
|
||||||
|
process.stderr.write(
|
||||||
|
"[gstack-gbrain-sync] the repo-policy helper could not be spawned (bash missing from PATH?) — " +
|
||||||
|
"refusing ingest rather than bypassing a possibly-set policy\n",
|
||||||
|
);
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
if (res.error) return "error";
|
||||||
|
return res.tier === "none" ? "unset" : res.tier;
|
||||||
|
}
|
||||||
|
|
||||||
async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const root = repoRoot();
|
const root = repoRoot();
|
||||||
@@ -787,6 +822,36 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
|||||||
|
|
||||||
const sourceId = deriveCodeSourceId(root);
|
const sourceId = deriveCodeSourceId(root);
|
||||||
|
|
||||||
|
// Per-repo trust tier — checked BEFORE the dry-run branch so previews report
|
||||||
|
// the refusal honestly instead of claiming they would sync.
|
||||||
|
const policyUrl = originUrl();
|
||||||
|
const tier = repoPolicyTier(policyUrl);
|
||||||
|
if (tier === "read-only") {
|
||||||
|
// Honoring an explicit user setting (search allowed, page writes never) is
|
||||||
|
// a clean skip, not a stage failure — code ingest writes pages.
|
||||||
|
return {
|
||||||
|
name: "code",
|
||||||
|
ran: false,
|
||||||
|
ok: true,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
summary: `skipped — repo policy is read-only for ${policyUrl} (code ingest writes pages). Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write`,
|
||||||
|
detail: { source_id: sourceId, source_path: root, status: "skipped-policy-read-only" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (tier === "deny" || tier === "error") {
|
||||||
|
const why = tier === "deny"
|
||||||
|
? `repo policy is deny for ${policyUrl} — no gbrain ingest for this repo. Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write`
|
||||||
|
: "repo policy store exists but could not be read (gstack-gbrain-repo-policy get failed) — refusing ingest rather than bypassing a set policy";
|
||||||
|
return {
|
||||||
|
name: "code",
|
||||||
|
ran: true,
|
||||||
|
ok: false,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
summary: `refused: ${why}`,
|
||||||
|
detail: { source_id: sourceId, source_path: root, status: tier === "deny" ? "refused-policy-deny" : "refused-policy-unreadable" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// dry-run preview always shows the would-do steps, regardless of local
|
// dry-run preview always shows the would-do steps, regardless of local
|
||||||
// engine state. Useful for "what would /sync-gbrain do" without probing
|
// engine state. Useful for "what would /sync-gbrain do" without probing
|
||||||
// the engine.
|
// the engine.
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ import {
|
|||||||
readSync,
|
readSync,
|
||||||
closeSync,
|
closeSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
|
realpathSync,
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import { join, basename, dirname } from "path";
|
import { join, basename, dirname, delimiter } from "path";
|
||||||
import { execFileSync, spawnSync, spawn, type ChildProcess } from "child_process";
|
import { execFileSync, spawnSync, spawn, type ChildProcess } from "child_process";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
@@ -1458,13 +1459,40 @@ function runGbrainImportOnce(
|
|||||||
// still reporting `written: N` from the staged count. Silent data loss
|
// still reporting `written: N` from the staged count. Silent data loss
|
||||||
// on every run. A working run logs `import.collect_files done ... files=N`
|
// on every run. A working run logs `import.collect_files done ... files=N`
|
||||||
// with N > 0 and takes minutes, not seconds.
|
// with N > 0 and takes minutes, not seconds.
|
||||||
const child = spawnGbrainAsync([
|
//
|
||||||
"import",
|
// GIT_CEILING_DIRECTORIES is the second layer of the same #2144 defense:
|
||||||
stagingDir,
|
// it stops git's upward repo discovery at the staging dir's parent, so a
|
||||||
"--no-embed",
|
// git-enumerating collector fails cleanly out of the git fast path and
|
||||||
...(includeGitignored ? ["--include-gitignored"] : []),
|
// falls back to its plain FS walk even on gbrain builds whose flag
|
||||||
"--json",
|
// semantics drift. The ceiling must be the REAL path — git compares
|
||||||
]);
|
// canonicalized directories during discovery, and a staging dir reached
|
||||||
|
// through a symlink (macOS /var -> /private/var, symlinked $GSTACK_HOME)
|
||||||
|
// otherwise never matches the ceiling entry. Scoped to this one child;
|
||||||
|
// no on-disk state, staging-guard/resume contracts untouched.
|
||||||
|
let ceiling: string;
|
||||||
|
try {
|
||||||
|
ceiling = realpathSync(dirname(stagingDir));
|
||||||
|
} catch {
|
||||||
|
ceiling = dirname(stagingDir); // staging parent vanished mid-run; spawn will fail loudly anyway
|
||||||
|
}
|
||||||
|
const baseEnv: NodeJS.ProcessEnv = {
|
||||||
|
...process.env,
|
||||||
|
// path.delimiter, not ':' — git splits this on ';' on Windows, and
|
||||||
|
// drive-letter paths contain ':' themselves.
|
||||||
|
GIT_CEILING_DIRECTORIES: process.env.GIT_CEILING_DIRECTORIES
|
||||||
|
? `${ceiling}${delimiter}${process.env.GIT_CEILING_DIRECTORIES}`
|
||||||
|
: ceiling,
|
||||||
|
};
|
||||||
|
const child = spawnGbrainAsync(
|
||||||
|
[
|
||||||
|
"import",
|
||||||
|
stagingDir,
|
||||||
|
"--no-embed",
|
||||||
|
...(includeGitignored ? ["--include-gitignored"] : []),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
{ baseEnv },
|
||||||
|
);
|
||||||
_activeImportChild = child;
|
_activeImportChild = child;
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
@@ -1941,6 +1969,18 @@ async function ingestPass(args: CliArgs): Promise<BulkResult> {
|
|||||||
: ""),
|
: ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Silent-zero pathology detector (#2144's other half): pages were staged
|
||||||
|
// but NOTHING imported or skipped-as-unchanged. That shape hid the dead
|
||||||
|
// ingest for months — it must be loud even under --quiet, because a run
|
||||||
|
// that indexes nothing is otherwise indistinguishable from a healthy one.
|
||||||
|
const importedCount = (importJson.imported ?? 0) + (importJson.skipped ?? 0);
|
||||||
|
if (prep.prepared.length > 0 && importedCount === 0 && (importJson.errors ?? 0) === 0) {
|
||||||
|
console.error(
|
||||||
|
`[memory-ingest] WARNING: ${prep.prepared.length} page(s) staged but gbrain collected ZERO ` +
|
||||||
|
`(no imports, no unchanged-skips, no errors). This is the #2144 silent-zero shape — ` +
|
||||||
|
`check gbrain's import.collect_files log line and your gbrain version.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// #1802 D1: in remote-http mode `stagingDir` is the PERSISTENT transcript
|
// #1802 D1: in remote-http mode `stagingDir` is the PERSISTENT transcript
|
||||||
// dir (makePersistentTranscriptDir, under ~/.gstack/transcripts/) that
|
// dir (makePersistentTranscriptDir, under ~/.gstack/transcripts/) that
|
||||||
|
|||||||
+26
-1
@@ -405,7 +405,32 @@ function parseArgs(argv: string[]): { base: string; bump: Bump; current: string;
|
|||||||
else if (a === "-h" || a === "--help") help = true;
|
else if (a === "-h" || a === "--help") help = true;
|
||||||
}
|
}
|
||||||
if (help) return { base: "", bump: "micro", current: "", excludePR: null, help: true };
|
if (help) return { base: "", bump: "micro", current: "", excludePR: null, help: true };
|
||||||
if (!base) base = "main";
|
if (!base) {
|
||||||
|
// Detect the default branch instead of assuming main (local-only repos
|
||||||
|
// on trunk/master work like GitHub repos on main). Same probe order as
|
||||||
|
// the canonical chain in bin/gstack-diff-scope and {{BASE_BRANCH_DETECT}}
|
||||||
|
// (scripts/resolvers/utility.ts): origin/HEAD -> origin/main ->
|
||||||
|
// origin/master -> literal "main". origin/HEAD is unset on plain clones
|
||||||
|
// that never ran `git remote set-head`, so the rev-parse probes matter.
|
||||||
|
try {
|
||||||
|
const head = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||||
|
base = head.replace("refs/remotes/origin/", "");
|
||||||
|
} catch {
|
||||||
|
// fall through to the rev-parse probes
|
||||||
|
}
|
||||||
|
if (!base) {
|
||||||
|
for (const candidate of ["main", "master"]) {
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["rev-parse", "--verify", "-q", `origin/${candidate}`], { stdio: ["ignore", "ignore", "ignore"] });
|
||||||
|
base = candidate;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// probe failed; try the next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!base) base = "main";
|
||||||
|
}
|
||||||
if (!bump) {
|
if (!bump) {
|
||||||
console.error("Error: --bump is required (major|minor|patch|micro)");
|
console.error("Error: --bump is required (major|minor|patch|micro)");
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ elif [ -n "${TMP:-}" ]; then
|
|||||||
else
|
else
|
||||||
_tmp_root=".gstack/tmp"
|
_tmp_root=".gstack/tmp"
|
||||||
fi
|
fi
|
||||||
|
# macOS exports TMPDIR with a trailing slash; mktemp templates built as
|
||||||
|
# "$TMP_ROOT/name-XXXXXX" would then carry "//", and any consumer comparing
|
||||||
|
# paths gets a spurious mismatch. Strip it (never strips a bare "/"). #2091
|
||||||
|
case "$_tmp_root" in
|
||||||
|
*/) [ "$_tmp_root" != "/" ] && _tmp_root="${_tmp_root%/}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# Strip any trailing slash so consumers can safely concatenate "$TMP_ROOT/name"
|
# Strip any trailing slash so consumers can safely concatenate "$TMP_ROOT/name"
|
||||||
# without producing a double slash. On macOS $TMPDIR ends in `/` by default
|
# without producing a double slash. On macOS $TMPDIR ends in `/` by default
|
||||||
|
|||||||
@@ -210,6 +210,13 @@ fi
|
|||||||
STEP_FIELD="null"
|
STEP_FIELD="null"
|
||||||
[ -n "$FAILED_STEP" ] && STEP_FIELD="\"$(json_safe "$FAILED_STEP")\""
|
[ -n "$FAILED_STEP" ] && STEP_FIELD="\"$(json_safe "$FAILED_STEP")\""
|
||||||
|
|
||||||
|
# Integrity first: a non-numeric duration would splice raw text into the
|
||||||
|
# JSON line ("duration_s":%s) and corrupt the whole JSONL stream — the range
|
||||||
|
# caps below silently no-op on non-integers because both test(1) comparisons
|
||||||
|
# fail. Reject anything that isn't a plain integer.
|
||||||
|
case "$DURATION" in
|
||||||
|
''|*[!0-9]*) DURATION="" ;;
|
||||||
|
esac
|
||||||
# Cap unreasonable durations
|
# Cap unreasonable durations
|
||||||
if [ -n "$DURATION" ] && [ "$DURATION" -gt 86400 ] 2>/dev/null; then
|
if [ -n "$DURATION" ] && [ "$DURATION" -gt 86400 ] 2>/dev/null; then
|
||||||
DURATION="" # null if > 24h
|
DURATION="" # null if > 24h
|
||||||
|
|||||||
@@ -81,15 +81,27 @@ while IFS= read -r LINE; do
|
|||||||
[ -z "$LINE" ] && continue
|
[ -z "$LINE" ] && continue
|
||||||
echo "$LINE" | grep -q '^{' || continue
|
echo "$LINE" | grep -q '^{' || continue
|
||||||
|
|
||||||
# Strip local-only fields (keep v, ts, sessions as-is for edge function)
|
# Strip local-only fields (keep v, ts, sessions as-is for edge function).
|
||||||
CLEAN="$(echo "$LINE" | sed \
|
# jq del() is structural — a value containing an escaped quote (repo names,
|
||||||
-e 's/,"_repo_slug":"[^"]*"//g' \
|
# branch names) can't smuggle the field past a regex or corrupt the strip.
|
||||||
-e 's/,"_branch":"[^"]*"//g' \
|
# The sed path stays only as a jq-less fallback.
|
||||||
-e 's/,"repo":"[^"]*"//g')"
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
if [ "$TIER" = "anonymous" ]; then
|
||||||
# If anonymous tier, strip installation_id
|
CLEAN="$(printf '%s' "$LINE" | jq -c 'del(._repo_slug, ._branch, .repo, .installation_id)' 2>/dev/null)" || CLEAN=""
|
||||||
if [ "$TIER" = "anonymous" ]; then
|
else
|
||||||
CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')"
|
CLEAN="$(printf '%s' "$LINE" | jq -c 'del(._repo_slug, ._branch, .repo)' 2>/dev/null)" || CLEAN=""
|
||||||
|
fi
|
||||||
|
# A line jq can't parse is malformed telemetry — drop it rather than
|
||||||
|
# forwarding bytes the strip never touched.
|
||||||
|
[ -z "$CLEAN" ] && continue
|
||||||
|
else
|
||||||
|
CLEAN="$(echo "$LINE" | sed \
|
||||||
|
-e 's/,"_repo_slug":"[^"]*"//g' \
|
||||||
|
-e 's/,"_branch":"[^"]*"//g' \
|
||||||
|
-e 's/,"repo":"[^"]*"//g')"
|
||||||
|
if [ "$TIER" = "anonymous" ]; then
|
||||||
|
CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$FIRST" = "true" ]; then
|
if [ "$FIRST" = "true" ]; then
|
||||||
|
|||||||
Executable
+214
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gstack-verify-gate — Stop hook. Blocks the turn from ending until the
|
||||||
|
# project's declared verification command passes.
|
||||||
|
#
|
||||||
|
# Declare the command on one line in the project's CLAUDE.md:
|
||||||
|
# <!-- gstack:verify: bun test -->
|
||||||
|
#
|
||||||
|
# Read-or-ask: gstack never invents this command. No declaration, no gate.
|
||||||
|
# Fails open on every absence (no CLAUDE.md, no declaration, empty value).
|
||||||
|
#
|
||||||
|
# Trust boundary: hooks bypass the permission system, so a declared command
|
||||||
|
# NEVER runs until the user records it in the per-repo trust store:
|
||||||
|
# gstack-verify-gate --trust (run from inside the repo)
|
||||||
|
# The store maps realpath(repo root) -> sha256(command) at
|
||||||
|
# ${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust (flat "path<TAB>hash",
|
||||||
|
# 0600, atomic rewrite). Any edit to the declared command invalidates trust
|
||||||
|
# until --trust is run again. Untrusted commands never block the turn.
|
||||||
|
#
|
||||||
|
# Exit 0 = allow the turn to end, one-line reason on stdout.
|
||||||
|
# Exit 2 = block, Claude Code feeds stderr back to the agent.
|
||||||
|
#
|
||||||
|
# Remove with: gstack-settings-hook remove-source --source verify-gate
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
TAB="$(printf '\t')"
|
||||||
|
STORE="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust"
|
||||||
|
|
||||||
|
_sha256() {
|
||||||
|
if command -v shasum >/dev/null 2>&1; then
|
||||||
|
printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
|
||||||
|
elif command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
printf '%s' "$1" | sha256sum | cut -d' ' -f1
|
||||||
|
else
|
||||||
|
printf '%s' "$1" | openssl dgst -sha256 | awk '{print $NF}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve the project root: CLAUDE_PROJECT_DIR, else walk up from $PWD to
|
||||||
|
# the first directory containing CLAUDE.md. Sets ROOT (may lack CLAUDE.md).
|
||||||
|
_resolve_root() {
|
||||||
|
ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||||
|
while [ ! -f "$ROOT/CLAUDE.md" ] && [ "$ROOT" != "/" ]; do
|
||||||
|
ROOT="$(dirname "$ROOT")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract the declared command from $ROOT/CLAUDE.md into CMD (may be empty).
|
||||||
|
# Accepts both `<!-- gstack:verify: cmd -->` and bare `gstack:verify: cmd`.
|
||||||
|
_extract_cmd() {
|
||||||
|
CMD="$(sed -n 's/^[[:space:]]*\(<!--[[:space:]]*\)\{0,1\}gstack:verify:[[:space:]]*\(.*\)$/\2/p' "$ROOT/CLAUDE.md" | head -1)"
|
||||||
|
CMD="${CMD%%-->*}"
|
||||||
|
CMD="$(printf '%s' "$CMD" | tr -d '`' | sed 's/[[:space:]]*$//')"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Symlink-stable store key for the root.
|
||||||
|
_trust_key() {
|
||||||
|
(cd "$ROOT" 2>/dev/null && pwd -P) || printf '%s' "$ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print the stored hash for key $1, or return 1 when absent.
|
||||||
|
_trusted_hash() {
|
||||||
|
[ -f "$STORE" ] || return 1
|
||||||
|
local p h
|
||||||
|
while IFS="$TAB" read -r p h; do
|
||||||
|
if [ "$p" = "$1" ]; then
|
||||||
|
printf '%s' "$h"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done <"$STORE"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Record key $1 -> hash $2, replacing any prior entry. Atomic, 0600.
|
||||||
|
_record_trust() {
|
||||||
|
local store_dir tmp p h
|
||||||
|
store_dir="$(dirname "$STORE")"
|
||||||
|
mkdir -p "$store_dir"
|
||||||
|
tmp="$STORE.tmp.$$"
|
||||||
|
: >"$tmp"
|
||||||
|
chmod 600 "$tmp"
|
||||||
|
if [ -f "$STORE" ]; then
|
||||||
|
while IFS="$TAB" read -r p h; do
|
||||||
|
[ "$p" = "$1" ] || printf '%s\t%s\n' "$p" "$h" >>"$tmp"
|
||||||
|
done <"$STORE"
|
||||||
|
fi
|
||||||
|
printf '%s\t%s\n' "$1" "$2" >>"$tmp"
|
||||||
|
mv -f "$tmp" "$STORE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Minimal JSON string escaping (backslash + double quote). CMD and paths are
|
||||||
|
# single-line by construction, so control characters never appear.
|
||||||
|
_json_escape() {
|
||||||
|
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forensic audit of trust grants. --trust stays agent-runnable (guardrail
|
||||||
|
# posture: catch accidents, not determined actors — same as the redaction
|
||||||
|
# guard), but a grant is never invisible: append {ts, root, cmd_sha256,
|
||||||
|
# cmd verbatim, tty} to a 0600 JSONL under GSTACK_HOME/security/.
|
||||||
|
# Args: $1 = root key, $2 = cmd sha256, $3 = cmd verbatim.
|
||||||
|
_log_trust_grant() {
|
||||||
|
local sec_dir log tty ts
|
||||||
|
sec_dir="${GSTACK_HOME:-$HOME/.gstack}/security"
|
||||||
|
log="$sec_dir/verify-gate-trust-grants.jsonl"
|
||||||
|
tty=false
|
||||||
|
[ -t 0 ] && tty=true
|
||||||
|
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
mkdir -p "$sec_dir"
|
||||||
|
[ -f "$log" ] || : >"$log"
|
||||||
|
chmod 600 "$log" 2>/dev/null || true
|
||||||
|
printf '{"ts":"%s","root":"%s","cmd_sha256":"%s","cmd":"%s","tty":%s}\n' \
|
||||||
|
"$ts" "$(_json_escape "$1")" "$2" "$(_json_escape "$3")" "$tty" >>"$log"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--trust" ]; then
|
||||||
|
_resolve_root
|
||||||
|
if [ ! -f "$ROOT/CLAUDE.md" ]; then
|
||||||
|
echo "verify-gate: no CLAUDE.md above $PWD, nothing to trust." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
_extract_cmd
|
||||||
|
if [ -z "$CMD" ]; then
|
||||||
|
echo "verify-gate: $ROOT/CLAUDE.md declares no 'gstack:verify:' command, nothing to trust." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
KEY="$(_trust_key)"
|
||||||
|
HASH="$(_sha256 "$CMD")"
|
||||||
|
_record_trust "$KEY" "$HASH"
|
||||||
|
_log_trust_grant "$KEY" "$HASH" "$CMD"
|
||||||
|
echo "verify-gate: trusted '$CMD' for $ROOT."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
INPUT=""
|
||||||
|
[ -t 0 ] || INPUT="$(cat)"
|
||||||
|
|
||||||
|
# Claude Code re-runs Stop hooks after a block (stop_hook_active=true). A
|
||||||
|
# re-entry is NOT a free pass: the gate re-runs the trusted check so an agent
|
||||||
|
# can't clear a red verification by simply stopping again. Re-entry blocks are
|
||||||
|
# bounded per episode (MAX_REENTRY_BLOCKS) so a stuck check can't loop forever;
|
||||||
|
# at the bound the gate allows with a loud warning.
|
||||||
|
REENTRY=0
|
||||||
|
if printf '%s' "$INPUT" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then
|
||||||
|
REENTRY=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
_resolve_root
|
||||||
|
|
||||||
|
if [ ! -f "$ROOT/CLAUDE.md" ]; then
|
||||||
|
echo "verify-gate: no CLAUDE.md above $PWD, no check declared, allowing."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
_extract_cmd
|
||||||
|
|
||||||
|
if [ -z "$CMD" ]; then
|
||||||
|
echo "verify-gate: $ROOT/CLAUDE.md declares no 'gstack:verify:' command, allowing."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trust gate: never execute a declared command the user has not recorded.
|
||||||
|
# Applies on re-entry too — untrusted commands keep the exit-0-with-hint path.
|
||||||
|
if [ "$(_trusted_hash "$(_trust_key)" || true)" != "$(_sha256 "$CMD")" ]; then
|
||||||
|
echo "verify-gate: found '$CMD' in $ROOT/CLAUDE.md but it is not trusted yet, skipping; enable with: cd $ROOT && $0 --trust" >&2
|
||||||
|
echo "verify-gate: declared command not trusted, allowing."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Episode-scoped re-entry attempt counter. Keyed by the hook-input session_id
|
||||||
|
# when present, else ppid+root — stale entries are fine to overwrite.
|
||||||
|
MAX_REENTRY_BLOCKS=3
|
||||||
|
_session_key() {
|
||||||
|
local sid
|
||||||
|
sid="$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
|
||||||
|
[ -n "$sid" ] || sid="ppid-$PPID"
|
||||||
|
_sha256 "$sid|$(_trust_key)"
|
||||||
|
}
|
||||||
|
ATTEMPTS_DIR="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-attempts"
|
||||||
|
COUNTER="$ATTEMPTS_DIR/$(_session_key)"
|
||||||
|
|
||||||
|
# A first entry (stop_hook_active=false) starts a fresh blocking episode.
|
||||||
|
if [ "$REENTRY" -eq 0 ]; then
|
||||||
|
rm -f "$COUNTER" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT="$(cd "$ROOT" && eval "$CMD" 2>&1)"
|
||||||
|
STATUS=$?
|
||||||
|
|
||||||
|
if [ "$STATUS" -eq 0 ]; then
|
||||||
|
rm -f "$COUNTER" 2>/dev/null || true
|
||||||
|
echo "verify-gate: declared check passed ($CMD)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$REENTRY" -eq 1 ]; then
|
||||||
|
COUNT="$(cat "$COUNTER" 2>/dev/null || echo 0)"
|
||||||
|
case "$COUNT" in
|
||||||
|
''|*[!0-9]*) COUNT=0 ;;
|
||||||
|
esac
|
||||||
|
if [ "$COUNT" -ge "$MAX_REENTRY_BLOCKS" ]; then
|
||||||
|
rm -f "$COUNTER" 2>/dev/null || true
|
||||||
|
WARN="verify-gate: WARNING — allowing after $MAX_REENTRY_BLOCKS blocked re-entries but the declared check is still FAILING ($CMD). Verification is RED; do not treat this turn as verified."
|
||||||
|
echo "$WARN"
|
||||||
|
echo "$WARN" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
mkdir -p "$ATTEMPTS_DIR"
|
||||||
|
echo $((COUNT + 1)) >"$COUNTER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "verify-gate: declared check FAILED with exit $STATUS: $CMD" >&2
|
||||||
|
printf '%s\n' "$OUT" | tail -20 >&2
|
||||||
|
echo "Fix the failure, or drop the gstack:verify line from $ROOT/CLAUDE.md." >&2
|
||||||
|
exit 2
|
||||||
@@ -769,6 +769,28 @@ should route through `browse` — `screenshot --selector` for visual output,
|
|||||||
`npm i puppeteer` and downloading a second Chromium that drifts out of version sync.
|
`npm i puppeteer` and downloading a second Chromium that drifts out of version sync.
|
||||||
One install to pin, one daemon's lifecycle to manage.
|
One install to pin, one daemon's lifecycle to manage.
|
||||||
|
|
||||||
|
## Session Persistence (opt-in)
|
||||||
|
|
||||||
|
By default the headless daemon's cookies and tab state die with it — a crash,
|
||||||
|
version auto-restart, or `browse stop` logs you out of everything (#778).
|
||||||
|
Opt in to persistence with `BROWSE_PERSIST_STATE=1` in the daemon's
|
||||||
|
environment: the daemon then snapshots cookies + per-tab
|
||||||
|
URL/localStorage/sessionStorage to `<stateDir>/session-state.json` (0600)
|
||||||
|
every 30 seconds and at clean shutdown, and restores it on the next launch.
|
||||||
|
|
||||||
|
Facts that matter:
|
||||||
|
- **Default OFF.** Cookies on disk are a real cost; the user opts in.
|
||||||
|
- **Headless only.** Headed mode's persistent Chromium profile already owns
|
||||||
|
its state; replaying tabs would clobber the user's window.
|
||||||
|
- **Never persisted:** loaded HTML and tab ownership — a tampered state file
|
||||||
|
cannot smuggle content past load-html's checks or forge ownership. Cookies
|
||||||
|
for localhost, `.internal`, and cloud-metadata addresses are dropped on
|
||||||
|
restore.
|
||||||
|
- **Corrupt state** is moved to `session-state.json.corrupt` (kept for
|
||||||
|
diagnosis) and the daemon boots fresh — persistence can never block a
|
||||||
|
launch. The boot log says which happened: `Session state restored: N
|
||||||
|
cookies / M tabs` or `fresh session`.
|
||||||
|
|
||||||
## User Handoff
|
## User Handoff
|
||||||
|
|
||||||
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
||||||
|
|||||||
@@ -216,6 +216,28 @@ should route through `browse` — `screenshot --selector` for visual output,
|
|||||||
`npm i puppeteer` and downloading a second Chromium that drifts out of version sync.
|
`npm i puppeteer` and downloading a second Chromium that drifts out of version sync.
|
||||||
One install to pin, one daemon's lifecycle to manage.
|
One install to pin, one daemon's lifecycle to manage.
|
||||||
|
|
||||||
|
## Session Persistence (opt-in)
|
||||||
|
|
||||||
|
By default the headless daemon's cookies and tab state die with it — a crash,
|
||||||
|
version auto-restart, or `browse stop` logs you out of everything (#778).
|
||||||
|
Opt in to persistence with `BROWSE_PERSIST_STATE=1` in the daemon's
|
||||||
|
environment: the daemon then snapshots cookies + per-tab
|
||||||
|
URL/localStorage/sessionStorage to `<stateDir>/session-state.json` (0600)
|
||||||
|
every 30 seconds and at clean shutdown, and restores it on the next launch.
|
||||||
|
|
||||||
|
Facts that matter:
|
||||||
|
- **Default OFF.** Cookies on disk are a real cost; the user opts in.
|
||||||
|
- **Headless only.** Headed mode's persistent Chromium profile already owns
|
||||||
|
its state; replaying tabs would clobber the user's window.
|
||||||
|
- **Never persisted:** loaded HTML and tab ownership — a tampered state file
|
||||||
|
cannot smuggle content past load-html's checks or forge ownership. Cookies
|
||||||
|
for localhost, `.internal`, and cloud-metadata addresses are dropped on
|
||||||
|
restore.
|
||||||
|
- **Corrupt state** is moved to `session-state.json.corrupt` (kept for
|
||||||
|
diagnosis) and the daemon boots fresh — persistence can never block a
|
||||||
|
launch. The boot log says which happened: `Session state restored: N
|
||||||
|
cookies / M tabs` or `fresh session`.
|
||||||
|
|
||||||
## User Handoff
|
## User Handoff
|
||||||
|
|
||||||
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
||||||
|
|||||||
+132
-39
@@ -73,6 +73,82 @@ export function shouldEnableChromiumSandbox(): boolean {
|
|||||||
return !(process.env.CI || process.env.CONTAINER || isRoot);
|
return !(process.env.CI || process.env.CONTAINER || isRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by probePoisonedChromiumBundle() when it finds — and removes — a
|
||||||
|
* Chromium bundle poisoned by the pre-v1.64 in-place rebrand (#2242).
|
||||||
|
* Call sites rethrow on `instanceof` (never message-string sniffing) so the
|
||||||
|
* actionable remediation reaches the user instead of being swallowed by the
|
||||||
|
* probe's fall-through-on-failure catch.
|
||||||
|
*/
|
||||||
|
export class PoisonedBundleError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PoisonedBundleError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-heal probe for bundles the OLD (pre-v1.64) rebrand code already
|
||||||
|
* poisoned (#2242): the mutation lives in the SHARED Playwright cache, so
|
||||||
|
* deleting the rebrand code fixes fresh installs only, and the documented
|
||||||
|
* deploy paths never run upgrade migrations. Detect the mutated plist and
|
||||||
|
* remove the bundle so the next `playwright install chromium` (or the
|
||||||
|
* upgrade migration) re-fetches a clean one.
|
||||||
|
*
|
||||||
|
* Removal scope: when the .app sits in the standard Playwright cache layout
|
||||||
|
* (chromium-<rev>/chrome-mac/<name>.app), the WHOLE chromium-<rev> revision
|
||||||
|
* dir is removed — Playwright's INSTALLATION_COMPLETE marker lives there,
|
||||||
|
* and `playwright install chromium` treats its presence as "is already
|
||||||
|
* downloaded", so removing only the .app would turn our own remediation
|
||||||
|
* command into a no-op that leaves the user with no browser at all. Outside
|
||||||
|
* that layout, the .app plus any sibling INSTALLATION_COMPLETE /
|
||||||
|
* DEPENDENCIES_VALIDATED markers are removed.
|
||||||
|
*
|
||||||
|
* Caller contract: pass ONLY Playwright-cache executables
|
||||||
|
* (chromium.executablePath()). A bundle supplied via GSTACK_CHROMIUM_PATH
|
||||||
|
* belongs to the wrapper/embedder — its plist legitimately says "GStack
|
||||||
|
* Browser" — and must never be deleted. Both call sites (launchHeaded and
|
||||||
|
* handoff) honor this, and as a second belt the probe refuses to act on the
|
||||||
|
* GSTACK_CHROMIUM_PATH executable itself.
|
||||||
|
*
|
||||||
|
* @param chromiumExecutablePath the Chromium binary inside the .app
|
||||||
|
* (…/<name>.app/Contents/MacOS/<name>), as returned by
|
||||||
|
* chromium.executablePath().
|
||||||
|
* @throws PoisonedBundleError after removing a poisoned bundle — the
|
||||||
|
* message carries the re-fetch command for the user.
|
||||||
|
*/
|
||||||
|
export function probePoisonedChromiumBundle(chromiumExecutablePath: string): void {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Belt to the caller contract: never act on the custom/embedder bundle.
|
||||||
|
const customPath = process.env.GSTACK_CHROMIUM_PATH;
|
||||||
|
if (customPath && path.resolve(chromiumExecutablePath) === path.resolve(customPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chromeContentsDir = path.resolve(path.dirname(chromiumExecutablePath), '..');
|
||||||
|
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
|
||||||
|
if (!fs.existsSync(chromePlist)) return;
|
||||||
|
if (!fs.readFileSync(chromePlist, 'utf-8').includes('GStack Browser')) return;
|
||||||
|
|
||||||
|
const appDir = path.resolve(chromeContentsDir, '..');
|
||||||
|
const revisionDir = path.resolve(appDir, '..', '..');
|
||||||
|
if (/^chromium-\d+$/.test(path.basename(revisionDir))) {
|
||||||
|
fs.rmSync(revisionDir, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
fs.rmSync(appDir, { recursive: true, force: true });
|
||||||
|
for (const marker of ['INSTALLATION_COMPLETE', 'DEPENDENCIES_VALIDATED']) {
|
||||||
|
fs.rmSync(path.join(path.dirname(appDir), marker), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new PoisonedBundleError(
|
||||||
|
'Chromium bundle was mutated by a previous gstack version (broken codesign seal — ' +
|
||||||
|
'GPU exit_code=5 on macOS 26). The poisoned bundle has been removed. ' +
|
||||||
|
'Re-fetch a clean one with: bunx playwright install chromium — then retry.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve why the underlying Chromium ChildProcess is going away.
|
* Resolve why the underlying Chromium ChildProcess is going away.
|
||||||
*
|
*
|
||||||
@@ -199,6 +275,15 @@ export class BrowserManager {
|
|||||||
|
|
||||||
// ─── Headed State ────────────────────────────────────────
|
// ─── Headed State ────────────────────────────────────────
|
||||||
private connectionMode: 'launched' | 'headed' = 'launched';
|
private connectionMode: 'launched' | 'headed' = 'launched';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired when a RUNNING daemon is promoted to headed mode (see handoff()),
|
||||||
|
* as opposed to starting headed. The server uses it to cancel the
|
||||||
|
* parent-process watchdog, which was registered on the assumption that mode
|
||||||
|
* is fixed at boot and would otherwise kill the freshly handed-off browser
|
||||||
|
* the next time the spawning shell exits.
|
||||||
|
*/
|
||||||
|
onHeadedPromotion?: () => void;
|
||||||
private intentionalDisconnect = false;
|
private intentionalDisconnect = false;
|
||||||
|
|
||||||
// ─── Tab Count Guardrail (D5 + Codex single-tab flag) ───────
|
// ─── Tab Count Guardrail (D5 + Codex single-tab flag) ───────
|
||||||
@@ -504,46 +589,32 @@ export class BrowserManager {
|
|||||||
// Used by GStack Browser.app to point at the bundled Chromium.
|
// Used by GStack Browser.app to point at the bundled Chromium.
|
||||||
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
||||||
|
|
||||||
// Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab.
|
// NOTE (#2242): the in-place "rebrand" that patched the Chromium .app's
|
||||||
// Patch the Chromium .app's Info.plist so macOS shows our name.
|
// Info.plist (global "Google Chrome for Testing" → "GStack Browser"
|
||||||
// This works for both dev mode (system Playwright cache) and .app bundle.
|
// replace) and overwrote its Resources/*.icns is deliberately GONE.
|
||||||
const chromePath = executablePath || chromium.executablePath();
|
// Chrome for Testing is a code-signed bundle: the global replace renamed
|
||||||
try {
|
// CFBundleExecutable to a binary that doesn't exist and the plist/icon
|
||||||
// Walk up from binary to the .app's Info.plist
|
// writes broke the codesign seal — GPU process exit_code=5, headed mode
|
||||||
// e.g. .../Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing
|
// dead on macOS 26 (#2242, #2138, #2139). Branding belongs in the
|
||||||
// → .../Google Chrome for Testing.app/Contents/Info.plist
|
// GStack Browser.app wrapper (GSTACK_CHROMIUM_PATH), never in a mutation
|
||||||
const chromeContentsDir = path.resolve(path.dirname(chromePath), '..');
|
// of the signed bundle. Do not reintroduce writes into the Chromium
|
||||||
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
|
// bundle here — browse/test/rebrand-signed-bundle.test.ts fails CI if
|
||||||
if (fs.existsSync(chromePlist)) {
|
// you do.
|
||||||
const plistContent = fs.readFileSync(chromePlist, 'utf-8');
|
//
|
||||||
if (plistContent.includes('Google Chrome for Testing')) {
|
// Self-heal for bundles the OLD code already poisoned: probe the
|
||||||
const patched = plistContent
|
// Playwright-cache bundle and remove it when the mutated plist is
|
||||||
.replace(/Google Chrome for Testing/g, 'GStack Browser');
|
// present (see probePoisonedChromiumBundle for the removal-scope
|
||||||
fs.writeFileSync(chromePlist, patched);
|
// rationale). Scoped to the Playwright cache copy — a
|
||||||
}
|
// GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder and is
|
||||||
// Replace Chromium's Dock icon with ours (Chromium's process owns the Dock icon)
|
// never probed.
|
||||||
const iconCandidates = [
|
if (!executablePath) {
|
||||||
path.join(__dirname, '..', '..', 'scripts', 'app', 'icon.icns'), // repo dev mode
|
try {
|
||||||
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'scripts', 'app', 'icon.icns'), // global install
|
probePoisonedChromiumBundle(chromium.executablePath());
|
||||||
];
|
} catch (err: unknown) {
|
||||||
const iconSrc = iconCandidates.find(p => fs.existsSync(p));
|
if (err instanceof PoisonedBundleError) throw err;
|
||||||
if (iconSrc) {
|
// Probe failures (no bundle yet, EACCES) fall through to launch,
|
||||||
const chromeResources = path.join(chromeContentsDir, 'Resources');
|
// which produces its own actionable error.
|
||||||
// Read original icon name from plist
|
|
||||||
const iconMatch = plistContent.match(/<key>CFBundleIconFile<\/key>\s*<string>([^<]+)<\/string>/);
|
|
||||||
let origIcon = iconMatch ? iconMatch[1] : 'app';
|
|
||||||
if (!origIcon.endsWith('.icns')) origIcon += '.icns';
|
|
||||||
const destIcon = path.join(chromeResources, origIcon);
|
|
||||||
try {
|
|
||||||
fs.copyFileSync(iconSrc, destIcon);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
|
||||||
// Non-fatal: app name stays as Chrome for Testing (ENOENT/EACCES expected)
|
|
||||||
if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build custom user agent: report as stock Chrome with the version
|
// Build custom user agent: report as stock Chrome with the version
|
||||||
@@ -1592,6 +1663,20 @@ export class BrowserManager {
|
|||||||
fs.mkdirSync(userDataDir, { recursive: true });
|
fs.mkdirSync(userDataDir, { recursive: true });
|
||||||
cleanSingletonLocks(userDataDir);
|
cleanSingletonLocks(userDataDir);
|
||||||
|
|
||||||
|
// Self-heal probe (#2242): handoff always launches the Playwright-cache
|
||||||
|
// bundle (this launchPersistentContext call passes no executablePath),
|
||||||
|
// so a bundle poisoned by the old in-place rebrand would GPU-crash here
|
||||||
|
// exactly like launchHeaded(). Same probe, same contract: a
|
||||||
|
// GSTACK_CHROMIUM_PATH bundle is never passed in. The rethrown typed
|
||||||
|
// error surfaces through the outer catch as the actionable
|
||||||
|
// "Cannot open headed browser" message, headless browser untouched.
|
||||||
|
try {
|
||||||
|
probePoisonedChromiumBundle(chromium.executablePath());
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof PoisonedBundleError) throw err;
|
||||||
|
// Probe failures (no bundle yet, EACCES) fall through to launch.
|
||||||
|
}
|
||||||
|
|
||||||
// T1: same automation-tell-stripping defaults as launchHeaded().
|
// T1: same automation-tell-stripping defaults as launchHeaded().
|
||||||
// The handoff path (headless → headed re-launch) takes the same
|
// The handoff path (headless → headed re-launch) takes the same
|
||||||
// anti-detection posture.
|
// anti-detection posture.
|
||||||
@@ -1624,6 +1709,14 @@ export class BrowserManager {
|
|||||||
this.tabSessions.clear();
|
this.tabSessions.clear();
|
||||||
this.connectionMode = 'headed';
|
this.connectionMode = 'headed';
|
||||||
|
|
||||||
|
// Promotion, not a headed boot. The server registered a parent-process
|
||||||
|
// watchdog because this daemon started headless, and that watchdog kills
|
||||||
|
// headed daemons when their parent exits — which for a CLI-spawned daemon
|
||||||
|
// is immediately. Without this the handed-off browser dies ~15s later,
|
||||||
|
// taking whatever the user was mid-way through (a login, an MFA prompt)
|
||||||
|
// with it.
|
||||||
|
this.onHeadedPromotion?.();
|
||||||
|
|
||||||
// Same Layer C stealth as launch()/launchHeaded(). Must run BEFORE
|
// Same Layer C stealth as launch()/launchHeaded(). Must run BEFORE
|
||||||
// restoreState() navigates so the init scripts apply to the restored
|
// restoreState() navigates so the init scripts apply to the restored
|
||||||
// pages — without this the handed-off browser had cmdline args but no
|
// pages — without this the handed-off browser had cmdline args but no
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ globalThis.Bun = {
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
// Node defaults windowsHide to false; Bun.spawn hides the console
|
// Node defaults windowsHide to false; Bun.spawn hides the console
|
||||||
// window. Without this the shim silently inverts the behavior on the
|
// window. Without this the shim silently inverts the behavior on the
|
||||||
// one platform it exists to serve. See the spawn() note below.
|
// one platform it exists to serve — every console child pops a window.
|
||||||
|
// Forwarded (not hardcoded) so an explicit windowsHide:false survives.
|
||||||
windowsHide: options.windowsHide !== false,
|
windowsHide: options.windowsHide !== false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,108 +98,17 @@ globalThis.Bun = {
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
// stdio:'ignore' silences a child's output but does not suppress its
|
// stdio:'ignore' silences a child's output but does not suppress its
|
||||||
// console window on Windows. The terminal-agent respawn (server.ts
|
// console window on Windows. The terminal-agent respawn (server.ts
|
||||||
// watchdog, 60s ticker) therefore popped a visible bun.exe window on
|
// watchdog, 60s ticker) popped a visible bun.exe window on every
|
||||||
// every respawn until this was forwarded.
|
// respawn until this was forwarded. Forwarded, not hardcoded, so an
|
||||||
|
// explicit windowsHide:false survives.
|
||||||
windowsHide: options.windowsHide !== false,
|
windowsHide: options.windowsHide !== false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drain stdout/stderr eagerly into in-memory buffers. Bun's spawn buffers
|
|
||||||
// these for the consumer; Node's Readables are pull-based, so if the caller
|
|
||||||
// awaits `proc.exited` before reading, anything past the OS pipe buffer
|
|
||||||
// (~16-64 KB) back-pressures the child until it blocks in write() and
|
|
||||||
// `exit` never fires. Eager draining keeps the pipes flowing regardless
|
|
||||||
// of read order; replay below is via fresh Web ReadableStreams.
|
|
||||||
//
|
|
||||||
// Cap the buffer so a runaway child can't OOM the server. 16 MB is
|
|
||||||
// generous: DPAPI outputs are tiny, tasklist is <1 KB, and the
|
|
||||||
// browser-skill consumer has its own 1 MB readCapped. Once the cap is
|
|
||||||
// reached we keep draining the pipe (so the child never blocks) but
|
|
||||||
// discard further bytes. Override via GSTACK_SPAWN_MAX_BUFFER (bytes).
|
|
||||||
const MAX_BUFFER = Math.max(
|
|
||||||
0,
|
|
||||||
parseInt(process.env.GSTACK_SPAWN_MAX_BUFFER || '', 10) || 16 * 1024 * 1024,
|
|
||||||
);
|
|
||||||
const drain = (stream) => {
|
|
||||||
if (!stream) return { done: Promise.resolve(), chunks: [], truncated: false };
|
|
||||||
const state = { chunks: [], bytes: 0, truncated: false };
|
|
||||||
const done = new Promise((resolve) => {
|
|
||||||
stream.on('data', (chunk) => {
|
|
||||||
if (state.bytes >= MAX_BUFFER) { state.truncated = true; return; }
|
|
||||||
if (state.bytes + chunk.length <= MAX_BUFFER) {
|
|
||||||
state.chunks.push(chunk);
|
|
||||||
state.bytes += chunk.length;
|
|
||||||
} else {
|
|
||||||
const remaining = MAX_BUFFER - state.bytes;
|
|
||||||
state.chunks.push(chunk.subarray(0, remaining));
|
|
||||||
state.bytes = MAX_BUFFER;
|
|
||||||
state.truncated = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Any terminal event resolves: 'end' on normal close, 'error' on a
|
|
||||||
// stream-level error, 'close' as the belt-and-suspenders for spawn
|
|
||||||
// failures where Node fires 'close' but neither 'end' nor 'error'.
|
|
||||||
stream.once('end', resolve);
|
|
||||||
stream.once('error', resolve);
|
|
||||||
stream.once('close', resolve);
|
|
||||||
});
|
|
||||||
return { done, chunks: state.chunks };
|
|
||||||
};
|
|
||||||
const stdoutDrain = drain(proc.stdout);
|
|
||||||
const stderrDrain = drain(proc.stderr);
|
|
||||||
|
|
||||||
// Bun's spawn exposes `proc.exited` as a Promise resolving to the exit
|
|
||||||
// code; several call sites — DPAPI decryption, isBrowserRunning,
|
|
||||||
// browser-skill-commands — `await proc.exited` directly or via
|
|
||||||
// Promise.race with a timeout. Without this, those awaits resolve to
|
|
||||||
// `undefined` immediately and the operation looks like a silent failure.
|
|
||||||
// Resolve only after both pipes have finished draining so consumers that
|
|
||||||
// read stdout AFTER awaiting exit see the full output, not a partial buffer.
|
|
||||||
const exited = new Promise((resolveExited) => {
|
|
||||||
let exitStatus;
|
|
||||||
proc.once('exit', (code, signal) => {
|
|
||||||
// Match Bun: exit code on normal exit; 128 + signal number on signal;
|
|
||||||
// 0 if neither was reported.
|
|
||||||
if (code !== null) exitStatus = code;
|
|
||||||
else if (signal) exitStatus = 128 + (require('os').constants.signals[signal] || 0);
|
|
||||||
else exitStatus = 0;
|
|
||||||
});
|
|
||||||
proc.once('error', () => {
|
|
||||||
if (exitStatus === undefined) exitStatus = 1;
|
|
||||||
});
|
|
||||||
// Wait for either 'exit' (normal child lifecycle) or 'error' (spawn
|
|
||||||
// failure — Node fires error without exit when the binary is missing).
|
|
||||||
// Either path resolves the lifecycle promise; without listening to both
|
|
||||||
// a spawn error hangs `await proc.exited` until the consumer's own
|
|
||||||
// timeout fires.
|
|
||||||
const lifecycle = new Promise((r) => {
|
|
||||||
proc.once('exit', r);
|
|
||||||
proc.once('error', r);
|
|
||||||
});
|
|
||||||
Promise.all([lifecycle, stdoutDrain.done, stderrDrain.done])
|
|
||||||
.then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Replay buffered output as a fresh Web ReadableStream. `start()` awaits
|
|
||||||
// the drain before enqueueing so `new Response(proc.stdout).text()` yields
|
|
||||||
// the complete output regardless of whether the consumer reads before or
|
|
||||||
// after awaiting `proc.exited`. Stream is single-shot (locked after one
|
|
||||||
// read), matching Bun's behavior.
|
|
||||||
const replay = (d) => new ReadableStream({
|
|
||||||
async start(controller) {
|
|
||||||
await d.done;
|
|
||||||
for (const chunk of d.chunks) {
|
|
||||||
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
|
||||||
}
|
|
||||||
controller.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pid: proc.pid,
|
pid: proc.pid,
|
||||||
stdout: replay(stdoutDrain),
|
stdout: proc.stdout,
|
||||||
stderr: replay(stderrDrain),
|
stderr: proc.stderr,
|
||||||
stdin: proc.stdin,
|
stdin: proc.stdin,
|
||||||
exited,
|
|
||||||
unref() { proc.unref(); },
|
unref() { proc.unref(); },
|
||||||
kill(signal) { proc.kill(signal); },
|
kill(signal) { proc.kill(signal); },
|
||||||
};
|
};
|
||||||
|
|||||||
+61
-63
@@ -14,7 +14,7 @@ import * as path from 'path';
|
|||||||
import { spawn as nodeSpawn } from 'child_process';
|
import { spawn as nodeSpawn } from 'child_process';
|
||||||
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
|
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
|
||||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled } from './config';
|
||||||
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
|
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
|
||||||
import { redactProxyUrl } from './proxy-redact';
|
import { redactProxyUrl } from './proxy-redact';
|
||||||
import { spawnTerminalAgent } from './terminal-agent-control';
|
import { spawnTerminalAgent } from './terminal-agent-control';
|
||||||
@@ -168,7 +168,7 @@ async function killServer(pid: number): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
Bun.spawnSync(
|
Bun.spawnSync(
|
||||||
['taskkill', '/PID', String(pid), '/T', '/F'],
|
['taskkill', '/PID', String(pid), '/T', '/F'],
|
||||||
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000 }
|
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000, windowsHide: true }
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code !== 'ENOENT') throw err;
|
if (err?.code !== 'ENOENT') throw err;
|
||||||
@@ -348,9 +348,9 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
|||||||
const launcherCode =
|
const launcherCode =
|
||||||
`const{spawn}=require('child_process');` +
|
`const{spawn}=require('child_process');` +
|
||||||
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
|
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
|
||||||
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
|
`{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
|
||||||
`${extraEnvStr})}).unref()`;
|
`${extraEnvStr})}).unref()`;
|
||||||
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
|
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
|
||||||
} else {
|
} else {
|
||||||
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
|
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
|
||||||
// loop — it does NOT call setsid(), so the spawned server stays in the
|
// loop — it does NOT call setsid(), so the spawned server stays in the
|
||||||
@@ -365,6 +365,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
|||||||
// the Windows path's rationale — same root cause, different OS API.
|
// the Windows path's rationale — same root cause, different OS API.
|
||||||
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
|
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
|
||||||
detached: true,
|
detached: true,
|
||||||
|
windowsHide: true,
|
||||||
stdio: ['ignore', 'ignore', 'ignore'],
|
stdio: ['ignore', 'ignore', 'ignore'],
|
||||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
|
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
|
||||||
}).unref();
|
}).unref();
|
||||||
@@ -408,31 +409,29 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
|||||||
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
|
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorCode(err: unknown): string {
|
export class ServerLockError extends Error {
|
||||||
if (err && typeof err === 'object' && 'code' in err) {
|
code: string;
|
||||||
const code = (err as { code?: unknown }).code;
|
constructor(code: string, lockPath: string, cause: string) {
|
||||||
if (typeof code === 'string' && code.length > 0) return code;
|
super(`E_SERVER_LOCK (${code}): cannot acquire ${lockPath} — ${cause}`);
|
||||||
|
this.name = 'ServerLockError';
|
||||||
|
this.code = code;
|
||||||
}
|
}
|
||||||
return 'UNKNOWN';
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(err: unknown): string {
|
|
||||||
if (err && typeof err === 'object' && 'message' in err) {
|
|
||||||
const message = (err as { message?: unknown }).message;
|
|
||||||
if (typeof message === 'string' && message.length > 0) return message;
|
|
||||||
}
|
|
||||||
return String(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
function logServerLockError(action: string, lockPath: string, err: unknown): void {
|
|
||||||
console.error(`[browse] acquireServerLock: unexpected ${errorCode(err)} while ${action} ${lockPath}: ${errorMessage(err)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquire an exclusive lockfile to prevent concurrent ensureServer() races (TOCTOU).
|
* Acquire an exclusive lockfile to prevent concurrent ensureServer() races (TOCTOU).
|
||||||
* Returns a cleanup function that releases the lock.
|
* Returns a cleanup function that releases the lock, or null when another
|
||||||
|
* LIVE process genuinely holds the lock (real contention).
|
||||||
|
*
|
||||||
|
* Error honesty (#1084): only EEXIST is contention. ENOENT (state dir
|
||||||
|
* missing) self-heals with one mkdir retry; every other errno (EACCES,
|
||||||
|
* ENOSPC, ...) throws ServerLockError with the real errno instead of
|
||||||
|
* reporting phantom "another process holds the lock" contention forever.
|
||||||
*/
|
*/
|
||||||
export function acquireServerLock(lockPath: string = `${config.stateFile}.lock`): (() => void) | null {
|
export function acquireServerLock(
|
||||||
|
lockPath: string = `${config.stateFile}.lock`,
|
||||||
|
depth = 0,
|
||||||
|
): (() => void) | null {
|
||||||
try {
|
try {
|
||||||
// 'wx' — create exclusively, fails if file already exists (atomic check-and-create)
|
// 'wx' — create exclusively, fails if file already exists (atomic check-and-create)
|
||||||
// Using string flag instead of numeric constants for Bun Windows compatibility
|
// Using string flag instead of numeric constants for Bun Windows compatibility
|
||||||
@@ -440,36 +439,35 @@ export function acquireServerLock(lockPath: string = `${config.stateFile}.lock`)
|
|||||||
fs.writeSync(fd, `${process.pid}\n`);
|
fs.writeSync(fd, `${process.pid}\n`);
|
||||||
fs.closeSync(fd);
|
fs.closeSync(fd);
|
||||||
return () => { safeUnlink(lockPath); };
|
return () => { safeUnlink(lockPath); };
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
if (errorCode(err) !== 'EEXIST') {
|
if (err?.code === 'ENOENT') {
|
||||||
logServerLockError('opening', lockPath, err);
|
// Lock dir missing — create it and retry once.
|
||||||
return null;
|
if (depth >= 1) throw new ServerLockError('ENOENT', lockPath, 'lock directory could not be created');
|
||||||
|
mkdirSecure(path.dirname(lockPath));
|
||||||
|
return acquireServerLock(lockPath, depth + 1);
|
||||||
}
|
}
|
||||||
|
if (err?.code !== 'EEXIST') {
|
||||||
// Lock already held — check if the holder is still alive
|
throw new ServerLockError(err?.code || 'UNKNOWN', lockPath, err?.message || String(err));
|
||||||
let holderPid: number;
|
}
|
||||||
|
// EEXIST — real contention. Check if the holder is still alive.
|
||||||
|
// Depth cap 5 bounds the stale-lock unlink/retry livelock.
|
||||||
try {
|
try {
|
||||||
holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
|
const holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
|
||||||
} catch (readErr) {
|
if (holderPid && isProcessAlive(holderPid)) {
|
||||||
if (errorCode(readErr) === 'ENOENT') {
|
return null; // Another live process holds the lock
|
||||||
return acquireServerLock(lockPath);
|
|
||||||
}
|
}
|
||||||
logServerLockError('reading holder PID from', lockPath, readErr);
|
// Stale lock — remove and retry
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (holderPid && isProcessAlive(holderPid)) {
|
|
||||||
return null; // Another live process holds the lock
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale lock — remove and retry
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(lockPath);
|
fs.unlinkSync(lockPath);
|
||||||
} catch (unlinkErr) {
|
if (depth >= 5) return null;
|
||||||
logServerLockError('removing stale', lockPath, unlinkErr);
|
return acquireServerLock(lockPath, depth + 1);
|
||||||
return null;
|
} catch (readErr: any) {
|
||||||
|
if (readErr?.code === 'ENOENT') {
|
||||||
|
// Lock vanished between open and read (holder released) — retry.
|
||||||
|
if (depth >= 5) return null;
|
||||||
|
return acquireServerLock(lockPath, depth + 1);
|
||||||
|
}
|
||||||
|
throw new ServerLockError(readErr?.code || 'UNKNOWN', lockPath, readErr?.message || String(readErr));
|
||||||
}
|
}
|
||||||
return acquireServerLock(lockPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,17 +654,7 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
// Connection error — server may have crashed, OR may just be busy.
|
// Connection error — server may have crashed, OR may just be busy.
|
||||||
// The compiled CLI runs on Bun, whose fetch reports a refused/dropped
|
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
|
||||||
// socket as err.code 'ConnectionRefused' / 'ConnectionClosed' (message
|
|
||||||
// "Unable to connect. Is the computer able to access the url?"), NOT Node's
|
|
||||||
// ECONNREFUSED/ECONNRESET. Match both, or daemon crashes leak the raw Bun
|
|
||||||
// error and exit 1 instead of triggering the busy-check/restart below.
|
|
||||||
const isConnError =
|
|
||||||
err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' ||
|
|
||||||
err.code === 'ConnectionRefused' || err.code === 'ConnectionClosed' ||
|
|
||||||
err.message?.includes('fetch failed') ||
|
|
||||||
err.message?.includes('Unable to connect');
|
|
||||||
if (isConnError) {
|
|
||||||
const oldState = readState();
|
const oldState = readState();
|
||||||
// #1781 busy-vs-dead: a single-threaded daemon under beacon/extension load
|
// #1781 busy-vs-dead: a single-threaded daemon under beacon/extension load
|
||||||
// can briefly stop answering HTTP while still alive. Before declaring a
|
// can briefly stop answering HTTP while still alive. Before declaring a
|
||||||
@@ -980,8 +968,12 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
|
|||||||
if (pairData.tunnel_url) {
|
if (pairData.tunnel_url) {
|
||||||
serverUrl = pairData.tunnel_url;
|
serverUrl = pairData.tunnel_url;
|
||||||
} else if (!localHost) {
|
} else if (!localHost) {
|
||||||
// No tunnel active. Check if ngrok is available and auto-start.
|
// No tunnel active. Remote tunneling (pair-agent) is opt-in — never
|
||||||
const ngrokAvailable = isNgrokAvailable();
|
// auto-start it unless the user explicitly enabled it, even if ngrok is
|
||||||
|
// installed and authed. First use goes through the /pair-agent skill's
|
||||||
|
// consent question, which sets the key.
|
||||||
|
const pairEnabled = isPairAgentEnabled();
|
||||||
|
const ngrokAvailable = pairEnabled && isNgrokAvailable();
|
||||||
if (ngrokAvailable) {
|
if (ngrokAvailable) {
|
||||||
console.log('[browse] ngrok detected. Starting tunnel...');
|
console.log('[browse] ngrok detected. Starting tunnel...');
|
||||||
try {
|
try {
|
||||||
@@ -1005,6 +997,14 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
|
|||||||
console.warn('[browse] Using localhost (same-machine only).\n');
|
console.warn('[browse] Using localhost (same-machine only).\n');
|
||||||
serverUrl = pairData.server_url;
|
serverUrl = pairData.server_url;
|
||||||
}
|
}
|
||||||
|
} else if (!pairEnabled) {
|
||||||
|
// Consent gate, not a tooling gap: when pair_agent is off, ngrok
|
||||||
|
// setup instructions can never fix it. Name the real remedy, with
|
||||||
|
// the same wording as the /tunnel/start 403 body in server.ts.
|
||||||
|
console.warn('[browse] No tunnel active: pair-agent is off (tunnel exposes this browser beyond the machine).');
|
||||||
|
console.warn('[browse] Instructions will use localhost (same-machine only).');
|
||||||
|
console.warn('[browse] For remote agents: enable once with `gstack-config set pair_agent on` — or run /pair-agent, which asks for consent and sets it.\n');
|
||||||
|
serverUrl = pairData.server_url;
|
||||||
} else {
|
} else {
|
||||||
console.warn('[browse] No tunnel active and ngrok is not installed/configured.');
|
console.warn('[browse] No tunnel active and ngrok is not installed/configured.');
|
||||||
console.warn('[browse] Instructions will use localhost (same-machine only).');
|
console.warn('[browse] Instructions will use localhost (same-machine only).');
|
||||||
@@ -1207,7 +1207,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
const newPid = spawnTerminalAgent({
|
const newPid = spawnTerminalAgent({
|
||||||
stateFile: config.stateFile,
|
stateFile: config.stateFile,
|
||||||
serverPort: newState.port,
|
serverPort: newState.port,
|
||||||
ownerPid: newState.pid,
|
|
||||||
cwd: config.projectDir,
|
cwd: config.projectDir,
|
||||||
});
|
});
|
||||||
if (newPid) {
|
if (newPid) {
|
||||||
@@ -1300,7 +1299,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
spawnTerminalAgent({
|
spawnTerminalAgent({
|
||||||
stateFile: config.stateFile,
|
stateFile: config.stateFile,
|
||||||
serverPort: respawned.port,
|
serverPort: respawned.port,
|
||||||
ownerPid: respawned.pid,
|
|
||||||
cwd: config.projectDir,
|
cwd: config.projectDir,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -187,6 +187,64 @@ export function resolveGstackHome(): string {
|
|||||||
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one key from the flat-YAML config store at <gstack home>/config.yaml
|
||||||
|
* (the shape bin/gstack-config writes: `key: value` lines). Tolerates
|
||||||
|
* optional single/double quotes around the value and a trailing `# comment`.
|
||||||
|
* Returns the unquoted value 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
|
||||||
|
* (pair_agent) and telemetry.ts (telemetry tier) both route through it so
|
||||||
|
* the two consent gates can never drift on parsing semantics.
|
||||||
|
*/
|
||||||
|
export function readGstackConfigYamlKey(key: string): string | null {
|
||||||
|
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
try {
|
||||||
|
const yaml = fs.readFileSync(path.join(resolveGstackHome(), 'config.yaml'), 'utf-8');
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the remote pair-agent (ngrok tunnel) surface opt-in enabled?
|
||||||
|
*
|
||||||
|
* Fail-closed: the tunnel exposes the local browser to the internet, so it
|
||||||
|
* stays OFF unless the user explicitly ran `gstack-config set pair_agent on`
|
||||||
|
* (the /pair-agent skill asks once on first use and sets it). Any read/parse
|
||||||
|
* failure (missing config, malformed JSON) also resolves OFF. The tunnel
|
||||||
|
* egress receipts cite this gate as their consent — it must exist and gate
|
||||||
|
* every activation point (#B6, fork port wave 2).
|
||||||
|
*
|
||||||
|
* Env override `GSTACK_PAIR_AGENT=on|off` wins (used by tests and as an
|
||||||
|
* emergency knob), mirroring the telemetry env-hint convention.
|
||||||
|
*/
|
||||||
|
export function isPairAgentEnabled(): boolean {
|
||||||
|
const env = process.env.GSTACK_PAIR_AGENT;
|
||||||
|
if (env === 'on') return true;
|
||||||
|
if (env === 'off') return false;
|
||||||
|
// Canonical store: ~/.gstack/config.yaml (flat `key: value` lines, written
|
||||||
|
// by bin/gstack-config — which is what the /pair-agent consent step runs).
|
||||||
|
// The fork read config.json; porting that verbatim would have made the gate
|
||||||
|
// silently un-enableable on main. JSON kept as a fallback shape only.
|
||||||
|
// Anything other than exactly on/off (missing key, malformed value) falls
|
||||||
|
// through to the JSON fallback and ultimately fails closed.
|
||||||
|
const yamlValue = readGstackConfigYamlKey('pair_agent');
|
||||||
|
if (yamlValue === 'on') return true;
|
||||||
|
if (yamlValue === 'off') return false;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(path.join(resolveGstackHome(), 'config.json'), 'utf-8');
|
||||||
|
return JSON.parse(raw)?.pair_agent === 'on';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the Chromium profile directory.
|
* Resolve the Chromium profile directory.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -526,6 +526,7 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
|
|||||||
].join('; ');
|
].join('; ');
|
||||||
|
|
||||||
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
|
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
|
||||||
|
windowsHide: true,
|
||||||
stdin: 'pipe',
|
stdin: 'pipe',
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
@@ -778,7 +779,7 @@ function isBrowserRunning(browserName: string): Promise<boolean> {
|
|||||||
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
|
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
|
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
|
||||||
stdout: 'pipe', stderr: 'pipe',
|
stdout: 'pipe', stderr: 'pipe', windowsHide: true,
|
||||||
});
|
});
|
||||||
proc.exited.then(async () => {
|
proc.exited.then(async () => {
|
||||||
const out = await new Response(proc.stdout).text();
|
const out = await new Response(proc.stdout).text();
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
const IS_WINDOWS = process.platform === 'win32';
|
||||||
|
|
||||||
// ─── Filesystem ────────────────────────────────────────────────
|
// ─── Filesystem ────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Remove a file, ignoring ENOENT (already gone). Rethrows other errors. */
|
/** Remove a file, ignoring ENOENT (already gone). Rethrows other errors. */
|
||||||
@@ -34,39 +36,23 @@ export function safeKill(pid: number, signal: NodeJS.Signals | number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Check if a PID is alive. Pure boolean probe — returns false for ALL errors. */
|
||||||
* Check if a PID is alive. Pure boolean probe — never throws.
|
|
||||||
*
|
|
||||||
* Signal 0 on every platform. Node and Bun both map `process.kill(pid, 0)` to
|
|
||||||
* an OpenProcess existence check on Windows, so the POSIX idiom is portable
|
|
||||||
* here — no shell-out needed.
|
|
||||||
*
|
|
||||||
* Windows used to shell out to `tasklist /FI "PID eq <pid>"` and string-match
|
|
||||||
* the CSV. That was wrong in two ways, both of which bit in production:
|
|
||||||
*
|
|
||||||
* 1. FALSE NEGATIVES UNDER LOAD. `tasklist` takes ~700-1700ms on an idle
|
|
||||||
* Windows box and far longer under memory pressure. A Bun.spawnSync that
|
|
||||||
* hits its `timeout` still RETURNS, carrying partial stdout — so the
|
|
||||||
* `.includes()` match came back false and a LIVE process was reported
|
|
||||||
* dead. Callers (killAgentByRecord, the terminal-agent watchdog) then
|
|
||||||
* skipped the kill and respawned around the survivor, leaking one
|
|
||||||
* terminal-agent per watchdog tick. The leak was self-reinforcing: every
|
|
||||||
* orphan added memory pressure, which made the next tasklist slower,
|
|
||||||
* which produced the next false negative.
|
|
||||||
* 2. A VISIBLE CONSOLE WINDOW per probe (no windowsHide), so a background
|
|
||||||
* watchdog strobed a terminal into the foreground every 60 seconds.
|
|
||||||
*
|
|
||||||
* Signal 0 is ~74,000x faster (0.004ms vs 270ms, measured), spawns nothing,
|
|
||||||
* and cannot time out.
|
|
||||||
*
|
|
||||||
* EPERM means the process EXISTS but we lack rights to signal it. That is
|
|
||||||
* alive; returning false there would reintroduce failure mode 1.
|
|
||||||
*/
|
|
||||||
export function isProcessAlive(pid: number): boolean {
|
export function isProcessAlive(pid: number): boolean {
|
||||||
|
if (IS_WINDOWS) {
|
||||||
|
try {
|
||||||
|
const result = Bun.spawnSync(
|
||||||
|
['tasklist', '/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'],
|
||||||
|
{ stdout: 'pipe', stderr: 'pipe', timeout: 3000, windowsHide: true }
|
||||||
|
);
|
||||||
|
return result.stdout.toString().includes(`"${pid}"`);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
return true;
|
return true;
|
||||||
} catch (err: any) {
|
} catch {
|
||||||
return err?.code === 'EPERM';
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function restrictFilePermissions(filePath: string): void {
|
|||||||
execFileSync(
|
execFileSync(
|
||||||
'icacls',
|
'icacls',
|
||||||
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
|
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
|
||||||
{ stdio: 'ignore' },
|
{ stdio: 'ignore', windowsHide: true },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
warnIcaclsFailure(filePath, err);
|
warnIcaclsFailure(filePath, err);
|
||||||
@@ -147,7 +147,7 @@ export function restrictDirectoryPermissions(dirPath: string): void {
|
|||||||
execFileSync(
|
execFileSync(
|
||||||
'icacls',
|
'icacls',
|
||||||
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
|
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
|
||||||
{ stdio: 'ignore' },
|
{ stdio: 'ignore', windowsHide: true },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
warnIcaclsFailure(dirPath, err);
|
warnIcaclsFailure(dirPath, err);
|
||||||
@@ -185,14 +185,54 @@ export function appendSecureFile(
|
|||||||
if (!existed) restrictFilePermissions(filePath);
|
if (!existed) restrictFilePermissions(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows only: probe whether the current process can actually list the
|
||||||
|
* directory. `fs.accessSync` doesn't consult NTFS ACLs on Windows, so a
|
||||||
|
* real readdir is the only honest check.
|
||||||
|
*/
|
||||||
|
function canListDir(dirPath: string): boolean {
|
||||||
|
try { fs.readdirSync(dirPath); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows only: repair a broken DACL on a state directory (#1605).
|
||||||
|
*
|
||||||
|
* `icacls /inheritance:r /grant:r <user>:(F)` is a single command, but the
|
||||||
|
* two halves can partially fail: inheritance gets stripped while the user
|
||||||
|
* grant doesn't resolve (localized account names, domain accounts, roaming
|
||||||
|
* profiles). The result is a DACL with no usable ACE — often just a machine
|
||||||
|
* SID — and the client can't read its own state files. `/reset` restores
|
||||||
|
* inherited ACLs from the parent, making the directory functional again.
|
||||||
|
* Functional-but-unhardened beats hardened-but-unusable.
|
||||||
|
*/
|
||||||
|
export function repairBrokenDacl(dirPath: string): void {
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
try {
|
||||||
|
execFileSync('icacls', [dirPath, '/reset', '/T', '/C', '/Q'], { stdio: 'ignore', windowsHide: true });
|
||||||
|
} catch (err) {
|
||||||
|
warnIcaclsFailure(dirPath, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `mkdir -p` with owner-only directory permissions, cross-platform.
|
* `mkdir -p` with owner-only directory permissions, cross-platform.
|
||||||
* Replaces `fs.mkdirSync(path, { recursive: true, mode: 0o700 })` + Windows ACL.
|
* Replaces `fs.mkdirSync(path, { recursive: true, mode: 0o700 })` + Windows ACL.
|
||||||
* Safe to call on an existing directory — re-applies the ACL idempotently.
|
* Safe to call on an existing directory — re-applies the ACL idempotently.
|
||||||
|
*
|
||||||
|
* Windows: after applying the restricted ACL, verifies the directory is
|
||||||
|
* still listable by this process and repairs a broken DACL (#1605) if not.
|
||||||
*/
|
*/
|
||||||
export function mkdirSecure(dirPath: string): void {
|
export function mkdirSecure(dirPath: string): void {
|
||||||
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
||||||
restrictDirectoryPermissions(dirPath);
|
restrictDirectoryPermissions(dirPath);
|
||||||
|
if (process.platform === 'win32' && !canListDir(dirPath)) {
|
||||||
|
repairBrokenDacl(dirPath);
|
||||||
|
restrictDirectoryPermissions(dirPath);
|
||||||
|
// If re-hardening broke access again, reset once more and leave the
|
||||||
|
// directory with inherited ACLs — the client must be able to read
|
||||||
|
// its own state.
|
||||||
|
if (!canListDir(dirPath)) repairBrokenDacl(dirPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+15
-23
@@ -20,6 +20,7 @@ import * as path from 'path';
|
|||||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||||
import { TEMP_DIR } from './platform';
|
import { TEMP_DIR } from './platform';
|
||||||
import { resolveConfig } from './config';
|
import { resolveConfig } from './config';
|
||||||
|
import { filterSessionCookies } from './session-persist';
|
||||||
import type { Frame } from 'playwright';
|
import type { Frame } from 'playwright';
|
||||||
|
|
||||||
/** Tokenize a pipe segment respecting double-quoted strings. */
|
/** Tokenize a pipe segment respecting double-quoted strings. */
|
||||||
@@ -421,25 +422,18 @@ export async function handleMetaCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'stop': {
|
case 'stop': {
|
||||||
// Defer shutdown so the response flushes before process.exit() (same
|
// Return the acknowledgement before closing the listener. Shutting down
|
||||||
// reason as 'restart' below). Otherwise the CLI sees a dropped socket;
|
// inline resets the CLI's fetch, which it reasonably interprets as a
|
||||||
// and now that connection-loss triggers the crash-retry path, that would
|
// crash and then restarts the daemon it was asked to stop.
|
||||||
// resurrect a fresh daemon only to stop it again. Send the 200, then exit.
|
setTimeout(() => { void shutdown(); }, 25).unref?.();
|
||||||
setTimeout(() => { void shutdown(); }, 100);
|
|
||||||
return 'Server stopped';
|
return 'Server stopped';
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'restart': {
|
case 'restart': {
|
||||||
// Signal that we want a restart — the CLI will detect exit and restart.
|
// Signal that we want a restart — the CLI will detect exit and restart
|
||||||
console.log('[browse] Restart requested. Exiting for CLI to restart.');
|
console.log('[browse] Restart requested. Exiting for CLI to restart.');
|
||||||
// Defer shutdown one tick so this HTTP response actually flushes before
|
setTimeout(() => { void shutdown(); }, 25).unref?.();
|
||||||
// process.exit(). shutdown() exits inline (server.ts), so the old
|
return 'Restarting...';
|
||||||
// `await shutdown(); return 'Restarting...'` never sent a response — the
|
|
||||||
// CLI saw a dropped socket and `browse restart` errored out. The daemon
|
|
||||||
// now exits ~100ms after the CLI gets its 200; the next browse command
|
|
||||||
// lazily cold-starts a fresh one.
|
|
||||||
setTimeout(() => { void shutdown(); }, 100);
|
|
||||||
return 'Restarting... (daemon exiting; next browse command starts a fresh one)';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Visual ────────────────────────────────────────
|
// ─── Visual ────────────────────────────────────────
|
||||||
@@ -939,15 +933,13 @@ export async function handleMetaCommand(
|
|||||||
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
|
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
|
||||||
throw new Error('Invalid state file: expected cookies and pages arrays');
|
throw new Error('Invalid state file: expected cookies and pages arrays');
|
||||||
}
|
}
|
||||||
// Validate and filter cookies — reject malformed or internal-network cookies
|
// Validate and filter cookies via the shared hygiene filter in
|
||||||
const validatedCookies = data.cookies.filter((c: any) => {
|
// session-persist.ts (isInternalCookieDomain): rejects malformed
|
||||||
if (typeof c !== 'object' || !c) return false;
|
// cookies and internal-network domains — localhost, *.internal,
|
||||||
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
|
// loopback literals (127.x, ::1), and link-local/cloud-metadata
|
||||||
if (typeof c.domain !== 'string' || !c.domain) return false;
|
// (169.254.x) — that a tampered state file could use to reach local
|
||||||
const d = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
|
// services or the metadata endpoint.
|
||||||
if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false;
|
const validatedCookies = filterSessionCookies(data.cookies);
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (validatedCookies.length < data.cookies.length) {
|
if (validatedCookies.length < data.cookies.length) {
|
||||||
console.warn(`[browse] Filtered ${data.cookies.length - validatedCookies.length} invalid cookies from state file`);
|
console.warn(`[browse] Filtered ${data.cookies.length - validatedCookies.length} invalid cookies from state file`);
|
||||||
}
|
}
|
||||||
|
|||||||
+187
-31
@@ -35,7 +35,11 @@ import {
|
|||||||
isRootToken, checkConnectRateLimit, type TokenInfo,
|
isRootToken, checkConnectRateLimit, type TokenInfo,
|
||||||
} from './token-registry';
|
} from './token-registry';
|
||||||
import { validateTempPath } from './path-security';
|
import { validateTempPath } from './path-security';
|
||||||
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks } from './config';
|
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config';
|
||||||
|
import {
|
||||||
|
isSessionPersistEnabled, persistSessionState, restoreSessionState,
|
||||||
|
sessionPersistIntervalMs, SESSION_STATE_FILE,
|
||||||
|
} from './session-persist';
|
||||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||||
import { createSseEndpoint } from './sse-helpers';
|
import { createSseEndpoint } from './sse-helpers';
|
||||||
import { initAuditLog, writeAuditEntry } from './audit';
|
import { initAuditLog, writeAuditEntry } from './audit';
|
||||||
@@ -728,6 +732,12 @@ const idleCheckInterval = setInterval(idleCheckTick, 60_000);
|
|||||||
// dual-instance fix` describe block for usage.
|
// dual-instance fix` describe block for usage.
|
||||||
export const __testInternals__ = {
|
export const __testInternals__ = {
|
||||||
idleCheckTick,
|
idleCheckTick,
|
||||||
|
// Watchdog seams (watchdog.test.ts): drive the 15s poll against an
|
||||||
|
// arbitrary (dead) PID, trigger the handoff-promotion suppression exactly
|
||||||
|
// as onHeadedPromotion does, and reset the latches between tests.
|
||||||
|
parentWatchdogTick,
|
||||||
|
suppressHeadedParentShutdown,
|
||||||
|
resetParentWatchdogState: () => { headedParentShutdownSuppressed = false; parentGone = false; },
|
||||||
setTunnelActive: (v: boolean) => { tunnelActive = v; },
|
setTunnelActive: (v: boolean) => { tunnelActive = v; },
|
||||||
setLastActivity: (t: number) => { lastActivity = t; },
|
setLastActivity: (t: number) => { lastActivity = t; },
|
||||||
formatExplicitPortUnavailableError,
|
formatExplicitPortUnavailableError,
|
||||||
@@ -757,40 +767,83 @@ const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
|
|||||||
// the closure every 15s. The CLI's connect path sets BROWSE_HEADED=1 + PID=0,
|
// the closure every 15s. The CLI's connect path sets BROWSE_HEADED=1 + PID=0,
|
||||||
// so this branch is the normal path for /open-gstack-browser.
|
// so this branch is the normal path for /open-gstack-browser.
|
||||||
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
|
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
|
||||||
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
// Runtime promotion to headed (`handoff`) must NOT clear this interval — the
|
||||||
let parentGone = false;
|
// same tick is the tunnel-orphan reaper, and idle timeout is disabled in
|
||||||
setInterval(() => {
|
// tunnel mode, so parent death is the ONLY thing that reaps an
|
||||||
try {
|
// internet-exposed daemon after handoff → resume → /pair-agent. Promotion
|
||||||
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
|
// sets this suppress flag instead; the tick re-reads it (and tunnelActive)
|
||||||
} catch {
|
// every pass. See suppressHeadedParentShutdown() below.
|
||||||
// Parent exited. Resolution order:
|
let headedParentShutdownSuppressed = false;
|
||||||
// 1. Active cookie picker (one-time code or session live)? Stay alive
|
// Latch for the one-time "parent exited, staying alive" log line.
|
||||||
// regardless of mode — tearing down the server mid-import leaves the
|
let parentGone = false;
|
||||||
// picker UI with a stale "Failed to fetch" error.
|
// Named + parameterized (default: the boot-time env PID) so watchdog.test.ts
|
||||||
// 2. Headed / tunnel mode? Shutdown. The idle timeout doesn't apply in
|
// can drive the tick deterministically via __testInternals__, mirroring
|
||||||
// these modes (see idleCheckInterval above — both early-return), so
|
// idleCheckTick above. setInterval invokes it with no args in production.
|
||||||
// ignoring parent death here would leak orphan daemons after
|
function parentWatchdogTick(parentPid: number = BROWSE_PARENT_PID): void {
|
||||||
// /pair-agent or /open-gstack-browser sessions.
|
try {
|
||||||
// 3. Normal (headless) mode? Stay alive. Claude Code's Bash tool kills
|
process.kill(parentPid, 0); // signal 0 = existence check only, no signal sent
|
||||||
// the parent shell between invocations. The idle timeout (30 min)
|
} catch {
|
||||||
// handles eventual cleanup.
|
// Parent exited. Resolution order:
|
||||||
if (hasActivePicker()) return;
|
// 1. Active cookie picker (one-time code or session live)? Stay alive
|
||||||
const headed = activeBrowserManager.getConnectionMode() === 'headed';
|
// regardless of mode — tearing down the server mid-import leaves the
|
||||||
if (headed || tunnelActive) {
|
// picker UI with a stale "Failed to fetch" error.
|
||||||
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
// 2. Headed (unless suppressed by a runtime promotion) / tunnel mode?
|
||||||
activeShutdown?.();
|
// Shutdown. The idle timeout doesn't apply in these modes (see
|
||||||
} else if (!parentGone) {
|
// idleCheckInterval above — both early-return), so ignoring parent
|
||||||
parentGone = true;
|
// death here would leak orphan daemons after /pair-agent or
|
||||||
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited (server stays alive, idle timeout will clean up)`);
|
// /open-gstack-browser sessions.
|
||||||
}
|
// 3. Normal (headless) mode, or headed-by-promotion? Stay alive. Claude
|
||||||
|
// Code's Bash tool kills the parent shell between invocations, and a
|
||||||
|
// promoted daemon's user owns the window lifecycle. The idle timeout
|
||||||
|
// (30 min) handles eventual cleanup.
|
||||||
|
if (hasActivePicker()) return;
|
||||||
|
const headed = activeBrowserManager.getConnectionMode() === 'headed'
|
||||||
|
&& !headedParentShutdownSuppressed;
|
||||||
|
if (headed || tunnelActive) {
|
||||||
|
console.log(`[browse] Parent process ${parentPid} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||||||
|
activeShutdown?.();
|
||||||
|
} else if (!parentGone) {
|
||||||
|
parentGone = true;
|
||||||
|
console.log(`[browse] Parent process ${parentPid} exited (server stays alive, idle timeout will clean up)`);
|
||||||
}
|
}
|
||||||
}, 15_000);
|
}
|
||||||
|
}
|
||||||
|
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
||||||
|
setInterval(parentWatchdogTick, 15_000);
|
||||||
} else if (IS_HEADED_WATCHDOG) {
|
} else if (IS_HEADED_WATCHDOG) {
|
||||||
console.log('[browse] Parent-process watchdog disabled (headed mode)');
|
console.log('[browse] Parent-process watchdog disabled (headed mode)');
|
||||||
} else if (BROWSE_PARENT_PID === 0) {
|
} else if (BROWSE_PARENT_PID === 0) {
|
||||||
console.log('[browse] Parent-process watchdog disabled (BROWSE_PARENT_PID=0)');
|
console.log('[browse] Parent-process watchdog disabled (BROWSE_PARENT_PID=0)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppress the headed-mode parent-death shutdown after a runtime promotion.
|
||||||
|
*
|
||||||
|
* The watchdog's contract is "headless daemons outlive their parent, headed ones
|
||||||
|
* do not" — reasonable at boot, when mode is fixed by env. `handoff` breaks that
|
||||||
|
* assumption: it swaps in a headed context on a RUNNING daemon
|
||||||
|
* (browser-manager.ts, connectionMode = 'headed') without a restart, so a daemon
|
||||||
|
* that legitimately registered a watchdog is suddenly on the fatal side of the
|
||||||
|
* branch. The parent is typically a short-lived shell — Claude Code's Bash tool
|
||||||
|
* kills one after every invocation — so the next 15s poll shuts the daemon down,
|
||||||
|
* discarding whatever the user was handed off to do, such as a login.
|
||||||
|
*
|
||||||
|
* Once promoted, the user owns the window lifecycle exactly as if the daemon had
|
||||||
|
* been started headed, which is the case the env guards already exempt.
|
||||||
|
*
|
||||||
|
* A flag, NOT clearInterval: the tick doubles as the tunnel-orphan reaper
|
||||||
|
* (its `tunnelActive` branch), and idle timeout is disabled in tunnel mode —
|
||||||
|
* clearing the whole interval here left handoff → resume → /pair-agent with
|
||||||
|
* an internet-exposed daemon nothing could ever reap. After promotion, parent
|
||||||
|
* death no longer kills the daemon for BEING HEADED, but still kills it when
|
||||||
|
* a tunnel is active.
|
||||||
|
*/
|
||||||
|
function suppressHeadedParentShutdown(): void {
|
||||||
|
if (headedParentShutdownSuppressed) return;
|
||||||
|
headedParentShutdownSuppressed = true;
|
||||||
|
console.log('[browse] Parent-death headed shutdown suppressed (promoted to headed at runtime); watchdog stays armed as the tunnel-orphan reaper');
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Command Sets (from commands.ts — single source of truth) ───
|
// ─── Command Sets (from commands.ts — single source of truth) ───
|
||||||
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
||||||
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
||||||
@@ -833,6 +886,11 @@ function emitInspectorEvent(event: any): void {
|
|||||||
|
|
||||||
// ─── Server ────────────────────────────────────────────────────
|
// ─── Server ────────────────────────────────────────────────────
|
||||||
const browserManager = new BrowserManager();
|
const browserManager = new BrowserManager();
|
||||||
|
// Declared here rather than beside suppressHeadedParentShutdown: that function
|
||||||
|
// sits with the watchdog it gates, which is above this line, and binding it up
|
||||||
|
// there would touch `browserManager` in its temporal dead zone — aborting
|
||||||
|
// module evaluation and leaving every later const uninitialized.
|
||||||
|
browserManager.onHeadedPromotion = suppressHeadedParentShutdown;
|
||||||
// Indirection for embedders. Module-level handlers (idleCheckTick, parent
|
// Indirection for embedders. Module-level handlers (idleCheckTick, parent
|
||||||
// watchdog, SIGTERM) read activeBrowserManager so that buildFetchHandler can
|
// watchdog, SIGTERM) read activeBrowserManager so that buildFetchHandler can
|
||||||
// retarget them at a caller-supplied BrowserManager. Symmetric with the
|
// retarget them at a caller-supplied BrowserManager. Symmetric with the
|
||||||
@@ -851,6 +909,11 @@ let activeBrowserManager: BrowserManager = browserManager;
|
|||||||
// any buildFetchHandler call rebinds onDisconnect onto the cfg instance.
|
// any buildFetchHandler call rebinds onDisconnect onto the cfg instance.
|
||||||
browserManager.onDisconnect = (code) => activeShutdown?.(code ?? 2);
|
browserManager.onDisconnect = (code) => activeShutdown?.(code ?? 2);
|
||||||
let isShuttingDown = false;
|
let isShuttingDown = false;
|
||||||
|
// Session-persist ticker handle. Registered in start() (module scope so the
|
||||||
|
// factory's shutdown() can reach it), cleared by shutdown() BEFORE the final
|
||||||
|
// snapshot — a tick landing during browser teardown would otherwise overwrite
|
||||||
|
// the good final snapshot with a degraded one (zero tabs).
|
||||||
|
let sessionPersistInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
type PortCheckResult =
|
type PortCheckResult =
|
||||||
| { available: true }
|
| { available: true }
|
||||||
@@ -1692,8 +1755,33 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
clearInterval(flushInterval);
|
clearInterval(flushInterval);
|
||||||
clearInterval(idleCheckInterval);
|
clearInterval(idleCheckInterval);
|
||||||
if (agentWatchdogInterval) clearInterval(agentWatchdogInterval);
|
if (agentWatchdogInterval) clearInterval(agentWatchdogInterval);
|
||||||
|
// Stop the session-persist ticker BEFORE the final snapshot below —
|
||||||
|
// paired with the isShuttingDown gate inside the tick, this guarantees
|
||||||
|
// no interval snapshot can race the final one during teardown.
|
||||||
|
if (sessionPersistInterval) {
|
||||||
|
clearInterval(sessionPersistInterval);
|
||||||
|
sessionPersistInterval = null;
|
||||||
|
}
|
||||||
await flushBuffers();
|
await flushBuffers();
|
||||||
|
|
||||||
|
// Final session snapshot before the browser goes away (#778). Best
|
||||||
|
// effort with a hard 2s deadline: shutdown must never hang on a wedged
|
||||||
|
// page.evaluate — after the deadline we proceed to browser close and let
|
||||||
|
// the previous interval snapshot stand (atomic writes guarantee it's
|
||||||
|
// intact). The .catch is attached to the persist promise itself so a
|
||||||
|
// late rejection after losing the race can't become an unhandled
|
||||||
|
// rejection.
|
||||||
|
if (isSessionPersistEnabled()) {
|
||||||
|
const finalSnapshot = persistSessionState(cfgBrowserManager, path.join(config.stateDir, SESSION_STATE_FILE))
|
||||||
|
.catch((err: any) => {
|
||||||
|
console.warn(`[browse] SESSION_PERSIST_FAILED at shutdown: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
await Promise.race([
|
||||||
|
finalSnapshot,
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, 2_000)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
await cfgBrowserManager.close();
|
await cfgBrowserManager.close();
|
||||||
|
|
||||||
cleanSingletonLocks(resolveChromiumProfile());
|
cleanSingletonLocks(resolveChromiumProfile());
|
||||||
@@ -1723,6 +1811,12 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
// after 30 min of HTTP idle because the dead module-level instance still
|
// after 30 min of HTTP idle because the dead module-level instance still
|
||||||
// reports connectionMode === 'launched'.
|
// reports connectionMode === 'launched'.
|
||||||
activeBrowserManager = cfgBrowserManager;
|
activeBrowserManager = cfgBrowserManager;
|
||||||
|
// Same reason as above: the watchdog reads activeBrowserManager, so the
|
||||||
|
// instance that can promote itself to headed must be the one that can
|
||||||
|
// suppress the headed parent-death branch. An embedder-supplied manager
|
||||||
|
// otherwise promotes silently and the watchdog keeps shutting down on a
|
||||||
|
// promotion it can no longer see.
|
||||||
|
cfgBrowserManager.onHeadedPromotion = suppressHeadedParentShutdown;
|
||||||
|
|
||||||
// Wire the cfg-instance's onDisconnect to run shutdown when the user
|
// Wire the cfg-instance's onDisconnect to run shutdown when the user
|
||||||
// closes the headed browser window. CHAIN any caller-provided handler
|
// closes the headed browser window. CHAIN any caller-provided handler
|
||||||
@@ -2435,6 +2529,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
status: 403, headers: { 'Content-Type': 'application/json' },
|
status: 403, headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
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 new Response(JSON.stringify({
|
||||||
|
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',
|
||||||
|
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
if (tunnelActive && tunnelUrl && tunnelServer) {
|
if (tunnelActive && tunnelUrl && tunnelServer) {
|
||||||
// Verify tunnel is still alive before returning cached URL.
|
// Verify tunnel is still alive before returning cached URL.
|
||||||
// Probe GET /connect (the only unauth-reachable path on the tunnel
|
// Probe GET /connect (the only unauth-reachable path on the tunnel
|
||||||
@@ -2471,7 +2573,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
const started = await startTunnel({
|
const started = await startTunnel({
|
||||||
fetchHandler: makeFetchHandler('tunnel'),
|
fetchHandler: makeFetchHandler('tunnel'),
|
||||||
authtoken,
|
authtoken,
|
||||||
consent: 'pair_agent=on',
|
consent: 'pair_agent=on (isPairAgentEnabled gate at /tunnel/start)',
|
||||||
});
|
});
|
||||||
if (!started.ok) {
|
if (!started.ok) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
@@ -3089,6 +3191,58 @@ export async function start() {
|
|||||||
|
|
||||||
browserManager.serverPort = port;
|
browserManager.serverPort = port;
|
||||||
|
|
||||||
|
// ─── Opt-in session persistence (#778 class) ─────────────────
|
||||||
|
// BROWSE_PERSIST_STATE=1: restore cookies/storage/tabs from the last
|
||||||
|
// snapshot, then keep snapshotting on an interval. Launched mode only —
|
||||||
|
// the headed persistent profile owns its own state. The final snapshot at
|
||||||
|
// clean shutdown lives in buildFetchHandler's shutdown().
|
||||||
|
//
|
||||||
|
// Runs AFTER Bun.serve() + the state-file write, in the BACKGROUND:
|
||||||
|
// restore re-creates tabs sequentially with up-to-15s goto timeouts while
|
||||||
|
// the CLI's readiness probe gives up at 8s — one slow/unreachable saved
|
||||||
|
// URL must never make every `$B` command report "Server failed to start".
|
||||||
|
// Fire-and-forget: a restore failure is logged and never affects the
|
||||||
|
// daemon.
|
||||||
|
if (!skipBrowser && isSessionPersistEnabled() && browserManager.getConnectionMode() === 'launched') {
|
||||||
|
const sessionStatePath = path.join(config.stateDir, SESSION_STATE_FILE);
|
||||||
|
restoreSessionState(browserManager, sessionStatePath)
|
||||||
|
.then((restored) => {
|
||||||
|
if (restored) {
|
||||||
|
// Counts come from the deserialized snapshot itself — no extra
|
||||||
|
// saveState() round-trip against pages that may still be loading.
|
||||||
|
console.log(`[browse] Session state restored: ${restored.cookies.length} cookies / ${restored.pages.length} tabs (BROWSE_PERSIST_STATE=1)`);
|
||||||
|
} else {
|
||||||
|
console.log('[browse] Session persistence on; no prior state — fresh session (BROWSE_PERSIST_STATE=1)');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
console.warn(`[browse] SESSION_RESTORE_FAILED: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
let persistWarned = false;
|
||||||
|
// In-flight guard: never start a new snapshot while the previous one is
|
||||||
|
// still pending (a slow page.evaluate would otherwise pile up ticks).
|
||||||
|
let persistInFlight = false;
|
||||||
|
sessionPersistInterval = setInterval(() => {
|
||||||
|
// Shutdown gate (belt; shutdown()'s clearInterval is the suspenders):
|
||||||
|
// a tick that fires during browser teardown snapshots a degraded state
|
||||||
|
// (zero tabs) over the good final snapshot.
|
||||||
|
if (isShuttingDown) return;
|
||||||
|
if (persistInFlight) return; // skip the tick
|
||||||
|
persistInFlight = true;
|
||||||
|
persistSessionState(browserManager, sessionStatePath)
|
||||||
|
.catch((err: any) => {
|
||||||
|
// Warn once — a full disk must not spam the log every 30s, and a
|
||||||
|
// snapshot failure must never kill the daemon (R3).
|
||||||
|
if (!persistWarned) {
|
||||||
|
persistWarned = true;
|
||||||
|
console.warn(`[browse] SESSION_PERSIST_FAILED: ${err?.message ?? err} (further failures suppressed)`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => { persistInFlight = false; });
|
||||||
|
}, sessionPersistIntervalMs());
|
||||||
|
(sessionPersistInterval as any)?.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
// Navigate to welcome page if in headed mode and still on about:blank
|
// Navigate to welcome page if in headed mode and still on about:blank
|
||||||
if (browserManager.getConnectionMode() === 'headed') {
|
if (browserManager.getConnectionMode() === 'headed') {
|
||||||
try {
|
try {
|
||||||
@@ -3130,7 +3284,9 @@ export async function start() {
|
|||||||
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener
|
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener
|
||||||
// pattern: bind a dedicated tunnel listener on an ephemeral port and
|
// pattern: bind a dedicated tunnel listener on an ephemeral port and
|
||||||
// point ngrok.forward() at IT, not the local daemon port.
|
// point ngrok.forward() at IT, not the local daemon port.
|
||||||
if (process.env.BROWSE_TUNNEL === '1') {
|
if (process.env.BROWSE_TUNNEL === '1' && !isPairAgentEnabled()) {
|
||||||
|
console.error('[browse] BROWSE_TUNNEL=1 ignored: pair-agent is off. Enable once with: gstack-config set pair_agent on');
|
||||||
|
} else if (process.env.BROWSE_TUNNEL === '1') {
|
||||||
const authtoken = resolveNgrokAuthtoken();
|
const authtoken = resolveNgrokAuthtoken();
|
||||||
if (!authtoken) {
|
if (!authtoken) {
|
||||||
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
|
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
|
||||||
@@ -3142,7 +3298,7 @@ export async function start() {
|
|||||||
const started = await startTunnel({
|
const started = await startTunnel({
|
||||||
fetchHandler: handle.fetchTunnel,
|
fetchHandler: handle.fetchTunnel,
|
||||||
authtoken,
|
authtoken,
|
||||||
consent: 'pair_agent=on (BROWSE_TUNNEL=1)',
|
consent: 'pair_agent=on (isPairAgentEnabled gate, BROWSE_TUNNEL=1)',
|
||||||
});
|
});
|
||||||
if (!started.ok) {
|
if (!started.ok) {
|
||||||
console.error(`[browse] Failed to start tunnel: ${started.error.message}`);
|
console.error(`[browse] Failed to start tunnel: ${started.error.message}`);
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Opt-in session-state persistence (#778, #2193, #1128, #1129).
|
||||||
|
*
|
||||||
|
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||||
|
*
|
||||||
|
* With BROWSE_PERSIST_STATE=1, the headless daemon snapshots cookies +
|
||||||
|
* per-tab URL/localStorage/sessionStorage to <stateDir>/session-state.json
|
||||||
|
* on an interval and at clean shutdown, and restores it on the next launch.
|
||||||
|
* Kills the auth-lost-on-restart class: a crash or binary-version
|
||||||
|
* auto-restart no longer silently logs the user out of everything.
|
||||||
|
*
|
||||||
|
* Default OFF: cookies on disk (0600) are a real cost the user must opt
|
||||||
|
* into. Headed mode is excluded — the persistent Chromium profile already
|
||||||
|
* owns that state, and replaying tabs would clobber the user's window.
|
||||||
|
*
|
||||||
|
* Disk shape (version 1): { version, savedAt, cookies, pages[{url,
|
||||||
|
* isActive, storage}] }. loadedHtml and owner are NEVER persisted — same
|
||||||
|
* in-memory-only invariant as `state save|load` (meta-commands.ts): a
|
||||||
|
* tampered file must not smuggle HTML past load-html's checks or forge tab
|
||||||
|
* ownership.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import type { BrowserManager, BrowserState } from './browser-manager';
|
||||||
|
import { writeSecureFile } from './file-permissions';
|
||||||
|
import { safeUnlinkQuiet } from './error-handling';
|
||||||
|
|
||||||
|
/** Rename a corrupt state file to .corrupt (forensic artifact) — best effort. */
|
||||||
|
function quarantineCorrupt(filePath: string): void {
|
||||||
|
try {
|
||||||
|
fs.renameSync(filePath, `${filePath}.corrupt`);
|
||||||
|
} catch {
|
||||||
|
safeUnlinkQuiet(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_STATE_FILE = 'session-state.json';
|
||||||
|
export const SESSION_STATE_VERSION = 1;
|
||||||
|
|
||||||
|
/** Config gate. Documented in browse/SKILL.md ("Session persistence"). */
|
||||||
|
export function isSessionPersistEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||||
|
return env.BROWSE_PERSIST_STATE === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist interval (ms). Env override exists for tests. */
|
||||||
|
export function sessionPersistIntervalMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||||
|
const parsed = parseInt(env.BROWSE_PERSIST_INTERVAL_MS || '', 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a BrowserState to the on-disk v1 shape. Strips loadedHtml,
|
||||||
|
* loadedHtmlWaitUntil, and owner (in-memory-only invariants).
|
||||||
|
*/
|
||||||
|
export function serializeSessionState(state: BrowserState): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
version: SESSION_STATE_VERSION,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
cookies: state.cookies,
|
||||||
|
pages: state.pages.map((p) => ({
|
||||||
|
url: p.url,
|
||||||
|
isActive: p.isActive,
|
||||||
|
storage: p.storage,
|
||||||
|
})),
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a cookie domain points at an internal-network target a tampered
|
||||||
|
* state file could use to reach localhost services, *.internal hosts, or
|
||||||
|
* cloud metadata: `localhost`, `*.internal`, IPv4 loopback literals
|
||||||
|
* (127.0.0.0/8), IPv6 loopback (`::1`, `[::1]`), and link-local/metadata
|
||||||
|
* (169.254.0.0/16, which covers 169.254.169.254). Leading-dot domain
|
||||||
|
* variants (`.127.0.0.1`) are normalized before matching. Single source of
|
||||||
|
* truth for the persistence restore path here AND `state load`
|
||||||
|
* (meta-commands.ts).
|
||||||
|
*/
|
||||||
|
export function isInternalCookieDomain(domain: string): boolean {
|
||||||
|
const d = domain.startsWith('.') ? domain.slice(1) : domain;
|
||||||
|
if (d === 'localhost' || d.endsWith('.internal')) return true;
|
||||||
|
if (d === '::1' || d === '[::1]') return true; // IPv6 loopback
|
||||||
|
if (/^127\./.test(d)) return true; // IPv4 loopback block
|
||||||
|
if (/^169\.254\./.test(d)) return true; // link-local incl. cloud metadata
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie hygiene shared with `state load` (meta-commands.ts): drop malformed
|
||||||
|
* cookies and internal-network domains (see isInternalCookieDomain).
|
||||||
|
*/
|
||||||
|
export function filterSessionCookies(cookies: unknown[]): BrowserState['cookies'] {
|
||||||
|
return cookies.filter((c: any) => {
|
||||||
|
if (typeof c !== 'object' || !c) return false;
|
||||||
|
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
|
||||||
|
if (typeof c.domain !== 'string' || !c.domain) return false;
|
||||||
|
return !isInternalCookieDomain(c.domain);
|
||||||
|
}) as BrowserState['cookies'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse + validate the on-disk shape into a BrowserState. Returns null for
|
||||||
|
* anything malformed (corrupt JSON, wrong version, missing arrays).
|
||||||
|
* loadedHtml/owner are stripped unconditionally even if present on disk.
|
||||||
|
*/
|
||||||
|
export function deserializeSessionState(raw: string): BrowserState | null {
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!data || data.version !== SESSION_STATE_VERSION) return null;
|
||||||
|
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) return null;
|
||||||
|
return {
|
||||||
|
cookies: filterSessionCookies(data.cookies),
|
||||||
|
pages: data.pages.map((p: any) => ({
|
||||||
|
url: typeof p?.url === 'string' ? p.url : '',
|
||||||
|
isActive: Boolean(p?.isActive),
|
||||||
|
storage: p?.storage && typeof p.storage === 'object'
|
||||||
|
? {
|
||||||
|
localStorage: typeof p.storage.localStorage === 'object' && p.storage.localStorage ? p.storage.localStorage : {},
|
||||||
|
sessionStorage: typeof p.storage.sessionStorage === 'object' && p.storage.sessionStorage ? p.storage.sessionStorage : {},
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
// NEVER accept loadedHtml / loadedHtmlWaitUntil / owner from disk.
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the live session to disk (0600). No-op outside launched
|
||||||
|
* (headless) mode — the headed persistent profile owns its own state.
|
||||||
|
*/
|
||||||
|
export async function persistSessionState(bm: BrowserManager, filePath: string): Promise<void> {
|
||||||
|
if (bm.getConnectionMode() !== 'launched') return;
|
||||||
|
const state = await bm.saveState();
|
||||||
|
// Atomic replace: stage the new snapshot beside the target, then rename
|
||||||
|
// over it. A crash mid-write must never destroy the previous good
|
||||||
|
// snapshot — surviving crashes is the point of this feature.
|
||||||
|
const tmpPath = `${filePath}.tmp`;
|
||||||
|
writeSecureFile(tmpPath, serializeSessionState(state));
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmpPath, filePath);
|
||||||
|
} catch (err) {
|
||||||
|
safeUnlinkQuiet(tmpPath);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a persisted session into a freshly launched manager. Returns the
|
||||||
|
* restored (already-filtered) state so callers can log counts without an
|
||||||
|
* extra saveState() round-trip, or null when there was nothing to restore
|
||||||
|
* (missing file, or corrupt data — which is warned, quarantined, and skipped
|
||||||
|
* rather than blocking launch). restoreState re-validates every URL before
|
||||||
|
* navigating.
|
||||||
|
*/
|
||||||
|
export async function restoreSessionState(bm: BrowserManager, filePath: string): Promise<BrowserState | null> {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'ENOENT') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const state = deserializeSessionState(raw);
|
||||||
|
if (!state) {
|
||||||
|
// Boot fresh, keep the evidence: the corrupt file moves to .corrupt so a
|
||||||
|
// 3-week-later bug report is reconstructable from the artifact.
|
||||||
|
console.warn(`[browse] SESSION_STATE_INVALID: corrupt ${filePath} moved to .corrupt; starting fresh`);
|
||||||
|
quarantineCorrupt(filePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// launch() opens one blank tab; replace it rather than restoring alongside.
|
||||||
|
await bm.closeAllPages();
|
||||||
|
await bm.restoreState(state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
+42
-7
@@ -21,6 +21,7 @@
|
|||||||
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 * as os from 'os';
|
||||||
|
import { readGstackConfigYamlKey } from './config';
|
||||||
|
|
||||||
function gstackHome(): string {
|
function gstackHome(): string {
|
||||||
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
||||||
@@ -43,17 +44,51 @@ async function ensureDir(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let telemetryDisabled: boolean | null = null;
|
let telemetryDisabled: boolean | null = null;
|
||||||
function isDisabled(): boolean {
|
/**
|
||||||
|
* Is telemetry disabled for this process? Telemetry is OPT-IN: the consent
|
||||||
|
* prompt writes a granted tier ('community' | 'anonymous') to
|
||||||
|
* ~/.gstack/config.yaml, and only a granted tier enables emission. Tiers,
|
||||||
|
* checked in order:
|
||||||
|
*
|
||||||
|
* 1. Env hint GSTACK_TELEMETRY_OFF=1 (set by preambles and test
|
||||||
|
* harnesses): always disabled, even over a granted config tier.
|
||||||
|
* 2. Persistent tier via the shared flat-YAML helper in config.ts (same
|
||||||
|
* parser as the pair-agent gate, so the two consent gates never drift):
|
||||||
|
* explicit `telemetry: off` disables; 'community'/'anonymous' enable.
|
||||||
|
* 3. Default: DISABLED. An absent key, absent file, or unrecognized value
|
||||||
|
* means consent was never granted — matching bin/gstack-config's
|
||||||
|
* DEFAULTS table, which reports 'off' for an unset telemetry key.
|
||||||
|
* Anything else would be a split-brain where `gstack-config get
|
||||||
|
* telemetry` tells the user 'off' while a direct-$B daemon emits.
|
||||||
|
* One escape hatch: GSTACK_TELEMETRY_OFF=0 is a harness-side consent
|
||||||
|
* assertion that flips this DEFAULT only (test harnesses exercising the
|
||||||
|
* write path against a scratch GSTACK_HOME) — it never overrides an
|
||||||
|
* explicit `telemetry: off` the user wrote.
|
||||||
|
*
|
||||||
|
* Exported so tests can pin the consent gate directly; the cached verdict
|
||||||
|
* resets via _resetTelemetryCache.
|
||||||
|
*/
|
||||||
|
export function isTelemetryDisabled(): boolean {
|
||||||
if (telemetryDisabled !== null) return telemetryDisabled;
|
if (telemetryDisabled !== null) return telemetryDisabled;
|
||||||
// Check env (set by preamble or test harnesses).
|
// Env kill switch (set by preamble or test harnesses): beats everything.
|
||||||
if (process.env.GSTACK_TELEMETRY_OFF === '1') {
|
if (process.env.GSTACK_TELEMETRY_OFF === '1') {
|
||||||
telemetryDisabled = true;
|
telemetryDisabled = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Conservative default: telemetry ON unless explicitly off. Users opt out via
|
// Persistent tier: an explicit user-written value always wins next.
|
||||||
// gstack-config set telemetry off (preamble reads this; we trust the env hint).
|
const tier = readGstackConfigYamlKey('telemetry');
|
||||||
telemetryDisabled = false;
|
if (tier === 'off') {
|
||||||
return false;
|
telemetryDisabled = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tier === 'community' || tier === 'anonymous') {
|
||||||
|
telemetryDisabled = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// No granted consent on record (absent key/file, unrecognized value):
|
||||||
|
// disabled — unless the harness asserted consent via the env seam.
|
||||||
|
telemetryDisabled = process.env.GSTACK_TELEMETRY_OFF !== '0';
|
||||||
|
return telemetryDisabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelemetryEvent {
|
export interface TelemetryEvent {
|
||||||
@@ -63,7 +98,7 @@ export interface TelemetryEvent {
|
|||||||
|
|
||||||
/** Fire-and-forget log. Never throws. */
|
/** Fire-and-forget log. Never throws. */
|
||||||
export function logTelemetry(payload: TelemetryEvent): void {
|
export function logTelemetry(payload: TelemetryEvent): void {
|
||||||
if (isDisabled()) return;
|
if (isTelemetryDisabled()) return;
|
||||||
const enriched = { ...payload, ts: new Date().toISOString() };
|
const enriched = { ...payload, ts: new Date().toISOString() };
|
||||||
ensureDir()
|
ensureDir()
|
||||||
.then(() => fs.appendFile(telemetryFile(), JSON.stringify(enriched) + '\n', 'utf8'))
|
.then(() => fs.appendFile(telemetryFile(), JSON.stringify(enriched) + '\n', 'utf8'))
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { describe, test, expect, afterAll } from 'bun:test';
|
import { describe, test, expect, afterAll, setDefaultTimeout } from 'bun:test';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// Every test here spawnSync's a `node` child; Windows CI cold-start (AV scan,
|
||||||
|
// first-touch of node.exe) alone can blow bun's 5s default — observed 5,007ms
|
||||||
|
// on a 50ms sleep test. Subprocess budget, not assertion looseness.
|
||||||
|
setDefaultTimeout(20_000);
|
||||||
|
|
||||||
// Load the polyfill into a fresh object (don't clobber globalThis.Bun)
|
// Load the polyfill into a fresh object (don't clobber globalThis.Bun)
|
||||||
const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
|
const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
|
||||||
// Forward slashes so the path survives interpolation into a JS string literal
|
|
||||||
// on Windows, which is the platform this polyfill exists for.
|
|
||||||
const requirePath = polyfillPath.replace(/\\/g, '/');
|
|
||||||
|
|
||||||
describe('bun-polyfill', () => {
|
describe('bun-polyfill', () => {
|
||||||
// We test the polyfill by requiring it in a subprocess under Node.js
|
// We test the polyfill by requiring it in a subprocess under Node.js
|
||||||
@@ -13,7 +15,7 @@ describe('bun-polyfill', () => {
|
|||||||
|
|
||||||
test('Bun.sleep resolves after delay', async () => {
|
test('Bun.sleep resolves after delay', async () => {
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
const result = Bun.spawnSync(['node', '-e', `
|
||||||
require('${requirePath}');
|
require(${JSON.stringify(polyfillPath)});
|
||||||
(async () => {
|
(async () => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
await Bun.sleep(50);
|
await Bun.sleep(50);
|
||||||
@@ -27,7 +29,7 @@ describe('bun-polyfill', () => {
|
|||||||
|
|
||||||
test('Bun.spawnSync runs a command and returns stdout', () => {
|
test('Bun.spawnSync runs a command and returns stdout', () => {
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
const result = Bun.spawnSync(['node', '-e', `
|
||||||
require('${requirePath}');
|
require(${JSON.stringify(polyfillPath)});
|
||||||
const r = Bun.spawnSync(['echo', 'hello'], { stdout: 'pipe' });
|
const r = Bun.spawnSync(['echo', 'hello'], { stdout: 'pipe' });
|
||||||
console.log(r.stdout.toString().trim());
|
console.log(r.stdout.toString().trim());
|
||||||
console.log('exit:' + r.exitCode);
|
console.log('exit:' + r.exitCode);
|
||||||
@@ -39,7 +41,7 @@ describe('bun-polyfill', () => {
|
|||||||
|
|
||||||
test('Bun.spawn launches a process with pid', async () => {
|
test('Bun.spawn launches a process with pid', async () => {
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
const result = Bun.spawnSync(['node', '-e', `
|
||||||
require('${requirePath}');
|
require(${JSON.stringify(polyfillPath)});
|
||||||
const p = Bun.spawn(['echo', 'test'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
const p = Bun.spawn(['echo', 'test'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
console.log(typeof p.pid === 'number' ? 'HAS_PID' : 'NO_PID');
|
console.log(typeof p.pid === 'number' ? 'HAS_PID' : 'NO_PID');
|
||||||
console.log(typeof p.kill === 'function' ? 'HAS_KILL' : 'NO_KILL');
|
console.log(typeof p.kill === 'function' ? 'HAS_KILL' : 'NO_KILL');
|
||||||
@@ -51,179 +53,9 @@ describe('bun-polyfill', () => {
|
|||||||
expect(lines[2]).toBe('HAS_UNREF');
|
expect(lines[2]).toBe('HAS_UNREF');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bun.spawn parity: `proc.exited` is a Promise resolving to the exit code.
|
|
||||||
// The DPAPI helper and isBrowserRunning both `await proc.exited`; without
|
|
||||||
// it the awaits resolve immediately to `undefined` and the caller reads
|
|
||||||
// stdout before the child has produced it — surfacing as a silent failure.
|
|
||||||
test('Bun.spawn exposes proc.exited that resolves to the exit code', async () => {
|
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
|
||||||
require('${requirePath}');
|
|
||||||
(async () => {
|
|
||||||
const p = Bun.spawn(['node', '-e', 'process.exit(0)'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
||||||
console.log(typeof p.exited === 'object' && typeof p.exited.then === 'function' ? 'IS_PROMISE' : 'NOT_PROMISE');
|
|
||||||
console.log('exit:' + await p.exited);
|
|
||||||
})();
|
|
||||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
||||||
const lines = result.stdout.toString().trim().split('\n');
|
|
||||||
expect(lines[0]).toBe('IS_PROMISE');
|
|
||||||
expect(lines[1]).toBe('exit:0');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Bun.spawn proc.exited reflects non-zero exit codes', async () => {
|
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
|
||||||
require('${requirePath}');
|
|
||||||
(async () => {
|
|
||||||
const p = Bun.spawn(['node', '-e', 'process.exit(3)'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
||||||
console.log('exit:' + await p.exited);
|
|
||||||
})();
|
|
||||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
||||||
expect(result.stdout.toString().trim()).toBe('exit:3');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Bun.spawn proc.exited resolves before reading stdout (no race)', async () => {
|
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
|
||||||
require('${requirePath}');
|
|
||||||
(async () => {
|
|
||||||
// Real-world pattern: write to stdout, then exit. Awaiting proc.exited
|
|
||||||
// before reading must guarantee the bytes are flushed.
|
|
||||||
const p = Bun.spawn(['node', '-e', 'process.stdout.write("ready"); process.exit(0)'], {
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore']
|
|
||||||
});
|
|
||||||
const code = await p.exited;
|
|
||||||
const out = await new Response(p.stdout).text();
|
|
||||||
console.log(out + ':' + code);
|
|
||||||
})();
|
|
||||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
||||||
expect(result.stdout.toString().trim()).toBe('ready:0');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spawn-failure case: Node emits 'error' but not 'exit' when the binary
|
|
||||||
// is missing, so listening only for 'exit' hangs `await proc.exited`
|
|
||||||
// forever. The lifecycle promise must resolve on either event.
|
|
||||||
test('Bun.spawn proc.exited resolves on spawn failure (missing binary)', async () => {
|
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
|
||||||
require('${requirePath}');
|
|
||||||
(async () => {
|
|
||||||
const p = Bun.spawn(['this-binary-does-not-exist-zzz-' + Date.now()], {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
|
||||||
});
|
|
||||||
const code = await Promise.race([
|
|
||||||
p.exited,
|
|
||||||
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 3000))
|
|
||||||
]).catch(() => 'TIMEOUT');
|
|
||||||
console.log('exit:' + code);
|
|
||||||
})();
|
|
||||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
||||||
// Anything other than 'TIMEOUT' (and ideally a non-zero number) means the
|
|
||||||
// lifecycle promise resolved on the spawn error.
|
|
||||||
const out = result.stdout.toString().trim();
|
|
||||||
expect(out).not.toBe('exit:TIMEOUT');
|
|
||||||
expect(out).toMatch(/^exit:\d+$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// GSTACK_SPAWN_MAX_BUFFER caps the drain so a runaway child can't OOM the
|
|
||||||
// server. Past the cap, the pipe keeps flowing (child doesn't block) but
|
|
||||||
// further bytes are dropped. Set a small cap, write more than that, assert
|
|
||||||
// the captured stdout equals the cap and the child exits cleanly.
|
|
||||||
test('Bun.spawn caps buffered output at GSTACK_SPAWN_MAX_BUFFER', async () => {
|
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
|
||||||
process.env.GSTACK_SPAWN_MAX_BUFFER = '${1024}';
|
|
||||||
require('${requirePath}');
|
|
||||||
(async () => {
|
|
||||||
// Child writes 10 KB; cap is 1 KB; drained output should be exactly 1 KB
|
|
||||||
// and exit should still resolve cleanly (child not back-pressured to death).
|
|
||||||
const p = Bun.spawn(
|
|
||||||
['node', '-e', 'process.stdout.write("y".repeat(10 * 1024)); process.exit(0)'],
|
|
||||||
{ stdio: ['ignore', 'pipe', 'ignore'] }
|
|
||||||
);
|
|
||||||
const code = await Promise.race([
|
|
||||||
p.exited,
|
|
||||||
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 3000))
|
|
||||||
]).catch(() => 'TIMEOUT');
|
|
||||||
const out = await new Response(p.stdout).text();
|
|
||||||
console.log(out.length + ':' + code);
|
|
||||||
})();
|
|
||||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
||||||
expect(result.stdout.toString().trim()).toBe('1024:0');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Regression for the pipe-blocking case: if the child writes more than the
|
|
||||||
// OS pipe buffer (~16-64 KB) and the polyfill doesn't drain eagerly, the
|
|
||||||
// child blocks in write() and `exit` never fires. 1 MB is well past every
|
|
||||||
// OS pipe buffer size. Pre-fix this test hangs forever; post-fix it returns
|
|
||||||
// in <500ms. Bun's default per-test timeout is 5s — generous here.
|
|
||||||
test('Bun.spawn drains large stdout so proc.exited still resolves', async () => {
|
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
|
||||||
require('${requirePath}');
|
|
||||||
(async () => {
|
|
||||||
const ONE_MB = 1024 * 1024;
|
|
||||||
// Exit in the write callback, not straight after write(): on modern
|
|
||||||
// Node a pipe write past the OS buffer is async, and process.exit()
|
|
||||||
// right after write() truncates at ~64 KB even with a live reader.
|
|
||||||
// The callback only fires once the full MB is flushed — which still
|
|
||||||
// requires the parent to drain, so the regression (no eager drain →
|
|
||||||
// child blocks → timeout) is still caught.
|
|
||||||
const p = Bun.spawn(
|
|
||||||
['node', '-e', 'process.stdout.write("x".repeat(' + ONE_MB + '), () => process.exit(0))'],
|
|
||||||
{ stdio: ['ignore', 'pipe', 'ignore'] }
|
|
||||||
);
|
|
||||||
const code = await Promise.race([
|
|
||||||
p.exited,
|
|
||||||
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 10000))
|
|
||||||
]).catch(e => 'TIMEOUT');
|
|
||||||
const out = await new Response(p.stdout).text();
|
|
||||||
console.log(out.length + ':' + code);
|
|
||||||
})().catch((e) => { console.log('THREW:' + e.message); });
|
|
||||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
||||||
expect(result.stdout.toString().trim()).toBe('1048576:0');
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
// windowsHide is the one option where Node's default is the opposite of
|
|
||||||
// Bun's: Node shows the child's console window, Bun hides it. Dropping it
|
|
||||||
// in translation makes every spawned child pop a window on Windows, which
|
|
||||||
// is the platform this whole file exists for. Both shims are covered.
|
|
||||||
test('Bun.spawn defaults windowsHide to true', () => {
|
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
|
||||||
const cp = require('child_process');
|
|
||||||
const orig = cp.spawn;
|
|
||||||
let seen;
|
|
||||||
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
|
|
||||||
require('${requirePath}');
|
|
||||||
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
||||||
console.log('windowsHide:' + seen.windowsHide);
|
|
||||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
||||||
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Bun.spawnSync defaults windowsHide to true', () => {
|
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
|
||||||
const cp = require('child_process');
|
|
||||||
const orig = cp.spawnSync;
|
|
||||||
let seen;
|
|
||||||
cp.spawnSync = (c, a, o) => { seen = o; return orig(c, a, o); };
|
|
||||||
require('${requirePath}');
|
|
||||||
Bun.spawnSync(['node', '-e', '']);
|
|
||||||
console.log('windowsHide:' + seen.windowsHide);
|
|
||||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
||||||
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an explicit windowsHide:false is honored', () => {
|
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
|
||||||
const cp = require('child_process');
|
|
||||||
const orig = cp.spawn;
|
|
||||||
let seen;
|
|
||||||
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
|
|
||||||
require('${requirePath}');
|
|
||||||
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
|
|
||||||
console.log('windowsHide:' + seen.windowsHide);
|
|
||||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
||||||
expect(result.stdout.toString().trim()).toBe('windowsHide:false');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Bun.serve creates an HTTP server that responds', async () => {
|
test('Bun.serve creates an HTTP server that responds', async () => {
|
||||||
const result = Bun.spawnSync(['node', '-e', `
|
const result = Bun.spawnSync(['node', '-e', `
|
||||||
require('${requirePath}');
|
require(${JSON.stringify(polyfillPath)});
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 0, // Note: polyfill uses port directly, so we pick one
|
port: 0, // Note: polyfill uses port directly, so we pick one
|
||||||
hostname: '127.0.0.1',
|
hostname: '127.0.0.1',
|
||||||
@@ -242,4 +74,48 @@ describe('bun-polyfill', () => {
|
|||||||
expect(lines[0]).toBe('HAS_STOP');
|
expect(lines[0]).toBe('HAS_STOP');
|
||||||
expect(lines[1]).toBe('HAS_PORT');
|
expect(lines[1]).toBe('HAS_PORT');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// windowsHide is the one option where Node's default is the opposite of
|
||||||
|
// Bun's: Node shows the child's console window, Bun hides it. Dropping it
|
||||||
|
// in translation makes every spawned child pop a window on Windows, which
|
||||||
|
// is the platform this whole file exists for. Both shims are covered, and
|
||||||
|
// an explicit windowsHide:false must survive forwarding (#2523 + #2539).
|
||||||
|
test('Bun.spawn defaults windowsHide to true', () => {
|
||||||
|
const result = Bun.spawnSync(['node', '-e', `
|
||||||
|
const cp = require('child_process');
|
||||||
|
const orig = cp.spawn;
|
||||||
|
let seen;
|
||||||
|
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
|
||||||
|
require(${JSON.stringify(polyfillPath)});
|
||||||
|
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||||
|
console.log('windowsHide:' + seen.windowsHide);
|
||||||
|
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Bun.spawnSync defaults windowsHide to true', () => {
|
||||||
|
const result = Bun.spawnSync(['node', '-e', `
|
||||||
|
const cp = require('child_process');
|
||||||
|
const orig = cp.spawnSync;
|
||||||
|
let seen;
|
||||||
|
cp.spawnSync = (c, a, o) => { seen = o; return orig(c, a, o); };
|
||||||
|
require(${JSON.stringify(polyfillPath)});
|
||||||
|
Bun.spawnSync(['node', '-e', '']);
|
||||||
|
console.log('windowsHide:' + seen.windowsHide);
|
||||||
|
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an explicit windowsHide:false is honored', () => {
|
||||||
|
const result = Bun.spawnSync(['node', '-e', `
|
||||||
|
const cp = require('child_process');
|
||||||
|
const orig = cp.spawn;
|
||||||
|
let seen;
|
||||||
|
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
|
||||||
|
require(${JSON.stringify(polyfillPath)});
|
||||||
|
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
|
||||||
|
console.log('windowsHide:' + seen.windowsHide);
|
||||||
|
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
expect(result.stdout.toString().trim()).toBe('windowsHide:false');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* #1781 busy-vs-dead pinning test — the "recover from a busy daemon" leg of
|
||||||
|
* the browser-lifecycle contract, previously untested.
|
||||||
|
*
|
||||||
|
* Wedges a fake daemon: /health answers healthy, but the FIRST POST /command
|
||||||
|
* hard-destroys the socket (the CLI sees ECONNRESET — exactly what a
|
||||||
|
* single-threaded daemon under beacon load looks like). The daemon "PID"
|
||||||
|
* is a live sleep child.
|
||||||
|
*
|
||||||
|
* Contract under test (cli.ts sendCommand + probeHealthWithBackoff):
|
||||||
|
* - CLI must NOT kill the live PID and must NOT restart the daemon
|
||||||
|
* (a restart drops tab/cookie state — the original crash-loop bug).
|
||||||
|
* - It probes /health, sees alive, and retries the SAME command against
|
||||||
|
* the SAME daemon instance.
|
||||||
|
*
|
||||||
|
* Fails on pre-#1781 code (which killed + restarted on any conn error) and
|
||||||
|
* on any regression that reorders the busy-probe before the alive check.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, afterAll } from 'bun:test';
|
||||||
|
import { spawn, type ChildProcess } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as http from 'http';
|
||||||
|
import { isProcessAlive } from '../src/error-handling';
|
||||||
|
|
||||||
|
const BOOT_ID = `boot-${Date.now()}`;
|
||||||
|
|
||||||
|
interface FakeDaemon {
|
||||||
|
port: number;
|
||||||
|
commandRequests: number;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /health healthy; first POST /command → socket destroy; then 200 + BOOT_ID. */
|
||||||
|
async function startWedgedDaemon(): Promise<FakeDaemon> {
|
||||||
|
const state = { commandRequests: 0 };
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.url === '/health') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ status: 'healthy' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === '/command' && req.method === 'POST') {
|
||||||
|
state.commandRequests += 1;
|
||||||
|
if (state.commandRequests === 1) {
|
||||||
|
req.socket.destroy(); // wedged: connection dies mid-request
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end(`RECOVERED ${BOOT_ID}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => resolve());
|
||||||
|
});
|
||||||
|
const addr = server.address();
|
||||||
|
if (!addr || typeof addr === 'string') throw new Error('fake daemon: bad address');
|
||||||
|
return {
|
||||||
|
port: addr.port,
|
||||||
|
get commandRequests() { return state.commandRequests; },
|
||||||
|
close: () => new Promise((r) => server.close(() => r())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(args: string[], env: Record<string, string>, timeoutMs = 20_000):
|
||||||
|
Promise<{ code: number; stdout: string; stderr: string }> {
|
||||||
|
const cliPath = path.resolve(__dirname, '../src/cli.ts');
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const proc = spawn('bun', ['run', cliPath, ...args], { timeout: timeoutMs, env });
|
||||||
|
let stdout = ''; let stderr = '';
|
||||||
|
proc.stdout.on('data', (d) => stdout += d.toString());
|
||||||
|
proc.stderr.on('data', (d) => stderr += d.toString());
|
||||||
|
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let daemonPidChild: ChildProcess | null = null;
|
||||||
|
afterAll(() => { daemonPidChild?.kill('SIGKILL'); });
|
||||||
|
|
||||||
|
describe('#1781 busy-daemon recovery (CLI integration)', () => {
|
||||||
|
test('retries without kill; same daemon instance, state file untouched', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-busy-'));
|
||||||
|
const stateFile = path.join(tmpDir, 'browse.json');
|
||||||
|
const daemon = await startWedgedDaemon();
|
||||||
|
try {
|
||||||
|
// A live process standing in for the daemon PID. If the CLI takes the
|
||||||
|
// dead path it SIGTERMs this child — the aliveness assert catches it.
|
||||||
|
daemonPidChild = spawn('sleep', ['60'], { stdio: 'ignore' });
|
||||||
|
const daemonPid = daemonPidChild.pid!;
|
||||||
|
|
||||||
|
const stateContent = {
|
||||||
|
pid: daemonPid,
|
||||||
|
port: daemon.port,
|
||||||
|
token: 'busy-test-token',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
serverPath: '',
|
||||||
|
mode: 'launched' as const,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(stateFile, JSON.stringify(stateContent, null, 2));
|
||||||
|
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(process.env)) {
|
||||||
|
if (v !== undefined) env[k] = v;
|
||||||
|
}
|
||||||
|
env.BROWSE_STATE_FILE = stateFile;
|
||||||
|
|
||||||
|
const result = await runCli(['status'], env);
|
||||||
|
|
||||||
|
// Recovered: retried the same command against the same daemon instance.
|
||||||
|
expect(result.code).toBe(0);
|
||||||
|
expect(result.stdout).toContain(`RECOVERED ${BOOT_ID}`);
|
||||||
|
// The fork's CLI announces the busy retry on stderr; ours retries at the
|
||||||
|
// probe layer without a message. Either is fine — the load-bearing
|
||||||
|
// behavior is retry-without-kill, asserted below.
|
||||||
|
expect(daemon.commandRequests).toBe(2); // wedged once, served once
|
||||||
|
|
||||||
|
// Never killed, never restarted — tab/cookie state intact.
|
||||||
|
expect(result.stderr).not.toContain('Restarting');
|
||||||
|
expect(isProcessAlive(daemonPid)).toBe(true);
|
||||||
|
expect(JSON.parse(fs.readFileSync(stateFile, 'utf-8'))).toEqual(stateContent);
|
||||||
|
} finally {
|
||||||
|
// Cleanup must run even when an assertion throws — otherwise a failed
|
||||||
|
// run leaks the wedged fake daemon and the tmp dir.
|
||||||
|
await daemon.close();
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
@@ -1,8 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* #1084 diagnostics — merged-design shape.
|
||||||
|
*
|
||||||
|
* Main's smell wave pinned a log-and-return-null acquireServerLock; this
|
||||||
|
* branch keeps the typed ServerLockError + bounded-retry design (fully pinned
|
||||||
|
* in server-lock-errors.test.ts). This file re-expresses the non-redundant
|
||||||
|
* assertion intents from the wave's test against the kept design:
|
||||||
|
* - unexpected open failures surface the REAL errno + lock path (typed
|
||||||
|
* throw), never phantom "another process holds the lock" contention;
|
||||||
|
* - holder-PID read failures surface errno + lock path the same way;
|
||||||
|
* - genuine live contention stays SILENT (null return, no stderr noise).
|
||||||
|
* Exact duplicates of server-lock-errors.test.ts coverage (stale-lock
|
||||||
|
* reacquire, ENOENT self-heal, EACCES throw) are deliberately not repeated.
|
||||||
|
*/
|
||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { acquireServerLock } from '../src/cli';
|
import { acquireServerLock, ServerLockError } from '../src/cli';
|
||||||
|
|
||||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-lock-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-lock-'));
|
||||||
@@ -27,14 +41,27 @@ function captureErrors<T>(fn: () => T): { result: T; messages: string[] } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('browse CLI server lock diagnostics (#1084)', () => {
|
describe('browse CLI server lock diagnostics (#1084)', () => {
|
||||||
test('logs non-EEXIST open failures instead of reporting phantom lock contention', () => {
|
test('unexpected open failures throw ServerLockError with the real errno — not phantom lock contention', () => {
|
||||||
|
if (process.platform === 'win32') return; // ENOTDIR errno mapping differs on Windows
|
||||||
withTempDir((dir) => {
|
withTempDir((dir) => {
|
||||||
const lockPath = path.join(dir, 'missing-parent', 'browse.json.lock');
|
// A FILE where a directory is expected: open('wx') fails ENOTDIR — an
|
||||||
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
|
// errno that is neither contention (EEXIST) nor the self-healing
|
||||||
|
// missing-dir case (ENOENT). The old code's bare catch would have
|
||||||
|
// reported "another process holds the lock" forever.
|
||||||
|
const blocker = path.join(dir, 'blocker');
|
||||||
|
fs.writeFileSync(blocker, 'not a directory\n');
|
||||||
|
const lockPath = path.join(blocker, 'browse.json.lock');
|
||||||
|
|
||||||
expect(result).toBeNull();
|
let thrown: any = null;
|
||||||
expect(messages.join('\n')).toContain('unexpected ENOENT while opening');
|
try {
|
||||||
expect(messages.join('\n')).toContain(lockPath);
|
acquireServerLock(lockPath);
|
||||||
|
} catch (err) {
|
||||||
|
thrown = err;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(ServerLockError);
|
||||||
|
expect(thrown.code).toBe('ENOTDIR');
|
||||||
|
expect(thrown.message).toContain('E_SERVER_LOCK (ENOTDIR)');
|
||||||
|
expect(thrown.message).toContain(lockPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,30 +77,25 @@ describe('browse CLI server lock diagnostics (#1084)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logs holder PID read failures with code and lock path', () => {
|
test('holder PID read failures throw ServerLockError with code and lock path', () => {
|
||||||
withTempDir((dir) => {
|
withTempDir((dir) => {
|
||||||
|
// Lock path exists but is a DIRECTORY: open('wx') → EEXIST (looks like
|
||||||
|
// contention), then the holder-PID read fails EISDIR. The kept design
|
||||||
|
// surfaces that errno + path in a typed error instead of retrying or
|
||||||
|
// reporting phantom contention.
|
||||||
const lockPath = path.join(dir, 'browse.json.lock');
|
const lockPath = path.join(dir, 'browse.json.lock');
|
||||||
fs.mkdirSync(lockPath);
|
fs.mkdirSync(lockPath);
|
||||||
|
|
||||||
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
|
let thrown: any = null;
|
||||||
|
try {
|
||||||
expect(result).toBeNull();
|
acquireServerLock(lockPath);
|
||||||
expect(messages.join('\n')).toContain('unexpected EISDIR while reading holder PID from');
|
} catch (err) {
|
||||||
expect(messages.join('\n')).toContain(lockPath);
|
thrown = err;
|
||||||
});
|
}
|
||||||
});
|
expect(thrown).toBeInstanceOf(ServerLockError);
|
||||||
|
expect(thrown.code).toBe('EISDIR');
|
||||||
test('removes stale lock and reacquires it', () => {
|
expect(thrown.message).toContain('E_SERVER_LOCK (EISDIR)');
|
||||||
withTempDir((dir) => {
|
expect(thrown.message).toContain(lockPath);
|
||||||
const lockPath = path.join(dir, 'browse.json.lock');
|
|
||||||
fs.writeFileSync(lockPath, 'not-a-pid\n');
|
|
||||||
|
|
||||||
const release = acquireServerLock(lockPath);
|
|
||||||
|
|
||||||
expect(release).toBeFunction();
|
|
||||||
expect(fs.readFileSync(lockPath, 'utf-8').trim()).toBe(String(process.pid));
|
|
||||||
release?.();
|
|
||||||
expect(fs.existsSync(lockPath)).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { handleReadCommand as _handleReadCommand } from '../src/read-commands';
|
|||||||
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
|
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
|
||||||
|
|
||||||
const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
||||||
_handleReadCommand(cmd, args, b.getActiveSession());
|
_handleReadCommand(cmd, args, b.getActiveSession(), b);
|
||||||
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
||||||
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
|
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
|
||||||
import { generateCompareHtml } from '../../design/src/compare';
|
import { generateCompareHtml } from '../../design/src/compare';
|
||||||
|
|||||||
@@ -32,9 +32,13 @@ const CONTENT_SCRIPT_SENDER = { id: OWN_ID, url: 'https://evil.example/page', ta
|
|||||||
const FOREIGN_SENDER = { id: FOREIGN_ID, url: `chrome-extension://${FOREIGN_ID}/background.html` };
|
const FOREIGN_SENDER = { id: FOREIGN_ID, url: `chrome-extension://${FOREIGN_ID}/background.html` };
|
||||||
const NO_URL_SENDER = { id: OWN_ID };
|
const NO_URL_SENDER = { id: OWN_ID };
|
||||||
|
|
||||||
|
// 'sidebar-command' is no longer a message type at all — the chat-queue path
|
||||||
|
// was ripped along with the /sidebar-command endpoint, so background.js now
|
||||||
|
// rejects it pre-gate as an unknown type (no response, nothing to leak). It is
|
||||||
|
// pinned separately below as a representative unknown type.
|
||||||
const PRIVILEGED = [
|
const PRIVILEGED = [
|
||||||
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
|
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
|
||||||
'command', 'sidebar-command', 'getTabState',
|
'command', 'getTabState',
|
||||||
];
|
];
|
||||||
// Content-script-originated flows that must keep working.
|
// Content-script-originated flows that must keep working.
|
||||||
const CONTENT_SCRIPT_TYPES = ['openSidePanel', 'elementPicked', 'pickerCancelled', 'inspectResult'];
|
const CONTENT_SCRIPT_TYPES = ['openSidePanel', 'elementPicked', 'pickerCancelled', 'inspectResult'];
|
||||||
@@ -208,6 +212,16 @@ describe('background.js onMessage listener (behavioral)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('retired sidebar-command type is rejected pre-gate with no response and no leaks', () => {
|
||||||
|
// Even from the most-trusted sender shape, a type outside ALLOWED_TYPES
|
||||||
|
// never reaches a handler: no sendResponse, no token/port fields possible.
|
||||||
|
for (const sender of [PAGE_SENDER, CONTENT_SCRIPT_SENDER, FOREIGN_SENDER, NO_URL_SENDER]) {
|
||||||
|
const r = dispatch(listener, { type: 'sidebar-command', message: 'hi' }, sender);
|
||||||
|
expect(r.responded).toBe(false);
|
||||||
|
expectDenied(r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('denied setPort never persists the attacker port', () => {
|
test('denied setPort never persists the attacker port', () => {
|
||||||
const before = calls.storageSet.length;
|
const before = calls.storageSet.length;
|
||||||
const r = dispatch(listener, { type: 'setPort', port: 6666 }, CONTENT_SCRIPT_SENDER);
|
const r = dispatch(listener, { type: 'setPort', port: 6666 }, CONTENT_SCRIPT_SENDER);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
writeSecureFile,
|
writeSecureFile,
|
||||||
appendSecureFile,
|
appendSecureFile,
|
||||||
mkdirSecure,
|
mkdirSecure,
|
||||||
|
repairBrokenDacl,
|
||||||
__resetWarnedForTests,
|
__resetWarnedForTests,
|
||||||
} from '../src/file-permissions';
|
} from '../src/file-permissions';
|
||||||
|
|
||||||
@@ -175,4 +176,35 @@ describe('mkdirSecure', () => {
|
|||||||
expect(fs.existsSync(path.join(tmpDir, 'a', 'b'))).toBe(true);
|
expect(fs.existsSync(path.join(tmpDir, 'a', 'b'))).toBe(true);
|
||||||
expect(fs.existsSync(d)).toBe(true);
|
expect(fs.existsSync(d)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('created directory is listable by the creating process', () => {
|
||||||
|
// #1605 contract: whatever ACL hardening happens, the client must be
|
||||||
|
// able to read its own state dir immediately after creation.
|
||||||
|
const d = path.join(tmpDir, 'state');
|
||||||
|
mkdirSecure(d);
|
||||||
|
fs.writeFileSync(path.join(d, 'browse.json'), '{}');
|
||||||
|
expect(fs.readdirSync(d)).toContain('browse.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repairBrokenDacl', () => {
|
||||||
|
test('is a no-op on non-Windows platforms', () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
const d = path.join(tmpDir, 'dir');
|
||||||
|
fs.mkdirSync(d);
|
||||||
|
expect(() => repairBrokenDacl(d)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on Windows, does not throw and directory stays listable', () => {
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
const d = path.join(tmpDir, 'dir');
|
||||||
|
fs.mkdirSync(d);
|
||||||
|
expect(() => repairBrokenDacl(d)).not.toThrow();
|
||||||
|
expect(() => fs.readdirSync(d)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on Windows, swallows icacls failure on a nonexistent path', () => {
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
expect(() => repairBrokenDacl(path.join(tmpDir, 'nonexistent'))).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,12 +15,20 @@ const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-config');
|
|||||||
let stateDir: string;
|
let stateDir: string;
|
||||||
|
|
||||||
function run(args: string[] = [], extraEnv: Record<string, string> = {}) {
|
function run(args: string[] = [], extraEnv: Record<string, string> = {}) {
|
||||||
|
// The script resolves its state dir as GSTACK_STATE_ROOT > GSTACK_HOME >
|
||||||
|
// GSTACK_STATE_DIR > $HOME/.gstack. Strip the higher-precedence vars so a
|
||||||
|
// stray value in the harness env (another test file's leftovers, operator
|
||||||
|
// shell) can never outrank the per-test GSTACK_STATE_DIR isolation.
|
||||||
|
const env: Record<string, string | undefined> = {
|
||||||
|
...process.env,
|
||||||
|
GSTACK_STATE_DIR: stateDir,
|
||||||
|
};
|
||||||
|
delete env.GSTACK_STATE_ROOT;
|
||||||
|
delete env.GSTACK_HOME;
|
||||||
|
Object.assign(env, extraEnv); // per-test overrides always win, deliberately
|
||||||
|
|
||||||
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
|
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
|
||||||
env: {
|
env,
|
||||||
...process.env,
|
|
||||||
GSTACK_STATE_DIR: stateDir,
|
|
||||||
...extraEnv,
|
|
||||||
},
|
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,14 +17,21 @@ let gstackDir: string;
|
|||||||
let stateDir: string;
|
let stateDir: string;
|
||||||
|
|
||||||
function run(extraEnv: Record<string, string> = {}, args: string[] = []) {
|
function run(extraEnv: Record<string, string> = {}, args: string[] = []) {
|
||||||
|
// gstack-config (which this script shells out to for update_check) resolves
|
||||||
|
// state as GSTACK_STATE_ROOT > GSTACK_HOME > GSTACK_STATE_DIR > ~/.gstack.
|
||||||
|
// Strip the higher-precedence vars so harness-env leftovers can never
|
||||||
|
// outrank the per-test GSTACK_STATE_DIR isolation.
|
||||||
|
const env: Record<string, string | undefined> = {
|
||||||
|
...process.env,
|
||||||
|
GSTACK_DIR: gstackDir,
|
||||||
|
GSTACK_STATE_DIR: stateDir,
|
||||||
|
GSTACK_REMOTE_URL: `file://${join(gstackDir, 'REMOTE_VERSION')}`,
|
||||||
|
};
|
||||||
|
delete env.GSTACK_STATE_ROOT;
|
||||||
|
delete env.GSTACK_HOME;
|
||||||
|
Object.assign(env, extraEnv); // per-test overrides always win, deliberately
|
||||||
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
|
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
|
||||||
env: {
|
env,
|
||||||
...process.env,
|
|
||||||
GSTACK_DIR: gstackDir,
|
|
||||||
GSTACK_STATE_DIR: stateDir,
|
|
||||||
GSTACK_REMOTE_URL: `file://${join(gstackDir, 'REMOTE_VERSION')}`,
|
|
||||||
...extraEnv,
|
|
||||||
},
|
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Pair-agent opt-in gate.
|
||||||
|
*
|
||||||
|
* The remote pair-agent (ngrok tunnel) is OFF by default. All three activation
|
||||||
|
* points — CLI auto-start, the /tunnel/start route, and the BROWSE_TUNNEL=1
|
||||||
|
* startup path — route through the single `isPairAgentEnabled()` guard. This
|
||||||
|
* test pins the guard's behavior (the root cause) plus a source-level tripwire
|
||||||
|
* that each call site actually consults it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, afterEach } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { isPairAgentEnabled } from '../src/config';
|
||||||
|
|
||||||
|
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 savedEnv = { GSTACK_HOME: process.env.GSTACK_HOME, GSTACK_PAIR_AGENT: process.env.GSTACK_PAIR_AGENT };
|
||||||
|
const tmpHomes: string[] = [];
|
||||||
|
|
||||||
|
function tmpHomeWith(config: Record<string, string> | null): string {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pair-'));
|
||||||
|
tmpHomes.push(dir);
|
||||||
|
if (config !== null) {
|
||||||
|
// Canonical store: flat YAML lines, the shape bin/gstack-config writes.
|
||||||
|
const yaml = Object.entries(config).map(([k, v]) => `${k}: ${v}`).join('\n') + '\n';
|
||||||
|
fs.writeFileSync(path.join(dir, 'config.yaml'), yaml);
|
||||||
|
}
|
||||||
|
process.env.GSTACK_HOME = dir;
|
||||||
|
delete process.env.GSTACK_PAIR_AGENT;
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const k of ['GSTACK_HOME', 'GSTACK_PAIR_AGENT'] as const) {
|
||||||
|
if (savedEnv[k] === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = savedEnv[k];
|
||||||
|
}
|
||||||
|
while (tmpHomes.length) fs.rmSync(tmpHomes.pop()!, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPairAgentEnabled — fail-closed default', () => {
|
||||||
|
test('OFF when no config store exists', () => {
|
||||||
|
tmpHomeWith(null);
|
||||||
|
expect(isPairAgentEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFF when config has no pair_agent key', () => {
|
||||||
|
tmpHomeWith({ telemetry: 'off' });
|
||||||
|
expect(isPairAgentEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ON via the config.json fallback shape too', () => {
|
||||||
|
const dir = tmpHomeWith(null);
|
||||||
|
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ pair_agent: 'on' }));
|
||||||
|
expect(isPairAgentEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFF when pair_agent is explicitly "off"', () => {
|
||||||
|
tmpHomeWith({ pair_agent: 'off' });
|
||||||
|
expect(isPairAgentEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ON only when pair_agent is exactly "on"', () => {
|
||||||
|
tmpHomeWith({ pair_agent: 'on' });
|
||||||
|
expect(isPairAgentEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFF when the store is malformed (fail-closed)', () => {
|
||||||
|
const dir = tmpHomeWith(null);
|
||||||
|
fs.writeFileSync(path.join(dir, 'config.yaml'), 'pair_agent: banana\n');
|
||||||
|
fs.writeFileSync(path.join(dir, 'config.json'), '{ not json');
|
||||||
|
expect(isPairAgentEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('env override wins: GSTACK_PAIR_AGENT=on forces ON even with config off', () => {
|
||||||
|
tmpHomeWith({ pair_agent: 'off' });
|
||||||
|
process.env.GSTACK_PAIR_AGENT = 'on';
|
||||||
|
expect(isPairAgentEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('env override wins: GSTACK_PAIR_AGENT=off forces OFF even with config on', () => {
|
||||||
|
tmpHomeWith({ pair_agent: 'on' });
|
||||||
|
process.env.GSTACK_PAIR_AGENT = 'off';
|
||||||
|
expect(isPairAgentEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gate wiring — every tunnel activation point consults the guard', () => {
|
||||||
|
test('CLI auto-start is gated (never auto-starts when disabled)', () => {
|
||||||
|
// pairEnabled short-circuits the ngrok probe so the tunnel can't auto-start.
|
||||||
|
expect(CLI_SRC).toContain('const pairEnabled = isPairAgentEnabled();');
|
||||||
|
expect(CLI_SRC).toContain('const ngrokAvailable = pairEnabled && isNgrokAvailable();');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLI consent-off branch names the real remedy, never ngrok reinstall', () => {
|
||||||
|
// When pair_agent is off but ngrok is installed+authed, telling the user
|
||||||
|
// to `ngrok config add-authtoken` can never fix it — the gate is consent,
|
||||||
|
// not tooling. The consent branch must carry the same remedy wording as
|
||||||
|
// the /tunnel/start 403 body, and must not mention ngrok setup.
|
||||||
|
const branchAt = CLI_SRC.indexOf('} else if (!pairEnabled) {');
|
||||||
|
expect(branchAt).toBeGreaterThan(-1);
|
||||||
|
const branchEnd = CLI_SRC.indexOf('} else {', branchAt);
|
||||||
|
expect(branchEnd).toBeGreaterThan(branchAt);
|
||||||
|
const branch = CLI_SRC.slice(branchAt, branchEnd);
|
||||||
|
expect(branch).toContain('gstack-config set pair_agent on');
|
||||||
|
expect(branch).toContain('/pair-agent');
|
||||||
|
expect(branch).not.toContain('ngrok config add-authtoken');
|
||||||
|
expect(branch).not.toContain('install ngrok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/tunnel/start refuses with the enable hint when disabled', () => {
|
||||||
|
const startIdx = SERVER_SRC.indexOf("url.pathname === '/tunnel/start'");
|
||||||
|
const block = SERVER_SRC.slice(startIdx, startIdx + 1200);
|
||||||
|
expect(block).toContain('if (!isPairAgentEnabled())');
|
||||||
|
expect(block).toContain('gstack-config set pair_agent on');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('BROWSE_TUNNEL=1 startup skips tunnel bind when disabled', () => {
|
||||||
|
expect(SERVER_SRC).toContain("process.env.BROWSE_TUNNEL === '1' && !isPairAgentEnabled()");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the extracted poisoned-bundle self-heal probe (#2242).
|
||||||
|
*
|
||||||
|
* probePoisonedChromiumBundle() detects a Chromium bundle mutated by the
|
||||||
|
* pre-v1.64 in-place rebrand (Info.plist contains "GStack Browser"),
|
||||||
|
* removes it so `playwright install chromium` actually re-downloads, and
|
||||||
|
* throws a typed PoisonedBundleError with the remediation command.
|
||||||
|
*
|
||||||
|
* Contracts pinned here:
|
||||||
|
* - standard cache layout (chromium-<rev>/chrome-mac/<name>.app): the
|
||||||
|
* WHOLE revision dir is removed, INSTALLATION_COMPLETE marker included
|
||||||
|
* (leaving the marker makes the recommended re-fetch a no-op)
|
||||||
|
* - non-cache layout: the .app + sibling install markers are removed,
|
||||||
|
* nothing else
|
||||||
|
* - clean bundle: untouched, no throw
|
||||||
|
* - GSTACK_CHROMIUM_PATH bundles (custom/embedder) are NEVER deleted:
|
||||||
|
* the probe refuses to act on that executable, and both call sites
|
||||||
|
* (launchHeaded + handoff) only pass chromium.executablePath()
|
||||||
|
* - the rethrow guard at the call sites is typed (instanceof), not a
|
||||||
|
* fragile message-string match
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { probePoisonedChromiumBundle, PoisonedBundleError } from '../src/browser-manager';
|
||||||
|
|
||||||
|
const SRC = fs.readFileSync(
|
||||||
|
path.join(import.meta.dir, '..', 'src', 'browser-manager.ts'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const POISONED_PLIST =
|
||||||
|
'<plist><dict><key>CFBundleName</key><string>GStack Browser</string></dict></plist>';
|
||||||
|
const CLEAN_PLIST =
|
||||||
|
'<plist><dict><key>CFBundleName</key><string>Google Chrome for Testing</string></dict></plist>';
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let savedCustomPath: string | undefined;
|
||||||
|
|
||||||
|
/** Build <parentDir>/<name>.app with a plist and executable; return the executable path. */
|
||||||
|
function makeApp(parentDir: string, plist: string): { appDir: string; exe: string } {
|
||||||
|
const appDir = path.join(parentDir, 'Google Chrome for Testing.app');
|
||||||
|
const macos = path.join(appDir, 'Contents', 'MacOS');
|
||||||
|
fs.mkdirSync(macos, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(appDir, 'Contents', 'Info.plist'), plist);
|
||||||
|
const exe = path.join(macos, 'Google Chrome for Testing');
|
||||||
|
fs.writeFileSync(exe, '#!/bin/sh\n', { mode: 0o755 });
|
||||||
|
return { appDir, exe };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'poison-probe-'));
|
||||||
|
savedCustomPath = process.env.GSTACK_CHROMIUM_PATH;
|
||||||
|
delete process.env.GSTACK_CHROMIUM_PATH;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
if (savedCustomPath === undefined) delete process.env.GSTACK_CHROMIUM_PATH;
|
||||||
|
else process.env.GSTACK_CHROMIUM_PATH = savedCustomPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('probePoisonedChromiumBundle — poisoned cache bundle', () => {
|
||||||
|
test('standard cache layout: whole chromium-<rev> dir removed (markers included), typed error thrown', () => {
|
||||||
|
const revDir = path.join(tmpDir, 'ms-playwright', 'chromium-1234');
|
||||||
|
const { exe } = makeApp(path.join(revDir, 'chrome-mac'), POISONED_PLIST);
|
||||||
|
fs.writeFileSync(path.join(revDir, 'INSTALLATION_COMPLETE'), '');
|
||||||
|
fs.writeFileSync(path.join(revDir, 'DEPENDENCIES_VALIDATED'), '');
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
probePoisonedChromiumBundle(exe);
|
||||||
|
} catch (err) {
|
||||||
|
caught = err;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(PoisonedBundleError);
|
||||||
|
// The message is the user's remediation — it must carry the command.
|
||||||
|
expect((caught as Error).message).toContain('playwright install chromium');
|
||||||
|
// Whole revision dir gone: leaving INSTALLATION_COMPLETE behind makes
|
||||||
|
// `playwright install chromium` no-op ("is already downloaded") and the
|
||||||
|
// remediation we just printed would do nothing.
|
||||||
|
expect(fs.existsSync(revDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-cache layout: only the .app + sibling install markers removed, neighbors survive', () => {
|
||||||
|
const parentDir = path.join(tmpDir, 'custom-bundles');
|
||||||
|
const { appDir, exe } = makeApp(parentDir, POISONED_PLIST);
|
||||||
|
fs.writeFileSync(path.join(parentDir, 'INSTALLATION_COMPLETE'), '');
|
||||||
|
fs.writeFileSync(path.join(parentDir, 'DEPENDENCIES_VALIDATED'), '');
|
||||||
|
fs.writeFileSync(path.join(parentDir, 'unrelated.txt'), 'keep me');
|
||||||
|
|
||||||
|
expect(() => probePoisonedChromiumBundle(exe)).toThrow(PoisonedBundleError);
|
||||||
|
expect(fs.existsSync(appDir)).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(parentDir, 'INSTALLATION_COMPLETE'))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(parentDir, 'DEPENDENCIES_VALIDATED'))).toBe(false);
|
||||||
|
// The parent dir itself and unrelated files are NOT swept.
|
||||||
|
expect(fs.readFileSync(path.join(parentDir, 'unrelated.txt'), 'utf-8')).toBe('keep me');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('probePoisonedChromiumBundle — clean and missing bundles', () => {
|
||||||
|
test('clean plist: untouched, no throw', () => {
|
||||||
|
const revDir = path.join(tmpDir, 'ms-playwright', 'chromium-1234');
|
||||||
|
const { appDir, exe } = makeApp(path.join(revDir, 'chrome-mac'), CLEAN_PLIST);
|
||||||
|
fs.writeFileSync(path.join(revDir, 'INSTALLATION_COMPLETE'), '');
|
||||||
|
|
||||||
|
expect(() => probePoisonedChromiumBundle(exe)).not.toThrow();
|
||||||
|
expect(fs.existsSync(path.join(appDir, 'Contents', 'Info.plist'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(revDir, 'INSTALLATION_COMPLETE'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no plist at the probed path: no-op, no throw (bundle not installed yet)', () => {
|
||||||
|
expect(() =>
|
||||||
|
probePoisonedChromiumBundle(path.join(tmpDir, 'nope.app', 'Contents', 'MacOS', 'nope')),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('probePoisonedChromiumBundle — GSTACK_CHROMIUM_PATH is never deleted', () => {
|
||||||
|
test('probe refuses to act on the GSTACK_CHROMIUM_PATH executable, even when poisoned', () => {
|
||||||
|
// A custom/embedder bundle (GStack Browser.app wrapper) legitimately
|
||||||
|
// contains "GStack Browser" in its plist — that is its branding, not
|
||||||
|
// cache poison. Deleting it would destroy the embedder's product.
|
||||||
|
const { appDir, exe } = makeApp(path.join(tmpDir, 'GStack Browser.app-parent'), POISONED_PLIST);
|
||||||
|
process.env.GSTACK_CHROMIUM_PATH = exe;
|
||||||
|
|
||||||
|
expect(() => probePoisonedChromiumBundle(exe)).not.toThrow();
|
||||||
|
expect(fs.existsSync(path.join(appDir, 'Contents', 'Info.plist'))).toBe(true);
|
||||||
|
expect(fs.existsSync(exe)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caller contract: both headed launch paths probe chromium.executablePath() only', () => {
|
||||||
|
// launchHeaded + handoff each call the probe with the Playwright-cache
|
||||||
|
// path. No call site may ever pass the custom-bundle env var.
|
||||||
|
const calls = SRC.match(/probePoisonedChromiumBundle\(chromium\.executablePath\(\)\)/g) || [];
|
||||||
|
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(SRC).not.toMatch(/probePoisonedChromiumBundle\([^)]*GSTACK_CHROMIUM_PATH/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('typed rethrow guard at the call sites', () => {
|
||||||
|
test('instanceof PoisonedBundleError, not message-string sniffing', () => {
|
||||||
|
expect(SRC).not.toContain("includes('poisoned bundle')");
|
||||||
|
expect(SRC).toMatch(/instanceof PoisonedBundleError/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,10 +54,16 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
|
|||||||
expect(isProcessAlive(2147483646)).toBe(false);
|
expect(isProcessAlive(2147483646)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('3. isProcessAlive spawns NO subprocess', () => {
|
test('3. isProcessAlive spawns NO subprocess on POSIX (signal-0 path)', () => {
|
||||||
// The heart of the bug: a liveness probe that forks is slow enough to
|
// The heart of the bug: a liveness probe that forks is slow enough to
|
||||||
// time out, and a timed-out probe silently answers "dead". Signal 0
|
// time out, and a timed-out probe silently answers "dead". Signal 0
|
||||||
// cannot time out because it never leaves the process.
|
// cannot time out because it never leaves the process.
|
||||||
|
//
|
||||||
|
// Merged design note: on win32 the helper DOES keep a single hardened
|
||||||
|
// tasklist probe (windowsHide, bounded timeout, quoted-CSV PID match)
|
||||||
|
// because Bun's process.kill(pid, 0) throws ESRCH for live Windows PIDs
|
||||||
|
// in compiled binaries. The POSIX path stays subprocess-free.
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
const origSpawn = (Bun as any).spawn;
|
const origSpawn = (Bun as any).spawn;
|
||||||
const origSpawnSync = (Bun as any).spawnSync;
|
const origSpawnSync = (Bun as any).spawnSync;
|
||||||
const spawns: string[] = [];
|
const spawns: string[] = [];
|
||||||
@@ -73,11 +79,16 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('4. no source file probes liveness via tasklist', () => {
|
test('4. no source file probes liveness via tasklist outside the central helper', () => {
|
||||||
// Static tripwire: re-introducing a tasklist-based existence check
|
// Static tripwire: ad-hoc tasklist existence checks scattered across src/
|
||||||
// anywhere in src/ resurrects the false-negative class.
|
// resurrect the false-negative class (each call site re-invents the
|
||||||
|
// timeout/parse handling and gets it subtly wrong). The ONE sanctioned
|
||||||
|
// site is error-handling.ts's isProcessAlive win32 branch — centralized,
|
||||||
|
// windowsHide, bounded timeout, quoted-CSV `"${pid}"` match. Every other
|
||||||
|
// file must route through the helper.
|
||||||
const offenders: string[] = [];
|
const offenders: string[] = [];
|
||||||
for (const { file, content } of readAllSourceFiles()) {
|
for (const { file, content } of readAllSourceFiles()) {
|
||||||
|
if (file === 'error-handling.ts') continue; // the canonical helper
|
||||||
const code = stripComments(content);
|
const code = stripComments(content);
|
||||||
// `PID eq` is the existence-probe form specifically. Other tasklist
|
// `PID eq` is the existence-probe form specifically. Other tasklist
|
||||||
// uses (e.g. IMAGENAME filters for browser detection) are unaffected.
|
// uses (e.g. IMAGENAME filters for browser detection) are unaffected.
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* #2242 bug 1 regression tripwire: never mutate the signed Chrome-for-Testing
|
||||||
|
* bundle.
|
||||||
|
*
|
||||||
|
* The old launchHeaded() "rebrand" ran a global
|
||||||
|
* `.replace(/Google Chrome for Testing/g, 'GStack Browser')` over the
|
||||||
|
* bundle's Info.plist — which renamed CFBundleExecutable to a binary that
|
||||||
|
* doesn't exist — and overwrote Resources/*.icns. Both writes broke the
|
||||||
|
* codesign seal: GPU process exit_code=5, headed mode dead on macOS 26
|
||||||
|
* (#2242, #2138, #2139).
|
||||||
|
*
|
||||||
|
* Static invariant (same pattern as cdp-session-cleanup.test.ts): the
|
||||||
|
* browser lifecycle code must contain NO write into the Chromium .app
|
||||||
|
* bundle. Branding lives in the wrapper .app / custom GBrowser build.
|
||||||
|
* These assertions fail on the pre-fix code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const SRC = fs.readFileSync(
|
||||||
|
path.join(import.meta.dir, '..', 'src', 'browser-manager.ts'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('#2242: signed Chromium bundle is never mutated', () => {
|
||||||
|
test('no global Google-Chrome-for-Testing plist replace', () => {
|
||||||
|
expect(SRC).not.toContain("replace(/Google Chrome for Testing/g");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no Info.plist write into the Chromium bundle (reads allowed: self-heal probe)', () => {
|
||||||
|
// The old code built `Info.plist` under the bundle's Contents dir and
|
||||||
|
// wrote it back. Any reappearance of a WRITE is a regression. The
|
||||||
|
// launch-time self-heal legitimately READS the plist to detect bundles
|
||||||
|
// the old code already poisoned (EV4), so the path construction itself
|
||||||
|
// is allowed — writes into it are not.
|
||||||
|
expect(SRC).not.toMatch(/writeFileSync\(\s*chromePlist/);
|
||||||
|
const plistWrites = SRC.match(/writeFileSync\([^)]*[Pp]list/g) || [];
|
||||||
|
expect(plistWrites).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no icon overwrite into the Chromium bundle Resources dir', () => {
|
||||||
|
expect(SRC).not.toMatch(/copyFileSync\([^)]*destIcon/);
|
||||||
|
expect(SRC).not.toContain("CFBundleIconFile");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the tombstone comment documenting why stays put', () => {
|
||||||
|
// If someone deletes the explanation, the next contributor reintroduces
|
||||||
|
// the mutation in good faith. Keep the why next to the where.
|
||||||
|
expect(SRC).toContain('#2242');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* acquireServerLock error honesty (#1084 regression).
|
||||||
|
*
|
||||||
|
* The old code wrapped fs.openSync(lockPath, 'wx') in a bare `catch {}` —
|
||||||
|
* EVERY errno (EACCES, EIO, ENOSPC, ENOENT) fell into the "lock already
|
||||||
|
* held" path and surfaced as "another instance is starting the server",
|
||||||
|
* a phantom 15s contention timeout that masked the real filesystem error.
|
||||||
|
*
|
||||||
|
* New contract:
|
||||||
|
* - EEXIST + live holder → null (real contention)
|
||||||
|
* - EEXIST + dead holder → stale lock removed, acquired
|
||||||
|
* - ENOENT (dir missing) → create dir, retry once, acquired
|
||||||
|
* - anything else → ServerLockError with the real errno
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, afterAll } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
// Default (CJS) export — its properties are mutable in Bun, unlike the frozen
|
||||||
|
// `* as fs` namespace, and mutations propagate to cli.ts's own fs import.
|
||||||
|
// Used only for the depth-cap livelock simulations below (restored in finally).
|
||||||
|
import fsMutable from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { acquireServerLock, ServerLockError } from '../src/cli';
|
||||||
|
|
||||||
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-lock-'));
|
||||||
|
afterAll(() => {
|
||||||
|
// Restore write perm so cleanup can delete the read-only dir.
|
||||||
|
try { fs.chmodSync(path.join(tmpRoot, 'rodir'), 0o700); } catch {}
|
||||||
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('acquireServerLock (#1084 error honesty)', () => {
|
||||||
|
test('happy path: acquires and releases', () => {
|
||||||
|
const lockPath = path.join(tmpRoot, 'happy.lock');
|
||||||
|
const release = acquireServerLock(lockPath);
|
||||||
|
expect(release).not.toBeNull();
|
||||||
|
expect(fs.readFileSync(lockPath, 'utf8').trim()).toBe(String(process.pid));
|
||||||
|
release!();
|
||||||
|
expect(fs.existsSync(lockPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EACCES throws ServerLockError with the real errno — NOT phantom contention', () => {
|
||||||
|
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod semantics differ
|
||||||
|
const rodir = path.join(tmpRoot, 'rodir');
|
||||||
|
fs.mkdirSync(rodir, { recursive: true });
|
||||||
|
fs.chmodSync(rodir, 0o500); // r-x: open('wx') inside fails EACCES
|
||||||
|
const lockPath = path.join(rodir, 'browse.json.lock');
|
||||||
|
let thrown: any = null;
|
||||||
|
try {
|
||||||
|
acquireServerLock(lockPath); // old code: returned null (phantom contention)
|
||||||
|
} catch (err) {
|
||||||
|
thrown = err;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(ServerLockError);
|
||||||
|
expect(thrown.code).toBe('EACCES');
|
||||||
|
expect(thrown.message).toContain('E_SERVER_LOCK (EACCES)');
|
||||||
|
expect(thrown.message).toContain(lockPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ENOENT (missing lock dir) creates the dir and acquires', () => {
|
||||||
|
const lockPath = path.join(tmpRoot, 'newdir', 'browse.json.lock');
|
||||||
|
// old code: openSync ENOENT → bare catch → readFileSync ENOENT → null
|
||||||
|
const release = acquireServerLock(lockPath);
|
||||||
|
expect(release).not.toBeNull();
|
||||||
|
expect(fs.existsSync(lockPath)).toBe(true);
|
||||||
|
release!();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EEXIST + dead holder: removes stale lock and acquires', () => {
|
||||||
|
const lockPath = path.join(tmpRoot, 'stale.lock');
|
||||||
|
fs.writeFileSync(lockPath, '999999999\n'); // PID that cannot be alive
|
||||||
|
const release = acquireServerLock(lockPath);
|
||||||
|
expect(release).not.toBeNull();
|
||||||
|
release!();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EEXIST + live holder: returns null (real contention, no throw)', () => {
|
||||||
|
const lockPath = path.join(tmpRoot, 'live.lock');
|
||||||
|
fs.writeFileSync(lockPath, `${process.pid}\n`); // this test process is alive
|
||||||
|
expect(acquireServerLock(lockPath)).toBeNull();
|
||||||
|
fs.unlinkSync(lockPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EEXIST + garbage lockfile content: NaN pid is treated as stale, lock acquired', () => {
|
||||||
|
const lockPath = path.join(tmpRoot, 'garbage.lock');
|
||||||
|
fs.writeFileSync(lockPath, 'not-a-pid\n'); // parseInt → NaN → falsy → stale path
|
||||||
|
const release = acquireServerLock(lockPath);
|
||||||
|
expect(release).not.toBeNull();
|
||||||
|
// Our pid replaced the garbage — the stale lock was removed and re-acquired.
|
||||||
|
expect(fs.readFileSync(lockPath, 'utf8').trim()).toBe(String(process.pid));
|
||||||
|
release!();
|
||||||
|
expect(fs.existsSync(lockPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: the "stale lock that survives unlink" livelock variant is deliberately
|
||||||
|
// not simulated here — the source removes locks through safeUnlink's own fs
|
||||||
|
// binding, which a test-side fs monkey-patch cannot reliably intercept in Bun.
|
||||||
|
// The depth cap itself is exercised by the vanish-race test below.
|
||||||
|
|
||||||
|
test('depth cap: holder that vanishes between open and read returns null after 5 retries', () => {
|
||||||
|
// The EEXIST → readFileSync ENOENT race: the lock exists at openSync but
|
||||||
|
// is gone by the read (holder released in between). Repeated forever
|
||||||
|
// (open/release storm), the same depth cap must bound the retry loop.
|
||||||
|
const lockPath = path.join(tmpRoot, 'vanish.lock');
|
||||||
|
fs.writeFileSync(lockPath, `${process.pid}\n`);
|
||||||
|
const origRead = fsMutable.readFileSync;
|
||||||
|
try {
|
||||||
|
(fsMutable as any).readFileSync = (p: fs.PathLike | number, ...rest: unknown[]) => {
|
||||||
|
if (p === lockPath) {
|
||||||
|
const e: NodeJS.ErrnoException = new Error('mock: lock vanished before read');
|
||||||
|
e.code = 'ENOENT';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return (origRead as any)(p, ...rest);
|
||||||
|
};
|
||||||
|
expect(acquireServerLock(lockPath)).toBeNull();
|
||||||
|
} finally {
|
||||||
|
(fsMutable as any).readFileSync = origRead;
|
||||||
|
fs.unlinkSync(lockPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Opt-in session-state persistence (#778, #2193, #1128, #1129).
|
||||||
|
*
|
||||||
|
* Pins the leg of the browser-lifecycle contract that had no coverage:
|
||||||
|
* "shut down without losing live session state." Pre-fix, the headless
|
||||||
|
* daemon used non-persistent chromium.launch() with zero storage
|
||||||
|
* persistence — any crash or binary-version auto-restart silently lost all
|
||||||
|
* auth. These tests fail on the old tree (module absent, no wiring).
|
||||||
|
*
|
||||||
|
* Suites:
|
||||||
|
* 1. Pure serialize/deserialize/filter units (free, instant).
|
||||||
|
* 2. Real-Chromium round-trip: cookie + localStorage survive a full
|
||||||
|
* manager teardown + relaunch via persist/restore.
|
||||||
|
* 3. Static wiring tripwire: server.ts restores at launch, snapshots at
|
||||||
|
* shutdown, and the gate is BROWSE_PERSIST_STATE (default off).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, afterAll } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
serializeSessionState, deserializeSessionState, filterSessionCookies,
|
||||||
|
isSessionPersistEnabled, persistSessionState, restoreSessionState,
|
||||||
|
} from '../src/session-persist';
|
||||||
|
import type { BrowserState } from '../src/browser-manager';
|
||||||
|
|
||||||
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-persist-'));
|
||||||
|
afterAll(() => { fs.rmSync(tmpRoot, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
describe('session-persist units', () => {
|
||||||
|
test('config gate: default off, exactly "1" enables', () => {
|
||||||
|
expect(isSessionPersistEnabled({} as NodeJS.ProcessEnv)).toBe(false);
|
||||||
|
expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: '0' } as any)).toBe(false);
|
||||||
|
expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: 'true' } as any)).toBe(false);
|
||||||
|
expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: '1' } as any)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serialize strips loadedHtml/owner, keeps cookies + storage', () => {
|
||||||
|
const state: BrowserState = {
|
||||||
|
cookies: [{ name: 'sid', value: 'abc', domain: 'example.com', path: '/', expires: -1, httpOnly: false, secure: false, sameSite: 'Lax' } as any],
|
||||||
|
pages: [{
|
||||||
|
url: 'https://example.com/app',
|
||||||
|
isActive: true,
|
||||||
|
storage: { localStorage: { k: 'v' }, sessionStorage: {} },
|
||||||
|
loadedHtml: '<script>evil</script>',
|
||||||
|
loadedHtmlWaitUntil: 'load',
|
||||||
|
owner: 'agent-1',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const raw = serializeSessionState(state);
|
||||||
|
expect(raw).not.toContain('loadedHtml');
|
||||||
|
expect(raw).not.toContain('evil');
|
||||||
|
expect(raw).not.toContain('owner');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
expect(parsed.version).toBe(1);
|
||||||
|
expect(parsed.cookies[0].name).toBe('sid');
|
||||||
|
expect(parsed.pages[0].storage.localStorage.k).toBe('v');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deserialize rejects corrupt JSON, wrong version, missing arrays', () => {
|
||||||
|
expect(deserializeSessionState('not json{')).toBeNull();
|
||||||
|
expect(deserializeSessionState('{"version":99,"cookies":[],"pages":[]}')).toBeNull();
|
||||||
|
expect(deserializeSessionState('{"version":1,"cookies":{}}')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deserialize strips loadedHtml/owner even if tampered onto disk', () => {
|
||||||
|
const raw = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
cookies: [],
|
||||||
|
pages: [{ url: 'https://x.com', isActive: true, storage: null, loadedHtml: '<h1>x</h1>', owner: 'evil' }],
|
||||||
|
});
|
||||||
|
const state = deserializeSessionState(raw)!;
|
||||||
|
expect((state.pages[0] as any).loadedHtml).toBeUndefined();
|
||||||
|
expect((state.pages[0] as any).owner).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cookie filter drops malformed + internal-network domains', () => {
|
||||||
|
const kept = filterSessionCookies([
|
||||||
|
{ name: 'ok', value: 'v', domain: 'example.com' },
|
||||||
|
{ name: 'ok2', value: 'v', domain: '.example.com' }, // leading-dot public domain kept
|
||||||
|
{ name: 'bad1', value: 'v', domain: 'localhost' },
|
||||||
|
{ name: 'bad2', value: 'v', domain: '.corp.internal' },
|
||||||
|
{ name: 'bad3', value: 'v', domain: '169.254.169.254' },
|
||||||
|
{ name: 'bad4', value: 'v', domain: '169.254.1.2' }, // whole link-local block, not just metadata
|
||||||
|
{ name: 'bad5', value: 'v', domain: '127.0.0.1' }, // IPv4 loopback literal
|
||||||
|
{ name: 'bad6', value: 'v', domain: '.127.0.0.1' }, // leading-dot loopback variant
|
||||||
|
{ name: 'bad7', value: 'v', domain: '::1' }, // IPv6 loopback
|
||||||
|
{ name: 'bad8', value: 'v', domain: '[::1]' }, // bracketed IPv6 loopback
|
||||||
|
{ name: 'bad9', value: 42, domain: 'example.com' },
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
expect(kept.map((c: any) => c.name)).toEqual(['ok', 'ok2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoreSessionState: missing file → null, corrupt file → quarantined to .corrupt', async () => {
|
||||||
|
const bmNeverCalled = { closeAllPages() { throw new Error('must not restore'); } } as any;
|
||||||
|
expect(await restoreSessionState(bmNeverCalled, path.join(tmpRoot, 'nope.json'))).toBeNull();
|
||||||
|
const corrupt = path.join(tmpRoot, 'corrupt.json');
|
||||||
|
fs.writeFileSync(corrupt, '{oops');
|
||||||
|
expect(await restoreSessionState(bmNeverCalled, corrupt)).toBeNull();
|
||||||
|
expect(fs.existsSync(corrupt)).toBe(false); // moved aside, won't block every future launch
|
||||||
|
expect(fs.existsSync(`${corrupt}.corrupt`)).toBe(true); // forensic artifact kept (R3)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persistSessionState is a no-op in headed mode (profile owns state)', async () => {
|
||||||
|
const file = path.join(tmpRoot, 'headed.json');
|
||||||
|
const bm = {
|
||||||
|
getConnectionMode: () => 'headed',
|
||||||
|
saveState() { throw new Error('must not snapshot headed session'); },
|
||||||
|
} as any;
|
||||||
|
await persistSessionState(bm, file);
|
||||||
|
expect(fs.existsSync(file)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persist writes atomically: no .tmp left behind, file parses', async () => {
|
||||||
|
const file = path.join(tmpRoot, 'atomic.json');
|
||||||
|
const state: BrowserState = {
|
||||||
|
cookies: [{ name: 'sid', value: 'abc', domain: 'example.com' } as any],
|
||||||
|
pages: [{ url: 'https://example.com', isActive: true, storage: null }],
|
||||||
|
};
|
||||||
|
const bm = { getConnectionMode: () => 'launched', saveState: async () => state } as any;
|
||||||
|
await persistSessionState(bm, file);
|
||||||
|
expect(fs.existsSync(`${file}.tmp`)).toBe(false); // staged copy renamed away
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||||
|
expect(parsed.cookies[0].name).toBe('sid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed snapshot write preserves the previous good snapshot', async () => {
|
||||||
|
// chmod-based read-only dirs don't bind on Windows or when running as root.
|
||||||
|
if (process.platform === 'win32' || process.getuid?.() === 0) return;
|
||||||
|
const dir = path.join(tmpRoot, 'ro');
|
||||||
|
fs.mkdirSync(dir);
|
||||||
|
const file = path.join(dir, 'session-state.json');
|
||||||
|
const goodState: BrowserState = {
|
||||||
|
cookies: [],
|
||||||
|
pages: [{ url: 'https://good.example', isActive: true, storage: null }],
|
||||||
|
};
|
||||||
|
const bm = { getConnectionMode: () => 'launched', saveState: async () => goodState } as any;
|
||||||
|
await persistSessionState(bm, file);
|
||||||
|
fs.chmodSync(dir, 0o500); // next .tmp write throws EACCES mid-persist
|
||||||
|
try {
|
||||||
|
await expect(persistSessionState(bm, file)).rejects.toThrow();
|
||||||
|
// The crash-mid-write scenario the feature exists to survive: the
|
||||||
|
// previous good snapshot is untouched and still parses.
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||||
|
expect(parsed.pages[0].url).toBe('https://good.example');
|
||||||
|
} finally {
|
||||||
|
fs.chmodSync(dir, 0o700);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session-persist round-trip (real Chromium)', () => {
|
||||||
|
test('cookie + localStorage + URL survive teardown → relaunch; loopback cookies dropped', async () => {
|
||||||
|
const { BrowserManager } = await import('../src/browser-manager');
|
||||||
|
const { startTestServer } = await import('./test-server');
|
||||||
|
const { server, url } = startTestServer(0);
|
||||||
|
const stateFile = path.join(tmpRoot, 'roundtrip.json');
|
||||||
|
|
||||||
|
const bm1 = new BrowserManager();
|
||||||
|
await bm1.launch();
|
||||||
|
try {
|
||||||
|
const page = bm1.getPage();
|
||||||
|
await page.goto(`${url}/basic.html`, { waitUntil: 'domcontentloaded' });
|
||||||
|
// Real-site cookie: set on the context for a non-loopback domain (the
|
||||||
|
// restore hygiene filter deliberately drops loopback/link-local
|
||||||
|
// domains, so a 127.0.0.1 test-server cookie can't stand in for it).
|
||||||
|
await page.context().addCookies([
|
||||||
|
{ name: 'session_marker', value: 'alive-after-restart', domain: 'example.com', path: '/' },
|
||||||
|
]);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.cookie = 'loopback_marker=must-be-dropped; path=/'; // 127.0.0.1 host cookie
|
||||||
|
localStorage.setItem('auth_marker', 'still-logged-in');
|
||||||
|
});
|
||||||
|
await persistSessionState(bm1, stateFile);
|
||||||
|
} finally {
|
||||||
|
await bm1.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// File on disk is owner-only (cookies are secrets).
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
expect(fs.statSync(stateFile).mode & 0o777).toBe(0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bm2 = new BrowserManager();
|
||||||
|
await bm2.launch();
|
||||||
|
try {
|
||||||
|
const restored = await restoreSessionState(bm2, stateFile);
|
||||||
|
expect(restored).not.toBeNull();
|
||||||
|
expect(restored!.pages.length).toBe(1); // counts derivable without a saveState() round-trip
|
||||||
|
// Hygiene filter applied at restore: the real-site cookie survives,
|
||||||
|
// the loopback cookie does not.
|
||||||
|
expect(restored!.cookies.map((c: any) => c.name)).toEqual(['session_marker']);
|
||||||
|
const page = bm2.getPage();
|
||||||
|
expect(page.url()).toContain('/basic.html');
|
||||||
|
const marker = await page.evaluate(() => ({
|
||||||
|
cookie: document.cookie,
|
||||||
|
auth: localStorage.getItem('auth_marker'),
|
||||||
|
}));
|
||||||
|
expect(marker.cookie).not.toContain('loopback_marker'); // dropped by isInternalCookieDomain
|
||||||
|
expect(marker.auth).toBe('still-logged-in');
|
||||||
|
const restoredCookies = await page.context().cookies('https://example.com');
|
||||||
|
expect(restoredCookies.map((c) => `${c.name}=${c.value}`)).toContain('session_marker=alive-after-restart');
|
||||||
|
} finally {
|
||||||
|
await bm2.close();
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('server wiring (static tripwire)', () => {
|
||||||
|
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '..', 'src', 'server.ts'), 'utf-8');
|
||||||
|
|
||||||
|
test('start() restores and schedules interval snapshots behind the gate', () => {
|
||||||
|
expect(SERVER_SRC).toContain('isSessionPersistEnabled()');
|
||||||
|
expect(SERVER_SRC).toContain('restoreSessionState(browserManager');
|
||||||
|
expect(SERVER_SRC).toContain('sessionPersistIntervalMs()');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start() restores in the background AFTER the port binds (CLI readiness must not wait)', () => {
|
||||||
|
// Restore re-creates tabs with up-to-15s goto timeouts; the CLI gives up
|
||||||
|
// at 8s. A restore that runs before Bun.serve() makes every $B command
|
||||||
|
// report "Server failed to start" on one slow saved URL.
|
||||||
|
const serveAt = SERVER_SRC.indexOf('const server = Bun.serve(');
|
||||||
|
const restoreAt = SERVER_SRC.indexOf('restoreSessionState(browserManager');
|
||||||
|
expect(serveAt).toBeGreaterThan(-1);
|
||||||
|
expect(restoreAt).toBeGreaterThan(serveAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('interval snapshots carry an in-flight guard (no overlapping persists)', () => {
|
||||||
|
expect(SERVER_SRC).toContain('persistInFlight');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('interval ticks are gated on isShuttingDown (belt half of the shutdown ordering fix)', () => {
|
||||||
|
// A tick that fires during browser teardown snapshots a degraded state
|
||||||
|
// (zero tabs) over the good final snapshot. The handle-clear in shutdown()
|
||||||
|
// is the suspenders; this gate is the belt for a tick already scheduled.
|
||||||
|
const tickerAt = SERVER_SRC.indexOf('sessionPersistInterval = setInterval(');
|
||||||
|
expect(tickerAt).toBeGreaterThan(-1);
|
||||||
|
const tickerBlock = SERVER_SRC.slice(tickerAt, tickerAt + 500);
|
||||||
|
expect(tickerBlock).toContain('if (isShuttingDown) return;');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shutdown() clears the persist ticker BEFORE the final snapshot (suspenders half)', () => {
|
||||||
|
const shutdownStart = SERVER_SRC.indexOf('async function shutdown(');
|
||||||
|
const clearAt = SERVER_SRC.indexOf('clearInterval(sessionPersistInterval)', shutdownStart);
|
||||||
|
const persistAt = SERVER_SRC.indexOf('persistSessionState(cfgBrowserManager', shutdownStart);
|
||||||
|
expect(clearAt).toBeGreaterThan(shutdownStart);
|
||||||
|
expect(persistAt).toBeGreaterThan(clearAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shutdown() takes a final snapshot BEFORE closing the browser', () => {
|
||||||
|
const shutdownStart = SERVER_SRC.indexOf('async function shutdown(');
|
||||||
|
const persistAt = SERVER_SRC.indexOf('persistSessionState(cfgBrowserManager', shutdownStart);
|
||||||
|
const closeAt = SERVER_SRC.indexOf('await cfgBrowserManager.close()', shutdownStart);
|
||||||
|
expect(persistAt).toBeGreaterThan(shutdownStart);
|
||||||
|
expect(closeAt).toBeGreaterThan(persistAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shutdown() snapshot is deadlined — a wedged page.evaluate cannot hang shutdown', () => {
|
||||||
|
const shutdownStart = SERVER_SRC.indexOf('async function shutdown(');
|
||||||
|
const closeAt = SERVER_SRC.indexOf('await cfgBrowserManager.close()', shutdownStart);
|
||||||
|
const raceAt = SERVER_SRC.indexOf('Promise.race', shutdownStart);
|
||||||
|
expect(raceAt).toBeGreaterThan(shutdownStart);
|
||||||
|
expect(raceAt).toBeLessThan(closeAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ import { handleMetaCommand } from '../src/meta-commands';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
||||||
_handleReadCommand(cmd, args, b.getActiveSession());
|
_handleReadCommand(cmd, args, b.getActiveSession(), b);
|
||||||
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
||||||
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
|
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { handleMetaCommand } from '../src/meta-commands';
|
||||||
|
|
||||||
|
describe('server control acknowledgement ordering', () => {
|
||||||
|
for (const [command, acknowledgement] of [
|
||||||
|
['stop', 'Server stopped'],
|
||||||
|
['restart', 'Restarting...'],
|
||||||
|
] as const) {
|
||||||
|
test(`${command} acknowledges before closing the listener`, async () => {
|
||||||
|
let shutdownCalls = 0;
|
||||||
|
const manager = { getActiveSession: () => ({}) } as any;
|
||||||
|
|
||||||
|
const result = await handleMetaCommand(command, [], manager, async () => {
|
||||||
|
shutdownCalls += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(acknowledgement);
|
||||||
|
expect(shutdownCalls).toBe(0);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
expect(shutdownCalls).toBe(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Telemetry consent tiers — the user-consent enforcement point.
|
||||||
|
*
|
||||||
|
* Telemetry is OPT-IN: it emits only when the user granted a tier through
|
||||||
|
* the consent prompt (`telemetry: community` or `telemetry: anonymous` in
|
||||||
|
* ~/.gstack/config.yaml). An absent key, an absent config file, an explicit
|
||||||
|
* `off`, or any unrecognized value all mean DISABLED — the same default
|
||||||
|
* bin/gstack-config's DEFAULTS table reports for an unset key, so a daemon
|
||||||
|
* spawned outside a skill preamble (direct $B use, embedders) can never
|
||||||
|
* emit while `gstack-config get telemetry` tells the user 'off'.
|
||||||
|
*
|
||||||
|
* The persistent tier reads through the shared flat-YAML helper in
|
||||||
|
* config.ts (readGstackConfigYamlKey), same parser as the pair-agent gate.
|
||||||
|
* Env tier: GSTACK_TELEMETRY_OFF=1 always disables; =0 is a harness-side
|
||||||
|
* consent assertion that covers the no-config default only — it never
|
||||||
|
* overrides an explicit `telemetry: off`.
|
||||||
|
*
|
||||||
|
* Harness mirrors pair-agent-optin-gate.test.ts: GSTACK_HOME → temp dir,
|
||||||
|
* env saved/restored per test, cache reset via _resetTelemetryCache.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, afterEach } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { isTelemetryDisabled, logTelemetry, _resetTelemetryCache } from '../src/telemetry';
|
||||||
|
|
||||||
|
const savedEnv = {
|
||||||
|
GSTACK_HOME: process.env.GSTACK_HOME,
|
||||||
|
GSTACK_TELEMETRY_OFF: process.env.GSTACK_TELEMETRY_OFF,
|
||||||
|
};
|
||||||
|
const tmpHomes: string[] = [];
|
||||||
|
|
||||||
|
/** Fresh GSTACK_HOME with the given config.yaml body (null = no file). */
|
||||||
|
function tmpHomeWith(configYaml: string | null): string {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-telemetry-optout-'));
|
||||||
|
tmpHomes.push(dir);
|
||||||
|
if (configYaml !== null) {
|
||||||
|
fs.writeFileSync(path.join(dir, 'config.yaml'), configYaml);
|
||||||
|
}
|
||||||
|
process.env.GSTACK_HOME = dir;
|
||||||
|
delete process.env.GSTACK_TELEMETRY_OFF;
|
||||||
|
_resetTelemetryCache();
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const k of ['GSTACK_HOME', 'GSTACK_TELEMETRY_OFF'] as const) {
|
||||||
|
if (savedEnv[k] === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = savedEnv[k]!;
|
||||||
|
}
|
||||||
|
_resetTelemetryCache();
|
||||||
|
while (tmpHomes.length) fs.rmSync(tmpHomes.pop()!, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('telemetry persistent opt-out tier (config.yaml)', () => {
|
||||||
|
test('DISABLED when config.yaml has plain `telemetry: off`', () => {
|
||||||
|
tmpHomeWith('telemetry: off\n');
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DISABLED when the value is single-quoted: telemetry: 'off'", () => {
|
||||||
|
tmpHomeWith("telemetry: 'off'\n");
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DISABLED when the value is double-quoted: telemetry: "off"', () => {
|
||||||
|
tmpHomeWith('telemetry: "off"\n');
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DISABLED with a trailing comment: telemetry: off # user opted out', () => {
|
||||||
|
tmpHomeWith('telemetry: off # user opted out\n');
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DISABLED when the key sits among other keys', () => {
|
||||||
|
tmpHomeWith('pair_agent: off\ntelemetry: off\nskill_prefix: none\n');
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ENABLED when the user granted the `anonymous` tier', () => {
|
||||||
|
tmpHomeWith('telemetry: anonymous\n');
|
||||||
|
expect(isTelemetryDisabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ENABLED when the user granted the `community` tier', () => {
|
||||||
|
tmpHomeWith('telemetry: community\n');
|
||||||
|
expect(isTelemetryDisabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DISABLED when the key is absent — consent was never granted', () => {
|
||||||
|
// bin/gstack-config's DEFAULTS table reports 'off' for an unset telemetry
|
||||||
|
// key; the daemon must agree or direct-$B spawns emit while the user is
|
||||||
|
// told telemetry is off (default-polarity split-brain).
|
||||||
|
tmpHomeWith('pair_agent: on\n');
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DISABLED when config.yaml does not exist — fresh installs emit nothing', () => {
|
||||||
|
tmpHomeWith(null);
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DISABLED on an unrecognized tier value (fail-closed)', () => {
|
||||||
|
tmpHomeWith('telemetry: banana\n');
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a commented-out consent line does not enable: `# telemetry: community`', () => {
|
||||||
|
tmpHomeWith('# telemetry: community\n');
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('telemetry env tier + cache semantics', () => {
|
||||||
|
test('GSTACK_TELEMETRY_OFF=1 disables even when config says anonymous', () => {
|
||||||
|
tmpHomeWith('telemetry: anonymous\n');
|
||||||
|
process.env.GSTACK_TELEMETRY_OFF = '1';
|
||||||
|
_resetTelemetryCache();
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GSTACK_TELEMETRY_OFF=0 never overrides an explicit `telemetry: off`', () => {
|
||||||
|
// The =0 hint is a harness-side consent assertion for scratch homes with
|
||||||
|
// no config store; a user's written opt-out always wins over it.
|
||||||
|
tmpHomeWith('telemetry: off\n');
|
||||||
|
process.env.GSTACK_TELEMETRY_OFF = '0';
|
||||||
|
_resetTelemetryCache();
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GSTACK_TELEMETRY_OFF=0 enables when no config store exists (harness seam)', () => {
|
||||||
|
tmpHomeWith(null);
|
||||||
|
process.env.GSTACK_TELEMETRY_OFF = '0';
|
||||||
|
_resetTelemetryCache();
|
||||||
|
expect(isTelemetryDisabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verdict is cached per process; _resetTelemetryCache re-reads config', () => {
|
||||||
|
const dir = tmpHomeWith('telemetry: anonymous\n');
|
||||||
|
expect(isTelemetryDisabled()).toBe(false);
|
||||||
|
// Opt out on disk mid-process: the cached verdict holds until reset.
|
||||||
|
fs.writeFileSync(path.join(dir, 'config.yaml'), 'telemetry: off\n');
|
||||||
|
expect(isTelemetryDisabled()).toBe(false);
|
||||||
|
_resetTelemetryCache();
|
||||||
|
expect(isTelemetryDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enforcement: logTelemetry writes only with granted consent', () => {
|
||||||
|
test('config-tier opt-out suppresses the JSONL append', async () => {
|
||||||
|
const dir = tmpHomeWith('telemetry: off\n');
|
||||||
|
logTelemetry({ event: 'domain_skill_fired', host: 'example.com' });
|
||||||
|
// Fire-and-forget path: give any (incorrect) async append time to land.
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(fs.existsSync(path.join(dir, 'analytics', 'browse-telemetry.jsonl'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no consent ever recorded (absent key) suppresses the JSONL append', async () => {
|
||||||
|
const dir = tmpHomeWith('pair_agent: on\n');
|
||||||
|
logTelemetry({ event: 'domain_skill_fired', host: 'example.com' });
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(fs.existsSync(path.join(dir, 'analytics', 'browse-telemetry.jsonl'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('granted `community` tier appends the event', async () => {
|
||||||
|
const dir = tmpHomeWith('telemetry: community\n');
|
||||||
|
logTelemetry({ event: 'domain_skill_fired', host: 'example.com' });
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
const file = path.join(dir, 'analytics', 'browse-telemetry.jsonl');
|
||||||
|
expect(fs.existsSync(file)).toBe(true);
|
||||||
|
expect(fs.readFileSync(file, 'utf-8')).toContain('domain_skill_fired');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { describe, test, expect, afterEach } from 'bun:test';
|
import { describe, test, expect, afterEach, beforeEach, mock } from 'bun:test';
|
||||||
import { spawn, type Subprocess } from 'bun';
|
import { spawn, type Subprocess } from 'bun';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { buildFetchHandler, __testInternals__, type ServerConfig } from '../src/server';
|
||||||
|
import { __resetRegistry } from '../src/token-registry';
|
||||||
|
import { resolveConfig } from '../src/config';
|
||||||
|
|
||||||
// End-to-end regression tests for the parent-process watchdog in server.ts.
|
// End-to-end regression tests for the parent-process watchdog in server.ts.
|
||||||
// The watchdog has layered behavior since v0.18.1.0 (#1025) and v0.18.2.0
|
// The watchdog has layered behavior since v0.18.1.0 (#1025) and v0.18.2.0
|
||||||
@@ -18,11 +22,9 @@ import * as os from 'os';
|
|||||||
// eventual cleanup.
|
// eventual cleanup.
|
||||||
//
|
//
|
||||||
// Tunnel mode coverage (parent dies → shutdown because idle timeout doesn't
|
// Tunnel mode coverage (parent dies → shutdown because idle timeout doesn't
|
||||||
// apply) is not covered by an automated test here — tunnelActive is a runtime
|
// apply) is covered behaviorally in the in-process suite at the bottom of this
|
||||||
// variable set by /pair-agent's tunnel-create flow, not an env var, so faking
|
// file: the tick is exported via __testInternals__.parentWatchdogTick (same
|
||||||
// it would require invasive test-only hooks. The mode check is documented
|
// seam as idleCheckTick) and tunnelActive is simulated via setTunnelActive.
|
||||||
// inline at the watchdog and SIGTERM handlers, and would regress visibly for
|
|
||||||
// /pair-agent users (server lingers after disconnect).
|
|
||||||
//
|
//
|
||||||
// Each test spawns the real server.ts. Tests 1 and 2 verify behavior via
|
// Each test spawns the real server.ts. Tests 1 and 2 verify behavior via
|
||||||
// stdout log line (fast). Test 3 waits for the watchdog poll cycle to confirm
|
// stdout log line (fast). Test 3 waits for the watchdog poll cycle to confirm
|
||||||
@@ -155,3 +157,163 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
|
|||||||
expect(isProcessAlive(serverPid)).toBe(true);
|
expect(isProcessAlive(serverPid)).toBe(true);
|
||||||
}, 45_000);
|
}, 45_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The three tests above all fix the mode via env at SPAWN time, so none of them
|
||||||
|
// reaches the headed branch of the watchdog. That branch is only reachable by a
|
||||||
|
// RUNTIME promotion, which `handoff` performs: it swaps in a headed context on a
|
||||||
|
// running daemon without a restart, moving a daemon that legitimately registered
|
||||||
|
// a watchdog onto the fatal side of the check. The parent is usually a
|
||||||
|
// short-lived shell (Claude Code's Bash tool kills one after every invocation),
|
||||||
|
// so the next poll shut the daemon down and discarded whatever the user had been
|
||||||
|
// handed off to do — observed as repeated session loss mid-login.
|
||||||
|
//
|
||||||
|
// The fix must NOT clear the interval, though: the same tick is the
|
||||||
|
// tunnel-orphan reaper (idle timeout is disabled in tunnel mode, so parent
|
||||||
|
// death is the ONLY thing that reaps an internet-exposed daemon). Promotion
|
||||||
|
// sets a suppress flag the tick re-reads each pass — "being headed" no longer
|
||||||
|
// kills the daemon on parent death, but an active tunnel still does.
|
||||||
|
//
|
||||||
|
// Driving a real `handoff` needs a headed Chromium, which does not belong in the
|
||||||
|
// free tier, so this pins the WIRING instead — the same static-tripwire approach
|
||||||
|
// used by cdp-session-cleanup.test.ts and server-auth.test.ts. If either half of
|
||||||
|
// the contract is dropped, the crash returns silently and these fail. The
|
||||||
|
// behavioral halves (suppression + tunnel reaping) run in-process below.
|
||||||
|
describe('headed parent-death shutdown is suppressed on runtime promotion', () => {
|
||||||
|
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
||||||
|
|
||||||
|
test('handoff() notifies the server that it promoted the daemon', () => {
|
||||||
|
const src = read('src/browser-manager.ts');
|
||||||
|
const promote = src.indexOf("this.connectionMode = 'headed';", src.indexOf('async handoff('));
|
||||||
|
expect(promote).toBeGreaterThan(-1);
|
||||||
|
// The notification must follow the promotion closely; a call left far away
|
||||||
|
// (or removed) is the regression this guards.
|
||||||
|
expect(src.slice(promote, promote + 800)).toContain('this.onHeadedPromotion?.()');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the server binds that callback to the suppress-flag setter', () => {
|
||||||
|
const src = read('src/server.ts');
|
||||||
|
expect(src).toContain('function suppressHeadedParentShutdown()');
|
||||||
|
// Bound on BOTH the module-level manager and any embedder-supplied one; the
|
||||||
|
// watchdog reads activeBrowserManager, so binding only the default instance
|
||||||
|
// leaves embedders (e.g. gbrowser) promoting silently.
|
||||||
|
expect(src).toContain('browserManager.onHeadedPromotion = suppressHeadedParentShutdown');
|
||||||
|
expect(src).toContain('cfgBrowserManager.onHeadedPromotion = suppressHeadedParentShutdown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('promotion must NOT clear the interval — the tick doubles as the tunnel-orphan reaper', () => {
|
||||||
|
const src = read('src/server.ts');
|
||||||
|
// The original #2565 absorption cleared the ENTIRE interval on promotion.
|
||||||
|
// Sequence handoff → resume → /pair-agent tunnel then left an
|
||||||
|
// internet-exposed daemon that nothing reaps. The tick must stay
|
||||||
|
// registered and re-check the suppress flag + tunnelActive every pass.
|
||||||
|
expect(src).not.toContain('clearInterval(parentWatchdogTimer)');
|
||||||
|
expect(src).toContain('setInterval(parentWatchdogTick');
|
||||||
|
const tickStart = src.indexOf('function parentWatchdogTick(');
|
||||||
|
expect(tickStart).toBeGreaterThan(-1);
|
||||||
|
const tick = src.slice(tickStart, src.indexOf('\n}', tickStart));
|
||||||
|
expect(tick).toContain('headedParentShutdownSuppressed');
|
||||||
|
expect(tick).toContain('tunnelActive');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Behavioral: suppressed watchdog still reaps tunnel orphans ────────────
|
||||||
|
//
|
||||||
|
// In-process, via the same __testInternals__ seam server-factory.test.ts uses
|
||||||
|
// for idleCheckTick. parentWatchdogTick(deadPid) simulates the 15s poll
|
||||||
|
// discovering a dead parent; setTunnelActive simulates /pair-agent's
|
||||||
|
// tunnel-create flow; suppressHeadedParentShutdown is exactly what the
|
||||||
|
// handoff promotion callback invokes.
|
||||||
|
function makeMinimalConfig(mode: 'launched' | 'headed', tmpDir: string): ServerConfig {
|
||||||
|
const base = resolveConfig();
|
||||||
|
return {
|
||||||
|
authToken: 'watchdog-test-' + crypto.randomBytes(16).toString('hex'),
|
||||||
|
browsePort: 34567,
|
||||||
|
idleTimeoutMs: 1_800_000,
|
||||||
|
// State paths pointed at a scratch dir so shutdown()'s cleanup can never
|
||||||
|
// touch a real daemon's files on the machine running the tests.
|
||||||
|
config: { ...base, stateFile: path.join(tmpDir, 'browse-state.json'), stateDir: tmpDir },
|
||||||
|
browserManager: {
|
||||||
|
getConnectionMode: () => mode,
|
||||||
|
isWatching: () => false,
|
||||||
|
stopWatch: () => {},
|
||||||
|
close: async () => {},
|
||||||
|
onDisconnect: null,
|
||||||
|
} as any,
|
||||||
|
startTime: Date.now(),
|
||||||
|
// Skip terminal-agent teardown: identity files live under the REAL state
|
||||||
|
// dir conventions and this suite must stay hermetic.
|
||||||
|
ownsTerminalAgent: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('suppressed watchdog still reaps tunnel orphans (behavioral)', () => {
|
||||||
|
// A PID above darwin/linux default pid_max: process.kill(pid, 0) throws
|
||||||
|
// ESRCH, which the tick reads as "parent exited".
|
||||||
|
const DEAD_PID = 999_999;
|
||||||
|
let scratch: string;
|
||||||
|
const savedChromiumProfile = process.env.CHROMIUM_PROFILE;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-tick-'));
|
||||||
|
// shutdown() runs cleanSingletonLocks(resolveChromiumProfile()); point it
|
||||||
|
// at scratch so the operator's real profile is never inspected.
|
||||||
|
process.env.CHROMIUM_PROFILE = path.join(scratch, 'chromium-profile');
|
||||||
|
__resetRegistry();
|
||||||
|
__testInternals__.setTunnelActive(false);
|
||||||
|
__testInternals__.setLastActivity(Date.now());
|
||||||
|
__testInternals__.resetShutdownState();
|
||||||
|
__testInternals__.resetParentWatchdogState();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (savedChromiumProfile === undefined) delete process.env.CHROMIUM_PROFILE;
|
||||||
|
else process.env.CHROMIUM_PROFILE = savedChromiumProfile;
|
||||||
|
__testInternals__.setTunnelActive(false);
|
||||||
|
__testInternals__.resetShutdownState();
|
||||||
|
__testInternals__.resetParentWatchdogState();
|
||||||
|
try { fs.rmSync(scratch, { recursive: true, force: true }); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drain the fire-and-forget shutdown promise chain (flushBuffers + close)
|
||||||
|
// the same way server-factory.test.ts does before asserting on exit.
|
||||||
|
async function drainShutdown(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise<void>((r) => setImmediate(r));
|
||||||
|
await new Promise<void>((r) => setImmediate(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('after promotion suppression, parent death does NOT shut down a headed daemon (#2565)', async () => {
|
||||||
|
const exitMock = mock((_code?: number) => {});
|
||||||
|
const originalExit = process.exit;
|
||||||
|
(process as any).exit = exitMock;
|
||||||
|
try {
|
||||||
|
buildFetchHandler(makeMinimalConfig('headed', scratch));
|
||||||
|
__testInternals__.suppressHeadedParentShutdown(); // what handoff promotion triggers
|
||||||
|
__testInternals__.parentWatchdogTick(DEAD_PID);
|
||||||
|
await drainShutdown();
|
||||||
|
expect(exitMock).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
(process as any).exit = originalExit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CRITICAL: suppression active + tunnel live — parent death still shuts down', async () => {
|
||||||
|
const exitMock = mock((_code?: number) => {});
|
||||||
|
const originalExit = process.exit;
|
||||||
|
(process as any).exit = exitMock;
|
||||||
|
try {
|
||||||
|
buildFetchHandler(makeMinimalConfig('headed', scratch));
|
||||||
|
__testInternals__.suppressHeadedParentShutdown();
|
||||||
|
__testInternals__.setTunnelActive(true); // handoff → resume → /pair-agent tunnel
|
||||||
|
__testInternals__.parentWatchdogTick(DEAD_PID);
|
||||||
|
await drainShutdown();
|
||||||
|
// The tick is the ONLY reaper for tunnel orphans (idle timeout is
|
||||||
|
// disabled in tunnel mode). If this fails, an internet-exposed daemon
|
||||||
|
// outlives its parent forever.
|
||||||
|
expect(exitMock).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
(process as any).exit = originalExit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Static tripwire for #1835: child spawns reachable on Windows must pass
|
||||||
|
* windowsHide, or every daemon relaunch / taskkill / icacls / powershell
|
||||||
|
* invocation flashes a black console window (and can steal focus).
|
||||||
|
*
|
||||||
|
* Source-level, same style as server-auth.test.ts / cdp-session-cleanup.test.ts:
|
||||||
|
* cheap, deterministic, runs on every platform.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const SRC = (f: string) => fs.readFileSync(path.join(import.meta.dir, '../src', f), 'utf-8');
|
||||||
|
|
||||||
|
/** Every occurrence of `needle` in `src` must have `windowsHide` within the
|
||||||
|
* next `window` chars (the spawn's options object). */
|
||||||
|
function expectHideNearEvery(src: string, needle: string, window = 400): void {
|
||||||
|
let idx = src.indexOf(needle);
|
||||||
|
expect(idx).toBeGreaterThanOrEqual(0);
|
||||||
|
while (idx !== -1) {
|
||||||
|
const slice = src.slice(idx, idx + window);
|
||||||
|
expect(slice).toMatch(/windowsHide:\s*true/);
|
||||||
|
idx = src.indexOf(needle, idx + needle.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('windowsHide on Windows-reachable spawns (#1835)', () => {
|
||||||
|
test('daemon launch paths in cli.ts pass windowsHide', () => {
|
||||||
|
const cli = SRC('cli.ts');
|
||||||
|
// Installed path: node -e launcher — both the outer spawnSync and the
|
||||||
|
// inner detached daemon spawn (inside the launcher code string).
|
||||||
|
expect(cli).toContain('detached:true,windowsHide:true');
|
||||||
|
expectHideNearEvery(cli, "'-e', launcherCode]");
|
||||||
|
// Dev fallback: detached bun spawn.
|
||||||
|
expectHideNearEvery(cli, "nodeSpawn('bun'");
|
||||||
|
// taskkill (killServer).
|
||||||
|
expectHideNearEvery(cli, "'taskkill'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows-only process probes pass windowsHide', () => {
|
||||||
|
// tasklist in isProcessAlive — runs in polling loops.
|
||||||
|
expectHideNearEvery(SRC('error-handling.ts'), "'tasklist'");
|
||||||
|
// powershell DPAPI + tasklist in cookie import.
|
||||||
|
const cookie = SRC('cookie-import-browser.ts');
|
||||||
|
expectHideNearEvery(cookie, "'powershell'");
|
||||||
|
expectHideNearEvery(cookie, "'tasklist'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('icacls calls in file-permissions.ts pass windowsHide', () => {
|
||||||
|
const perms = SRC('file-permissions.ts');
|
||||||
|
expect((perms.match(/'icacls'/g) || []).length).toBeGreaterThanOrEqual(3);
|
||||||
|
expectHideNearEvery(perms, "'icacls'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminal-agent respawn in terminal-agent-control.ts passes windowsHide', () => {
|
||||||
|
// The CLI cold-start + v1.44 watchdog respawn path. On Windows it runs
|
||||||
|
// through the Node polyfill (dist/bun-polyfill.cjs) whose host default is
|
||||||
|
// the opposite of Bun's — a visible console window on every watchdog
|
||||||
|
// respawn is the symptom when the flag is dropped. Wider window: the
|
||||||
|
// spawn's options object carries the full env wiring before the flag.
|
||||||
|
expectHideNearEvery(SRC('terminal-agent-control.ts'), '(Bun as any).spawn(', 700);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -674,6 +674,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -677,6 +677,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -677,6 +677,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -680,6 +680,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -700,6 +700,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -681,6 +681,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
+46
-11
@@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
@@ -899,41 +903,70 @@ If `NEEDS_SETUP`:
|
|||||||
|
|
||||||
## Test Framework Bootstrap
|
## Test Framework Bootstrap
|
||||||
|
|
||||||
**Detect existing test framework and project runtime:**
|
**Read the project's CLAUDE.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5.
|
||||||
|
|
||||||
|
**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||||
# Detect project runtime
|
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
|
||||||
[ -f Gemfile ] && echo "RUNTIME:ruby"
|
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py"
|
||||||
|
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
|
||||||
|
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
|
||||||
[ -f package.json ] && echo "RUNTIME:node"
|
[ -f package.json ] && echo "RUNTIME:node"
|
||||||
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
|
|
||||||
[ -f go.mod ] && echo "RUNTIME:go"
|
[ -f go.mod ] && echo "RUNTIME:go"
|
||||||
[ -f Cargo.toml ] && echo "RUNTIME:rust"
|
[ -f Cargo.toml ] && echo "RUNTIME:rust"
|
||||||
[ -f composer.json ] && echo "RUNTIME:php"
|
[ -f composer.json ] && echo "RUNTIME:php"
|
||||||
[ -f mix.exs ] && echo "RUNTIME:elixir"
|
[ -f mix.exs ] && echo "RUNTIME:elixir"
|
||||||
|
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
|
||||||
|
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
|
||||||
# Detect sub-frameworks
|
# Detect sub-frameworks
|
||||||
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
|
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
|
||||||
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
|
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
|
||||||
# Check for existing test infrastructure
|
# Existing test path — config files, declared scripts, AND test FILES.
|
||||||
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null
|
# A project with real tests and no config file is the common miss.
|
||||||
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
|
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null
|
||||||
|
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
|
||||||
|
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
|
||||||
|
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest"
|
||||||
|
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
|
||||||
|
# Rust keeps unit tests inside src/, so file names alone miss them
|
||||||
|
[ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source"
|
||||||
# Check opt-out marker
|
# Check opt-out marker
|
||||||
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
|
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
|
||||||
```
|
```
|
||||||
|
|
||||||
**If test framework detected** (config files or test directories found):
|
Map the markers to the command you will OFFER — never to one you run on a guess:
|
||||||
Print "Test framework detected: {name} ({N} existing tests). Skipping bootstrap."
|
|
||||||
|
| Marker | Ecosystem | Candidate command to offer |
|
||||||
|
|--------|-----------|----------------------------|
|
||||||
|
| `manage.py` | Django | `python manage.py test` (or `pytest` when pytest-django is in the deps) |
|
||||||
|
| `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` |
|
||||||
|
| `go.mod` (+ any `*_test.go`) | Go | `go test ./...` |
|
||||||
|
| `Cargo.toml` | Rust | `cargo test` |
|
||||||
|
| `pom.xml` | JVM (Maven) | `mvn test` |
|
||||||
|
| `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` |
|
||||||
|
| `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` |
|
||||||
|
| `mix.exs` | Elixir | `mix test` |
|
||||||
|
| `composer.json` | PHP | `composer test` or `./vendor/bin/phpunit` |
|
||||||
|
| `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names |
|
||||||
|
| `Makefile` with a `test:` target | any | `make test` |
|
||||||
|
|
||||||
|
**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one.
|
||||||
Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns).
|
Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns).
|
||||||
Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.**
|
Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.**
|
||||||
|
|
||||||
|
Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `<app>/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate.
|
||||||
|
|
||||||
**If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.**
|
**If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.**
|
||||||
|
|
||||||
**If NO runtime detected** (no config files found): Use AskUserQuestion:
|
**If NO ecosystem marker matched:** Use AskUserQuestion:
|
||||||
"I couldn't detect your project's language. What runtime are you using?"
|
"I couldn't detect your project's language. What runtime are you using?"
|
||||||
Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests.
|
Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests.
|
||||||
|
If the runtime you need isn't listed, offer "Other" and take the runtime plus the test command as free text.
|
||||||
If user picks H → write `.gstack/no-test-bootstrap` and continue without tests.
|
If user picks H → write `.gstack/no-test-bootstrap` and continue without tests.
|
||||||
|
|
||||||
**If runtime detected but no test framework — bootstrap:**
|
**If an ecosystem matched but there is no existing-test evidence at all — bootstrap:**
|
||||||
|
|
||||||
### B2. Research best practices
|
### B2. Research best practices
|
||||||
|
|
||||||
@@ -949,7 +982,9 @@ If WebSearch is unavailable, use this built-in knowledge table:
|
|||||||
| Node.js | vitest + @testing-library | jest + @testing-library |
|
| Node.js | vitest + @testing-library | jest + @testing-library |
|
||||||
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
|
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
|
||||||
| Python | pytest + pytest-cov | unittest |
|
| Python | pytest + pytest-cov | unittest |
|
||||||
|
| Django | pytest + pytest-django | Django's built-in `manage.py test` (unittest) |
|
||||||
| Go | stdlib testing + testify | stdlib only |
|
| Go | stdlib testing + testify | stdlib only |
|
||||||
|
| JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only |
|
||||||
| Rust | cargo test (built-in) + mockall | — |
|
| Rust | cargo test (built-in) + mockall | — |
|
||||||
| PHP | phpunit + mockery | pest |
|
| PHP | phpunit + mockery | pest |
|
||||||
| Elixir | ExUnit (built-in) + ex_machina | — |
|
| Elixir | ExUnit (built-in) + ex_machina | — |
|
||||||
|
|||||||
@@ -695,6 +695,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -680,6 +680,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
# Code-Intelligence Provider Contract
|
||||||
|
|
||||||
|
Status: design + first implementation slice
|
||||||
|
Owner: maintainer-directed internal work
|
||||||
|
Related: `runtime/context.js` (Context.dev provider pattern),
|
||||||
|
`scripts/gstack2/browser-provider-contract.ts` (the existing provider-contract idiom),
|
||||||
|
`lib/gstack-decision-semantic.ts` (degrade-to-null reliability contract)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
gstack carries ~17k LOC of home-grown code-intelligence glue: transcript
|
||||||
|
ingestion (`bin/gstack-memory-ingest.ts`, ~1.9k), a unified sync verb
|
||||||
|
(`bin/gstack-gbrain-sync.ts`, ~1.6k), context loading
|
||||||
|
(`bin/gstack-brain-context-load.ts`), a three-tier planning cache
|
||||||
|
(`bin/gstack-brain-cache`), source reconciliation, engine-status classification,
|
||||||
|
destructive-op guards, plus ~15 `bin/gstack-gbrain-*` and `bin/gstack-brain-*`
|
||||||
|
entrypoints and ~40 tests. All of it is bespoke wiring around one external tool
|
||||||
|
(GBrain) reached by direct CLI shell-out.
|
||||||
|
|
||||||
|
We do not want to keep maintaining a home-grown indexer. We want gstack to
|
||||||
|
define a **small optional contract** that external providers implement, so the
|
||||||
|
indexing/search/graph work lives in the provider, not in gstack.
|
||||||
|
|
||||||
|
Hard requirement, non-negotiable: **gstack must remain fully functional with the
|
||||||
|
provider OFF.** File-only paths (the decision store, Context Recovery, grep) stay
|
||||||
|
reliable and never depend on a provider being present. This is the existing
|
||||||
|
decision-store philosophy (`lib/gstack-decision.ts` has zero gbrain imports;
|
||||||
|
`lib/gstack-decision-semantic.ts` degrades to `null`). The contract is an
|
||||||
|
enhancement, never a dependency.
|
||||||
|
|
||||||
|
## Design decision: repo-oriented, not document-store
|
||||||
|
|
||||||
|
Settled (do not relitigate). The contract is **repo-oriented**:
|
||||||
|
|
||||||
|
```
|
||||||
|
register_source(repo) — required
|
||||||
|
refresh(source) — required
|
||||||
|
search(query) — required
|
||||||
|
status(source) — required
|
||||||
|
```
|
||||||
|
|
||||||
|
`add` / `delete` / `export` are **optional capabilities** a provider MAY
|
||||||
|
advertise. GBrain advertises them (its native primitive is document-by-slug:
|
||||||
|
put/delete/get/export); code-search and code-graph tools decline them.
|
||||||
|
|
||||||
|
A document-store contract (add / delete-by-id / export as *required* ops) was
|
||||||
|
rejected: it misrepresents code-search and code-graph tools. Sourcebot indexes a
|
||||||
|
whole repo and exposes search; it has no concept of "delete document id X".
|
||||||
|
Forcing every provider to implement a document CRUD surface would either exclude
|
||||||
|
the exact tools we most want (whole-repo indexers, graph tools) or force them to
|
||||||
|
stub required ops with lies. Repo-in / query-out is the honest common
|
||||||
|
denominator. GBrain's document axis survives as an *optional* capability, not as
|
||||||
|
the contract's shape.
|
||||||
|
|
||||||
|
## The contract
|
||||||
|
|
||||||
|
TypeScript in `lib/code-intelligence/contract.ts`. Shape (abridged):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CodeProviderCapability =
|
||||||
|
| "register_source" | "refresh" | "search" | "status" // required
|
||||||
|
| "add" | "delete" | "export"; // optional
|
||||||
|
|
||||||
|
interface CodeProvider {
|
||||||
|
readonly id: "gbrain" | "sourcebot" | "graphify";
|
||||||
|
readonly label: string;
|
||||||
|
readonly capabilities: ReadonlySet<CodeProviderCapability>;
|
||||||
|
readonly local: boolean; // true = no repo content leaves the machine
|
||||||
|
|
||||||
|
registerSource(repo: RepoRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||||
|
refresh(source: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||||
|
search(query: string, opts?: SearchOptions): Promise<CodeSearchHit[]>;
|
||||||
|
status(source?: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||||
|
|
||||||
|
add?(doc: { slug: string; body: string }, opts?: OpOptions): Promise<SourceStatus>;
|
||||||
|
delete?(slug: string, opts?: OpOptions): Promise<SourceStatus>;
|
||||||
|
export?(source: SourceRef, opts?: OpOptions): Promise<string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every provider MUST implement the four required methods and MUST advertise
|
||||||
|
exactly the capabilities it backs (`assertRequiredCapabilities` enforces the
|
||||||
|
required four at construction; a test pins it). Optional methods are present iff
|
||||||
|
the matching capability is advertised. Calling an unadvertised optional op throws
|
||||||
|
`CAPABILITY_UNSUPPORTED` — never a silent no-op.
|
||||||
|
|
||||||
|
### Typed failures
|
||||||
|
|
||||||
|
Mirrors `runtime/context.js`'s `ContextError` discipline (a closed code set,
|
||||||
|
constructor throws on an unknown code):
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `PROVIDER_UNAVAILABLE` | CLI/MCP transport absent — degrade to file-only |
|
||||||
|
| `PROVIDER_NOT_CONSENTED` | repo indexing not consented and content would leave the machine |
|
||||||
|
| `CAPABILITY_UNSUPPORTED` | provider declines this op |
|
||||||
|
| `SOURCE_NOT_REGISTERED` | op needs a source that isn't registered |
|
||||||
|
| `PROVIDER_TIMEOUT` | provider exceeded the op timeout |
|
||||||
|
| `PROVIDER_ERROR` | provider ran and failed |
|
||||||
|
|
||||||
|
`PROVIDER_UNAVAILABLE` is the load-bearing one: callers catch it (or use the
|
||||||
|
picker's null resolution) and fall back to grep / file-only. It is never fatal.
|
||||||
|
|
||||||
|
### Consent
|
||||||
|
|
||||||
|
Two orthogonal consent axes, both explicit, neither auto-granted:
|
||||||
|
|
||||||
|
1. **Network / content-egress consent (repo-scoped).** Before any repo content
|
||||||
|
leaves the machine, indexing must be consented *per repo*. The contract
|
||||||
|
enforces this in `registerSource`/`refresh`/`add`: when
|
||||||
|
`provider.local === false` and `opts.consented !== true`, it throws
|
||||||
|
`PROVIDER_NOT_CONSENTED`. Local providers (Graphify) skip this axis — nothing
|
||||||
|
leaves the machine.
|
||||||
|
2. **Install consent (Graphify only).** Graphify is never auto-installed. The
|
||||||
|
`options`/`status` display marks it available only when its CLI is present,
|
||||||
|
and nothing in gstack runs a Graphify installer. Install is a user action
|
||||||
|
(`pip install graphifyy && graphify install`).
|
||||||
|
|
||||||
|
This matches the Context.dev model: selection persists without granting egress
|
||||||
|
consent; egress requires a separate explicit step.
|
||||||
|
|
||||||
|
## Per-provider capability matrix
|
||||||
|
|
||||||
|
| Op | GBrain (recommend first) | Sourcebot | Graphify |
|
||||||
|
|----|--------------------------|-----------|----------|
|
||||||
|
| `register_source` | ✓ `sources add --federated` | ✓ local `git` connection in config.json | ✓ `graphify update <dir>` (local, no LLM) |
|
||||||
|
| `refresh` | ✓ `sync` + `sync --strategy code --full` | ✓ auto (config change + reindexIntervalMs) | ✓ `graphify update <dir>` |
|
||||||
|
| `search` | ✓ `gbrain search` (federated corpora) | ✓ `POST /api/search` (keyless w/ anonymous access; Bearer key optional) | ✓ `graphify query "<q>" --graph <graph.json>` |
|
||||||
|
| `status` | ✓ `sources list` + page_count | ~ partial (server liveness) | ~ partial (graph.json present + node count) |
|
||||||
|
| `add` | ✓ `put <slug>` | ✗ declines | ✗ declines |
|
||||||
|
| `delete` | ✓ `delete <slug>` | ✗ declines | ✗ declines |
|
||||||
|
| `export` | ✓ `export` | ✗ declines | ✓ read `graphify-out/graph.json` |
|
||||||
|
| `local` (no egress) | no (federated DB) | loopback → **yes**; remote host → no | **yes** (local only) |
|
||||||
|
|
||||||
|
All three are driven directly from the runtime — no MCP client:
|
||||||
|
|
||||||
|
- **GBrain** (`garrytan/gbrain`, the gstack-ecosystem tool): full contract fit,
|
||||||
|
driven via the existing `gbrain` CLI chokepoint (`lib/gbrain-exec.ts`). Native
|
||||||
|
primitive is document-by-slug (put/delete/get/export) PLUS a repo axis
|
||||||
|
(`sources add`/`sync`). Advertises all seven capabilities. **Recommended
|
||||||
|
first.**
|
||||||
|
- **Sourcebot** (`github.com/sourcebot-dev/sourcebot`, YC Fall 2025): self-hosted
|
||||||
|
whole-repo regex search, deployed via Docker Compose (bundled server + Postgres
|
||||||
|
+ Redis; no supported non-Docker path). `register_source` adds a local `{ "type":
|
||||||
|
"git", "url": "file:///path" }` connection to the server's `config.json` (it
|
||||||
|
re-indexes on config change; a local repo needs a `remote.origin.url` or it is
|
||||||
|
skipped); `search` is `POST {baseUrl}/api/search`; `status` probes that endpoint.
|
||||||
|
Declines `add`/`delete`/`export`. It is a **local** tool — indexed code stays on
|
||||||
|
your machine — and an **API key is optional**: a local instance with anonymous
|
||||||
|
access (`FORCE_ENABLE_ANONYMOUS_ACCESS=true`) serves `/api/search` keyless. The
|
||||||
|
adapter sends `Authorization: Bearer <SOURCEBOT_API_KEY>` only when a key is set.
|
||||||
|
A loopback `baseUrl` keeps content on the machine (local=true); a remote one
|
||||||
|
requires egress consent.
|
||||||
|
- **Graphify** (`github.com/Graphify-Labs/graphify`, YC-backed): local
|
||||||
|
tree-sitter code graph via the `graphify` CLI. The adapter uses **`graphify
|
||||||
|
update <dir>`** — the local, no-LLM build (writes `graphify-out/graph.json`);
|
||||||
|
it deliberately avoids the bare `graphify <dir>` build, which runs an LLM
|
||||||
|
extraction backend needing an API key + network. `graphify query "<q>" --graph
|
||||||
|
<graph.json>` searches it (its `NODE ...`/`EDGE ...` output carries the file at
|
||||||
|
`src=`/`at=`); `export` reads the graph JSON. Fully local — nothing leaves the
|
||||||
|
machine. Optional, **install only with explicit user action** (`pip install
|
||||||
|
graphifyy && graphify install`, needs Python >= 3.10); never auto-installed.
|
||||||
|
|
||||||
|
**No local-index option is offered** (deliberately excluded — a naive local
|
||||||
|
index degrades result quality; we route to a real provider or to file-only grep,
|
||||||
|
not to a half-baked in-house index).
|
||||||
|
|
||||||
|
### Integration surfaces (no MCP needed)
|
||||||
|
|
||||||
|
Each provider exposes a runtime-drivable surface, so gstack drives them with a
|
||||||
|
CLI shell-out or plain HTTP — it never speaks MCP:
|
||||||
|
|
||||||
|
- **GBrain / Graphify: CLI.** Shell out (`spawnSync`), same shape and the same
|
||||||
|
ENOENT→`PROVIDER_UNAVAILABLE` degrade as the existing gbrain glue.
|
||||||
|
- **Sourcebot: HTTP + a config-file edit.** `POST /api/search` for queries and a
|
||||||
|
JSON edit of the server's `config.json` to register a repo. `fetch` is
|
||||||
|
injectable so tests run against a stub, no live server.
|
||||||
|
|
||||||
|
Sourcebot and Graphify also ship MCP servers for in-agent use; the contract does
|
||||||
|
not depend on them, because their CLI/HTTP surfaces are enough to index and
|
||||||
|
search from the runtime.
|
||||||
|
|
||||||
|
## Picker: recommend GBrain first
|
||||||
|
|
||||||
|
`lib/code-intelligence/picker.ts` + `selection.ts`. The user picks a provider
|
||||||
|
with `gstack-code-intelligence select <provider>`, persisted to
|
||||||
|
`$GSTACK_HOME/code-intelligence.json`. `resolveSelectedProvider()` constructs the
|
||||||
|
selected provider, or returns `null` when nothing is selected — the provider-OFF
|
||||||
|
path, where callers degrade to grep / the file-only decision store. Availability
|
||||||
|
is proven at call time: a selected provider whose CLI/server is absent throws
|
||||||
|
`PROVIDER_UNAVAILABLE`, which callers catch and degrade on.
|
||||||
|
|
||||||
|
`RECOMMENDED_ORDER` is the static **GBrain → Sourcebot → Graphify** fact — GBrain
|
||||||
|
is always recommended first. `detectAvailable()` probes each provider for the
|
||||||
|
`options`/`status` display (GBrain via the real `localEngineStatus()`; Graphify
|
||||||
|
via its CLI/graph presence; Sourcebot via an HTTP liveness probe). The picker
|
||||||
|
never silently prefers a non-recommended tool.
|
||||||
|
|
||||||
|
## How this replaces the current GBrain glue
|
||||||
|
|
||||||
|
The contract is the seam; the bespoke glue collapses onto it. Mapping:
|
||||||
|
|
||||||
|
| Today (bespoke) | Under the contract |
|
||||||
|
|-----------------|--------------------|
|
||||||
|
| `bin/gstack-gbrain-sync.ts` (`sync`/`reindex-code`/`sources`) | `provider.registerSource` / `provider.refresh` |
|
||||||
|
| `lib/gstack-decision-semantic.ts` `semanticRecall` | `provider.search` (scoped) → same degrade-to-null |
|
||||||
|
| `bin/gstack-brain-context-load.ts` (`query`/`list_pages`) | `provider.search` / `provider.status` |
|
||||||
|
| `bin/gstack-memory-ingest.ts` (`import`, put) | `provider.add` (optional cap; GBrain-only) |
|
||||||
|
| `lib/gbrain-sources.ts` (`ensureSourceRegistered`, `probeSource`) | GBrain adapter internals |
|
||||||
|
| `lib/gbrain-local-status.ts` | GBrain adapter availability probe (kept, reused) |
|
||||||
|
| `bin/gstack-gbrain-detect` / `-install` / `-source-wireup` / `-repo-policy` | provider setup + picker + consent (thinner) |
|
||||||
|
|
||||||
|
The point is not to delete 17k LOC in one commit — it is to make every consumer
|
||||||
|
call the contract, then retire the bespoke paths provider-by-provider behind it.
|
||||||
|
Consumers that only need "search my code, or degrade" stop importing gbrain
|
||||||
|
specifics entirely.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Phased, each phase independently revertable. Skill-template edits are deferred to
|
||||||
|
a later phase precisely so the first slices do not trigger the
|
||||||
|
`gen:gstack2` / parity re-baseline cycle.
|
||||||
|
|
||||||
|
- **Phase 1 (this slice): the contract, three real adapters, and a usable CLI.**
|
||||||
|
`contract.ts` + fully-drivable GBrain (CLI), Graphify (CLI), and Sourcebot
|
||||||
|
(HTTP + config) adapters + the selection store + the `gstack-code-intelligence`
|
||||||
|
CLI (`options`/`status`/`select`/`consent`/`index`/`search`) + tests. A user
|
||||||
|
can select a provider and index/search their repo today. No skill-template or
|
||||||
|
generated-file changes yet, so no `gen:gstack2` / parity re-baseline.
|
||||||
|
- **Phase 2: route internal consumers through the contract.** Point
|
||||||
|
`gstack-decision-semantic` and `gstack-brain-context-load` at
|
||||||
|
`resolveSelectedProvider()`, preserving degrade-to-null exactly. Behavior-neutral
|
||||||
|
for the file-only paths.
|
||||||
|
- **Phase 3: surface selection in the skills.** Offer the picker at the moments a
|
||||||
|
skill would benefit from indexed search, mirroring the `context` command's
|
||||||
|
just-in-time consent prompt. Regenerate skills (`bun run gen:gstack2`), re-run
|
||||||
|
`bun run test:gstack2`, re-baseline parity intentionally.
|
||||||
|
- **Phase 4: retire bespoke glue.** Once every consumer is on the contract,
|
||||||
|
delete the sync/ingest/cache entrypoints and their tests provider-by-provider.
|
||||||
|
|
||||||
|
## Verified against real environments
|
||||||
|
|
||||||
|
All three adapters were driven against the real tools in isolated environments
|
||||||
|
(parallel agents, one worktree each), and all three now index + search a real repo
|
||||||
|
end-to-end. Two rounds ran, because the first round's fixes included a mistake that
|
||||||
|
only real execution caught — recorded here honestly.
|
||||||
|
|
||||||
|
- **GBrain — real Postgres+pgvector (Docker), gbrain 0.42.56 — PROVEN.** The
|
||||||
|
default pglite/WASM engine is broken on macOS (upstream garrytan/gbrain#223), so
|
||||||
|
the working recipe points gbrain at a real Postgres via `DATABASE_URL`. Real
|
||||||
|
end-to-end search returned the actual code definition
|
||||||
|
(`[0.88] src-checksum-ts … export statement computeChecksum`). Real execution
|
||||||
|
caught a **regression I had introduced**: I removed `--strategy code` from
|
||||||
|
`refresh` based on a `--help` misread, which silently stopped code from ever
|
||||||
|
being indexed (only docs were). Restored to the verified two-pass
|
||||||
|
(`sync`, then `sync --strategy code --full`); `--federated` registration is
|
||||||
|
load-bearing for global search. Also fixed earlier: engine-down now degrades to
|
||||||
|
`PROVIDER_UNAVAILABLE` (one-line message) instead of `PROVIDER_ERROR` + a WASM
|
||||||
|
stack dump.
|
||||||
|
- **Sourcebot — live v6.5.0 (Docker) — PROVEN keyless.** Endpoint, body, and
|
||||||
|
response parsing were correct against the real server. Correcting an earlier
|
||||||
|
wrong conclusion: Sourcebot does **not** require an API key for local use —
|
||||||
|
enabling anonymous access (`FORCE_ENABLE_ANONYMOUS_ACCESS=true`) serves
|
||||||
|
`/api/search` keyless, verified with a real hit through the CLI with no key set.
|
||||||
|
The key stays optional; the only fix was messaging (point users to anonymous
|
||||||
|
access first, key as fallback) plus a note that a local repo needs a
|
||||||
|
`remote.origin.url` to be indexed. It is a local tool (code stays on the
|
||||||
|
machine; a boot telemetry ping unless `SOURCEBOT_TELEMETRY_DISABLED=true`).
|
||||||
|
- **Graphify — real install, graphify 0.9.23 — PROVEN.** Correcting an earlier
|
||||||
|
wrong claim of mine: for **code**, `graphify <dir>` and `graphify update <dir>`
|
||||||
|
produce the identical AST graph with **no LLM call**; the LLM only renames
|
||||||
|
community clusters and ingests non-code docs, adding zero nodes/edges, and our
|
||||||
|
parser discards the field it touches. So there is deliberately no LLM mode, and
|
||||||
|
`local=true` is correct. The adapter uses `graphify update`; real `index`+search
|
||||||
|
returned correct `file:line` refs. Also fixed: `search` now reads the indexed
|
||||||
|
repo's graph (persisted root), and `options` reports an installed provider as
|
||||||
|
available.
|
||||||
|
|
||||||
|
The larger lesson, kept on the record: a `--help` reading or a single agent's
|
||||||
|
conclusion is not proof — running the real tool is. It reversed two of my
|
||||||
|
first-round calls (the gbrain flag removal and the graphify LLM claim).
|
||||||
|
|
||||||
|
## What this does NOT change
|
||||||
|
|
||||||
|
Per the GStack 2 canonical contract and CLAUDE.md boundaries: no cloud browsers,
|
||||||
|
no alternate iOS drivers, no local image models, no provider marketplaces, no
|
||||||
|
workflow engines, **no new state database**. Context.dev remains the only
|
||||||
|
newly-authorized external service for web context; this contract governs code
|
||||||
|
intelligence, a separate axis. The existing decision store and Context Recovery
|
||||||
|
stay file-only and provider-independent.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`test/code-intelligence.test.ts` (19 tests, no live tools): capability-matrix
|
||||||
|
invariants (all providers advertise the four required; only GBrain advertises the
|
||||||
|
document ops; `local` flags, including loopback-vs-remote Sourcebot); the result
|
||||||
|
parsers; the selection store + per-repo consent + provider-OFF (`null`); consent
|
||||||
|
gating (GBrain non-local without consent throws `PROVIDER_NOT_CONSENTED`; local
|
||||||
|
Graphify is exempt); the GBrain adapter against a fake `gbrain` shim; the Graphify
|
||||||
|
adapter against a fake `graphify` shim (index builds a graph, search returns hits,
|
||||||
|
status counts nodes); the Sourcebot adapter against an injected `fetch` + a temp
|
||||||
|
`config.json` (register writes a local git connection, search maps `files[]` to
|
||||||
|
hits); and every adapter degrading to `PROVIDER_UNAVAILABLE` when its tool/server
|
||||||
|
is absent. The `gstack-code-intelligence` CLI was smoke-tested end-to-end:
|
||||||
|
select → consent gate → local Graphify index (5-node graph) → search.
|
||||||
@@ -93,3 +93,17 @@ V2 items remain deferred:
|
|||||||
- Per-skill or per-topic explain levels
|
- Per-skill or per-topic explain levels
|
||||||
- Team profiles
|
- Team profiles
|
||||||
- AST-based "delivered features" metric
|
- AST-based "delivered features" metric
|
||||||
|
|
||||||
|
## Fold-in from fork port wave 2 (2026-08-14)
|
||||||
|
|
||||||
|
The time-attack/gstack fork attacked the same question fatigue from a
|
||||||
|
complementary axis: build-scale classification (session/hobby/project/
|
||||||
|
product/venture) sizing the machinery, plus CHAIN-WIDE question budgets.
|
||||||
|
Approved decision (CEO review 2026-08-14): fold the fork's ACCOUNTING
|
||||||
|
judgment into this design round — the budget is chain-scoped (a chained
|
||||||
|
review deducts from what's left, never resets), handoffs carry
|
||||||
|
questions-already-spent, approval/mutation gates never count against it, and
|
||||||
|
the budget is spent on the hardest-to-reverse decisions first. Do NOT adopt
|
||||||
|
the fork's 5/8/12 numeric constants — the fork itself later replaced them
|
||||||
|
with a zero-default autonomy dial. Scale sizes the machinery and sets the
|
||||||
|
budget; pacing (this doc) ranks what the budget is spent on.
|
||||||
|
|||||||
@@ -241,7 +241,9 @@ Codex, Factory) is "host explosion for a path alias." The current architecture h
|
|||||||
- Per-host branches in `transformFrontmatter()` with near-duplicate logic
|
- Per-host branches in `transformFrontmatter()` with near-duplicate logic
|
||||||
- Per-host config in `EXTERNAL_HOST_CONFIG` with similar patterns
|
- Per-host config in `EXTERNAL_HOST_CONFIG` with similar patterns
|
||||||
- Per-host functions in the setup script (`create_codex_runtime_root`, `link_codex_skill_dirs`)
|
- Per-host functions in the setup script (`create_codex_runtime_root`, `link_codex_skill_dirs`)
|
||||||
- Host names duplicated in `bin/gstack-platform-detect`, `bin/gstack-uninstall`, `bin/dev-setup`
|
- Host names duplicated in `bin/gstack-platform-detect` (since deleted — host
|
||||||
|
detection now lives in the `hosts/` registry, exported to shell via
|
||||||
|
`scripts/host-config-export.ts`), `bin/gstack-uninstall`, `bin/dev-setup`
|
||||||
|
|
||||||
Adding Slate means copying all of these patterns again. A refactor to make hosts
|
Adding Slate means copying all of these patterns again. A refactor to make hosts
|
||||||
data-driven (config objects instead of if/else branches) would make Slate integration
|
data-driven (config objects instead of if/else branches) would make Slate integration
|
||||||
@@ -253,7 +255,9 @@ trivial AND make future hosts (any new OpenCode fork, any new agent) zero-effort
|
|||||||
have Slate skills
|
have Slate skills
|
||||||
- `bin/gstack-uninstall` doesn't know about `.slate/`
|
- `bin/gstack-uninstall` doesn't know about `.slate/`
|
||||||
- `bin/dev-setup` doesn't wire `.slate/` for contributor dev mode
|
- `bin/dev-setup` doesn't wire `.slate/` for contributor dev mode
|
||||||
- `bin/gstack-platform-detect` doesn't detect Slate
|
- `bin/gstack-platform-detect` doesn't detect Slate (obsolete: the bin was
|
||||||
|
deleted; host detection is now the `hosts/` registry via
|
||||||
|
`scripts/host-config-export.ts` — `hosts/slate.ts` is where Slate lives)
|
||||||
- E2E tests should set `SLATE_DISABLE_CLAUDE_CODE_SKILLS=1` to prove `.slate/` path
|
- E2E tests should set `SLATE_DISABLE_CLAUDE_CODE_SKILLS=1` to prove `.slate/` path
|
||||||
actually works (not just falling back to `.claude/`)
|
actually works (not just falling back to `.claude/`)
|
||||||
|
|
||||||
|
|||||||
@@ -680,6 +680,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ JOURNAL="${MIGRATION_DIR}/v1.27.0.0.journal"
|
|||||||
DONE="${MIGRATION_DIR}/v1.27.0.0.done"
|
DONE="${MIGRATION_DIR}/v1.27.0.0.done"
|
||||||
SKIPPED="${MIGRATION_DIR}/v1.27.0.0.skipped-by-user"
|
SKIPPED="${MIGRATION_DIR}/v1.27.0.0.skipped-by-user"
|
||||||
|
|
||||||
|
# Real, copy-pasteable re-run command for every remediation message below.
|
||||||
|
# There is no runner re-ask: the upgrade runners' version windows never
|
||||||
|
# re-select an already-passed migration, so the only honest remediation is
|
||||||
|
# a direct invocation of this script ($0-derived so it survives any cwd).
|
||||||
|
SELF_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
|
||||||
|
RERUN_CMD="GSTACK_MIGRATE_ASSUME_YES=1 bash ${SELF_PATH}"
|
||||||
|
|
||||||
USER_NAME="${USER:-$(whoami 2>/dev/null || echo unknown)}"
|
USER_NAME="${USER:-$(whoami 2>/dev/null || echo unknown)}"
|
||||||
OLD_REPO_NAME="gstack-brain-${USER_NAME}"
|
OLD_REPO_NAME="gstack-brain-${USER_NAME}"
|
||||||
NEW_REPO_NAME="gstack-artifacts-${USER_NAME}"
|
NEW_REPO_NAME="gstack-artifacts-${USER_NAME}"
|
||||||
@@ -61,8 +68,8 @@ mkdir -p "$MIGRATION_DIR"
|
|||||||
# Already done? exit silently.
|
# Already done? exit silently.
|
||||||
[ -f "$DONE" ] && exit 0
|
[ -f "$DONE" ] && exit 0
|
||||||
|
|
||||||
# User opted out previously? exit silently. (Re-invoke via
|
# User opted out previously? exit silently. (To re-run after an opt-out:
|
||||||
# `/setup-gbrain --rerun-migration` removes this marker.)
|
# rm the skipped-by-user marker, then invoke this script directly.)
|
||||||
[ -f "$SKIPPED" ] && exit 0
|
[ -f "$SKIPPED" ] && exit 0
|
||||||
|
|
||||||
journal_done() {
|
journal_done() {
|
||||||
@@ -119,19 +126,33 @@ EOF
|
|||||||
read -r REPLY || REPLY=""
|
read -r REPLY || REPLY=""
|
||||||
case "$REPLY" in
|
case "$REPLY" in
|
||||||
n|N|no|No|NO)
|
n|N|no|No|NO)
|
||||||
echo " Skipping migration. Re-run via /setup-gbrain --rerun-migration." >&2
|
echo " Skipping migration. To re-run later:" >&2
|
||||||
|
echo " rm ${SKIPPED} && ${RERUN_CMD}" >&2
|
||||||
touch "$SKIPPED"
|
touch "$SKIPPED"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
skip|skip-for-now|s)
|
skip|skip-for-now|s)
|
||||||
echo " Skipping for now. Will ask again next upgrade." >&2
|
echo " Skipping for now. Re-run manually with: ${RERUN_CMD}" >&2
|
||||||
# Don't write SKIPPED — leave both old + new state untouched, ask again next time.
|
# Don't write SKIPPED — leave both old + new state untouched. The
|
||||||
|
# upgrade runner will NOT re-select this migration, so re-running is
|
||||||
|
# manual via the command above.
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
else
|
else
|
||||||
# Non-interactive (CI, scripted upgrade): proceed automatically.
|
# Non-interactive (CI, Claude Code Bash tool, scripted upgrade). Step 1
|
||||||
echo " (non-interactive: proceeding automatically)" >&2
|
# renames a REMOTE repo — consent-shaped, and blanket auto-proceed once
|
||||||
|
# left an install half-migrated when that step failed mid-run (#1383).
|
||||||
|
# Skip for now by default (asked again next upgrade); explicit opt-in
|
||||||
|
# proceeds unattended.
|
||||||
|
if [ "${GSTACK_MIGRATE_ASSUME_YES:-0}" = "1" ]; then
|
||||||
|
echo " (non-interactive: proceeding — GSTACK_MIGRATE_ASSUME_YES=1)" >&2
|
||||||
|
else
|
||||||
|
echo " Non-interactive session: skipping for now." >&2
|
||||||
|
echo " Re-run manually with: ${RERUN_CMD}" >&2
|
||||||
|
echo " To run interactively: bash ${SELF_PATH}" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -195,8 +216,8 @@ if ! journal_done "gh_repo_renamed"; then
|
|||||||
mark_done "gh_repo_renamed"
|
mark_done "gh_repo_renamed"
|
||||||
else
|
else
|
||||||
echo " WARNING: gh rename failed (repo may not exist or permission denied)" >&2
|
echo " WARNING: gh rename failed (repo may not exist or permission denied)" >&2
|
||||||
echo " skipping step 1; subsequent steps still run" >&2
|
echo " step 1 stays PENDING and will retry on re-run; later steps still run (#1383)" >&2
|
||||||
mark_done "gh_repo_renamed"
|
echo " manual: gh repo rename $NEW_REPO_NAME --repo $OLD_REPO_NAME --yes" >&2
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
@@ -335,8 +356,20 @@ EOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Step 6: finalize (touchfile + clear journal)
|
# Step 6: finalize (touchfile + clear journal) — only when EVERY step is
|
||||||
|
# journaled. A failed step must leave the migration visibly incomplete and
|
||||||
|
# retryable, never silently recorded as done (#1383).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
INCOMPLETE=""
|
||||||
|
for _step in gh_repo_renamed remote_txt_renamed config_key_renamed claude_md_block_rewritten sources_swapped; do
|
||||||
|
journal_done "$_step" || INCOMPLETE="$INCOMPLETE $_step"
|
||||||
|
done
|
||||||
|
if [ -n "$INCOMPLETE" ]; then
|
||||||
|
echo " [v1.27.0.0] migration INCOMPLETE — pending step(s):$INCOMPLETE" >&2
|
||||||
|
echo " Completed steps are journaled and will be skipped on re-run." >&2
|
||||||
|
echo " Re-run manually with: ${RERUN_CMD}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
touch "$DONE"
|
touch "$DONE"
|
||||||
rm -f "$JOURNAL"
|
rm -f "$JOURNAL"
|
||||||
|
|
||||||
|
|||||||
Executable
+150
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Migration: v1.65.0.0 — repair Chrome-for-Testing bundles poisoned by the
|
||||||
|
# old in-place rebrand (#2242).
|
||||||
|
#
|
||||||
|
# Why a migration: pre-v1.64 launchHeaded() rewrote the Chromium .app's
|
||||||
|
# Info.plist ("Google Chrome for Testing" → "GStack Browser") and overwrote
|
||||||
|
# its Resources/*.icns — inside the SHARED Playwright cache. That broke the
|
||||||
|
# codesign seal (GPU process exit_code=5; headed mode dead on macOS 26) and
|
||||||
|
# poisoned the cache for the user's OTHER Playwright projects too. Deleting
|
||||||
|
# the rebrand code fixes fresh installs only; every existing macOS install
|
||||||
|
# still has the mutated bundle on disk. This migration removes poisoned
|
||||||
|
# bundles and re-fetches a clean one so the upgrade doesn't leave the user
|
||||||
|
# with zero working browser (the browse launch path also self-heals, as the
|
||||||
|
# belt to this suspenders, for installs that never run migrations).
|
||||||
|
#
|
||||||
|
# Removal scope: `playwright install chromium` treats the revision dir's
|
||||||
|
# INSTALLATION_COMPLETE marker as "is already downloaded" — removing only
|
||||||
|
# the .app strands the user with a marker, no browser, and a re-fetch that
|
||||||
|
# no-ops. So when the poisoned .app sits in the standard cache layout
|
||||||
|
# (chromium-<rev>/chrome-mac/<name>.app) the WHOLE revision dir goes;
|
||||||
|
# otherwise the .app plus its sibling INSTALLATION_COMPLETE /
|
||||||
|
# DEPENDENCIES_VALIDATED markers go. A revision dir already stranded in
|
||||||
|
# exactly that state (markers present, .app missing) is detected and
|
||||||
|
# removed too, so the re-fetch actually downloads.
|
||||||
|
#
|
||||||
|
# Affected: macOS installs that ever ran headed mode before v1.64.
|
||||||
|
#
|
||||||
|
# Idempotent: detection is content-based (plist contains "GStack Browser");
|
||||||
|
# a clean cache is a no-op, and the .done touchfile gates re-runs. After a
|
||||||
|
# removal, .done is only written once the end state is VERIFIED (a real
|
||||||
|
# Chromium executable exists in the cache) — a removal followed by a failed
|
||||||
|
# re-fetch (e.g. offline) leaves the migration pending, with a needs-refetch
|
||||||
|
# sentinel so the next run retries the download.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
GSTACK_HOME="${GSTACK_HOME:-${HOME}/.gstack}"
|
||||||
|
MIGRATION_DIR="${GSTACK_HOME}/.migrations"
|
||||||
|
DONE="${MIGRATION_DIR}/v1.65.0.0.done"
|
||||||
|
# Written when a removal happened but the verified end state (a Chromium
|
||||||
|
# executable in the cache) wasn't reached — e.g. the re-fetch failed
|
||||||
|
# offline. Its presence re-triggers the re-fetch on the next run even when
|
||||||
|
# the scans below find nothing left to remove.
|
||||||
|
NEEDS_REFETCH="${MIGRATION_DIR}/v1.65.0.0.needs-refetch"
|
||||||
|
mkdir -p "${MIGRATION_DIR}" 2>/dev/null || true
|
||||||
|
[ -f "${DONE}" ] && exit 0
|
||||||
|
|
||||||
|
# macOS only: the mutation targeted .app bundle plists.
|
||||||
|
if [ "$(uname -s 2>/dev/null)" != "Darwin" ]; then
|
||||||
|
touch "${DONE}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
PW_CACHE="${PLAYWRIGHT_BROWSERS_PATH:-${HOME}/Library/Caches/ms-playwright}"
|
||||||
|
REMOVED=0
|
||||||
|
[ -f "${NEEDS_REFETCH}" ] && REMOVED=1
|
||||||
|
|
||||||
|
is_revision_dir() {
|
||||||
|
# Standard Playwright cache revision dir name: chromium-<digits>.
|
||||||
|
printf '%s' "$(basename "$1")" | grep -Eq '^chromium-[0-9]+$'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -d "${PW_CACHE}" ]; then
|
||||||
|
# 1. Content-based poison scan: every Chrome-for-Testing bundle in the
|
||||||
|
# cache (one per pinned chromium build) whose plist carries the rebrand.
|
||||||
|
while IFS= read -r plist; do
|
||||||
|
if grep -q "GStack Browser" "${plist}" 2>/dev/null; then
|
||||||
|
app_dir="$(dirname "$(dirname "${plist}")")"
|
||||||
|
case "${app_dir}" in
|
||||||
|
"${PW_CACHE}"/*.app|"${PW_CACHE}"/*/*.app|"${PW_CACHE}"/*/*/*.app)
|
||||||
|
rev_dir="$(dirname "$(dirname "${app_dir}")")"
|
||||||
|
if is_revision_dir "${rev_dir}"; then
|
||||||
|
# Remove the WHOLE revision dir: Playwright's
|
||||||
|
# INSTALLATION_COMPLETE marker lives beside chrome-mac/, and
|
||||||
|
# `playwright install chromium` treats its presence as "already
|
||||||
|
# downloaded" — removing only the .app would make the re-fetch
|
||||||
|
# below a no-op and leave the user with NO browser.
|
||||||
|
echo " [v1.65.0.0] removing rebrand-poisoned revision dir (incl. install markers): ${rev_dir}" >&2
|
||||||
|
rm -rf "${rev_dir}"
|
||||||
|
else
|
||||||
|
echo " [v1.65.0.0] removing rebrand-poisoned bundle: ${app_dir}" >&2
|
||||||
|
rm -rf "${app_dir}"
|
||||||
|
rm -f "$(dirname "${app_dir}")/INSTALLATION_COMPLETE" \
|
||||||
|
"$(dirname "${app_dir}")/DEPENDENCIES_VALIDATED" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
REMOVED=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo " [v1.65.0.0] WARNING: poisoned plist outside the Playwright cache shape, skipping: ${plist}" >&2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
done < <(find "${PW_CACHE}" -maxdepth 5 -name "Info.plist" -path "*.app/Contents/Info.plist" 2>/dev/null)
|
||||||
|
|
||||||
|
# 2. Stranded-state scan: an earlier version of this migration removed
|
||||||
|
# only the poisoned .app, leaving the revision dir with its
|
||||||
|
# INSTALLATION_COMPLETE marker — the exact state that makes
|
||||||
|
# `playwright install chromium` no-op while the user has NO browser.
|
||||||
|
# A chromium revision dir without any .app inside is that strand;
|
||||||
|
# remove it whole so the re-fetch actually downloads.
|
||||||
|
for rev_dir in "${PW_CACHE}"/chromium-*; do
|
||||||
|
[ -d "${rev_dir}" ] || continue
|
||||||
|
is_revision_dir "${rev_dir}" || continue
|
||||||
|
if [ -z "$(find "${rev_dir}" -maxdepth 2 -name '*.app' -print 2>/dev/null | head -1)" ]; then
|
||||||
|
echo " [v1.65.0.0] removing stranded revision dir (install markers without a browser): ${rev_dir}" >&2
|
||||||
|
rm -rf "${rev_dir}"
|
||||||
|
REMOVED=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${REMOVED}" = "1" ]; then
|
||||||
|
# Re-fetch immediately: migrations run AFTER ./setup, so without this the
|
||||||
|
# user finishes the upgrade with no working browser at all (headless AND
|
||||||
|
# headed use the same bundle). Run from the gstack install root so bunx
|
||||||
|
# resolves the repo-pinned playwright version — an arbitrary migration-
|
||||||
|
# runner cwd could resolve a different playwright and populate a revision
|
||||||
|
# the pinned one never launches (same subshell-cd pattern as ./setup's
|
||||||
|
# Chromium install block).
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
echo " [v1.65.0.0] re-fetching a clean Chromium (bunx playwright install chromium)..." >&2
|
||||||
|
if command -v bunx >/dev/null 2>&1 && (cd "${SCRIPT_DIR}" && bunx playwright install chromium >&2); then
|
||||||
|
echo " [v1.65.0.0] playwright install finished." >&2
|
||||||
|
else
|
||||||
|
echo " [v1.65.0.0] WARNING: automatic re-fetch failed." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gate .done on the VERIFIED end state, not the installer's exit code:
|
||||||
|
# `playwright install` exits 0 even when it skips the download, and a
|
||||||
|
# successful removal followed by a failed/offline fetch must not be
|
||||||
|
# recorded as done — that would strand the user with no browser and a
|
||||||
|
# success message.
|
||||||
|
CHROME_EXE="$(find "${PW_CACHE}" -maxdepth 6 -type f -perm -u+x -path "*/chromium-*/*.app/Contents/MacOS/*" 2>/dev/null | head -1)"
|
||||||
|
if [ -n "${CHROME_EXE}" ]; then
|
||||||
|
echo " [v1.65.0.0] verified working Chromium at: ${CHROME_EXE}" >&2
|
||||||
|
rm -f "${NEEDS_REFETCH}" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
touch "${NEEDS_REFETCH}" 2>/dev/null || true
|
||||||
|
echo " [v1.65.0.0] WARNING: no Chromium executable present after removing the poisoned bundle." >&2
|
||||||
|
echo " [v1.65.0.0] Headless AND headed browsing are unavailable until it is re-fetched. Run:" >&2
|
||||||
|
echo " [v1.65.0.0] cd ${SCRIPT_DIR} && bunx playwright install chromium" >&2
|
||||||
|
echo " [v1.65.0.0] Leaving this migration pending — it retries on the next run." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " [v1.65.0.0] no rebrand-poisoned bundles found — no-op." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch "${DONE}"
|
||||||
|
exit 0
|
||||||
@@ -676,6 +676,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -715,6 +715,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -680,6 +680,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -681,6 +681,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -684,6 +684,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -60,14 +60,20 @@ export async function writeAudit(row: AuditRow, path: string = defaultAuditPath(
|
|||||||
await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 });
|
await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-reversible identifier for tokens/identities in logs and API responses.
|
||||||
|
// Same device salt as the attempts log, so ids correlate across both.
|
||||||
|
export async function saltedHash(raw: string): Promise<string> {
|
||||||
|
const salt = await loadDeviceSalt();
|
||||||
|
return createHash('sha256').update(salt + ':' + raw).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
export async function writeAttempt(opts: {
|
export async function writeAttempt(opts: {
|
||||||
rawIdentity: string;
|
rawIdentity: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
reason: AttemptRow['reason'];
|
reason: AttemptRow['reason'];
|
||||||
path?: string;
|
path?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const salt = await loadDeviceSalt();
|
const hash = await saltedHash(opts.rawIdentity);
|
||||||
const hash = createHash('sha256').update(salt + ':' + opts.rawIdentity).digest('hex').slice(0, 16);
|
|
||||||
const row: AttemptRow = {
|
const row: AttemptRow = {
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
identity_canon: hash,
|
identity_canon: hash,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { probeTailscale, whoIs } from './tailscale-localapi';
|
|||||||
import { SessionTokenStore } from './session-tokens';
|
import { SessionTokenStore } from './session-tokens';
|
||||||
import { mintForCaller } from './auth-mint';
|
import { mintForCaller } from './auth-mint';
|
||||||
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
|
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
|
||||||
import { writeAudit, writeAttempt, sanitizeReplacer } from './audit';
|
import { writeAudit, writeAttempt, sanitizeReplacer, saltedHash } from './audit';
|
||||||
import { bootstrapTunnel } from './tunnel-bootstrap';
|
import { bootstrapTunnel } from './tunnel-bootstrap';
|
||||||
import { startTunnelKeepalive } from './devicectl';
|
import { startTunnelKeepalive } from './devicectl';
|
||||||
import type { Capability } from './types';
|
import type { Capability } from './types';
|
||||||
@@ -362,20 +362,38 @@ async function handleLoopback(ctx: HandlerCtx): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// /auth/sessions — list active sessions (owner only).
|
// /auth/sessions — list active sessions (owner only). Raw token values
|
||||||
|
// never leave the store: any local process can hit this listener, so a
|
||||||
|
// list that echoed live bearer tokens was a harvest-and-replay primitive.
|
||||||
|
// Callers get a salted-hash id plus metadata; revoke by identity, by the
|
||||||
|
// token they already hold from mint, or by token_id from this list.
|
||||||
if (method === 'GET' && path === '/auth/sessions') {
|
if (method === 'GET' && path === '/auth/sessions') {
|
||||||
sendJson(res, 200, { sessions: tokenStore.list() });
|
const sessions = await Promise.all(tokenStore.list().map(async ({ token, ...meta }) => ({
|
||||||
|
token_id: await saltedHash(token),
|
||||||
|
...meta,
|
||||||
|
})));
|
||||||
|
sendJson(res, 200, { sessions });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// /auth/revoke — revoke a token.
|
// /auth/revoke — revoke by raw token (the caller's own, from mint), by
|
||||||
|
// token_id (from /auth/sessions — keeps the list→revoke workflow alive
|
||||||
|
// now that the list is hash-only), or by identity.
|
||||||
if (method === 'POST' && path === '/auth/revoke') {
|
if (method === 'POST' && path === '/auth/revoke') {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
if ('error' in body) { sendJson(res, 413, body); return; }
|
if ('error' in body) { sendJson(res, 413, body); return; }
|
||||||
const parsed = JSON.parse(body.toString('utf-8') || '{}') as { token?: string; identity?: string };
|
const parsed = JSON.parse(body.toString('utf-8') || '{}') as {
|
||||||
|
token?: string; token_id?: string; identity?: string;
|
||||||
|
};
|
||||||
let count = 0;
|
let count = 0;
|
||||||
if (parsed.token) {
|
if (parsed.token) {
|
||||||
count = tokenStore.revoke(parsed.token) ? 1 : 0;
|
count = tokenStore.revoke(parsed.token) ? 1 : 0;
|
||||||
|
} else if (parsed.token_id) {
|
||||||
|
for (const s of tokenStore.list()) {
|
||||||
|
if ((await saltedHash(s.token)) === parsed.token_id) {
|
||||||
|
count += tokenStore.revoke(s.token) ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (parsed.identity) {
|
} else if (parsed.identity) {
|
||||||
count = tokenStore.revokeByIdentity(parsed.identity);
|
count = tokenStore.revokeByIdentity(parsed.identity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,83 @@ describe('daemon — loopback listener', () => {
|
|||||||
rmSync(workDir, { recursive: true, force: true });
|
rmSync(workDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('/auth/sessions returns salted-hash ids and metadata, never raw tokens', async () => {
|
||||||
|
const minted = daemon.tokenStore.mint({
|
||||||
|
identity: 'owner@example.com',
|
||||||
|
capability: 'interact',
|
||||||
|
deviceUdid: 'STUB-UDID',
|
||||||
|
origin: 'owner_granted',
|
||||||
|
});
|
||||||
|
if ('error' in minted) throw new Error(minted.error);
|
||||||
|
try {
|
||||||
|
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/auth/sessions`);
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
// The harvest-and-replay primitive: any local process could read live
|
||||||
|
// bearer tokens off this endpoint. The raw token must never appear.
|
||||||
|
expect(r.bodyText).not.toContain(minted.token);
|
||||||
|
|
||||||
|
const { sessions } = JSON.parse(r.bodyText) as { sessions: Array<Record<string, unknown>> };
|
||||||
|
const row = sessions.find(s => s.identity === 'owner@example.com');
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
capability: 'interact',
|
||||||
|
device_udid: 'STUB-UDID',
|
||||||
|
origin: 'owner_granted',
|
||||||
|
expires_at: minted.expires_at,
|
||||||
|
});
|
||||||
|
expect(row?.token_id).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
expect(row?.token).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
daemon.tokenStore.revoke(minted.token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revoke by token_id from the hash-only list still works (list→revoke)', async () => {
|
||||||
|
const minted = daemon.tokenStore.mint({
|
||||||
|
identity: 'revoke-by-id@example.com',
|
||||||
|
capability: 'observe',
|
||||||
|
origin: 'owner_granted',
|
||||||
|
});
|
||||||
|
if ('error' in minted) throw new Error(minted.error);
|
||||||
|
|
||||||
|
const list = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/auth/sessions`);
|
||||||
|
const { sessions } = JSON.parse(list.bodyText) as { sessions: Array<Record<string, unknown>> };
|
||||||
|
const row = sessions.find(s => s.identity === 'revoke-by-id@example.com');
|
||||||
|
expect(row?.token_id).toBeDefined();
|
||||||
|
|
||||||
|
const revoke = await fetchWith('POST', `http://127.0.0.1:${daemon.loopbackPort}/auth/revoke`, {
|
||||||
|
body: JSON.stringify({ token_id: row!.token_id }),
|
||||||
|
});
|
||||||
|
expect(revoke.status).toBe(200);
|
||||||
|
expect(JSON.parse(revoke.bodyText).revoked).toBe(1);
|
||||||
|
expect(daemon.tokenStore.list().some(s => s.identity === 'revoke-by-id@example.com')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revoke with an unknown token_id revokes nothing and leaves live sessions untouched', async () => {
|
||||||
|
const minted = daemon.tokenStore.mint({
|
||||||
|
identity: 'unknown-id-survivor@example.com',
|
||||||
|
capability: 'observe',
|
||||||
|
origin: 'owner_granted',
|
||||||
|
});
|
||||||
|
if ('error' in minted) throw new Error(minted.error);
|
||||||
|
try {
|
||||||
|
// Well-formed (16 hex chars) but matches no session's salted hash.
|
||||||
|
const revoke = await fetchWith('POST', `http://127.0.0.1:${daemon.loopbackPort}/auth/revoke`, {
|
||||||
|
body: JSON.stringify({ token_id: '0'.repeat(16) }),
|
||||||
|
});
|
||||||
|
expect(revoke.status).toBe(200);
|
||||||
|
expect(JSON.parse(revoke.bodyText).revoked).toBe(0);
|
||||||
|
|
||||||
|
// The minted session must survive: still in the token store...
|
||||||
|
expect(daemon.tokenStore.list().some(s => s.identity === 'unknown-id-survivor@example.com')).toBe(true);
|
||||||
|
// ...and still visible on the list endpoint.
|
||||||
|
const list = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/auth/sessions`);
|
||||||
|
const { sessions } = JSON.parse(list.bodyText) as { sessions: Array<Record<string, unknown>> };
|
||||||
|
expect(sessions.some(s => s.identity === 'unknown-id-survivor@example.com')).toBe(true);
|
||||||
|
} finally {
|
||||||
|
daemon.tokenStore.revoke(minted.token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('healthz returns 200 with mode=loopback', async () => {
|
test('healthz returns 200 with mode=loopback', async () => {
|
||||||
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/healthz`);
|
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/healthz`);
|
||||||
expect(r.status).toBe(200);
|
expect(r.status).toBe(200);
|
||||||
|
|||||||
@@ -97,10 +97,12 @@ public final class StateServer {
|
|||||||
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
|
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
|
||||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
|
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
|
||||||
|
|
||||||
// 2. Log the boot token EXACTLY ONCE so the daemon can scrape it.
|
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
|
||||||
// The daemon will rotate immediately; this log line is dead within
|
// token from the 0600 file above (copyFileFromAppContainer); the
|
||||||
// seconds.
|
// os_log line that used to carry it had no consumer and handed a
|
||||||
logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
|
// live credential to anything reading the unified log during the
|
||||||
|
// launch window. Port/build stay for diagnostics.
|
||||||
|
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
|
||||||
|
|
||||||
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
|
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
|
||||||
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
|
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
|
||||||
@@ -149,7 +151,19 @@ public final class StateServer {
|
|||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
params.allowLocalEndpointReuse = true
|
params.allowLocalEndpointReuse = true
|
||||||
|
|
||||||
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
|
// IPv4 has no CoreDevice tunnel path, so it binds strictly to
|
||||||
|
// loopback at the socket level; IPv6 keeps the wildcard bind and
|
||||||
|
// relies on the per-connection peer check below for tunnel peers.
|
||||||
|
let listener: NWListener
|
||||||
|
switch family {
|
||||||
|
case .ipv4:
|
||||||
|
params.requiredLocalEndpoint = NWEndpoint.hostPort(
|
||||||
|
host: NWEndpoint.Host("127.0.0.1"),
|
||||||
|
port: NWEndpoint.Port(rawValue: port)!)
|
||||||
|
listener = try NWListener(using: params)
|
||||||
|
case .ipv6:
|
||||||
|
listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
|
||||||
|
}
|
||||||
listener.stateUpdateHandler = { [weak self] state in
|
listener.stateUpdateHandler = { [weak self] state in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
if case .ready = state {
|
if case .ready = state {
|
||||||
|
|||||||
@@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -673,6 +673,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
@@ -803,6 +807,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
|
|||||||
|
|
||||||
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
|
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
|
||||||
|
|
||||||
|
## Third-Party Web Actions
|
||||||
|
|
||||||
|
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
|
||||||
|
|
||||||
|
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
|
||||||
|
|
||||||
|
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
|
||||||
|
|
||||||
|
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
|
||||||
|
|
||||||
|
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
|
||||||
|
|
||||||
|
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
|
||||||
|
|
||||||
## SETUP (run this check BEFORE any browse command)
|
## SETUP (run this check BEFORE any browse command)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ triggers:
|
|||||||
|
|
||||||
{{PREAMBLE}}
|
{{PREAMBLE}}
|
||||||
|
|
||||||
|
{{THIRD_PARTY_ACTIONS}}
|
||||||
|
|
||||||
{{BROWSE_SETUP}}
|
{{BROWSE_SETUP}}
|
||||||
|
|
||||||
{{BASE_BRANCH_DETECT}}
|
{{BASE_BRANCH_DETECT}}
|
||||||
|
|||||||
@@ -675,6 +675,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -676,6 +676,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
|
|||||||
|
|
||||||
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
|
||||||
|
|
||||||
|
## Claimed Limitations Need Evidence
|
||||||
|
|
||||||
|
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
|
||||||
|
|
||||||
## Continuous Checkpoint Mode
|
## Continuous Checkpoint Mode
|
||||||
|
|
||||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* code-intelligence/contract — the OPTIONAL, repo-oriented provider contract.
|
||||||
|
*
|
||||||
|
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||||
|
*
|
||||||
|
* gstack does not maintain a home-grown indexer. It defines this small contract
|
||||||
|
* and external providers (GBrain, Sourcebot, Graphify) implement it. The whole
|
||||||
|
* contract is OPTIONAL: when no provider is available/consented,
|
||||||
|
* `resolveCodeProvider()` returns null and callers degrade to grep / the
|
||||||
|
* file-only decision store. Never a dependency, always an enhancement — the same
|
||||||
|
* reliability contract as lib/gstack-decision-semantic.ts.
|
||||||
|
*
|
||||||
|
* Repo-oriented, not document-store (settled): register_source / refresh /
|
||||||
|
* search / status are required; add / delete / export are optional capabilities
|
||||||
|
* a provider MAY advertise. A document-CRUD-required contract would misrepresent
|
||||||
|
* whole-repo code-search and code-graph tools. See
|
||||||
|
* docs/designs/CODE_INTELLIGENCE_PROVIDER_CONTRACT.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CodeProviderId = "gbrain" | "sourcebot" | "graphify";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Policy op classification for the per-remote trust-tier veto (selection.ts).
|
||||||
|
* Write-class ops (register_source / index / refresh / add / delete) cause
|
||||||
|
* pages to be written, so BOTH `deny` and `read-only` tiers veto them — the
|
||||||
|
* same semantics as runCodeImport in bin/gstack-gbrain-sync.ts ("code ingest
|
||||||
|
* writes pages"). Read-class ops (search / export / status) write nothing, so
|
||||||
|
* only `deny` vetoes them. Callers that don't say get "write" — fail-closed.
|
||||||
|
*/
|
||||||
|
export type OpClass = "read" | "write";
|
||||||
|
|
||||||
|
export type CodeProviderCapability =
|
||||||
|
| "register_source"
|
||||||
|
| "refresh"
|
||||||
|
| "search"
|
||||||
|
| "status"
|
||||||
|
| "add"
|
||||||
|
| "delete"
|
||||||
|
| "export";
|
||||||
|
|
||||||
|
export const REQUIRED_CAPABILITIES: readonly CodeProviderCapability[] = [
|
||||||
|
"register_source",
|
||||||
|
"refresh",
|
||||||
|
"search",
|
||||||
|
"status",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const OPTIONAL_CAPABILITIES: readonly CodeProviderCapability[] = [
|
||||||
|
"add",
|
||||||
|
"delete",
|
||||||
|
"export",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface RepoRef {
|
||||||
|
/** Source id the provider registers this repo under. */
|
||||||
|
id: string;
|
||||||
|
/** Local worktree path. */
|
||||||
|
path: string;
|
||||||
|
/** Remote URL, when the provider clones/manages it. */
|
||||||
|
remoteUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceRef {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceStatus {
|
||||||
|
id: string;
|
||||||
|
state: "registered" | "indexing" | "ready" | "absent" | "unknown";
|
||||||
|
/** Pages / files / graph nodes, when the provider reports a count. */
|
||||||
|
itemCount?: number;
|
||||||
|
detail?: string;
|
||||||
|
/** True when the provider only implements a partial status probe. */
|
||||||
|
partial?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeSearchHit {
|
||||||
|
/** Slug, file path, or symbol id — whatever the provider keys results on. */
|
||||||
|
ref: string;
|
||||||
|
score?: number;
|
||||||
|
snippet?: string;
|
||||||
|
kind?: "document" | "file" | "symbol" | "graph-node";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpOptions {
|
||||||
|
/**
|
||||||
|
* Env override for spawned processes and egress-receipt home resolution.
|
||||||
|
* Production callers leave this unset; tests inject a synthetic env (fake
|
||||||
|
* CLI on PATH, temp GSTACK_HOME). Matches the existing gbrain helpers.
|
||||||
|
*/
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
/** Timeout in ms for the underlying op. */
|
||||||
|
timeout?: number;
|
||||||
|
/**
|
||||||
|
* Explicit per-repo consent that repo content may leave the machine. Required
|
||||||
|
* for non-local providers on register_source / refresh / add / search (the
|
||||||
|
* search query text is repo-derived content). The recorded value also feeds
|
||||||
|
* the egress receipt, which attests the ACTUAL consent state — never assumed.
|
||||||
|
*/
|
||||||
|
consented?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchOptions extends OpOptions {
|
||||||
|
/** Restrict to a registered source. */
|
||||||
|
source?: string;
|
||||||
|
limit?: number;
|
||||||
|
minScore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeProvider {
|
||||||
|
readonly id: CodeProviderId;
|
||||||
|
readonly label: string;
|
||||||
|
readonly capabilities: ReadonlySet<CodeProviderCapability>;
|
||||||
|
/** True when no repo content leaves the machine (Graphify). */
|
||||||
|
readonly local: boolean;
|
||||||
|
|
||||||
|
has(capability: CodeProviderCapability): boolean;
|
||||||
|
|
||||||
|
registerSource(repo: RepoRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||||
|
refresh(source: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||||
|
search(query: string, opts?: SearchOptions): Promise<CodeSearchHit[]>;
|
||||||
|
status(source?: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||||
|
|
||||||
|
add?(doc: { slug: string; body: string }, opts?: OpOptions): Promise<SourceStatus>;
|
||||||
|
delete?(slug: string, opts?: OpOptions): Promise<SourceStatus>;
|
||||||
|
export?(source: SourceRef, opts?: OpOptions): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CODE_PROVIDER_FAILURES = Object.freeze([
|
||||||
|
"PROVIDER_UNAVAILABLE",
|
||||||
|
"PROVIDER_NOT_CONSENTED",
|
||||||
|
"CAPABILITY_UNSUPPORTED",
|
||||||
|
"SOURCE_NOT_REGISTERED",
|
||||||
|
"PROVIDER_TIMEOUT",
|
||||||
|
"PROVIDER_ERROR",
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
export type CodeProviderFailure = (typeof CODE_PROVIDER_FAILURES)[number];
|
||||||
|
|
||||||
|
const FAILURE_SET = new Set<string>(CODE_PROVIDER_FAILURES);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed provider failure. Mirrors runtime/context.js ContextError discipline:
|
||||||
|
* the code set is closed and the constructor throws on an unknown code, so a
|
||||||
|
* typo can never mint an untyped failure.
|
||||||
|
*/
|
||||||
|
export class CodeProviderError extends Error {
|
||||||
|
readonly code: CodeProviderFailure;
|
||||||
|
readonly providerId?: CodeProviderId;
|
||||||
|
|
||||||
|
constructor(code: CodeProviderFailure, message: string, providerId?: CodeProviderId) {
|
||||||
|
if (!FAILURE_SET.has(code)) throw new TypeError(`Unknown code-provider failure code: ${code}`);
|
||||||
|
super(message);
|
||||||
|
this.name = "CodeProviderError";
|
||||||
|
this.code = code;
|
||||||
|
this.providerId = providerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce that a provider advertises every required capability. Called by each
|
||||||
|
* adapter constructor so an incomplete provider fails fast, not at first search.
|
||||||
|
*/
|
||||||
|
export function assertRequiredCapabilities(
|
||||||
|
id: CodeProviderId,
|
||||||
|
capabilities: ReadonlySet<CodeProviderCapability>,
|
||||||
|
): void {
|
||||||
|
const missing = REQUIRED_CAPABILITIES.filter((cap) => !capabilities.has(cap));
|
||||||
|
if (missing.length) {
|
||||||
|
throw new TypeError(`Code provider ${id} is missing required capabilities: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard for optional ops: throw CAPABILITY_UNSUPPORTED (never a silent no-op)
|
||||||
|
* when a provider is asked for a capability it does not advertise.
|
||||||
|
*/
|
||||||
|
export function assertCapability(provider: CodeProvider, capability: CodeProviderCapability): void {
|
||||||
|
if (!provider.has(capability)) {
|
||||||
|
throw new CodeProviderError(
|
||||||
|
"CAPABILITY_UNSUPPORTED",
|
||||||
|
`${provider.label} does not support "${capability}"`,
|
||||||
|
provider.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repo-scoped egress consent gate. Non-local providers must not move repo
|
||||||
|
* content off the machine without explicit per-repo consent. Local providers
|
||||||
|
* (nothing leaves the machine) are exempt.
|
||||||
|
*/
|
||||||
|
export function assertEgressConsent(provider: CodeProvider, opts?: OpOptions): void {
|
||||||
|
if (provider.local) return;
|
||||||
|
if (opts?.consented === true) return;
|
||||||
|
throw new CodeProviderError(
|
||||||
|
"PROVIDER_NOT_CONSENTED",
|
||||||
|
`${provider.label} would send repo content off this machine; per-repo indexing consent is required`,
|
||||||
|
provider.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* GBrain adapter — full contract fit over the existing gbrain CLI chokepoint.
|
||||||
|
*
|
||||||
|
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||||
|
*
|
||||||
|
* Reuses lib/gbrain-exec.ts (spawnGbrain, seeded DATABASE_URL) and
|
||||||
|
* lib/gbrain-sources.ts (ensureSourceRegistered, probeSource, sourcePageCount)
|
||||||
|
* rather than re-issuing raw commands, so the DATABASE_URL / GBRAIN_HOME /
|
||||||
|
* Windows-shim guarantees carry over unchanged. GBrain's native primitive is
|
||||||
|
* document-by-slug (put/delete/get/export) PLUS a repo axis (sources add/sync),
|
||||||
|
* so it advertises all seven capabilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import { sha256Hex, writeReceipt } from "../egress-receipt.js";
|
||||||
|
import { spawnGbrain, buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "../gbrain-exec";
|
||||||
|
import { ensureSourceRegistered, probeSource, sourcePageCount } from "../gbrain-sources";
|
||||||
|
import {
|
||||||
|
assertCapability,
|
||||||
|
assertEgressConsent,
|
||||||
|
assertRequiredCapabilities,
|
||||||
|
CodeProviderError,
|
||||||
|
type CodeProvider,
|
||||||
|
type CodeProviderCapability,
|
||||||
|
type CodeSearchHit,
|
||||||
|
type OpOptions,
|
||||||
|
type RepoRef,
|
||||||
|
type SearchOptions,
|
||||||
|
type SourceRef,
|
||||||
|
type SourceStatus,
|
||||||
|
} from "./contract";
|
||||||
|
|
||||||
|
const CAPABILITIES: CodeProviderCapability[] = [
|
||||||
|
"register_source",
|
||||||
|
"refresh",
|
||||||
|
"search",
|
||||||
|
"status",
|
||||||
|
"add",
|
||||||
|
"delete",
|
||||||
|
"export",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
|
/**
|
||||||
|
* refresh() default. Full code indexing on the 1000+-tracked-file repos this
|
||||||
|
* feature targets routinely outruns the 30s op default; GraphifyProvider uses
|
||||||
|
* the same 120s ceiling for the same indexing work. Query/status stay at 30s.
|
||||||
|
*/
|
||||||
|
const REFRESH_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environmental (engine / DB / config) failure shapes, shared by #assertOk and
|
||||||
|
* #wrap so the two paths can never drift. These degrade to
|
||||||
|
* PROVIDER_UNAVAILABLE (caller falls back to grep / file-only), not a hard
|
||||||
|
* PROVIDER_ERROR with a raw dump. Covers the real case where gbrain's pglite
|
||||||
|
* engine fails to init its WASM runtime (garrytan/gbrain#223) as well as
|
||||||
|
* unreachable/unconfigured databases and a missing CLI.
|
||||||
|
*/
|
||||||
|
const ENVIRONMENTAL_ERROR_RE =
|
||||||
|
/not on PATH|command not found|PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json|database (is )?un(reachable|available)/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `gbrain search` text output (`[score] slug -- snippet`) into hits.
|
||||||
|
* gbrain's search prints text, not JSON (verified in
|
||||||
|
* lib/gstack-decision-semantic.ts). Exported for deterministic unit testing.
|
||||||
|
*/
|
||||||
|
export function parseGbrainSearch(stdout: string, minScore: number, limit: number): CodeSearchHit[] {
|
||||||
|
const hits: CodeSearchHit[] = [];
|
||||||
|
for (const line of stdout.split("\n")) {
|
||||||
|
const m = line.match(/^\[([\d.]+)\]\s+(\S+)\s+--\s+(.*)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const score = parseFloat(m[1]);
|
||||||
|
if (!Number.isFinite(score) || score < minScore) continue;
|
||||||
|
hits.push({ ref: m[2], score, snippet: m[3].trim(), kind: "document" });
|
||||||
|
}
|
||||||
|
return hits.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GbrainProvider implements CodeProvider {
|
||||||
|
readonly id = "gbrain" as const;
|
||||||
|
readonly label = "GBrain";
|
||||||
|
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
|
||||||
|
/** GBrain federates into a (possibly remote) DB, so content can leave the machine. */
|
||||||
|
readonly local = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
assertRequiredCapabilities(this.id, this.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(capability: CodeProviderCapability): boolean {
|
||||||
|
return this.capabilities.has(capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail-closed egress receipt, written BEFORE every content-bearing send
|
||||||
|
* (register/refresh/add AND search/export). The gbrain subprocess owns the
|
||||||
|
* wire bytes, so the receipt records destination + payload class; sha256 is
|
||||||
|
* known when the exact payload text is (the `add` document body, the
|
||||||
|
* `search` query). The consent field records the ACTUAL consent state from
|
||||||
|
* opts — the tamper-evident ledger must never attest consented=true for a
|
||||||
|
* send where nothing checked consent (every current caller asserts consent
|
||||||
|
* first, so the unchecked branch is defense-in-depth, not a live path).
|
||||||
|
*/
|
||||||
|
#receipt(payloadClass: string, opts: OpOptions, body?: string): void {
|
||||||
|
writeReceipt({
|
||||||
|
env: opts.env,
|
||||||
|
sink: "gbrain",
|
||||||
|
host: "gbrain-db (user-configured DATABASE_URL)",
|
||||||
|
payloadClass,
|
||||||
|
bytes: body == null ? 0 : Buffer.byteLength(body),
|
||||||
|
sha256: body == null ? null : sha256Hex(body),
|
||||||
|
consent: opts.consented === true
|
||||||
|
? "code-intelligence provider=gbrain + per-repo consented=true"
|
||||||
|
: "code-intelligence provider=gbrain + consent=unchecked (content-bearing ops assert consent before sending)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
|
assertEgressConsent(this, opts);
|
||||||
|
this.#receipt("repo-source-registration (sent by gbrain subprocess)", opts);
|
||||||
|
try {
|
||||||
|
const result = await ensureSourceRegistered(repo.id, repo.path, {
|
||||||
|
federated: true,
|
||||||
|
env: opts.env,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: repo.id,
|
||||||
|
state: result.state.status === "match" ? "registered" : "unknown",
|
||||||
|
detail: result.changed ? "registered" : "already registered",
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw this.#wrap(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
|
assertEgressConsent(this, opts);
|
||||||
|
this.#receipt("repo-code-index (sent by gbrain subprocess)", opts);
|
||||||
|
const timeout = opts.timeout ?? REFRESH_TIMEOUT_MS;
|
||||||
|
// Two passes, verified end-to-end against real Postgres-backed gbrain 0.42.56:
|
||||||
|
// 1. default sync (markdown strategy) — indexes docs.
|
||||||
|
// 2. `sync --strategy code` — the ACTUAL code-indexing pass. Without it code
|
||||||
|
// is never indexed (the whole point of a code provider); `code-def` stays
|
||||||
|
// "not_built" and search only finds incidental doc mentions. `--full`
|
||||||
|
// forces it past the per-source checkpoint the markdown pass advanced.
|
||||||
|
this.#assertOk(spawnGbrain(["sync", "--source", source.id], { baseEnv: opts.env, timeout }));
|
||||||
|
this.#assertOk(spawnGbrain(["sync", "--source", source.id, "--strategy", "code", "--full"], { baseEnv: opts.env, timeout }));
|
||||||
|
return this.status(source, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
// The query text is repo-derived content and DATABASE_URL may point at a
|
||||||
|
// remote DB. Unlike Sourcebot there is no cheap loopback check here — the
|
||||||
|
// URL is resolved inside the gbrain CLI's own config, not by this adapter
|
||||||
|
// — so EVERY send is treated as consent-requiring (fail closed, matching
|
||||||
|
// the contract's OpOptions doc). The throw happens before any bytes (or
|
||||||
|
// any receipt) exist; the receipt lands before the subprocess spawns.
|
||||||
|
assertEgressConsent(this, opts);
|
||||||
|
this.#receipt("code-search-query (sent by gbrain subprocess)", opts, query);
|
||||||
|
// `gbrain search` is global and has no `--source` flag; `--limit` is real
|
||||||
|
// (verified against gbrain 0.42.x --help).
|
||||||
|
const args = ["search", query];
|
||||||
|
if (opts.limit) args.push("--limit", String(opts.limit));
|
||||||
|
const r = spawnGbrain(args, { baseEnv: opts.env, timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS });
|
||||||
|
this.#assertOk(r);
|
||||||
|
return parseGbrainSearch(r.stdout || "", opts.minScore ?? 0.1, opts.limit ?? 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
|
if (!source) {
|
||||||
|
// No source given: liveness probe. `sources list` reachable = ready.
|
||||||
|
this.#assertOk(spawnGbrain(["sources", "list", "--json"], {
|
||||||
|
baseEnv: opts.env,
|
||||||
|
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||||
|
}));
|
||||||
|
return { id: "*", state: "ready" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const probed = probeSource(source.id, opts.env);
|
||||||
|
if (probed.status === "absent") return { id: source.id, state: "absent" };
|
||||||
|
const count = sourcePageCount(source.id, opts.env);
|
||||||
|
return {
|
||||||
|
id: source.id,
|
||||||
|
state: "ready",
|
||||||
|
itemCount: count ?? undefined,
|
||||||
|
detail: probed.registered_path,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw this.#wrap(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document ops (add/delete/export) are GBrain-only and secondary; they match
|
||||||
|
// gbrain's documented CLI surface (`put <slug>` reads stdin; `delete <slug>`;
|
||||||
|
// `export`) but could not be exercised against a live engine on the test host
|
||||||
|
// (pglite WASM broken, garrytan/gbrain#223), so treat them as best-effort.
|
||||||
|
async add(doc: { slug: string; body: string }, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
|
assertCapability(this, "add");
|
||||||
|
assertEgressConsent(this, opts);
|
||||||
|
this.#receipt("document-body (sent by gbrain subprocess)", opts, doc.body);
|
||||||
|
// `gbrain put <slug>` reads the document body from stdin.
|
||||||
|
this.#assertOk(this.#runInput(["put", doc.slug], doc.body, opts));
|
||||||
|
return { id: doc.slug, state: "ready" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(slug: string, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
|
assertCapability(this, "delete");
|
||||||
|
// stdin closed ("") so any confirmation prompt gets EOF rather than hanging.
|
||||||
|
this.#assertOk(this.#runInput(["delete", slug], "", opts));
|
||||||
|
return { id: slug, state: "absent" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(_source: SourceRef, opts: OpOptions = {}): Promise<string> {
|
||||||
|
assertCapability(this, "export");
|
||||||
|
// The export request federates into the same possibly-remote DB as
|
||||||
|
// search — same fail-closed consent gate + receipt (no loopback
|
||||||
|
// exemption exists for gbrain; see search()).
|
||||||
|
assertEgressConsent(this, opts);
|
||||||
|
this.#receipt("brain-export-request (sent by gbrain subprocess)", opts);
|
||||||
|
// `gbrain export` is brain-wide (no per-source flag); returns whatever it prints.
|
||||||
|
const r = spawnGbrain(["export"], {
|
||||||
|
baseEnv: opts.env,
|
||||||
|
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
this.#assertOk(r);
|
||||||
|
return r.stdout || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** spawn gbrain with `input` on stdin, seeded env, Windows-shim aware. */
|
||||||
|
#runInput(args: string[], input: string, opts: OpOptions) {
|
||||||
|
return spawnSync("gbrain", args, {
|
||||||
|
input,
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||||
|
env: buildGbrainEnv({ baseEnv: opts.env }),
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw a typed failure unless the spawn succeeded. Distinguishes a missing
|
||||||
|
* CLI (ENOENT → PROVIDER_UNAVAILABLE, the degrade signal) from a timeout
|
||||||
|
* (ETIMEDOUT/SIGTERM, status=null) and a real non-zero exit.
|
||||||
|
*/
|
||||||
|
#assertOk(r: {
|
||||||
|
status: number | null;
|
||||||
|
stderr?: string;
|
||||||
|
error?: Error & { code?: string };
|
||||||
|
signal?: NodeJS.Signals | null;
|
||||||
|
}): void {
|
||||||
|
if (r.status === 0) return;
|
||||||
|
const stderr = (r.stderr || "").trim();
|
||||||
|
if (r.error?.code === "ENOENT" || /command not found/.test(stderr)) {
|
||||||
|
throw new CodeProviderError("PROVIDER_UNAVAILABLE", "gbrain CLI not on PATH", this.id);
|
||||||
|
}
|
||||||
|
if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") {
|
||||||
|
throw new CodeProviderError("PROVIDER_TIMEOUT", "gbrain timed out", this.id);
|
||||||
|
}
|
||||||
|
// Engine / DB / config problems are ENVIRONMENTAL — degrade to UNAVAILABLE
|
||||||
|
// (caller falls back to file-only). Shapes hoisted to ENVIRONMENTAL_ERROR_RE.
|
||||||
|
if (ENVIRONMENTAL_ERROR_RE.test(stderr)) {
|
||||||
|
throw new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(stderr) || "gbrain engine unavailable", this.id);
|
||||||
|
}
|
||||||
|
throw new CodeProviderError("PROVIDER_ERROR", firstLine(stderr) || `gbrain exited ${r.status}`, this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#wrap(err: unknown): CodeProviderError {
|
||||||
|
if (err instanceof CodeProviderError) return err;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
// Same environmental-vs-real split as #assertOk — literally the same
|
||||||
|
// regex (ENVIRONMENTAL_ERROR_RE), so the two paths can never drift.
|
||||||
|
if (ENVIRONMENTAL_ERROR_RE.test(message)) {
|
||||||
|
return new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(message), this.id);
|
||||||
|
}
|
||||||
|
return new CodeProviderError("PROVIDER_ERROR", firstLine(message), this.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First non-empty line, so a multi-line WASM/stack dump never reaches the user. */
|
||||||
|
function firstLine(text: string): string {
|
||||||
|
return (text || "").split("\n").map((l) => l.trim()).find(Boolean) ?? "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Graphify adapter — real CLI integration (github.com/Graphify-Labs/graphify).
|
||||||
|
*
|
||||||
|
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||||
|
*
|
||||||
|
* Graphify is a LOCAL tree-sitter knowledge graph. For CODE, `graphify <dir>`
|
||||||
|
* and `graphify update <dir>` produce the SAME AST graph with NO LLM and NO
|
||||||
|
* network (verified against graphify 0.9.23 — both emit `AST extraction on N
|
||||||
|
* code files`, all node origins `ast`). The LLM backend (openai/gemini) is only
|
||||||
|
* used to RENAME community clusters (`graphify label` / `cluster-only`) and to
|
||||||
|
* ingest non-code docs (`graphify add`); it adds zero nodes/edges, and our parser
|
||||||
|
* discards the `community=` field it touches — so an LLM mode would send code
|
||||||
|
* off-machine for no change in search output, and is intentionally not offered.
|
||||||
|
*
|
||||||
|
* This adapter uses `graphify update <dir>` (writes `<dir>/graphify-out/graph.json`
|
||||||
|
* and does clustering in one shot) and stays fully local — nothing leaves the
|
||||||
|
* machine, so `local = true` and no egress consent is needed.
|
||||||
|
*
|
||||||
|
* Query is `graphify query "<q>" --graph <dir>/graphify-out/graph.json`; the
|
||||||
|
* `--graph` flag points at the built graph so search never depends on cwd.
|
||||||
|
*
|
||||||
|
* Never auto-installed: install is `pip install graphifyy && graphify install`
|
||||||
|
* (needs Python >= 3.10), a user action the picker surfaces. When the CLI is
|
||||||
|
* absent every op throws PROVIDER_UNAVAILABLE and callers degrade to file-only.
|
||||||
|
*
|
||||||
|
* Path-based, not id-based: for Graphify a source "id" IS the absolute repo path
|
||||||
|
* (that is where `graphify-out/` lives), unlike GBrain's short source ids.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import { existsSync, readFileSync, statSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import {
|
||||||
|
assertCapability,
|
||||||
|
assertRequiredCapabilities,
|
||||||
|
CodeProviderError,
|
||||||
|
type CodeProvider,
|
||||||
|
type CodeProviderCapability,
|
||||||
|
type CodeSearchHit,
|
||||||
|
type OpOptions,
|
||||||
|
type RepoRef,
|
||||||
|
type SearchOptions,
|
||||||
|
type SourceRef,
|
||||||
|
type SourceStatus,
|
||||||
|
} from "./contract";
|
||||||
|
|
||||||
|
const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status", "export"];
|
||||||
|
const OUT_DIR = "graphify-out";
|
||||||
|
const GRAPH_JSON = "graph.json";
|
||||||
|
const DEFAULT_TIMEOUT_MS = 120_000; // indexing a repo can take a while
|
||||||
|
const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; // graphify is a shim on Windows
|
||||||
|
/**
|
||||||
|
* status() only parses graph.json for a node count when the file is at most
|
||||||
|
* this big. On the 1000+-file repos this feature targets, graph.json can run
|
||||||
|
* to hundreds of MB — JSON.parsing that for a cosmetic count is a heap spike.
|
||||||
|
* Above the threshold the display reports the file size instead.
|
||||||
|
*/
|
||||||
|
const STATUS_PARSE_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface GraphifyOptions {
|
||||||
|
/** Directory whose `graphify-out/` search/status/export read. Defaults to cwd. */
|
||||||
|
root?: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GraphifyProvider implements CodeProvider {
|
||||||
|
readonly id = "graphify" as const;
|
||||||
|
readonly label = "Graphify";
|
||||||
|
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
|
||||||
|
/** Fully local — no repo content leaves the machine. */
|
||||||
|
readonly local = true;
|
||||||
|
readonly #root: string;
|
||||||
|
readonly #env?: NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
constructor(opts: GraphifyOptions = {}) {
|
||||||
|
this.#root = opts.root ?? process.cwd();
|
||||||
|
this.#env = opts.env;
|
||||||
|
assertRequiredCapabilities(this.id, this.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(capability: CodeProviderCapability): boolean {
|
||||||
|
return this.capabilities.has(capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
#run(args: string[], cwd: string, timeout: number) {
|
||||||
|
return spawnSync("graphify", args, {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: this.#env,
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#assertOk(r: { status: number | null; stderr?: string; error?: Error & { code?: string }; signal?: NodeJS.Signals | null }): void {
|
||||||
|
if (r.status === 0) return;
|
||||||
|
const stderr = (r.stderr || "").trim();
|
||||||
|
if (r.error?.code === "ENOENT" || /command not found/.test(stderr)) {
|
||||||
|
throw new CodeProviderError("PROVIDER_UNAVAILABLE", "graphify CLI not on PATH (install: pip install graphifyy && graphify install)", this.id);
|
||||||
|
}
|
||||||
|
if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") {
|
||||||
|
throw new CodeProviderError("PROVIDER_TIMEOUT", "graphify timed out", this.id);
|
||||||
|
}
|
||||||
|
throw new CodeProviderError("PROVIDER_ERROR", stderr || `graphify exited ${r.status}`, this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the graph over repo.path locally (no LLM, no egress consent needed). */
|
||||||
|
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
|
this.#assertOk(this.#run(["update", repo.path], repo.path, opts.timeout ?? DEFAULT_TIMEOUT_MS));
|
||||||
|
return this.status({ id: repo.path }, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-parse and rebuild the graph (same local `graphify update` path). */
|
||||||
|
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
|
this.#assertOk(this.#run(["update", source.id], source.id, opts.timeout ?? DEFAULT_TIMEOUT_MS));
|
||||||
|
return this.status(source, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `graphify query "<q>" --graph <graph.json>` traces the graph and prints
|
||||||
|
* `NODE ...` / `EDGE ...` lines (plus a `Traversal:` header). We pass `--graph`
|
||||||
|
* explicitly so the query reads the indexed repo's graph regardless of cwd.
|
||||||
|
*/
|
||||||
|
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
const root = opts.source ?? this.#root;
|
||||||
|
const graphPath = join(root, OUT_DIR, GRAPH_JSON);
|
||||||
|
const r = this.#run(["query", query, "--graph", graphPath], root, opts.timeout ?? DEFAULT_TIMEOUT_MS);
|
||||||
|
this.#assertOk(r);
|
||||||
|
return parseGraphifyQuery(r.stdout || "", opts.limit ?? 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(source?: SourceRef, _opts: OpOptions = {}): Promise<SourceStatus> {
|
||||||
|
const dir = source?.id ?? this.#root;
|
||||||
|
const graphPath = join(dir, OUT_DIR, GRAPH_JSON);
|
||||||
|
if (!existsSync(graphPath)) return { id: dir, state: "absent" };
|
||||||
|
let itemCount: number | undefined;
|
||||||
|
let detail = graphPath;
|
||||||
|
try {
|
||||||
|
// stat first: parse the whole graph only when it's small (the node count
|
||||||
|
// is display-only, never worth a hundreds-of-MB JSON.parse heap spike).
|
||||||
|
const size = statSync(graphPath).size;
|
||||||
|
if (size <= STATUS_PARSE_MAX_BYTES) {
|
||||||
|
const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] };
|
||||||
|
if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length;
|
||||||
|
} else {
|
||||||
|
detail = `${graphPath} (${(size / (1024 * 1024)).toFixed(1)} MB graph; node count skipped)`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// graph.json present but unstatable/unparseable — still ready, just no count.
|
||||||
|
}
|
||||||
|
return { id: dir, state: "ready", itemCount, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(source: SourceRef, _opts: OpOptions = {}): Promise<string> {
|
||||||
|
assertCapability(this, "export");
|
||||||
|
const graphPath = join(source.id, OUT_DIR, GRAPH_JSON);
|
||||||
|
if (!existsSync(graphPath)) {
|
||||||
|
throw new CodeProviderError("SOURCE_NOT_REGISTERED", `no graph at ${graphPath}; index it first`, this.id);
|
||||||
|
}
|
||||||
|
return readFileSync(graphPath, "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse real `graphify query` output into hits. The format (graphify 0.9.23):
|
||||||
|
* Traversal: BFS depth=2 | Start: ['query()'] | ... | 4 nodes found
|
||||||
|
* NODE query() [src=db.py loc=L4 community=login]
|
||||||
|
* EDGE query() --calls [EXTRACTED context=call]--> login() at=auth.py:L8
|
||||||
|
* The file lives mid-line (`src=<file> loc=L<n>` on NODE, `at=<file>:L<n>` on
|
||||||
|
* EDGE), so the ref is `<file>:L<n>`. The `Traversal:` header and any other line
|
||||||
|
* are skipped. Exported for deterministic unit testing against the real format.
|
||||||
|
*/
|
||||||
|
export function parseGraphifyQuery(stdout: string, limit: number): CodeSearchHit[] {
|
||||||
|
const hits: CodeSearchHit[] = [];
|
||||||
|
for (const raw of stdout.split("\n")) {
|
||||||
|
const line = raw.trim();
|
||||||
|
let ref: string | undefined;
|
||||||
|
const node = line.match(/^NODE\b.*?\[src=(\S+)\s+loc=(L\d+)/);
|
||||||
|
const edge = line.match(/^EDGE\b.*?\bat=(\S+?):(L\d+)\b/);
|
||||||
|
if (node) ref = `${node[1]}:${node[2]}`;
|
||||||
|
else if (edge) ref = `${edge[1]}:${edge[2]}`;
|
||||||
|
else continue; // skip the Traversal header and anything non-NODE/EDGE
|
||||||
|
hits.push({ ref, snippet: line, kind: "graph-node" });
|
||||||
|
if (hits.length >= limit) break;
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the `graphify` CLI is installed (for the picker's availability probe). */
|
||||||
|
export function graphifyInstalled(env?: NodeJS.ProcessEnv): boolean {
|
||||||
|
const r = spawnSync("graphify", ["--version"], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 5_000,
|
||||||
|
stdio: ["ignore", "ignore", "ignore"],
|
||||||
|
env,
|
||||||
|
shell: NEEDS_SHELL_ON_WINDOWS,
|
||||||
|
});
|
||||||
|
return r.status === 0;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user