From 64d82101cac149125d28df888e3f2ea14a444dc5 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 09:35:45 -0700 Subject: [PATCH] v1.91.216.0 fix(sync-gbrain): refuse an active autopilot before touching the source list (#3360 part 2, port of #3362) (#3386) * fix(sync-gbrain): refuse on an active autopilot before the code stage registers a source Co-Authored-By: Claude Sonnet 5.5 * docs(changelog): sync-gbrain autopilot refusal before source registration (#3360, port of #3362) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(sync-gbrain): the early autopilot refusal check matches #3377's pid-naming wording (#3360) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Evgenii Lopatin Co-authored-by: Claude Sonnet 5.5 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 +++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-gbrain-sync.ts | 20 ++++++++----- package.json | 2 +- test/gstack-gbrain-sync.test.ts | 50 +++++++++++++++++++++++++++++++++ 6 files changed, 76 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d049cbf..325076f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.216.0] - 2026-10-25 + +**/sync-gbrain refuses an active autopilot before it touches the brain's source list (#3360, part 2; ported from #3362 by @exGeni).** The code stage checked for a running `gbrain autopilot` only right before its destructive walk. By then it had already cleaned up legacy sources, renamed a source and run `gbrain sources add`, so a refused run still registered a source it never synced. + +### Fixed +- **`bin/gstack-gbrain-sync.ts`**: the code stage runs the autopilot check before Step 0a, so a refusal leaves the source list untouched. The check before the walk stays, because autopilot can start while the steps in between run. Dry-run, read-only and deny policies and the local-engine skip still return earlier and are unchanged. Which processes count as an autopilot (the machine-wide probe, #3360 part 1) is unchanged. + +### Tests +- `test/gstack-gbrain-sync.test.ts` runs the real script against a fake `gbrain` and a `pgrep` that reports an autopilot: the stage is refused and the fake saw no `sources add`, `rename`, `remove` or `sync`. On main the same run issues `sources add`. + ## [1.91.215.0] - 2026-10-25 **An unattended `/ship` leaves its receipt in a file the parent can read, and the parent is told to read that file (GSTA-88).** Before, Step 18's handoff section wrote the ship receipt only to stdout, and `docs/unattended.md` told the parent to read it from the PR body with `gstack-ship-receipt read --pr --json`. An unattended run must not push, so there is no PR: the parent got a usage error or `RECEIPT_MISSING`, or, if an older PR existed, read that PR's receipt with exit 0 and a stale `head`. Now the handoff writes `.gstack/tmp/ship-receipt.md`, the parent reads it with `read --body` in the child's checkout, and it compares the receipt's `head` to the branch SHA either way. diff --git a/VERSION b/VERSION index 42f68a055..e3ddd732f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.215.0 +1.91.216.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 4784a3a02..0d9aff51c 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.215.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.216.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index b9840da4c..4087df644 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -1225,6 +1225,18 @@ async function runCodeImport(args: CliArgs): Promise { return skipStageForLocalStatus("code", localStatus, t0); } + // #1734: refuse on an active autopilot BEFORE any source-metadata change + // (Step 0b rename, Step 1 `sources add`), so a refused stage leaves the + // brain's source list untouched. The check right before the destructive walk + // below stays: autopilot can start while the steps in between run. + const refusedAutopilot = (signal: string | null): StageResult => ({ + name: "code", ran: true, ok: false, duration_ms: Date.now() - t0, + summary: `refused: gbrain autopilot active (${signal}). Stop autopilot, then re-run /sync-gbrain.`, + detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" }, + }); + const apEarly = detectAutopilot(gbrainEnv); + if (apEarly.active) return refusedAutopilot(apEarly.signal); + // Step 0a: Best-effort cleanup of pre-pathhash legacy source (v1.x form). // Earlier /sync-gbrain versions registered `gstack-code-` (no path // suffix). On a multi-worktree repo, those collapsed onto a single id @@ -1308,13 +1320,7 @@ async function runCodeImport(args: CliArgs): Promise { // --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" }, - }; - } + if (apBeforeWalk.active) return refusedAutopilot(apBeforeWalk.signal); const reclone = decideCodeSync(sourceId, gbrainEnv, args.allowReclone); if (!reclone.allow) { return { diff --git a/package.json b/package.json index d03901193..486c51a4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.215", + "version": "1.91.216", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/gstack-gbrain-sync.test.ts b/test/gstack-gbrain-sync.test.ts index 51e50fc5d..32bf537c2 100644 --- a/test/gstack-gbrain-sync.test.ts +++ b/test/gstack-gbrain-sync.test.ts @@ -1139,3 +1139,53 @@ esac expect(r.stderr.length).toBeLessThan(64 * 1024); }); }); + +// #1734: a refused code stage must leave the brain's source list untouched. +describe("code stage autopilot refusal registers nothing (#1734)", () => { + it.skipIf(process.platform === "win32")("refuses before `gbrain sources add` when an autopilot process is visible", () => { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + const repo = mkdtempSync(join(tmpdir(), "gstack-ap-refuse-repo-")); + const bindir = mkdtempSync(join(tmpdir(), "gstack-ap-refuse-bin-")); + const commandLog = join(home, "gbrain-commands.log"); + mkdirSync(gstackHome, { recursive: true }); + mkdirSync(join(home, ".gbrain"), { recursive: true }); + writeFileSync(join(home, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite", database_url: "pglite:///test" })); + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo, timeout: 30_000 }); + writeFileSync(join(bindir, "gbrain"), `#!/bin/sh +printf '%s\\n' "$*" >> "$GSTACK_TEST_GBRAIN_LOG" +case "$1 $2" in + "--version ") echo 'gbrain 0.59.0.0' ;; + "sources list") echo '{"sources":[]}' ;; + "sources add"|"sources attach"|"sync --strategy") ;; + *) echo "unexpected gbrain command: $*" >&2; exit 1 ;; +esac +`); + chmodSync(join(bindir, "gbrain"), 0o755); + writeFileSync(join(bindir, "pgrep"), "#!/bin/sh\necho 4242\nexit 0\n"); + chmodSync(join(bindir, "pgrep"), 0o755); + const r = spawnSync("bun", [SCRIPT, "--code-only", "--quiet"], { + encoding: "utf-8", + timeout: 60000, + cwd: repo, + env: { + ...process.env, + HOME: home, + GSTACK_HOME: gstackHome, + GBRAIN_HOME: "", + GSTACK_TEST_GBRAIN_LOG: commandLog, + PATH: `${bindir}:${process.env.PATH || ""}`, + }, + }); + const commands = existsSync(commandLog) ? readFileSync(commandLog, "utf-8") : ""; + const state = JSON.parse(readFileSync(join(gstackHome, ".gbrain-sync-state.json"), "utf-8")); + const code = state.last_stages.find((s: { name: string }) => s.name === "code"); + for (const d of [repo, bindir, home]) rmSync(d, { recursive: true, force: true }); + expect(r.status).toBe(1); + expect(code.ok).toBe(false); + expect(code.summary).toContain("refused: gbrain autopilot active (process:gbrain autopilot pid 4242 "); + expect(code.summary).toContain("Stop autopilot, then re-run /sync-gbrain."); + expect(commands).not.toMatch(/^sources (add|rename|remove) /m); + expect(commands).not.toMatch(/^sync /m); + }); +});