mirror of https://github.com/garrytan/gstack.git
Merge origin/main (v1.65.0.0 fork port wave 2) into test-evals-ci-speedup
Second overlapping-wave merge; resolutions compose intent: - TEST_ROOTS: ours is the superset (main also wired ios-qa/daemon/test; ours additionally has ios-qa/scripts + browser-skills). package.json 'test' keeps routing through the canonical strict runner. - gbrainAvailable: main fixed the same load-flake with a strictly better mechanism (memoized stat-based PATH scan, no subprocess at all) — theirs supersedes this branch's memoized-exec probe. Main also made the query timeout env-overridable (GSTACK_BRAIN_TIMEOUT_MS). - Model defaults: adopted main's lib/eval-model.ts abstraction (one resolution point, env-overridable per kind) and applied decision D1a inside it: capture defaults to Sonnet (Opus opt-in via explicit arg or GSTACK_EVAL_MODEL_CAPTURE); test pins updated to follow. - Parent watchdog: main's rewrite (named parameterized tick, driven deterministically by its test via __testInternals__, plus handoff suppression semantics from session persistence) supersedes this branch's env-tunable interval; adopted their server + test wholesale. - windows-free-tests: ours (curated bun run test:windows) — main's hand-list grew by one more file, which the curated runner subsumes automatically; that drift is the reason for D11. - context-skills 0-for-26 fix: both waves made the IDENTICAL fix; kept this branch's comment (carries the receipts). - .gitignore: main's superset (also ignores Package.resolved — their never-commit call; untracked the copy this branch had committed). Verified: 239-test merge battery green, watchdog 8/8, eval-model 5/5, actionlint clean, eval:select works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
e99e53cc71
|
|
@ -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:
|
||||
# Build Docker image with pre-baked toolchain (cached — only rebuilds on Dockerfile/lockfile change)
|
||||
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
|
||||
permissions:
|
||||
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
|
||||
|
|
@ -45,5 +45,7 @@ docs/throughput-*.json
|
|||
# gbrain local source-staging dir (capability checks, source clones) — runtime artifact
|
||||
.sources/
|
||||
|
||||
# Swift build output from the ios-qa gen-accessors tool (built on demand by its tests)
|
||||
# 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
BROWSER.md
13
BROWSER.md
|
|
@ -1091,6 +1091,19 @@ $B state load my-session # restore
|
|||
In-memory `load-html` content is intentionally NOT persisted (avoid leaking
|
||||
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
|
||||
|
||||
```bash
|
||||
|
|
|
|||
190
CHANGELOG.md
190
CHANGELOG.md
|
|
@ -1,5 +1,195 @@
|
|||
# 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
|
||||
|
||||
**Every guard in the pipeline now provably fires.**
|
||||
|
|
|
|||
|
|
@ -165,11 +165,11 @@ gstack/
|
|||
│ ├── test/ # Integration tests
|
||||
│ └── dist/ # Compiled binary
|
||||
├── 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
|
||||
├── setup-deploy/ # /setup-deploy skill (one-time deploy config)
|
||||
├── .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)
|
||||
├── contrib/ # Contributor-only tools (never installed for users)
|
||||
│ └── 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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-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-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-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. |
|
||||
|
|
|
|||
85
TODOS.md
85
TODOS.md
|
|
@ -2,6 +2,88 @@
|
|||
|
||||
## 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)
|
||||
|
||||
**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.
|
||||
**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.
|
||||
**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:**
|
||||
- Rewire `ctx.paths.binDir` (and browse/design dir paths) + the ~9 resolvers that
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
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')
|
||||
DESIGN=$(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)
|
||||
_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-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"
|
||||
```
|
||||
|
||||
|
|
@ -1676,12 +1693,12 @@ if command -v jq >/dev/null 2>&1; then
|
|||
# Filter to current branch + recent commits, then keep records for the
|
||||
# latest run_id only. (Single phase may have multiple files if the user
|
||||
# re-ran the review; aggregator takes the newest.)
|
||||
# NOTE: bind .commit BEFORE the split pipe. Inside ($commits | split(...))
|
||||
# the "." context is the resulting ARRAY, so a bare .commit there raises
|
||||
# "Cannot index array with string" on every record — and the 2>/dev/null
|
||||
# below swallows it, so the whole aggregation silently yields zero tasks.
|
||||
# .commit must be bound BEFORE piping to the split commit array: a
|
||||
# pipe rebinds jq's context, so a bare .commit after it indexes the
|
||||
# ARRAY with a string, every line errors into 2>/dev/null, and the
|
||||
# aggregate is empty forever — the #2018 zero-tasks bug.
|
||||
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
|
||||
done < <(find "$TASKS_DIR" -maxdepth 1 -name "tasks-$phase-*.jsonl" 2>/dev/null | sort)
|
||||
# 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
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync, readdirSync } from "fs";
|
||||
import { join, dirname, basename, resolve } from "path";
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
import { existsSync, readFileSync, statSync, readdirSync, accessSync, constants } from "fs";
|
||||
import { join, dirname, basename, resolve, delimiter } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
import { homedir } from "os";
|
||||
|
||||
import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers";
|
||||
|
|
@ -68,7 +68,9 @@ interface QueryResult {
|
|||
|
||||
const HOME = homedir();
|
||||
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
|
||||
|
||||
// ── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -190,26 +192,28 @@ function resolveSkillFile(args: CliArgs): string | null {
|
|||
|
||||
// ── Dispatchers ────────────────────────────────────────────────────────────
|
||||
|
||||
// Memoized: availability can't change mid-invocation, and the per-query
|
||||
// re-probe was both wasteful (N probes per run) and load-flaky — a cold
|
||||
// `gbrain --version` on a saturated box can exceed the 500ms budget, branding
|
||||
// gbrain "missing" for one query while its siblings succeed (observed under
|
||||
// the parallel free-suite runner: SKIP at dur=505ms with two OKs after it).
|
||||
let _gbrainAvailable: boolean | null = null;
|
||||
let gbrainOnPath: boolean | null = null;
|
||||
|
||||
function gbrainAvailable(): boolean {
|
||||
if (_gbrainAvailable !== null) return _gbrainAvailable;
|
||||
try {
|
||||
execFileSync("gbrain", ["--version"], {
|
||||
stdio: "ignore",
|
||||
// Generous first-probe budget: this runs ONCE, and a slow-to-start CLI
|
||||
// is not a missing CLI. Query calls keep the tight MCP_TIMEOUT_MS.
|
||||
timeout: 5_000,
|
||||
});
|
||||
_gbrainAvailable = true;
|
||||
} catch {
|
||||
_gbrainAvailable = false;
|
||||
}
|
||||
return _gbrainAvailable;
|
||||
// Stat-based PATH scan, memoized. Spawning `gbrain --version` under the
|
||||
// 500ms budget misreported gbrain as missing whenever a cold process spawn
|
||||
// exceeded the timeout (loaded machine, node-based CLI cold start), and
|
||||
// re-probing per query burned 3x the budget before any real work.
|
||||
if (gbrainOnPath !== null) return gbrainOnPath;
|
||||
const exts = process.platform === "win32"
|
||||
? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";")
|
||||
: [""];
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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_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_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
|
||||
# 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
|
||||
VALUE="false"
|
||||
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
|
||||
echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2
|
||||
VALUE="prompt"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// 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.
|
||||
|
||||
import '../lib/conductor-env-shim'; // --exact needs GSTACK_ANTHROPIC_API_KEY promotion inside Conductor
|
||||
import { contextBillMain } from '../lib/context-bill';
|
||||
|
||||
process.exit(await contextBillMain(process.argv.slice(2)));
|
||||
|
|
|
|||
|
|
@ -51,39 +51,64 @@ if (args.includes("--compact")) {
|
|||
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 redactId = flagValue(args, "--redact");
|
||||
if (supersedeId || redactId) {
|
||||
const kind = supersedeId ? "supersede" : "redact";
|
||||
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" }));
|
||||
rebuildSnapshot(paths);
|
||||
enqueue();
|
||||
console.log(`${kind}: ${targetId}`);
|
||||
console.log(replacement ? `${kind}: ${targetId} -> ${replacement.id}` : `${kind}: ${targetId}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const jsonArg = args.find((a) => !a.startsWith("--"));
|
||||
if (!jsonArg) {
|
||||
process.stderr.write(
|
||||
"gstack-decision-log: provide a JSON decision, or --supersede/--redact <id>, or --compact\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
let obj: Partial<DecisionEvent>;
|
||||
try {
|
||||
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);
|
||||
const event = validPayload(jsonArg);
|
||||
appendEvent(paths, event);
|
||||
rebuildSnapshot(paths);
|
||||
enqueue();
|
||||
console.log(res.event.id);
|
||||
console.log(event.id);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,15 @@
|
|||
# Or: gstack-diff-scope main → prints SCOPE_*=... lines
|
||||
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
|
||||
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 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({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
model: distillModel,
|
||||
max_tokens: 4096,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbra
|
|||
import { writeReceipt } from "../lib/egress-receipt";
|
||||
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
|
||||
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";
|
||||
|
||||
// ── 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> {
|
||||
const t0 = Date.now();
|
||||
const root = repoRoot();
|
||||
|
|
@ -787,6 +822,36 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
|||
|
||||
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
|
||||
// engine state. Useful for "what would /sync-gbrain do" without probing
|
||||
// the engine.
|
||||
|
|
|
|||
|
|
@ -52,8 +52,9 @@ import {
|
|||
readSync,
|
||||
closeSync,
|
||||
rmSync,
|
||||
realpathSync,
|
||||
} 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 { homedir } from "os";
|
||||
import { createHash } from "crypto";
|
||||
|
|
@ -1458,13 +1459,40 @@ function runGbrainImportOnce(
|
|||
// still reporting `written: N` from the staged count. Silent data loss
|
||||
// on every run. A working run logs `import.collect_files done ... files=N`
|
||||
// with N > 0 and takes minutes, not seconds.
|
||||
const child = spawnGbrainAsync([
|
||||
"import",
|
||||
stagingDir,
|
||||
"--no-embed",
|
||||
...(includeGitignored ? ["--include-gitignored"] : []),
|
||||
"--json",
|
||||
]);
|
||||
//
|
||||
// GIT_CEILING_DIRECTORIES is the second layer of the same #2144 defense:
|
||||
// it stops git's upward repo discovery at the staging dir's parent, so a
|
||||
// git-enumerating collector fails cleanly out of the git fast path and
|
||||
// falls back to its plain FS walk even on gbrain builds whose flag
|
||||
// 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;
|
||||
let stdout = "";
|
||||
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 {
|
||||
// #1802 D1: in remote-http mode `stagingDir` is the PERSISTENT transcript
|
||||
// dir (makePersistentTranscriptDir, under ~/.gstack/transcripts/) that
|
||||
|
|
|
|||
|
|
@ -405,7 +405,32 @@ function parseArgs(argv: string[]): { base: string; bump: Bump; current: string;
|
|||
else if (a === "-h" || a === "--help") 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) {
|
||||
console.error("Error: --bump is required (major|minor|patch|micro)");
|
||||
process.exit(2);
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ elif [ -n "${TMP:-}" ]; then
|
|||
else
|
||||
_tmp_root=".gstack/tmp"
|
||||
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"
|
||||
# without producing a double slash. On macOS $TMPDIR ends in `/` by default
|
||||
|
|
|
|||
|
|
@ -210,6 +210,13 @@ fi
|
|||
STEP_FIELD="null"
|
||||
[ -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
|
||||
if [ -n "$DURATION" ] && [ "$DURATION" -gt 86400 ] 2>/dev/null; then
|
||||
DURATION="" # null if > 24h
|
||||
|
|
|
|||
|
|
@ -81,15 +81,27 @@ while IFS= read -r LINE; do
|
|||
[ -z "$LINE" ] && continue
|
||||
echo "$LINE" | grep -q '^{' || continue
|
||||
|
||||
# Strip local-only fields (keep v, ts, sessions as-is for edge function)
|
||||
CLEAN="$(echo "$LINE" | sed \
|
||||
-e 's/,"_repo_slug":"[^"]*"//g' \
|
||||
-e 's/,"_branch":"[^"]*"//g' \
|
||||
-e 's/,"repo":"[^"]*"//g')"
|
||||
|
||||
# If anonymous tier, strip installation_id
|
||||
if [ "$TIER" = "anonymous" ]; then
|
||||
CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')"
|
||||
# Strip local-only fields (keep v, ts, sessions as-is for edge function).
|
||||
# jq del() is structural — a value containing an escaped quote (repo names,
|
||||
# branch names) can't smuggle the field past a regex or corrupt the strip.
|
||||
# The sed path stays only as a jq-less fallback.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if [ "$TIER" = "anonymous" ]; then
|
||||
CLEAN="$(printf '%s' "$LINE" | jq -c 'del(._repo_slug, ._branch, .repo, .installation_id)' 2>/dev/null)" || CLEAN=""
|
||||
else
|
||||
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
|
||||
|
||||
if [ "$FIRST" = "true" ]; then
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
||||
|
|
|
|||
|
|
@ -73,6 +73,82 @@ export function shouldEnableChromiumSandbox(): boolean {
|
|||
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.
|
||||
*
|
||||
|
|
@ -199,6 +275,15 @@ export class BrowserManager {
|
|||
|
||||
// ─── Headed State ────────────────────────────────────────
|
||||
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;
|
||||
|
||||
// ─── 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.
|
||||
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
||||
|
||||
// Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab.
|
||||
// Patch the Chromium .app's Info.plist so macOS shows our name.
|
||||
// This works for both dev mode (system Playwright cache) and .app bundle.
|
||||
const chromePath = executablePath || chromium.executablePath();
|
||||
try {
|
||||
// Walk up from binary to the .app's Info.plist
|
||||
// e.g. .../Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing
|
||||
// → .../Google Chrome for Testing.app/Contents/Info.plist
|
||||
const chromeContentsDir = path.resolve(path.dirname(chromePath), '..');
|
||||
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
|
||||
if (fs.existsSync(chromePlist)) {
|
||||
const plistContent = fs.readFileSync(chromePlist, 'utf-8');
|
||||
if (plistContent.includes('Google Chrome for Testing')) {
|
||||
const patched = plistContent
|
||||
.replace(/Google Chrome for Testing/g, 'GStack Browser');
|
||||
fs.writeFileSync(chromePlist, patched);
|
||||
}
|
||||
// Replace Chromium's Dock icon with ours (Chromium's process owns the Dock icon)
|
||||
const iconCandidates = [
|
||||
path.join(__dirname, '..', '..', 'scripts', 'app', 'icon.icns'), // repo dev mode
|
||||
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'scripts', 'app', 'icon.icns'), // global install
|
||||
];
|
||||
const iconSrc = iconCandidates.find(p => fs.existsSync(p));
|
||||
if (iconSrc) {
|
||||
const chromeResources = path.join(chromeContentsDir, 'Resources');
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// NOTE (#2242): the in-place "rebrand" that patched the Chromium .app's
|
||||
// Info.plist (global "Google Chrome for Testing" → "GStack Browser"
|
||||
// replace) and overwrote its Resources/*.icns is deliberately GONE.
|
||||
// Chrome for Testing is a code-signed bundle: the global replace renamed
|
||||
// CFBundleExecutable to a binary that doesn't exist and the plist/icon
|
||||
// writes broke the codesign seal — GPU process exit_code=5, headed mode
|
||||
// dead on macOS 26 (#2242, #2138, #2139). Branding belongs in the
|
||||
// GStack Browser.app wrapper (GSTACK_CHROMIUM_PATH), never in a mutation
|
||||
// of the signed bundle. Do not reintroduce writes into the Chromium
|
||||
// bundle here — browse/test/rebrand-signed-bundle.test.ts fails CI if
|
||||
// you do.
|
||||
//
|
||||
// Self-heal for bundles the OLD code already poisoned: probe the
|
||||
// Playwright-cache bundle and remove it when the mutated plist is
|
||||
// present (see probePoisonedChromiumBundle for the removal-scope
|
||||
// rationale). Scoped to the Playwright cache copy — a
|
||||
// GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder and is
|
||||
// never probed.
|
||||
if (!executablePath) {
|
||||
try {
|
||||
probePoisonedChromiumBundle(chromium.executablePath());
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof PoisonedBundleError) throw err;
|
||||
// Probe failures (no bundle yet, EACCES) fall through to launch,
|
||||
// which produces its own actionable error.
|
||||
}
|
||||
} 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
|
||||
|
|
@ -1607,6 +1678,20 @@ export class BrowserManager {
|
|||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
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().
|
||||
// The handoff path (headless → headed re-launch) takes the same
|
||||
// anti-detection posture.
|
||||
|
|
@ -1639,6 +1724,14 @@ export class BrowserManager {
|
|||
this.tabSessions.clear();
|
||||
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
|
||||
// restoreState() navigates so the init scripts apply to the restored
|
||||
// pages — without this the handed-off browser had cmdline args but no
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ globalThis.Bun = {
|
|||
cwd: options.cwd,
|
||||
// Node defaults windowsHide to false; Bun.spawn hides the console
|
||||
// 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,
|
||||
});
|
||||
|
||||
|
|
@ -97,108 +98,17 @@ globalThis.Bun = {
|
|||
cwd: options.cwd,
|
||||
// stdio:'ignore' silences a child's output but does not suppress its
|
||||
// console window on Windows. The terminal-agent respawn (server.ts
|
||||
// watchdog, 60s ticker) therefore popped a visible bun.exe window on
|
||||
// every respawn until this was forwarded.
|
||||
// watchdog, 60s ticker) popped a visible bun.exe window on every
|
||||
// respawn until this was forwarded. Forwarded, not hardcoded, so an
|
||||
// explicit windowsHide:false survives.
|
||||
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 {
|
||||
pid: proc.pid,
|
||||
stdout: replay(stdoutDrain),
|
||||
stderr: replay(stderrDrain),
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
stdin: proc.stdin,
|
||||
exited,
|
||||
unref() { proc.unref(); },
|
||||
kill(signal) { proc.kill(signal); },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import * as path from 'path';
|
|||
import { spawn as nodeSpawn } from 'child_process';
|
||||
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
|
||||
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 { redactProxyUrl } from './proxy-redact';
|
||||
import { spawnTerminalAgent } from './terminal-agent-control';
|
||||
|
|
@ -168,7 +168,7 @@ async function killServer(pid: number): Promise<void> {
|
|||
try {
|
||||
Bun.spawnSync(
|
||||
['taskkill', '/PID', String(pid), '/T', '/F'],
|
||||
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000 }
|
||||
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000, windowsHide: true }
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') throw err;
|
||||
|
|
@ -348,9 +348,9 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
|||
const launcherCode =
|
||||
`const{spawn}=require('child_process');` +
|
||||
`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()`;
|
||||
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
|
||||
} else {
|
||||
// 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
|
||||
|
|
@ -365,6 +365,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
|||
// the Windows path's rationale — same root cause, different OS API.
|
||||
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
|
||||
}).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`);
|
||||
}
|
||||
|
||||
function errorCode(err: unknown): string {
|
||||
if (err && typeof err === 'object' && 'code' in err) {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (typeof code === 'string' && code.length > 0) return code;
|
||||
export class ServerLockError extends Error {
|
||||
code: string;
|
||||
constructor(code: string, lockPath: string, cause: string) {
|
||||
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).
|
||||
* 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 {
|
||||
// 'wx' — create exclusively, fails if file already exists (atomic check-and-create)
|
||||
// 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.closeSync(fd);
|
||||
return () => { safeUnlink(lockPath); };
|
||||
} catch (err) {
|
||||
if (errorCode(err) !== 'EEXIST') {
|
||||
logServerLockError('opening', lockPath, err);
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
// Lock dir missing — create it and retry once.
|
||||
if (depth >= 1) throw new ServerLockError('ENOENT', lockPath, 'lock directory could not be created');
|
||||
mkdirSecure(path.dirname(lockPath));
|
||||
return acquireServerLock(lockPath, depth + 1);
|
||||
}
|
||||
|
||||
// Lock already held — check if the holder is still alive
|
||||
let holderPid: number;
|
||||
if (err?.code !== 'EEXIST') {
|
||||
throw new ServerLockError(err?.code || 'UNKNOWN', lockPath, err?.message || String(err));
|
||||
}
|
||||
// EEXIST — real contention. Check if the holder is still alive.
|
||||
// Depth cap 5 bounds the stale-lock unlink/retry livelock.
|
||||
try {
|
||||
holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
|
||||
} catch (readErr) {
|
||||
if (errorCode(readErr) === 'ENOENT') {
|
||||
return acquireServerLock(lockPath);
|
||||
const holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
|
||||
if (holderPid && isProcessAlive(holderPid)) {
|
||||
return null; // Another live process holds the lock
|
||||
}
|
||||
logServerLockError('reading holder PID from', lockPath, readErr);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (holderPid && isProcessAlive(holderPid)) {
|
||||
return null; // Another live process holds the lock
|
||||
}
|
||||
|
||||
// Stale lock — remove and retry
|
||||
try {
|
||||
// Stale lock — remove and retry
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch (unlinkErr) {
|
||||
logServerLockError('removing stale', lockPath, unlinkErr);
|
||||
return null;
|
||||
if (depth >= 5) return null;
|
||||
return acquireServerLock(lockPath, depth + 1);
|
||||
} 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);
|
||||
}
|
||||
// Connection error — server may have crashed, OR may just be busy.
|
||||
// The compiled CLI runs on Bun, whose fetch reports a refused/dropped
|
||||
// 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) {
|
||||
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
|
||||
const oldState = readState();
|
||||
// #1781 busy-vs-dead: a single-threaded daemon under beacon/extension load
|
||||
// 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) {
|
||||
serverUrl = pairData.tunnel_url;
|
||||
} else if (!localHost) {
|
||||
// No tunnel active. Check if ngrok is available and auto-start.
|
||||
const ngrokAvailable = isNgrokAvailable();
|
||||
// No tunnel active. Remote tunneling (pair-agent) is opt-in — never
|
||||
// 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) {
|
||||
console.log('[browse] ngrok detected. Starting tunnel...');
|
||||
try {
|
||||
|
|
@ -1005,6 +997,14 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
|
|||
console.warn('[browse] Using localhost (same-machine only).\n');
|
||||
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 {
|
||||
console.warn('[browse] No tunnel active and ngrok is not installed/configured.');
|
||||
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({
|
||||
stateFile: config.stateFile,
|
||||
serverPort: newState.port,
|
||||
ownerPid: newState.pid,
|
||||
cwd: config.projectDir,
|
||||
});
|
||||
if (newPid) {
|
||||
|
|
@ -1300,7 +1299,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||
spawnTerminalAgent({
|
||||
stateFile: config.stateFile,
|
||||
serverPort: respawned.port,
|
||||
ownerPid: respawned.pid,
|
||||
cwd: config.projectDir,
|
||||
});
|
||||
} catch (err: any) {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,64 @@ export function resolveGstackHome(): string {
|
|||
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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -526,6 +526,7 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
|
|||
].join('; ');
|
||||
|
||||
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
|
||||
windowsHide: true,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
|
|
@ -778,7 +779,7 @@ function isBrowserRunning(browserName: string): Promise<boolean> {
|
|||
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
|
||||
return new Promise((resolve) => {
|
||||
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
stdout: 'pipe', stderr: 'pipe', windowsHide: true,
|
||||
});
|
||||
proc.exited.then(async () => {
|
||||
const out = await new Response(proc.stdout).text();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
import * as fs from 'fs';
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
// ─── Filesystem ────────────────────────────────────────────────
|
||||
|
||||
/** 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 — 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.
|
||||
*/
|
||||
/** Check if a PID is alive. Pure boolean probe — returns false for ALL errors. */
|
||||
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 {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
return err?.code === 'EPERM';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export function restrictFilePermissions(filePath: string): void {
|
|||
execFileSync(
|
||||
'icacls',
|
||||
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
|
||||
{ stdio: 'ignore' },
|
||||
{ stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
} catch (err) {
|
||||
warnIcaclsFailure(filePath, err);
|
||||
|
|
@ -147,7 +147,7 @@ export function restrictDirectoryPermissions(dirPath: string): void {
|
|||
execFileSync(
|
||||
'icacls',
|
||||
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
|
||||
{ stdio: 'ignore' },
|
||||
{ stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
} catch (err) {
|
||||
warnIcaclsFailure(dirPath, err);
|
||||
|
|
@ -185,14 +185,54 @@ export function appendSecureFile(
|
|||
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.
|
||||
* Replaces `fs.mkdirSync(path, { recursive: true, mode: 0o700 })` + Windows ACL.
|
||||
* 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 {
|
||||
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import * as path from 'path';
|
|||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { TEMP_DIR } from './platform';
|
||||
import { resolveConfig } from './config';
|
||||
import { filterSessionCookies } from './session-persist';
|
||||
import type { Frame } from 'playwright';
|
||||
|
||||
/** Tokenize a pipe segment respecting double-quoted strings. */
|
||||
|
|
@ -421,25 +422,18 @@ export async function handleMetaCommand(
|
|||
}
|
||||
|
||||
case 'stop': {
|
||||
// Defer shutdown so the response flushes before process.exit() (same
|
||||
// reason as 'restart' below). Otherwise the CLI sees a dropped socket;
|
||||
// and now that connection-loss triggers the crash-retry path, that would
|
||||
// resurrect a fresh daemon only to stop it again. Send the 200, then exit.
|
||||
setTimeout(() => { void shutdown(); }, 100);
|
||||
// Return the acknowledgement before closing the listener. Shutting down
|
||||
// inline resets the CLI's fetch, which it reasonably interprets as a
|
||||
// crash and then restarts the daemon it was asked to stop.
|
||||
setTimeout(() => { void shutdown(); }, 25).unref?.();
|
||||
return 'Server stopped';
|
||||
}
|
||||
|
||||
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.');
|
||||
// Defer shutdown one tick so this HTTP response actually flushes before
|
||||
// process.exit(). shutdown() exits inline (server.ts), so the old
|
||||
// `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)';
|
||||
setTimeout(() => { void shutdown(); }, 25).unref?.();
|
||||
return 'Restarting...';
|
||||
}
|
||||
|
||||
// ─── Visual ────────────────────────────────────────
|
||||
|
|
@ -939,15 +933,13 @@ export async function handleMetaCommand(
|
|||
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
|
||||
throw new Error('Invalid state file: expected cookies and pages arrays');
|
||||
}
|
||||
// Validate and filter cookies — reject malformed or internal-network cookies
|
||||
const validatedCookies = data.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;
|
||||
const d = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
|
||||
if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false;
|
||||
return true;
|
||||
});
|
||||
// Validate and filter cookies via the shared hygiene filter in
|
||||
// session-persist.ts (isInternalCookieDomain): rejects malformed
|
||||
// cookies and internal-network domains — localhost, *.internal,
|
||||
// loopback literals (127.x, ::1), and link-local/cloud-metadata
|
||||
// (169.254.x) — that a tampered state file could use to reach local
|
||||
// services or the metadata endpoint.
|
||||
const validatedCookies = filterSessionCookies(data.cookies);
|
||||
if (validatedCookies.length < data.cookies.length) {
|
||||
console.warn(`[browse] Filtered ${data.cookies.length - validatedCookies.length} invalid cookies from state file`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ import {
|
|||
isRootToken, checkConnectRateLimit, type TokenInfo,
|
||||
} from './token-registry';
|
||||
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 { createSseEndpoint } from './sse-helpers';
|
||||
import { initAuditLog, writeAuditEntry } from './audit';
|
||||
|
|
@ -728,6 +732,12 @@ const idleCheckInterval = setInterval(idleCheckTick, 60_000);
|
|||
// dual-instance fix` describe block for usage.
|
||||
export const __testInternals__ = {
|
||||
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; },
|
||||
setLastActivity: (t: number) => { lastActivity = t; },
|
||||
formatExplicitPortUnavailableError,
|
||||
|
|
@ -757,47 +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,
|
||||
// so this branch is the normal path for /open-gstack-browser.
|
||||
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
|
||||
// Poll interval is env-tunable so the watchdog E2E test can use a ~250ms tick
|
||||
// instead of waiting out the production 15s interval (was a 20s blind sleep).
|
||||
// Floor of 50ms guards against a typo'd 0 busy-looping the server.
|
||||
const WATCHDOG_INTERVAL_MS = (() => {
|
||||
const raw = parseInt(process.env.BROWSE_WATCHDOG_INTERVAL_MS || '', 10);
|
||||
return Number.isFinite(raw) && raw >= 50 ? raw : 15_000;
|
||||
})();
|
||||
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
||||
let parentGone = false;
|
||||
setInterval(() => {
|
||||
try {
|
||||
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
|
||||
} catch {
|
||||
// Parent exited. Resolution order:
|
||||
// 1. Active cookie picker (one-time code or session live)? Stay alive
|
||||
// regardless of mode — tearing down the server mid-import leaves the
|
||||
// picker UI with a stale "Failed to fetch" error.
|
||||
// 2. Headed / tunnel mode? Shutdown. The idle timeout doesn't apply in
|
||||
// these modes (see idleCheckInterval above — both early-return), so
|
||||
// ignoring parent death here would leak orphan daemons after
|
||||
// /pair-agent or /open-gstack-browser sessions.
|
||||
// 3. Normal (headless) mode? Stay alive. Claude Code's Bash tool kills
|
||||
// the parent shell between invocations. The idle timeout (30 min)
|
||||
// handles eventual cleanup.
|
||||
if (hasActivePicker()) return;
|
||||
const headed = activeBrowserManager.getConnectionMode() === 'headed';
|
||||
if (headed || tunnelActive) {
|
||||
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||||
activeShutdown?.();
|
||||
} else if (!parentGone) {
|
||||
parentGone = true;
|
||||
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited (server stays alive, idle timeout will clean up)`);
|
||||
}
|
||||
// Runtime promotion to headed (`handoff`) must NOT clear this interval — the
|
||||
// same tick is the tunnel-orphan reaper, and idle timeout is disabled in
|
||||
// tunnel mode, so parent death is the ONLY thing that reaps an
|
||||
// internet-exposed daemon after handoff → resume → /pair-agent. Promotion
|
||||
// sets this suppress flag instead; the tick re-reads it (and tunnelActive)
|
||||
// every pass. See suppressHeadedParentShutdown() below.
|
||||
let headedParentShutdownSuppressed = false;
|
||||
// Latch for the one-time "parent exited, staying alive" log line.
|
||||
let parentGone = false;
|
||||
// Named + parameterized (default: the boot-time env PID) so watchdog.test.ts
|
||||
// can drive the tick deterministically via __testInternals__, mirroring
|
||||
// idleCheckTick above. setInterval invokes it with no args in production.
|
||||
function parentWatchdogTick(parentPid: number = BROWSE_PARENT_PID): void {
|
||||
try {
|
||||
process.kill(parentPid, 0); // signal 0 = existence check only, no signal sent
|
||||
} catch {
|
||||
// Parent exited. Resolution order:
|
||||
// 1. Active cookie picker (one-time code or session live)? Stay alive
|
||||
// regardless of mode — tearing down the server mid-import leaves the
|
||||
// picker UI with a stale "Failed to fetch" error.
|
||||
// 2. Headed (unless suppressed by a runtime promotion) / tunnel mode?
|
||||
// Shutdown. The idle timeout doesn't apply in these modes (see
|
||||
// idleCheckInterval above — both early-return), so ignoring parent
|
||||
// death here would leak orphan daemons after /pair-agent or
|
||||
// /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)`);
|
||||
}
|
||||
}, WATCHDOG_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
||||
setInterval(parentWatchdogTick, 15_000);
|
||||
} else if (IS_HEADED_WATCHDOG) {
|
||||
console.log('[browse] Parent-process watchdog disabled (headed mode)');
|
||||
} else if (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) ───
|
||||
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
||||
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
||||
|
|
@ -840,6 +886,11 @@ function emitInspectorEvent(event: any): void {
|
|||
|
||||
// ─── Server ────────────────────────────────────────────────────
|
||||
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
|
||||
// watchdog, SIGTERM) read activeBrowserManager so that buildFetchHandler can
|
||||
// retarget them at a caller-supplied BrowserManager. Symmetric with the
|
||||
|
|
@ -858,6 +909,11 @@ let activeBrowserManager: BrowserManager = browserManager;
|
|||
// any buildFetchHandler call rebinds onDisconnect onto the cfg instance.
|
||||
browserManager.onDisconnect = (code) => activeShutdown?.(code ?? 2);
|
||||
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 =
|
||||
| { available: true }
|
||||
|
|
@ -1699,8 +1755,33 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
clearInterval(flushInterval);
|
||||
clearInterval(idleCheckInterval);
|
||||
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();
|
||||
|
||||
// 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();
|
||||
|
||||
cleanSingletonLocks(resolveChromiumProfile());
|
||||
|
|
@ -1730,6 +1811,12 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
// after 30 min of HTTP idle because the dead module-level instance still
|
||||
// reports connectionMode === 'launched'.
|
||||
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
|
||||
// closes the headed browser window. CHAIN any caller-provided handler
|
||||
|
|
@ -2442,6 +2529,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
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) {
|
||||
// Verify tunnel is still alive before returning cached URL.
|
||||
// Probe GET /connect (the only unauth-reachable path on the tunnel
|
||||
|
|
@ -2478,7 +2573,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
const started = await startTunnel({
|
||||
fetchHandler: makeFetchHandler('tunnel'),
|
||||
authtoken,
|
||||
consent: 'pair_agent=on',
|
||||
consent: 'pair_agent=on (isPairAgentEnabled gate at /tunnel/start)',
|
||||
});
|
||||
if (!started.ok) {
|
||||
return new Response(JSON.stringify({
|
||||
|
|
@ -3096,6 +3191,58 @@ export async function start() {
|
|||
|
||||
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
|
||||
if (browserManager.getConnectionMode() === 'headed') {
|
||||
try {
|
||||
|
|
@ -3137,7 +3284,9 @@ export async function start() {
|
|||
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener
|
||||
// pattern: bind a dedicated tunnel listener on an ephemeral port and
|
||||
// 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();
|
||||
if (!authtoken) {
|
||||
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
|
||||
|
|
@ -3149,7 +3298,7 @@ export async function start() {
|
|||
const started = await startTunnel({
|
||||
fetchHandler: handle.fetchTunnel,
|
||||
authtoken,
|
||||
consent: 'pair_agent=on (BROWSE_TUNNEL=1)',
|
||||
consent: 'pair_agent=on (isPairAgentEnabled gate, BROWSE_TUNNEL=1)',
|
||||
});
|
||||
if (!started.ok) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { readGstackConfigYamlKey } from './config';
|
||||
|
||||
function gstackHome(): string {
|
||||
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
||||
|
|
@ -43,17 +44,51 @@ async function ensureDir(): Promise<void> {
|
|||
}
|
||||
|
||||
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;
|
||||
// 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') {
|
||||
telemetryDisabled = true;
|
||||
return true;
|
||||
}
|
||||
// Conservative default: telemetry ON unless explicitly off. Users opt out via
|
||||
// gstack-config set telemetry off (preamble reads this; we trust the env hint).
|
||||
telemetryDisabled = false;
|
||||
return false;
|
||||
// Persistent tier: an explicit user-written value always wins next.
|
||||
const tier = readGstackConfigYamlKey('telemetry');
|
||||
if (tier === 'off') {
|
||||
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 {
|
||||
|
|
@ -63,7 +98,7 @@ export interface TelemetryEvent {
|
|||
|
||||
/** Fire-and-forget log. Never throws. */
|
||||
export function logTelemetry(payload: TelemetryEvent): void {
|
||||
if (isDisabled()) return;
|
||||
if (isTelemetryDisabled()) return;
|
||||
const enriched = { ...payload, ts: new Date().toISOString() };
|
||||
ensureDir()
|
||||
.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';
|
||||
|
||||
// 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)
|
||||
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', () => {
|
||||
// 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 () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${requirePath}');
|
||||
require(${JSON.stringify(polyfillPath)});
|
||||
(async () => {
|
||||
const start = Date.now();
|
||||
await Bun.sleep(50);
|
||||
|
|
@ -27,7 +29,7 @@ describe('bun-polyfill', () => {
|
|||
|
||||
test('Bun.spawnSync runs a command and returns stdout', () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${requirePath}');
|
||||
require(${JSON.stringify(polyfillPath)});
|
||||
const r = Bun.spawnSync(['echo', 'hello'], { stdout: 'pipe' });
|
||||
console.log(r.stdout.toString().trim());
|
||||
console.log('exit:' + r.exitCode);
|
||||
|
|
@ -39,7 +41,7 @@ describe('bun-polyfill', () => {
|
|||
|
||||
test('Bun.spawn launches a process with pid', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${requirePath}');
|
||||
require(${JSON.stringify(polyfillPath)});
|
||||
const p = Bun.spawn(['echo', 'test'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
console.log(typeof p.pid === 'number' ? 'HAS_PID' : 'NO_PID');
|
||||
console.log(typeof p.kill === 'function' ? 'HAS_KILL' : 'NO_KILL');
|
||||
|
|
@ -51,179 +53,9 @@ describe('bun-polyfill', () => {
|
|||
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 () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${requirePath}');
|
||||
require(${JSON.stringify(polyfillPath)});
|
||||
const server = Bun.serve({
|
||||
port: 0, // Note: polyfill uses port directly, so we pick one
|
||||
hostname: '127.0.0.1',
|
||||
|
|
@ -242,4 +74,48 @@ describe('bun-polyfill', () => {
|
|||
expect(lines[0]).toBe('HAS_STOP');
|
||||
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 * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
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 {
|
||||
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)', () => {
|
||||
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) => {
|
||||
const lockPath = path.join(dir, 'missing-parent', 'browse.json.lock');
|
||||
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
|
||||
// A FILE where a directory is expected: open('wx') fails ENOTDIR — an
|
||||
// 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();
|
||||
expect(messages.join('\n')).toContain('unexpected ENOENT while opening');
|
||||
expect(messages.join('\n')).toContain(lockPath);
|
||||
let thrown: any = null;
|
||||
try {
|
||||
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) => {
|
||||
// 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');
|
||||
fs.mkdirSync(lockPath);
|
||||
|
||||
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(messages.join('\n')).toContain('unexpected EISDIR while reading holder PID from');
|
||||
expect(messages.join('\n')).toContain(lockPath);
|
||||
});
|
||||
});
|
||||
|
||||
test('removes stale lock and reacquires it', () => {
|
||||
withTempDir((dir) => {
|
||||
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);
|
||||
let thrown: any = null;
|
||||
try {
|
||||
acquireServerLock(lockPath);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(ServerLockError);
|
||||
expect(thrown.code).toBe('EISDIR');
|
||||
expect(thrown.message).toContain('E_SERVER_LOCK (EISDIR)');
|
||||
expect(thrown.message).toContain(lockPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { handleReadCommand as _handleReadCommand } from '../src/read-commands';
|
|||
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
|
||||
|
||||
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) =>
|
||||
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
|
||||
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 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 = [
|
||||
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
|
||||
'command', 'sidebar-command', 'getTabState',
|
||||
'command', 'getTabState',
|
||||
];
|
||||
// Content-script-originated flows that must keep working.
|
||||
const CONTENT_SCRIPT_TYPES = ['openSidePanel', 'elementPicked', 'pickerCancelled', 'inspectResult'];
|
||||
|
|
@ -216,6 +220,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', () => {
|
||||
const before = calls.storageSet.length;
|
||||
const r = dispatch(listener, { type: 'setPort', port: 6666 }, CONTENT_SCRIPT_SENDER);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
writeSecureFile,
|
||||
appendSecureFile,
|
||||
mkdirSecure,
|
||||
repairBrokenDacl,
|
||||
__resetWarnedForTests,
|
||||
} from '../src/file-permissions';
|
||||
|
||||
|
|
@ -175,4 +176,35 @@ describe('mkdirSecure', () => {
|
|||
expect(fs.existsSync(path.join(tmpDir, 'a', 'b'))).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;
|
||||
|
||||
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], {
|
||||
env: {
|
||||
...process.env,
|
||||
GSTACK_STATE_DIR: stateDir,
|
||||
...extraEnv,
|
||||
},
|
||||
env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,14 +17,21 @@ let gstackDir: string;
|
|||
let stateDir: 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], {
|
||||
env: {
|
||||
...process.env,
|
||||
GSTACK_DIR: gstackDir,
|
||||
GSTACK_STATE_DIR: stateDir,
|
||||
GSTACK_REMOTE_URL: `file://${join(gstackDir, 'REMOTE_VERSION')}`,
|
||||
...extraEnv,
|
||||
},
|
||||
env,
|
||||
stdout: '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);
|
||||
});
|
||||
|
||||
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
|
||||
// time out, and a timed-out probe silently answers "dead". Signal 0
|
||||
// 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 origSpawnSync = (Bun as any).spawnSync;
|
||||
const spawns: string[] = [];
|
||||
|
|
@ -73,11 +79,16 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('4. no source file probes liveness via tasklist', () => {
|
||||
// Static tripwire: re-introducing a tasklist-based existence check
|
||||
// anywhere in src/ resurrects the false-negative class.
|
||||
test('4. no source file probes liveness via tasklist outside the central helper', () => {
|
||||
// Static tripwire: ad-hoc tasklist existence checks scattered across src/
|
||||
// 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[] = [];
|
||||
for (const { file, content } of readAllSourceFiles()) {
|
||||
if (file === 'error-handling.ts') continue; // the canonical helper
|
||||
const code = stripComments(content);
|
||||
// `PID eq` is the existence-probe form specifically. Other tasklist
|
||||
// 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';
|
||||
|
||||
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) =>
|
||||
_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 * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
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.
|
||||
// The watchdog has layered behavior since v0.18.1.0 (#1025) and v0.18.2.0
|
||||
|
|
@ -18,16 +22,13 @@ import * as os from 'os';
|
|||
// eventual cleanup.
|
||||
//
|
||||
// Tunnel mode coverage (parent dies → shutdown because idle timeout doesn't
|
||||
// apply) is not covered by an automated test here — tunnelActive is a runtime
|
||||
// variable set by /pair-agent's tunnel-create flow, not an env var, so faking
|
||||
// it would require invasive test-only hooks. The mode check is documented
|
||||
// inline at the watchdog and SIGTERM handlers, and would regress visibly for
|
||||
// /pair-agent users (server lingers after disconnect).
|
||||
// apply) is covered behaviorally in the in-process suite at the bottom of this
|
||||
// file: the tick is exported via __testInternals__.parentWatchdogTick (same
|
||||
// seam as idleCheckTick) and tunnelActive is simulated via setTunnelActive.
|
||||
//
|
||||
// Each test spawns the real server.ts. Tests 1 and 2 verify behavior via
|
||||
// stdout log line (fast). Test 3 shrinks the watchdog tick to 250ms via
|
||||
// BROWSE_WATCHDOG_INTERVAL_MS and waits for the stay-alive log line, then
|
||||
// confirms the server survived parent death (~1-2s instead of a 20s sleep).
|
||||
// stdout log line (fast). Test 3 waits for the watchdog poll cycle to confirm
|
||||
// the server REMAINS alive after parent death (slow — ~20s observation window).
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SERVER_SCRIPT = path.join(ROOT, 'src', 'server.ts');
|
||||
|
|
@ -138,26 +139,181 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
|
|||
const parentPid = parentProc.pid!;
|
||||
|
||||
// Default headless: no BROWSE_HEADED, real parent PID — watchdog active.
|
||||
// 250ms tick (test-only knob) so this test doesn't wait out the
|
||||
// production 15s interval; the old version blind-slept 22s.
|
||||
serverProc = spawnServer(
|
||||
{ BROWSE_PARENT_PID: String(parentPid), BROWSE_WATCHDOG_INTERVAL_MS: '250' },
|
||||
34903,
|
||||
);
|
||||
serverProc = spawnServer({ BROWSE_PARENT_PID: String(parentPid) }, 34903);
|
||||
const serverPid = serverProc.pid!;
|
||||
|
||||
// Give the server a beat to register the watchdog interval.
|
||||
await Bun.sleep(500);
|
||||
// Give the server a moment to start and register the watchdog interval.
|
||||
await Bun.sleep(2000);
|
||||
expect(isProcessAlive(serverPid)).toBe(true);
|
||||
|
||||
// Kill the parent. Pre-#994 the server would shut down on the next tick.
|
||||
// Post-#994 it logs the parent exit and stays alive — wait for that log
|
||||
// line instead of sleeping past a fixed interval.
|
||||
// Kill the parent. The watchdog polls every 15s, so first tick after
|
||||
// parent death lands within ~15s. Pre-#994 the server would shutdown
|
||||
// here. Post-#994 the server logs the parent exit and stays alive.
|
||||
parentProc.kill('SIGKILL');
|
||||
const out = await readStdoutUntil(serverProc, 'server stays alive', 10_000);
|
||||
expect(out).toContain(
|
||||
`Parent process ${parentPid} exited (server stays alive, idle timeout will clean up)`,
|
||||
);
|
||||
|
||||
// Wait long enough for at least one watchdog tick (15s) plus margin.
|
||||
// Server should still be alive — that's the whole point of #994.
|
||||
await Bun.sleep(20_000);
|
||||
expect(isProcessAlive(serverPid)).toBe(true);
|
||||
}, 30_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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
|
||||
|
|
@ -899,41 +903,70 @@ If `NEEDS_SETUP`:
|
|||
|
||||
## 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
|
||||
setopt +o nomatch 2>/dev/null || true # zsh compat
|
||||
# Detect project runtime
|
||||
[ -f Gemfile ] && echo "RUNTIME:ruby"
|
||||
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
|
||||
[ -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 requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
|
||||
[ -f go.mod ] && echo "RUNTIME:go"
|
||||
[ -f Cargo.toml ] && echo "RUNTIME:rust"
|
||||
[ -f composer.json ] && echo "RUNTIME:php"
|
||||
[ -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
|
||||
[ -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"
|
||||
# Check for existing test infrastructure
|
||||
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null
|
||||
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
|
||||
# Existing test path — config files, declared scripts, AND test FILES.
|
||||
# A project with real tests and no config file is the common miss.
|
||||
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
|
||||
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
|
||||
```
|
||||
|
||||
**If test framework detected** (config files or test directories found):
|
||||
Print "Test framework detected: {name} ({N} existing tests). Skipping bootstrap."
|
||||
Map the markers to the command you will OFFER — never to one you run on a guess:
|
||||
|
||||
| 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).
|
||||
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 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?"
|
||||
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 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
|
||||
|
||||
|
|
@ -949,7 +982,9 @@ If WebSearch is unavailable, use this built-in knowledge table:
|
|||
| Node.js | vitest + @testing-library | jest + @testing-library |
|
||||
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
|
||||
| Python | pytest + pytest-cov | unittest |
|
||||
| Django | pytest + pytest-django | Django's built-in `manage.py test` (unittest) |
|
||||
| Go | stdlib testing + testify | stdlib only |
|
||||
| JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only |
|
||||
| Rust | cargo test (built-in) + mockall | — |
|
||||
| PHP | phpunit + mockery | pest |
|
||||
| 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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
- Team profiles
|
||||
- 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 config in `EXTERNAL_HOST_CONFIG` with similar patterns
|
||||
- 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
|
||||
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
|
||||
- `bin/gstack-uninstall` doesn't know about `.slate/`
|
||||
- `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
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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"
|
||||
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)}"
|
||||
OLD_REPO_NAME="gstack-brain-${USER_NAME}"
|
||||
NEW_REPO_NAME="gstack-artifacts-${USER_NAME}"
|
||||
|
|
@ -61,8 +68,8 @@ mkdir -p "$MIGRATION_DIR"
|
|||
# Already done? exit silently.
|
||||
[ -f "$DONE" ] && exit 0
|
||||
|
||||
# User opted out previously? exit silently. (Re-invoke via
|
||||
# `/setup-gbrain --rerun-migration` removes this marker.)
|
||||
# User opted out previously? exit silently. (To re-run after an opt-out:
|
||||
# rm the skipped-by-user marker, then invoke this script directly.)
|
||||
[ -f "$SKIPPED" ] && exit 0
|
||||
|
||||
journal_done() {
|
||||
|
|
@ -119,19 +126,33 @@ EOF
|
|||
read -r REPLY || REPLY=""
|
||||
case "$REPLY" in
|
||||
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"
|
||||
exit 0
|
||||
;;
|
||||
skip|skip-for-now|s)
|
||||
echo " Skipping for now. Will ask again next upgrade." >&2
|
||||
# Don't write SKIPPED — leave both old + new state untouched, ask again next time.
|
||||
echo " Skipping for now. Re-run manually with: ${RERUN_CMD}" >&2
|
||||
# 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
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Non-interactive (CI, scripted upgrade): proceed automatically.
|
||||
echo " (non-interactive: proceeding automatically)" >&2
|
||||
# Non-interactive (CI, Claude Code Bash tool, scripted upgrade). Step 1
|
||||
# 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
|
||||
|
||||
|
|
@ -195,8 +216,8 @@ if ! journal_done "gh_repo_renamed"; then
|
|||
mark_done "gh_repo_renamed"
|
||||
else
|
||||
echo " WARNING: gh rename failed (repo may not exist or permission denied)" >&2
|
||||
echo " skipping step 1; subsequent steps still run" >&2
|
||||
mark_done "gh_repo_renamed"
|
||||
echo " step 1 stays PENDING and will retry on re-run; later steps still run (#1383)" >&2
|
||||
echo " manual: gh repo rename $NEW_REPO_NAME --repo $OLD_REPO_NAME --yes" >&2
|
||||
fi
|
||||
fi
|
||||
else
|
||||
|
|
@ -335,8 +356,20 @@ EOF
|
|||
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"
|
||||
rm -f "$JOURNAL"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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: {
|
||||
rawIdentity: string;
|
||||
endpoint: string;
|
||||
reason: AttemptRow['reason'];
|
||||
path?: string;
|
||||
}): Promise<void> {
|
||||
const salt = await loadDeviceSalt();
|
||||
const hash = createHash('sha256').update(salt + ':' + opts.rawIdentity).digest('hex').slice(0, 16);
|
||||
const hash = await saltedHash(opts.rawIdentity);
|
||||
const row: AttemptRow = {
|
||||
ts: new Date().toISOString(),
|
||||
identity_canon: hash,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { probeTailscale, whoIs } from './tailscale-localapi';
|
|||
import { SessionTokenStore } from './session-tokens';
|
||||
import { mintForCaller } from './auth-mint';
|
||||
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 { startTunnelKeepalive } from './devicectl';
|
||||
import type { Capability } from './types';
|
||||
|
|
@ -362,20 +362,38 @@ async function handleLoopback(ctx: HandlerCtx): Promise<void> {
|
|||
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') {
|
||||
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;
|
||||
}
|
||||
|
||||
// /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') {
|
||||
const body = await readBody(req);
|
||||
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;
|
||||
if (parsed.token) {
|
||||
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) {
|
||||
count = tokenStore.revokeByIdentity(parsed.identity);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,83 @@ describe('daemon — loopback listener', () => {
|
|||
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 () => {
|
||||
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/healthz`);
|
||||
expect(r.status).toBe(200);
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
||||
"state" : {
|
||||
"revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82",
|
||||
"version" : "510.0.3"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
|
|
@ -97,10 +97,12 @@ public final class StateServer {
|
|||
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
|
||||
|
||||
// 2. Log the boot token EXACTLY ONCE so the daemon can scrape it.
|
||||
// The daemon will rotate immediately; this log line is dead within
|
||||
// seconds.
|
||||
logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
|
||||
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
|
||||
// token from the 0600 file above (copyFileFromAppContainer); the
|
||||
// os_log line that used to carry it had no consumer and handed a
|
||||
// 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;
|
||||
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
|
||||
|
|
@ -149,7 +151,19 @@ public final class StateServer {
|
|||
let params = NWParameters.tcp
|
||||
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
|
||||
Task { @MainActor in
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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)
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ triggers:
|
|||
|
||||
{{PREAMBLE}}
|
||||
|
||||
{{THIRD_PARTY_ACTIONS}}
|
||||
|
||||
{{BROWSE_SETUP}}
|
||||
|
||||
{{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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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
Loading…
Reference in New Issue