Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/windows-free-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
446 changes: 446 additions & 0 deletions bin/gstack-gbrain-capability-check

Large diffs are not rendered by default.

7 changes: 2 additions & 5 deletions bin/gstack-gbrain-detect
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,15 @@ 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;
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 {
Expand Down
53 changes: 47 additions & 6 deletions bin/gstack-gbrain-install
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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)"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
110 changes: 93 additions & 17 deletions bin/gstack-gbrain-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
},
};
}

Expand Down
2 changes: 1 addition & 1 deletion docs/ADDING_A_HOST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
},

Expand Down
2 changes: 1 addition & 1 deletion hosts/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
4 changes: 2 additions & 2 deletions hosts/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion hosts/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
2 changes: 1 addition & 1 deletion hosts/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
2 changes: 1 addition & 1 deletion hosts/gbrain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
2 changes: 1 addition & 1 deletion hosts/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
2 changes: 1 addition & 1 deletion hosts/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
2 changes: 1 addition & 1 deletion hosts/openclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
2 changes: 1 addition & 1 deletion hosts/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
2 changes: 1 addition & 1 deletion hosts/slate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
Loading