fix(gbrain): harden capability and sync guards

This commit is contained in:
Oliver Nagel 2026-07-15 18:27:04 +02:00
parent a3259400a3
commit 3a0e986917
35 changed files with 2732 additions and 187 deletions

View File

@ -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 \

View File

@ -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<url>" 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

View File

@ -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 {

View File

@ -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 <sha> 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

View File

@ -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 <id> --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<StageResult> {
// 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<StageResult> {
// - 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",
},
};
}

View File

@ -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'] },
},

View File

@ -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'],
},

View File

@ -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: {

View File

@ -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'],
},

View File

@ -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'],
},

View File

@ -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'],
},

View File

@ -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'],
},

View File

@ -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'],
},

View File

@ -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'],
},

View File

@ -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'],
},

View File

@ -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'],
},

View File

@ -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;

View File

@ -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 <id>` is safe to run.
*
* A source with a remote_url can trigger gbrain's auto-reclone, the ungated
* rm-rf path behind the data loss (gbrain #1526). Require an explicit
* --allow-reclone opt-in for URL-managed sources. Read failure here is NOT
* itself destructive, so it fails open (proceed) the autopilot guard, checked
* first, is the primary protection against the race that caused the loss.
* --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,

View File

@ -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 {

View File

@ -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 <id> --path <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"}`);
}
}

View File

@ -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.

22
setup
View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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 });
}
});
});

View File

@ -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" };

View File

@ -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<void> {
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<number | null>((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);
}
});
});

View File

@ -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<string, string>; cwd?: string };
type RunOpts = { env?: Record<string, string | undefined>; 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');

View File

@ -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);
}
});
});

View File

@ -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");
});
});
});

View File

@ -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)", () => {

View File

@ -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" }],

View File

@ -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');

View File

@ -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');
}
});

View File

@ -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.