v0.42.48.0 feat(durability): auto-harden brain repos for git durability on PAT+URL (#2241)

* feat(git): divergence-safe pull, push-probe, default-branch detection for brain durability

Add GIT_ENV_AUTH + divergenceSafePull (skip-on-dirty, conflict-abort-clean,
never-mid-rebase), detectDefaultBranch, pushProbe, and an env-gated
GBRAIN_GIT_ALLOW_FILE_TRANSPORT escape hatch. Export GIT_ENV. pullRepo's
--ff-only contract is unchanged.

* feat(durability): brain-repo hardening core (hook, helper, cron, PAT, AGENTS rules)

hardenBrainRepo/unhardenBrainRepo: local untracked post-commit hook + committed
brain-commit-push.sh (one shared push-retry template), repo-scoped credential
with existing-helper reuse, push-probe verify, active-resolver-file rules with
taxonomy from _brain-filing-rules.json, minimal DB-free pull cron. PAT redaction
via redactSecretsInText.

* feat(sources): harden/pull/unharden commands + auto-harden on add --url

sources harden/pull/unharden subcommands; --pat-file/--no-harden on add;
auto-harden managed clones on add; unharden-before-remove. cli.ts pre-connect
early-exit for DB-free 'sources pull --path' (the cron entry, never opens PGLite).

* test(durability): unit + integration coverage for brain-repo durability

git helpers, core harden/unharden, hook+helper E2E (real background push),
cron generators. 41 tests across 4 files.

* chore: bump version and changelog (v0.42.48.0)

Brain-repo git durability: auto-harden a brain's working tree (local auto-push
hook, committed commit-push helper, always-on agent rules, DB-free pull cron,
repo-scoped credential, push-probe verify) the moment gbrain gets a PAT + URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sources): route harden exit code through setCliExitVerdict

