mirror of https://github.com/garrytan/gstack.git
Merge 3a0e986917 into a3259400a3
This commit is contained in:
commit
3ef5506db5
|
|
@ -115,6 +115,7 @@ jobs:
|
||||||
browse/test/server-sanitize-surrogates.test.ts \
|
browse/test/server-sanitize-surrogates.test.ts \
|
||||||
test/setup-windows-fallback.test.ts \
|
test/setup-windows-fallback.test.ts \
|
||||||
test/bin-windows-bun-import-paths.test.ts \
|
test/bin-windows-bun-import-paths.test.ts \
|
||||||
|
test/gbrain-guards-windows.test.ts \
|
||||||
test/build-script-shell-compat.test.ts \
|
test/build-script-shell-compat.test.ts \
|
||||||
test/docs-config-keys.test.ts \
|
test/docs-config-keys.test.ts \
|
||||||
test/brain-sync-windows-paths.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,
|
resolveGbrainBin,
|
||||||
readGbrainVersion,
|
readGbrainVersion,
|
||||||
} from "../lib/gbrain-local-status";
|
} 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 STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack");
|
||||||
const SCRIPT_DIR = __dirname;
|
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
|
// Honors GBRAIN_HOME — must stay consistent with lib/gbrain-local-status's
|
||||||
// config resolution, or the detect JSON reports gbrain_local_status "ok"
|
// config resolution, or the detect JSON reports gbrain_local_status "ok"
|
||||||
// alongside gbrain_config_exists false for relocated-home users.
|
// alongside gbrain_config_exists false for relocated-home users.
|
||||||
const GBRAIN_CONFIG = join(
|
const GBRAIN_CONFIG = resolveGbrainConfigPath(process.env);
|
||||||
process.env.GBRAIN_HOME || join(userHome(), ".gbrain"),
|
|
||||||
"config.json",
|
|
||||||
);
|
|
||||||
const CLAUDE_JSON = join(userHome(), ".claude.json");
|
const CLAUDE_JSON = join(userHome(), ".claude.json");
|
||||||
|
|
||||||
function userHome(): string {
|
function userHome(): string {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# --- defaults ---
|
# --- 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).
|
# No version pin by default — install the latest default-branch HEAD (#1744).
|
||||||
# --pinned-commit <sha> overrides for reproducibility.
|
# --pinned-commit <sha> overrides for reproducibility.
|
||||||
PINNED_COMMIT=""
|
PINNED_COMMIT=""
|
||||||
|
|
@ -77,8 +83,9 @@ check_prereq() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_prereq "$BUN_BIN" "Install: curl -fsSL https://bun.sh/install | bash"
|
||||||
|
|
||||||
if ! $VALIDATE_ONLY; then
|
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"
|
check_prereq git "Install: xcode-select --install (macOS) or your package manager"
|
||||||
|
|
||||||
# GitHub reachability — fail fast if offline rather than hanging `git clone`.
|
# 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
|
# Read the version from the install-dir's package.json; compare to
|
||||||
# `gbrain --version`. If they disagree, PATH is returning a DIFFERENT
|
# `gbrain --version`. If they disagree, PATH is returning a DIFFERENT
|
||||||
# gbrain than the one we just linked. Fail hard with remediation.
|
# 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)
|
expected_version=$(jq -r '.version // empty' "$INSTALL_DIR/package.json" 2>/dev/null || true)
|
||||||
if [ -z "$expected_version" ]; then
|
if [ -z "$expected_version" ]; then
|
||||||
fail "cannot read version from $INSTALL_DIR/package.json (install may be broken)"
|
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."
|
fail "bun link completed but 'gbrain' is not on PATH. Ensure ~/.bun/bin is in your PATH."
|
||||||
fi
|
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
|
if [ -z "$actual_version" ]; then
|
||||||
fail "gbrain is on PATH but 'gbrain --version' produced no output — the binary may be broken."
|
fail "gbrain is on PATH but 'gbrain --version' produced no output — the binary may be broken."
|
||||||
fi
|
fi
|
||||||
|
|
@ -235,9 +260,25 @@ fi
|
||||||
# a hard gate so a broken gbrain is caught at setup, not at data-loss time.
|
# 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
|
# Pre-init installs skip this (config not written yet); the full
|
||||||
# `/sync-gbrain --dry-run` self-test runs from /setup-gbrain after `gbrain init`.
|
# `/sync-gbrain --dry-run` self-test runs from /setup-gbrain after `gbrain init`.
|
||||||
_GBRAIN_HOME_CHECK="${GBRAIN_HOME:-$HOME/.gbrain}"
|
resolve_gbrain_config_path() {
|
||||||
if [ -f "$_GBRAIN_HOME_CHECK/config.json" ]; then
|
GSTACK_GBRAIN_EXEC_MODULE="$GBRAIN_EXEC_MODULE" "$BUN_BIN" --no-env-file -e '
|
||||||
if ! gbrain doctor --fast >/dev/null 2>&1; then
|
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 "" >&2
|
||||||
echo "gstack-gbrain-install: gbrain $actual_version installed but 'gbrain doctor --fast' failed." >&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
|
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
|
# 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 —
|
# 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.
|
# 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 "" >&2
|
||||||
echo "gstack-gbrain-install: WARNING — gbrain installed but 'gbrain sources --help' did not exit 0." >&2
|
echo "gstack-gbrain-install: WARNING — gbrain installed but 'gbrain sources --help' did not exit 0." >&2
|
||||||
if [ "$IS_WINDOWS" -eq 1 ]; then
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,12 @@ import { createHash } from "crypto";
|
||||||
import "../lib/conductor-env-shim";
|
import "../lib/conductor-env-shim";
|
||||||
import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers";
|
import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers";
|
||||||
import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources";
|
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 { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
|
||||||
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
|
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
|
||||||
import { checkOwnedStagingDir } from "../lib/staging-guard";
|
import { checkOwnedStagingDir } from "../lib/staging-guard";
|
||||||
|
|
@ -524,6 +529,11 @@ export interface GuardedRemoveResult {
|
||||||
reason: string;
|
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
|
* #1734: run `gbrain sources remove <id> --confirm-destructive` only behind the
|
||||||
* data-loss guards. Checked immediately before the destructive op (E8: as late
|
* 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
|
* caller decides whether a skip is fatal (it never is today — removes are
|
||||||
* best-effort cleanup).
|
* best-effort cleanup).
|
||||||
*/
|
*/
|
||||||
export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): GuardedRemoveResult {
|
export function safeSourcesRemove(
|
||||||
const ap = detectAutopilot(env);
|
sourceId: string,
|
||||||
|
env?: NodeJS.ProcessEnv,
|
||||||
|
deps: DestructiveGuardDeps = {},
|
||||||
|
): GuardedRemoveResult {
|
||||||
|
const detect = deps.detectAutopilot ?? detectAutopilot;
|
||||||
|
const ap = detect(env);
|
||||||
if (ap.active) {
|
if (ap.active) {
|
||||||
return {
|
return {
|
||||||
removed: false,
|
removed: false,
|
||||||
|
|
@ -547,13 +562,74 @@ export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): Gu
|
||||||
if (!decision.allow) {
|
if (!decision.allow) {
|
||||||
return { removed: false, skipped: true, reason: decision.reason };
|
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(
|
const r = spawnGbrain(
|
||||||
["sources", "remove", sourceId, "--confirm-destructive", ...decision.extraArgs],
|
["sources", "remove", sourceId, ...confirmationArgs, ...decision.extraArgs],
|
||||||
{ baseEnv: env },
|
{ baseEnv: env },
|
||||||
);
|
);
|
||||||
return { removed: r.status === 0, skipped: false, reason: decision.reason };
|
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
|
* Remove an orphaned source. Called only after new-source sync verifies pages
|
||||||
* exist, so the old source is provably redundant before deletion. Routed through
|
* 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).
|
// no synchronous duplicate here (per /codex review #12).
|
||||||
let registered = false;
|
let registered = false;
|
||||||
try {
|
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;
|
registered = result.changed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -878,20 +958,16 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
|
||||||
// - URL-managed source → the walk can auto-reclone (rm-rf); require
|
// - URL-managed source → the walk can auto-reclone (rm-rf); require
|
||||||
// --allow-reclone. Both surface a visible reason and fail the stage so the
|
// --allow-reclone. Both surface a visible reason and fail the stage so the
|
||||||
// verdict shows ERR rather than silently skipping protection.
|
// verdict shows ERR rather than silently skipping protection.
|
||||||
const apBeforeWalk = detectAutopilot(gbrainEnv);
|
const walkGuard = guardCodeSyncBeforeWalk(sourceId, gbrainEnv, args.allowReclone);
|
||||||
if (apBeforeWalk.active) {
|
if (!walkGuard.allow) {
|
||||||
return {
|
return {
|
||||||
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
|
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.`,
|
summary: `refused: ${walkGuard.reason}`,
|
||||||
detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" },
|
detail: {
|
||||||
};
|
source_id: sourceId,
|
||||||
}
|
source_path: root,
|
||||||
const reclone = decideCodeSync(sourceId, gbrainEnv, args.allowReclone);
|
status: walkGuard.status ?? "refused-reclone",
|
||||||
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" },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ const myhost: HostConfig = {
|
||||||
],
|
],
|
||||||
|
|
||||||
runtimeRoot: {
|
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'] },
|
globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'] },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const claude: HostConfig = {
|
||||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||||
|
|
||||||
runtimeRoot: {
|
runtimeRoot: {
|
||||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||||
globalFiles: {
|
globalFiles: {
|
||||||
'review': ['checklist.md', 'TODOS-format.md'],
|
'review': ['checklist.md', 'TODOS-format.md'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,14 @@ const codex: HostConfig = {
|
||||||
],
|
],
|
||||||
|
|
||||||
runtimeRoot: {
|
runtimeRoot: {
|
||||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||||
globalFiles: {
|
globalFiles: {
|
||||||
'review': ['checklist.md', 'TODOS-format.md'],
|
'review': ['checklist.md', 'TODOS-format.md'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sidecar: {
|
sidecar: {
|
||||||
path: '.agents/skills/gstack',
|
path: '.agents/skills/gstack',
|
||||||
symlinks: ['bin', 'browse', 'review', 'qa', 'ETHOS.md'],
|
symlinks: ['bin', 'lib', 'browse', 'review', 'qa', 'ETHOS.md'],
|
||||||
},
|
},
|
||||||
|
|
||||||
install: {
|
install: {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ const cursor: HostConfig = {
|
||||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||||
|
|
||||||
runtimeRoot: {
|
runtimeRoot: {
|
||||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||||
globalFiles: {
|
globalFiles: {
|
||||||
'review': ['checklist.md', 'TODOS-format.md'],
|
'review': ['checklist.md', 'TODOS-format.md'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ const factory: HostConfig = {
|
||||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||||
|
|
||||||
runtimeRoot: {
|
runtimeRoot: {
|
||||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||||
globalFiles: {
|
globalFiles: {
|
||||||
'review': ['checklist.md', 'TODOS-format.md'],
|
'review': ['checklist.md', 'TODOS-format.md'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ const gbrain: HostConfig = {
|
||||||
],
|
],
|
||||||
|
|
||||||
runtimeRoot: {
|
runtimeRoot: {
|
||||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||||
globalFiles: {
|
globalFiles: {
|
||||||
'review': ['checklist.md', 'TODOS-format.md'],
|
'review': ['checklist.md', 'TODOS-format.md'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ const hermes: HostConfig = {
|
||||||
],
|
],
|
||||||
|
|
||||||
runtimeRoot: {
|
runtimeRoot: {
|
||||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||||
globalFiles: {
|
globalFiles: {
|
||||||
'review': ['checklist.md', 'TODOS-format.md'],
|
'review': ['checklist.md', 'TODOS-format.md'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const kiro: HostConfig = {
|
||||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||||
|
|
||||||
runtimeRoot: {
|
runtimeRoot: {
|
||||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||||
globalFiles: {
|
globalFiles: {
|
||||||
'review': ['checklist.md', 'TODOS-format.md'],
|
'review': ['checklist.md', 'TODOS-format.md'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const openclaw: HostConfig = {
|
||||||
],
|
],
|
||||||
|
|
||||||
runtimeRoot: {
|
runtimeRoot: {
|
||||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||||
globalFiles: {
|
globalFiles: {
|
||||||
'review': ['checklist.md', 'TODOS-format.md'],
|
'review': ['checklist.md', 'TODOS-format.md'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ const opencode: HostConfig = {
|
||||||
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||||
|
|
||||||
runtimeRoot: {
|
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: {
|
globalFiles: {
|
||||||
'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
|
'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'],
|
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
|
||||||
|
|
||||||
runtimeRoot: {
|
runtimeRoot: {
|
||||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||||
globalFiles: {
|
globalFiles: {
|
||||||
'review': ['checklist.md', 'TODOS-format.md'],
|
'review': ['checklist.md', 'TODOS-format.md'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@
|
||||||
* caller's `.env.local`. gbrain auto-loads `.env.local` via dotenv on
|
* caller's `.env.local`. gbrain auto-loads `.env.local` via dotenv on
|
||||||
* startup. When `/sync-gbrain` runs inside a Next.js / Prisma / Rails
|
* startup. When `/sync-gbrain` runs inside a Next.js / Prisma / Rails
|
||||||
* project with its own `DATABASE_URL`, gbrain reads that one and not
|
* 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.
|
* code + memory stages crash; only brain-sync's git push survives.
|
||||||
*
|
*
|
||||||
* 2. **Bun-aware env passing.** Mutating `process.env.DATABASE_URL` does
|
* 2. **Bun-aware env passing.** Mutating `process.env.DATABASE_URL` does
|
||||||
|
|
@ -23,7 +24,7 @@
|
||||||
*
|
*
|
||||||
* 3. **`GBRAIN_HOME` honored consistently.** Other gstack helpers
|
* 3. **`GBRAIN_HOME` honored consistently.** Other gstack helpers
|
||||||
* (`detectEngineTier`) already honor `GBRAIN_HOME`. `buildGbrainEnv`
|
* (`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.
|
* gstack-side gbrain calls agree on which config file matters.
|
||||||
*
|
*
|
||||||
* **Escape hatch:** `GSTACK_RESPECT_ENV_DATABASE_URL=1` returns the
|
* **Escape hatch:** `GSTACK_RESPECT_ENV_DATABASE_URL=1` returns the
|
||||||
|
|
@ -36,8 +37,11 @@ import { join } from "path";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { spawnSync, spawn, execFileSync, type SpawnSyncReturns, type ChildProcess, type SpawnOptions } from "child_process";
|
import { spawnSync, spawn, execFileSync, type SpawnSyncReturns, type ChildProcess, type SpawnOptions } from "child_process";
|
||||||
|
|
||||||
interface GbrainConfig {
|
export interface GbrainConfig {
|
||||||
|
engine?: string;
|
||||||
database_url?: string;
|
database_url?: string;
|
||||||
|
database_path?: string;
|
||||||
|
remote_mcp?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildGbrainEnvOptions {
|
export interface BuildGbrainEnvOptions {
|
||||||
|
|
@ -54,6 +58,94 @@ export interface BuildGbrainEnvOptions {
|
||||||
announce?: boolean;
|
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.
|
* 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
|
* 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:
|
* unchanged when:
|
||||||
* - `GSTACK_RESPECT_ENV_DATABASE_URL=1` (intentional opt-out),
|
* - `GSTACK_RESPECT_ENV_DATABASE_URL=1` (intentional opt-out),
|
||||||
* - the config file is missing or unparseable,
|
* - the config file is missing or unparseable,
|
||||||
|
|
@ -98,17 +190,9 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process
|
||||||
const out: NodeJS.ProcessEnv = { ...baseEnv };
|
const out: NodeJS.ProcessEnv = { ...baseEnv };
|
||||||
if (baseEnv.GSTACK_RESPECT_ENV_DATABASE_URL === "1") return out;
|
if (baseEnv.GSTACK_RESPECT_ENV_DATABASE_URL === "1") return out;
|
||||||
|
|
||||||
const homeBase = baseEnv.HOME || homedir();
|
const active = readActiveGbrainConfig(baseEnv);
|
||||||
const gbrainHome = baseEnv.GBRAIN_HOME || join(homeBase, ".gbrain");
|
if (!active) return out;
|
||||||
const configPath = join(gbrainHome, "config.json");
|
const { path: configPath, config: cfg } = active;
|
||||||
if (!existsSync(configPath)) return out;
|
|
||||||
|
|
||||||
let cfg: GbrainConfig = {};
|
|
||||||
try {
|
|
||||||
cfg = JSON.parse(readFileSync(configPath, "utf-8")) as GbrainConfig;
|
|
||||||
} catch {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
if (!cfg.database_url) return out;
|
if (!cfg.database_url) return out;
|
||||||
|
|
||||||
const hadCaller = baseEnv.DATABASE_URL !== undefined;
|
const hadCaller = baseEnv.DATABASE_URL !== undefined;
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
* subcommand `--help` is generic — so capability detection is best-effort and
|
* subcommand `--help` is generic — so capability detection is best-effort and
|
||||||
* defaults to "unsupported". When we can't protect a user-managed source's
|
* defaults to "unsupported". When we can't protect a user-managed source's
|
||||||
* files, we FAIL CLOSED (refuse the remove) rather than delete unprotected.
|
* files, we FAIL CLOSED (refuse the remove) rather than delete unprotected.
|
||||||
* - The autopilot lock filename isn't documented and (gbrain #1226) ignores
|
* - Current gbrain treats GBRAIN_HOME as the parent of `.gbrain`; older
|
||||||
* GBRAIN_HOME, so the live `gbrain autopilot` process is the PRIMARY signal;
|
* releases treated it as the state directory itself. The live process is
|
||||||
* known lock paths under both the configured home and ~/.gbrain are secondary.
|
* the PRIMARY signal; lock paths for both layouts are secondary.
|
||||||
* - We refuse only on an AFFIRMATIVE autopilot signal — inability to introspect
|
* - We refuse only on an AFFIRMATIVE autopilot signal — inability to introspect
|
||||||
* never blocks a normal sync (that would brick the tool).
|
* never blocks a normal sync (that would brick the tool).
|
||||||
* - Path containment uses realpath so a symlink inside ~/.gbrain/clones can't
|
* - Path containment uses realpath so a symlink inside ~/.gbrain/clones can't
|
||||||
|
|
@ -29,24 +29,65 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawnSync } from "child_process";
|
import { spawnSync } from "child_process";
|
||||||
import { existsSync, realpathSync, readFileSync } from "fs";
|
import { existsSync, lstatSync, realpathSync, readFileSync } from "fs";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { join, resolve, sep } from "path";
|
import { join, resolve, sep } from "path";
|
||||||
import { execGbrainJson, execGbrainText, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
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 {
|
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
|
* Infer the ONE active state layout used for destructive ownership decisions.
|
||||||
* resolves inside one of these is gbrain-managed; outside = user-managed and
|
* Current gbrain treats GBRAIN_HOME as a parent; a legacy config directly under
|
||||||
* must be protected. Both the configured home and the default ~/.gbrain are
|
* GBRAIN_HOME is accepted only when the current nested config is absent.
|
||||||
* checked because gbrain #1226 shows home-resolution is inconsistent.
|
* Multi-path probing is deliberately reserved for non-destructive lock checks.
|
||||||
*/
|
*/
|
||||||
function clonesDirs(env: NodeJS.ProcessEnv = process.env): string[] {
|
function destructiveGbrainHome(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
return [...new Set([join(gbrainHome(env), "clones"), join(homedir(), ".gbrain", "clones")])];
|
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`. */
|
/** True if `p` resolves (symlinks + `..` collapsed) to a location inside `dir`. */
|
||||||
|
|
@ -83,14 +124,12 @@ export function detectAutopilot(
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
probe: AutopilotProbe = {},
|
probe: AutopilotProbe = {},
|
||||||
): AutopilotStatus {
|
): AutopilotStatus {
|
||||||
// Secondary signal: known lock files. gbrain #1226 — the lock ignores
|
// Secondary signal: current, legacy, and default lock paths. This remains
|
||||||
// GBRAIN_HOME, so check both the configured home and the default ~/.gbrain.
|
// sufficient on Windows where there is no reliable pgrep fallback.
|
||||||
const lockPaths = probe.lockPaths ?? [
|
const lockPaths = probe.lockPaths ?? gbrainHomes(env).flatMap((home) => [
|
||||||
join(gbrainHome(env), "autopilot.lock"),
|
join(home, "autopilot.lock"),
|
||||||
join(homedir(), ".gbrain", "autopilot.lock"),
|
join(home, "autopilot.pid"),
|
||||||
join(gbrainHome(env), "autopilot.pid"),
|
]);
|
||||||
join(homedir(), ".gbrain", "autopilot.pid"),
|
|
||||||
];
|
|
||||||
for (const lp of lockPaths) {
|
for (const lp of lockPaths) {
|
||||||
if (!existsSync(lp)) continue;
|
if (!existsSync(lp)) continue;
|
||||||
// A lock FILE alone is not proof of life — a crashed daemon leaves a stale
|
// 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";
|
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 {
|
export function gbrainSupportsKeepStorage(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||||
const key = gbrainIdentity(env);
|
const key = gbrainIdentity(env);
|
||||||
if (_keepStorageMemo && _keepStorageMemo.key === key) return _keepStorageMemo.value;
|
if (_keepStorageMemo && _keepStorageMemo.key === key) return _keepStorageMemo.value;
|
||||||
|
|
@ -198,9 +291,41 @@ export function _resetCapabilityMemo(): void {
|
||||||
* Injectable for hermetic tests.
|
* Injectable for hermetic tests.
|
||||||
*/
|
*/
|
||||||
export function fetchSources(env: NodeJS.ProcessEnv = process.env): GbrainSourceRow[] {
|
export function fetchSources(env: NodeJS.ProcessEnv = process.env): GbrainSourceRow[] {
|
||||||
const raw = execGbrainJson(["sources", "list", "--json"], { baseEnv: env });
|
// The public CLI list intentionally omits ownership config. The read-only
|
||||||
if (raw === null) throw new Error("gbrain sources list returned no JSON");
|
// sources_list operation exposes an authoritative (redacted) remote_url,
|
||||||
return parseSourcesList(raw);
|
// 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 {
|
export interface RemoveDecision {
|
||||||
|
|
@ -215,18 +340,20 @@ export interface RemoveDecision {
|
||||||
*
|
*
|
||||||
* Fail-closed cases (allow=false):
|
* Fail-closed cases (allow=false):
|
||||||
* - sources list unreadable/unparseable (can't prove the row is safe).
|
* - 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
|
* - ownership metadata is unavailable on a CLI that may support URL sources.
|
||||||
* clones) and gbrain has no --keep-storage to protect the files.
|
* - 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
|
* Allowed: absent row (no-op), authoritatively gbrain-managed, or path-managed
|
||||||
* without a remote_url (gbrain's remove won't touch an outside-clones path that
|
* without a remote_url. --keep-storage is appended whenever supported.
|
||||||
* it didn't clone). --keep-storage is appended whenever supported, as extra armor.
|
|
||||||
*/
|
*/
|
||||||
export interface DecideRemoveOpts {
|
export interface DecideRemoveOpts {
|
||||||
/** Override capability detection (tests / cached caps). */
|
/** Override capability detection (tests / cached caps). */
|
||||||
keepStorage?: boolean;
|
keepStorage?: boolean;
|
||||||
/** Override the source-list fetch (tests). Throwing simulates a read failure. */
|
/** Override the source-list fetch (tests). Throwing simulates a read failure. */
|
||||||
fetchRows?: (env: NodeJS.ProcessEnv) => GbrainSourceRow[];
|
fetchRows?: (env: NodeJS.ProcessEnv) => GbrainSourceRow[];
|
||||||
|
/** Override whether this CLI can create URL-managed sources (tests). */
|
||||||
|
urlManagedSources?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decideSourceRemove(
|
export function decideSourceRemove(
|
||||||
|
|
@ -247,9 +374,24 @@ export function decideSourceRemove(
|
||||||
const row = rows.find((r) => r.id === sourceId);
|
const row = rows.find((r) => r.id === sourceId);
|
||||||
if (!row) return { allow: true, extraArgs: extra, reason: "source absent (no-op)" };
|
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 remoteUrl = row.config?.remote_url;
|
||||||
const userManaged =
|
const userManaged = !!remoteUrl && !isOwnedClone(row, env);
|
||||||
!!remoteUrl && !!row.local_path && !clonesDirs(env).some((d) => isInside(row.local_path!, d));
|
|
||||||
|
|
||||||
if (userManaged) {
|
if (userManaged) {
|
||||||
if (keepStorage) {
|
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 {
|
export interface SyncDecision {
|
||||||
|
|
@ -273,28 +415,53 @@ export interface SyncDecision {
|
||||||
reason: string;
|
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.
|
* 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
|
* 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
|
* 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
|
* --allow-reclone opt-in for URL-managed sources. A missing config field is not
|
||||||
* itself destructive, so it fails open (proceed) — the autopilot guard, checked
|
* evidence that the source is path-managed on gbrain >=0.28: current CLI list
|
||||||
* first, is the primary protection against the race that caused the loss.
|
* 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(
|
export function decideCodeSync(
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
allowReclone = false,
|
allowReclone = false,
|
||||||
fetchRows: (env: NodeJS.ProcessEnv) => GbrainSourceRow[] = fetchSources,
|
fetchRows: (env: NodeJS.ProcessEnv) => GbrainSourceRow[] = fetchSources,
|
||||||
|
opts: DecideSyncOpts = {},
|
||||||
): SyncDecision {
|
): SyncDecision {
|
||||||
let rows: GbrainSourceRow[];
|
let rows: GbrainSourceRow[];
|
||||||
try {
|
try {
|
||||||
rows = fetchRows(env);
|
rows = fetchRows(env);
|
||||||
} catch {
|
} 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);
|
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) {
|
if (row?.config?.remote_url && !allowReclone) {
|
||||||
return {
|
return {
|
||||||
allow: false,
|
allow: false,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ import {
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { dirname, join } from "path";
|
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 =
|
export type LocalEngineStatus =
|
||||||
| "ok"
|
| "ok"
|
||||||
|
|
@ -116,9 +116,7 @@ export function cacheFilePath(): string {
|
||||||
|
|
||||||
/** Honors GBRAIN_HOME (codex D11) — same resolution as buildGbrainEnv. */
|
/** Honors GBRAIN_HOME (codex D11) — same resolution as buildGbrainEnv. */
|
||||||
function gbrainConfigPath(env?: NodeJS.ProcessEnv): string {
|
function gbrainConfigPath(env?: NodeJS.ProcessEnv): string {
|
||||||
const e = env ?? process.env;
|
return resolveGbrainConfigPath(env ?? process.env);
|
||||||
const gbrainHome = e.GBRAIN_HOME || join(userHome(e), ".gbrain");
|
|
||||||
return join(gbrainHome, "config.json");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function configuredEngine(env?: NodeJS.ProcessEnv): "pglite" | "postgres" | null {
|
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
|
* One row of `gbrain sources list --json`.
|
||||||
* URL-managed sources (gbrain owns the clone, may auto-reclone) from
|
*
|
||||||
* path-managed ones (user owns the working tree) — load-bearing for the #1734
|
* IMPORTANT: current gbrain CLI list rows intentionally omit `config`; callers
|
||||||
* destructive-op guards.
|
* 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 {
|
export interface GbrainSourceRow {
|
||||||
id?: string;
|
id?: string;
|
||||||
local_path?: string;
|
local_path?: string;
|
||||||
page_count?: number;
|
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.
|
* throwing — callers treat "no rows" as absent.
|
||||||
*/
|
*/
|
||||||
export function parseSourcesList(raw: unknown): GbrainSourceRow[] {
|
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)) {
|
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 [];
|
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 {
|
export interface EnsureOptions {
|
||||||
/** Pass --federated to `gbrain sources add`. Default false. */
|
/** Pass --federated to `gbrain sources add`. Default false. */
|
||||||
federated?: boolean;
|
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;
|
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
|
* Optional env override for the spawned `gbrain` calls. Production callers
|
||||||
* leave this unset (inherit process.env). Tests pass a custom env to point
|
* 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:
|
* Behavior:
|
||||||
* - status=absent → `gbrain sources add <id> --path <path> [--federated]`, returns changed=true.
|
* - status=absent → `gbrain sources add <id> --path <path> [--federated]`, returns changed=true.
|
||||||
* - status=match + same path → no-op, returns changed=false.
|
* - status=match + same path → no-op, returns changed=false.
|
||||||
* - status=match + different path → `sources remove` + `sources add`, returns changed=true.
|
* - status=match + different path → guarded `sources remove` + `sources add`, returns changed=true.
|
||||||
* (Skip when reregister_on_drift=false; returns changed=false.)
|
* (Skip when reregister_on_drift=false; fail closed when guardedRemove is missing/refuses.)
|
||||||
*
|
*
|
||||||
* Caller is responsible for catching errors. The function uses withErrorContext for
|
* Caller is responsible for catching errors. The function uses withErrorContext for
|
||||||
* forensic logging to ~/.gstack/.gbrain-errors.jsonl.
|
* forensic logging to ~/.gstack/.gbrain-errors.jsonl.
|
||||||
|
|
@ -156,16 +212,16 @@ export async function ensureSourceRegistered(
|
||||||
return { changed: false, state };
|
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") {
|
if (state.status === "drift") {
|
||||||
const rm = spawnSync("gbrain", ["sources", "remove", id, "--yes"], {
|
if (!options.guardedRemove) {
|
||||||
encoding: "utf-8",
|
throw new Error(`source ${id} path drift requires a guarded remove; refusing re-registration`);
|
||||||
timeout: 30_000,
|
}
|
||||||
env,
|
const rm = options.guardedRemove(id);
|
||||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
if (!rm.removed) {
|
||||||
});
|
throw new Error(`guarded remove of drifted source ${id} refused: ${rm.reason || "unknown reason"}`);
|
||||||
if (rm.status !== 0) {
|
|
||||||
throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, appendFil
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { execFileSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
|
import { resolveGbrainConfigPath } from "./gbrain-exec";
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -254,13 +255,12 @@ export function detectEngineTier(): EngineDetect {
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns gbrain's config.json path, honoring GBRAIN_HOME env var with a
|
// Returns gbrain's active config.json path. Current releases append `.gbrain`
|
||||||
// fallback to ~/.gbrain. gbrain >=0.25 dropped the top-level `engine` field
|
// to GBRAIN_HOME; the shared resolver retains the legacy direct-layout fallback.
|
||||||
// from doctor output, so this file is the only reliable source for engine
|
// gbrain >=0.25 dropped the top-level `engine` field from doctor output, so this
|
||||||
// detection on that version. See #1415.
|
// file is the only reliable source for engine detection on that version. #1415.
|
||||||
function gbrainConfigPath(): string {
|
function gbrainConfigPath(): string {
|
||||||
const root = process.env.GBRAIN_HOME || join(homedir(), ".gbrain");
|
return resolveGbrainConfigPath(process.env);
|
||||||
return join(root, "config.json");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort JSONL append to ~/.gstack/.gbrain-errors.jsonl. Never throws.
|
// 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"
|
mkdir -p "$agents_gstack"
|
||||||
|
|
||||||
# Sidecar directories that skills reference at runtime
|
# 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 src="$SOURCE_GSTACK_DIR/$asset"
|
||||||
local dst="$agents_gstack/$asset"
|
local dst="$agents_gstack/$asset"
|
||||||
if [ -d "$src" ] || [ -f "$src" ]; then
|
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"
|
_link_or_copy "$src" "$dst"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
@ -764,7 +767,10 @@ create_agents_sidecar() {
|
||||||
local src="$SOURCE_GSTACK_DIR/$file"
|
local src="$SOURCE_GSTACK_DIR/$file"
|
||||||
local dst="$agents_gstack/$file"
|
local dst="$agents_gstack/$file"
|
||||||
if [ -f "$src" ]; then
|
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"
|
_link_or_copy "$src" "$dst"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
@ -796,6 +802,9 @@ create_codex_runtime_root() {
|
||||||
if [ -d "$gstack_dir/bin" ]; then
|
if [ -d "$gstack_dir/bin" ]; then
|
||||||
_link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
|
_link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
|
||||||
fi
|
fi
|
||||||
|
if [ -d "$gstack_dir/lib" ]; then
|
||||||
|
_link_or_copy "$gstack_dir/lib" "$codex_gstack/lib"
|
||||||
|
fi
|
||||||
if [ -d "$gstack_dir/browse/dist" ]; then
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
||||||
_link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist"
|
_link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist"
|
||||||
fi
|
fi
|
||||||
|
|
@ -836,6 +845,9 @@ create_factory_runtime_root() {
|
||||||
if [ -d "$gstack_dir/bin" ]; then
|
if [ -d "$gstack_dir/bin" ]; then
|
||||||
_link_or_copy "$gstack_dir/bin" "$factory_gstack/bin"
|
_link_or_copy "$gstack_dir/bin" "$factory_gstack/bin"
|
||||||
fi
|
fi
|
||||||
|
if [ -d "$gstack_dir/lib" ]; then
|
||||||
|
_link_or_copy "$gstack_dir/lib" "$factory_gstack/lib"
|
||||||
|
fi
|
||||||
if [ -d "$gstack_dir/browse/dist" ]; then
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
||||||
_link_or_copy "$gstack_dir/browse/dist" "$factory_gstack/browse/dist"
|
_link_or_copy "$gstack_dir/browse/dist" "$factory_gstack/browse/dist"
|
||||||
fi
|
fi
|
||||||
|
|
@ -874,6 +886,9 @@ create_opencode_runtime_root() {
|
||||||
if [ -d "$gstack_dir/bin" ]; then
|
if [ -d "$gstack_dir/bin" ]; then
|
||||||
_link_or_copy "$gstack_dir/bin" "$opencode_gstack/bin"
|
_link_or_copy "$gstack_dir/bin" "$opencode_gstack/bin"
|
||||||
fi
|
fi
|
||||||
|
if [ -d "$gstack_dir/lib" ]; then
|
||||||
|
_link_or_copy "$gstack_dir/lib" "$opencode_gstack/lib"
|
||||||
|
fi
|
||||||
if [ -d "$gstack_dir/browse/dist" ]; then
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
||||||
_link_or_copy "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist"
|
_link_or_copy "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist"
|
||||||
fi
|
fi
|
||||||
|
|
@ -1115,6 +1130,7 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then
|
||||||
[ -L "$KIRO_GSTACK" ] && rm -f "$KIRO_GSTACK"
|
[ -L "$KIRO_GSTACK" ] && rm -f "$KIRO_GSTACK"
|
||||||
mkdir -p "$KIRO_GSTACK" "$KIRO_GSTACK/browse" "$KIRO_GSTACK/gstack-upgrade" "$KIRO_GSTACK/review"
|
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/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/dist" "$KIRO_GSTACK/browse/dist"
|
||||||
_link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin"
|
_link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin"
|
||||||
# ETHOS.md — referenced by "Search Before Building" in all skill preambles
|
# 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):
|
Capability check (per /plan-eng-review §6):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SLUG="_capability_check_$$"
|
|
||||||
CAPABILITY_OK=0
|
CAPABILITY_OK=0
|
||||||
if [ -f ~/.gbrain/config.json ] && \
|
# Do NOT export GBRAIN_PREPARE here (#1965). gbrain auto-disables prepared
|
||||||
gbrain --version 2>/dev/null | grep -q '^gbrain '; then
|
# statements on transaction-mode poolers (port 6543) — forcing them on breaks
|
||||||
# Do NOT export GBRAIN_PREPARE here (#1965). gbrain auto-disables prepared
|
# every write with "prepared statement does not exist". Local engines use a
|
||||||
# statements on transaction-mode poolers (port 6543) — forcing them on
|
# unique non-federated source rooted in private gstack state; EXIT/INT/TERM
|
||||||
# breaks every write with "prepared statement does not exist". Users on a
|
# cleanup removes only that exact source + owned directory. The helper honors
|
||||||
# session-mode pooler at 6543 can set GBRAIN_PREPARE=true themselves (the
|
# relocated GBRAIN_HOME, checks gstack's Autopilot guard before creation and
|
||||||
# gbrain banner documents this override).
|
# again immediately before source removal, and keeps caller TMPDIR out of the
|
||||||
if echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1; then
|
# write path. Thin clients use gbrain's live OAuth scope probe + read-only
|
||||||
# Retry search up to 3 times with 1s delay — under transaction-mode
|
# search because delete_page cannot remove a server-side write-through file.
|
||||||
# pooling the search index may not be visible on the next connection
|
if ~/.claude/skills/gstack/bin/gstack-gbrain-capability-check; then
|
||||||
# immediately after the put.
|
CAPABILITY_OK=1
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
gbrain delete "$SLUG" 2>/dev/null || true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then update CLAUDE.md based on capability state:
|
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):
|
Capability check (per /plan-eng-review §6):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SLUG="_capability_check_$$"
|
|
||||||
CAPABILITY_OK=0
|
CAPABILITY_OK=0
|
||||||
if [ -f ~/.gbrain/config.json ] && \
|
# Do NOT export GBRAIN_PREPARE here (#1965). gbrain auto-disables prepared
|
||||||
gbrain --version 2>/dev/null | grep -q '^gbrain '; then
|
# statements on transaction-mode poolers (port 6543) — forcing them on breaks
|
||||||
# Do NOT export GBRAIN_PREPARE here (#1965). gbrain auto-disables prepared
|
# every write with "prepared statement does not exist". Local engines use a
|
||||||
# statements on transaction-mode poolers (port 6543) — forcing them on
|
# unique non-federated source rooted in private gstack state; EXIT/INT/TERM
|
||||||
# breaks every write with "prepared statement does not exist". Users on a
|
# cleanup removes only that exact source + owned directory. The helper honors
|
||||||
# session-mode pooler at 6543 can set GBRAIN_PREPARE=true themselves (the
|
# relocated GBRAIN_HOME, checks gstack's Autopilot guard before creation and
|
||||||
# gbrain banner documents this override).
|
# again immediately before source removal, and keeps caller TMPDIR out of the
|
||||||
if echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1; then
|
# write path. Thin clients use gbrain's live OAuth scope probe + read-only
|
||||||
# Retry search up to 3 times with 1s delay — under transaction-mode
|
# search because delete_page cannot remove a server-side write-through file.
|
||||||
# pooling the search index may not be visible on the next connection
|
if {{BIN_DIR}}/gstack-gbrain-capability-check; then
|
||||||
# immediately after the put.
|
CAPABILITY_OK=1
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
gbrain delete "$SLUG" 2>/dev/null || true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then update CLAUDE.md based on capability state:
|
Then update CLAUDE.md based on capability state:
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,17 @@
|
||||||
|
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import { spawnSync } from "child_process";
|
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 { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
|
|
@ -57,11 +67,20 @@ describe("bin/ — Windows bun-import path guard (#1950)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("known-affected bins carry the guard explicitly", () => {
|
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");
|
const content = readFileSync(join(BIN_DIR, name), "utf-8");
|
||||||
expect(content).toContain("cygpath -m");
|
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)", () => {
|
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 { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
import { buildGbrainEnv, isTransactionModePooler } from "../lib/gbrain-exec";
|
import { buildConfiguredGbrainEnv, buildGbrainEnv, isTransactionModePooler } from "../lib/gbrain-exec";
|
||||||
|
|
||||||
describe("buildGbrainEnv", () => {
|
describe("buildGbrainEnv", () => {
|
||||||
let home: string;
|
let home: string;
|
||||||
|
|
@ -77,17 +77,35 @@ describe("buildGbrainEnv", () => {
|
||||||
expect(result.DATABASE_URL).toBe("postgresql://app/db");
|
expect(result.DATABASE_URL).toBe("postgresql://app/db");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("honors GBRAIN_HOME when set (config aligned with detectEngineTier)", () => {
|
it("honors current GBRAIN_HOME parent semantics", () => {
|
||||||
// Move the config to an alternate dir; set GBRAIN_HOME to point at it.
|
const altGbrainParent = join(home, "alt-gbrain");
|
||||||
const altGbrainHome = join(home, "alt-gbrain");
|
const currentState = join(altGbrainParent, ".gbrain");
|
||||||
mkdirSync(altGbrainHome, { recursive: true });
|
mkdirSync(currentState, { recursive: true });
|
||||||
writeFileSync(join(altGbrainHome, "config.json"), JSON.stringify({ database_url: "postgresql://alt/db" }));
|
writeFileSync(join(currentState, "config.json"), JSON.stringify({ database_url: "postgresql://alt/db" }));
|
||||||
// No file at the default ~/.gbrain location.
|
// 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 });
|
const result = buildGbrainEnv({ baseEnv });
|
||||||
expect(result.DATABASE_URL).toBe("postgresql://alt/db");
|
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", () => {
|
it("returns a fresh env object — never the caller's env by identity", () => {
|
||||||
// Codex review #11: object-identity equality lets later mutation of the
|
// Codex review #11: object-identity equality lets later mutation of the
|
||||||
// returned env leak back into the caller's view. The helper MUST clone.
|
// returned env leak back into the caller's view. The helper MUST clone.
|
||||||
|
|
@ -100,6 +118,55 @@ describe("buildGbrainEnv", () => {
|
||||||
expect(baseEnv.FOO).toBe("bar");
|
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", () => {
|
it("preserves unrelated env vars from the base env", () => {
|
||||||
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: "postgresql://gbrain/db" }));
|
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: "postgresql://gbrain/db" }));
|
||||||
const baseEnv = { HOME: home, PATH: "/usr/bin", FOO: "bar" };
|
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 tmpHome: string;
|
||||||
let tmpHomeReal: 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 = {}) {
|
function run(bin: string, args: string[], opts: RunOpts = {}) {
|
||||||
const env = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
GSTACK_HOME: tmpHome,
|
GSTACK_HOME: tmpHome,
|
||||||
HOME: tmpHomeReal,
|
HOME: tmpHomeReal,
|
||||||
|
BUN_BIN: process.execPath,
|
||||||
...(opts.env || {}),
|
...(opts.env || {}),
|
||||||
};
|
};
|
||||||
|
for (const [key, value] of Object.entries(opts.env || {})) {
|
||||||
|
if (value === undefined) delete env[key];
|
||||||
|
}
|
||||||
const res = spawnSync(bin, args, {
|
const res = spawnSync(bin, args, {
|
||||||
env,
|
env,
|
||||||
cwd: opts.cwd,
|
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-'));
|
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-bin-'));
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(binDir, 'gbrain'),
|
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 }
|
{ mode: 0o755 }
|
||||||
);
|
);
|
||||||
return binDir;
|
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', () => {
|
test('passes when install-dir version matches `gbrain --version` on PATH', () => {
|
||||||
// Version must be >= MIN_GBRAIN_VERSION (0.20.0) floor (#1744).
|
// Version must be >= MIN_GBRAIN_VERSION (0.20.0) floor (#1744).
|
||||||
const installDir = seedInstallDir('0.41.29');
|
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)', () => {
|
test('hard-fails (exit 3) when the installed gbrain is below the version floor (#1744)', () => {
|
||||||
const installDir = seedInstallDir('0.18.2');
|
const installDir = seedInstallDir('0.18.2');
|
||||||
const fakeBin = seedFakeGbrainBinary('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 fs from "fs";
|
||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import { pathToFileURL } from "url";
|
||||||
import {
|
import {
|
||||||
detectAutopilot,
|
detectAutopilot,
|
||||||
decideSourceRemove,
|
decideSourceRemove,
|
||||||
decideCodeSync,
|
decideCodeSync,
|
||||||
isInside,
|
isInside,
|
||||||
|
gbrainHome,
|
||||||
|
gbrainSourceRemoveConfirmationArgs,
|
||||||
_resetCapabilityMemo,
|
_resetCapabilityMemo,
|
||||||
type GbrainSourceRow,
|
type GbrainSourceRow,
|
||||||
} from "../lib/gbrain-guards";
|
} from "../lib/gbrain-guards";
|
||||||
|
import {
|
||||||
|
guardCodeSyncBeforeWalk,
|
||||||
|
safeSourcesRemove,
|
||||||
|
} from "../bin/gstack-gbrain-sync";
|
||||||
|
|
||||||
const HOME = os.homedir();
|
const HOME = os.homedir();
|
||||||
const clonesPath = (name: string) => join(HOME, ".gbrain", "clones", name);
|
const clonesPath = (name: string) => join(HOME, ".gbrain", "clones", name);
|
||||||
|
|
||||||
afterEach(() => _resetCapabilityMemo());
|
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) ────────────────
|
// ── #1734 autopilot detection (E1: affirmative multi-signal) ────────────────
|
||||||
describe("detectAutopilot", () => {
|
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)", () => {
|
test("refuses on a present lock file (secondary signal)", () => {
|
||||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ap-"));
|
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ap-"));
|
||||||
const lock = join(tmp, "autopilot.lock");
|
const lock = join(tmp, "autopilot.lock");
|
||||||
|
|
@ -92,9 +295,9 @@ describe("detectAutopilot", () => {
|
||||||
// ── #1734 remove safety (E7: fail closed on user-managed without keep-storage) ─
|
// ── #1734 remove safety (E7: fail closed on user-managed without keep-storage) ─
|
||||||
describe("decideSourceRemove", () => {
|
describe("decideSourceRemove", () => {
|
||||||
const rows = (extra: GbrainSourceRow[] = []): GbrainSourceRow[] => [
|
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: "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,
|
...extra,
|
||||||
];
|
];
|
||||||
const fetchRows = (extra?: GbrainSourceRow[]) => () => rows(extra);
|
const fetchRows = (extra?: GbrainSourceRow[]) => () => rows(extra);
|
||||||
|
|
@ -118,7 +321,8 @@ describe("decideSourceRemove", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("gbrain-managed (inside clones) → allow even without keep-storage", () => {
|
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);
|
expect(d.allow).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -127,6 +331,49 @@ describe("decideSourceRemove", () => {
|
||||||
expect(d.allow).toBe(true);
|
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", () => {
|
test("sources unreadable → FAIL CLOSED", () => {
|
||||||
const d = decideSourceRemove("user-managed", process.env, {
|
const d = decideSourceRemove("user-managed", process.env, {
|
||||||
keepStorage: false,
|
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) ───
|
// ── #1734 reclone guard (E-level: require --allow-reclone for URL-managed) ───
|
||||||
describe("decideCodeSync", () => {
|
describe("decideCodeSync", () => {
|
||||||
const rows: GbrainSourceRow[] = [
|
const rows: GbrainSourceRow[] = [
|
||||||
{ id: "url-managed", local_path: "/tmp/u", config: { remote_url: "https://x/r.git" } },
|
{ 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;
|
const fetch = () => rows;
|
||||||
|
|
||||||
|
|
@ -161,9 +440,135 @@ describe("decideCodeSync", () => {
|
||||||
expect(d.allow).toBe(true);
|
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"); });
|
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)", () => {
|
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 });
|
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false });
|
||||||
restoreEnv = applyEnv(env);
|
restoreEnv = applyEnv(env);
|
||||||
const altHome = join(env.tmp, "alt-gbrain");
|
const altHome = join(env.tmp, "alt-gbrain");
|
||||||
mkdirSync(altHome, { recursive: true });
|
mkdirSync(join(altHome, ".gbrain"), { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(altHome, "config.json"),
|
join(altHome, ".gbrain", "config.json"),
|
||||||
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
|
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
|
||||||
);
|
);
|
||||||
// Without GBRAIN_HOME: misclassified as missing-config.
|
// Without GBRAIN_HOME: misclassified as missing-config.
|
||||||
|
|
@ -270,6 +270,20 @@ describe("lib/gbrain-local-status — status classification", () => {
|
||||||
process.env.GBRAIN_HOME = altHome;
|
process.env.GBRAIN_HOME = altHome;
|
||||||
expect(localEngineStatus({ noCache: true })).toBe("ok");
|
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)", () => {
|
describe("gstack-gbrain-detect --is-ok — timeout is usable (eng review D1)", () => {
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,48 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, chmodSync } from "fs";
|
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, chmodSync } from "fs";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
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 {
|
interface FakeGbrainSetup {
|
||||||
bindir: string;
|
bindir: string;
|
||||||
|
|
@ -162,6 +199,14 @@ describe("ensureSourceRegistered", () => {
|
||||||
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
|
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
|
||||||
federated: true,
|
federated: true,
|
||||||
env: fake.env,
|
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.changed).toBe(true);
|
||||||
expect(result.state.status).toBe("match");
|
expect(result.state.status).toBe("match");
|
||||||
|
|
@ -173,6 +218,21 @@ describe("ensureSourceRegistered", () => {
|
||||||
fake.cleanup();
|
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 () => {
|
it("when reregister_on_drift=false and source is at different path, returns changed=false", async () => {
|
||||||
const fake = makeFakeGbrain({
|
const fake = makeFakeGbrain({
|
||||||
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
|
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
|
||||||
|
|
|
||||||
|
|
@ -2424,11 +2424,12 @@ describe('setup script validation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create_agents_sidecar links runtime assets', () => {
|
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 fnStart = setupContent.indexOf('create_agents_sidecar()');
|
||||||
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', fnStart));
|
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', fnStart));
|
||||||
const fnBody = setupContent.slice(fnStart, fnEnd);
|
const fnBody = setupContent.slice(fnStart, fnEnd);
|
||||||
expect(fnBody).toContain('bin');
|
expect(fnBody).toContain('bin');
|
||||||
|
expect(fnBody).toContain('lib');
|
||||||
expect(fnBody).toContain('browse');
|
expect(fnBody).toContain('browse');
|
||||||
expect(fnBody).toContain('review');
|
expect(fnBody).toContain('review');
|
||||||
expect(fnBody).toContain('qa');
|
expect(fnBody).toContain('qa');
|
||||||
|
|
@ -2439,6 +2440,7 @@ describe('setup script validation', () => {
|
||||||
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', setupContent.indexOf('review/', fnStart)));
|
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', setupContent.indexOf('review/', fnStart)));
|
||||||
const fnBody = setupContent.slice(fnStart, fnEnd);
|
const fnBody = setupContent.slice(fnStart, fnEnd);
|
||||||
expect(fnBody).toContain('gstack/SKILL.md');
|
expect(fnBody).toContain('gstack/SKILL.md');
|
||||||
|
expect(fnBody).toContain('lib');
|
||||||
expect(fnBody).toContain('browse/dist');
|
expect(fnBody).toContain('browse/dist');
|
||||||
expect(fnBody).toContain('browse/bin');
|
expect(fnBody).toContain('browse/bin');
|
||||||
expect(fnBody).toContain('gstack-upgrade/SKILL.md');
|
expect(fnBody).toContain('gstack-upgrade/SKILL.md');
|
||||||
|
|
|
||||||
|
|
@ -484,6 +484,7 @@ describe('host config correctness', () => {
|
||||||
test('codex has sidecar config', () => {
|
test('codex has sidecar config', () => {
|
||||||
expect(codex.sidecar).toBeDefined();
|
expect(codex.sidecar).toBeDefined();
|
||||||
expect(codex.sidecar!.path).toBe('.agents/skills/gstack');
|
expect(codex.sidecar!.path).toBe('.agents/skills/gstack');
|
||||||
|
expect(codex.sidecar!.symlinks).toContain('lib');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('factory has tool rewrites', () => {
|
test('factory has tool rewrites', () => {
|
||||||
|
|
@ -559,6 +560,7 @@ describe('host config correctness', () => {
|
||||||
for (const config of ALL_HOST_CONFIGS) {
|
for (const config of ALL_HOST_CONFIGS) {
|
||||||
expect(config.runtimeRoot.globalSymlinks.length).toBeGreaterThan(0);
|
expect(config.runtimeRoot.globalSymlinks.length).toBeGreaterThan(0);
|
||||||
expect(config.runtimeRoot.globalSymlinks).toContain('bin');
|
expect(config.runtimeRoot.globalSymlinks).toContain('bin');
|
||||||
|
expect(config.runtimeRoot.globalSymlinks).toContain('lib');
|
||||||
expect(config.runtimeRoot.globalSymlinks).toContain('ETHOS.md');
|
expect(config.runtimeRoot.globalSymlinks).toContain('ETHOS.md');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ function extractHelper(): string {
|
||||||
return SETUP_SRC.slice(start, end + 2);
|
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)', () => {
|
describe('setup: _link_or_copy invariant (D7)', () => {
|
||||||
test('helper function is defined near the top of setup', () => {
|
test('helper function is defined near the top of setup', () => {
|
||||||
expect(SETUP_SRC).toContain('_link_or_copy() {');
|
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);
|
const fnBody = SETUP_SRC.slice(fnStart, fnEnd);
|
||||||
expect(fnBody).toContain('_print_windows_copy_note_once');
|
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.
|
// Behavior matrix uses Unix `ln -snf` semantics in the IS_WINDOWS=0 cells.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue