v1.55.0.0 fix wave: gbrain data-loss guards + browser crash-loop + 6 more (#1808)

* fix(jsonl-merge): make equal-ts resolution converge across machines

The JSONL append merge driver sorted timestamped entries by (0, ts) with no
further tiebreaker. Equal-ts entries then fell back to stable-sort insertion
order (base, ours, theirs), but git assigns the local side to "ours", so two
machines resolving the same conflict emitted equal-ts lines in opposite order.
The merged files diverged and never converged. gstack-telemetry-log uses
second-granularity timestamps, so same-ts collisions are routine.

Add the line content as the final sort tiebreaker so the order is total and
side-independent. Add a regression test that runs the driver with the two
sides swapped and asserts identical output.

* fix(gen-skill-docs): quote frontmatter descriptions with interior colons (#1778)

Generated SKILL.md frontmatter emitted the catalog-trimmed description: as a
plain YAML scalar. A description with an interior ": " (e.g. "Ship workflow:
detect...") parses as a nested mapping under strict YAML loaders, so Codex/OpenAI
skill loading rejected those skills.

applyCatalogTrim now routes the value through toYamlInlineScalar, which quotes
(via JSON.stringify) only when a plain scalar would be invalid — interior ": ",
inline " #", leading indicator char, or surrounding whitespace. Strings that are
already valid plain scalars pass through unchanged to keep regen diffs small.

The frontmatter test now parses every generated block (Claude + Codex hosts) with
Bun.YAML.parse instead of string-checking that name:/description: substrings exist,
so the regression can't reappear. Runs under `bun test` (already in CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(skills): regenerate SKILL.md after frontmatter quoting fix (#1778)

9 catalog-trimmed descriptions whose values contain an interior colon or inline-
comment marker are now quoted. Generated output only; rerun of bun run gen:skill-docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(gbrain-sources): centralize sources-list shape handling in parseSourcesList (#1576)

#1576's crash in sourceLocalPath was already fixed in v1.42.0.0 (dual-shape
handling). But the readers disagreed: sourceLocalPath accepted both the wrapped
{sources:[...]} object (v0.20+) and a bare array, while probeSource and
sourcePageCount accepted only the wrapped shape. Extract one parseSourcesList()
normalizer and route all three through it, so the shape assumption lives in a
single place. This is also the base the #1734 remote_url audit builds on.

parseSourcesList returns [] for null/garbage rather than throwing; callers treat
'no rows' as absent. New test/gbrain-sources-parse.test.ts pins both shapes plus
the garbage paths and confirms config.remote_url survives for the audit.

#1576 is closeable as already-fixed in v1.42.0.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gbrain): spawn gbrain + brain-sync through a shell on Windows (#1731)

On Windows, bun/npm install gbrain as a gbrain.cmd/.ps1 shim and gstack-brain-sync
is a bash shebang script. spawnSync/spawn/execFileSync resolve neither without a
shell, so the child spawn failed ENOENT — on the sync orchestrator this surfaced
as 'brain-sync exited undefined' (#1731).

Add NEEDS_SHELL_ON_WINDOWS (process.platform === 'win32') in gbrain-exec and pass
it as shell: to every gbrain/brain-sync child spawn: spawnGbrain, spawnGbrainAsync,
execGbrainText (gbrain-exec), the two sources-list/remove/add spawns (gbrain-sources),
the version + probe spawns (gbrain-local-status), and the two brain-sync spawns in
the orchestrator. POSIX keeps the cheaper no-shell path.

macOS/Linux CI can't exercise the Windows path, so test/gbrain-spawn-windows-shell.ts
is a static-grep tripwire: it fails CI if a gbrain/brain-sync spawn is added without
the shell flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(catalog-trim): expect YAML-quoted descriptions with interior colons (#1778)

The quoting fix wraps colon-bearing catalog descriptions in double quotes;
two catalog-trim assertions still pinned the old unquoted form. Tolerate the
optional quotes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gbrain-sync): defensive guards against destructive gbrain ops (#1734)

The orchestrator shelled out to gbrain's destructive subcommands as if they were
safe. gbrain can rm-rf a user's working tree during an autopilot race (its own
bug, upstream gbrain #1526); gstack now defends itself. New lib/gbrain-guards.ts
gates the two destructive reach points, all checked immediately before the op:

- Autopilot refuse (multi-signal, affirmative-only): refuse a destructive op when
  a live 'gbrain autopilot' process (primary) or a known autopilot lock file
  (secondary; checked under both GBRAIN_HOME and ~/.gbrain since gbrain #1226
  ignores GBRAIN_HOME) is present. No signal → proceed; inability to introspect
  never bricks a normal sync.
- sources remove: routed through safeSourcesRemove → decideSourceRemove. Fail
  CLOSED — refuse to remove a user-managed source (remote_url set, local_path
  outside gbrain's clones) when gbrain has no --keep-storage to protect the files
  (it doesn't in 0.41.x). Also fail closed when the source list can't be read.
  Path containment uses realpath so a symlink can't smuggle a delete out of clones.
- sync --strategy code: decideCodeSync refuses URL-managed sources (remote_url
  set) unless --allow-reclone is passed, since the walk can auto-reclone (rm-rf).

Capability detection memoizes per process keyed to gbrain's identity (no stale
persistent cache); --keep-storage can't be probed (generic help) so it defaults
unsupported → fail closed. Every guard surfaces a visible reason; autopilot/reclone
refusals fail the code stage (verdict ERR) rather than silently skipping protection.

test/gbrain-guards.test.ts covers all branches hermetically (injected rows + probe
overrides): autopilot signals, fail-closed remove, keep-storage path, reclone gate,
realpath/symlink containment. Supersedes #1736 (which guarded a nonexistent path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sync-gbrain): warn against running during autopilot; prefer --path sources (#1734)

Adds a Safety note to the /sync-gbrain guidance (template + regenerated SKILL.md +
this repo's CLAUDE.md): don't run while autopilot is active, and prefer
`gbrain sources add --path` over URL-managed sources, which can auto-reclone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(memory-ingest): configurable import timeout + resume-on-timeout messaging (#1611)

The gbrain import (the long pole on big brains) had a hardcoded 30-min timeout,
so large memory corpora got SIGTERM'd mid-import on /sync-gbrain --full. Make it
configurable via GSTACK_INGEST_TIMEOUT_MS (default 30 min, validated 1min–24h).

gstack can't drive gbrain's internal resume, but the existing SIGTERM forwarder
already preserves gbrain's import-checkpoint.json, so the next run resumes. On a
timeout we now say so explicitly ('checkpoint preserved — re-run /sync-gbrain to
resume, raise GSTACK_INGEST_TIMEOUT_MS for big brains') instead of surfacing a
bare 'exited null'. True gstack-driven ingest-resume is deferred to gbrain
(.context/gbrain-asks.md).

Also guards the module's main() behind import.meta.main so resolveImportTimeoutMs
is unit-testable; the orchestrator runs it as a subprocess where main still fires.
New test/memory-ingest-timeout.test.ts pins default/override/invalid resolution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(browse): stop the headed daemon crash-loop + silent headless downgrade (#1781)

A headed session against a beacon-heavy page (analytics/extension load) could tip
the single-threaded daemon into a self-inflicted crash-loop: a brief HTTP stall
was read as a crash, the restart didn't clear the dead Chromium's SingletonLock,
the relaunch failed, and the session silently came back headless. Four fixes:

1. Busy-vs-dead (sendCommand): on a connection error, if the process is alive give
   /health a bounded probe (3x/250ms) and just retry the command — never kill+restart
   a live-but-busy server. A 30s timeout now reports 'busy, not restarting' when the
   process is alive instead of exiting into a kill cycle.
2. Profile-lock cleanup on (re)start: startServer reaps the orphaned Chromium holding
   the SingletonLock and clears Singleton{Lock,Socket,Cookie} before relaunch, so the
   auto-restart path gets the same clean profile the manual connect preamble did.
3. Headed persistence: the restart env reapplies BROWSE_HEADED from this invocation OR
   the persisted server state (mode==='headed'), so a restart from a plain command
   never downgrades a headed window to invisible headless. Extracted to buildRestartEnv.
4. Force-clean disconnect reaps the Chromium child tree (via the SingletonLock PID) so
   the next connect starts clean instead of fighting an orphan.

Plus macOS window surfacing: connect + focus raise 'Google Chrome for Testing' to the
active Space (best-effort osascript) with a Mission Control hint — the first thing
users read as 'I can't see the browser'.

Shared lock helpers (chromiumProfileDir / cleanChromiumProfileLocks / killOrphanChromium)
dedupe the connect, disconnect, and restart paths. browse/test/restart-env.test.ts pins
the headed-persistence decision; the full crash-loop repro is an E2E (periodic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(gbrain-install): remove the v0.18.2 pin, install latest + version floor + doctor self-test (#1744)

The installer pinned gbrain at v0.18.2 while gbrain shipped v0.41.x — ~23 versions
behind. Remove the hard pin: a fresh clone now stays on the latest default-branch
HEAD. --pinned-commit <sha> still pins for reproducibility.

Unpinning removes the version gate the pin provided, so add two install-time gates
that fail closed (exit 3, matching the existing PATH-shadow/version-mismatch posture):
- MIN_GBRAIN_VERSION floor (0.20.0, the sources-list/federated surface gstack needs):
  refuse an install below it.
- gbrain doctor --fast self-test when a brain config already exists (re-install /
  detected clone): refuse to leave a broken gbrain in place. Pre-init installs skip
  it; the full /sync-gbrain --dry-run self-test runs from /setup-gbrain after init.

Docs updated (USING_GBRAIN_WITH_GSTACK.md no longer says 'edit PINNED_COMMIT').
Detect-install tests bump the success-path fixtures above the floor and add a
below-floor exit-3 test. The gbrain-side asks (root #1526 fix, --keep-storage,
remove-lease, capability command, ingest-resume, integration CI) are written to
.context/gbrain-asks.md for filing against garrytan/gbrain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(#1778): update claude-ship golden + catalog-mode assertions for quoted descriptions

ship's catalog description ('Ship workflow: detect...') has an interior colon, so
the #1778 fix now YAML-quotes it. Refresh the claude-ship golden baseline to the
quoted output and make the catalog-mode-full trim/restore assertions quote-tolerant.
codex/factory ship goldens are unaffected (they use block-scalar descriptions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gen-skill-docs): use function replacer so a $ in a description can't corrupt frontmatter (#1778)

String.prototype.replace treats $&/$1/$` in the replacement as patterns. A future
skill description containing $ (e.g. referencing $B/$D) would silently corrupt the
generated frontmatter. Use a function replacer. Behavior-preserving for all current
descriptions (regen produces no diff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v1.55.0.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(gbrain): document configurable memory-ingest timeout for v1.55.0.0

USING_GBRAIN_WITH_GSTACK.md: note GSTACK_INGEST_TIMEOUT_MS (default 30 min,
1 min-24h range) on the /sync-gbrain memory stage, plus checkpoint-resume on
timeout. Fills the reference gap left by the configurable-import-timeout fix
(#1611) shipped in v1.55.0.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-30 14:57:07 -07:00
committed by GitHub
parent b88223677b
commit 3bef43bc5a
37 changed files with 1241 additions and 116 deletions
+50
View File
@@ -1,5 +1,55 @@
# Changelog # Changelog
## [1.55.0.0] - 2026-05-30
## **`/sync-gbrain` can no longer be the trigger that lets gbrain delete your repo. The headed browser stops crash-looping, and gbrain installs the current release instead of a pin 23 versions stale.**
gbrain can rm-rf a working tree when its autopilot daemon reclones mid-cycle. `/sync-gbrain` used to call gbrain's `sources remove` and `sync --strategy code` as if they were safe, so it could be the thing that set that race off. Now every destructive gbrain call sits behind feature-detected guards: the orchestrator refuses to run while autopilot is active, refuses to remove a user-managed source it can't storage-protect (it fails closed), canonicalizes paths with realpath so a symlink can't smuggle a delete outside gbrain's own clones, and requires an explicit `--allow-reclone` before a URL-managed source's code walk. Shipped in the same wave: the headed browser's self-inflicted crash-loop is gone, big-brain memory ingests stop getting killed at a fixed 30 minutes, and the gbrain installer moves off its frozen v0.18.2 pin onto the latest release behind a version floor and a `doctor` self-test.
### The numbers that matter
From the shipped diff and its regression suites (`bun test test/gbrain-*.test.ts browse/test/restart-env.test.ts test/memory-ingest-timeout.test.ts`):
| Metric | Before | After | Δ |
|--------|--------|-------|---|
| Destructive gbrain ops behind guards | 0 | 4 | +4 |
| gbrain / brain-sync spawns that work on Windows | 0/8 | 8/8 | +8 |
| gbrain version installed | v0.18.2 (pinned, ~23 behind) | latest + min-version floor + doctor gate | — |
| Memory-ingest timeout | hardcoded 30 min | configurable, checkpoint preserved on timeout | — |
| Generated SKILL.md that parse under strict YAML | partial (colons broke Codex) | all (quoted) | — |
The guard that matters most: a `sources remove` on a source whose files live outside `~/.gbrain/clones/` and can't be storage-protected now refuses instead of proceeding. The path that ate a repo no longer runs unattended.
### What this means for you
If you use `/sync-gbrain`, you are protected from the data-loss race even before gbrain ships its own root fix. "Don't run `/sync-gbrain` while `gbrain autopilot` is active" is now enforced, not just advised, and nothing gets deleted that can't be proven safe. Headed-browser QA against beacon-heavy pages (analytics, live extensions) no longer crash-loops, leaks Chromium, or silently drops to an invisible headless window. New gbrain installs track the current release. Codex and OpenAI can load every gstack skill again.
### Itemized changes
#### Added
- `/sync-gbrain` destructive-op guards (`lib/gbrain-guards.ts`): multi-signal autopilot detection, fail-closed `sources remove`, realpath `remote_url` pre-flight audit, and a `--allow-reclone` gate before URL-managed code walks.
- Install-time gbrain gate (`bin/gstack-gbrain-install`): a minimum-version floor and a `gbrain doctor --fast` self-test, both hard-fail with remediation.
- `GSTACK_INGEST_TIMEOUT_MS` to configure the memory-ingest timeout; on timeout the gbrain checkpoint is preserved so the next run resumes.
#### Changed
- gbrain installs at the latest default-branch HEAD by default; pin a commit with `gstack-gbrain-install --pinned-commit <sha>` for reproducibility.
- Generated SKILL.md descriptions with interior colons are now quoted, so strict YAML loaders (Codex/OpenAI) parse them.
- `/sync-gbrain` guidance: do not run during autopilot; prefer `gbrain sources add --path` over URL-managed sources.
#### Fixed
- `/sync-gbrain` no longer races gbrain's autopilot into a destructive reclone or remove (#1734). Report by @mvanhorn.
- `gstack-jsonl-merge` resolves equal-timestamp entries deterministically across machines, so append-only logs converge instead of re-conflicting forever (#1769). Contributed by @jbetala7.
- Generated SKILL.md frontmatter parses under strict YAML loaders (#1778). Reported by @GilbertzzzZZ, @genisis0x, @cathrynlavery, and @sator-imaging.
- The headed browser daemon no longer crash-loops under load, leaks Chromium processes, or silently downgrades a headed session to headless (#1781).
- `/sync-gbrain --full` memory ingests on large brains are no longer killed at a fixed 30-minute timeout (#1611).
- The gbrain CLI and `gstack-brain-sync` spawn correctly on Windows (#1731).
#### For contributors
- `lib/gbrain-guards.ts` with hermetic tests for every guard branch (autopilot signals, fail-closed remove, reclone gate, realpath containment).
- `parseSourcesList` centralizes `gbrain sources list --json` shape handling across all readers (#1576, whose crash was already fixed in v1.42.0.0 — this removes the last divergent reader).
- Static-grep tripwire (`test/gbrain-spawn-windows-shell.test.ts`) fails CI if a gbrain spawn drops the Windows shell flag.
- gbrain-side requirements for the root fixes (ungated reclone, `--keep-storage`, a cooperative remove-lease, a capability command, true ingest-resume, integration CI) are tracked for the gbrain repo.
## [1.54.0.0] - 2026-05-30 ## [1.54.0.0] - 2026-05-30
## **The heaviest skill stopped taxing every session. /ship's always-loaded cost dropped 59%, and its prose now loads only when a step needs it.** ## **The heaviest skill stopped taxing every session. /ship's always-loaded cost dropped 59%, and its prose now loads only when a step needs it.**
+6
View File
@@ -938,4 +938,10 @@ file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing
auto-sync across all worktrees, run `gbrain autopilot --install` once per auto-sync across all worktrees, run `gbrain autopilot --install` once per
machine — gbrain's daemon handles incremental refresh on a schedule. machine — gbrain's daemon handles incremental refresh on a schedule.
Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the
orchestrator refuses destructive source ops when it detects a running autopilot
to avoid racing it (#1734). Prefer registering user repos with `gbrain sources
add --path <dir>` (no `--url`): URL-managed sources can auto-reclone, and the
sync code walk for them requires an explicit `--allow-reclone` opt-in.
<!-- gstack-gbrain-search-guidance:end --> <!-- gstack-gbrain-search-guidance:end -->
+2 -2
View File
@@ -136,7 +136,7 @@ The skill runs three stages — code, memory, brain-sync — independently. A fa
1. **Pre-flight.** Checks `gbrain_local_status` (the local engine's health). If the engine is `broken-db` or `broken-config`, the skill STOPs with a remediation menu — it refuses to silently degrade. If the local engine is missing and you're in remote-MCP mode (Path 4), the code stage SKIPs cleanly and only brain-sync runs. 1. **Pre-flight.** Checks `gbrain_local_status` (the local engine's health). If the engine is `broken-db` or `broken-config`, the skill STOPs with a remediation menu — it refuses to silently degrade. If the local engine is missing and you're in remote-MCP mode (Path 4), the code stage SKIPs cleanly and only brain-sync runs.
2. **Code stage.** Registers the cwd as a federated source via `gbrain sources add`, writes a `.gbrain-source` pin file in the repo root (kubectl-style context — every worktree gets its own pin, so Conductor sibling worktrees don't collide), runs `gbrain sync --strategy code`. 2. **Code stage.** Registers the cwd as a federated source via `gbrain sources add`, writes a `.gbrain-source` pin file in the repo root (kubectl-style context — every worktree gets its own pin, so Conductor sibling worktrees don't collide), runs `gbrain sync --strategy code`.
3. **Memory stage.** Stages your `~/.gstack/` transcripts + curated memory. In local-stdio MCP mode, ingests into the local engine. In remote-http MCP mode, persists staged markdown to `~/.gstack/transcripts/run-<pid>-<ts>/` for the remote brain admin's pull pipeline. 3. **Memory stage.** Stages your `~/.gstack/` transcripts + curated memory. In local-stdio MCP mode, ingests into the local engine. In remote-http MCP mode, persists staged markdown to `~/.gstack/transcripts/run-<pid>-<ts>/` for the remote brain admin's pull pipeline. The ingest timeout is 30 minutes by default; raise it for a big brain with `GSTACK_INGEST_TIMEOUT_MS` (accepts 1 min24h). On timeout the gbrain import checkpoint is preserved, so the next `/sync-gbrain` resumes instead of starting over.
4. **Brain-sync stage.** Pushes curated artifacts (plans, designs, retros) to your private artifacts repo if you have one configured. 4. **Brain-sync stage.** Pushes curated artifacts (plans, designs, retros) to your private artifacts repo if you have one configured.
5. **CLAUDE.md guidance.** Capability-checks the round-trip (write a page → search → find it). If green, writes the `## GBrain Search Guidance` block to your project's CLAUDE.md. If red, REMOVES the block — the agent should never be told to use a tool that isn't installed. 5. **CLAUDE.md guidance.** Capability-checks the round-trip (write a page → search → find it). If green, writes the `## GBrain Search Guidance` block to your project's CLAUDE.md. If red, REMOVES the block — the agent should never be told to use a tool that isn't installed.
@@ -379,7 +379,7 @@ Another gstack session in a sibling Conductor workspace may be holding a lock on
## Related skills + next steps ## Related skills + next steps
- `/health` — includes a GBrain dimension (doctor status, sync queue depth, last-push age) in its 0-10 composite score. The dimension is omitted when gbrain isn't installed; running `/health` on a non-gbrain machine doesn't penalize that choice. - `/health` — includes a GBrain dimension (doctor status, sync queue depth, last-push age) in its 0-10 composite score. The dimension is omitted when gbrain isn't installed; running `/health` on a non-gbrain machine doesn't penalize that choice.
- `/gstack-upgrade` — keeps gstack itself up to date. Does NOT upgrade gbrain independently. To bump gbrain, update `PINNED_COMMIT` in `bin/gstack-gbrain-install` and re-run `/setup-gbrain`. - `/gstack-upgrade` — keeps gstack itself up to date. Does NOT upgrade gbrain independently. gbrain installs at the latest HEAD by default; to refresh it, `git pull` in your gbrain clone (default `~/gbrain`) and re-run `/setup-gbrain`. Pin a specific commit with `gstack-gbrain-install --pinned-commit <sha>` if you need reproducibility. Installs below the minimum tested version are refused.
- `/retro` — weekly retrospective pulls learnings and plans from your gbrain when memory sync is on, letting the retro reference cross-machine history. - `/retro` — weekly retrospective pulls learnings and plans from your gbrain when memory sync is on, letting the retro reference cross-machine history.
Run `/setup-gbrain` and see what sticks. Run `/setup-gbrain` and see what sticks.
+1 -1
View File
@@ -1 +1 @@
1.54.0.0 1.55.0.0
+61 -8
View File
@@ -19,9 +19,14 @@
# - git # - git
# - network reachability to https://github.com # - network reachability to https://github.com
# #
# The pinned commit is declared here rather than resolved dynamically so # gbrain installs at the latest default-branch HEAD by default — the hard pin
# upgrades are explicit and reviewable. Update PINNED_COMMIT when gstack # was removed in #1744 (it had drifted ~23 versions behind). Pass
# verifies compatibility with a new gbrain release. # --pinned-commit <sha> to install a specific commit for reproducibility. A
# minimum-version floor (MIN_GBRAIN_VERSION) hard-fails the install when the
# resulting gbrain is too old for gstack's sync integration, and a fast
# `gbrain doctor` self-test hard-fails a broken install when gbrain is already
# configured. This keeps the version gate that the pin used to provide without
# freezing users 23 releases behind.
# #
# Env: # Env:
# GBRAIN_INSTALL_DIR — override default install path (~/gbrain) # GBRAIN_INSTALL_DIR — override default install path (~/gbrain)
@@ -33,8 +38,14 @@
set -euo pipefail set -euo pipefail
# --- defaults --- # --- defaults ---
PINNED_COMMIT="08b3698e90532b7b66c445e6b1d8cdfe71822802" # gbrain v0.18.2 # No version pin by default — install the latest default-branch HEAD (#1744).
PINNED_TAG="v0.18.2" # --pinned-commit <sha> overrides for reproducibility.
PINNED_COMMIT=""
PINNED_TAG=""
# Minimum gbrain version gstack's integration is known to work with. The
# `sources list --json` wrapped-object shape + federated sources landed by 0.20;
# older predates the surface gstack drives. Hard-fail below this floor (#1744).
MIN_GBRAIN_VERSION="0.20.0"
GBRAIN_REPO_URL="https://github.com/garrytan/gbrain.git" GBRAIN_REPO_URL="https://github.com/garrytan/gbrain.git"
DEFAULT_INSTALL_DIR="${GBRAIN_INSTALL_DIR:-$HOME/gbrain}" DEFAULT_INSTALL_DIR="${GBRAIN_INSTALL_DIR:-$HOME/gbrain}"
INSTALL_DIR="$DEFAULT_INSTALL_DIR" INSTALL_DIR="$DEFAULT_INSTALL_DIR"
@@ -113,7 +124,7 @@ elif [ -n "$DETECTED_CLONE" ]; then
else else
# Fresh clone path. # Fresh clone path.
if $DRY_RUN; then if $DRY_RUN; then
log "DRY RUN: would clone $GBRAIN_REPO_URL @ $PINNED_COMMIT → $INSTALL_DIR" log "DRY RUN: would clone $GBRAIN_REPO_URL ${PINNED_COMMIT:+@ $PINNED_COMMIT }→ $INSTALL_DIR (latest HEAD unless --pinned-commit)"
exit 0 exit 0
fi fi
if [ -d "$INSTALL_DIR" ]; then if [ -d "$INSTALL_DIR" ]; then
@@ -121,8 +132,12 @@ else
fi fi
log "cloning $GBRAIN_REPO_URL → $INSTALL_DIR" log "cloning $GBRAIN_REPO_URL → $INSTALL_DIR"
git clone --quiet "$GBRAIN_REPO_URL" "$INSTALL_DIR" git clone --quiet "$GBRAIN_REPO_URL" "$INSTALL_DIR"
( cd "$INSTALL_DIR" && git checkout --quiet "$PINNED_COMMIT" ) if [ -n "$PINNED_COMMIT" ]; then
log "pinned to $PINNED_COMMIT${PINNED_TAG:+ ($PINNED_TAG)}" ( cd "$INSTALL_DIR" && git checkout --quiet "$PINNED_COMMIT" )
log "checked out pinned commit $PINNED_COMMIT${PINNED_TAG:+ ($PINNED_TAG)}"
else
log "installed latest gbrain (default-branch HEAD)"
fi
fi fi
if $DRY_RUN; then if $DRY_RUN; then
@@ -195,6 +210,44 @@ fi
log "installed gbrain $actual_version from $INSTALL_DIR" log "installed gbrain $actual_version from $INSTALL_DIR"
# --- minimum-version floor (#1744) ---
# Unpinning means new installs track gbrain HEAD. Hard-fail if the resulting
# version is below the floor gstack's sync integration needs — same exit-3 posture
# as the PATH-shadow / version-mismatch failures above. A warning here is exactly
# how the data-loss class slipped through, so this gate fails closed.
version_lt() {
# 0 (true) when $1 < $2 by version sort; equal versions are NOT less-than.
[ "$1" = "$2" ] && return 1
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]
}
if version_lt "$actual_norm" "$MIN_GBRAIN_VERSION"; then
echo "" >&2
echo "gstack-gbrain-install: gbrain $actual_version is below the minimum gstack-tested version ($MIN_GBRAIN_VERSION)." >&2
echo " gstack's sync integration needs the v0.20+ source/list surface." >&2
echo " Fix: update the gbrain clone at $INSTALL_DIR to a newer release (git pull), then" >&2
echo " re-run /setup-gbrain. Or pass --pinned-commit <sha> to install a specific newer commit." >&2
echo "" >&2
exit 3
fi
# --- functional self-test when gbrain is already configured (#1744) ---
# When a brain config exists (re-install / detected clone), run a fast doctor as
# a hard gate so a broken gbrain is caught at setup, not at data-loss time.
# Pre-init installs skip this (config not written yet); the full
# `/sync-gbrain --dry-run` self-test runs from /setup-gbrain after `gbrain init`.
_GBRAIN_HOME_CHECK="${GBRAIN_HOME:-$HOME/.gbrain}"
if [ -f "$_GBRAIN_HOME_CHECK/config.json" ]; then
if ! gbrain doctor --fast >/dev/null 2>&1; then
echo "" >&2
echo "gstack-gbrain-install: gbrain $actual_version installed but 'gbrain doctor --fast' failed." >&2
echo " Refusing to leave a broken gbrain in place. Run 'gbrain doctor' to see what's wrong," >&2
echo " fix it, then re-run /setup-gbrain." >&2
echo "" >&2
exit 3
fi
log "gbrain doctor --fast passed"
fi
# v1.40.0.0 post-install validation (T6 / codex review #19): --ignore-scripts # v1.40.0.0 post-install validation (T6 / codex review #19): --ignore-scripts
# may skip artifacts gbrain needs at runtime, especially on Windows # may skip artifacts gbrain needs at runtime, especially on Windows
# MSYS/MINGW where we DID pass --ignore-scripts. `gbrain --version` above # MSYS/MINGW where we DID pass --ignore-scripts. `gbrain --version` above
+85 -25
View File
@@ -37,9 +37,10 @@ import { createHash } from "crypto";
import "../lib/conductor-env-shim"; import "../lib/conductor-env-shim";
import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers"; import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers";
import { ensureSourceRegistered, sourcePageCount } from "../lib/gbrain-sources"; import { ensureSourceRegistered, sourcePageCount, parseSourcesList } from "../lib/gbrain-sources";
import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards";
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status"; import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
import { buildGbrainEnv, spawnGbrain, execGbrainJson } from "../lib/gbrain-exec"; import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
// ── Types ────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────
@@ -52,6 +53,8 @@ interface CliArgs {
noMemory: boolean; noMemory: boolean;
noBrainSync: boolean; noBrainSync: boolean;
codeOnly: boolean; codeOnly: boolean;
/** #1734: opt-in to sync a URL-managed source whose code walk may auto-reclone. */
allowReclone: boolean;
} }
interface CodeStageDetail { interface CodeStageDetail {
@@ -59,7 +62,7 @@ interface CodeStageDetail {
source_path?: string; source_path?: string;
page_count?: number | null; page_count?: number | null;
last_imported?: string; last_imported?: string;
status?: "ok" | "skipped" | "failed"; status?: "ok" | "skipped" | "failed" | "refused-autopilot" | "refused-reclone";
} }
interface StageResult { interface StageResult {
@@ -205,6 +208,8 @@ Options:
--no-memory Skip the gstack-memory-ingest stage (transcripts + artifacts). --no-memory Skip the gstack-memory-ingest stage (transcripts + artifacts).
--no-brain-sync Skip the gstack-brain-sync git pipeline stage. --no-brain-sync Skip the gstack-brain-sync git pipeline stage.
--code-only Only run the code-import stage (alias for --no-memory --no-brain-sync). --code-only Only run the code-import stage (alias for --no-memory --no-brain-sync).
--allow-reclone Permit the code walk for URL-managed sources (remote_url set)
even though gbrain may auto-reclone the working tree (#1734).
--help This text. --help This text.
Stages run in order: code → memory ingest → curated git push. Stages run in order: code → memory ingest → curated git push.
@@ -220,6 +225,7 @@ function parseArgs(): CliArgs {
let noMemory = false; let noMemory = false;
let noBrainSync = false; let noBrainSync = false;
let codeOnly = false; let codeOnly = false;
let allowReclone = false;
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const a = args[i]; const a = args[i];
@@ -231,6 +237,7 @@ function parseArgs(): CliArgs {
case "--no-code": noCode = true; break; case "--no-code": noCode = true; break;
case "--no-memory": noMemory = true; break; case "--no-memory": noMemory = true; break;
case "--no-brain-sync": noBrainSync = true; break; case "--no-brain-sync": noBrainSync = true; break;
case "--allow-reclone": allowReclone = true; break;
case "--code-only": case "--code-only":
codeOnly = true; codeOnly = true;
noMemory = true; noMemory = true;
@@ -247,7 +254,7 @@ function parseArgs(): CliArgs {
} }
} }
return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly }; return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly, allowReclone };
} }
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
@@ -407,10 +414,7 @@ export function sourceLocalPath(sourceId: string, env?: NodeJS.ProcessEnv): stri
{ baseEnv: env }, { baseEnv: env },
); );
if (!raw) return null; if (!raw) return null;
const list: Array<{ id?: string; local_path?: string }> = Array.isArray(raw) const found = parseSourcesList(raw).find((s) => s.id === sourceId);
? (raw as Array<{ id?: string; local_path?: string }>)
: ((raw as { sources?: Array<{ id?: string; local_path?: string }> }).sources ?? []);
const found = list.find((s) => s.id === sourceId);
return found?.local_path ?? null; return found?.local_path ?? null;
} }
@@ -469,20 +473,50 @@ export function planHostnameFoldMigration(
return { kind: "pending-cleanup", oldId: legacyPathHashId }; return { kind: "pending-cleanup", oldId: legacyPathHashId };
} }
export interface GuardedRemoveResult {
removed: boolean;
/** True when a guard refused the remove (autopilot active or unsafe source). */
skipped: boolean;
reason: string;
}
/**
* #1734: run `gbrain sources remove <id> --confirm-destructive` only behind the
* data-loss guards. Checked immediately before the destructive op (E8: as late
* as possible) so the autopilot window is as small as we can make it without a
* gbrain-side lease. Refuses when autopilot is active or when the source is
* user-managed and gbrain can't keep its storage. Pure side-effect helper; the
* caller decides whether a skip is fatal (it never is today — removes are
* best-effort cleanup).
*/
export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): GuardedRemoveResult {
const ap = detectAutopilot(env);
if (ap.active) {
return {
removed: false,
skipped: true,
reason: `autopilot active (${ap.signal}); refusing destructive remove of ${sourceId}. ` +
`Stop autopilot, then re-run /sync-gbrain.`,
};
}
const decision = decideSourceRemove(sourceId, env);
if (!decision.allow) {
return { removed: false, skipped: true, reason: decision.reason };
}
const r = spawnGbrain(
["sources", "remove", sourceId, "--confirm-destructive", ...decision.extraArgs],
{ baseEnv: env },
);
return { removed: r.status === 0, skipped: false, reason: decision.reason };
}
/** /**
* Remove an orphaned source. Called only after new-source sync verifies pages * Remove an orphaned source. Called only after new-source sync verifies pages
* exist, so the old source is provably redundant before deletion. * exist, so the old source is provably redundant before deletion. Routed through
* * safeSourcesRemove for the #1734 guards.
* Flag note: existing call sites used `--confirm-destructive` here and
* `--yes` in `lib/gbrain-sources.ts` — gbrain 0.35.0.0 accepts neither
* deterministically (the subcommand surface help is generic). We pass
* `--confirm-destructive` to match the existing call site convention; the
* flag-helper centralization in commit 4 (lib/gbrain-exec.ts) will resolve
* the inconsistency across the codebase.
*/ */
export function removeOrphanedSource(oldId: string, env?: NodeJS.ProcessEnv): boolean { export function removeOrphanedSource(oldId: string, env?: NodeJS.ProcessEnv): boolean {
const r = spawnGbrain(["sources", "remove", oldId, "--confirm-destructive"], { baseEnv: env }); return safeSourcesRemove(oldId, env).removed;
return r.status === 0;
} }
/** /**
@@ -661,13 +695,12 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
const legacyId = deriveLegacyCodeSourceId(root); const legacyId = deriveLegacyCodeSourceId(root);
let legacyRemoved = false; let legacyRemoved = false;
if (legacyId !== sourceId) { if (legacyId !== sourceId) {
const rm = spawnGbrain(["sources", "remove", legacyId, "--confirm-destructive"], { // #1734: route through the data-loss guards (autopilot + source-safety).
timeout: 30_000, const rm = safeSourcesRemove(legacyId, gbrainEnv);
baseEnv: gbrainEnv, if (rm.skipped && !args.quiet) {
}); console.error(`[sync:code] legacy-source cleanup skipped: ${rm.reason}`);
// Treat absent-source as success (clean state). gbrain emits "not found" on }
// missing id; treat any non-zero exit without "not found" as a soft fail. if (rm.removed) legacyRemoved = true;
if (rm.status === 0) legacyRemoved = true;
} }
// Step 0b: Hostname-fold migration (#1414). // Step 0b: Hostname-fold migration (#1414).
@@ -720,6 +753,29 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
process.env.GSTACK_SYNC_CODE_TIMEOUT_MS, process.env.GSTACK_SYNC_CODE_TIMEOUT_MS,
"GSTACK_SYNC_CODE_TIMEOUT_MS", "GSTACK_SYNC_CODE_TIMEOUT_MS",
); );
// #1734 guards, checked immediately before the destructive walk (E8):
// - autopilot active → refuse (the race that wiped a working tree).
// - URL-managed source → the walk can auto-reclone (rm-rf); require
// --allow-reclone. Both surface a visible reason and fail the stage so the
// verdict shows ERR rather than silently skipping protection.
const apBeforeWalk = detectAutopilot(gbrainEnv);
if (apBeforeWalk.active) {
return {
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
summary: `refused: gbrain autopilot active (${apBeforeWalk.signal}). Stop autopilot, then re-run /sync-gbrain.`,
detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" },
};
}
const reclone = decideCodeSync(sourceId, gbrainEnv, args.allowReclone);
if (!reclone.allow) {
return {
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
summary: `refused: ${reclone.reason}`,
detail: { source_id: sourceId, source_path: root, status: "refused-reclone" },
};
}
const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], { const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], {
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
timeout: codeTimeoutMs, timeout: codeTimeoutMs,
@@ -961,13 +1017,17 @@ function runBrainSyncPush(args: CliArgs): StageResult {
return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" }; return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" };
} }
// #1731: gstack-brain-sync is a bash shebang script; Windows can't spawn it
// without a shell, which surfaced as "brain-sync exited undefined".
spawnSync(brainSyncPath, ["--discover-new"], { spawnSync(brainSyncPath, ["--discover-new"], {
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
timeout: 60 * 1000, timeout: 60 * 1000,
shell: NEEDS_SHELL_ON_WINDOWS,
}); });
const result = spawnSync(brainSyncPath, ["--once"], { const result = spawnSync(brainSyncPath, ["--once"], {
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
timeout: 60 * 1000, timeout: 60 * 1000,
shell: NEEDS_SHELL_ON_WINDOWS,
}); });
return { return {
+10 -3
View File
@@ -53,18 +53,25 @@ for path in paths:
continue continue
if line in seen: if line in seen:
continue continue
# Prefer ISO ts field for sort; fall back to SHA-256. # Prefer ISO ts field for sort; fall back to SHA-256. The line
# content is the final tiebreaker so the order is total: two
# entries sharing a ts must resolve identically regardless of
# which side they arrive on. Without it, equal-ts entries fall
# back to insertion order (base, ours, theirs), and since ours
# and theirs are swapped depending on which machine runs the
# merge, the two sides produce divergent files that never
# converge.
sort_key = None sort_key = None
try: try:
obj = json.loads(line) obj = json.loads(line)
ts = obj.get('ts') or obj.get('timestamp') ts = obj.get('ts') or obj.get('timestamp')
if isinstance(ts, str): if isinstance(ts, str):
sort_key = (0, ts) sort_key = (0, ts, line)
except (json.JSONDecodeError, ValueError, TypeError): except (json.JSONDecodeError, ValueError, TypeError):
pass pass
if sort_key is None: if sort_key is None:
h = hashlib.sha256(line.encode('utf-8')).hexdigest() h = hashlib.sha256(line.encode('utf-8')).hexdigest()
sort_key = (1, h) sort_key = (1, h, line)
seen[line] = sort_key seen[line] = sort_key
except FileNotFoundError: except FileNotFoundError:
# Absent base / absent ours / absent theirs are all valid. # Absent base / absent ours / absent theirs are all valid.
+55 -6
View File
@@ -1349,10 +1349,32 @@ function installSignalForwarder(): void {
* that kill the child on parent SIGTERM/SIGINT. Returns the same shape as * that kill the child on parent SIGTERM/SIGINT. Returns the same shape as
* spawnSync's result so the caller doesn't care which mode was used. * spawnSync's result so the caller doesn't care which mode was used.
*/ */
/**
* #1611: the `gbrain import` is the long pole on big brains. Its timeout is
* configurable via GSTACK_INGEST_TIMEOUT_MS (default 30 min, 1min24h) so large
* memory corpora aren't SIGTERM'd mid-import. On timeout we SIGTERM the child,
* which preserves gbrain's import-checkpoint.json (see installSignalForwarder)
* so the next run resumes instead of restarting from scratch.
*/
const DEFAULT_IMPORT_TIMEOUT_MS = 30 * 60 * 1000;
export function resolveImportTimeoutMs(
raw: string | undefined = process.env.GSTACK_INGEST_TIMEOUT_MS,
): number {
if (raw === undefined || raw === "") return DEFAULT_IMPORT_TIMEOUT_MS;
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || Number.isNaN(n) || n < 60_000 || n > 86_400_000) {
console.error(
`[memory-ingest] GSTACK_INGEST_TIMEOUT_MS="${raw}" invalid (need 6000086400000ms); using ${DEFAULT_IMPORT_TIMEOUT_MS}ms`,
);
return DEFAULT_IMPORT_TIMEOUT_MS;
}
return n;
}
function runGbrainImport( function runGbrainImport(
stagingDir: string, stagingDir: string,
timeoutMs: number, timeoutMs: number,
): Promise<{ status: number | null; stdout: string; stderr: string }> { ): Promise<{ status: number | null; stdout: string; stderr: string; timedOut: boolean }> {
installSignalForwarder(); installSignalForwarder();
return new Promise((resolve) => { return new Promise((resolve) => {
// Seed DATABASE_URL from gbrain's own config so this stage works // Seed DATABASE_URL from gbrain's own config so this stage works
@@ -1385,6 +1407,7 @@ function runGbrainImport(
status: timedOut ? null : status, status: timedOut ? null : status,
stdout, stdout,
stderr, stderr,
timedOut,
}); });
}); });
child.on("error", (err) => { child.on("error", (err) => {
@@ -1394,6 +1417,7 @@ function runGbrainImport(
status: null, status: null,
stdout, stdout,
stderr: stderr + `\n[spawn-error] ${(err as Error).message}`, stderr: stderr + `\n[spawn-error] ${(err as Error).message}`,
timedOut,
}); });
}); });
}); });
@@ -1608,13 +1632,33 @@ async function ingestPass(args: CliArgs): Promise<BulkResult> {
// spawn, parent termination orphans the gbrain process (observed // spawn, parent termination orphans the gbrain process (observed
// during 2026-05-10 cold-run testing — gbrain kept running 15 min // during 2026-05-10 cold-run testing — gbrain kept running 15 min
// after the orchestrator timed out). // after the orchestrator timed out).
const importResult = await runGbrainImport(stagingDir, 30 * 60 * 1000); const importResult = await runGbrainImport(stagingDir, resolveImportTimeoutMs());
const stdout = importResult.stdout || ""; const stdout = importResult.stdout || "";
const stderr = importResult.stderr || ""; const stderr = importResult.stderr || "";
const importJson = parseImportJson(stdout); const importJson = parseImportJson(stdout);
if (importResult.status !== 0) { if (importResult.status !== 0) {
// #1611: on timeout, gbrain's import-checkpoint.json is preserved (the
// SIGTERM forwarder keeps the staging dir), so the next /sync-gbrain
// resumes rather than restarting. Tell the user instead of looking failed.
if (importResult.timedOut) {
const mins = Math.round(resolveImportTimeoutMs() / 60000);
const msg =
`gbrain import timed out after ${mins}min; checkpoint preserved — re-run ` +
`/sync-gbrain to resume (raise GSTACK_INGEST_TIMEOUT_MS for big brains)`;
console.error(`[memory-ingest] ${msg}`);
return {
written: 0,
skipped_secret: prep.skippedSecret,
skipped_dedup: prep.skippedDedup,
skipped_unattributed: prep.skippedUnattributed,
failed,
duration_ms: Date.now() - t0,
partial_pages: prep.partialPages,
system_error: msg,
};
}
const tail = (stderr.trim().split("\n").pop() || "").slice(0, 300); const tail = (stderr.trim().split("\n").pop() || "").slice(0, 300);
const msg = `gbrain import exited ${importResult.status}: ${tail}`; const msg = `gbrain import exited ${importResult.status}: ${tail}`;
console.error(`[memory-ingest] ERR: ${msg}`); console.error(`[memory-ingest] ERR: ${msg}`);
@@ -1810,7 +1854,12 @@ async function main(): Promise<void> {
if (result.system_error) process.exit(1); if (result.system_error) process.exit(1);
} }
main().catch((err) => { // Guard so the module is import-safe for unit tests (e.g. resolveImportTimeoutMs).
console.error(`gstack-memory-ingest fatal: ${err instanceof Error ? err.message : String(err)}`); // The orchestrator runs it as `bun gstack-memory-ingest.ts ...`, where
process.exit(1); // import.meta.main is true, so the CLI path is unaffected.
}); if (import.meta.main) {
main().catch((err) => {
console.error(`gstack-memory-ingest fatal: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
}
+135 -41
View File
@@ -211,6 +211,86 @@ function cleanupLegacyState(): void {
} }
} }
// ─── Chromium profile lock helpers (#1781) ─────────────────────
/** Profile dir used by headed/connect Chromium sessions. */
function chromiumProfileDir(): string {
return path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
}
/** Remove Chromium SingletonLock/Socket/Cookie so a relaunch can acquire the
* profile. Safe to call when absent. */
function cleanChromiumProfileLocks(profileDir: string = chromiumProfileDir()): void {
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
safeUnlinkQuiet(path.join(profileDir, lockFile));
}
}
/** Kill an orphaned Chromium that still holds the profile's SingletonLock. The
* lock symlink target is "hostname-PID"; killing that PID tears down its
* renderer tree so the next launch starts clean. No-op when absent/stale. */
async function killOrphanChromium(profileDir: string = chromiumProfileDir()): Promise<void> {
try {
const lockTarget = fs.readlinkSync(path.join(profileDir, 'SingletonLock')); // "hostname-12345"
const orphanPid = parseInt(lockTarget.split('-').pop() || '', 10);
if (orphanPid && isProcessAlive(orphanPid)) {
safeKill(orphanPid, 'SIGTERM');
await new Promise(r => setTimeout(r, 1000));
if (isProcessAlive(orphanPid)) {
safeKill(orphanPid, 'SIGKILL');
await new Promise(r => setTimeout(r, 500));
}
}
} catch (err: any) {
if (err?.code !== 'ENOENT' && err?.code !== 'EINVAL') throw err;
}
}
/** Bounded /health probe. Returns true if the server answers within `attempts`
* tries spaced `backoffMs` apart — distinguishes a busy-but-alive daemon from a
* dead one (#1781) so a slow server isn't killed and restarted into a crash-loop. */
async function probeHealthWithBackoff(port: number, attempts = 3, backoffMs = 250): Promise<boolean> {
for (let i = 0; i < attempts; i++) {
if (await isServerHealthy(port)) return true;
if (i < attempts - 1) await Bun.sleep(backoffMs);
}
return false;
}
/**
* Build the env for an auto-restart after a crash. headed/proxy/configHash are
* reapplied from THIS invocation OR the persisted server state, so a restart
* triggered by a plain command (goto/status, no --headed flag) never silently
* downgrades a headed session to headless (#1781). Pure + exported for tests.
*/
export function buildRestartEnv(
globalFlags: GlobalFlags | null | undefined,
oldState: ServerState | null,
): Record<string, string> {
const env: Record<string, string> = {};
if (globalFlags?.proxyUrl) env.BROWSE_PROXY_URL = globalFlags.proxyUrl;
if (globalFlags?.headed || oldState?.mode === 'headed') env.BROWSE_HEADED = '1';
const configHash = globalFlags?.configHash || oldState?.configHash;
if (configHash) env.BROWSE_CONFIG_HASH = configHash;
return env;
}
/** macOS only: pull the headed Chromium window to the user's current Space.
* "Google Chrome for Testing" frequently opens behind the active window or on
* another Space — the first thing users read as "I can't see the browser"
* (#1781). Best-effort, fire-and-forget, never throws. The app name is a fixed
* literal (no interpolation). */
function raiseHeadedWindowMacOS(): void {
if (process.platform !== 'darwin') return;
try {
nodeSpawn('osascript', ['-e', 'tell application "Google Chrome for Testing" to activate'], {
stdio: 'ignore',
detached: true,
}).unref();
} catch {
// osascript missing or app not present — non-fatal
}
}
// ─── Server Lifecycle ────────────────────────────────────────── // ─── Server Lifecycle ──────────────────────────────────────────
async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> { async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> {
ensureStateDir(config); ensureStateDir(config);
@@ -219,6 +299,13 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
safeUnlink(config.stateFile); safeUnlink(config.stateFile);
safeUnlink(path.join(config.stateDir, 'browse-startup-error.log')); safeUnlink(path.join(config.stateDir, 'browse-startup-error.log'));
// #1781: clear a stale Chromium profile lock (and kill the orphan still
// holding it) before launch, so an auto-restart after an abrupt kill isn't
// blocked by the previous Chromium's SingletonLock — the self-inflicted
// crash-loop. Previously only the manual connect preamble did this.
await killOrphanChromium();
cleanChromiumProfileLocks();
// Allow the caller to opt out of the parent-process watchdog by setting // Allow the caller to opt out of the parent-process watchdog by setting
// BROWSE_PARENT_PID=0 in the environment. Useful for CI, non-interactive // BROWSE_PARENT_PID=0 in the environment. Useful for CI, non-interactive
// shells, and short-lived Bash invocations that need the server to outlive // shells, and short-lived Bash invocations that need the server to outlive
@@ -486,26 +573,42 @@ async function sendCommand(state: ServerState, command: string, args: string[],
} }
} catch (err: any) { } catch (err: any) {
if (err.name === 'AbortError') { if (err.name === 'AbortError') {
console.error('[browse] Command timed out after 30s'); // #1781: a 30s timeout on a heavy page usually means busy, not dead.
// Don't kill a live server (that's what triggered the crash-loop) — report
// and exit so the user can retry rather than losing their (headed) window.
const ts = readState();
const alive = ts?.pid ? isProcessAlive(ts.pid) : false;
console.error(alive
? '[browse] Command timed out after 30s (server still alive — busy, not restarting). Retry, or raise load.'
: '[browse] Command timed out after 30s');
process.exit(1); process.exit(1);
} }
// Connection error — server may have crashed // Connection error — server may have crashed, OR may just be busy.
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) { 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
// crash, if the process is alive give /health a bounded chance to recover
// and just retry the command — never kill+restart a live-but-busy server.
if (oldState?.pid && isProcessAlive(oldState.pid) && await probeHealthWithBackoff(oldState.port)) {
if (retries >= 1) throw new Error('[browse] Server unresponsive after retry — aborting');
console.error('[browse] Server was briefly unresponsive (busy); retrying command...');
return sendCommand(oldState, command, args, retries + 1);
}
// Truly dead (or health never recovered) → restart.
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting'); if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
console.error('[browse] Server connection lost. Restarting...'); console.error('[browse] Server connection lost. Restarting...');
// Kill the old server to avoid orphaned chromium processes
const oldState = readState();
if (oldState && oldState.pid) { if (oldState && oldState.pid) {
await killServer(oldState.pid); await killServer(oldState.pid);
} }
// Reapply --proxy / --headed flags from this invocation when restarting // startServer() now clears the Chromium SingletonLock + reaps the orphan,
// after a crash. Without this, a proxied daemon that dies mid-command // so the relaunch isn't blocked by the dead Chromium's profile lock (#1781).
// would silently restart in default direct/headless mode and bypass //
// the SOCKS bridge. // Reapply --proxy / --headed when restarting. headed comes from THIS
const restartEnv: Record<string, string> = {}; // invocation OR the persisted server mode, so a restart triggered by a
if (_globalFlags?.proxyUrl) restartEnv.BROWSE_PROXY_URL = _globalFlags.proxyUrl; // plain command (goto/status, no --headed) never silently downgrades a
if (_globalFlags?.headed) restartEnv.BROWSE_HEADED = '1'; // headed session to headless (#1781). Same for proxy/configHash.
if (_globalFlags?.configHash) restartEnv.BROWSE_CONFIG_HASH = _globalFlags.configHash; const restartEnv = buildRestartEnv(_globalFlags, oldState);
const newState = await startServer(Object.keys(restartEnv).length ? restartEnv : undefined); const newState = await startServer(Object.keys(restartEnv).length ? restartEnv : undefined);
return sendCommand(newState, command, args, retries + 1); return sendCommand(newState, command, args, retries + 1);
} }
@@ -966,30 +1069,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
} }
} }
// Kill orphaned Chromium processes that may still hold the profile lock. // Kill an orphaned Chromium still holding the profile lock (the Bun server
// The server PID is the Bun process; Chromium is a child that can outlive it // PID's Chromium child can outlive an abrupt kill/crash), then clear the
// if the server is killed abruptly (SIGKILL, crash, manual rm of state file). // lock files so the launch is clean. Shared with the auto-restart path (#1781).
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); await killOrphanChromium();
try { cleanChromiumProfileLocks();
const singletonLock = path.join(profileDir, 'SingletonLock');
const lockTarget = fs.readlinkSync(singletonLock); // e.g. "hostname-12345"
const orphanPid = parseInt(lockTarget.split('-').pop() || '', 10);
if (orphanPid && isProcessAlive(orphanPid)) {
safeKill(orphanPid, 'SIGTERM');
await new Promise(resolve => setTimeout(resolve, 1000));
if (isProcessAlive(orphanPid)) {
safeKill(orphanPid, 'SIGKILL');
await new Promise(resolve => setTimeout(resolve, 500));
}
}
} catch (err: any) {
if (err?.code !== 'ENOENT' && err?.code !== 'EINVAL') throw err;
}
// Clean up Chromium profile locks (can persist after crashes)
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
safeUnlinkQuiet(path.join(profileDir, lockFile));
}
// Delete stale state file // Delete stale state file
safeUnlinkQuiet(config.stateFile); safeUnlinkQuiet(config.stateFile);
@@ -1027,6 +1111,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
}); });
const status = await resp.text(); const status = await resp.text();
console.log(`Connected to real Chrome\n${status}`); console.log(`Connected to real Chrome\n${status}`);
// #1781: surface the window — it often opens behind/on another Space.
raiseHeadedWindowMacOS();
if (process.platform === 'darwin') {
console.log('(If you still don\'t see it, check Mission Control / other Spaces.)');
}
// sidebar-agent.ts spawn was here. Ripped alongside the chat queue — // sidebar-agent.ts spawn was here. Ripped alongside the chat queue —
// the Terminal pane runs an interactive PTY now, no more one-shot // the Terminal pane runs an interactive PTY now, no more one-shot
@@ -1194,11 +1283,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
safeKill(existingState.pid, 'SIGKILL'); safeKill(existingState.pid, 'SIGKILL');
} }
} }
// Clean profile locks and state file // #1781: killing the daemon can orphan its Chromium child tree, which keeps
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); // holding the SingletonLock and makes the next `connect` fail to launch.
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { // Reap the orphan via the lock, then clear the lock files + state.
safeUnlinkQuiet(path.join(profileDir, lockFile)); await killOrphanChromium();
} cleanChromiumProfileLocks();
// Xvfb orphan cleanup: if the recorded PID still matches our Xvfb (by // Xvfb orphan cleanup: if the recorded PID still matches our Xvfb (by
// cmdline AND start-time), kill it. PID-only would risk killing a // cmdline AND start-time), kill it. PID-only would risk killing a
// recycled PID belonging to an unrelated process. // recycled PID belonging to an unrelated process.
@@ -1258,6 +1347,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
} }
await sendCommand(state, command, commandArgs); await sendCommand(state, command, commandArgs);
// #1781: `focus` means "show me the window". The server-side focus activates
// the page via CDP, but on macOS the app can still sit on another Space — pull
// it to the user's current Space too.
if (command === 'focus') raiseHeadedWindowMacOS();
} }
if (import.meta.main) { if (import.meta.main) {
+39
View File
@@ -0,0 +1,39 @@
import { describe, test, expect } from "bun:test";
import { buildRestartEnv } from "../src/cli";
// #1781: an auto-restart triggered by a plain command (no --headed flag) must
// NOT silently downgrade a headed session to headless. buildRestartEnv reapplies
// headed/proxy/configHash from this invocation OR the persisted server state.
describe("buildRestartEnv (#1781 headed persistence)", () => {
const headedState = { pid: 1, port: 9, token: "t", startedAt: "", serverPath: "", mode: "headed" as const };
const launchedState = { pid: 1, port: 9, token: "t", startedAt: "", serverPath: "", mode: "launched" as const };
test("headed flag on this invocation → BROWSE_HEADED=1", () => {
expect(buildRestartEnv({ headed: true } as any, null).BROWSE_HEADED).toBe("1");
});
test("plain command + persisted headed state → still BROWSE_HEADED=1 (the regression)", () => {
const env = buildRestartEnv({} as any, headedState as any);
expect(env.BROWSE_HEADED).toBe("1");
});
test("plain command + headless state → no BROWSE_HEADED (no spurious headed)", () => {
const env = buildRestartEnv({} as any, launchedState as any);
expect(env.BROWSE_HEADED).toBeUndefined();
});
test("nothing set → empty env", () => {
expect(buildRestartEnv(null, null)).toEqual({});
});
test("proxy + configHash reapplied from flags", () => {
const env = buildRestartEnv({ proxyUrl: "socks5://x", configHash: "abc" } as any, null);
expect(env.BROWSE_PROXY_URL).toBe("socks5://x");
expect(env.BROWSE_CONFIG_HASH).toBe("abc");
});
test("configHash falls back to persisted state", () => {
const env = buildRestartEnv({} as any, { ...launchedState, configHash: "fromstate" } as any);
expect(env.BROWSE_CONFIG_HASH).toBe("fromstate");
});
});
+1 -1
View File
@@ -2,7 +2,7 @@
name: design-consultation name: design-consultation
preamble-tier: 3 preamble-tier: 3
version: 1.0.0 version: 1.0.0
description: Design consultation: understands your product, researches the landscape, proposes a complete design system (aesthetic, typography, color, layout, spacing, motion), and generates font+color preview... (gstack) description: "Design consultation: understands your product, researches the landscape, proposes a complete design system (aesthetic, typography, color, layout, spacing, motion), and generates font+color preview... (gstack)"
allowed-tools: allowed-tools:
- Bash - Bash
- Read - Read
+1 -1
View File
@@ -2,7 +2,7 @@
name: design-html name: design-html
preamble-tier: 2 preamble-tier: 2
version: 1.0.0 version: 1.0.0
description: Design finalization: generates production-quality Pretext-native HTML/CSS. (gstack) description: "Design finalization: generates production-quality Pretext-native HTML/CSS. (gstack)"
triggers: triggers:
- build the design - build the design
- code the mockup - code the mockup
+1 -1
View File
@@ -2,7 +2,7 @@
name: design-review name: design-review
preamble-tier: 4 preamble-tier: 4
version: 2.0.0 version: 2.0.0
description: Designer's eye QA: finds visual inconsistency, spacing issues, hierarchy problems, AI slop patterns, and slow interactions — then fixes them. (gstack) description: "Designer's eye QA: finds visual inconsistency, spacing issues, hierarchy problems, AI slop patterns, and slow interactions — then fixes them. (gstack)"
allowed-tools: allowed-tools:
- Bash - Bash
- Read - Read
+1 -1
View File
@@ -2,7 +2,7 @@
name: design-shotgun name: design-shotgun
preamble-tier: 2 preamble-tier: 2
version: 1.0.0 version: 1.0.0
description: Design shotgun: generate multiple AI design variants, open a comparison board, collect structured feedback, and iterate. (gstack) description: "Design shotgun: generate multiple AI design variants, open a comparison board, collect structured feedback, and iterate. (gstack)"
triggers: triggers:
- explore design variants - explore design variants
- show me design options - show me design options
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: guard name: guard
version: 0.1.0 version: 0.1.0
description: Full safety mode: destructive command warnings + directory-scoped edits. (gstack) description: "Full safety mode: destructive command warnings + directory-scoped edits. (gstack)"
triggers: triggers:
- full safety mode - full safety mode
- guard against mistakes - guard against mistakes
+1 -1
View File
@@ -2,7 +2,7 @@
name: ios-clean name: ios-clean
preamble-tier: 3 preamble-tier: 3
version: 1.0.0 version: 1.0.0
description: Remove the DebugBridge SPM package and all #if DEBUG wiring from an iOS app. (gstack) description: "Remove the DebugBridge SPM package and all #if DEBUG wiring from an iOS app. (gstack)"
allowed-tools: allowed-tools:
- Bash - Bash
- Read - Read
+15
View File
@@ -137,6 +137,18 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process
return out; return out;
} }
/**
* Windows can't directly spawn the `gbrain` launcher (bun/npm install it as a
* `gbrain.cmd`/`.ps1` shim) or a shebang script like the bash `gstack-brain-sync`
* `spawnSync`/`spawn` resolve those only through a shell's PATHEXT + interpreter
* lookup. Without `shell: true` the child spawn fails ENOENT, which on the sync
* orchestrator surfaced as "brain-sync exited undefined" (#1731). Gate on platform
* so POSIX keeps the cheaper no-shell path. Exported so the static-grep tripwire
* (test/gbrain-spawn-windows-shell.test.ts) can assert every gbrain/brain-sync
* spawn carries it.
*/
export const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32";
export interface SpawnGbrainOptions { export interface SpawnGbrainOptions {
/** Timeout in milliseconds. Defaults to 30s. */ /** Timeout in milliseconds. Defaults to 30s. */
timeout?: number; timeout?: number;
@@ -166,6 +178,7 @@ export function spawnGbrain(args: string[], opts: SpawnGbrainOptions = {}): Spaw
cwd: opts.cwd, cwd: opts.cwd,
stdio: opts.stdio || ["ignore", "pipe", "pipe"], stdio: opts.stdio || ["ignore", "pipe", "pipe"],
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }), env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
}); });
} }
@@ -198,6 +211,7 @@ export function spawnGbrainAsync(
stdio: opts.stdio || ["ignore", "pipe", "pipe"], stdio: opts.stdio || ["ignore", "pipe", "pipe"],
cwd: opts.cwd, cwd: opts.cwd,
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }), env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }),
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
}); });
} }
@@ -212,5 +226,6 @@ export function execGbrainText(args: string[], opts: SpawnGbrainOptions = {}): s
cwd: opts.cwd, cwd: opts.cwd,
stdio: opts.stdio || ["ignore", "pipe", "pipe"], stdio: opts.stdio || ["ignore", "pipe", "pipe"],
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }), env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
}); });
} }
+266
View File
@@ -0,0 +1,266 @@
/**
* gbrain-guards defense-in-depth against gbrain's destructive code paths (#1734).
*
* gbrain (the separate CLI gstack shells out to) can rm-rf a user's working tree
* during an autopilot race (its own bug, upstream gbrain #1526). gstack can't fix
* that, but it MUST stop treating gbrain's destructive subcommands as safe. These
* guards gate the two ways the orchestrator can reach destruction:
*
* 1. `sources remove --confirm-destructive` decideSourceRemove()
* 2. `sync --strategy code` (can auto-reclone) decideCodeSync()
*
* plus an autopilot-active check (detectAutopilot) that refuses to run destructive
* ops concurrently with the daemon.
*
* Design notes grounded in the real gbrain 0.41.x surface:
* - There is NO `--keep-storage` flag and NO structured capability command, and
* subcommand `--help` is generic so capability detection is best-effort and
* defaults to "unsupported". When we can't protect a user-managed source's
* files, we FAIL CLOSED (refuse the remove) rather than delete unprotected.
* - The autopilot lock filename isn't documented and (gbrain #1226) ignores
* GBRAIN_HOME, so the live `gbrain autopilot` process is the PRIMARY signal;
* known lock paths under both the configured home and ~/.gbrain are secondary.
* - We refuse only on an AFFIRMATIVE autopilot signal inability to introspect
* never blocks a normal sync (that would brick the tool).
* - Path containment uses realpath so a symlink inside ~/.gbrain/clones can't
* smuggle a delete out to a user repo.
*
* Pure decision functions; the orchestrator logs the reasons (observability).
*/
import { spawnSync } from "child_process";
import { existsSync, realpathSync } from "fs";
import { homedir } from "os";
import { join, resolve, sep } from "path";
import { execGbrainJson, execGbrainText, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
import { parseSourcesList, type GbrainSourceRow } from "./gbrain-sources";
export function gbrainHome(env: NodeJS.ProcessEnv = process.env): string {
return env.GBRAIN_HOME || join(homedir(), ".gbrain");
}
/**
* Directories gbrain owns and may delete safely. A source whose local_path
* resolves inside one of these is gbrain-managed; outside = user-managed and
* must be protected. Both the configured home and the default ~/.gbrain are
* checked because gbrain #1226 shows home-resolution is inconsistent.
*/
function clonesDirs(env: NodeJS.ProcessEnv = process.env): string[] {
return [...new Set([join(gbrainHome(env), "clones"), join(homedir(), ".gbrain", "clones")])];
}
/** True if `p` resolves (symlinks + `..` collapsed) to a location inside `dir`. */
export function isInside(p: string, dir: string): boolean {
let rp: string;
let rd: string;
try { rp = realpathSync(p); } catch { rp = resolve(p); }
try { rd = realpathSync(dir); } catch { rd = resolve(dir); }
const base = rd.endsWith(sep) ? rd : rd + sep;
return rp === rd || rp.startsWith(base);
}
// ── Autopilot detection (E1: multi-signal, affirmative-only) ────────────────
export interface AutopilotStatus {
active: boolean;
/** Which signal fired (lock path or "process"), or null when inactive. */
signal: string | null;
}
export interface AutopilotProbe {
/** Override the lock-path list (tests). */
lockPaths?: string[];
/** Override the live-process check (tests). */
processRunning?: () => boolean;
}
/**
* Detect a running gbrain autopilot. Refuse the caller's destructive op only on
* an affirmative signal; absence of a confirmable mechanism returns inactive so
* normal syncs are never bricked.
*/
export function detectAutopilot(
env: NodeJS.ProcessEnv = process.env,
probe: AutopilotProbe = {},
): AutopilotStatus {
// Secondary signal: known lock files. gbrain #1226 — the lock ignores
// GBRAIN_HOME, so check both the configured home and the default ~/.gbrain.
const lockPaths = probe.lockPaths ?? [
join(gbrainHome(env), "autopilot.lock"),
join(homedir(), ".gbrain", "autopilot.lock"),
join(gbrainHome(env), "autopilot.pid"),
join(homedir(), ".gbrain", "autopilot.pid"),
];
for (const lp of lockPaths) {
if (existsSync(lp)) return { active: true, signal: `lock:${lp}` };
}
// Primary signal: a live `gbrain autopilot` process.
const running = (probe.processRunning ?? defaultProcessRunning)();
if (running) return { active: true, signal: "process:gbrain autopilot" };
return { active: false, signal: null };
}
function defaultProcessRunning(): boolean {
// No reliable pgrep on Windows; rely on the lock-file signal there.
if (process.platform === "win32") return false;
const r = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 });
return r.status === 0 && (r.stdout || "").trim().length > 0;
}
// ── Capability detection (E4 + Codex: per-process memo, no persistent cache) ─
//
// No structured capability command exists and subcommand --help is generic, so
// --keep-storage support can't be probed reliably; default unsupported. Memoize
// per process (keyed to the resolved gbrain identity) rather than persisting a
// cross-run cache — Codex flagged stale persistent caches, and the probe is cheap.
let _keepStorageMemo: { key: string; value: boolean } | undefined;
function gbrainIdentity(env: NodeJS.ProcessEnv): string {
const r = spawnSync("gbrain", ["--version"], {
encoding: "utf-8",
timeout: 3_000,
shell: NEEDS_SHELL_ON_WINDOWS,
env,
});
return (r.stdout || "").trim() || "unknown";
}
export function gbrainSupportsKeepStorage(env: NodeJS.ProcessEnv = process.env): boolean {
const key = gbrainIdentity(env);
if (_keepStorageMemo && _keepStorageMemo.key === key) return _keepStorageMemo.value;
let value = false;
for (const args of [["sources", "remove", "--help"], ["--help"]]) {
try {
if (/--keep-storage/.test(execGbrainText(args, { baseEnv: env, timeout: 5_000 }))) {
value = true;
break;
}
} catch {
// generic/empty help or non-zero exit → treat as unsupported
}
}
_keepStorageMemo = { key, value };
return value;
}
/** Test-only: reset the per-process capability memo. */
export function _resetCapabilityMemo(): void {
_keepStorageMemo = undefined;
}
// ── Destructive-op decisions ────────────────────────────────────────────────
/**
* Fetch + normalize the source list. Throws on read/parse failure so callers can
* distinguish "couldn't read" (fail closed) from "empty list" (source absent).
* Injectable for hermetic tests.
*/
export function fetchSources(env: NodeJS.ProcessEnv = process.env): GbrainSourceRow[] {
const raw = execGbrainJson(["sources", "list", "--json"], { baseEnv: env });
if (raw === null) throw new Error("gbrain sources list returned no JSON");
return parseSourcesList(raw);
}
export interface RemoveDecision {
allow: boolean;
/** Extra args to append to `sources remove` (e.g. --keep-storage). */
extraArgs: string[];
reason: string;
}
/**
* Decide whether `sources remove <id>` is safe, and with what flags.
*
* Fail-closed cases (allow=false):
* - sources list unreadable/unparseable (can't prove the row is safe).
* - the row is user-managed (remote_url set AND local_path outside gbrain's
* clones) and gbrain has no --keep-storage to protect the files.
*
* Allowed: absent row (no-op), gbrain-managed (inside clones), or path-managed
* without a remote_url (gbrain's remove won't touch an outside-clones path that
* it didn't clone). --keep-storage is appended whenever supported, as extra armor.
*/
export interface DecideRemoveOpts {
/** Override capability detection (tests / cached caps). */
keepStorage?: boolean;
/** Override the source-list fetch (tests). Throwing simulates a read failure. */
fetchRows?: (env: NodeJS.ProcessEnv) => GbrainSourceRow[];
}
export function decideSourceRemove(
sourceId: string,
env: NodeJS.ProcessEnv = process.env,
opts: DecideRemoveOpts = {},
): RemoveDecision {
const keepStorage = opts.keepStorage ?? gbrainSupportsKeepStorage(env);
const extra = keepStorage ? ["--keep-storage"] : [];
let rows: GbrainSourceRow[];
try {
rows = (opts.fetchRows ?? fetchSources)(env);
} catch {
return { allow: false, extraArgs: [], reason: "could not read sources list; refusing remove (fail closed)" };
}
const row = rows.find((r) => r.id === sourceId);
if (!row) return { allow: true, extraArgs: extra, reason: "source absent (no-op)" };
const remoteUrl = row.config?.remote_url;
const userManaged =
!!remoteUrl && !!row.local_path && !clonesDirs(env).some((d) => isInside(row.local_path!, d));
if (userManaged) {
if (keepStorage) {
return { allow: true, extraArgs: ["--keep-storage"], reason: "user-managed; --keep-storage protects files" };
}
return {
allow: false,
extraArgs: [],
reason:
`refusing remove of user-managed source "${sourceId}" (remote_url set, local_path ` +
`${row.local_path} outside gbrain clones) — this gbrain has no --keep-storage to ` +
`protect the working tree. Upgrade gbrain or remove the source manually.`,
};
}
return { allow: true, extraArgs: extra, reason: "gbrain-managed or path-managed without remote_url" };
}
export interface SyncDecision {
allow: boolean;
reason: string;
}
/**
* Decide whether `sync --strategy code --source <id>` is safe to run.
*
* A source with a remote_url can trigger gbrain's auto-reclone, the ungated
* rm-rf path behind the data loss (gbrain #1526). Require an explicit
* --allow-reclone opt-in for URL-managed sources. Read failure here is NOT
* itself destructive, so it fails open (proceed) the autopilot guard, checked
* first, is the primary protection against the race that caused the loss.
*/
export function decideCodeSync(
sourceId: string,
env: NodeJS.ProcessEnv = process.env,
allowReclone = false,
fetchRows: (env: NodeJS.ProcessEnv) => GbrainSourceRow[] = fetchSources,
): SyncDecision {
let rows: GbrainSourceRow[];
try {
rows = fetchRows(env);
} catch {
return { allow: true, reason: "sources unreadable; proceeding (sync read is non-destructive)" };
}
const row = rows.find((r) => r.id === sourceId);
if (row?.config?.remote_url && !allowReclone) {
return {
allow: false,
reason:
`source "${sourceId}" is URL-managed (remote_url set); sync may auto-reclone and ` +
`delete the working tree. Re-run /sync-gbrain with --allow-reclone to proceed.`,
};
}
return { allow: true, reason: "no remote_url, or reclone explicitly allowed" };
}
+4 -1
View File
@@ -35,7 +35,7 @@ import {
} from "fs"; } from "fs";
import { homedir } from "os"; import { homedir } from "os";
import { dirname, join } from "path"; import { dirname, join } from "path";
import { buildGbrainEnv } from "./gbrain-exec"; import { buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
export type LocalEngineStatus = export type LocalEngineStatus =
| "ok" | "ok"
@@ -113,6 +113,7 @@ export function resolveGbrainBin(env?: NodeJS.ProcessEnv): string | null {
timeout: 2_000, timeout: 2_000,
stdio: ["ignore", "ignore", "ignore"], stdio: ["ignore", "ignore", "ignore"],
env: e, env: e,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
}); });
result = "gbrain"; result = "gbrain";
} catch { } catch {
@@ -135,6 +136,7 @@ export function readGbrainVersion(env?: NodeJS.ProcessEnv): string {
timeout: 2_000, timeout: 2_000,
stdio: ["ignore", "pipe", "ignore"], stdio: ["ignore", "pipe", "ignore"],
env: e, env: e,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
}); });
result = out.trim().split("\n")[0] || ""; result = out.trim().split("\n")[0] || "";
} catch { } catch {
@@ -241,6 +243,7 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
timeout: PROBE_TIMEOUT_MS, timeout: PROBE_TIMEOUT_MS,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: buildGbrainEnv({ baseEnv: env ?? process.env }), env: buildGbrainEnv({ baseEnv: env ?? process.env }),
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
}); });
return "ok"; return "ok";
} catch (err) { } catch (err) {
+39 -4
View File
@@ -11,6 +11,7 @@
import { execFileSync, spawnSync } from "child_process"; import { execFileSync, spawnSync } from "child_process";
import { withErrorContext } from "./gstack-memory-helpers"; import { withErrorContext } from "./gstack-memory-helpers";
import { NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
export interface SourceState { export interface SourceState {
/** "absent" — id not registered. "match" — id at expected path. "drift" — id at different path. */ /** "absent" — id not registered. "match" — id at expected path. "drift" — id at different path. */
@@ -26,6 +27,37 @@ export interface EnsureResult {
state: SourceState; state: SourceState;
} }
/**
* One row of `gbrain sources list --json`. `config.remote_url` distinguishes
* URL-managed sources (gbrain owns the clone, may auto-reclone) from
* path-managed ones (user owns the working tree) load-bearing for the #1734
* destructive-op guards.
*/
export interface GbrainSourceRow {
id?: string;
local_path?: string;
page_count?: number;
config?: { remote_url?: string | null } | null;
}
/**
* Normalize `gbrain sources list --json` output to an array of source rows.
*
* gbrain has shipped two shapes: a wrapped `{ sources: [...] }` object (v0.20+)
* and, in older/other variants, a bare top-level array. #1576 was a crash when a
* reader assumed one shape; the parse is centralized here so every reader
* (probeSource, sourcePageCount, sourceLocalPath, the #1734 remote_url audit)
* agrees on the shape in ONE place. Returns [] for null/garbage rather than
* throwing callers treat "no rows" as absent.
*/
export function parseSourcesList(raw: unknown): GbrainSourceRow[] {
if (Array.isArray(raw)) return raw as GbrainSourceRow[];
if (raw && typeof raw === "object" && Array.isArray((raw as { sources?: unknown }).sources)) {
return (raw as { sources: GbrainSourceRow[] }).sources;
}
return [];
}
export interface EnsureOptions { export interface EnsureOptions {
/** Pass --federated to `gbrain sources add`. Default false. */ /** Pass --federated to `gbrain sources add`. Default false. */
federated?: boolean; federated?: boolean;
@@ -56,6 +88,7 @@ export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState {
timeout: 30_000, timeout: 30_000,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env, env,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
}); });
} catch (err) { } catch (err) {
const e = err as NodeJS.ErrnoException & { stderr?: Buffer }; const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
@@ -69,14 +102,14 @@ export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState {
throw err; throw err;
} }
let parsed: { sources?: Array<{ id?: string; local_path?: string }> }; let parsed: unknown;
try { try {
parsed = JSON.parse(stdout); parsed = JSON.parse(stdout);
} catch (err) { } catch (err) {
throw new Error(`gbrain sources list returned non-JSON output: ${(err as Error).message}`); throw new Error(`gbrain sources list returned non-JSON output: ${(err as Error).message}`);
} }
const sources = parsed.sources || []; const sources = parseSourcesList(parsed);
const match = sources.find((s) => s.id === id); const match = sources.find((s) => s.id === id);
if (!match) return { status: "absent" }; if (!match) return { status: "absent" };
return { return {
@@ -129,6 +162,7 @@ export async function ensureSourceRegistered(
encoding: "utf-8", encoding: "utf-8",
timeout: 30_000, timeout: 30_000,
env, env,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
}); });
if (rm.status !== 0) { if (rm.status !== 0) {
throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`); throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`);
@@ -142,6 +176,7 @@ export async function ensureSourceRegistered(
encoding: "utf-8", encoding: "utf-8",
timeout: 30_000, timeout: 30_000,
env, env,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
}); });
if (add.status !== 0) { if (add.status !== 0) {
throw new Error(`gbrain sources add ${id} failed: ${add.stderr || add.stdout || `exit ${add.status}`}`); throw new Error(`gbrain sources add ${id} failed: ${add.stderr || add.stdout || `exit ${add.status}`}`);
@@ -167,14 +202,14 @@ export function sourcePageCount(id: string, env?: NodeJS.ProcessEnv): number | n
timeout: 30_000, timeout: 30_000,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env, env,
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
}); });
} catch { } catch {
return null; return null;
} }
try { try {
const parsed = JSON.parse(stdout) as { sources?: Array<{ id?: string; page_count?: number }> }; const match = parseSourcesList(JSON.parse(stdout)).find((s) => s.id === id);
const match = (parsed.sources || []).find((s) => s.id === id);
if (!match) return null; if (!match) return null;
if (typeof match.page_count !== "number") return null; if (typeof match.page_count !== "number") return null;
return match.page_count; return match.page_count;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.54.0.0", "version": "1.55.0.0",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+1 -1
View File
@@ -2,7 +2,7 @@
name: plan-tune name: plan-tune
preamble-tier: 2 preamble-tier: 2
version: 1.0.0 version: 1.0.0
description: Self-tuning question sensitivity + developer psychographic for gstack (v1: observational). (gstack) description: "Self-tuning question sensitivity + developer psychographic for gstack (v1: observational). (gstack)"
triggers: triggers:
- tune questions - tune questions
- stop asking me that - stop asking me that
+31 -1
View File
@@ -356,6 +356,28 @@ export function buildWhenToInvokeSection(parts: CatalogParts): string {
return lines.join('\n'); return lines.join('\n');
} }
/**
* Render a string as a YAML inline scalar value (the text after `key: `),
* quoting only when a plain scalar would be invalid or ambiguous.
*
* The bug this guards (#1778): a description like "Ship workflow: detect..."
* emitted as a plain scalar has an interior ": " that a strict YAML parser
* (Codex/OpenAI skill loading) reads as a nested mapping and rejects with
* "mapping values are not allowed in this context". When quoting is needed we
* fall back to JSON.stringify, which produces a double-quoted scalar that YAML
* accepts verbatim (YAML is a superset of JSON for flow scalars). Strings that
* are already valid plain scalars pass through unchanged to keep regen diffs small.
*/
export function toYamlInlineScalar(s: string): string {
const needsQuote =
s.length === 0 ||
s !== s.trim() || // leading/trailing whitespace
/:(\s|$)/.test(s) || // "foo: bar" / trailing colon → mapping ambiguity
/\s#/.test(s) || // " #" → inline comment
/^[\s>|&*!%@`"'#,\[\]{}?-]/.test(s); // leading YAML indicator char
return needsQuote ? JSON.stringify(s) : s;
}
/** /**
* Apply catalog trim to a SKILL.md body: * Apply catalog trim to a SKILL.md body:
* - shorten frontmatter `description:` to lead + (gstack) * - shorten frontmatter `description:` to lead + (gstack)
@@ -397,8 +419,16 @@ export function applyCatalogTrim(content: string, skillName: string): { content:
// Replace description in frontmatter — keep trailing newline so the next // Replace description in frontmatter — keep trailing newline so the next
// YAML field doesn't collide on the same line as the description value. // YAML field doesn't collide on the same line as the description value.
// Quote the value when it would be an invalid YAML plain scalar (the common
// case: an interior ": " like "Ship workflow: detect..." which a strict YAML
// parser reads as a nested mapping and rejects — #1778). toYamlInlineScalar
// only quotes when needed, so descriptions without special chars stay plain.
const newDesc = buildTrimmedDescription(parts); const newDesc = buildTrimmedDescription(parts);
const newFrontmatter = frontmatter.replace(descMatch[0], `description: ${newDesc}\n`); // Function replacer (not a string) so a `$` in the description — e.g. a future
// skill referencing `$B`/`$D` — can't be interpreted as a `$&`/`$1` replacement
// pattern and silently corrupt the frontmatter.
const newDescLine = `description: ${toYamlInlineScalar(newDesc)}\n`;
const newFrontmatter = frontmatter.replace(descMatch[0], () => newDescLine);
let newContent = '---\n' + newFrontmatter + content.slice(fmEnd); let newContent = '---\n' + newFrontmatter + content.slice(fmEnd);
// Insert body section after frontmatter (after the closing ---\n and any // Insert body section after frontmatter (after the closing ---\n and any
+1 -1
View File
@@ -2,7 +2,7 @@
name: setup-gbrain name: setup-gbrain
preamble-tier: 2 preamble-tier: 2
version: 1.0.0 version: 1.0.0
description: Set up gbrain for this coding agent: install the CLI, initialize a local PGLite or Supabase brain, register MCP, capture per-remote trust policy. (gstack) description: "Set up gbrain for this coding agent: install the CLI, initialize a local PGLite or Supabase brain, register MCP, capture per-remote trust policy. (gstack)"
triggers: triggers:
- setup gbrain - setup gbrain
- install gbrain - install gbrain
+1 -1
View File
@@ -2,7 +2,7 @@
name: ship name: ship
preamble-tier: 4 preamble-tier: 4
version: 1.0.0 version: 1.0.0
description: Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack) description: "Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack)"
allowed-tools: allowed-tools:
- Bash - Bash
- Read - Read
+6
View File
@@ -990,6 +990,12 @@ file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing
auto-sync across all worktrees, run `gbrain autopilot --install` once per auto-sync across all worktrees, run `gbrain autopilot --install` once per
machine — gbrain's daemon handles incremental refresh on a schedule. machine — gbrain's daemon handles incremental refresh on a schedule.
Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the
orchestrator refuses destructive source ops when it detects a running autopilot
to avoid racing it (#1734). Prefer registering user repos with `gbrain sources
add --path <dir>` (no `--url`): URL-managed sources can auto-reclone, and the
sync code walk for them requires an explicit `--allow-reclone` opt-in.
<!-- gstack-gbrain-search-guidance:end --> <!-- gstack-gbrain-search-guidance:end -->
``` ```
+6
View File
@@ -295,6 +295,12 @@ file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing
auto-sync across all worktrees, run `gbrain autopilot --install` once per auto-sync across all worktrees, run `gbrain autopilot --install` once per
machine — gbrain's daemon handles incremental refresh on a schedule. machine — gbrain's daemon handles incremental refresh on a schedule.
Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the
orchestrator refuses destructive source ops when it detects a running autopilot
to avoid racing it (#1734). Prefer registering user repos with `gbrain sources
add --path <dir>` (no `--url`): URL-managed sources can auto-reclone, and the
sync code walk for them requires an explicit `--allow-reclone` opt-in.
<!-- gstack-gbrain-search-guidance:end --> <!-- gstack-gbrain-search-guidance:end -->
``` ```
+5 -2
View File
@@ -60,7 +60,9 @@ describe('--catalog-mode=full opt-out behavior (smoke)', () => {
test('--catalog-mode=full produces multi-line description in frontmatter', () => { test('--catalog-mode=full produces multi-line description in frontmatter', () => {
// Save the trim'd state so we can restore it. // Save the trim'd state so we can restore it.
const trimmedShip = fs.readFileSync(SHIP_SKILL, 'utf-8'); const trimmedShip = fs.readFileSync(SHIP_SKILL, 'utf-8');
expect(trimmedShip).toMatch(/^description: Ship workflow:[^\n]*\(gstack\)\n/m); // #1778: the trimmed ship description has an interior colon ("Ship workflow:")
// and is now YAML-quoted — tolerate the optional surrounding quotes.
expect(trimmedShip).toMatch(/^description: "?Ship workflow:[^\n]*\(gstack\)"?\n/m);
try { try {
// Run with --catalog-mode=full. Mutates working tree. // Run with --catalog-mode=full. Mutates working tree.
@@ -100,7 +102,8 @@ describe('--catalog-mode=full opt-out behavior (smoke)', () => {
} }
// Sanity-check the restored state matches what we saw at the start. // Sanity-check the restored state matches what we saw at the start.
const restoredShip = fs.readFileSync(SHIP_SKILL, 'utf-8'); const restoredShip = fs.readFileSync(SHIP_SKILL, 'utf-8');
expect(restoredShip).toMatch(/^description: Ship workflow:[^\n]*\(gstack\)\n/m); // #1778: restored trim state has the YAML-quoted (interior-colon) description.
expect(restoredShip).toMatch(/^description: "?Ship workflow:[^\n]*\(gstack\)"?\n/m);
} }
}, 180_000); }, 180_000);
+6 -3
View File
@@ -227,8 +227,10 @@ Original body content here.
const result = applyCatalogTrim(minimalSkill, 'example'); const result = applyCatalogTrim(minimalSkill, 'example');
expect(result).not.toBeNull(); expect(result).not.toBeNull();
const { content, parts } = result!; const { content, parts } = result!;
// Frontmatter description is now ONE line ending with (gstack) // Frontmatter description is now ONE line ending with (gstack). #1778: a
expect(content).toMatch(/^description: Example skill:[^\n]*\(gstack\)\n/m); // description with an interior colon ("Example skill:") is YAML-quoted, so
// the value is wrapped in double quotes — tolerate the optional quotes.
expect(content).toMatch(/^description: "?Example skill:[^\n]*\(gstack\)"?\n/m);
// Body has the When to invoke section // Body has the When to invoke section
expect(content).toContain('## When to invoke this skill'); expect(content).toContain('## When to invoke this skill');
expect(content).toContain('Use when asked to do an example task.'); expect(content).toContain('Use when asked to do an example task.');
@@ -257,7 +259,8 @@ Original body content here.
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!.content).not.toMatch(/\(gstack\)preamble-tier/); expect(result!.content).not.toMatch(/\(gstack\)preamble-tier/);
expect(result!.content).not.toMatch(/\(gstack\)allowed-tools/); expect(result!.content).not.toMatch(/\(gstack\)allowed-tools/);
expect(result!.content).toMatch(/\(gstack\)\n[a-z-]+:/); // #1778: optional closing quote when the description was YAML-quoted.
expect(result!.content).toMatch(/\(gstack\)"?\n[a-z-]+:/);
}); });
test('returns null on content without proper frontmatter', () => { test('returns null on content without proper frontmatter', () => {
+1 -1
View File
@@ -2,7 +2,7 @@
name: ship name: ship
preamble-tier: 4 preamble-tier: 4
version: 1.0.0 version: 1.0.0
description: Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack) description: "Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack)"
allowed-tools: allowed-tools:
- Bash - Bash
- Read - Read
+20 -4
View File
@@ -204,14 +204,30 @@ describe('gstack-gbrain-install D19 PATH-shadow validation', () => {
} }
test('passes when install-dir version matches `gbrain --version` on PATH', () => { test('passes when install-dir version matches `gbrain --version` on PATH', () => {
// Version must be >= MIN_GBRAIN_VERSION (0.20.0) floor (#1744).
const installDir = seedInstallDir('0.41.29');
const fakeBin = seedFakeGbrainBinary('0.41.29');
try {
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
});
expect(r.status).toBe(0);
expect(r.stdout).toContain('installed gbrain 0.41.29');
} finally {
fs.rmSync(installDir, { recursive: true, force: true });
fs.rmSync(fakeBin, { recursive: true, force: true });
}
});
test('hard-fails (exit 3) when the installed gbrain is below the version floor (#1744)', () => {
const installDir = seedInstallDir('0.18.2'); const installDir = seedInstallDir('0.18.2');
const fakeBin = seedFakeGbrainBinary('0.18.2'); const fakeBin = seedFakeGbrainBinary('0.18.2');
try { try {
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], { const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
env: { PATH: `${fakeBin}:${SAFE_PATH}` }, env: { PATH: `${fakeBin}:${SAFE_PATH}` },
}); });
expect(r.status).toBe(0); expect(r.status).toBe(3);
expect(r.stdout).toContain('installed gbrain 0.18.2'); expect(r.stderr).toContain('below the minimum gstack-tested version');
} finally { } finally {
fs.rmSync(installDir, { recursive: true, force: true }); fs.rmSync(installDir, { recursive: true, force: true });
fs.rmSync(fakeBin, { recursive: true, force: true }); fs.rmSync(fakeBin, { recursive: true, force: true });
@@ -219,8 +235,8 @@ describe('gstack-gbrain-install D19 PATH-shadow validation', () => {
}); });
test('tolerates a leading "v" in `gbrain --version` output', () => { test('tolerates a leading "v" in `gbrain --version` output', () => {
const installDir = seedInstallDir('0.18.2'); const installDir = seedInstallDir('0.41.29');
const fakeBin = seedFakeGbrainBinary('v0.18.2'); const fakeBin = seedFakeGbrainBinary('v0.41.29');
try { try {
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], { const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
env: { PATH: `${fakeBin}:${SAFE_PATH}` }, env: { PATH: `${fakeBin}:${SAFE_PATH}` },
+140
View File
@@ -0,0 +1,140 @@
import { describe, test, expect, afterEach } from "bun:test";
import * as fs from "fs";
import * as os from "os";
import { join } from "path";
import {
detectAutopilot,
decideSourceRemove,
decideCodeSync,
isInside,
_resetCapabilityMemo,
type GbrainSourceRow,
} from "../lib/gbrain-guards";
const HOME = os.homedir();
const clonesPath = (name: string) => join(HOME, ".gbrain", "clones", name);
afterEach(() => _resetCapabilityMemo());
// ── #1734 autopilot detection (E1: affirmative multi-signal) ────────────────
describe("detectAutopilot", () => {
test("refuses on a present lock file (secondary signal)", () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ap-"));
const lock = join(tmp, "autopilot.lock");
fs.writeFileSync(lock, "");
const r = detectAutopilot(process.env, { lockPaths: [lock], processRunning: () => false });
expect(r.active).toBe(true);
expect(r.signal).toContain("lock:");
});
test("refuses on a live autopilot process (primary signal)", () => {
const r = detectAutopilot(process.env, { lockPaths: [], processRunning: () => true });
expect(r.active).toBe(true);
expect(r.signal).toBe("process:gbrain autopilot");
});
test("proceeds when no signal fires (never blanket-refuses)", () => {
const r = detectAutopilot(process.env, { lockPaths: [], processRunning: () => false });
expect(r.active).toBe(false);
expect(r.signal).toBeNull();
});
});
// ── #1734 remove safety (E7: fail closed on user-managed without keep-storage) ─
describe("decideSourceRemove", () => {
const rows = (extra: GbrainSourceRow[] = []): GbrainSourceRow[] => [
{ id: "gbrain-managed", local_path: clonesPath("repo"), config: { remote_url: "https://x/r.git" } },
{ id: "user-managed", local_path: "/tmp/user-repo", config: { remote_url: "https://x/r.git" } },
{ id: "path-managed", local_path: "/tmp/path-repo" }, // no remote_url
...extra,
];
const fetchRows = (extra?: GbrainSourceRow[]) => () => rows(extra);
test("absent source → allow (no-op)", () => {
const d = decideSourceRemove("nope", process.env, { keepStorage: false, fetchRows: fetchRows() });
expect(d.allow).toBe(true);
expect(d.reason).toContain("absent");
});
test("user-managed + no --keep-storage → FAIL CLOSED", () => {
const d = decideSourceRemove("user-managed", process.env, { keepStorage: false, fetchRows: fetchRows() });
expect(d.allow).toBe(false);
expect(d.reason).toContain("user-managed");
});
test("user-managed + --keep-storage supported → allow with flag", () => {
const d = decideSourceRemove("user-managed", process.env, { keepStorage: true, fetchRows: fetchRows() });
expect(d.allow).toBe(true);
expect(d.extraArgs).toContain("--keep-storage");
});
test("gbrain-managed (inside clones) → allow even without keep-storage", () => {
const d = decideSourceRemove("gbrain-managed", process.env, { keepStorage: false, fetchRows: fetchRows() });
expect(d.allow).toBe(true);
});
test("path-managed without remote_url → allow (normal --path case)", () => {
const d = decideSourceRemove("path-managed", process.env, { keepStorage: false, fetchRows: fetchRows() });
expect(d.allow).toBe(true);
});
test("sources unreadable → FAIL CLOSED", () => {
const d = decideSourceRemove("user-managed", process.env, {
keepStorage: false,
fetchRows: () => { throw new Error("boom"); },
});
expect(d.allow).toBe(false);
expect(d.reason).toContain("fail closed");
});
});
// ── #1734 reclone guard (E-level: require --allow-reclone for URL-managed) ───
describe("decideCodeSync", () => {
const rows: GbrainSourceRow[] = [
{ id: "url-managed", local_path: "/tmp/u", config: { remote_url: "https://x/r.git" } },
{ id: "plain", local_path: "/tmp/p" },
];
const fetch = () => rows;
test("URL-managed + no --allow-reclone → refuse", () => {
const d = decideCodeSync("url-managed", process.env, false, fetch);
expect(d.allow).toBe(false);
expect(d.reason).toContain("auto-reclone");
});
test("URL-managed + --allow-reclone → allow", () => {
const d = decideCodeSync("url-managed", process.env, true, fetch);
expect(d.allow).toBe(true);
});
test("no remote_url → allow", () => {
const d = decideCodeSync("plain", process.env, false, fetch);
expect(d.allow).toBe(true);
});
test("sources unreadable → fail OPEN (sync read is non-destructive)", () => {
const d = decideCodeSync("url-managed", process.env, false, () => { throw new Error("boom"); });
expect(d.allow).toBe(true);
});
});
// ── path containment uses realpath (symlink can't smuggle a delete out) ──────
describe("isInside", () => {
test("plain path inside dir", () => {
expect(isInside("/a/b/c", "/a/b")).toBe(true);
expect(isInside("/a/x", "/a/b")).toBe(false);
});
test("sibling-prefix is not 'inside' (clonesX vs clones)", () => {
expect(isInside("/a/clones-evil/x", "/a/clones")).toBe(false);
});
test("symlink pointing outside resolves outside", () => {
const base = fs.mkdtempSync(join(os.tmpdir(), "clones-"));
const outside = fs.mkdtempSync(join(os.tmpdir(), "outside-"));
const link = join(base, "sneaky");
fs.symlinkSync(outside, link);
// link lives under base, but realpath resolves to `outside` → not inside base.
expect(isInside(link, base)).toBe(false);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, test, expect } from "bun:test";
import { parseSourcesList } from "../lib/gbrain-sources";
// #1576 hardening: `gbrain sources list --json` has shipped two shapes — a
// wrapped `{ sources: [...] }` object (v0.20+) and a bare top-level array.
// parseSourcesList is the single place that normalizes both, so every reader
// (probeSource, sourcePageCount, sourceLocalPath, the #1734 remote_url audit)
// agrees on the shape. These tests pin both shapes plus the garbage paths.
describe("parseSourcesList", () => {
const rows = [
{ id: "a", local_path: "/x", page_count: 3 },
{ id: "b", local_path: "/y", config: { remote_url: "https://example.com/r.git" } },
];
test("wrapped { sources: [...] } shape", () => {
expect(parseSourcesList({ sources: rows })).toEqual(rows);
});
test("bare top-level array shape", () => {
expect(parseSourcesList(rows)).toEqual(rows);
});
test("both shapes yield identical rows (shape-independent)", () => {
expect(parseSourcesList({ sources: rows })).toEqual(parseSourcesList(rows));
});
test("null / undefined → empty array (no throw)", () => {
expect(parseSourcesList(null)).toEqual([]);
expect(parseSourcesList(undefined)).toEqual([]);
});
test("object without sources key → empty array", () => {
expect(parseSourcesList({ pages: [] })).toEqual([]);
});
test("sources key present but not an array → empty array", () => {
expect(parseSourcesList({ sources: "oops" })).toEqual([]);
});
test("scalar garbage → empty array", () => {
expect(parseSourcesList("nope")).toEqual([]);
expect(parseSourcesList(42)).toEqual([]);
});
test("preserves config.remote_url for the #1734 audit", () => {
const parsed = parseSourcesList({ sources: rows });
expect(parsed.find((r) => r.id === "b")?.config?.remote_url).toBe("https://example.com/r.git");
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, test, expect } from "bun:test";
import * as fs from "fs";
import * as path from "path";
const ROOT = path.resolve(import.meta.dir, "..");
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8");
// #1731 tripwire. Windows can't spawn the `gbrain` shim (gbrain.cmd) or the bash
// shebang script gstack-brain-sync without a shell; the fix gates `shell: true`
// behind NEEDS_SHELL_ON_WINDOWS. These static checks fail CI if a refactor adds
// a gbrain/brain-sync child spawn without the Windows shell flag, since macOS/
// Linux CI can't exercise the Windows path at runtime.
describe("#1731 gbrain spawns carry the Windows shell flag", () => {
test("NEEDS_SHELL_ON_WINDOWS is platform-gated in gbrain-exec.ts", () => {
const src = read("lib/gbrain-exec.ts");
expect(src).toMatch(/export const NEEDS_SHELL_ON_WINDOWS\s*=\s*process\.platform === "win32"/);
});
// Every direct `gbrain` child spawn in these files must be matched by a
// shell:NEEDS_SHELL_ON_WINDOWS flag. Count openers vs flags as a cheap,
// refactor-resistant invariant.
const gbrainSpawnFiles = [
"lib/gbrain-exec.ts",
"lib/gbrain-sources.ts",
"lib/gbrain-local-status.ts",
];
for (const rel of gbrainSpawnFiles) {
test(`${rel}: every gbrain spawn has shell:NEEDS_SHELL_ON_WINDOWS`, () => {
const src = read(rel);
const spawnOpeners = src.match(/(spawnSync|spawn|execFileSync)\("gbrain"/g)?.length ?? 0;
const shellFlags = src.match(/shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
expect(spawnOpeners).toBeGreaterThan(0);
expect(shellFlags).toBeGreaterThanOrEqual(spawnOpeners);
});
}
test("orchestrator brain-sync spawns carry the Windows shell flag", () => {
const src = read("bin/gstack-gbrain-sync.ts");
const brainSyncSpawns = src.match(/spawnSync\(brainSyncPath,/g)?.length ?? 0;
expect(brainSyncSpawns).toBe(2);
// Both spawnSync(brainSyncPath, ...) blocks must include the shell flag.
const withShell = src.match(/spawnSync\(brainSyncPath,[\s\S]*?shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
expect(withShell).toBe(2);
});
});
+31 -4
View File
@@ -173,12 +173,39 @@ describe('gen-skill-docs', () => {
} }
}); });
test('every generated SKILL.md has valid YAML frontmatter', () => { // #1778: strict YAML parsers (Codex/OpenAI skill loading) reject frontmatter
// whose plain `description:` scalar contains an interior ": " (read as a nested
// mapping). Parse EVERY generated frontmatter block with a strict YAML parser,
// not just string-check that name:/description: exist.
function frontmatterBlock(content: string): string {
expect(content.startsWith('---\n')).toBe(true);
const end = content.indexOf('\n---', 4);
expect(end).toBeGreaterThan(0);
return content.slice(4, end);
}
test('every generated SKILL.md frontmatter parses as strict YAML', () => {
for (const skill of CLAUDE_GENERATED_SKILLS) { for (const skill of CLAUDE_GENERATED_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8'); const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
expect(content.startsWith('---\n')).toBe(true); const fm = frontmatterBlock(content);
expect(content).toContain('name:'); let parsed: any;
expect(content).toContain('description:'); expect(() => { parsed = Bun.YAML.parse(fm); },
`frontmatter for ${skill.dir} must be valid YAML`).not.toThrow();
expect(typeof parsed?.name).toBe('string');
expect(typeof parsed?.description).toBe('string');
}
});
test('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => {
const agentsDir = path.join(ROOT, '.agents', 'skills');
if (!fs.existsSync(agentsDir)) return; // skip if external hosts not generated
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const mdPath = path.join(agentsDir, entry.name, 'SKILL.md');
if (!fs.existsSync(mdPath)) continue;
const fm = frontmatterBlock(fs.readFileSync(mdPath, 'utf-8'));
expect(() => Bun.YAML.parse(fm),
`Codex frontmatter for ${entry.name} must be valid YAML`).not.toThrow();
} }
}); });
+96
View File
@@ -0,0 +1,96 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const ROOT = path.resolve(import.meta.dir, '..');
const DRIVER = path.join(ROOT, 'bin', 'gstack-jsonl-merge');
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-jsonl-merge-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
/**
* Run the merge driver the way git does: `driver <base> <ours> <theirs>`.
* The driver writes the merged result back to the <ours> file. Returns that
* file's content. `base`/`ours`/`theirs` are arrays of JSONL lines (the file
* is created from them); pass `null` to omit a file entirely (git passes an
* absent path for an added file, which the driver must tolerate).
*/
function runMerge(
base: string[] | null,
ours: string[] | null,
theirs: string[] | null,
): string {
const write = (name: string, lines: string[] | null): string => {
const p = path.join(tmpDir, name);
if (lines === null) return path.join(tmpDir, `${name}.absent`);
fs.writeFileSync(p, lines.length ? lines.join('\n') + '\n' : '');
return p;
};
const basePath = write('base', base);
const oursPath = write('ours', ours);
const theirsPath = write('theirs', theirs);
execFileSync(DRIVER, [basePath, oursPath, theirsPath], {
encoding: 'utf-8',
timeout: 15000,
});
return fs.readFileSync(oursPath, 'utf-8');
}
describe('gstack-jsonl-merge', () => {
test('equal-ts entries resolve identically regardless of side (convergence)', () => {
// Two machines append a different event in the same second, then each
// merges the other's push. Machine A sees its own line as "ours"; machine
// B sees the same line as "theirs". The merge must produce the same file
// on both, or the repos diverge and never reconcile.
const a = '{"ts":"2026-05-28T10:00:00Z","event":"a"}';
const b = '{"ts":"2026-05-28T10:00:00Z","event":"b"}';
const machineA = runMerge([], [a], [b]); // a = ours, b = theirs
const machineB = runMerge([], [b], [a]); // b = ours, a = theirs
expect(machineA).toBe(machineB);
// Both lines survive.
expect(machineA).toContain('"event":"a"');
expect(machineA).toContain('"event":"b"');
});
test('non-timestamped lines also resolve identically regardless of side', () => {
const a = '{"event":"a"}'; // no ts -> hash-ordered
const b = '{"event":"b"}';
expect(runMerge([], [a], [b])).toBe(runMerge([], [b], [a]));
});
test('plain (non-JSON) lines resolve identically regardless of side', () => {
expect(runMerge([], ['zebra'], ['apple'])).toBe(
runMerge([], ['apple'], ['zebra']),
);
});
test('exact-duplicate lines are deduped', () => {
const line = '{"ts":"2026-05-28T10:00:00Z","event":"a"}';
const out = runMerge([line], [line], [line]);
expect(out.trimEnd().split('\n')).toEqual([line]);
});
test('timestamped entries sort ascending by ts', () => {
const early = '{"ts":"2026-05-28T09:00:00Z","event":"early"}';
const late = '{"ts":"2026-05-28T11:00:00Z","event":"late"}';
const out = runMerge([], [late], [early]).trimEnd().split('\n');
expect(out).toEqual([early, late]);
});
test('absent ours/theirs files are tolerated (added-file merge)', () => {
const a = '{"ts":"2026-05-28T10:00:00Z","event":"a"}';
const out = runMerge(null, [a], null);
expect(out.trimEnd()).toBe(a);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, test, expect } from "bun:test";
import { resolveImportTimeoutMs } from "../bin/gstack-memory-ingest";
// #1611: the gbrain import timeout is configurable via GSTACK_INGEST_TIMEOUT_MS
// (default 30 min) so big-brain --full ingests aren't SIGTERM'd mid-import.
const DEFAULT = 30 * 60 * 1000;
describe("resolveImportTimeoutMs", () => {
test("unset → 30 min default", () => {
expect(resolveImportTimeoutMs(undefined)).toBe(DEFAULT);
expect(resolveImportTimeoutMs("")).toBe(DEFAULT);
});
test("valid override is honored", () => {
expect(resolveImportTimeoutMs("3600000")).toBe(3_600_000); // 1h
expect(resolveImportTimeoutMs("60000")).toBe(60_000); // floor
expect(resolveImportTimeoutMs("86400000")).toBe(86_400_000); // ceiling
});
test("invalid / out-of-range → default (no SIGTERM-too-soon footgun)", () => {
expect(resolveImportTimeoutMs("nope")).toBe(DEFAULT);
expect(resolveImportTimeoutMs("0")).toBe(DEFAULT);
expect(resolveImportTimeoutMs("59999")).toBe(DEFAULT); // below 1min floor
expect(resolveImportTimeoutMs("86400001")).toBe(DEFAULT); // above 24h ceiling
expect(resolveImportTimeoutMs("-5")).toBe(DEFAULT);
});
});