A raw process.exitCode write is zeroed by the owned-verdict flush-exit
(#2084 PGLite-Emscripten pollution defense); cli-exit-verdict-pin guard
caught it. Use setCliExitVerdict(3) so 'sources harden' actually reports
needs-attention to cron/automation.

* docs: document brain-repo durability (KEY_FILES + multi-source guide)

KEY_FILES: extend git-remote.ts entry (divergenceSafePull, pushProbe,
detectDefaultBranch, GIT_ENV_AUTH, GBRAIN_GIT_ALLOW_FILE_TRANSPORT) + add
brain-repo-durability.ts/sources-harden.ts entry. multi-source-brains.md:
add a Durability (auto-harden) how-to covering sources harden/pull/unharden,
--pat-file, the guarantees, and the security posture.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-17 06:41:37 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 9d88680a51
commit 7ea92d602c
15 changed files with 1801 additions and 4 deletions
+11
View File
@@ -284,6 +284,17 @@ async function main() {
}
}
// DB-free durability pull (v0.42.44 D2): the harden cron calls
// `gbrain sources pull --path <dir>` every ~30 min. It must NOT open PGLite
// (a live long-lived session holds the single-writer lock), so handle it
// BEFORE connectEngine. The `sources pull <id>` form (no --path) still routes
// through handleCliOnly → runSources with an engine.
if (command === 'sources' && subArgs[0] === 'pull' && subArgs.includes('--path')) {
const { runPull } = await import('./commands/sources-harden.ts');
await runPull(null, subArgs.slice(1));
return;
}
// CLI-only commands
if (CLI_ONLY.has(command)) {
await handleCliOnly(command, subArgs);
+179
View File
@@ -0,0 +1,179 @@
/**
* gbrain sources harden / pull / unharden — brain-repo git durability (v0.42.44).
*
* gbrain sources harden <id|--all> [--pat-file <p>] [--branch <b>]
* [--no-cron] [--no-verify] [--dry-run] [--json]
* gbrain sources pull <id> | --path <dir> [--branch <b>]
* gbrain sources unharden <id>
*
* `harden`/`unharden` write executables, an OS cron, and a credential helper on
* the host → CLI-only (never MCP). `pull --path` is DB-free (the cron's entry):
* cli.ts dispatches it BEFORE connectEngine so a live PGLite session keeps its
* single-writer lock.
*/
import type { BrainEngine } from '../core/engine.ts';
import {
hardenBrainRepo, unhardenBrainRepo, acceptPat,
type DurabilityReport,
} from '../core/brain-repo-durability.ts';
import { divergenceSafePull, detectDefaultBranch } from '../core/git-remote.ts';
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
import { existsSync } from 'fs';
import { join } from 'path';
interface SourceRow { id: string; local_path: string | null; config: unknown; }
function flagVal(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
if (i !== -1 && i + 1 < args.length) return args[i + 1];
const pref = `${name}=`;
const hit = args.find(a => a.startsWith(pref));
return hit ? hit.slice(pref.length) : undefined;
}
function configHost(config: unknown): string | null {
try {
const url = (config as Record<string, unknown>)?.remote_url;
if (typeof url === 'string' && url) return new URL(url).hostname;
} catch { /* */ }
return null;
}
async function loadSourceRows(engine: BrainEngine, id: string | undefined, all: boolean): Promise<SourceRow[]> {
if (all) {
return engine.executeRaw<SourceRow>(`SELECT id, local_path, config FROM sources WHERE local_path IS NOT NULL ORDER BY id`);
}
if (!id) throw new Error('Usage: gbrain sources harden <id|--all> [--pat-file <p>] [--branch <b>] [--no-cron] [--no-verify] [--dry-run] [--json]');
return engine.executeRaw<SourceRow>(`SELECT id, local_path, config FROM sources WHERE id = $1`, [id]);
}
// ── harden ──────────────────────────────────────────────────────────────────
export async function runHarden(engine: BrainEngine, args: string[]): Promise<void> {
const all = args.includes('--all');
const id = all ? undefined : args.find(a => !a.startsWith('--')
&& a !== flagVal(args, '--pat-file') && a !== flagVal(args, '--branch'));
const json = args.includes('--json');
const dryRun = args.includes('--dry-run');
const installCron = !args.includes('--no-cron');
const verify = !args.includes('--no-verify');
const branch = flagVal(args, '--branch');
const patFile = flagVal(args, '--pat-file');
const pat = acceptPat({ patFile });
for (const w of pat?.warnings ?? []) console.error(`[gbrain] ${w}`);
const rows = await loadSourceRows(engine, id, all);
if (rows.length === 0) {
console.error(all ? 'No sources with a local_path to harden.' : `Source "${id}" not found.`);
process.exit(1);
}
// --all guard (codex): one PAT must not silently span multiple hosts/accounts.
if (all && pat) {
const hosts = new Set(rows.map(r => configHost(r.config)).filter(Boolean));
if (hosts.size > 1) {
console.error(`[gbrain] Refusing --all with one PAT across multiple hosts (${[...hosts].join(', ')}). Harden each source with its own --pat-file.`);
process.exit(2);
}
}
const reports: DurabilityReport[] = [];
for (const row of rows) {
if (!row.local_path || !existsSync(join(row.local_path, '.git'))) {
console.error(`[${row.id}] skipped — no local git repo at ${row.local_path ?? '(none)'}`);
continue;
}
const report = await hardenBrainRepo({
repoPath: row.local_path, sourceId: row.id, branch,
pat: pat?.token, installCron, verify, dryRun,
logger: json ? undefined : (l) => console.error(` ${l}`),
});
reports.push(report);
if (!json) renderReport(report);
}
if (json) console.log(JSON.stringify({ reports }, null, 2));
// Non-zero exit if any source needs attention, so cron/automation notices.
// Route through setCliExitVerdict — a raw process.exitCode write is zeroed by
// the owned-verdict flush-exit (#2084 / PGLite-Emscripten pollution defense).
if (reports.some(r => r.needs_attention.length > 0)) setCliExitVerdict(3);
}
function renderReport(r: DurabilityReport): void {
console.log(`\n[${r.source_id}] durability — ${r.repo_path} (branch ${r.branch})`);
for (const s of r.steps) {
const mark = s.status === 'ok' ? '✓' : s.status === 'fixed' ? '+' : s.status === 'skipped' ? '·' : '⚠';
console.log(` ${mark} ${s.step.padEnd(11)} ${s.detail}`);
}
if (r.needs_attention.length) {
console.log(` NEEDS ATTENTION:`);
for (const n of r.needs_attention) console.log(` - ${n}`);
}
console.log(` clean against origin: ${r.clean_against_origin ? 'yes' : 'no'}`);
}
// ── pull (DB-free when --path is given) ─────────────────────────────────────
export async function runPull(engine: BrainEngine | null, args: string[]): Promise<void> {
const path = flagVal(args, '--path');
const branchFlag = flagVal(args, '--branch');
let repoPath: string;
if (path) {
repoPath = path;
} else {
const id = args.find(a => !a.startsWith('--') && a !== branchFlag);
if (!engine || !id) {
console.error('Usage: gbrain sources pull <id> | --path <dir> [--branch <b>]');
process.exit(2);
}
const rows = await engine.executeRaw<SourceRow>(`SELECT id, local_path, config FROM sources WHERE id = $1`, [id]);
if (rows.length === 0 || !rows[0].local_path) {
console.error(`Source "${id}" not found or has no local_path.`);
process.exit(1);
}
repoPath = rows[0].local_path;
}
if (!existsSync(join(repoPath, '.git'))) {
console.error(`[gbrain] not a git repo: ${repoPath}`);
process.exit(1);
}
const branch = branchFlag || detectDefaultBranch(repoPath);
const outcome = divergenceSafePull(repoPath, branch);
switch (outcome.status) {
case 'up_to_date': console.log(`up to date (${branch})`); break;
case 'advanced': console.log(`advanced ${outcome.from.slice(0, 7)}${outcome.to.slice(0, 7)} (${branch})`); break;
case 'skipped_dirty': console.log(`skipped — working tree dirty (${branch})`); break;
case 'conflict_aborted':
console.error(`[gbrain] ${outcome.detail}`);
process.exit(3);
}
}
// ── unharden ────────────────────────────────────────────────────────────────
export async function runUnharden(engine: BrainEngine, args: string[]): Promise<void> {
const id = args.find(a => !a.startsWith('--'));
if (!id) {
console.error('Usage: gbrain sources unharden <id>');
process.exit(2);
}
const rows = await engine.executeRaw<SourceRow>(`SELECT id, local_path, config FROM sources WHERE id = $1`, [id]);
if (rows.length === 0) {
console.error(`Source "${id}" not found.`);
process.exit(1);
}
const steps = await unhardenBrainRepo({
repoPath: rows[0].local_path ?? '',
sourceId: rows[0].id,
logger: (l) => console.error(l),
});
for (const s of steps) {
const mark = s.status === 'fixed' ? '+' : '·';
console.log(` ${mark} ${s.step}: ${s.detail}`);
}
}
+57
View File
@@ -130,6 +130,8 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
let displayName: string | undefined;
let federated: boolean | null = null;
let cloneDir: string | undefined;
let patFile: string | undefined;
let noHarden = false;
for (let i = 1; i < args.length; i++) {
const a = args[i];
@@ -139,6 +141,8 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
if (a === '--federated') { federated = true; continue; }
if (a === '--no-federated') { federated = false; continue; }
if (a === '--clone-dir') { cloneDir = args[++i]; continue; }
if (a === '--pat-file') { patFile = args[++i]; continue; }
if (a === '--no-harden') { noHarden = true; continue; }
console.error(`Unknown flag: ${a}`);
process.exit(2);
}
@@ -181,6 +185,36 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
console.log(
` federated: ${fed}${fed ? ' — appears in cross-source default search' : ' — only searched when explicitly named via --source'}`,
);
// v0.42.44 — auto-harden managed clones for git durability the moment a brain
// repo is added with a PAT. Best-effort: NEVER fail `add` if hardening fails.
// Only managed clones (gbrain owns the working tree); --path repos are unowned.
if (finalRemoteUrl && created.local_path && !noHarden) {
try {
const { hardenBrainRepo, acceptPat } = await import('../core/brain-repo-durability.ts');
const pat = acceptPat({ patFile });
for (const w of pat?.warnings ?? []) console.error(`[gbrain] ${w}`);
if (!pat) {
console.error('[gbrain] No PAT provided (--pat-file or GBRAIN_GITHUB_PAT) — skipping durability hardening.');
console.error(` Run \`gbrain sources harden ${id} --pat-file <p>\` later to enable auto-push.`);
} else {
console.error('[gbrain] Hardening brain repo for durability…');
const report = await hardenBrainRepo({
repoPath: created.local_path, sourceId: id, pat: pat.token,
logger: (l) => console.error(` ${l}`),
});
if (report.needs_attention.length) {
console.error('[gbrain] Durability hardened with warnings:');
for (const n of report.needs_attention) console.error(` - ${n}`);
} else {
console.error('[gbrain] Durability hardened ✓');
}
}
} catch (e) {
console.error(`[gbrain] Durability hardening skipped (non-fatal): ${(e as Error).message}`);
console.error(` Run \`gbrain sources harden ${id}\` to retry.`);
}
}
}
/**
@@ -366,6 +400,16 @@ async function runRemove(engine: BrainEngine, args: string[]): Promise<void> {
}
}
// v0.42.44 — tear down durability scaffolding BEFORE the row is deleted (we
// need the path/label while it still exists). Best-effort; tolerates missing
// repo/cron/credential independently.
try {
const { unhardenBrainRepo } = await import('../core/brain-repo-durability.ts');
await unhardenBrainRepo({ repoPath: src.local_path ?? '', sourceId: id, logger: (l) => console.error(l) });
} catch (e) {
console.error(`[gbrain] durability teardown skipped (non-fatal): ${(e as Error).message}`);
}
await engine.executeRaw(`DELETE FROM sources WHERE id = $1`, [id]);
const pageCount = impact?.pageCount ?? 0;
console.log(`Removed source "${id}" (${pageCount} pages + dependent rows cascaded).`);
@@ -1265,6 +1309,10 @@ export async function runSources(engine: BrainEngine, args: string[]): Promise<v
// v0.40.3.0 contextual retrieval (from master)
case 'set-cr-mode': return runSetCrMode(engine, rest);
case 'audit': return runAudit(engine, rest);
// v0.42.44 brain-repo git durability
case 'harden': { const { runHarden } = await import('./sources-harden.ts'); return runHarden(engine, rest); }
case 'pull': { const { runPull } = await import('./sources-harden.ts'); return runPull(engine, rest); }
case 'unharden': { const { runUnharden } = await import('./sources-harden.ts'); return runUnharden(engine, rest); }
case undefined:
case '--help':
case '-h':
@@ -1316,6 +1364,15 @@ Subcommands:
override (v0.40.3.0). Pass "unset" or
"default" to clear (NULL falls through
to the global search.mode bundle).
harden <id|--all> [--pat-file <p>] [--branch <b>] [--no-cron] [--no-verify] [--dry-run] [--json]
v0.42.44 — make a brain repo durable: local
auto-push hook, committed commit-push helper,
always-on agent rules, 30-min pull cron, and
repo-scoped credential. Idempotent.
pull <id> | --path <dir> [--branch <b>]
Divergence-safe rebase-pull (skip-on-dirty).
--path is DB-free (the harden cron's entry).
unharden <id> Remove durability cron/hook/credential wiring.
Source id: [a-z0-9-]{1,32}. Immutable citation key.
+716
View File
@@ -0,0 +1,716 @@
/**
* brain-repo-durability.ts — auto-harden a brain's git working tree (v0.42.44).
*
* Problem: fresh headless agents (OpenClaw/Hermes) fall out of sync with their
* knowledge-wiki git repos — writes sit local-only and never push, long-lived
* sessions edit a stale tree. The moment gbrain is given a PAT + a GitHub URL
* for a brain repo, `hardenBrainRepo` makes durability work, idempotently:
*
* 1. pull current state (divergence-safe rebase; skip-on-dirty)
* 2. repo-scoped credential wiring (reuse an existing helper if present)
* 3. LOCAL untracked post-commit hook (best-effort background auto-push)
* 4. committed `scripts/brain-commit-push.sh` (the DURABILITY GUARANTEE —
* synchronous add→commit→push that refuses to exit 0 without a push)
* 5. durability rules in the ACTIVE resolver file (RESOLVER.md > AGENTS.md)
* 6. a DB-free pull cron (every 30 min)
* 7. verify by authenticated push-probe (proves push auth; no heartbeat)
*
* Trust boundary (this is gbrain's FIRST push path + FIRST secret storage):
* - The hook is LOCAL + untracked so a pulled commit can't rewrite executed
* code next to the PAT. Both hook and helper render from ONE bash template
* (PUSH_RETRY) — DRY at the TS source level, NOT by the hook sourcing a
* repo-controlled script.
* - Credential is repo-scoped (local git config), token redacted everywhere
* via shell-redact's exact-value scrubber, store file 0600.
*
* CLI-only by design (writes executables + an OS cron + a credential helper on
* the host): never exposed over MCP.
*/
import {
existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync, statSync, appendFileSync,
} from 'fs';
import { join, dirname, relative, isAbsolute } from 'path';
import { execFileSync, execSync } from 'child_process';
import {
GIT_ENV, GIT_ENV_AUTH, divergenceSafePull, detectDefaultBranch, pushProbe,
type PullOutcome, type PushProbeResult,
} from './git-remote.ts';
import { findResolverFile, RESOLVER_FILENAMES } from './resolver-filenames.ts';
import { redactSecretsInText } from './minions/handlers/shell-redact.ts';
// Static import → bundled into the --compile binary so the taxonomy never drifts
// and needs no runtime skills/ directory.
import filingRulesDoc from '../../skills/_brain-filing-rules.json';
// ── Types ───────────────────────────────────────────────────────────────────
export type StepName =
| 'pull' | 'credential' | 'hook' | 'helper' | 'agents' | 'cron' | 'verify' | 'commit';
export type StepStatus = 'ok' | 'fixed' | 'skipped' | 'needs_attention';
export interface DurabilityStep {
step: StepName;
status: StepStatus;
detail: string; // ALWAYS redacted — never contains the PAT
}
export interface DurabilityReport {
source_id: string;
repo_path: string;
branch: string;
steps: DurabilityStep[];
missing: string[]; // what was missing on entry
fixed: string[]; // what this run changed
needs_attention: string[];
clean_against_origin: boolean;
}
export interface HardenOpts {
repoPath: string;
sourceId: string;
branch?: string; // default: detectDefaultBranch
pat?: string; // already-loaded token; never logged
installCron?: boolean; // default true
verify?: boolean; // default true
dryRun?: boolean;
intervalSec?: number; // cron cadence; default 1800
logger?: (line: string) => void;
}
export interface UnhardenOpts {
repoPath: string;
sourceId: string;
logger?: (line: string) => void;
}
// ── Banners / markers (idempotency keys) ────────────────────────────────────
const HOOK_BANNER = '# gbrain brain-durability post-commit hook (v0.42.44+)';
const HELPER_BANNER = '# gbrain brain-commit-push helper (v0.42.44+)';
const AGENTS_BEGIN = '<!-- BEGIN gbrain-brain-durability (managed; do not edit between markers) -->';
const AGENTS_END = '<!-- END gbrain-brain-durability -->';
const HELPER_REL = 'scripts/brain-commit-push.sh';
const CRED_MANAGED_KEY = 'gbrain.durability.managedcredential';
function gbrainHome(): string {
return process.env.GBRAIN_HOME || join(process.env.HOME || '', '.gbrain');
}
/** Resolve the gbrain CLI path for the cron wrapper (inlined to avoid a
* core→commands import). which gbrain → process.execPath → argv[1] → "gbrain". */
function resolveGbrainCliPath(): string {
try {
const which = execSync('which gbrain', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
if (which) return which;
} catch { /* not on PATH */ }
const exec = process.execPath ?? '';
if (exec.endsWith('/gbrain') || exec.endsWith('\\gbrain.exe')) return exec;
const arg1 = process.argv[1] ?? '';
if (arg1.endsWith('/gbrain') || arg1.endsWith('\\gbrain.exe')) return arg1;
return 'gbrain';
}
function credStoreFile(): string {
return join(gbrainHome(), 'git-credentials');
}
function pushLogPath(): string {
return join(gbrainHome(), 'brain-push.log');
}
// ── Shared bash push-retry template (DRY at the TS source — D7) ──────────────
// Rendered into BOTH the (committed) helper and the (local, untracked) hook so
// there is one source of truth without the hook executing repo-controlled code.
const PUSH_RETRY = `# --- gbrain durability push-retry (generated; one source of truth) ---
brain_push() {
_branch="$1"
_log="\${GBRAIN_HOME:-$HOME/.gbrain}/brain-push.log"
mkdir -p "$(dirname "$_log")" 2>/dev/null || true
_gd="$(git rev-parse --git-dir 2>/dev/null || echo .git)"
# Serialize concurrent pushes (commit bursts) so they coalesce instead of a
# rebase-retry herd. No-op if flock is unavailable.
if command -v flock >/dev/null 2>&1; then
exec 9>"$_gd/gbrain-push.lock"
flock -w 30 9 || { echo "$(date -u +%FT%TZ) [push] lock-timeout $_branch" >>"$_log"; return 0; }
fi
if git push origin "HEAD:$_branch" >>"$_log" 2>&1; then
echo "$(date -u +%FT%TZ) [push] ok $_branch $(git rev-parse --short HEAD 2>/dev/null)" >>"$_log"; return 0
fi
echo "$(date -u +%FT%TZ) [push] rejected; rebase-pull $_branch" >>"$_log"
if git pull --rebase origin "$_branch" >>"$_log" 2>&1 && git push origin "HEAD:$_branch" >>"$_log" 2>&1; then
echo "$(date -u +%FT%TZ) [push] ok-after-rebase $_branch $(git rev-parse --short HEAD 2>/dev/null)" >>"$_log"; return 0
fi
git rebase --abort >/dev/null 2>&1 || true
echo "$(date -u +%FT%TZ) [push] LOCAL-ONLY, NEEDS ATTENTION: $_branch @ $(git rev-parse --short HEAD 2>/dev/null) could not reach origin. Run: gbrain sources pull <id> && git push" >>"$_log"
return 1
}`;
function renderPostCommitHook(): string {
return `#!/usr/bin/env bash
${HOOK_BANNER}
# LOCAL + untracked — NEVER commit this file. Best-effort background auto-push so
# agent writes don't sit local-only. The real guarantee is ${HELPER_REL}.
# Bypass: git commit --no-verify.
set -euo pipefail
_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
if [ "$_branch" = "HEAD" ]; then
echo "$(date -u +%FT%TZ) [push] detached HEAD; skip" >> "\${GBRAIN_HOME:-$HOME/.gbrain}/brain-push.log" 2>/dev/null || true
exit 0
fi
${PUSH_RETRY}
# Detach so the commit returns instantly; all output goes to the log.
( brain_push "$_branch" ) </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true
exit 0
`;
}
function renderCommitPushHelper(): string {
return `#!/usr/bin/env bash
${HELPER_BANNER}
# THE DURABILITY GUARANTEE: add -> commit -> push, atomically. Refuses to exit 0
# without a confirmed push. Usage:
# scripts/brain-commit-push.sh "message" <path> [path ...]
# scripts/brain-commit-push.sh --push-only [branch]
set -euo pipefail
${PUSH_RETRY}
_branch="$(git rev-parse --abbrev-ref HEAD)"
if [ "\${1:-}" = "--push-only" ]; then
brain_push "\${2:-$_branch}"; exit $?
fi
_msg="\${1:?usage: brain-commit-push.sh <message> <path> [paths...]}"; shift || true
# Pull first so the local tree is current before we stage.
git fetch origin >/dev/null 2>&1 || true
git pull --rebase origin "$_branch" || { git rebase --abort >/dev/null 2>&1 || true; echo "rebase conflict: manual attention needed" >&2; exit 3; }
# EXPLICIT paths only — never a blind 'git add -A' (would risk committing
# secrets, temp files, or unrelated edits).
if [ "$#" -eq 0 ]; then
echo "refusing blind 'git add -A' — pass explicit path(s) to commit" >&2; exit 2
fi
git add -- "$@"
if git diff --cached --quiet; then echo "nothing to commit"; exit 0; fi
git commit -m "$_msg"
if brain_push "$_branch"; then exit 0; fi
echo "PUSH FAILED — commit is local-only, NEEDS ATTENTION (see ${'$'}{GBRAIN_HOME:-$HOME/.gbrain}/brain-push.log)" >&2
exit 4
`;
}
// ── Managed AGENTS/RESOLVER block (taxonomy from filing rules; no drift) ─────
function renderTaxonomyLines(): string {
const seen = new Set<string>();
const lines: string[] = [];
for (const r of (filingRulesDoc as any).rules ?? []) {
const dir = String(r.directory || '').trim();
if (!dir || seen.has(dir)) continue;
seen.add(dir);
lines.push(` - \`${dir}\`${r.kind}`);
}
return lines.join('\n');
}
function renderManagedBlock(): string {
return `${AGENTS_BEGIN}
<!-- gbrain durability rules. This block is regenerated by \`gbrain sources harden\`.
Do not index as user knowledge; do not edit between the markers. -->
## Brain durability rules (always on)
1. **Deterministic filing — never use /tmp as storage.** Every persistent output
goes to its taxonomy path (canonical, from \`skills/_brain-filing-rules.json\`):
${renderTaxonomyLines()}
Writing to /tmp, scratch dirs, or outside the repo is forbidden for anything
meant to persist.
2. **Every write is committed AND pushed — push is never deferred.** After any
persistent write, run \`scripts/brain-commit-push.sh "<msg>" <path>\` (it commits,
pushes, and FAILS LOUDLY if the push doesn't land), then confirm links resolve
with \`gbrain check-resolvable\`. Do not move on until the push succeeded. The
post-commit hook is only a best-effort fallback — the helper is the guarantee.
3. **Pull before you touch anything.** Run \`git fetch && git pull --rebase\` at
session start and again before each batch of writes, so a long-lived session
never edits a stale tree (a cron also pulls every ~30 min).
${AGENTS_END}`;
}
/** Patch the active resolver file with the managed block (idempotent). */
function patchResolverFile(repoPath: string, dryRun: boolean): { status: StepStatus; detail: string } {
const existing = findResolverFile(repoPath);
const target = existing ?? join(repoPath, RESOLVER_FILENAMES[1]); // default AGENTS.md
const block = renderManagedBlock();
const name = relative(repoPath, target) || target;
let current = '';
if (existsSync(target)) current = readFileSync(target, 'utf-8');
let next: string;
const b = current.indexOf(AGENTS_BEGIN);
const e = current.indexOf(AGENTS_END);
if (b !== -1 && e !== -1 && e > b) {
const before = current.slice(0, b);
const after = current.slice(e + AGENTS_END.length);
next = before + block + after;
if (next === current) return { status: 'ok', detail: `${name}: durability rules already current` };
} else if (current.trim().length === 0) {
next = block + '\n';
} else {
next = current.replace(/\s*$/, '') + '\n\n' + block + '\n';
}
if (dryRun) return { status: 'fixed', detail: `${name}: would write durability rules (dry-run)` };
writeFileSync(target, next);
return { status: 'fixed', detail: `${name}: durability rules written` };
}
// ── Local untracked post-commit hook (D9) ───────────────────────────────────
/** Resolve the active hooks dir (honors a pre-existing core.hooksPath). */
function resolveHooksDir(repoPath: string): { dir: string; tracked: boolean } {
let hooksPath = '';
try {
hooksPath = execFileSync('git', ['-C', repoPath, 'config', '--get', 'core.hooksPath'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
}).toString().trim();
} catch { /* unset — normal */ }
if (hooksPath) {
const dir = isAbsolute(hooksPath) ? hooksPath : join(repoPath, hooksPath);
// A hooksPath outside .git/ (e.g. .githooks) is a TRACKED location.
const tracked = !dir.includes(`${join('.git', '')}`) && !dir.endsWith('.git/hooks');
return { dir, tracked };
}
return { dir: join(repoPath, '.git', 'hooks'), tracked: false };
}
/** Ensure a repo-relative path is in .git/info/exclude so our hook stays untracked. */
function ensureExcluded(repoPath: string, relPath: string): void {
const exclude = join(repoPath, '.git', 'info', 'exclude');
try {
mkdirSync(dirname(exclude), { recursive: true });
let body = existsSync(exclude) ? readFileSync(exclude, 'utf-8') : '';
if (!body.split('\n').some(l => l.trim() === relPath)) {
if (body.length && !body.endsWith('\n')) body += '\n';
body += `${relPath}\n`;
writeFileSync(exclude, body);
}
} catch { /* best-effort */ }
}
function installLocalHook(repoPath: string, dryRun: boolean): { status: StepStatus; detail: string } {
const { dir, tracked } = resolveHooksDir(repoPath);
const hookPath = join(dir, 'post-commit');
const script = renderPostCommitHook();
if (existsSync(hookPath)) {
const cur = readFileSync(hookPath, 'utf-8');
if (cur.includes(HOOK_BANNER)) {
if (cur === script) return { status: 'ok', detail: `${relative(repoPath, hookPath)} already current` };
if (dryRun) return { status: 'fixed', detail: `would refresh ${relative(repoPath, hookPath)} (dry-run)` };
writeFileSync(hookPath, script); chmodSync(hookPath, 0o755);
return { status: 'fixed', detail: `refreshed ${relative(repoPath, hookPath)}` };
}
// Foreign post-commit hook present — back it up, then install ours.
if (!dryRun) writeFileSync(hookPath + '.bak', cur);
}
if (dryRun) return { status: 'fixed', detail: `would install ${relative(repoPath, hookPath)} (dry-run)` };
mkdirSync(dir, { recursive: true });
writeFileSync(hookPath, script); chmodSync(hookPath, 0o755);
// If the hooks dir is a tracked location (.githooks via frontmatter), keep OUR
// hook untracked so it never becomes repo-controlled code (D9).
if (tracked) ensureExcluded(repoPath, relative(repoPath, hookPath));
return { status: 'fixed', detail: `installed local untracked ${relative(repoPath, hookPath)}` };
}
function uninstallLocalHook(repoPath: string): boolean {
const { dir } = resolveHooksDir(repoPath);
const hookPath = join(dir, 'post-commit');
if (!existsSync(hookPath)) return false;
if (!readFileSync(hookPath, 'utf-8').includes(HOOK_BANNER)) return false;
rmSync(hookPath);
if (existsSync(hookPath + '.bak')) { writeFileSync(hookPath, readFileSync(hookPath + '.bak')); rmSync(hookPath + '.bak'); }
return true;
}
// ── Committed helper ────────────────────────────────────────────────────────
function installHelper(repoPath: string, dryRun: boolean): { status: StepStatus; detail: string } {
const helperPath = join(repoPath, HELPER_REL);
const script = renderCommitPushHelper();
if (existsSync(helperPath) && readFileSync(helperPath, 'utf-8') === script) {
// Ensure exec bit even when content is current.
try { chmodSync(helperPath, 0o755); } catch { /* */ }
return { status: 'ok', detail: `${HELPER_REL} already current` };
}
if (dryRun) return { status: 'fixed', detail: `would write ${HELPER_REL} (dry-run)` };
mkdirSync(dirname(helperPath), { recursive: true });
writeFileSync(helperPath, script); chmodSync(helperPath, 0o755);
return { status: 'fixed', detail: `wrote ${HELPER_REL}` };
}
// ── Repo-scoped credential wiring (D11) ─────────────────────────────────────
function gitConfigGet(repoPath: string, key: string, localOnly = false): string {
try {
const scope = localOnly ? ['--local'] : [];
return execFileSync('git', ['-C', repoPath, 'config', ...scope, '--get', key], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
}).toString().trim();
} catch { return ''; }
}
function gitConfigSet(repoPath: string, key: string, value: string): void {
execFileSync('git', ['-C', repoPath, 'config', key, value], {
stdio: 'ignore', timeout: 10_000, env: { ...process.env, ...GIT_ENV },
});
}
function gitConfigUnset(repoPath: string, key: string): void {
try {
execFileSync('git', ['-C', repoPath, 'config', '--unset-all', key], {
stdio: 'ignore', timeout: 10_000, env: { ...process.env, ...GIT_ENV },
});
} catch { /* not set */ }
}
function remoteHost(repoPath: string): string {
try {
const url = execFileSync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
}).toString().trim();
return new URL(url).hostname || 'github.com';
} catch { return 'github.com'; }
}
/**
* Wire a repo-scoped credential. If a working helper is already configured,
* reuse it (no plaintext write). Otherwise fall back to a 0600 store file wired
* via the repo's LOCAL config only (least-privilege — not every github.com
* remote under the account). The token is never returned or logged.
*/
function wireRepoCredential(repoPath: string, pat: string, dryRun: boolean): { status: StepStatus; detail: string } {
// Only a REPO-LOCAL helper triggers reuse. A global helper (e.g. the macOS
// osxkeychain default) must NOT block wiring the explicitly-provided PAT —
// the user gave us a token expressly to use for this repo (D11).
const existing = gitConfigGet(repoPath, 'credential.helper', /*localOnly*/ true);
const ours = gitConfigGet(repoPath, CRED_MANAGED_KEY, true) === 'true';
if (existing && !ours) {
return { status: 'ok', detail: `reusing repo-local credential.helper (no plaintext store written)` };
}
const host = remoteHost(repoPath);
const store = credStoreFile();
const line = `https://x-access-token:${pat}@${host}`;
// Already fully wired by us with this credential present → idempotent no-op.
if (ours && existing && existsSync(store) && readFileSync(store, 'utf-8').split('\n').includes(line)) {
return { status: 'ok', detail: `repo-scoped credential already wired for ${host}` };
}
if (dryRun) return { status: 'fixed', detail: 'would wire repo-scoped credential (dry-run)' };
mkdirSync(dirname(store), { recursive: true, mode: 0o700 });
try { chmodSync(gbrainHome(), 0o700); } catch { /* */ }
let body = existsSync(store) ? readFileSync(store, 'utf-8') : '';
if (!body.split('\n').some(l => l === line)) {
if (body.length && !body.endsWith('\n')) body += '\n';
body += `${line}\n`;
writeFileSync(store, body, { mode: 0o600 });
}
try { chmodSync(store, 0o600); } catch { /* */ }
// Repo-LOCAL wiring → only this repo uses the store (D11).
gitConfigSet(repoPath, 'credential.helper', `store --file ${store}`);
gitConfigSet(repoPath, CRED_MANAGED_KEY, 'true');
return { status: 'fixed', detail: `wired repo-scoped credential for ${host} (store 0600)` };
}
function removeCredentialWiring(repoPath: string): boolean {
if (gitConfigGet(repoPath, CRED_MANAGED_KEY, true) !== 'true') return false; // only what we created
gitConfigUnset(repoPath, 'credential.helper');
gitConfigUnset(repoPath, CRED_MANAGED_KEY);
return true;
}
// ── Minimal DB-free pull cron (D2 + D12) ────────────────────────────────────
function cronLabel(sourceId: string): string {
return `com.gbrain.brain-pull.${sourceId.replace(/[^A-Za-z0-9._-]/g, '_')}`;
}
function cronWrapperPath(sourceId: string): string {
return join(gbrainHome(), `brain-pull-${sourceId.replace(/[^A-Za-z0-9._-]/g, '_')}.sh`);
}
function launchdPlistPath(sourceId: string): string {
return join(process.env.HOME || '', 'Library', 'LaunchAgents', `${cronLabel(sourceId)}.plist`);
}
/** Pure cron-wrapper renderer (DB-free pull; secret-free — sources the shell
* profile rather than baking keys in). Exported for tests. */
export function renderCronWrapper(sourceId: string, repoPath: string, branch: string, cli: string, logPath: string): string {
const q = (s: string) => s.replace(/'/g, "'\\''");
return `#!/bin/bash
# Auto-generated by gbrain sources harden — DB-free durability pull (${sourceId}).
# Sources the shell profile for secrets, then runs the hardened, DB-free pull.
[ -f ~/.zshenv ] && source ~/.zshenv 2>/dev/null
source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null || true
# Self-disable if the captured checkout is gone (rename/relocation).
if [ ! -d '${q(repoPath)}/.git' ]; then
echo "$(date -u +%FT%TZ) [cron] path gone, skipping: ${q(repoPath)}" >> "${q(logPath)}" 2>/dev/null || true
exit 0
fi
exec '${q(cli)}' sources pull --path '${q(repoPath)}' --branch '${q(branch)}'
`;
}
function writeCronWrapper(sourceId: string, repoPath: string, branch: string): string {
const wrapper = cronWrapperPath(sourceId);
const body = renderCronWrapper(sourceId, repoPath, branch, resolveGbrainCliPath(), pushLogPath());
mkdirSync(dirname(wrapper), { recursive: true });
writeFileSync(wrapper, body, { mode: 0o755 });
return wrapper;
}
export function generateBrainPullPlist(label: string, wrapperPath: string, home: string, intervalSec: number): string {
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>${esc(label)}</string>
<key>ProgramArguments</key><array><string>${esc(wrapperPath)}</string></array>
<key>StartInterval</key><integer>${intervalSec}</integer>
<key>StandardOutPath</key><string>${esc(home)}/.gbrain/brain-pull.log</string>
<key>StandardErrorPath</key><string>${esc(home)}/.gbrain/brain-pull.err</string>
</dict>
</plist>`;
}
function installDurabilityCron(sourceId: string, repoPath: string, branch: string, intervalSec: number, dryRun: boolean): { status: StepStatus; detail: string } {
const wrapper = dryRun ? cronWrapperPath(sourceId) : writeCronWrapper(sourceId, repoPath, branch);
const home = process.env.HOME || '';
if (process.platform === 'darwin') {
const plistPath = launchdPlistPath(sourceId);
if (dryRun) return { status: 'fixed', detail: `would install launchd ${cronLabel(sourceId)} every ${intervalSec}s (dry-run)` };
mkdirSync(dirname(plistPath), { recursive: true });
writeFileSync(plistPath, generateBrainPullPlist(cronLabel(sourceId), wrapper, home, intervalSec));
try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: 'ignore' }); } catch { /* */ }
try { execSync(`launchctl load "${plistPath}"`, { stdio: 'ignore' }); } catch { /* loaded best-effort */ }
return { status: 'fixed', detail: `launchd ${cronLabel(sourceId)} every ${intervalSec}s` };
}
// Linux: crontab line, deduped on the label marker.
const minutes = Math.max(1, Math.round(intervalSec / 60));
const marker = `# ${cronLabel(sourceId)}`;
const cronLine = `*/${minutes} * * * * ${wrapper} ${marker}`;
if (dryRun) return { status: 'fixed', detail: `would install crontab (every ${minutes}m) (dry-run)` };
let existingCron = '';
try { existingCron = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8' }); } catch { /* none */ }
const kept = existingCron.split('\n').filter(l => l && !l.includes(marker));
const next = [...kept, cronLine, ''].join('\n');
try {
execSync('crontab -', { input: next, stdio: ['pipe', 'ignore', 'ignore'] });
return { status: 'fixed', detail: `crontab every ${minutes}m` };
} catch (e) {
return { status: 'needs_attention', detail: `crontab install failed: ${(e as Error).message.slice(0, 120)}` };
}
}
function removeDurabilityCron(sourceId: string): boolean {
let removed = false;
if (process.platform === 'darwin') {
const plistPath = launchdPlistPath(sourceId);
if (existsSync(plistPath)) {
try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: 'ignore' }); } catch { /* */ }
rmSync(plistPath); removed = true;
}
} else {
const marker = `# ${cronLabel(sourceId)}`;
try {
const cur = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8' });
if (cur.includes(marker)) {
const next = cur.split('\n').filter(l => l && !l.includes(marker)).join('\n') + '\n';
execSync('crontab -', { input: next, stdio: ['pipe', 'ignore', 'ignore'] });
removed = true;
}
} catch { /* none */ }
}
const wrapper = cronWrapperPath(sourceId);
if (existsSync(wrapper)) { rmSync(wrapper); removed = true; }
return removed;
}
// ── PAT acceptance (D8) ─────────────────────────────────────────────────────
export interface AcceptPatResult { token: string; source: string; warnings: string[]; }
/**
* Resolve a PAT: --pat-file (preferred) > GBRAIN_GITHUB_PAT env. Never a bare CLI
* arg (process-listing leak). Validates non-empty; WARNs loudly on loose perms
* but continues (mirrors GBRAIN_ALLOW_PRIVATE_REMOTES). Returns null if none.
*/
export function acceptPat(opts: { patFile?: string }): AcceptPatResult | null {
const warnings: string[] = [];
if (opts.patFile) {
if (!existsSync(opts.patFile)) throw new Error(`--pat-file not found: ${opts.patFile}`);
try {
const mode = statSync(opts.patFile).mode;
if (mode & 0o077) warnings.push(`WARN: PAT file ${opts.patFile} is group/other-readable (mode ${(mode & 0o777).toString(8)}); chmod 600 it`);
} catch { /* */ }
const token = readFileSync(opts.patFile, 'utf-8').trim();
if (!token) throw new Error(`--pat-file is empty: ${opts.patFile}`);
return { token, source: 'pat-file', warnings };
}
const env = (process.env.GBRAIN_GITHUB_PAT || '').trim();
if (env) return { token: env, source: 'env:GBRAIN_GITHUB_PAT', warnings };
return null;
}
// ── Orchestration ───────────────────────────────────────────────────────────
function isGitRepo(repoPath: string): boolean {
return existsSync(join(repoPath, '.git'));
}
function currentBranch(repoPath: string): string {
try {
return execFileSync('git', ['-C', repoPath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
}).toString().trim();
} catch { return 'HEAD'; }
}
function headSha(repoPath: string): string {
try {
return execFileSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
}).toString().trim();
} catch { return ''; }
}
function pullDetail(o: PullOutcome): { status: StepStatus; detail: string } {
switch (o.status) {
case 'up_to_date': return { status: 'ok', detail: 'already up to date with origin' };
case 'advanced': return { status: 'fixed', detail: `advanced ${o.from.slice(0, 7)}${o.to.slice(0, 7)}` };
case 'skipped_dirty': return { status: 'skipped', detail: 'working tree dirty — pull skipped (in-progress edits preserved)' };
case 'conflict_aborted': return { status: 'needs_attention', detail: o.detail };
}
}
/**
* Harden a brain repo for durability. Idempotent: a second run on an
* already-hardened repo produces all ok/skipped and NO new commit.
*/
export async function hardenBrainRepo(opts: HardenOpts): Promise<DurabilityReport> {
const { repoPath, sourceId } = opts;
const dryRun = !!opts.dryRun;
const installCron = opts.installCron !== false;
const verify = opts.verify !== false;
const intervalSec = opts.intervalSec ?? 1800;
const redact = opts.pat ? (s: string) => redactSecretsInText(s, new Map([['github_pat', opts.pat!]])) : (s: string) => s;
const log = (l: string) => opts.logger?.(redact(l));
if (!isGitRepo(repoPath)) throw new Error(`not a git repo: ${repoPath}`);
const branch = opts.branch || detectDefaultBranch(repoPath);
const steps: DurabilityStep[] = [];
const push = (step: StepName, r: { status: StepStatus; detail: string }) => {
const s: DurabilityStep = { step, status: r.status, detail: redact(r.detail) };
steps.push(s); log(`[${step}] ${s.status}: ${s.detail}`);
return s;
};
// Refuse on detached HEAD — pushing to a wrong ref is worse than not pushing.
if (currentBranch(repoPath) === 'HEAD') {
push('pull', { status: 'needs_attention', detail: 'detached HEAD — checkout a branch before hardening' });
} else {
// 1. pull current state
try { push('pull', pullDetail(divergenceSafePull(repoPath, branch))); }
catch (e) { push('pull', { status: 'needs_attention', detail: `fetch/pull failed: ${(e as Error).message.slice(0, 140)}` }); }
}
// 2. credential
if (opts.pat) push('credential', wireRepoCredential(repoPath, opts.pat, dryRun));
else push('credential', { status: 'skipped', detail: 'no PAT provided — relying on existing git auth' });
// 3. local untracked hook
push('hook', installLocalHook(repoPath, dryRun));
// 4. committed helper
push('helper', installHelper(repoPath, dryRun));
// 5. resolver/AGENTS rules
push('agents', patchResolverFile(repoPath, dryRun));
// 6. cron
if (installCron) push('cron', installDurabilityCron(sourceId, repoPath, branch, intervalSec, dryRun));
else push('cron', { status: 'skipped', detail: '--no-cron' });
// 7. verify (push-probe) + commit scaffolding if push works
let clean = false;
if (verify && !dryRun) {
const probe: PushProbeResult = pushProbe(repoPath, branch, { redactDetail: redact });
if (!probe.ok) {
push('verify', { status: 'needs_attention', detail: `push-probe failed (${probe.reason}): ${probe.detail}` });
} else {
push('verify', { status: 'ok', detail: 'push-probe ok — push auth confirmed' });
// Commit the durability scaffolding (helper + rules) — real content, the
// genuine end-to-end proof (no synthetic heartbeat). No-op when unchanged.
const committed = commitScaffolding(repoPath, branch, redact);
if (committed) push('commit', committed);
clean = headMatchesOrigin(repoPath, branch);
}
} else if (dryRun) {
push('verify', { status: 'skipped', detail: 'dry-run' });
} else {
push('verify', { status: 'skipped', detail: '--no-verify' });
}
const missing = steps.filter(s => s.status === 'fixed').map(s => s.step);
const fixed = missing;
const needs_attention = steps.filter(s => s.status === 'needs_attention').map(s => `${s.step}: ${s.detail}`);
return { source_id: sourceId, repo_path: repoPath, branch, steps, missing, fixed, needs_attention, clean_against_origin: clean };
}
function commitScaffolding(repoPath: string, branch: string, redact: (s: string) => string): { status: StepStatus; detail: string } | null {
// Stage only the durability artifacts we manage — never a blind add.
const paths: string[] = [HELPER_REL];
const resolver = findResolverFile(repoPath);
if (resolver) paths.push(relative(repoPath, resolver));
try {
execFileSync('git', ['-C', repoPath, 'add', '--', ...paths], { stdio: 'ignore', timeout: 30_000, env: { ...process.env, ...GIT_ENV } });
const staged = execFileSync('git', ['-C', repoPath, 'diff', '--cached', '--name-only'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
}).toString().trim();
if (!staged) return { status: 'ok', detail: 'scaffolding already committed' };
execFileSync('git', ['-C', repoPath, 'commit', '-m', 'chore(gbrain): install brain durability scaffolding'], {
stdio: 'ignore', timeout: 30_000, env: { ...process.env, ...GIT_ENV },
});
execFileSync('git', ['-C', repoPath, ...['-c', 'http.followRedirects=false'], 'push', 'origin', `HEAD:${branch}`], {
stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000, env: { ...process.env, ...GIT_ENV_AUTH },
});
return { status: 'fixed', detail: 'committed + pushed durability scaffolding' };
} catch (e) {
return { status: 'needs_attention', detail: redact(`scaffolding commit/push failed: ${(e as Error).message.slice(0, 140)}`) };
}
}
function headMatchesOrigin(repoPath: string, branch: string): boolean {
try {
const local = headSha(repoPath);
const remote = execFileSync('git', ['-C', repoPath, 'rev-parse', `origin/${branch}`], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
}).toString().trim();
return !!local && local === remote;
} catch { return false; }
}
/** Remove durability scaffolding: cron, local hook, credential wiring. Leaves
* committed content (helper, resolver block) intact. Idempotent. */
export async function unhardenBrainRepo(opts: UnhardenOpts): Promise<DurabilityStep[]> {
const { repoPath, sourceId } = opts;
const steps: DurabilityStep[] = [];
const cronRemoved = removeDurabilityCron(sourceId);
steps.push({ step: 'cron', status: cronRemoved ? 'fixed' : 'skipped', detail: cronRemoved ? 'cron removed' : 'no cron' });
const hookRemoved = isGitRepo(repoPath) ? uninstallLocalHook(repoPath) : false;
steps.push({ step: 'hook', status: hookRemoved ? 'fixed' : 'skipped', detail: hookRemoved ? 'hook removed' : 'no gbrain hook' });
const credRemoved = isGitRepo(repoPath) ? removeCredentialWiring(repoPath) : false;
steps.push({ step: 'credential', status: credRemoved ? 'fixed' : 'skipped', detail: credRemoved ? 'credential wiring removed' : 'no gbrain credential wiring' });
opts.logger?.(steps.map(s => `[${s.step}] ${s.status}: ${s.detail}`).join('\n'));
return steps;
}
+203 -1
View File
@@ -140,7 +140,7 @@ export class GitOperationError extends Error {
}
}
const GIT_ENV = {
export const GIT_ENV = {
// Confine to the gbrain SSRF model — no credential helpers, no SSH askpass,
// no GUI prompts. Inherit PATH so git itself is findable.
GIT_TERMINAL_PROMPT: '0',
@@ -149,6 +149,21 @@ const GIT_ENV = {
SSH_ASKPASS: '/bin/false',
} as const;
/**
* Auth-capable git env for the durability push/probe paths (v0.42.44).
*
* Read-only clone/pull keep the strict GIT_ENV (askpass=/bin/false) so they can
* never prompt. But push, push-probe, and the durability cron's authed fetch
* MUST be able to consult the repo's configured credential helper (repo-scoped
* `store`/`osxkeychain`) — a `/bin/false` askpass would defeat that. We drop the
* askpass overrides but KEEP `GIT_TERMINAL_PROMPT=0` so a *missing* credential
* fails fast instead of hanging a non-interactive cron forever.
*/
export const GIT_ENV_AUTH = {
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never',
} as const;
/**
* Clone a remote git repo with SSRF-defensive flags.
* - destDir must NOT exist or must be empty.
@@ -287,3 +302,190 @@ export function validateRepoState(
}
return 'healthy';
}
// ── Durability helpers (v0.42.44) ───────────────────────────────────────────
// Used by the brain-repo durability feature (`gbrain sources harden/pull`) and
// the DB-free pull cron. These are the auth-capable, rebase-aware counterparts
// to the strict read-only `pullRepo` (which stays `--ff-only` for `sync.ts`).
/**
* Global SSRF flags for the durability fetch/pull/push paths. Identical to
* GIT_SSRF_FLAGS except `protocol.file.allow` honors the env escape hatch
* `GBRAIN_GIT_ALLOW_FILE_TRANSPORT=1` (mirrors GBRAIN_ALLOW_PRIVATE_REMOTES) so
* self-hosted local-filesystem remotes — and the test suite — can use the file
* transport. Default stays `never`. These ops act on an ALREADY-validated origin
* (set + checked at clone time); `http.followRedirects=false` is the live guard.
*/
function durableSsrfFlags(): string[] {
const fileAllow = process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT === '1' ? 'always' : 'never';
return [
'-c', 'http.followRedirects=false',
'-c', `protocol.file.allow=${fileAllow}`,
'-c', 'protocol.ext.allow=never',
];
}
/** Run a git subcommand, returning trimmed stdout. Throws GitOperationError. */
function runGit(
repoPath: string,
globalFlags: readonly string[],
subcommand: string,
subArgs: readonly string[],
op: GitOperationError['op'],
opts: { timeoutMs?: number; env?: Record<string, string> } = {},
): string {
try {
const out = execFileSync(
'git',
['-C', repoPath, ...globalFlags, subcommand, ...subArgs],
{
stdio: ['ignore', 'pipe', 'pipe'],
timeout: opts.timeoutMs ?? 120_000,
env: { ...process.env, ...(opts.env ?? GIT_ENV) },
},
);
return out.toString().trim();
} catch (e) {
throw new GitOperationError(op, `git ${subcommand} failed in ${repoPath}: ${(e as Error).message}`, e);
}
}
/** True if the working tree has staged or unstaged changes (untracked too). */
export function isWorkingTreeDirty(repoPath: string): boolean {
const out = runGit(repoPath, [], 'status', ['--porcelain'], 'pull', { timeoutMs: 30_000 });
return out.length > 0;
}
/**
* Resolve the repo's default branch, local-only (no network):
* origin/HEAD symbolic-ref → current branch (if not detached) → 'main'.
*/
export function detectDefaultBranch(repoPath: string): string {
try {
const sym = execFileSync('git', ['-C', repoPath, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
}).toString().trim();
if (sym.startsWith('origin/')) return sym.slice('origin/'.length);
if (sym) return sym;
} catch { /* origin/HEAD not set — fall through */ }
try {
const cur = execFileSync('git', ['-C', repoPath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
}).toString().trim();
if (cur && cur !== 'HEAD') return cur;
} catch { /* detached or no commits */ }
return 'main';
}
/** True if a rebase is mid-flight (rebase-merge or rebase-apply state dir exists). */
function rebaseInProgress(repoPath: string): boolean {
for (const name of ['rebase-merge', 'rebase-apply']) {
try {
const p = execFileSync('git', ['-C', repoPath, 'rev-parse', '--git-path', name], {
stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: { ...process.env, ...GIT_ENV },
}).toString().trim();
const abs = p.startsWith('/') ? p : join(repoPath, p);
if (existsSync(abs)) return true;
} catch { /* ignore */ }
}
return false;
}
export type PullOutcome =
| { status: 'up_to_date' }
| { status: 'advanced'; from: string; to: string }
| { status: 'skipped_dirty' }
| { status: 'conflict_aborted'; detail: string };
/**
* Divergence-safe pull: `fetch` + `pull --rebase`, never leaving a mid-rebase.
*
* - Dirty working tree → `skipped_dirty` (NORMAL mid-session state, not an
* error; never auto-stashes, never touches in-progress edits).
* - Rebase conflict → `git rebase --abort`, verify no rebase state remains,
* return `conflict_aborted` ("manual attention needed"). Never throws past
* this — the repo is always left clean (possibly un-advanced).
*
* Auth-capable (GIT_ENV_AUTH) so it works against private remotes via the
* repo's configured credential helper. SSRF flags applied on every call.
*/
export function divergenceSafePull(
repoPath: string,
branch: string,
opts: { timeoutMs?: number } = {},
): PullOutcome {
const timeoutMs = opts.timeoutMs ?? 300_000;
if (isWorkingTreeDirty(repoPath)) return { status: 'skipped_dirty' };
const before = runGit(repoPath, [], 'rev-parse', ['HEAD'], 'pull', { timeoutMs: 10_000 });
const ssrf = durableSsrfFlags();
runGit(repoPath, ssrf, 'fetch', [...GIT_SSRF_SUBCOMMAND_FLAGS, 'origin', branch], 'pull', {
timeoutMs, env: { ...GIT_ENV_AUTH },
});
try {
runGit(repoPath, ssrf, 'pull', [...GIT_SSRF_SUBCOMMAND_FLAGS, '--rebase', 'origin', branch], 'pull', {
timeoutMs, env: { ...GIT_ENV_AUTH },
});
} catch (e) {
// Abort any half-applied rebase so the tree is never left mid-rebase.
try {
execFileSync('git', ['-C', repoPath, 'rebase', '--abort'], {
stdio: 'ignore', timeout: 30_000, env: { ...process.env, ...GIT_ENV },
});
} catch { /* best-effort */ }
// If state STILL remains, try once more, then report regardless.
if (rebaseInProgress(repoPath)) {
try {
execFileSync('git', ['-C', repoPath, 'rebase', '--abort'], {
stdio: 'ignore', timeout: 30_000, env: { ...process.env, ...GIT_ENV },
});
} catch { /* best-effort */ }
}
return {
status: 'conflict_aborted',
detail: `pull --rebase on ${branch} conflicted; rebase aborted — manual attention needed (${(e as Error).message.slice(0, 120)})`,
};
}
const after = runGit(repoPath, [], 'rev-parse', ['HEAD'], 'pull', { timeoutMs: 10_000 });
return before === after ? { status: 'up_to_date' } : { status: 'advanced', from: before, to: after };
}
export type PushProbeResult =
| { ok: true }
| { ok: false; reason: 'auth' | 'protected' | 'unreachable' | 'other'; detail: string };
/**
* Authenticated `git push --dry-run` against origin/<branch>. Proves push auth
* works AND surfaces read-only PATs / branch protection BEFORE harden declares
* "hardened" — with zero history pollution (no commit). Auth-capable env.
*
* `redactDetail` (e.g. shell-redact's value scrubber bound to the PAT) is
* applied to the captured stderr so a token echoed by git never reaches a log.
*/
export function pushProbe(
repoPath: string,
branch: string,
opts: { timeoutMs?: number; redactDetail?: (s: string) => string } = {},
): PushProbeResult {
const redact = opts.redactDetail ?? ((s: string) => s);
try {
execFileSync(
'git',
['-C', repoPath, ...durableSsrfFlags(), 'push', ...GIT_SSRF_SUBCOMMAND_FLAGS, '--dry-run', 'origin', `HEAD:${branch}`],
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: opts.timeoutMs ?? 60_000, env: { ...process.env, ...GIT_ENV_AUTH } },
);
return { ok: true };
} catch (e) {
const raw = redact((e as Error).message || '');
const low = raw.toLowerCase();
let reason: 'auth' | 'protected' | 'unreachable' | 'other' = 'other';
if (low.includes('authentication') || low.includes('403') || low.includes('permission') || low.includes('could not read')) reason = 'auth';
else if (low.includes('protected') || low.includes('pre-receive') || low.includes('hook declined')) reason = 'protected';
else if (low.includes('could not resolve') || low.includes('unable to access') || low.includes('timed out') || low.includes('network')) reason = 'unreachable';
return { ok: false, reason, detail: raw.slice(0, 200) };
}
}