From 3a0e9869174debb52d8b137d135036d3e289db09 Mon Sep 17 00:00:00 2001 From: Oliver Nagel Date: Wed, 15 Jul 2026 18:27:04 +0200 Subject: [PATCH] fix(gbrain): harden capability and sync guards --- .github/workflows/windows-free-tests.yml | 1 + bin/gstack-gbrain-capability-check | 446 ++++++++++++++ bin/gstack-gbrain-detect | 7 +- bin/gstack-gbrain-install | 53 +- bin/gstack-gbrain-sync.ts | 110 +++- docs/ADDING_A_HOST.md | 2 +- hosts/claude.ts | 2 +- hosts/codex.ts | 4 +- hosts/cursor.ts | 2 +- hosts/factory.ts | 2 +- hosts/gbrain.ts | 2 +- hosts/hermes.ts | 2 +- hosts/kiro.ts | 2 +- hosts/openclaw.ts | 2 +- hosts/opencode.ts | 2 +- hosts/slate.ts | 2 +- lib/gbrain-exec.ts | 114 +++- lib/gbrain-guards.ts | 237 +++++-- lib/gbrain-local-status.ts | 6 +- lib/gbrain-sources.ts | 94 ++- lib/gstack-memory-helpers.ts | 12 +- setup | 22 +- sync-gbrain/SKILL.md | 32 +- sync-gbrain/SKILL.md.tmpl | 32 +- test/bin-windows-bun-import-paths.test.ts | 156 ++++- test/build-gbrain-env.test.ts | 81 ++- test/gbrain-capability-check.test.ts | 720 ++++++++++++++++++++++ test/gbrain-detect-install.test.ts | 162 ++++- test/gbrain-guards-windows.test.ts | 52 ++ test/gbrain-guards.test.ts | 417 ++++++++++++- test/gbrain-local-status.test.ts | 20 +- test/gbrain-sources.test.ts | 62 +- test/gen-skill-docs.test.ts | 4 +- test/host-config.test.ts | 2 + test/setup-windows-fallback.test.ts | 53 ++ 35 files changed, 2732 insertions(+), 187 deletions(-) create mode 100755 bin/gstack-gbrain-capability-check create mode 100644 test/gbrain-capability-check.test.ts create mode 100644 test/gbrain-guards-windows.test.ts diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index c05a7d002..687eecc6b 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -115,6 +115,7 @@ jobs: browse/test/server-sanitize-surrogates.test.ts \ test/setup-windows-fallback.test.ts \ test/bin-windows-bun-import-paths.test.ts \ + test/gbrain-guards-windows.test.ts \ test/build-script-shell-compat.test.ts \ test/docs-config-keys.test.ts \ test/brain-sync-windows-paths.test.ts \ diff --git a/bin/gstack-gbrain-capability-check b/bin/gstack-gbrain-capability-check new file mode 100755 index 000000000..7b5e0f654 --- /dev/null +++ b/bin/gstack-gbrain-capability-check @@ -0,0 +1,446 @@ +#!/usr/bin/env bash +# Side-effect-safe write/search capability probe for /sync-gbrain. +# +# Local engines get a unique, non-federated source rooted in a private temp +# directory. The probe therefore never writes into a user checkout. The EXIT / +# INT / TERM trap removes that exact source (and its cascaded page) before it +# removes the owned temp directory. Thin clients cannot safely remove a +# server-side write-through file, so they use gbrain's live OAuth scope probe +# plus a read-only search instead of issuing a write. + +set -u + +GBRAIN_BIN="${GBRAIN_BIN:-gbrain}" +BUN_BIN="${BUN_BIN:-bun}" +RETRY_ATTEMPTS="${GSTACK_GBRAIN_CAPABILITY_ATTEMPTS:-3}" +RETRY_DELAY_SECONDS="${GSTACK_GBRAIN_CAPABILITY_RETRY_DELAY_SECONDS:-1}" +# Resolve the physical bin directory. External-host installs expose bin/ as a +# symlink without a sibling lib/ directory; a logical `bin/../lib` path is +# normalized before Bun follows that symlink and therefore cannot import the +# guard module. +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# Git Bash reports /c/Users/... while native Bun expects C:/Users/.... Keep +# module imports portable across the Windows runtime copies installed by setup. +case "$(uname -s 2>/dev/null || true)" in + MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; +esac +GUARD_MODULE="$SCRIPT_DIR/../lib/gbrain-guards.ts" +SOURCES_MODULE="$SCRIPT_DIR/../lib/gbrain-sources.ts" +EXEC_MODULE="$SCRIPT_DIR/../lib/gbrain-exec.ts" + +# Resolve a strict child environment from a valid active gbrain config. The +# protocol is deliberately tiny: "postgres\n" or "file". The URL stays in +# the command-substitution pipe and is never logged. PGLite/thin-client configs +# clear both database env names so caller state cannot redirect the probe. +resolve_gbrain_environment() { + local routing mode database_url + routing=$(GSTACK_GBRAIN_EXEC_MODULE="$EXEC_MODULE" "$BUN_BIN" --no-env-file -e ' + import { pathToFileURL } from "url"; + try { + const modulePath = process.env.GSTACK_GBRAIN_EXEC_MODULE; + if (!modulePath) throw new Error("gbrain exec module path is missing"); + const { buildConfiguredGbrainEnv } = await import(pathToFileURL(modulePath).href); + const env = buildConfiguredGbrainEnv(process.env); + if (env.GBRAIN_DATABASE_URL) { + process.stdout.write(`postgres\n${env.GBRAIN_DATABASE_URL}`); + } else { + process.stdout.write("file"); + } + } catch (error) { + console.error(`gstack-gbrain-capability-check: gbrain environment unavailable: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + ') || return 1 + mode="${routing%%$'\n'*}" + case "$mode" in + postgres) + database_url="${routing#*$'\n'}" + [ -n "$database_url" ] || return 1 + export DATABASE_URL="$database_url" + export GBRAIN_DATABASE_URL="$database_url" + ;; + file) + unset DATABASE_URL GBRAIN_DATABASE_URL + ;; + *) return 1 ;; + esac +} + +if ! resolve_gbrain_environment; then + exit 1 +fi + +case "$RETRY_ATTEMPTS" in + ''|*[!0-9]*) + echo "gstack-gbrain-capability-check: attempts must be a positive integer" >&2 + exit 2 + ;; +esac +if [ "$RETRY_ATTEMPTS" -lt 1 ]; then + echo "gstack-gbrain-capability-check: attempts must be a positive integer" >&2 + exit 2 +fi +case "$RETRY_DELAY_SECONDS" in + ''|*[!0-9.]*|.*|*.*.*) + echo "gstack-gbrain-capability-check: retry delay must be a non-negative number" >&2 + exit 2 + ;; +esac + +RUN_TOKEN="$(date +%s)-${$}-${RANDOM}" +SOURCE_HASH="$(printf '%s' "$RUN_TOKEN" | "$BUN_BIN" --no-env-file -e ' + const raw = await Bun.stdin.text(); + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(raw); + process.stdout.write(hasher.digest("hex").slice(0, 20)); +' 2>/dev/null)" +case "$SOURCE_HASH" in + ''|*[!0-9a-f]*) exit 1 ;; +esac +SOURCE_ID="gsc-$SOURCE_HASH" +SLUG="_capability_check_${$}_$(date +%s)_${RANDOM}" +MARKER="gstack-capability-probe:${SLUG}" +TEMP_DIR="" +SOURCE_ADD_ATTEMPTED=0 +ACTIVE_CHILD_PID="" + +# Run a potentially mutating gbrain command as a tracked background child. +# Bash executes INT/TERM traps immediately while waiting for a background pid; +# the signal handler can therefore terminate and reap the child before cleanup. +run_tracked() { + # Bash otherwise redirects stdin of an asynchronous command from /dev/null + # when job control is inactive. The explicit duplicate preserves a caller's + # here-string/file input (the capability put payload) for the tracked child. + "$@" <&0 & + ACTIVE_CHILD_PID=$! + wait "$ACTIVE_CHILD_PID" + child_status=$? + ACTIVE_CHILD_PID="" + return "$child_status" +} + +handle_signal() { + signal_name="$1" + signal_status="$2" + if [ -n "$ACTIVE_CHILD_PID" ]; then + kill -s "$signal_name" "$ACTIVE_CHILD_PID" 2>/dev/null || true + wait "$ACTIVE_CHILD_PID" 2>/dev/null || true + ACTIVE_CHILD_PID="" + fi + exit "$signal_status" +} + +config_is_thin_client() { + "$BUN_BIN" --no-env-file -e ' + import { homedir } from "os"; + import { join } from "path"; + const override = process.env.GBRAIN_HOME?.trim(); + const candidates = override + ? [join(override, ".gbrain", "config.json"), join(override, "config.json")] + : [join(homedir(), ".gbrain", "config.json")]; + for (const path of candidates) { + try { + const parsed = JSON.parse(await Bun.file(path).text()); + process.exit(parsed?.remote_mcp && typeof parsed.remote_mcp === "object" ? 0 : 1); + } catch { + // Try the legacy GBRAIN_HOME-as-state-directory layout next. + } + } + process.exit(1); + ' >/dev/null 2>&1 +} + +remote_scope_allows_probe() { + "$BUN_BIN" --no-env-file -e ' + const raw = await Bun.stdin.text(); + try { + const report = JSON.parse(raw); + if (report?.mode !== "thin-client" || report?.status === "fail") process.exit(1); + const check = Array.isArray(report.checks) + ? report.checks.find((item) => item?.name === "oauth_client_scopes_probe") + : undefined; + const granted = String(check?.detail?.granted ?? report?.oauth_scope ?? ""); + const scopes = granted.split(/[\s,]+/).filter(Boolean); + const readOk = check?.detail?.read_ok === true; + // The gbrain scope lattice defines admin => write => read. Do not reject a + // valid admin-only client merely because the literal `write` token is not + // repeated in the grant string. + const writeOk = scopes.includes("write") || scopes.includes("admin"); + process.exit(readOk && writeOk ? 0 : 1); + } catch { + process.exit(1); + } + ' >/dev/null 2>&1 +} + +make_temp_dir() { + "$BUN_BIN" --no-env-file -e ' + import { chmodSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from "fs"; + import { homedir } from "os"; + import { dirname, join, relative, resolve } from "path"; + + // TMPDIR is caller-controlled and may point inside the checkout being + // probed. Keep capability artifacts in a sibling of gstack state instead: + // GSTACK_HOME can itself be the supported Git-backed artifacts repository, + // so putting the probe below it would make every capability check fail. + // Reject the sibling as well when a custom GSTACK_HOME still places it in + // an enclosing Git working tree. + const stateRoot = resolve(process.env.GSTACK_HOME?.trim() || join(homedir(), ".gstack")); + const parent = resolve( + process.env.GSTACK_GBRAIN_CAPABILITY_TMP_ROOT?.trim() + || join(dirname(stateRoot), ".gstack-capability-tmp"), + ); + + function resolveThroughExistingAncestor(candidate) { + let existing = candidate; + while (!existsSync(existing)) { + const next = dirname(existing); + if (next === existing) break; + existing = next; + } + return resolve(realpathSync(existing), relative(existing, candidate)); + } + + function assertOutsideGitWorktree(candidate) { + const resolvedCandidate = resolveThroughExistingAncestor(candidate); + let cursor = resolvedCandidate; + while (true) { + if (existsSync(join(cursor, ".git"))) { + throw new Error(`refusing temp root inside Git worktree: ${resolvedCandidate}`); + } + const next = dirname(cursor); + if (next === cursor) return; + cursor = next; + } + } + + // Identify the shallowest path mkdirSync would create so a symlink race + // discovered by the post-create verification can be rolled back exactly. + let firstCreated = null; + let cursor = parent; + while (!existsSync(cursor)) { + firstCreated = cursor; + const next = dirname(cursor); + if (next === cursor) break; + cursor = next; + } + + try { + // The first check happens before mkdirSync: a rejected custom GSTACK_HOME + // must not leave even an empty directory in the caller checkout. + assertOutsideGitWorktree(parent); + mkdirSync(parent, { recursive: true, mode: 0o700 }); + const resolvedParent = realpathSync(parent); + assertOutsideGitWorktree(resolvedParent); + + const created = mkdtempSync(join(resolvedParent, "gstack-gbrain-capability-")); + chmodSync(created, 0o700); + // Bun returns native backslash paths on Windows. This value is consumed + // by Git Bash before it returns to Bun/gbrain; forward slashes preserve + // the Windows absolute path while making POSIX dirname/cd operate on it. + const shellPath = process.platform === "win32" ? created.replaceAll("\\", "/") : created; + process.stdout.write(shellPath); + } catch (error) { + if (firstCreated && existsSync(firstCreated)) { + rmSync(firstCreated, { recursive: true, force: true }); + } + console.error(`gstack-gbrain-capability-check: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + ' +} + +autopilot_guard_clear() { + GSTACK_GBRAIN_GUARD_MODULE="$GUARD_MODULE" "$BUN_BIN" --no-env-file -e ' + import { pathToFileURL } from "url"; + try { + const modulePath = process.env.GSTACK_GBRAIN_GUARD_MODULE; + if (!modulePath) throw new Error("guard module path is missing"); + const { detectAutopilot } = await import(pathToFileURL(modulePath).href); + const result = detectAutopilot(process.env); + if (result.active) { + console.error(`gstack-gbrain-capability-check: autopilot active (${result.signal}); refusing temporary source mutation`); + process.exit(1); + } + process.exit(0); + } catch (error) { + console.error(`gstack-gbrain-capability-check: autopilot guard unavailable: ${error instanceof Error ? error.message : String(error)}`); + process.exit(2); + } + ' +} + +source_remove_confirmation_arg() { + local identity + identity=$("$GBRAIN_BIN" --version 2>/dev/null || true) + GSTACK_GBRAIN_GUARD_MODULE="$GUARD_MODULE" \ + GBRAIN_IDENTITY_FOR_CHECK="$identity" "$BUN_BIN" --no-env-file -e ' + import { pathToFileURL } from "url"; + try { + const modulePath = process.env.GSTACK_GBRAIN_GUARD_MODULE; + if (!modulePath) throw new Error("guard module path is missing"); + const { gbrainSourceRemoveConfirmationArgsForIdentity } = await import(pathToFileURL(modulePath).href); + const args = gbrainSourceRemoveConfirmationArgsForIdentity(process.env.GBRAIN_IDENTITY_FOR_CHECK ?? ""); + process.stdout.write(args[0]); + } catch (error) { + console.error(`gstack-gbrain-capability-check: remove contract unavailable: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + ' +} + +source_matches_temp_dir() { + LIST_OUTPUT=$("$GBRAIN_BIN" sources list --json 2>/dev/null) + LIST_STATUS=$? + [ "$LIST_STATUS" -eq 0 ] || return 2 + printf '%s' "$LIST_OUTPUT" | \ + GSTACK_GBRAIN_SOURCES_MODULE="$SOURCES_MODULE" \ + SOURCE_ID_FOR_CHECK="$SOURCE_ID" TEMP_DIR_FOR_CHECK="$TEMP_DIR" "$BUN_BIN" --no-env-file -e ' + import { pathToFileURL } from "url"; + const raw = await Bun.stdin.text(); + try { + const modulePath = process.env.GSTACK_GBRAIN_SOURCES_MODULE; + if (!modulePath) throw new Error("sources parser module path is missing"); + const { parseSourcesListStrict } = await import(pathToFileURL(modulePath).href); + const parsed = JSON.parse(raw); + const source = parseSourcesListStrict(parsed) + .find((item) => item?.id === process.env.SOURCE_ID_FOR_CHECK); + process.exit(source?.local_path === process.env.TEMP_DIR_FOR_CHECK ? 0 : 1); + } catch { + process.exit(2); + } + ' >/dev/null 2>&1 +} + +remove_owned_temp_dir() { + OWNER_FILE="$TEMP_DIR/.gstack-capability-owner" + [ -n "$TEMP_DIR" ] && [ -f "$OWNER_FILE" ] && [ ! -L "$OWNER_FILE" ] || return 1 + [ "$(cat "$OWNER_FILE" 2>/dev/null)" = "$SOURCE_ID" ] || return 1 + TEMP_DIR_TO_REMOVE="$TEMP_DIR" "$BUN_BIN" --no-env-file -e ' + import { lstatSync, rmSync } from "fs"; + const path = process.env.TEMP_DIR_TO_REMOVE; + if (!path) process.exit(1); + const stat = lstatSync(path); + if (!stat.isDirectory() || stat.isSymbolicLink()) process.exit(1); + rmSync(path, { recursive: true, force: false }); + ' >/dev/null 2>&1 +} + +cleanup() { + status=$? + trap - EXIT + trap '' INT TERM + set +e + cleanup_failed=0 + + # SOURCE_ADD_ATTEMPTED covers an interrupt after the DB row committed but + # before `sources add` returned. Exact id+path readback prevents a collision + # from ever deleting a pre-existing source. + if [ "$SOURCE_ADD_ATTEMPTED" -eq 1 ]; then + source_matches_temp_dir + source_match_status=$? + if [ "$source_match_status" -eq 0 ]; then + # Resolve the version-dependent removal contract before the final + # Autopilot check. Even this read-only version probe would otherwise open + # a race between the guard and the destructive operation. Exact id+path + # readback above supplies the source-ownership half of safeSourcesRemove + # without depending on PATH. + if ! REMOVE_CONFIRMATION_ARG=$(source_remove_confirmation_arg); then + cleanup_failed=1 + echo "gstack-gbrain-capability-check: could not resolve temporary source removal contract" >&2 + elif ! autopilot_guard_clear; then + cleanup_failed=1 + # v0.26.5+ requires --confirm-destructive for a source with pages. Only a + # positively identified older CLI receives its supported --yes flag; + # unknown/new identities remain on the current fail-closed contract. + elif ! "$GBRAIN_BIN" sources remove "$SOURCE_ID" "$REMOVE_CONFIRMATION_ARG" \ + >/dev/null 2>&1; then + cleanup_failed=1 + echo "gstack-gbrain-capability-check: failed to remove owned temporary source $SOURCE_ID" >&2 + fi + elif [ "$source_match_status" -gt 1 ]; then + cleanup_failed=1 + echo "gstack-gbrain-capability-check: could not verify temporary source cleanup for $SOURCE_ID" >&2 + fi + fi + + # Preserve the temp directory when source cleanup failed: its exact path is + # still the source's recovery anchor. Otherwise remove only the directory + # carrying this invocation's owner sentinel. + if [ "$cleanup_failed" -eq 0 ] && [ -n "$TEMP_DIR" ]; then + if ! remove_owned_temp_dir; then + cleanup_failed=1 + echo "gstack-gbrain-capability-check: failed to remove owned temporary directory $TEMP_DIR" >&2 + fi + fi + + if [ "$cleanup_failed" -ne 0 ] && [ "$status" -eq 0 ]; then + status=1 + fi + exit "$status" +} + +trap cleanup EXIT +trap 'handle_signal INT 130' INT +trap 'handle_signal TERM 143' TERM + +# Create the isolated source root before the first gbrain invocation, then run +# every child from its non-repository parent. This keeps Bun's cwd dotenv loader +# outside the caller checkout for local and thin-client probes alike. Cleanup +# removes only TEMP_DIR; the process never has its cwd inside the removed tree. +TEMP_DIR=$(make_temp_dir) || exit 1 +printf '%s' "$SOURCE_ID" > "$TEMP_DIR/.gstack-capability-owner" || exit 1 +SAFE_COMMAND_CWD=$(dirname -- "$TEMP_DIR") +cd -- "$SAFE_COMMAND_CWD" || exit 1 + +if ! "$GBRAIN_BIN" --version 2>/dev/null | grep -q '^gbrain '; then + exit 1 +fi + +if config_is_thin_client; then + # gbrain's thin-client doctor performs harmless live read/admin probes and + # reports the granted OAuth scopes. There is deliberately no remote put: + # delete_page is DB-only and cannot remove the host's write-through file. + DOCTOR_OUTPUT=$("$GBRAIN_BIN" doctor --json --fast 2>/dev/null) + DOCTOR_STATUS=$? + if [ "$DOCTOR_STATUS" -ne 0 ] || ! printf '%s' "$DOCTOR_OUTPUT" | remote_scope_allows_probe; then + exit 1 + fi + "$GBRAIN_BIN" search "$MARKER" >/dev/null 2>&1 + exit $? +fi + +# Refuse before creating anything when Autopilot is already active. Cleanup +# repeats the same guard immediately before source removal to close the race +# where the daemon starts during the probe. +if ! autopilot_guard_clear; then + exit 1 +fi + +SOURCE_ADD_ATTEMPTED=1 +if ! run_tracked "$GBRAIN_BIN" sources add "$SOURCE_ID" --path "$TEMP_DIR" --no-federated \ + >/dev/null 2>&1; then + exit 1 +fi + +run_tracked env GBRAIN_SOURCE="$SOURCE_ID" "$GBRAIN_BIN" put "$SLUG" \ + > /dev/null 2>&1 <<< "$MARKER" +PUT_STATUS=$? +if [ "$PUT_STATUS" -ne 0 ]; then + exit 1 +fi + +attempt=1 +SEARCH_RESULT_FILE="$TEMP_DIR/.gstack-capability-search-result" +while [ "$attempt" -le "$RETRY_ATTEMPTS" ]; do + if run_tracked env GBRAIN_SOURCE="$SOURCE_ID" "$GBRAIN_BIN" search "$MARKER" \ + > "$SEARCH_RESULT_FILE" 2>/dev/null && grep -Fq -- "$SLUG" "$SEARCH_RESULT_FILE"; then + exit 0 + fi + if [ "$attempt" -lt "$RETRY_ATTEMPTS" ]; then + sleep "$RETRY_DELAY_SECONDS" + fi + attempt=$((attempt + 1)) +done + +exit 1 diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index 2100f913b..adbfb6c3b 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -43,7 +43,7 @@ import { resolveGbrainBin, readGbrainVersion, } from "../lib/gbrain-local-status"; -import { isTransactionModePooler } from "../lib/gbrain-exec"; +import { isTransactionModePooler, resolveGbrainConfigPath } from "../lib/gbrain-exec"; const STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack"); const SCRIPT_DIR = __dirname; @@ -51,10 +51,7 @@ const CONFIG_BIN = join(SCRIPT_DIR, "gstack-config"); // Honors GBRAIN_HOME — must stay consistent with lib/gbrain-local-status's // config resolution, or the detect JSON reports gbrain_local_status "ok" // alongside gbrain_config_exists false for relocated-home users. -const GBRAIN_CONFIG = join( - process.env.GBRAIN_HOME || join(userHome(), ".gbrain"), - "config.json", -); +const GBRAIN_CONFIG = resolveGbrainConfigPath(process.env); const CLAUDE_JSON = join(userHome(), ".claude.json"); function userHome(): string { diff --git a/bin/gstack-gbrain-install b/bin/gstack-gbrain-install index 60c8f86b6..38cd071c7 100755 --- a/bin/gstack-gbrain-install +++ b/bin/gstack-gbrain-install @@ -38,6 +38,12 @@ set -euo pipefail # --- defaults --- +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +case "$(uname -s 2>/dev/null || true)" in + MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; +esac +BUN_BIN="${BUN_BIN:-bun}" +GBRAIN_EXEC_MODULE="$SCRIPT_DIR/../lib/gbrain-exec.ts" # No version pin by default — install the latest default-branch HEAD (#1744). # --pinned-commit overrides for reproducibility. PINNED_COMMIT="" @@ -77,8 +83,9 @@ check_prereq() { fi } +check_prereq "$BUN_BIN" "Install: curl -fsSL https://bun.sh/install | bash" + if ! $VALIDATE_ONLY; then - check_prereq bun "Install: curl -fsSL https://bun.sh/install | bash" check_prereq git "Install: xcode-select --install (macOS) or your package manager" # GitHub reachability — fail fast if offline rather than hanging `git clone`. @@ -172,6 +179,24 @@ fi # Read the version from the install-dir's package.json; compare to # `gbrain --version`. If they disagree, PATH is returning a DIFFERENT # gbrain than the one we just linked. Fail hard with remediation. +# +# Every gbrain probe runs from a fresh empty directory. Bun auto-loads dotenv +# files from the current working directory before gbrain's own config guards can +# run; invoking from the caller's project would therefore let an unrelated +# `.env.local` redirect GBRAIN_HOME/DATABASE_URL during install validation. +run_gbrain_safe() { + local safe_cwd rc + # Do not honor caller TMPDIR here: it may itself point into the project + # checkout, which would recreate the dirty-worktree hazard this isolation is + # meant to prevent if the process is interrupted before cleanup. + safe_cwd=$(TMPDIR=/tmp mktemp -d "/tmp/gstack-gbrain-install.XXXXXX") \ + || fail "could not create an isolated directory for gbrain validation" + rc=0 + ( cd "$safe_cwd" && gbrain "$@" ) || rc=$? + rm -rf "$safe_cwd" + return "$rc" +} + expected_version=$(jq -r '.version // empty' "$INSTALL_DIR/package.json" 2>/dev/null || true) if [ -z "$expected_version" ]; then fail "cannot read version from $INSTALL_DIR/package.json (install may be broken)" @@ -181,7 +206,7 @@ if ! command -v gbrain >/dev/null 2>&1; then fail "bun link completed but 'gbrain' is not on PATH. Ensure ~/.bun/bin is in your PATH." fi -actual_version=$(gbrain --version 2>/dev/null | head -1 | awk '{print $NF}' | tr -d '[:space:]' || true) +actual_version=$(run_gbrain_safe --version 2>/dev/null | head -1 | awk '{print $NF}' | tr -d '[:space:]' || true) if [ -z "$actual_version" ]; then fail "gbrain is on PATH but 'gbrain --version' produced no output — the binary may be broken." fi @@ -235,9 +260,25 @@ fi # 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 +resolve_gbrain_config_path() { + GSTACK_GBRAIN_EXEC_MODULE="$GBRAIN_EXEC_MODULE" "$BUN_BIN" --no-env-file -e ' + import { pathToFileURL } from "url"; + try { + const modulePath = process.env.GSTACK_GBRAIN_EXEC_MODULE; + if (!modulePath) throw new Error("gbrain exec module path is missing"); + const { resolveGbrainConfigPath } = await import(pathToFileURL(modulePath).href); + process.stdout.write(resolveGbrainConfigPath(process.env)); + } catch (error) { + console.error(`gstack-gbrain-install: config resolver unavailable: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + ' +} + +_GBRAIN_CONFIG_CHECK=$(resolve_gbrain_config_path) \ + || fail "could not resolve the active gbrain config for the functional self-test" +if [ -f "$_GBRAIN_CONFIG_CHECK" ]; then + if ! run_gbrain_safe 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 @@ -255,7 +296,7 @@ fi # subcommand surface is reachable (`sources` is the entry point the sync # stage hits first). If the probe fails, we warn but don't exit non-zero — # the user may still be able to use other commands. -if ! gbrain sources --help >/dev/null 2>&1; then +if ! run_gbrain_safe sources --help >/dev/null 2>&1; then echo "" >&2 echo "gstack-gbrain-install: WARNING — gbrain installed but 'gbrain sources --help' did not exit 0." >&2 if [ "$IS_WINDOWS" -eq 1 ]; then diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 07a94af3f..544996b6d 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -38,7 +38,12 @@ import { createHash } from "crypto"; import "../lib/conductor-env-shim"; import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers"; import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources"; -import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards"; +import { + detectAutopilot, + decideSourceRemove, + decideCodeSync, + gbrainSourceRemoveConfirmationArgs, +} from "../lib/gbrain-guards"; import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status"; import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec"; import { checkOwnedStagingDir } from "../lib/staging-guard"; @@ -524,6 +529,11 @@ export interface GuardedRemoveResult { reason: string; } +export interface DestructiveGuardDeps { + /** Injectable only for hermetic race tests; production uses live signals. */ + detectAutopilot?: typeof detectAutopilot; +} + /** * #1734: run `gbrain sources remove --confirm-destructive` only behind the * data-loss guards. Checked immediately before the destructive op (E8: as late @@ -533,8 +543,13 @@ export interface GuardedRemoveResult { * 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); +export function safeSourcesRemove( + sourceId: string, + env?: NodeJS.ProcessEnv, + deps: DestructiveGuardDeps = {}, +): GuardedRemoveResult { + const detect = deps.detectAutopilot ?? detectAutopilot; + const ap = detect(env); if (ap.active) { return { removed: false, @@ -547,13 +562,74 @@ export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): Gu if (!decision.allow) { return { removed: false, skipped: true, reason: decision.reason }; } + // Resolve every version/capability argument before the final Autopilot + // check. Even a read-only `gbrain --version` subprocess widens the race if it + // runs between that check and the destructive spawn. + const confirmationArgs = gbrainSourceRemoveConfirmationArgs(env); + // decideSourceRemove and confirmation selection perform version/capability/ + // source metadata probes. The daemon can start during those subprocesses, so + // repeat the affirmative signal check at the last possible point before the + // destructive spawn. + const apBeforeSpawn = detect(env); + if (apBeforeSpawn.active) { + return { + removed: false, + skipped: true, + reason: `autopilot active (${apBeforeSpawn.signal}); refusing destructive remove of ${sourceId}. ` + + `Stop autopilot, then re-run /sync-gbrain.`, + }; + } const r = spawnGbrain( - ["sources", "remove", sourceId, "--confirm-destructive", ...decision.extraArgs], + ["sources", "remove", sourceId, ...confirmationArgs, ...decision.extraArgs], { baseEnv: env }, ); return { removed: r.status === 0, skipped: false, reason: decision.reason }; } +export interface CodeSyncWalkGuard { + allow: boolean; + status: "refused-autopilot" | "refused-reclone" | null; + reason: string; +} + +/** + * Guard the code walk across its metadata-probe race window. The first check + * avoids needless probes while Autopilot is already active; the second is the + * authoritative last-moment gate after decideCodeSync has spawned gbrain for + * version/source metadata and immediately before the caller spawns code sync. + */ +export function guardCodeSyncBeforeWalk( + sourceId: string, + env: NodeJS.ProcessEnv, + allowReclone: boolean, + deps: DestructiveGuardDeps = {}, +): CodeSyncWalkGuard { + const detect = deps.detectAutopilot ?? detectAutopilot; + const apBeforeProbe = detect(env); + if (apBeforeProbe.active) { + return { + allow: false, + status: "refused-autopilot", + reason: `gbrain autopilot active (${apBeforeProbe.signal}). Stop autopilot, then re-run /sync-gbrain.`, + }; + } + + const reclone = decideCodeSync(sourceId, env, allowReclone); + if (!reclone.allow) { + return { allow: false, status: "refused-reclone", reason: reclone.reason }; + } + + const apBeforeSpawn = detect(env); + if (apBeforeSpawn.active) { + return { + allow: false, + status: "refused-autopilot", + reason: `gbrain autopilot active (${apBeforeSpawn.signal}). Stop autopilot, then re-run /sync-gbrain.`, + }; + } + return { allow: true, status: null, reason: reclone.reason }; +} + /** * Remove an orphaned source. Called only after new-source sync verifies pages * exist, so the old source is provably redundant before deletion. Routed through @@ -844,7 +920,11 @@ async function runCodeImport(args: CliArgs): Promise { // no synchronous duplicate here (per /codex review #12). let registered = false; try { - const result = await ensureSourceRegistered(sourceId, root, { federated: true, env: gbrainEnv }); + const result = await ensureSourceRegistered(sourceId, root, { + federated: true, + env: gbrainEnv, + guardedRemove: (id) => safeSourcesRemove(id, gbrainEnv), + }); registered = result.changed; } catch (err) { return { @@ -878,20 +958,16 @@ async function runCodeImport(args: CliArgs): Promise { // - 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) { + const walkGuard = guardCodeSyncBeforeWalk(sourceId, gbrainEnv, args.allowReclone); + if (!walkGuard.allow) { 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" }, + summary: `refused: ${walkGuard.reason}`, + detail: { + source_id: sourceId, + source_path: root, + status: walkGuard.status ?? "refused-reclone", + }, }; } diff --git a/docs/ADDING_A_HOST.md b/docs/ADDING_A_HOST.md index 50654e4e2..e6fe105dd 100644 --- a/docs/ADDING_A_HOST.md +++ b/docs/ADDING_A_HOST.md @@ -73,7 +73,7 @@ const myhost: HostConfig = { ], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'] }, }, diff --git a/hosts/claude.ts b/hosts/claude.ts index f805da040..4f657550d 100644 --- a/hosts/claude.ts +++ b/hosts/claude.ts @@ -27,7 +27,7 @@ const claude: HostConfig = { suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'], }, diff --git a/hosts/codex.ts b/hosts/codex.ts index 7dc80ea87..a6f32da5a 100644 --- a/hosts/codex.ts +++ b/hosts/codex.ts @@ -42,14 +42,14 @@ const codex: HostConfig = { ], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'], }, }, sidecar: { path: '.agents/skills/gstack', - symlinks: ['bin', 'browse', 'review', 'qa', 'ETHOS.md'], + symlinks: ['bin', 'lib', 'browse', 'review', 'qa', 'ETHOS.md'], }, install: { diff --git a/hosts/cursor.ts b/hosts/cursor.ts index 48e3a0f14..a4c84e536 100644 --- a/hosts/cursor.ts +++ b/hosts/cursor.ts @@ -31,7 +31,7 @@ const cursor: HostConfig = { suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'], }, diff --git a/hosts/factory.ts b/hosts/factory.ts index 08ac2f9a1..16d6b1d28 100644 --- a/hosts/factory.ts +++ b/hosts/factory.ts @@ -46,7 +46,7 @@ const factory: HostConfig = { suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'], }, diff --git a/hosts/gbrain.ts b/hosts/gbrain.ts index ae777f2f1..f3525bac7 100644 --- a/hosts/gbrain.ts +++ b/hosts/gbrain.ts @@ -60,7 +60,7 @@ const gbrain: HostConfig = { ], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'], }, diff --git a/hosts/hermes.ts b/hosts/hermes.ts index 43598989d..eb50c78c7 100644 --- a/hosts/hermes.ts +++ b/hosts/hermes.ts @@ -55,7 +55,7 @@ const hermes: HostConfig = { ], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'], }, diff --git a/hosts/kiro.ts b/hosts/kiro.ts index 31adc7c72..bac58e4c9 100644 --- a/hosts/kiro.ts +++ b/hosts/kiro.ts @@ -33,7 +33,7 @@ const kiro: HostConfig = { suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'], }, diff --git a/hosts/openclaw.ts b/hosts/openclaw.ts index f8268b5c7..0c81b39da 100644 --- a/hosts/openclaw.ts +++ b/hosts/openclaw.ts @@ -58,7 +58,7 @@ const openclaw: HostConfig = { ], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'], }, diff --git a/hosts/opencode.ts b/hosts/opencode.ts index 3ad0901ec..5563119d3 100644 --- a/hosts/opencode.ts +++ b/hosts/opencode.ts @@ -31,7 +31,7 @@ const opencode: HostConfig = { suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'design/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'], globalFiles: { 'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'], }, diff --git a/hosts/slate.ts b/hosts/slate.ts index 0c29cf8f6..5e35fc9e1 100644 --- a/hosts/slate.ts +++ b/hosts/slate.ts @@ -31,7 +31,7 @@ const slate: HostConfig = { suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'], runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'], }, diff --git a/lib/gbrain-exec.ts b/lib/gbrain-exec.ts index 0cb1ddc57..b3409bdf3 100644 --- a/lib/gbrain-exec.ts +++ b/lib/gbrain-exec.ts @@ -11,7 +11,8 @@ * caller's `.env.local`. gbrain auto-loads `.env.local` via dotenv on * startup. When `/sync-gbrain` runs inside a Next.js / Prisma / Rails * project with its own `DATABASE_URL`, gbrain reads that one and not - * its own `${GBRAIN_HOME:-$HOME/.gbrain}/config.json`. Auth fails; + * its own gbrain config (`$GBRAIN_HOME/.gbrain/config.json` on current + * releases, with the legacy direct layout still accepted). Auth fails; * code + memory stages crash; only brain-sync's git push survives. * * 2. **Bun-aware env passing.** Mutating `process.env.DATABASE_URL` does @@ -23,7 +24,7 @@ * * 3. **`GBRAIN_HOME` honored consistently.** Other gstack helpers * (`detectEngineTier`) already honor `GBRAIN_HOME`. `buildGbrainEnv` - * reads from `${GBRAIN_HOME:-$HOME/.gbrain}/config.json` so all + * reads the current/legacy gbrain config layout so all * gstack-side gbrain calls agree on which config file matters. * * **Escape hatch:** `GSTACK_RESPECT_ENV_DATABASE_URL=1` returns the @@ -36,8 +37,11 @@ import { join } from "path"; import { homedir } from "os"; import { spawnSync, spawn, execFileSync, type SpawnSyncReturns, type ChildProcess, type SpawnOptions } from "child_process"; -interface GbrainConfig { +export interface GbrainConfig { + engine?: string; database_url?: string; + database_path?: string; + remote_mcp?: unknown; } export interface BuildGbrainEnvOptions { @@ -54,6 +58,94 @@ export interface BuildGbrainEnvOptions { announce?: boolean; } +/** + * Ordered gbrain config candidates for the active environment. + * + * Current gbrain treats GBRAIN_HOME as a parent and appends `.gbrain`. + * Older releases treated GBRAIN_HOME as the state directory itself. Keep the + * legacy direct path as a read-only fallback, but always prefer the current + * nested layout when both exist. + */ +export function gbrainConfigCandidates(baseEnv: NodeJS.ProcessEnv = process.env): string[] { + const homeBase = baseEnv.HOME || homedir(); + const override = baseEnv.GBRAIN_HOME?.trim(); + return override + ? [join(override, ".gbrain", "config.json"), join(override, "config.json")] + : [join(homeBase, ".gbrain", "config.json")]; +} + +/** Existing active config, or the current-layout path when no candidate exists. */ +export function resolveGbrainConfigPath(baseEnv: NodeJS.ProcessEnv = process.env): string { + const candidates = gbrainConfigCandidates(baseEnv); + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]; +} + +export interface ActiveGbrainConfig { + path: string; + config: GbrainConfig; +} + +function readGbrainConfigFile(path: string): ActiveGbrainConfig | null { + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return { path, config: parsed as GbrainConfig }; + } catch { + return null; + } +} + +/** + * Read the active config as an object. Missing, malformed, array, and scalar + * configs are rejected instead of being confused with a configured brain. + */ +export function readActiveGbrainConfig( + baseEnv: NodeJS.ProcessEnv = process.env, +): ActiveGbrainConfig | null { + return readGbrainConfigFile(resolveGbrainConfigPath(baseEnv)); +} + +/** + * Strict environment for a capability probe that may mutate the brain. + * + * Unlike buildGbrainEnv(), this requires a valid active config and deliberately + * ignores caller database overrides. A Postgres brain receives the configured + * URL through both supported env names; PGLite and thin-client configs have all + * caller database routing removed. This prevents a project/caller DATABASE_URL + * from redirecting a temporary capability source into an unrelated database. + */ +export function buildConfiguredGbrainEnv( + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + // Mutating probes may use only the current upstream layout. The direct + // GBRAIN_HOME/config.json candidate remains a read-only compatibility + // fallback elsewhere, but current gbrain never reads it. Treating a stale + // legacy file as active here could route writes to an unrelated database. + const canonicalPath = gbrainConfigCandidates(baseEnv)[0]; + const active = readGbrainConfigFile(canonicalPath); + if (!active) { + throw new Error(`active gbrain config is missing or malformed at ${canonicalPath}`); + } + + const out: NodeJS.ProcessEnv = { ...baseEnv }; + delete out.DATABASE_URL; + delete out.GBRAIN_DATABASE_URL; + + const cfg = active.config; + if (cfg.remote_mcp && typeof cfg.remote_mcp === "object") return out; + if (cfg.engine === "pglite" || (typeof cfg.database_path === "string" && cfg.database_path.trim())) { + return out; + } + if (typeof cfg.database_url === "string" && cfg.database_url.trim()) { + out.DATABASE_URL = cfg.database_url; + out.GBRAIN_DATABASE_URL = cfg.database_url; + return out; + } + + throw new Error(`active gbrain config at ${active.path} has no usable engine routing`); +} + /** * Detect whether a DATABASE_URL targets a PgBouncer transaction-mode pooler. * @@ -76,7 +168,7 @@ export function isTransactionModePooler(url: string): boolean { /** * Build an env dict with DATABASE_URL seeded from - * `${GBRAIN_HOME:-$HOME/.gbrain}/config.json`. Returns the base env + * the active gbrain config. Returns the base env * unchanged when: * - `GSTACK_RESPECT_ENV_DATABASE_URL=1` (intentional opt-out), * - the config file is missing or unparseable, @@ -98,17 +190,9 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process const out: NodeJS.ProcessEnv = { ...baseEnv }; if (baseEnv.GSTACK_RESPECT_ENV_DATABASE_URL === "1") return out; - const homeBase = baseEnv.HOME || homedir(); - const gbrainHome = baseEnv.GBRAIN_HOME || join(homeBase, ".gbrain"); - const configPath = join(gbrainHome, "config.json"); - if (!existsSync(configPath)) return out; - - let cfg: GbrainConfig = {}; - try { - cfg = JSON.parse(readFileSync(configPath, "utf-8")) as GbrainConfig; - } catch { - return out; - } + const active = readActiveGbrainConfig(baseEnv); + if (!active) return out; + const { path: configPath, config: cfg } = active; if (!cfg.database_url) return out; const hadCaller = baseEnv.DATABASE_URL !== undefined; diff --git a/lib/gbrain-guards.ts b/lib/gbrain-guards.ts index e983de260..499a637e6 100644 --- a/lib/gbrain-guards.ts +++ b/lib/gbrain-guards.ts @@ -17,9 +17,9 @@ * 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. + * - Current gbrain treats GBRAIN_HOME as the parent of `.gbrain`; older + * releases treated it as the state directory itself. The live process is + * the PRIMARY signal; lock paths for both layouts 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 @@ -29,24 +29,65 @@ */ import { spawnSync } from "child_process"; -import { existsSync, realpathSync, readFileSync } from "fs"; +import { existsSync, lstatSync, realpathSync, readFileSync } 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"; +import { parseSourcesListStrict, type GbrainSourceRow } from "./gbrain-sources"; + +function effectiveHome(env: NodeJS.ProcessEnv): string { + return env.HOME?.trim() || homedir(); +} export function gbrainHome(env: NodeJS.ProcessEnv = process.env): string { - return env.GBRAIN_HOME || join(homedir(), ".gbrain"); + const override = env.GBRAIN_HOME?.trim(); + return override ? join(override, ".gbrain") : join(effectiveHome(env), ".gbrain"); +} + +/** Current gbrain home first, followed by legacy/default compatibility paths. */ +function gbrainHomes(env: NodeJS.ProcessEnv = process.env): string[] { + const override = env.GBRAIN_HOME?.trim(); + return [...new Set([ + gbrainHome(env), + ...(override ? [override] : []), + join(effectiveHome(env), ".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. + * Infer the ONE active state layout used for destructive ownership decisions. + * Current gbrain treats GBRAIN_HOME as a parent; a legacy config directly under + * GBRAIN_HOME is accepted only when the current nested config is absent. + * Multi-path probing is deliberately reserved for non-destructive lock checks. */ -function clonesDirs(env: NodeJS.ProcessEnv = process.env): string[] { - return [...new Set([join(gbrainHome(env), "clones"), join(homedir(), ".gbrain", "clones")])]; +function destructiveGbrainHome(env: NodeJS.ProcessEnv = process.env): string { + const override = env.GBRAIN_HOME?.trim(); + if (!override) return join(effectiveHome(env), ".gbrain"); + const current = join(override, ".gbrain"); + if (!existsSync(join(current, "config.json")) && existsSync(join(override, "config.json"))) { + return override; + } + return current; +} + +/** + * Mirror gbrain's ownership invariant: an explicit managed_clone marker, or an + * exact default clone path for this source. Merely living somewhere below a + * clones directory never proves ownership. + */ +function isOwnedClone(row: GbrainSourceRow, env: NodeJS.ProcessEnv): boolean { + if (row.config?.managed_clone === true) return true; + if (!row.id || !row.local_path) return false; + const actual = resolve(row.local_path); + const expected = resolve(join(destructiveGbrainHome(env), "clones", row.id)); + if (actual !== expected) return false; + try { + return !lstatSync(actual).isSymbolicLink(); + } catch { + // A missing legacy default clone still has an exact, source-specific path; + // there is no filesystem entry for remove to traverse. + return true; + } } /** True if `p` resolves (symlinks + `..` collapsed) to a location inside `dir`. */ @@ -83,14 +124,12 @@ 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"), - ]; + // Secondary signal: current, legacy, and default lock paths. This remains + // sufficient on Windows where there is no reliable pgrep fallback. + const lockPaths = probe.lockPaths ?? gbrainHomes(env).flatMap((home) => [ + join(home, "autopilot.lock"), + join(home, "autopilot.pid"), + ]); for (const lp of lockPaths) { if (!existsSync(lp)) continue; // A lock FILE alone is not proof of life — a crashed daemon leaves a stale @@ -167,6 +206,60 @@ function gbrainIdentity(env: NodeJS.ProcessEnv): string { return (r.stdout || "").trim() || "unknown"; } +type GbrainVersion = [number, number, number]; + +function parseGbrainVersion(identity: string): GbrainVersion | null { + const match = identity.match(/\b(\d+)\.(\d+)\.(\d+)/); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; +} + +function versionIsBefore(version: GbrainVersion, floor: GbrainVersion): boolean { + for (let i = 0; i < floor.length; i += 1) { + if (version[i] > floor[i]) return false; + if (version[i] < floor[i]) return true; + } + return false; +} + +/** + * gbrain 0.26.5 introduced --confirm-destructive for source removal. Older + * positively identified clients support only --yes. Unknown/new clients stay + * on the current fail-closed contract; never optimistically downgrade them. + */ +export function gbrainSourceRemoveConfirmationArgs( + env: NodeJS.ProcessEnv = process.env, +): string[] { + return gbrainSourceRemoveConfirmationArgsForIdentity(gbrainIdentity(env)); +} + +/** Pure identity variant for shell helpers that invoke an explicit GBRAIN_BIN. */ +export function gbrainSourceRemoveConfirmationArgsForIdentity(identity: string): string[] { + const version = parseGbrainVersion(identity); + return version && versionIsBefore(version, [0, 26, 5]) + ? ["--yes"] + : ["--confirm-destructive"]; +} + +/** + * URL-managed sources first shipped in gbrain 0.28.0. An older, positively + * identified CLI cannot own a remote clone, so its metadata-free `sources + * list --json` rows are safe to treat as path-managed. Unknown/new versions + * stay conservative because they may support `--url` while omitting config. + */ +function gbrainMaySupportUrlSources(env: NodeJS.ProcessEnv): boolean { + const version = parseGbrainVersion(gbrainIdentity(env)); + return !version || !versionIsBefore(version, [0, 28, 0]); +} + +/** `gbrain call --source` landed in 0.31.8; older call surfaces are global. */ +function gbrainSupportsScopedCall(env: NodeJS.ProcessEnv): boolean { + const version = parseGbrainVersion(gbrainIdentity(env)); + // Unknown identities stay on the current, explicitly scoped contract. A + // failed scoped call then falls back to metadata-free CLI rows and the + // destructive callers fail closed rather than trusting ambiguous metadata. + return !version || !versionIsBefore(version, [0, 31, 8]); +} + export function gbrainSupportsKeepStorage(env: NodeJS.ProcessEnv = process.env): boolean { const key = gbrainIdentity(env); if (_keepStorageMemo && _keepStorageMemo.key === key) return _keepStorageMemo.value; @@ -198,9 +291,41 @@ export function _resetCapabilityMemo(): void { * 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); + // The public CLI list intentionally omits ownership config. The read-only + // sources_list operation exposes an authoritative (redacted) remote_url, + // which is exactly the bit the destructive/reclone guards need. Older gbrain + // releases may not have `call`; retain the CLI fallback. Metadata-free rows + // fail closed except on a positively identified pre-0.28 CLI, which predates + // the URL-managed source surface entirely. + // First obtain a source id through the non-scoped CLI list. Clear a stale + // GBRAIN_SOURCE pin; `sources list` does not need source resolution. + const neutralEnv = { ...env }; + delete neutralEnv.GBRAIN_SOURCE; + const listed = execGbrainJson(["sources", "list", "--json"], { baseEnv: neutralEnv }); + if (listed === null) throw new Error("gbrain sources list returned no JSON"); + const cliRows = parseSourcesListStrict(listed); + if (cliRows.length === 0) return []; + + // Current `gbrain call` resolves a source before dispatch, even for global + // metadata. Pin it explicitly to a source proven by the list above. Releases + // 0.28.0-0.31.7 already expose URL-managed ownership metadata but predate the + // `call --source` grammar; their call surface is global and must be invoked + // without the flag. Unknown identities stay scoped/fail-closed. + const anchor = cliRows.find((row) => /^[a-z0-9-]{1,32}$/.test(row.id ?? "")); + if (!anchor?.id) throw new Error("gbrain sources list had no usable source id"); + const callArgs = gbrainSupportsScopedCall(neutralEnv) + ? ["call", "--source", anchor.id, "sources_list", "{}"] + : ["call", "sources_list", "{}"]; + const authoritative = execGbrainJson( + callArgs, + { baseEnv: neutralEnv }, + ); + if (authoritative === null) { + // Older clients have no `call` surface. Preserve their CLI rows; callers + // still apply the version-gated metadata policy below. + return cliRows; + } + return parseSourcesListStrict(authoritative); } export interface RemoveDecision { @@ -215,18 +340,20 @@ export interface RemoveDecision { * * 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. + * - ownership metadata is unavailable on a CLI that may support URL sources. + * - the row is user-managed (remote_url set without an authoritative + * managed-clone marker/default path) and gbrain has no --keep-storage. * - * 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. + * Allowed: absent row (no-op), authoritatively gbrain-managed, or path-managed + * without a remote_url. --keep-storage is appended whenever supported. */ 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[]; + /** Override whether this CLI can create URL-managed sources (tests). */ + urlManagedSources?: boolean; } export function decideSourceRemove( @@ -247,9 +374,24 @@ export function decideSourceRemove( const row = rows.find((r) => r.id === sourceId); if (!row) return { allow: true, extraArgs: extra, reason: "source absent (no-op)" }; + if (!row.config || typeof row.config !== "object") { + const mayOwnRemoteClone = opts.urlManagedSources ?? gbrainMaySupportUrlSources(env); + if (!mayOwnRemoteClone) { + return { + allow: true, + extraArgs: extra, + reason: "legacy gbrain predates URL-managed sources; metadata-free row is path-managed", + }; + } + return { + allow: false, + extraArgs: [], + reason: `source "${sourceId}" has no ownership metadata; refusing remove (fail closed)`, + }; + } + const remoteUrl = row.config?.remote_url; - const userManaged = - !!remoteUrl && !!row.local_path && !clonesDirs(env).some((d) => isInside(row.local_path!, d)); + const userManaged = !!remoteUrl && !isOwnedClone(row, env); if (userManaged) { if (keepStorage) { @@ -265,7 +407,7 @@ export function decideSourceRemove( }; } - return { allow: true, extraArgs: extra, reason: "gbrain-managed or path-managed without remote_url" }; + return { allow: true, extraArgs: extra, reason: "authoritatively gbrain-managed or path-managed" }; } export interface SyncDecision { @@ -273,28 +415,53 @@ export interface SyncDecision { reason: string; } +export interface DecideSyncOpts { + /** Override whether this CLI can create URL-managed sources (tests). */ + urlManagedSources?: boolean; +} + /** * Decide whether `sync --strategy code --source ` 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. + * --allow-reclone opt-in for URL-managed sources. A missing config field is not + * evidence that the source is path-managed on gbrain >=0.28: current CLI list + * rows omit ownership config. Only a positively identified pre-0.28 CLI may + * proceed, because that release line has no URL-managed source surface. */ export function decideCodeSync( sourceId: string, env: NodeJS.ProcessEnv = process.env, allowReclone = false, fetchRows: (env: NodeJS.ProcessEnv) => GbrainSourceRow[] = fetchSources, + opts: DecideSyncOpts = {}, ): SyncDecision { let rows: GbrainSourceRow[]; try { rows = fetchRows(env); } catch { - return { allow: true, reason: "sources unreadable; proceeding (sync read is non-destructive)" }; + return allowReclone + ? { allow: true, reason: "sources unreadable; reclone explicitly allowed" } + : { allow: false, reason: "sources unreadable; refusing code sync without --allow-reclone" }; } const row = rows.find((r) => r.id === sourceId); + if (!row) return { allow: true, reason: "source absent (sync will be a no-op/error)" }; + if (!row.config || typeof row.config !== "object") { + const mayOwnRemoteClone = opts.urlManagedSources ?? gbrainMaySupportUrlSources(env); + if (!mayOwnRemoteClone) { + return { + allow: true, + reason: "legacy gbrain predates URL-managed sources; metadata-free row is path-managed", + }; + } + return allowReclone + ? { allow: true, reason: "ownership metadata unavailable; reclone explicitly allowed" } + : { + allow: false, + reason: `source "${sourceId}" has no ownership metadata; re-run with --allow-reclone to proceed`, + }; + } if (row?.config?.remote_url && !allowReclone) { return { allow: false, diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts index 2b6caa780..00478bc92 100644 --- a/lib/gbrain-local-status.ts +++ b/lib/gbrain-local-status.ts @@ -41,7 +41,7 @@ import { } from "fs"; import { homedir } from "os"; import { dirname, join } from "path"; -import { buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec"; +import { buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS, resolveGbrainConfigPath } from "./gbrain-exec"; export type LocalEngineStatus = | "ok" @@ -116,9 +116,7 @@ export function cacheFilePath(): string { /** Honors GBRAIN_HOME (codex D11) — same resolution as buildGbrainEnv. */ function gbrainConfigPath(env?: NodeJS.ProcessEnv): string { - const e = env ?? process.env; - const gbrainHome = e.GBRAIN_HOME || join(userHome(e), ".gbrain"); - return join(gbrainHome, "config.json"); + return resolveGbrainConfigPath(env ?? process.env); } function configuredEngine(env?: NodeJS.ProcessEnv): "pglite" | "postgres" | null { diff --git a/lib/gbrain-sources.ts b/lib/gbrain-sources.ts index cdaa25eaa..373de0484 100644 --- a/lib/gbrain-sources.ts +++ b/lib/gbrain-sources.ts @@ -28,16 +28,23 @@ export interface EnsureResult { } /** - * 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. + * One row of `gbrain sources list --json`. + * + * IMPORTANT: current gbrain CLI list rows intentionally omit `config`; callers + * must distinguish that missing ownership metadata from an authoritative empty + * config object. The #1734 destructive-op guards fail closed when `config` is + * absent instead of silently treating every source as path-managed. */ export interface GbrainSourceRow { id?: string; local_path?: string; page_count?: number; - config?: { remote_url?: string | null } | null; + /** Authoritative MCP `sources_list` field; normalized into config below. */ + remote_url?: string | null; + config?: { + remote_url?: string | null; + managed_clone?: boolean; + } | null; } /** @@ -51,18 +58,67 @@ export interface GbrainSourceRow { * throwing — callers treat "no rows" as absent. */ export function parseSourcesList(raw: unknown): GbrainSourceRow[] { - if (Array.isArray(raw)) return raw as GbrainSourceRow[]; + const normalize = (rows: GbrainSourceRow[]): GbrainSourceRow[] => rows.map((row) => { + if (row.config !== undefined) return row; + if (Object.prototype.hasOwnProperty.call(row, "remote_url")) { + return { + ...row, + config: { remote_url: row.remote_url ?? null }, + }; + } + return row; + }); + if (Array.isArray(raw)) return normalize(raw as GbrainSourceRow[]); if (raw && typeof raw === "object" && Array.isArray((raw as { sources?: unknown }).sources)) { - return (raw as { sources: GbrainSourceRow[] }).sources; + return normalize((raw as { sources: GbrainSourceRow[] }).sources); } return []; } +/** + * Strict variant for destructive decisions. A valid empty array is distinct + * from an unsupported/error envelope: only the former proves that no source + * exists. Every row must at least carry a string id so malformed entries cannot + * turn a registered source into a false "absent" decision. + */ +export function parseSourcesListStrict(raw: unknown): GbrainSourceRow[] { + let rows: unknown; + if (Array.isArray(raw)) { + rows = raw; + } else if ( + raw + && typeof raw === "object" + && Object.prototype.hasOwnProperty.call(raw, "sources") + && Array.isArray((raw as { sources?: unknown }).sources) + ) { + rows = (raw as { sources: unknown[] }).sources; + } else { + throw new Error("unsupported gbrain sources-list JSON shape"); + } + + if (!(rows as unknown[]).every((row) => ( + !!row + && typeof row === "object" + && !Array.isArray(row) + && typeof (row as { id?: unknown }).id === "string" + ))) { + throw new Error("malformed gbrain sources-list row"); + } + return parseSourcesList(rows); +} + export interface EnsureOptions { /** Pass --federated to `gbrain sources add`. Default false. */ federated?: boolean; - /** When status=drift, force a remove+add to update the registered path. Default true. */ + /** When status=drift, request a guarded remove+add to update the registered path. Default true. */ reregister_on_drift?: boolean; + /** + * Required for drift re-registration. The caller owns the destructive-op + * policy (autopilot, ownership metadata, keep-storage capability) and must + * report whether the exact source was safely removed. Missing/refused guards + * fail closed; this helper never issues an unguarded `sources remove`. + */ + guardedRemove?: (id: string) => { removed: boolean; reason?: string }; /** * Optional env override for the spawned `gbrain` calls. Production callers * leave this unset (inherit process.env). Tests pass a custom env to point @@ -124,8 +180,8 @@ export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState { * Behavior: * - status=absent → `gbrain sources add --path [--federated]`, returns changed=true. * - status=match + same path → no-op, returns changed=false. - * - status=match + different path → `sources remove` + `sources add`, returns changed=true. - * (Skip when reregister_on_drift=false; returns changed=false.) + * - status=match + different path → guarded `sources remove` + `sources add`, returns changed=true. + * (Skip when reregister_on_drift=false; fail closed when guardedRemove is missing/refuses.) * * Caller is responsible for catching errors. The function uses withErrorContext for * forensic logging to ~/.gstack/.gbrain-errors.jsonl. @@ -156,16 +212,16 @@ export async function ensureSourceRegistered( return { changed: false, state }; } - // For drift, remove first. + // For drift, remove first — but only through the caller's destructive-op + // guard. A direct `--yes` here used to bypass the autopilot and ownership + // checks used by every other gstack source removal. if (state.status === "drift") { - const rm = spawnSync("gbrain", ["sources", "remove", id, "--yes"], { - encoding: "utf-8", - timeout: 30_000, - env, - shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows - }); - if (rm.status !== 0) { - throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`); + if (!options.guardedRemove) { + throw new Error(`source ${id} path drift requires a guarded remove; refusing re-registration`); + } + const rm = options.guardedRemove(id); + if (!rm.removed) { + throw new Error(`guarded remove of drifted source ${id} refused: ${rm.reason || "unknown reason"}`); } } diff --git a/lib/gstack-memory-helpers.ts b/lib/gstack-memory-helpers.ts index 3a1c563ba..7f26036e5 100644 --- a/lib/gstack-memory-helpers.ts +++ b/lib/gstack-memory-helpers.ts @@ -21,6 +21,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, appendFil import { dirname, join } from "path"; import { execFileSync } from "child_process"; import { homedir } from "os"; +import { resolveGbrainConfigPath } from "./gbrain-exec"; // ── Types ────────────────────────────────────────────────────────────────── @@ -254,13 +255,12 @@ export function detectEngineTier(): EngineDetect { return fresh; } -// Returns gbrain's config.json path, honoring GBRAIN_HOME env var with a -// fallback to ~/.gbrain. gbrain >=0.25 dropped the top-level `engine` field -// from doctor output, so this file is the only reliable source for engine -// detection on that version. See #1415. +// Returns gbrain's active config.json path. Current releases append `.gbrain` +// to GBRAIN_HOME; the shared resolver retains the legacy direct-layout fallback. +// gbrain >=0.25 dropped the top-level `engine` field from doctor output, so this +// file is the only reliable source for engine detection on that version. #1415. function gbrainConfigPath(): string { - const root = process.env.GBRAIN_HOME || join(homedir(), ".gbrain"); - return join(root, "config.json"); + return resolveGbrainConfigPath(process.env); } // Best-effort JSONL append to ~/.gstack/.gbrain-errors.jsonl. Never throws. diff --git a/setup b/setup index 275236cd3..ad0ef5d63 100755 --- a/setup +++ b/setup @@ -749,11 +749,14 @@ create_agents_sidecar() { mkdir -p "$agents_gstack" # Sidecar directories that skills reference at runtime - for asset in bin browse review qa; do + for asset in bin lib browse review qa; do local src="$SOURCE_GSTACK_DIR/$asset" local dst="$agents_gstack/$asset" if [ -d "$src" ] || [ -f "$src" ]; then - if [ -L "$dst" ] || [ ! -e "$dst" ]; then + # Windows installs are real copies, not symlinks. Refresh every managed + # sidecar asset on rerun so newly-added bins and their imported lib/ + # modules cannot stay missing or stale after an upgrade. + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then _link_or_copy "$src" "$dst" fi fi @@ -764,7 +767,10 @@ create_agents_sidecar() { local src="$SOURCE_GSTACK_DIR/$file" local dst="$agents_gstack/$file" if [ -f "$src" ]; then - if [ -L "$dst" ] || [ ! -e "$dst" ]; then + # Managed files follow the same Windows copy-refresh contract as the + # sidecar directories above; otherwise ETHOS.md remains stale forever + # after the first non-symlink install. + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then _link_or_copy "$src" "$dst" fi fi @@ -796,6 +802,9 @@ create_codex_runtime_root() { if [ -d "$gstack_dir/bin" ]; then _link_or_copy "$gstack_dir/bin" "$codex_gstack/bin" fi + if [ -d "$gstack_dir/lib" ]; then + _link_or_copy "$gstack_dir/lib" "$codex_gstack/lib" + fi if [ -d "$gstack_dir/browse/dist" ]; then _link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist" fi @@ -836,6 +845,9 @@ create_factory_runtime_root() { if [ -d "$gstack_dir/bin" ]; then _link_or_copy "$gstack_dir/bin" "$factory_gstack/bin" fi + if [ -d "$gstack_dir/lib" ]; then + _link_or_copy "$gstack_dir/lib" "$factory_gstack/lib" + fi if [ -d "$gstack_dir/browse/dist" ]; then _link_or_copy "$gstack_dir/browse/dist" "$factory_gstack/browse/dist" fi @@ -874,6 +886,9 @@ create_opencode_runtime_root() { if [ -d "$gstack_dir/bin" ]; then _link_or_copy "$gstack_dir/bin" "$opencode_gstack/bin" fi + if [ -d "$gstack_dir/lib" ]; then + _link_or_copy "$gstack_dir/lib" "$opencode_gstack/lib" + fi if [ -d "$gstack_dir/browse/dist" ]; then _link_or_copy "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist" fi @@ -1115,6 +1130,7 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then [ -L "$KIRO_GSTACK" ] && rm -f "$KIRO_GSTACK" mkdir -p "$KIRO_GSTACK" "$KIRO_GSTACK/browse" "$KIRO_GSTACK/gstack-upgrade" "$KIRO_GSTACK/review" _link_or_copy "$SOURCE_GSTACK_DIR/bin" "$KIRO_GSTACK/bin" + _link_or_copy "$SOURCE_GSTACK_DIR/lib" "$KIRO_GSTACK/lib" _link_or_copy "$SOURCE_GSTACK_DIR/browse/dist" "$KIRO_GSTACK/browse/dist" _link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin" # ETHOS.md — referenced by "Search Before Building" in all skill preambles diff --git a/sync-gbrain/SKILL.md b/sync-gbrain/SKILL.md index 02055bb6f..b2474625d 100644 --- a/sync-gbrain/SKILL.md +++ b/sync-gbrain/SKILL.md @@ -1059,29 +1059,19 @@ only that a cycle has run, not that edges exist (a non-code-aware pack reports Capability check (per /plan-eng-review §6): ```bash -SLUG="_capability_check_$$" CAPABILITY_OK=0 -if [ -f ~/.gbrain/config.json ] && \ - gbrain --version 2>/dev/null | grep -q '^gbrain '; then - # Do NOT export GBRAIN_PREPARE here (#1965). gbrain auto-disables prepared - # statements on transaction-mode poolers (port 6543) — forcing them on - # breaks every write with "prepared statement does not exist". Users on a - # session-mode pooler at 6543 can set GBRAIN_PREPARE=true themselves (the - # gbrain banner documents this override). - if echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1; then - # Retry search up to 3 times with 1s delay — under transaction-mode - # pooling the search index may not be visible on the next connection - # immediately after the put. - for _attempt in 1 2 3; do - if gbrain search "ping" 2>/dev/null | grep -q "$SLUG"; then - CAPABILITY_OK=1 - break - fi - sleep 1 - done - fi +# Do NOT export GBRAIN_PREPARE here (#1965). gbrain auto-disables prepared +# statements on transaction-mode poolers (port 6543) — forcing them on breaks +# every write with "prepared statement does not exist". Local engines use a +# unique non-federated source rooted in private gstack state; EXIT/INT/TERM +# cleanup removes only that exact source + owned directory. The helper honors +# relocated GBRAIN_HOME, checks gstack's Autopilot guard before creation and +# again immediately before source removal, and keeps caller TMPDIR out of the +# write path. Thin clients use gbrain's live OAuth scope probe + read-only +# search because delete_page cannot remove a server-side write-through file. +if ~/.claude/skills/gstack/bin/gstack-gbrain-capability-check; then + CAPABILITY_OK=1 fi -gbrain delete "$SLUG" 2>/dev/null || true ``` Then update CLAUDE.md based on capability state: diff --git a/sync-gbrain/SKILL.md.tmpl b/sync-gbrain/SKILL.md.tmpl index aa97f7eb7..23b5490b7 100644 --- a/sync-gbrain/SKILL.md.tmpl +++ b/sync-gbrain/SKILL.md.tmpl @@ -305,29 +305,19 @@ only that a cycle has run, not that edges exist (a non-code-aware pack reports Capability check (per /plan-eng-review §6): ```bash -SLUG="_capability_check_$$" CAPABILITY_OK=0 -if [ -f ~/.gbrain/config.json ] && \ - gbrain --version 2>/dev/null | grep -q '^gbrain '; then - # Do NOT export GBRAIN_PREPARE here (#1965). gbrain auto-disables prepared - # statements on transaction-mode poolers (port 6543) — forcing them on - # breaks every write with "prepared statement does not exist". Users on a - # session-mode pooler at 6543 can set GBRAIN_PREPARE=true themselves (the - # gbrain banner documents this override). - if echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1; then - # Retry search up to 3 times with 1s delay — under transaction-mode - # pooling the search index may not be visible on the next connection - # immediately after the put. - for _attempt in 1 2 3; do - if gbrain search "ping" 2>/dev/null | grep -q "$SLUG"; then - CAPABILITY_OK=1 - break - fi - sleep 1 - done - fi +# Do NOT export GBRAIN_PREPARE here (#1965). gbrain auto-disables prepared +# statements on transaction-mode poolers (port 6543) — forcing them on breaks +# every write with "prepared statement does not exist". Local engines use a +# unique non-federated source rooted in private gstack state; EXIT/INT/TERM +# cleanup removes only that exact source + owned directory. The helper honors +# relocated GBRAIN_HOME, checks gstack's Autopilot guard before creation and +# again immediately before source removal, and keeps caller TMPDIR out of the +# write path. Thin clients use gbrain's live OAuth scope probe + read-only +# search because delete_page cannot remove a server-side write-through file. +if {{BIN_DIR}}/gstack-gbrain-capability-check; then + CAPABILITY_OK=1 fi -gbrain delete "$SLUG" 2>/dev/null || true ``` Then update CLAUDE.md based on capability state: diff --git a/test/bin-windows-bun-import-paths.test.ts b/test/bin-windows-bun-import-paths.test.ts index d051f930e..15937b800 100644 --- a/test/bin-windows-bun-import-paths.test.ts +++ b/test/bin-windows-bun-import-paths.test.ts @@ -20,7 +20,17 @@ import { describe, it, expect } from "bun:test"; import { spawnSync } from "child_process"; -import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "fs"; import { tmpdir } from "os"; import { join } from "path"; @@ -57,11 +67,20 @@ describe("bin/ — Windows bun-import path guard (#1950)", () => { }); it("known-affected bins carry the guard explicitly", () => { - for (const name of ["gstack-learnings-log", "gstack-question-log"]) { + for (const name of [ + "gstack-gbrain-capability-check", + "gstack-learnings-log", + "gstack-question-log", + ]) { const content = readFileSync(join(BIN_DIR, name), "utf-8"); expect(content).toContain("cygpath -m"); } }); + + it("capability temp paths are normalized before POSIX dirname/cd", () => { + const content = readFileSync(join(BIN_DIR, "gstack-gbrain-capability-check"), "utf-8"); + expect(content).toContain('process.platform === "win32" ? created.replaceAll("\\\\", "/")'); + }); }); describe("gstack-learnings-log — behavioral (runs on Windows CI via git-bash)", () => { @@ -117,3 +136,136 @@ describe("gstack-learnings-log — behavioral (runs on Windows CI via git-bash)" } }); }); + +describe("gstack-gbrain-capability-check — behavioral on Windows git-bash", () => { + function fixture() { + const root = mkdtempSync(join(tmpdir(), "gstack-win-gbrain-cap-")); + const fake = join(root, "fake-gbrain"); + const calls = join(root, "calls.log"); + const sourceId = join(root, "source-id"); + const sourcePath = join(root, "source-path"); + const state = join(root, ".gbrain"); + mkdirSync(state); + writeFileSync( + join(state, "config.json"), + JSON.stringify({ engine: "pglite", database_path: join(state, "brain.pglite") }), + ); + writeFileSync(fake, `#!/usr/bin/env bash +set -u +cmd="\${1:-}" +case "$cmd" in + --version) echo 'gbrain 0.42.59.0' ;; + doctor) + printf 'doctor\n' >> "$FIXTURE_CALLS" + printf '%s\n' '{"mode":"thin-client","status":"ok","checks":[{"name":"oauth_client_scopes_probe","status":"ok","detail":{"granted":"read,write","read_ok":true}}]}' + ;; + sources) + case "\${2:-}" in + add) + id="$3"; path=""; shift 3 + while [ "$#" -gt 0 ]; do + if [ "$1" = '--path' ]; then path="$2"; shift 2; else shift; fi + done + printf '%s' "$id" > "$FIXTURE_SOURCE_ID" + printf '%s' "$path" > "$FIXTURE_SOURCE_PATH" + printf 'add:%s\n' "$id" >> "$FIXTURE_CALLS" + ;; + list) + if [ -f "$FIXTURE_SOURCE_ID" ]; then + FIXTURE_JSON_ID="$(cat "$FIXTURE_SOURCE_ID")" \ + FIXTURE_JSON_PATH="$(cat "$FIXTURE_SOURCE_PATH")" \ + "$BUN_BIN" -e 'process.stdout.write(JSON.stringify({sources:[{id:process.env.FIXTURE_JSON_ID,local_path:process.env.FIXTURE_JSON_PATH}]}) + "\\n")' + else + printf '%s\n' '{"sources":[]}' + fi + ;; + remove) + printf 'remove:%s\n' "$3" >> "$FIXTURE_CALLS" + rm -f "$FIXTURE_SOURCE_ID" + ;; + *) exit 9 ;; + esac + ;; + put) + slug="$2"; marker=$(cat); path=$(cat "$FIXTURE_SOURCE_PATH") + [ -n "$marker" ] || exit 14 + printf '%s\n' "$marker" > "$path/$slug.md" + printf '%s' "$slug" > "$FIXTURE_SLUG" + printf 'put:%s\n' "$slug" >> "$FIXTURE_CALLS" + ;; + search) + if [ "\${FIXTURE_MODE:-}" = 'thin' ]; then + printf 'thin-search\n' >> "$FIXTURE_CALLS" + else + printf '[1.0] %s -- probe\n' "$(cat "$FIXTURE_SLUG")" + fi + ;; + *) exit 9 ;; +esac +`); + chmodSync(fake, 0o755); + const env = { + ...process.env, + HOME: root, + USERPROFILE: root, + GBRAIN_BIN: fake, + BUN_BIN: process.execPath, + GBRAIN_HOME: root, + GSTACK_HOME: join(root, "gstack-state"), + GSTACK_GBRAIN_CAPABILITY_RETRY_DELAY_SECONDS: "0", + FIXTURE_CALLS: calls, + FIXTURE_SOURCE_ID: sourceId, + FIXTURE_SOURCE_PATH: sourcePath, + FIXTURE_SLUG: join(root, "slug"), + FIXTURE_MODE: "local", + }; + return { root, calls, sourcePath, env }; + } + + it.skipIf(process.platform !== "win32")( + "runs the local add-put-search-readback-cleanup flow end-to-end on Windows", + () => { + const f = fixture(); + try { + const r = spawnSync("bash", [join(BIN_DIR, "gstack-gbrain-capability-check")], { + encoding: "utf-8", + timeout: 20_000, + cwd: ROOT, + env: f.env, + }); + expect(r.status, r.stderr).toBe(0); + const calls = readFileSync(f.calls, "utf-8"); + expect(calls).toContain("add:"); + expect(calls).toContain("put:"); + expect(calls).toContain("remove:"); + const ownedPath = readFileSync(f.sourcePath, "utf-8"); + expect(existsSync(ownedPath)).toBe(false); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } + }, + ); + + it("runs the thin-client scope plus read probe without a remote write", () => { + const f = fixture(); + try { + const state = join(f.root, ".gbrain"); + writeFileSync(join(state, "config.json"), JSON.stringify({ remote_mcp: { mcp_url: "https://example.invalid/mcp" } })); + f.env.FIXTURE_MODE = "thin"; + const r = spawnSync("bash", [join(BIN_DIR, "gstack-gbrain-capability-check")], { + encoding: "utf-8", + timeout: 20_000, + cwd: ROOT, + env: f.env, + }); + expect(r.status, r.stderr).toBe(0); + const calls = readFileSync(f.calls, "utf-8"); + expect(calls).toContain("doctor"); + expect(calls).toContain("thin-search"); + expect(calls).not.toContain("add:"); + expect(calls).not.toContain("put:"); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/build-gbrain-env.test.ts b/test/build-gbrain-env.test.ts index 46403ba41..89a307ed7 100644 --- a/test/build-gbrain-env.test.ts +++ b/test/build-gbrain-env.test.ts @@ -15,7 +15,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { buildGbrainEnv, isTransactionModePooler } from "../lib/gbrain-exec"; +import { buildConfiguredGbrainEnv, buildGbrainEnv, isTransactionModePooler } from "../lib/gbrain-exec"; describe("buildGbrainEnv", () => { let home: string; @@ -77,17 +77,35 @@ describe("buildGbrainEnv", () => { expect(result.DATABASE_URL).toBe("postgresql://app/db"); }); - it("honors GBRAIN_HOME when set (config aligned with detectEngineTier)", () => { - // Move the config to an alternate dir; set GBRAIN_HOME to point at it. - const altGbrainHome = join(home, "alt-gbrain"); - mkdirSync(altGbrainHome, { recursive: true }); - writeFileSync(join(altGbrainHome, "config.json"), JSON.stringify({ database_url: "postgresql://alt/db" })); + it("honors current GBRAIN_HOME parent semantics", () => { + const altGbrainParent = join(home, "alt-gbrain"); + const currentState = join(altGbrainParent, ".gbrain"); + mkdirSync(currentState, { recursive: true }); + writeFileSync(join(currentState, "config.json"), JSON.stringify({ database_url: "postgresql://alt/db" })); // No file at the default ~/.gbrain location. - const baseEnv = { HOME: home, GBRAIN_HOME: altGbrainHome }; + const baseEnv = { HOME: home, GBRAIN_HOME: altGbrainParent }; const result = buildGbrainEnv({ baseEnv }); expect(result.DATABASE_URL).toBe("postgresql://alt/db"); }); + it("accepts the legacy GBRAIN_HOME-as-state-directory layout", () => { + const legacyState = join(home, "legacy-gbrain"); + mkdirSync(legacyState, { recursive: true }); + writeFileSync(join(legacyState, "config.json"), JSON.stringify({ database_url: "postgresql://legacy/db" })); + const result = buildGbrainEnv({ baseEnv: { HOME: home, GBRAIN_HOME: legacyState } }); + expect(result.DATABASE_URL).toBe("postgresql://legacy/db"); + }); + + it("prefers the current nested config when both layouts exist", () => { + const parent = join(home, "both-layouts"); + const currentState = join(parent, ".gbrain"); + mkdirSync(currentState, { recursive: true }); + writeFileSync(join(parent, "config.json"), JSON.stringify({ database_url: "postgresql://legacy/db" })); + writeFileSync(join(currentState, "config.json"), JSON.stringify({ database_url: "postgresql://current/db" })); + const result = buildGbrainEnv({ baseEnv: { HOME: home, GBRAIN_HOME: parent } }); + expect(result.DATABASE_URL).toBe("postgresql://current/db"); + }); + it("returns a fresh env object — never the caller's env by identity", () => { // Codex review #11: object-identity equality lets later mutation of the // returned env leak back into the caller's view. The helper MUST clone. @@ -100,6 +118,55 @@ describe("buildGbrainEnv", () => { expect(baseEnv.FOO).toBe("bar"); }); + it("strict configured env rejects a missing or malformed active config", () => { + expect(() => buildConfiguredGbrainEnv({ HOME: home, DATABASE_URL: "postgresql://wrong/db" })) + .toThrow("missing or malformed"); + writeFileSync(join(gbrainHome, "config.json"), "{broken"); + expect(() => buildConfiguredGbrainEnv({ HOME: home, DATABASE_URL: "postgresql://wrong/db" })) + .toThrow("missing or malformed"); + }); + + it("strict configured env never trusts a stale direct-layout GBRAIN_HOME config", () => { + const legacyParent = join(home, "strict-legacy"); + mkdirSync(legacyParent); + writeFileSync( + join(legacyParent, "config.json"), + JSON.stringify({ engine: "postgres", database_url: "postgresql://stale/wrong" }), + ); + expect(() => buildConfiguredGbrainEnv({ HOME: home, GBRAIN_HOME: legacyParent })) + .toThrow(join(legacyParent, ".gbrain", "config.json")); + }); + + it("strict configured env clears hostile routing for PGLite and thin clients", () => { + for (const config of [ + { engine: "pglite", database_path: join(gbrainHome, "brain.pglite") }, + { remote_mcp: { mcp_url: "https://brain.example.invalid/mcp" } }, + ]) { + writeFileSync(join(gbrainHome, "config.json"), JSON.stringify(config)); + const result = buildConfiguredGbrainEnv({ + HOME: home, + DATABASE_URL: "postgresql://wrong/db", + GBRAIN_DATABASE_URL: "postgresql://also-wrong/db", + }); + expect(result.DATABASE_URL).toBeUndefined(); + expect(result.GBRAIN_DATABASE_URL).toBeUndefined(); + } + }); + + it("strict configured env pins both Postgres routing names to active config", () => { + writeFileSync( + join(gbrainHome, "config.json"), + JSON.stringify({ engine: "postgres", database_url: "postgresql://gbrain/db" }), + ); + const result = buildConfiguredGbrainEnv({ + HOME: home, + DATABASE_URL: "postgresql://wrong/db", + GBRAIN_DATABASE_URL: "postgresql://also-wrong/db", + }); + expect(result.DATABASE_URL).toBe("postgresql://gbrain/db"); + expect(result.GBRAIN_DATABASE_URL).toBe("postgresql://gbrain/db"); + }); + it("preserves unrelated env vars from the base env", () => { writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: "postgresql://gbrain/db" })); const baseEnv = { HOME: home, PATH: "/usr/bin", FOO: "bar" }; diff --git a/test/gbrain-capability-check.test.ts b/test/gbrain-capability-check.test.ts new file mode 100644 index 000000000..eb6856417 --- /dev/null +++ b/test/gbrain-capability-check.test.ts @@ -0,0 +1,720 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { + chmodSync, + cpSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'fs'; +import { spawn, spawnSync } from 'child_process'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const REPO_ROOT = process.cwd(); +const HELPER = join(REPO_ROOT, 'bin', 'gstack-gbrain-capability-check'); + +let root: string; +let fixtureRepo: string; +let fakeGbrain: string; +let slugFile: string; +let callLog: string; +let sourceIdFile: string; +let sourcePathFile: string; +let putReadyFile: string; +let autopilotActiveFile: string; +let versionCountFile: string; +let markerFile: string; +let fixtureHelper: string; + +function git(args: string[]): string { + const result = spawnSync('git', args, { + cwd: fixtureRepo, + encoding: 'utf8', + }); + if (result.status !== 0) { + throw new Error(result.stderr || `git ${args.join(' ')} failed`); + } + return result.stdout; +} + +function status(): string { + return git(['status', '--short']); +} + +function helperEnv(mode: string): NodeJS.ProcessEnv { + return { + ...process.env, + GBRAIN_BIN: fakeGbrain, + BUN_BIN: process.execPath, + GBRAIN_HOME: root, + GSTACK_HOME: join(root, 'gstack-state'), + GSTACK_GBRAIN_CAPABILITY_RETRY_DELAY_SECONDS: '0', + FIXTURE_MODE: mode, + FIXTURE_REPO: fixtureRepo, + FIXTURE_SLUG_FILE: slugFile, + FIXTURE_CALL_LOG: callLog, + FIXTURE_SOURCE_ID_FILE: sourceIdFile, + FIXTURE_SOURCE_PATH_FILE: sourcePathFile, + FIXTURE_PUT_READY_FILE: putReadyFile, + FIXTURE_AUTOPILOT_ACTIVE_FILE: autopilotActiveFile, + FIXTURE_VERSION_COUNT_FILE: versionCountFile, + FIXTURE_MARKER_FILE: markerFile, + FIXTURE_EXPECT_DATABASE_ROUTING_CLEARED: '1', + FIXTURE_EXPECT_OUTSIDE_PROJECT_CWD: '1', + }; +} + +function runHelper(mode: string) { + return spawnSync(fixtureHelper, [], { + cwd: fixtureRepo, + encoding: 'utf8', + env: helperEnv(mode), + }); +} + +function sourcePath(): string { + return readFileSync(sourcePathFile, 'utf8').trim(); +} + +function generatedPath(): string { + const slug = readFileSync(slugFile, 'utf8').trim(); + return join(sourcePath(), `${slug}.md`); +} + +function writeThinClientConfig(layout: 'current' | 'legacy' = 'current'): void { + if (layout === 'legacy') { + rmSync(join(root, '.gbrain', 'config.json'), { force: true }); + } + const dir = layout === 'current' ? join(root, '.gbrain') : root; + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'config.json'), + JSON.stringify({ + engine: 'postgres', + remote_mcp: { + mcp_url: 'https://brain.example.invalid/mcp', + issuer_url: 'https://brain.example.invalid', + }, + }), + ); +} + +async function waitForFile(path: string, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(path)) return; + await Bun.sleep(20); + } + throw new Error(`timed out waiting for ${path}`); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'gstack-gbrain-capability-test-')); + fixtureRepo = join(root, 'repo'); + mkdirSync(fixtureRepo); + slugFile = join(root, 'slug'); + callLog = join(root, 'calls.log'); + sourceIdFile = join(root, 'source-id'); + sourcePathFile = join(root, 'source-path'); + putReadyFile = join(root, 'put-ready'); + autopilotActiveFile = join(root, 'autopilot-active'); + versionCountFile = join(root, 'version-count'); + markerFile = join(root, 'put-marker'); + fakeGbrain = join(root, 'fake-gbrain'); + mkdirSync(join(root, '.gbrain'), { recursive: true }); + writeFileSync( + join(root, '.gbrain', 'config.json'), + JSON.stringify({ engine: 'pglite', database_path: join(root, '.gbrain', 'brain.pglite') }), + ); + const fixtureInstall = join(root, 'gstack-install'); + const fixtureBin = join(fixtureInstall, 'bin'); + const fixtureLib = join(fixtureInstall, 'lib'); + mkdirSync(fixtureBin, { recursive: true }); + cpSync(join(REPO_ROOT, 'lib'), fixtureLib, { recursive: true }); + fixtureHelper = join(fixtureBin, 'gstack-gbrain-capability-check'); + writeFileSync(fixtureHelper, readFileSync(HELPER)); + chmodSync(fixtureHelper, 0o755); + writeFileSync( + join(fixtureLib, 'gbrain-guards.ts'), + `import { existsSync } from 'fs'; +export function detectAutopilot(env: NodeJS.ProcessEnv) { + return existsSync(env.FIXTURE_AUTOPILOT_ACTIVE_FILE ?? '') + ? { active: true, signal: 'fixture:autopilot' } + : { active: false, signal: null }; +} +export function gbrainSourceRemoveConfirmationArgsForIdentity(identity: string) { + const match = identity.match(/\\b(\\d+)\\.(\\d+)\\.(\\d+)/); + if (!match) return ['--confirm-destructive']; + const version = match.slice(1, 4).map(Number); + return version[0] === 0 && (version[1] < 26 || (version[1] === 26 && version[2] < 5)) + ? ['--yes'] + : ['--confirm-destructive']; +} +`, + ); + writeFileSync( + fakeGbrain, + `#!/usr/bin/env bash +set -u +if [ "\${FIXTURE_EXPECT_DATABASE_ROUTING_CLEARED:-0}" = "1" ] && \ + { [ -n "\${DATABASE_URL:-}" ] || [ -n "\${GBRAIN_DATABASE_URL:-}" ]; }; then + printf 'database-routing-not-cleared\n' >&2 + exit 15 +fi +if [ -n "\${FIXTURE_EXPECTED_DATABASE_URL:-}" ] && \ + { [ "\${DATABASE_URL:-}" != "$FIXTURE_EXPECTED_DATABASE_URL" ] || \ + [ "\${GBRAIN_DATABASE_URL:-}" != "$FIXTURE_EXPECTED_DATABASE_URL" ]; }; then + printf 'wrong-database-url\n' >&2 + exit 15 +fi +if [ "\${FIXTURE_EXPECT_OUTSIDE_PROJECT_CWD:-0}" = "1" ]; then + case "$PWD" in + "$FIXTURE_REPO"|"$FIXTURE_REPO"/*) + printf 'project-cwd-not-isolated\n' >&2 + exit 16 + ;; + esac +fi +cmd="\${1:-}" +if [ "$cmd" = "--version" ]; then + if [ "\${FIXTURE_MODE:-}" = "autopilot-confirmation-race" ]; then + count=0 + [ -f "$FIXTURE_VERSION_COUNT_FILE" ] && count=$(cat "$FIXTURE_VERSION_COUNT_FILE") + count=$((count + 1)) + printf '%s\n' "$count" > "$FIXTURE_VERSION_COUNT_FILE" + if [ "$count" -eq 2 ]; then : > "$FIXTURE_AUTOPILOT_ACTIVE_FILE"; fi + fi + if [ "\${FIXTURE_MODE:-}" = "legacy-confirm-unsupported" ]; then + echo "gbrain 0.25.9" + elif [ "\${FIXTURE_MODE:-}" = "unknown-version" ]; then + echo "gbrain development-build" + else + echo "gbrain 0.42.56.0" + fi + exit 0 +fi +case "$cmd" in + doctor) + printf 'doctor\n' >> "$FIXTURE_CALL_LOG" + if [ "\${FIXTURE_MODE:-}" = "remote-missing-write" ]; then + printf '%s\n' '{"mode":"thin-client","status":"ok","oauth_scope":"read","checks":[{"name":"oauth_client_scopes_probe","status":"ok","detail":{"granted":"read","read_ok":true,"admin_ok":false}}]}' + elif [ "\${FIXTURE_MODE:-}" = "remote-admin-only" ]; then + printf '%s\n' '{"mode":"thin-client","status":"ok","oauth_scope":"admin","checks":[{"name":"oauth_client_scopes_probe","status":"ok","detail":{"granted":"admin","read_ok":true,"admin_ok":true}}]}' + else + printf '%s\n' '{"mode":"thin-client","status":"ok","oauth_scope":"read,write,admin","checks":[{"name":"oauth_client_scopes_probe","status":"ok","detail":{"granted":"read,write,admin","read_ok":true,"admin_ok":true}}]}' + fi + ;; + sources) + sub="\${2:-}" + case "$sub" in + add) + id="$3" + path="" + shift 3 + while [ "$#" -gt 0 ]; do + if [ "$1" = "--path" ]; then path="$2"; shift 2; else shift; fi + done + printf 'sources-add:%s\n' "$id" >> "$FIXTURE_CALL_LOG" + if [ "\${FIXTURE_MODE:-}" = "source-collision" ]; then + printf '%s\n' "$id" > "$FIXTURE_SOURCE_ID_FILE" + printf '%s\n' "$FIXTURE_REPO/pre-existing-source" > "$FIXTURE_SOURCE_PATH_FILE" + exit 6 + fi + printf '%s\n' "$id" > "$FIXTURE_SOURCE_ID_FILE" + printf '%s\n' "$path" > "$FIXTURE_SOURCE_PATH_FILE" + if [ "\${FIXTURE_MODE:-}" = "source-add-late-failure" ]; then exit 7; fi + ;; + list) + if [ "\${FIXTURE_MODE:-}" = "source-list-failure" ]; then exit 13; fi + if [ "\${FIXTURE_MODE:-}" = "source-list-empty-object" ]; then printf '%s\n' '{}'; exit 0; fi + if [ "\${FIXTURE_MODE:-}" = "source-list-null" ]; then printf '%s\n' '{"sources":null}'; exit 0; fi + if [ "\${FIXTURE_MODE:-}" = "source-list-error-envelope" ]; then printf '%s\n' '{"error":"denied"}'; exit 0; fi + if [ -f "$FIXTURE_SOURCE_ID_FILE" ] && [ -f "$FIXTURE_SOURCE_PATH_FILE" ]; then + id=$(cat "$FIXTURE_SOURCE_ID_FILE") + path=$(cat "$FIXTURE_SOURCE_PATH_FILE") + if [ "\${FIXTURE_MODE:-}" = "bare-array-list" ]; then + printf '[{"id":"%s","local_path":"%s"}]\n' "$id" "$path" + else + printf '{"sources":[{"id":"%s","local_path":"%s"}]}\n' "$id" "$path" + fi + else + if [ "\${FIXTURE_MODE:-}" = "bare-array-list" ]; then + printf '%s\n' '[]' + else + printf '%s\n' '{"sources":[]}' + fi + fi + ;; + remove) + id="$3" + printf 'sources-remove:%s:%s\n' "$id" "\${4:-}" >> "$FIXTURE_CALL_LOG" + if [ "\${FIXTURE_MODE:-}" = "legacy-confirm-unsupported" ] && [ "\${4:-}" != "--yes" ]; then exit 2; fi + if [ "\${FIXTURE_MODE:-}" = "unknown-version" ] && [ "\${4:-}" != "--confirm-destructive" ]; then exit 2; fi + if [ "\${FIXTURE_MODE:-}" = "source-remove-failure" ]; then exit 12; fi + rm -f "$FIXTURE_SOURCE_ID_FILE" + ;; + *) exit 9 ;; + esac + ;; + put) + slug="$2" + path=$(cat "$FIXTURE_SOURCE_PATH_FILE") + marker=$(cat) + printf '%s\n' "$marker" > "$FIXTURE_MARKER_FILE" + printf '%s\n' "$slug" > "$FIXTURE_SLUG_FILE" + printf '%s\n' "$marker" > "$path/$slug.md" + printf 'put:%s\n' "$slug" >> "$FIXTURE_CALL_LOG" + if [ "\${FIXTURE_MODE:-}" = "put-block" ]; then + : > "$FIXTURE_PUT_READY_FILE" + while :; do sleep 1; done + fi + if [ "\${FIXTURE_MODE:-}" = "put-failure-no-json" ]; then exit 7; fi + printf '{"slug":"%s","write_through":{"written":true,"path":"%s"}}\n' "$slug" "$path/$slug.md" + ;; + search) + if [ "\${FIXTURE_MODE:-}" = "remote-success" ] || [ "\${FIXTURE_MODE:-}" = "remote-missing-write" ] || [ "\${FIXTURE_MODE:-}" = "remote-admin-only" ]; then + printf 'remote-search\n' >> "$FIXTURE_CALL_LOG" + exit 0 + fi + slug=$(cat "$FIXTURE_SLUG_FILE") + printf 'search:%s\n' "$slug" >> "$FIXTURE_CALL_LOG" + if [ "\${FIXTURE_MODE:-}" = "search-failure" ]; then exit 8; fi + if [ "\${FIXTURE_MODE:-}" = "autopilot-race" ]; then : > "$FIXTURE_AUTOPILOT_ACTIVE_FILE"; fi + printf '[1.0] %s -- probe\n' "$slug" + ;; + *) + exit 9 + ;; +esac +`, + ); + chmodSync(fakeGbrain, 0o755); + + git(['init', '-q']); + git(['config', 'user.email', 'gstack-test@example.invalid']); + git(['config', 'user.name', 'gstack test']); + writeFileSync(join(fixtureRepo, 'tracked.txt'), 'baseline\n'); + git(['add', 'tracked.txt']); + git(['commit', '-qm', 'fixture']); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('gstack-gbrain-capability-check', () => { + test('sync-gbrain generated contract delegates to the isolated helper', () => { + const template = readFileSync(join(REPO_ROOT, 'sync-gbrain', 'SKILL.md.tmpl'), 'utf8'); + const generated = readFileSync(join(REPO_ROOT, 'sync-gbrain', 'SKILL.md'), 'utf8'); + + expect(template).toContain('{{BIN_DIR}}/gstack-gbrain-capability-check'); + expect(generated).toContain('bin/gstack-gbrain-capability-check'); + for (const content of [template, generated]) { + expect(content).not.toContain('gbrain delete "$SLUG"'); + expect(content).not.toContain('gbrain put "$SLUG"'); + } + expect(readFileSync(HELPER, 'utf8')).toContain('detectAutopilot'); + }); + + test('successful local probe uses a private source and restores exact git status', () => { + writeFileSync(join(fixtureRepo, 'tracked.txt'), 'baseline\npre-existing dirty change\n'); + const before = status(); + + const result = runHelper('success'); + + expect(result.status).toBe(0); + expect(status()).toBe(before); + expect(readFileSync(join(fixtureRepo, 'tracked.txt'), 'utf8')).toContain('pre-existing dirty change'); + expect(readFileSync(callLog, 'utf8')).toContain('sources-add:'); + expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:'); + expect(readFileSync(markerFile, 'utf8')).toContain('gstack-capability-probe:_capability_check_'); + expect(existsSync(sourcePath())).toBe(false); + }); + + test('every gbrain command uses the configured database despite inherited and project dotenv conflicts', () => { + const configuredDatabase = 'postgresql://gbrain.invalid:5432/canonical'; + mkdirSync(join(root, '.gbrain'), { recursive: true }); + writeFileSync( + join(root, '.gbrain', 'config.json'), + JSON.stringify({ engine: 'postgres', database_url: configuredDatabase }), + ); + writeFileSync( + join(fixtureRepo, '.env.local'), + 'DATABASE_URL=postgresql://project.invalid:5432/wrong-project\n', + ); + const before = status(); + + const result = spawnSync(fixtureHelper, [], { + cwd: fixtureRepo, + encoding: 'utf8', + env: { + ...helperEnv('success'), + DATABASE_URL: 'postgresql://caller.invalid:5432/wrong-caller', + GBRAIN_DATABASE_URL: 'postgresql://caller.invalid:5432/also-wrong', + FIXTURE_EXPECTED_DATABASE_URL: configuredDatabase, + FIXTURE_EXPECT_DATABASE_ROUTING_CLEARED: '0', + }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:'); + expect(status()).toBe(before); + }); + + test('project dotenv cannot inject GBRAIN_HOME or GSTACK_HOME before isolation', () => { + const actualHome = join(root, 'actual-home'); + const actualState = join(actualHome, '.gbrain'); + const foreignHome = join(root, 'foreign-brain-parent'); + const foreignState = join(foreignHome, '.gbrain'); + mkdirSync(actualState, { recursive: true }); + mkdirSync(foreignState, { recursive: true }); + writeFileSync( + join(actualState, 'config.json'), + JSON.stringify({ engine: 'pglite', database_path: join(actualState, 'brain.pglite') }), + ); + writeFileSync( + join(foreignState, 'config.json'), + JSON.stringify({ engine: 'postgres', database_url: 'postgresql://foreign.invalid/wrong-brain' }), + ); + writeFileSync( + join(fixtureRepo, '.env.local'), + `GBRAIN_HOME=${foreignHome}\nGSTACK_HOME=${join(fixtureRepo, 'dotenv-gstack-state')}\n`, + ); + + const env = { + ...helperEnv('success'), + HOME: actualHome, + }; + delete env.GBRAIN_HOME; + delete env.GSTACK_HOME; + const result = spawnSync(fixtureHelper, [], { + cwd: fixtureRepo, + encoding: 'utf8', + env, + }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:'); + expect(existsSync(join(fixtureRepo, 'dotenv-gstack-state'))).toBe(false); + }); + + test('PGLite probe clears hostile caller routing and leaves project dotenv scope', () => { + writeFileSync( + join(fixtureRepo, '.env.local'), + 'DATABASE_URL=postgresql://project.invalid:5432/wrong-project\n', + ); + const before = status(); + + const result = spawnSync(fixtureHelper, [], { + cwd: fixtureRepo, + encoding: 'utf8', + env: { + ...helperEnv('success'), + DATABASE_URL: 'postgresql://caller.invalid:5432/wrong-caller', + GBRAIN_DATABASE_URL: 'postgresql://caller.invalid:5432/also-wrong', + }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:'); + expect(status()).toBe(before); + }); + + test('missing and malformed active configs fail before any gbrain mutation', () => { + const configPath = join(root, '.gbrain', 'config.json'); + for (const setup of [ + () => rmSync(configPath, { force: true }), + () => writeFileSync(configPath, '{broken'), + ]) { + setup(); + rmSync(callLog, { force: true }); + const result = spawnSync(fixtureHelper, [], { + cwd: fixtureRepo, + encoding: 'utf8', + env: { + ...helperEnv('success'), + DATABASE_URL: 'postgresql://caller.invalid:5432/wrong-caller', + GBRAIN_DATABASE_URL: 'postgresql://caller.invalid:5432/also-wrong', + }, + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('active gbrain config is missing or malformed'); + expect(existsSync(callLog)).toBe(false); + } + }); + + test('external-host bin symlink resolves the physical sibling guard module', () => { + const runtimeRoot = join(root, 'external-runtime'); + mkdirSync(runtimeRoot); + symlinkSync(join(root, 'gstack-install', 'bin'), join(runtimeRoot, 'bin')); + + const result = spawnSync(join(runtimeRoot, 'bin', 'gstack-gbrain-capability-check'), [], { + cwd: fixtureRepo, + encoding: 'utf8', + env: helperEnv('success'), + }); + + expect(result.status).toBe(0); + expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:'); + }); + + test('caller TMPDIR inside the checkout cannot receive probe files', () => { + const result = spawnSync(fixtureHelper, [], { + cwd: fixtureRepo, + encoding: 'utf8', + env: { ...helperEnv('success'), TMPDIR: fixtureRepo }, + }); + + expect(result.status).toBe(0); + expect(sourcePath()).toStartWith(realpathSync(join(root, '.gstack-capability-tmp'))); + expect(sourcePath()).not.toStartWith(fixtureRepo); + expect(status()).toBe(''); + }); + + test('Git-backed GSTACK_HOME keeps probe scratch space outside that repository', () => { + const stateRoot = join(root, 'gstack-state'); + mkdirSync(stateRoot, { recursive: true }); + const init = spawnSync('git', ['init', '-q'], { cwd: stateRoot, encoding: 'utf8' }); + expect(init.status, init.stderr).toBe(0); + + const result = runHelper('success'); + + expect(result.status, result.stderr).toBe(0); + expect(sourcePath()).toStartWith(realpathSync(join(root, '.gstack-capability-tmp'))); + expect(sourcePath()).not.toStartWith(realpathSync(stateRoot)); + expect(spawnSync('git', ['status', '--short'], { cwd: stateRoot, encoding: 'utf8' }).stdout).toBe(''); + }); + + test('GSTACK_HOME inside the checkout is rejected before creating directories', () => { + const nestedState = join(fixtureRepo, 'would-dirty-checkout'); + const before = status(); + const result = spawnSync(fixtureHelper, [], { + cwd: fixtureRepo, + encoding: 'utf8', + env: { ...helperEnv('success'), GSTACK_HOME: nestedState }, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('refusing temp root inside Git worktree'); + expect(existsSync(nestedState)).toBe(false); + expect(status()).toBe(before); + }); + + test('failed search removes the temporary source and preserves dirty files', () => { + writeFileSync(join(fixtureRepo, 'untracked-user-file.txt'), 'keep me\n'); + const before = status(); + + const result = runHelper('search-failure'); + + expect(result.status).not.toBe(0); + expect(status()).toBe(before); + expect(readFileSync(join(fixtureRepo, 'untracked-user-file.txt'), 'utf8')).toBe('keep me\n'); + expect(readFileSync(callLog, 'utf8').match(/search:/g)?.length).toBe(3); + expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:'); + expect(existsSync(sourcePath())).toBe(false); + }); + + test('put failure before JSON still cleans its page, source, and temp directory', () => { + const before = status(); + + const result = runHelper('put-failure-no-json'); + const path = generatedPath(); + + expect(result.status).not.toBe(0); + expect(status()).toBe(before); + expect(existsSync(path)).toBe(false); + expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:'); + }); + + test('late source-add failure is recovered only when id and path match', () => { + const result = runHelper('source-add-late-failure'); + const path = sourcePath(); + + expect(result.status).not.toBe(0); + expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:'); + expect(existsSync(path)).toBe(false); + }); + + test('bare-array source readback still removes the owned source and directory', () => { + const result = runHelper('bare-array-list'); + const path = sourcePath(); + + expect(result.status).toBe(0); + expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:'); + expect(existsSync(path)).toBe(false); + }); + + test('cleanup uses the v0.20 --yes contract only for a positively identified legacy CLI', () => { + const result = runHelper('legacy-confirm-unsupported'); + const path = sourcePath(); + + expect(result.status).toBe(0); + expect(readFileSync(callLog, 'utf8').match(/sources-remove:/g)?.length).toBe(1); + expect(readFileSync(callLog, 'utf8')).toContain(':--yes'); + expect(existsSync(path)).toBe(false); + }); + + test('unknown gbrain identity keeps the current fail-closed remove contract', () => { + const result = runHelper('unknown-version'); + const path = sourcePath(); + + expect(result.status).toBe(0); + expect(readFileSync(callLog, 'utf8')).toContain(':--confirm-destructive'); + expect(existsSync(path)).toBe(false); + }); + + test('active autopilot refuses before any temporary source is created', () => { + writeFileSync(autopilotActiveFile, 'active\n'); + const before = status(); + + const result = runHelper('success'); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('autopilot active'); + expect(status()).toBe(before); + expect(existsSync(callLog)).toBe(false); + }); + + test('autopilot starting during the probe blocks remove and preserves recovery state', () => { + const result = runHelper('autopilot-race'); + const path = sourcePath(); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('autopilot active'); + expect(readFileSync(callLog, 'utf8')).not.toContain('sources-remove:'); + expect(existsSync(path)).toBe(true); + }); + + test('autopilot starting during the confirmation probe blocks remove', () => { + const result = runHelper('autopilot-confirmation-race'); + const path = sourcePath(); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('autopilot active'); + expect(readFileSync(callLog, 'utf8')).not.toContain('sources-remove:'); + expect(existsSync(path)).toBe(true); + }); + + test('source id collision never removes the pre-existing source', () => { + const result = runHelper('source-collision'); + + expect(result.status).not.toBe(0); + expect(readFileSync(callLog, 'utf8')).not.toContain('sources-remove:'); + expect(readFileSync(sourcePathFile, 'utf8')).toContain('pre-existing-source'); + }); + + test('parent-only SIGTERM terminates the active child before isolated cleanup', async () => { + const before = status(); + const child = spawn(fixtureHelper, [], { + cwd: fixtureRepo, + env: helperEnv('put-block'), + stdio: ['ignore', 'pipe', 'pipe'], + }); + + await waitForFile(putReadyFile); + const path = sourcePath(); + process.kill(child.pid ?? 0, 'SIGTERM'); + const exit = await new Promise((resolve) => child.once('exit', resolve)); + + expect(exit).not.toBe(0); + expect(status()).toBe(before); + expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:'); + expect(existsSync(path)).toBe(false); + }); + + test('thin-client probe is read-only and accepts live read plus granted write scope', () => { + writeThinClientConfig(); + const before = status(); + + const result = runHelper('remote-success'); + + expect(result.status).toBe(0); + expect(status()).toBe(before); + const calls = readFileSync(callLog, 'utf8'); + expect(calls).toContain('doctor'); + expect(calls).toContain('remote-search'); + expect(calls).not.toContain('sources-add:'); + expect(calls).not.toContain('put:'); + }); + + test('stale direct-layout thin-client config is rejected before any remote call', () => { + writeThinClientConfig('legacy'); + + const result = runHelper('remote-success'); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain(join(root, '.gbrain', 'config.json')); + expect(existsSync(callLog)).toBe(false); + }); + + test('thin-client probe accepts admin because the gbrain scope lattice implies write', () => { + writeThinClientConfig(); + + const result = runHelper('remote-admin-only'); + + expect(result.status).toBe(0); + const calls = readFileSync(callLog, 'utf8'); + expect(calls).toContain('doctor'); + expect(calls).toContain('remote-search'); + expect(calls).not.toContain('sources-add:'); + expect(calls).not.toContain('put:'); + }); + + test('thin-client probe fails closed when granted write scope is absent', () => { + writeThinClientConfig(); + const before = status(); + + const result = runHelper('remote-missing-write'); + + expect(result.status).not.toBe(0); + expect(status()).toBe(before); + const calls = readFileSync(callLog, 'utf8'); + expect(calls).toContain('doctor'); + expect(calls).not.toContain('remote-search'); + expect(calls).not.toContain('sources-add:'); + expect(calls).not.toContain('put:'); + }); + + test('cleanup failure is surfaced and preserves the recovery directory', () => { + const result = runHelper('source-remove-failure'); + const path = sourcePath(); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('failed to remove owned temporary source'); + expect(existsSync(path)).toBe(true); + }); + + test('source readback failure fails closed and preserves the recovery directory', () => { + const result = runHelper('source-list-failure'); + const path = sourcePath(); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('could not verify temporary source cleanup'); + expect(readFileSync(callLog, 'utf8')).not.toContain('sources-remove:'); + expect(existsSync(path)).toBe(true); + }); + + test('unsupported successful source-list JSON fails closed and preserves recovery state', () => { + for (const mode of [ + 'source-list-empty-object', + 'source-list-null', + 'source-list-error-envelope', + ]) { + rmSync(callLog, { force: true }); + rmSync(sourceIdFile, { force: true }); + rmSync(sourcePathFile, { force: true }); + const result = runHelper(mode); + const path = sourcePath(); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('could not verify temporary source cleanup'); + expect(readFileSync(callLog, 'utf8')).not.toContain('sources-remove:'); + expect(existsSync(path)).toBe(true); + } + }); +}); diff --git a/test/gbrain-detect-install.test.ts b/test/gbrain-detect-install.test.ts index b9c82c155..3e1695908 100644 --- a/test/gbrain-detect-install.test.ts +++ b/test/gbrain-detect-install.test.ts @@ -30,14 +30,18 @@ const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bi let tmpHome: string; let tmpHomeReal: string; -type RunOpts = { env?: Record; cwd?: string }; +type RunOpts = { env?: Record; cwd?: string }; function run(bin: string, args: string[], opts: RunOpts = {}) { - const env = { + const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_HOME: tmpHome, HOME: tmpHomeReal, + BUN_BIN: process.execPath, ...(opts.env || {}), }; + for (const [key, value] of Object.entries(opts.env || {})) { + if (value === undefined) delete env[key]; + } const res = spawnSync(bin, args, { env, cwd: opts.cwd, @@ -197,12 +201,44 @@ describe('gstack-gbrain-install D19 PATH-shadow validation', () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-bin-')); fs.writeFileSync( path.join(binDir, 'gbrain'), - `#!/bin/bash\necho "${version}"\nexit 0\n`, + `#!/bin/bash +case "\${1:-}" in + --version) echo "${version}" ;; + doctor) + if [ -n "\${GBRAIN_TEST_CALL_LOG:-}" ]; then printf 'doctor\\n' >> "$GBRAIN_TEST_CALL_LOG"; fi + if [ -n "\${GBRAIN_TEST_STATE_LOG:-}" ]; then + selected_home=$("\${BUN_BIN}" -e 'process.stdout.write(process.env.GBRAIN_HOME || process.env.HOME || "")') + printf 'doctor|%s|%s\\n' "$PWD" "$selected_home" >> "$GBRAIN_TEST_STATE_LOG" + fi + ;; + sources) ;; +esac +exit 0 +`, { mode: 0o755 } ); return binDir; } + test('validate-only reports its Bun prerequisite instead of failing later', () => { + 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}`, + BUN_BIN: path.join(tmpHome, 'missing-bun'), + }, + }); + expect(r.status).toBe(3); + expect(r.stderr).toContain("required tool"); + expect(r.stderr).toContain('missing-bun'); + } finally { + fs.rmSync(installDir, { recursive: true, force: true }); + fs.rmSync(fakeBin, { recursive: true, force: true }); + } + }); + 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'); @@ -219,6 +255,126 @@ describe('gstack-gbrain-install D19 PATH-shadow validation', () => { } }); + test('runs doctor for the current GBRAIN_HOME/.gbrain/config.json layout', () => { + const installDir = seedInstallDir('0.41.29'); + const fakeBin = seedFakeGbrainBinary('0.41.29'); + const gbrainHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-current-home-')); + const callLog = path.join(gbrainHome, 'calls.log'); + fs.mkdirSync(path.join(gbrainHome, '.gbrain')); + fs.writeFileSync(path.join(gbrainHome, '.gbrain', 'config.json'), '{}'); + try { + const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], { + env: { + PATH: `${fakeBin}:${SAFE_PATH}`, + BUN_BIN: process.execPath, + GBRAIN_HOME: gbrainHome, + GBRAIN_TEST_CALL_LOG: callLog, + }, + }); + expect(r.status, r.stderr).toBe(0); + expect(fs.readFileSync(callLog, 'utf8')).toBe('doctor\n'); + } finally { + fs.rmSync(installDir, { recursive: true, force: true }); + fs.rmSync(fakeBin, { recursive: true, force: true }); + fs.rmSync(gbrainHome, { recursive: true, force: true }); + } + }); + + test('runs doctor for the legacy GBRAIN_HOME/config.json layout', () => { + const installDir = seedInstallDir('0.41.29'); + const fakeBin = seedFakeGbrainBinary('0.41.29'); + const gbrainHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-legacy-home-')); + const callLog = path.join(gbrainHome, 'calls.log'); + fs.writeFileSync(path.join(gbrainHome, 'config.json'), '{}'); + try { + const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], { + env: { + PATH: `${fakeBin}:${SAFE_PATH}`, + BUN_BIN: process.execPath, + GBRAIN_HOME: gbrainHome, + GBRAIN_TEST_CALL_LOG: callLog, + }, + }); + expect(r.status, r.stderr).toBe(0); + expect(fs.readFileSync(callLog, 'utf8')).toBe('doctor\n'); + } finally { + fs.rmSync(installDir, { recursive: true, force: true }); + fs.rmSync(fakeBin, { recursive: true, force: true }); + fs.rmSync(gbrainHome, { recursive: true, force: true }); + } + }); + + test('ignores a project dotenv that points at a foreign configured brain', () => { + const installDir = seedInstallDir('0.41.29'); + const fakeBin = seedFakeGbrainBinary('0.41.29'); + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-install-project-')); + const foreignHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-foreign-home-')); + const callLog = path.join(project, 'calls.log'); + fs.mkdirSync(path.join(foreignHome, '.gbrain')); + fs.writeFileSync(path.join(foreignHome, '.gbrain', 'config.json'), '{}'); + fs.writeFileSync( + path.join(project, '.env.local'), + `GBRAIN_HOME=${foreignHome}\nGSTACK_HOME=${path.join(project, 'foreign-gstack')}\n`, + ); + try { + const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], { + cwd: project, + env: { + PATH: `${fakeBin}:${SAFE_PATH}`, + BUN_BIN: process.execPath, + GBRAIN_HOME: undefined, + GBRAIN_TEST_CALL_LOG: callLog, + }, + }); + expect(r.status, r.stderr).toBe(0); + expect(fs.existsSync(callLog)).toBe(false); + } finally { + fs.rmSync(installDir, { recursive: true, force: true }); + fs.rmSync(fakeBin, { recursive: true, force: true }); + fs.rmSync(project, { recursive: true, force: true }); + fs.rmSync(foreignHome, { recursive: true, force: true }); + } + }); + + test('validates the canonical brain from an isolated cwd when project dotenv has a second config', () => { + const installDir = seedInstallDir('0.41.29'); + const fakeBin = seedFakeGbrainBinary('0.41.29'); + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-install-project-')); + const foreignHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-foreign-home-')); + const stateLog = path.join(project, 'state.log'); + fs.mkdirSync(path.join(tmpHomeReal, '.gbrain')); + fs.writeFileSync(path.join(tmpHomeReal, '.gbrain', 'config.json'), '{}'); + fs.mkdirSync(path.join(foreignHome, '.gbrain')); + fs.writeFileSync(path.join(foreignHome, '.gbrain', 'config.json'), '{}'); + fs.writeFileSync( + path.join(project, '.env.local'), + `GBRAIN_HOME=${foreignHome}\nGSTACK_HOME=${path.join(project, 'foreign-gstack')}\n`, + ); + try { + const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], { + cwd: project, + env: { + PATH: `${fakeBin}:${SAFE_PATH}`, + BUN_BIN: process.execPath, + GBRAIN_HOME: undefined, + GBRAIN_TEST_STATE_LOG: stateLog, + TMPDIR: project, + }, + }); + expect(r.status, r.stderr).toBe(0); + const [kind, probeCwd, selectedHome] = fs.readFileSync(stateLog, 'utf8').trim().split('|'); + expect(kind).toBe('doctor'); + expect(probeCwd).not.toBe(project); + expect(probeCwd.startsWith(`${project}${path.sep}`)).toBe(false); + expect(selectedHome).toBe(tmpHomeReal); + } finally { + fs.rmSync(installDir, { recursive: true, force: true }); + fs.rmSync(fakeBin, { recursive: true, force: true }); + fs.rmSync(project, { recursive: true, force: true }); + fs.rmSync(foreignHome, { 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 fakeBin = seedFakeGbrainBinary('0.18.2'); diff --git a/test/gbrain-guards-windows.test.ts b/test/gbrain-guards-windows.test.ts new file mode 100644 index 000000000..7892b4038 --- /dev/null +++ b/test/gbrain-guards-windows.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { detectAutopilot, gbrainHome } from "../lib/gbrain-guards"; + +const roots: string[] = []; + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "gbrain-guard-win-")); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("gbrain autopilot locks on Windows-safe paths", () => { + test("current GBRAIN_HOME parent semantics resolve to the nested state directory", () => { + const root = tempRoot(); + const state = join(root, ".gbrain"); + mkdirSync(state); + const lock = join(state, "autopilot.lock"); + writeFileSync(lock, String(process.pid)); + + expect(gbrainHome({ ...process.env, GBRAIN_HOME: root })).toBe(state); + const result = detectAutopilot( + { ...process.env, GBRAIN_HOME: root }, + { processRunning: () => false }, + ); + expect(result.active).toBe(true); + expect(result.signal).toContain(lock); + }); + + test("legacy GBRAIN_HOME lock and pid files remain affirmative signals", () => { + for (const name of ["autopilot.lock", "autopilot.pid"]) { + const root = tempRoot(); + const legacy = join(root, name); + writeFileSync(legacy, String(process.pid)); + + const result = detectAutopilot( + { ...process.env, GBRAIN_HOME: root }, + { processRunning: () => false }, + ); + expect(result.active).toBe(true); + expect(result.signal).toContain(legacy); + } + }); +}); diff --git a/test/gbrain-guards.test.ts b/test/gbrain-guards.test.ts index 4ba388f6d..d280f4f8e 100644 --- a/test/gbrain-guards.test.ts +++ b/test/gbrain-guards.test.ts @@ -2,22 +2,225 @@ import { describe, test, expect, afterEach } from "bun:test"; import * as fs from "fs"; import * as os from "os"; import { join } from "path"; +import { spawnSync } from "child_process"; +import { pathToFileURL } from "url"; import { detectAutopilot, decideSourceRemove, decideCodeSync, isInside, + gbrainHome, + gbrainSourceRemoveConfirmationArgs, _resetCapabilityMemo, type GbrainSourceRow, } from "../lib/gbrain-guards"; +import { + guardCodeSyncBeforeWalk, + safeSourcesRemove, +} from "../bin/gstack-gbrain-sync"; const HOME = os.homedir(); const clonesPath = (name: string) => join(HOME, ".gbrain", "clones", name); afterEach(() => _resetCapabilityMemo()); +describe("fetchSources global routing", () => { + test("stale env and dotfile pins cannot block authoritative metadata discovery", () => { + const root = fs.mkdtempSync(join(os.tmpdir(), "gbrain-global-sources-")); + const bin = join(root, "bin"); + const cwd = join(root, "repo"); + const log = join(root, "calls.log"); + fs.mkdirSync(bin); + fs.mkdirSync(cwd); + fs.writeFileSync(join(cwd, ".gbrain-source"), "also-stale\n"); + const fake = join(bin, "gbrain"); + fs.writeFileSync(fake, `#!/bin/sh +printf '%s|%s\n' "\${GBRAIN_SOURCE:-}" "$*" >> "${log}" +[ -z "\${GBRAIN_SOURCE:-}" ] || exit 31 +if [ "$1 $2 $3" = "sources list --json" ]; then + printf '%s\n' '{"sources":[{"id":"good-source","local_path":"/repo"}]}' + exit 0 +fi +if [ "$1" = "call" ] && [ "$2" = "--source" ] && [ "$3" = "good-source" ] && [ "$4" = "sources_list" ]; then + printf '%s\n' '{"sources":[{"id":"good-source","local_path":"/repo","remote_url":null}]}' + exit 0 +fi +exit 32 +`, { mode: 0o755 }); + const moduleUrl = pathToFileURL(join(import.meta.dir, "..", "lib", "gbrain-guards.ts")).href; + const runner = ` + const { fetchSources } = await import(${JSON.stringify(moduleUrl)}); + process.stdout.write(JSON.stringify(fetchSources(process.env))); + `; + try { + const result = spawnSync(process.execPath, ["-e", runner], { + cwd, + encoding: "utf8", + env: { + ...process.env, + HOME: root, + GBRAIN_HOME: root, + GBRAIN_SOURCE: "stale-source", + PATH: `${bin}:${process.env.PATH ?? ""}`, + }, + }); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)[0]?.config?.remote_url).toBeNull(); + const calls = fs.readFileSync(log, "utf8"); + expect(calls).toContain("|sources list --json"); + expect(calls).toContain("|call --source good-source sources_list {}"); + expect(calls).not.toContain("stale-source|"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("unsupported successful metadata JSON fails closed", () => { + const root = fs.mkdtempSync(join(os.tmpdir(), "gbrain-global-shape-")); + const bin = join(root, "bin"); + fs.mkdirSync(bin); + const fake = join(bin, "gbrain"); + fs.writeFileSync(fake, `#!/bin/sh +if [ "$1 $2 $3" = "sources list --json" ]; then + printf '%s\n' '{"sources":[{"id":"good-source","local_path":"/repo"}]}' + exit 0 +fi +if [ "$1" = "call" ]; then + printf '%s\n' "$FIXTURE_AUTHORITATIVE_JSON" + exit 0 +fi +exit 32 +`, { mode: 0o755 }); + const moduleUrl = pathToFileURL(join(import.meta.dir, "..", "lib", "gbrain-guards.ts")).href; + const runner = ` + const { fetchSources } = await import(${JSON.stringify(moduleUrl)}); + try { fetchSources(process.env); process.exit(0); } + catch (error) { console.error(String(error)); process.exit(4); } + `; + try { + for (const raw of ["{}", '{"sources":null}', '{"error":"denied"}']) { + const result = spawnSync(process.execPath, ["-e", runner], { + encoding: "utf8", + env: { + ...process.env, + HOME: root, + GBRAIN_HOME: root, + PATH: `${bin}:${process.env.PATH ?? ""}`, + FIXTURE_AUTHORITATIVE_JSON: raw, + }, + }); + expect(result.status).toBe(4); + expect(result.stderr).toContain("unsupported gbrain sources-list JSON shape"); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("gbrain 0.30 uses the pre-0.31.8 global call and keeps ownership metadata", () => { + const root = fs.mkdtempSync(join(os.tmpdir(), "gbrain-global-030-")); + const bin = join(root, "bin"); + const log = join(root, "calls.log"); + fs.mkdirSync(bin); + const fake = join(bin, "gbrain"); + fs.writeFileSync(fake, `#!/bin/sh +printf '%s\n' "$*" >> "${log}" +if [ "$1" = "--version" ]; then + printf '%s\n' 'gbrain 0.30.4' + exit 0 +fi +if [ "$1 $2 $3" = "sources list --json" ]; then + printf '%s\n' '{"sources":[{"id":"old-source","local_path":"/repo"}]}' + exit 0 +fi +if [ "$1 $2" = "call sources_list" ]; then + printf '%s\n' '{"sources":[{"id":"old-source","local_path":"/repo","remote_url":null}]}' + exit 0 +fi +exit 32 +`, { mode: 0o755 }); + const moduleUrl = pathToFileURL(join(import.meta.dir, "..", "lib", "gbrain-guards.ts")).href; + const runner = ` + const { fetchSources } = await import(${JSON.stringify(moduleUrl)}); + process.stdout.write(JSON.stringify(fetchSources(process.env))); + `; + try { + const result = spawnSync(process.execPath, ["-e", runner], { + encoding: "utf8", + env: { + ...process.env, + HOME: root, + GBRAIN_HOME: root, + PATH: `${bin}:${process.env.PATH ?? ""}`, + }, + }); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)[0]?.config?.remote_url).toBeNull(); + const calls = fs.readFileSync(log, "utf8"); + expect(calls).toContain("call sources_list {}"); + expect(calls).not.toContain("call --source"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + // ── #1734 autopilot detection (E1: affirmative multi-signal) ──────────────── describe("detectAutopilot", () => { + test("uses the supplied HOME instead of inspecting the caller's real home", () => { + const root = fs.mkdtempSync(join(os.tmpdir(), "ap-env-home-")); + const state = join(root, ".gbrain"); + fs.mkdirSync(state); + const lock = join(state, "autopilot.lock"); + fs.writeFileSync(lock, String(process.pid)); + try { + const env = { ...process.env, HOME: root, GBRAIN_HOME: "" }; + expect(gbrainHome(env)).toBe(state); + const r = detectAutopilot(env, { processRunning: () => false }); + expect(r.active).toBe(true); + expect(r.signal).toContain(lock); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("uses current GBRAIN_HOME parent semantics for the canonical lock", () => { + const root = fs.mkdtempSync(join(os.tmpdir(), "ap-home-")); + const state = join(root, ".gbrain"); + fs.mkdirSync(state); + const lock = join(state, "autopilot.lock"); + fs.writeFileSync(lock, String(process.pid)); + try { + expect(gbrainHome({ ...process.env, GBRAIN_HOME: root })).toBe(state); + const r = detectAutopilot( + { ...process.env, GBRAIN_HOME: root }, + { processRunning: () => false }, + ); + expect(r.active).toBe(true); + expect(r.signal).toContain(lock); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("detects legacy GBRAIN_HOME lock and pid paths", () => { + for (const name of ["autopilot.lock", "autopilot.pid"]) { + const root = fs.mkdtempSync(join(os.tmpdir(), "ap-legacy-")); + const legacy = join(root, name); + fs.writeFileSync(legacy, String(process.pid)); + try { + const r = detectAutopilot( + { ...process.env, GBRAIN_HOME: root }, + { processRunning: () => false }, + ); + expect(r.active).toBe(true); + expect(r.signal).toContain(legacy); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + } + }); + test("refuses on a present lock file (secondary signal)", () => { const tmp = fs.mkdtempSync(join(os.tmpdir(), "ap-")); const lock = join(tmp, "autopilot.lock"); @@ -92,9 +295,9 @@ describe("detectAutopilot", () => { // ── #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: "gbrain-managed", local_path: clonesPath("gbrain-managed"), 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 + { id: "path-managed", local_path: "/tmp/path-repo", config: {} }, // authoritative no remote_url ...extra, ]; const fetchRows = (extra?: GbrainSourceRow[]) => () => rows(extra); @@ -118,7 +321,8 @@ describe("decideSourceRemove", () => { }); test("gbrain-managed (inside clones) → allow even without keep-storage", () => { - const d = decideSourceRemove("gbrain-managed", process.env, { keepStorage: false, fetchRows: fetchRows() }); + const env = { ...process.env, HOME, GBRAIN_HOME: "" }; + const d = decideSourceRemove("gbrain-managed", env, { keepStorage: false, fetchRows: fetchRows() }); expect(d.allow).toBe(true); }); @@ -127,6 +331,49 @@ describe("decideSourceRemove", () => { expect(d.allow).toBe(true); }); + test("CLI row without ownership metadata → FAIL CLOSED", () => { + const d = decideSourceRemove("metadata-omitted", process.env, { + keepStorage: false, + fetchRows: () => [{ id: "metadata-omitted", local_path: "/tmp/path-repo" }], + }); + expect(d.allow).toBe(false); + expect(d.reason).toContain("no ownership metadata"); + }); + + test("gbrain before 0.28 safely allows metadata-free path-managed rows", () => { + const d = decideSourceRemove("metadata-omitted", process.env, { + keepStorage: false, + urlManagedSources: false, + fetchRows: () => [{ id: "metadata-omitted", local_path: "/tmp/path-repo" }], + }); + expect(d.allow).toBe(true); + expect(d.reason).toContain("predates URL-managed sources"); + }); + + test("a user checkout merely nested under legacy GBRAIN_HOME/clones is not owned", () => { + const root = fs.mkdtempSync(join(os.tmpdir(), "gbrain-parent-")); + const userCheckout = join(root, "clones", "nested-user-checkout"); + fs.mkdirSync(userCheckout, { recursive: true }); + try { + const d = decideSourceRemove( + "remote-source", + { ...process.env, GBRAIN_HOME: root }, + { + keepStorage: false, + fetchRows: () => [{ + id: "remote-source", + local_path: userCheckout, + config: { remote_url: "https://x/r.git" }, + }], + }, + ); + expect(d.allow).toBe(false); + expect(d.reason).toContain("user-managed"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + test("sources unreadable → FAIL CLOSED", () => { const d = decideSourceRemove("user-managed", process.env, { keepStorage: false, @@ -137,11 +384,43 @@ describe("decideSourceRemove", () => { }); }); +describe("gbrain source-remove confirmation contract", () => { + function withFakeVersion(versionOutput: string, run: (env: NodeJS.ProcessEnv) => void): void { + const root = fs.mkdtempSync(join(os.tmpdir(), "gbrain-version-")); + const fake = join(root, "gbrain"); + fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' '${versionOutput}'\n`, { mode: 0o755 }); + try { + run({ ...process.env, PATH: `${root}:${process.env.PATH ?? ""}` }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + } + + test("positively identified pre-0.26.5 CLI uses legacy --yes", () => { + withFakeVersion("gbrain 0.25.9", (env) => { + expect(gbrainSourceRemoveConfirmationArgs(env)).toEqual(["--yes"]); + }); + }); + + test("0.26.5 and newer CLI uses --confirm-destructive", () => { + withFakeVersion("gbrain 0.26.5", (env) => { + expect(gbrainSourceRemoveConfirmationArgs(env)).toEqual(["--confirm-destructive"]); + }); + }); + + test("unknown CLI identity stays on the current fail-closed contract", () => { + withFakeVersion("development-build", (env) => { + expect(gbrainSourceRemoveConfirmationArgs(env)).toEqual(["--confirm-destructive"]); + }); + }); +}); + // ── #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" }, + { id: "plain", local_path: "/tmp/p", config: {} }, + { id: "metadata-omitted", local_path: "/tmp/unknown" }, ]; const fetch = () => rows; @@ -161,9 +440,135 @@ describe("decideCodeSync", () => { expect(d.allow).toBe(true); }); - test("sources unreadable → fail OPEN (sync read is non-destructive)", () => { + test("sources unreadable → fail CLOSED unless reclone is explicitly allowed", () => { const d = decideCodeSync("url-managed", process.env, false, () => { throw new Error("boom"); }); - expect(d.allow).toBe(true); + expect(d.allow).toBe(false); + expect(d.reason).toContain("--allow-reclone"); + expect(decideCodeSync("url-managed", process.env, true, () => { throw new Error("boom"); }).allow).toBe(true); + }); + + test("CLI row without ownership metadata requires explicit reclone opt-in", () => { + const refused = decideCodeSync("metadata-omitted", process.env, false, fetch); + expect(refused.allow).toBe(false); + expect(refused.reason).toContain("no ownership metadata"); + expect(decideCodeSync("metadata-omitted", process.env, true, fetch).allow).toBe(true); + }); + + test("gbrain before 0.28 keeps normal metadata-free code sync compatible", () => { + const allowed = decideCodeSync( + "metadata-omitted", + process.env, + false, + fetch, + { urlManagedSources: false }, + ); + expect(allowed.allow).toBe(true); + expect(allowed.reason).toContain("predates URL-managed sources"); + }); +}); + +describe("destructive spawn race gates", () => { + function withAutopilotStartingDuringProbe( + run: (env: NodeJS.ProcessEnv, log: string) => void, + startAutopilot = true, + ): void { + const root = fs.mkdtempSync(join(os.tmpdir(), "gbrain-autopilot-race-")); + const bin = join(root, "bin"); + const state = join(root, ".gbrain"); + const lock = join(state, "autopilot.lock"); + const trigger = join(root, "triggered"); + const log = join(root, "calls.log"); + fs.mkdirSync(bin); + fs.mkdirSync(state); + const fake = join(bin, "gbrain"); + fs.writeFileSync(fake, `#!/bin/sh +printf '%s\n' "$*" >> "${log}" +if [ "$START_AUTOPILOT" = "1" ] && [ ! -f "${trigger}" ]; then + printf '%s\n' "$LOCK_PID" > "${lock}" + : > "${trigger}" +fi +if [ "$1" = "--version" ]; then + printf '%s\n' 'gbrain 0.42.59.0' + exit 0 +fi +if [ "$1 $2 $3" = "sources remove --help" ] || [ "$1" = "--help" ]; then + exit 0 +fi +if [ "$1 $2 $3" = "sources list --json" ]; then + printf '%s\n' '{"sources":[{"id":"race-source","local_path":"/repo"}]}' + exit 0 +fi +if [ "$1" = "call" ]; then + printf '%s\n' '{"sources":[{"id":"race-source","local_path":"/repo","remote_url":null}]}' + exit 0 +fi +if [ "$1 $2 $3" = "sources remove race-source" ]; then + printf '%s\n' 'DESTRUCTIVE_REMOVE' >> "${log}" + exit 0 +fi +exit 32 +`, { mode: 0o755 }); + try { + run( + { + ...process.env, + HOME: root, + GBRAIN_HOME: root, + PATH: `${bin}:${process.env.PATH ?? ""}`, + LOCK_PID: String(process.pid), + START_AUTOPILOT: startAutopilot ? "1" : "0", + }, + log, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + } + + test("source removal rechecks Autopilot after metadata probes", () => { + withAutopilotStartingDuringProbe((env, log) => { + const lock = join(env.GBRAIN_HOME ?? "", ".gbrain", "autopilot.lock"); + const detector = () => fs.existsSync(lock) + ? { active: true, signal: `lock:${lock}` } + : { active: false, signal: null }; + const result = safeSourcesRemove("race-source", env, { detectAutopilot: detector }); + expect(result.removed).toBe(false); + expect(result.skipped).toBe(true); + expect(result.reason).toContain("autopilot active"); + expect(fs.readFileSync(log, "utf8")).not.toContain("DESTRUCTIVE_REMOVE"); + }); + }); + + test("source removal performs no gbrain probe after the final Autopilot check", () => { + withAutopilotStartingDuringProbe((env, log) => { + const detector = () => { + fs.appendFileSync(log, "AUTOPILOT_CHECK\n"); + return { active: false, signal: null }; + }; + const result = safeSourcesRemove("race-source", env, { detectAutopilot: detector }); + + expect(result.removed).toBe(true); + expect(result.skipped).toBe(false); + const calls = fs.readFileSync(log, "utf8").trim().split("\n"); + const removeIndex = calls.findIndex((line) => line.startsWith("sources remove race-source")); + expect(removeIndex).toBeGreaterThan(0); + expect(calls[removeIndex - 1]).toBe("AUTOPILOT_CHECK"); + }, false); + }); + + test("code walk rechecks Autopilot after ownership probes", () => { + withAutopilotStartingDuringProbe((env) => { + const lock = join(env.GBRAIN_HOME ?? "", ".gbrain", "autopilot.lock"); + const detector = () => fs.existsSync(lock) + ? { active: true, signal: `lock:${lock}` } + : { active: false, signal: null }; + const result = guardCodeSyncBeforeWalk("race-source", env, false, { + detectAutopilot: detector, + }); + expect(result.allow).toBe(false); + expect(result.status).toBe("refused-autopilot"); + expect(result.reason).toContain("autopilot active"); + }); }); }); diff --git a/test/gbrain-local-status.test.ts b/test/gbrain-local-status.test.ts index 0d5a44539..9f9da0272 100644 --- a/test/gbrain-local-status.test.ts +++ b/test/gbrain-local-status.test.ts @@ -255,13 +255,13 @@ describe("lib/gbrain-local-status — status classification", () => { }); it("honors GBRAIN_HOME for config detection (codex D11)", () => { - // Config lives ONLY at the alternate GBRAIN_HOME; ~/.gbrain has none. + // Current gbrain appends .gbrain to GBRAIN_HOME; ~/.gbrain has none. env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false }); restoreEnv = applyEnv(env); const altHome = join(env.tmp, "alt-gbrain"); - mkdirSync(altHome, { recursive: true }); + mkdirSync(join(altHome, ".gbrain"), { recursive: true }); writeFileSync( - join(altHome, "config.json"), + join(altHome, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }), ); // Without GBRAIN_HOME: misclassified as missing-config. @@ -270,6 +270,20 @@ describe("lib/gbrain-local-status — status classification", () => { process.env.GBRAIN_HOME = altHome; expect(localEngineStatus({ noCache: true })).toBe("ok"); }); + + it("retains the legacy direct GBRAIN_HOME config fallback", () => { + env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false }); + restoreEnv = applyEnv(env); + const altHome = join(env.tmp, "legacy-gbrain"); + mkdirSync(altHome, { recursive: true }); + writeFileSync( + join(altHome, "config.json"), + JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }), + ); + + process.env.GBRAIN_HOME = altHome; + expect(localEngineStatus({ noCache: true })).toBe("ok"); + }); }); describe("gstack-gbrain-detect --is-ok — timeout is usable (eng review D1)", () => { diff --git a/test/gbrain-sources.test.ts b/test/gbrain-sources.test.ts index 9486eb06c..042a56b14 100644 --- a/test/gbrain-sources.test.ts +++ b/test/gbrain-sources.test.ts @@ -8,11 +8,48 @@ */ import { describe, it, expect } from "bun:test"; +import { spawnSync } from "child_process"; import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, chmodSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { ensureSourceRegistered, probeSource, sourcePageCount } from "../lib/gbrain-sources"; +import { + ensureSourceRegistered, + parseSourcesList, + parseSourcesListStrict, + probeSource, + sourcePageCount, +} from "../lib/gbrain-sources"; + +describe("parseSourcesList ownership metadata", () => { + it("normalizes authoritative MCP remote_url fields into guard config", () => { + const [managed, pathManaged] = parseSourcesList({ + sources: [ + { id: "managed", local_path: "/clone", remote_url: "https://example.invalid/repo.git" }, + { id: "path", local_path: "/checkout", remote_url: null }, + ], + }); + expect(managed.config?.remote_url).toBe("https://example.invalid/repo.git"); + expect(pathManaged.config?.remote_url).toBeNull(); + }); + + it("keeps CLI rows without remote_url distinguishable as metadata-unknown", () => { + const [row] = parseSourcesList({ sources: [{ id: "unknown", local_path: "/checkout" }] }); + expect(row.config).toBeUndefined(); + }); + + it("strict parsing accepts only the documented array shapes, including empty", () => { + expect(parseSourcesListStrict([])).toEqual([]); + expect(parseSourcesListStrict({ sources: [] })).toEqual([]); + expect(parseSourcesListStrict({ sources: [{ id: "ok", local_path: "/x" }] })[0]?.id).toBe("ok"); + }); + + it("strict parsing rejects valid JSON that cannot prove source absence", () => { + for (const raw of [{}, { sources: null }, { error: "denied" }, { sources: [{}] }]) { + expect(() => parseSourcesListStrict(raw)).toThrow(); + } + }); +}); interface FakeGbrainSetup { bindir: string; @@ -162,6 +199,14 @@ describe("ensureSourceRegistered", () => { const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", { federated: true, env: fake.env, + guardedRemove: (id) => { + const rm = spawnSync("gbrain", ["sources", "remove", id, "--yes"], { + encoding: "utf-8", + env: fake.env, + shell: process.platform === "win32", + }); + return { removed: rm.status === 0, reason: rm.stderr || rm.stdout || undefined }; + }, }); expect(result.changed).toBe(true); expect(result.state.status).toBe("match"); @@ -173,6 +218,21 @@ describe("ensureSourceRegistered", () => { fake.cleanup(); }); + it("fails closed on path drift when no guarded remover is provided", async () => { + const fake = makeFakeGbrain({ + sources: [{ id: "gstack-code-foo", local_path: "/old/path" }], + }); + + await expect(ensureSourceRegistered("gstack-code-foo", "/new/path", { + env: fake.env, + })).rejects.toThrow("requires a guarded remove"); + + const log = readFileSync(fake.logPath, "utf-8"); + expect(log).not.toContain("sources remove"); + expect(log).not.toContain("sources add"); + fake.cleanup(); + }); + it("when reregister_on_drift=false and source is at different path, returns changed=false", async () => { const fake = makeFakeGbrain({ sources: [{ id: "gstack-code-foo", local_path: "/old/path" }], diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 2fb783ffd..476aa0748 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2424,11 +2424,12 @@ describe('setup script validation', () => { }); test('create_agents_sidecar links runtime assets', () => { - // Sidecar must link bin, browse, review, qa + // Sidecar must link the executable plus its imported runtime libraries. const fnStart = setupContent.indexOf('create_agents_sidecar()'); const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', fnStart)); const fnBody = setupContent.slice(fnStart, fnEnd); expect(fnBody).toContain('bin'); + expect(fnBody).toContain('lib'); expect(fnBody).toContain('browse'); expect(fnBody).toContain('review'); expect(fnBody).toContain('qa'); @@ -2439,6 +2440,7 @@ describe('setup script validation', () => { const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', setupContent.indexOf('review/', fnStart))); const fnBody = setupContent.slice(fnStart, fnEnd); expect(fnBody).toContain('gstack/SKILL.md'); + expect(fnBody).toContain('lib'); expect(fnBody).toContain('browse/dist'); expect(fnBody).toContain('browse/bin'); expect(fnBody).toContain('gstack-upgrade/SKILL.md'); diff --git a/test/host-config.test.ts b/test/host-config.test.ts index 1c939d7be..9fa5f018e 100644 --- a/test/host-config.test.ts +++ b/test/host-config.test.ts @@ -484,6 +484,7 @@ describe('host config correctness', () => { test('codex has sidecar config', () => { expect(codex.sidecar).toBeDefined(); expect(codex.sidecar!.path).toBe('.agents/skills/gstack'); + expect(codex.sidecar!.symlinks).toContain('lib'); }); test('factory has tool rewrites', () => { @@ -559,6 +560,7 @@ describe('host config correctness', () => { for (const config of ALL_HOST_CONFIGS) { expect(config.runtimeRoot.globalSymlinks.length).toBeGreaterThan(0); expect(config.runtimeRoot.globalSymlinks).toContain('bin'); + expect(config.runtimeRoot.globalSymlinks).toContain('lib'); expect(config.runtimeRoot.globalSymlinks).toContain('ETHOS.md'); } }); diff --git a/test/setup-windows-fallback.test.ts b/test/setup-windows-fallback.test.ts index cc04fbbd7..daf6b1279 100644 --- a/test/setup-windows-fallback.test.ts +++ b/test/setup-windows-fallback.test.ts @@ -17,6 +17,13 @@ function extractHelper(): string { return SETUP_SRC.slice(start, end + 2); } +function extractFunction(name: string): string { + const start = SETUP_SRC.indexOf(`${name}() {`); + const end = SETUP_SRC.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`); + return SETUP_SRC.slice(start, end + 2); +} + describe('setup: _link_or_copy invariant (D7)', () => { test('helper function is defined near the top of setup', () => { expect(SETUP_SRC).toContain('_link_or_copy() {'); @@ -55,6 +62,52 @@ describe('setup: _link_or_copy invariant (D7)', () => { const fnBody = SETUP_SRC.slice(fnStart, fnEnd); expect(fnBody).toContain('_print_windows_copy_note_once'); }); + + test('repo-local sidecars refresh existing Windows copies after upgrades', () => { + const fnStart = SETUP_SRC.indexOf('create_agents_sidecar() {'); + const fnEnd = SETUP_SRC.indexOf('\n}\n', fnStart); + const fnBody = SETUP_SRC.slice(fnStart, fnEnd); + expect(fnBody).toContain('for asset in bin lib browse review qa'); + expect(fnBody).toContain('for file in ETHOS.md'); + expect(fnBody).toContain('[ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]'); + }); + + test('Windows sidecar upgrade refreshes copied directories and ETHOS.md behaviorally', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-sidecar-upgrade-')); + try { + const source = path.join(tmp, 'source'); + const repo = path.join(tmp, 'repo'); + const sidecar = path.join(repo, '.agents', 'skills', 'gstack'); + fs.mkdirSync(path.join(source, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(source, 'bin', 'version.txt'), 'new-bin\n'); + fs.writeFileSync(path.join(source, 'ETHOS.md'), 'new-ethos\n'); + fs.mkdirSync(path.join(sidecar, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(sidecar, 'bin', 'version.txt'), 'old-bin\n'); + fs.writeFileSync(path.join(sidecar, 'ETHOS.md'), 'old-ethos\n'); + + const script = [ + 'IS_WINDOWS=1', + extractHelper(), + extractFunction('create_agents_sidecar'), + 'create_agents_sidecar "$FIXTURE_REPO_ROOT"', + ].join('\n'); + const result = spawnSync('bash', ['-c', script], { + encoding: 'utf-8', + timeout: 5000, + env: { + ...process.env, + SOURCE_GSTACK_DIR: source, + FIXTURE_REPO_ROOT: repo, + }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(path.join(sidecar, 'bin', 'version.txt'), 'utf8')).toBe('new-bin\n'); + expect(fs.readFileSync(path.join(sidecar, 'ETHOS.md'), 'utf8')).toBe('new-ethos\n'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); }); // Behavior matrix uses Unix `ln -snf` semantics in the IS_WINDOWS=0 cells.