mirror of https://github.com/garrytan/gstack.git
fix(gbrain): harden capability and sync guards
This commit is contained in:
parent
a3259400a3
commit
3a0e986917
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
22
setup
|
|
@ -749,11 +749,14 @@ create_agents_sidecar() {
|
|||
mkdir -p "$agents_gstack"
|
||||
|
||||
# Sidecar directories that skills reference at runtime
|
||||
for asset in bin browse review qa; do
|
||||
for asset in bin lib browse review qa; do
|
||||
local src="$SOURCE_GSTACK_DIR/$asset"
|
||||
local dst="$agents_gstack/$asset"
|
||||
if [ -d "$src" ] || [ -f "$src" ]; then
|
||||
if [ -L "$dst" ] || [ ! -e "$dst" ]; then
|
||||
# Windows installs are real copies, not symlinks. Refresh every managed
|
||||
# sidecar asset on rerun so newly-added bins and their imported lib/
|
||||
# modules cannot stay missing or stale after an upgrade.
|
||||
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then
|
||||
_link_or_copy "$src" "$dst"
|
||||
fi
|
||||
fi
|
||||
|
|
@ -764,7 +767,10 @@ create_agents_sidecar() {
|
|||
local src="$SOURCE_GSTACK_DIR/$file"
|
||||
local dst="$agents_gstack/$file"
|
||||
if [ -f "$src" ]; then
|
||||
if [ -L "$dst" ] || [ ! -e "$dst" ]; then
|
||||
# Managed files follow the same Windows copy-refresh contract as the
|
||||
# sidecar directories above; otherwise ETHOS.md remains stale forever
|
||||
# after the first non-symlink install.
|
||||
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then
|
||||
_link_or_copy "$src" "$dst"
|
||||
fi
|
||||
fi
|
||||
|
|
@ -796,6 +802,9 @@ create_codex_runtime_root() {
|
|||
if [ -d "$gstack_dir/bin" ]; then
|
||||
_link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
|
||||
fi
|
||||
if [ -d "$gstack_dir/lib" ]; then
|
||||
_link_or_copy "$gstack_dir/lib" "$codex_gstack/lib"
|
||||
fi
|
||||
if [ -d "$gstack_dir/browse/dist" ]; then
|
||||
_link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist"
|
||||
fi
|
||||
|
|
@ -836,6 +845,9 @@ create_factory_runtime_root() {
|
|||
if [ -d "$gstack_dir/bin" ]; then
|
||||
_link_or_copy "$gstack_dir/bin" "$factory_gstack/bin"
|
||||
fi
|
||||
if [ -d "$gstack_dir/lib" ]; then
|
||||
_link_or_copy "$gstack_dir/lib" "$factory_gstack/lib"
|
||||
fi
|
||||
if [ -d "$gstack_dir/browse/dist" ]; then
|
||||
_link_or_copy "$gstack_dir/browse/dist" "$factory_gstack/browse/dist"
|
||||
fi
|
||||
|
|
@ -874,6 +886,9 @@ create_opencode_runtime_root() {
|
|||
if [ -d "$gstack_dir/bin" ]; then
|
||||
_link_or_copy "$gstack_dir/bin" "$opencode_gstack/bin"
|
||||
fi
|
||||
if [ -d "$gstack_dir/lib" ]; then
|
||||
_link_or_copy "$gstack_dir/lib" "$opencode_gstack/lib"
|
||||
fi
|
||||
if [ -d "$gstack_dir/browse/dist" ]; then
|
||||
_link_or_copy "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist"
|
||||
fi
|
||||
|
|
@ -1115,6 +1130,7 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then
|
|||
[ -L "$KIRO_GSTACK" ] && rm -f "$KIRO_GSTACK"
|
||||
mkdir -p "$KIRO_GSTACK" "$KIRO_GSTACK/browse" "$KIRO_GSTACK/gstack-upgrade" "$KIRO_GSTACK/review"
|
||||
_link_or_copy "$SOURCE_GSTACK_DIR/bin" "$KIRO_GSTACK/bin"
|
||||
_link_or_copy "$SOURCE_GSTACK_DIR/lib" "$KIRO_GSTACK/lib"
|
||||
_link_or_copy "$SOURCE_GSTACK_DIR/browse/dist" "$KIRO_GSTACK/browse/dist"
|
||||
_link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin"
|
||||
# ETHOS.md — referenced by "Search Before Building" in all skill preambles
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue