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
+16
View File
@@ -2,6 +2,22 @@
All notable changes to GBrain will be documented in this file. All notable changes to GBrain will be documented in this file.
## [0.42.48.0] - 2026-06-16
**Brain repos harden themselves for durability the moment gbrain is given a PAT and a GitHub URL.** Fresh agents kept drifting out of sync with their knowledge-wiki git repos: writes sat local-only and never pushed, long-lived sessions edited a stale tree, and scratch output landed outside the repo and vanished. Now `gbrain sources add --url <repo> --pat-file <p>` auto-hardens the managed clone, and `gbrain sources harden <id>` runs the same audit idempotently against any source. Hardening is six always-on guarantees: it pulls current state (divergence-safe rebase that skips a dirty tree and never leaves a half-rebase), installs a local auto-push safety net, ships a committed `scripts/brain-commit-push.sh` that refuses to report success without a confirmed push, writes always-on durability rules into the agent's context file (deterministic filing from the canonical taxonomy, commit-and-push-never-deferred, pull-before-each-write-batch), registers a 30-minute background pull so an idle session can't go stale, and verifies push access up front.
This is gbrain's first push path and first credential storage, built secure by default. The push automation is installed locally per machine rather than committed into the repo, the GitHub token is wired per-repo (least-privilege; an existing credential helper is reused when present rather than writing a new one), and the token never enters the repo, the tracked remote URL, logs, or the run report. Hardening proves push works with a dry-run probe before declaring done, so a read-only token or a protected branch surfaces immediately instead of silently dropping writes later. `gbrain sources unharden <id>` cleanly removes everything it installed and runs automatically before `sources remove`.
### Added
- `gbrain sources harden <id|--all> [--pat-file <p>] [--branch <b>] [--no-cron] [--no-verify] [--dry-run] [--json]` — idempotent brain-repo durability hardening.
- `gbrain sources pull <id> | --path <dir> [--branch <b>]` — divergence-safe rebase-pull; `--path` runs DB-free so the 30-minute cron never contends for the local engine lock.
- `gbrain sources unharden <id>` — remove the durability cron, hook, and credential wiring.
- `--pat-file` / `--no-harden` on `gbrain sources add`; managed clones added with a PAT auto-harden.
- `git-remote.ts`: `divergenceSafePull`, `detectDefaultBranch`, `pushProbe`, and an env-gated `GBRAIN_GIT_ALLOW_FILE_TRANSPORT` escape hatch for self-hosted filesystem remotes.
### To take advantage of v0.42.48.0
`gbrain upgrade`. Add a brain repo with `gbrain sources add <id> --url <https-repo> --pat-file <path-to-token>` and it hardens automatically; or run `gbrain sources harden <id> --pat-file <path>` on an existing source. Use a fine-grained PAT scoped to just that repo. Existing brains are untouched until you opt in.
## [0.42.47.0] - 2026-06-16 ## [0.42.47.0] - 2026-06-16
**A brain now travels with its own operating manual, and gbrain finally tells you how to run it better (gbrain#2180).** Two long-standing gaps closed. First: a brain repo can carry its own skillpack — skills authored for and versioned with that specific brain — and any harness that connects is offered it. Connect a fresh Claude Code or a thin client to a mature brain and it learns, on the spot, which meeting-ingestion or diligence protocol the brain expects, instead of starting blind. Second: gbrain stops being purely passive. `gbrain advisor` reads the brain's own state and hands back a ranked, read-only list of high-leverage actions — pending migrations, version drift, stalled backfills, low embedding coverage, setup smells — each with the exact command to fix it. It never acts on its own; it shows you and asks. **A brain now travels with its own operating manual, and gbrain finally tells you how to run it better (gbrain#2180).** Two long-standing gaps closed. First: a brain repo can carry its own skillpack — skills authored for and versioned with that specific brain — and any harness that connects is offered it. Connect a fresh Claude Code or a thin client to a mature brain and it learns, on the spot, which meeting-ingestion or diligence protocol the brain expects, instead of starting blind. Second: gbrain stops being purely passive. `gbrain advisor` reads the brain's own state and hands back a ranked, read-only list of high-leverage actions — pending migrations, version drift, stalled backfills, low embedding coverage, setup smells — each with the exact command to fix it. It never acts on its own; it shows you and asks.
+20
View File
@@ -1,5 +1,25 @@
# TODOS # TODOS
## brain-repo durability follow-ups (filed v0.42.48.0)
- [ ] **P3 — gbrain write-path calls commit-push synchronously when durability is on.**
v0.42.48.0 ships the synchronous `brain-commit-push.sh` as the guarantee and a local
post-commit hook as a best-effort fallback. The strongest durability (codex outside-voice
D13-C) is to have gbrain's own write-through path call the commit-push helper synchronously
when a source is hardened — that also covers writes that never get committed by an agent.
Deferred because it touches the write path; the hook + mandated helper cover the
agent-driven case today.
- **Where to start:** `src/core/write-through.ts:writePageThrough` + a per-source "hardened"
flag to gate the synchronous push.
- [ ] **P3 — Unify the durability pull cron with autopilot's OS-scheduler.**
v0.42.48.0 ships a minimal launchd/crontab installer inside `brain-repo-durability.ts`
(D12: minimal-now to keep the diff off the load-bearing autopilot feature). Extract a shared
`os-scheduler.ts` (`installPeriodic`/`removePeriodic`) and have both autopilot and brain-pull
call it, so there's one OS-cron path.
- **Where to start:** `src/commands/autopilot.ts` (`installLaunchd`/`installSystemd`/
`installCrontab`/`writeWrapperScript`) + `brain-repo-durability.ts:installDurabilityCron`.
## gbrain#2200 federated-read follow-ups (filed v0.42.46.0) ## gbrain#2200 federated-read follow-ups (filed v0.42.46.0)
- [ ] **P1 — Close the federated-read scope on the remaining same-class by-slug read ops.** - [ ] **P1 — Close the federated-read scope on the remaining same-class by-slug read ops.**
+1 -1
View File
@@ -1 +1 @@
0.42.47.0 0.42.48.0
+2 -1
View File
@@ -49,7 +49,8 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
- `src/core/disk-walk.ts``walkBrainRepo(repoPath)` returns `Map<slug, {size, mtimeMs}>` from one recursive `readdirSync`. Skips dot-dirs, `node_modules`, non-`.md` files. Used by `gbrain storage status` to replace per-page `existsSync + statSync` (~400K syscalls on 200K-page brains → tens). - `src/core/disk-walk.ts``walkBrainRepo(repoPath)` returns `Map<slug, {size, mtimeMs}>` from one recursive `readdirSync`. Skips dot-dirs, `node_modules`, non-`.md` files. Used by `gbrain storage status` to replace per-page `existsSync + statSync` (~400K syscalls on 200K-page brains → tens).
- `src/core/git-head.ts` — local git HEAD freshness probe for `gbrain doctor`. `isSourceUnchangedSinceSync(localPath, lastCommit, opts?)` returns true iff `localPath` is a git repo whose current HEAD matches `lastCommit`; when `opts.requireCleanWorkingTree` is true also requires a clean working tree (mirrors `gbrain sync`'s force-walk gate at `sync.ts:1075` so doctor and sync agree on "is there work to do?"). `requireCleanWorkingTree` is `boolean | 'ignore-untracked'` — in `'ignore-untracked'` mode the clean probe runs `git status --porcelain --untracked-files=no` so a quiet repo with stray untracked dirs (`?? companies/`, `?? media/`) is still "unchanged" (sync's incremental path keys off the commit diff and never imports untracked files); `GitCleanProbe` gains an `ignoreUntracked?` second arg. Two probe seams (`_setGitHeadProbeForTests`, `_setGitCleanProbeForTests`) keep unit tests R2-compliant (no `mock.module`). Uses `execFileSync` with array args so shell metachars in `local_path` cannot escape to a shell (the regression test runs real `execFileSync` against `'/nonexistent/$(touch <sentinel>)/repo'` and asserts the sentinel is never created). Fail-open on every error (missing path, not a git repo, git not installed, timeout, NULL inputs, dirty-probe errored → false) preserving the caller's prior time-based behavior. The chunker-version-match check lives in the caller (doctor.ts) because it depends on engine state (`sources.chunker_version` vs `CHUNKER_VERSION` from `src/core/chunkers/code.ts`). Pinned by `test/core/git-head.test.ts` (incl. the shell-injection regression guard). - `src/core/git-head.ts` — local git HEAD freshness probe for `gbrain doctor`. `isSourceUnchangedSinceSync(localPath, lastCommit, opts?)` returns true iff `localPath` is a git repo whose current HEAD matches `lastCommit`; when `opts.requireCleanWorkingTree` is true also requires a clean working tree (mirrors `gbrain sync`'s force-walk gate at `sync.ts:1075` so doctor and sync agree on "is there work to do?"). `requireCleanWorkingTree` is `boolean | 'ignore-untracked'` — in `'ignore-untracked'` mode the clean probe runs `git status --porcelain --untracked-files=no` so a quiet repo with stray untracked dirs (`?? companies/`, `?? media/`) is still "unchanged" (sync's incremental path keys off the commit diff and never imports untracked files); `GitCleanProbe` gains an `ignoreUntracked?` second arg. Two probe seams (`_setGitHeadProbeForTests`, `_setGitCleanProbeForTests`) keep unit tests R2-compliant (no `mock.module`). Uses `execFileSync` with array args so shell metachars in `local_path` cannot escape to a shell (the regression test runs real `execFileSync` against `'/nonexistent/$(touch <sentinel>)/repo'` and asserts the sentinel is never created). Fail-open on every error (missing path, not a git repo, git not installed, timeout, NULL inputs, dirty-probe errored → false) preserving the caller's prior time-based behavior. The chunker-version-match check lives in the caller (doctor.ts) because it depends on engine state (`sources.chunker_version` vs `CHUNKER_VERSION` from `src/core/chunkers/code.ts`). Pinned by `test/core/git-head.test.ts` (incl. the shell-injection regression guard).
- `src/core/source-health.ts` — per-source health metrics for `gbrain sources status` + doctor's `federation_health`. Commit-relative staleness: `newestCommitMs(localPath)` = HEAD committer time via `git log -1 --format=%ct` (fail-open null; NO working-tree mtime parsing — committed content only, robust against the porcelain-mtime bug farm); pure `lagFromContentMs(contentMs|null, lastSyncMs|null, nowMs)` = remote/column comparator (null lastSync → null; negative wall-clock → skew passthrough; `contentMs <= lastSync` → 0; else/null-content → wall-clock). `computeAllSourceMetrics(engine, sources, {probeContent?})`: LOCAL (`probeContent:true`, `gbrain sources status`) → `isSourceUnchangedSinceSync(..., {requireCleanWorkingTree:'ignore-untracked'}) ? 0 : wall-clock` (live commit-hash catches HEAD moving to an old-dated commit a timestamp compare would miss); REMOTE (default, `federation_health` on the HTTP MCP path) → `lagFromContentMs(row.newest_content_at, ...)`, NO git subprocess (trust boundary). `commitTimeMs(localPath, sha)` is the `newestCommitMs` sibling pinned to an arbitrary commit (committer time via `git show -s --format=%ct <sha>`, fail-open null, execFileSync array args) — the resumable sync stamps `newest_content_at` against its pinned target commit, not whatever HEAD raced to. Pinned by `test/source-health.test.ts`. - `src/core/source-health.ts` — per-source health metrics for `gbrain sources status` + doctor's `federation_health`. Commit-relative staleness: `newestCommitMs(localPath)` = HEAD committer time via `git log -1 --format=%ct` (fail-open null; NO working-tree mtime parsing — committed content only, robust against the porcelain-mtime bug farm); pure `lagFromContentMs(contentMs|null, lastSyncMs|null, nowMs)` = remote/column comparator (null lastSync → null; negative wall-clock → skew passthrough; `contentMs <= lastSync` → 0; else/null-content → wall-clock). `computeAllSourceMetrics(engine, sources, {probeContent?})`: LOCAL (`probeContent:true`, `gbrain sources status`) → `isSourceUnchangedSinceSync(..., {requireCleanWorkingTree:'ignore-untracked'}) ? 0 : wall-clock` (live commit-hash catches HEAD moving to an old-dated commit a timestamp compare would miss); REMOTE (default, `federation_health` on the HTTP MCP path) → `lagFromContentMs(row.newest_content_at, ...)`, NO git subprocess (trust boundary). `commitTimeMs(localPath, sha)` is the `newestCommitMs` sibling pinned to an arbitrary commit (committer time via `git show -s --format=%ct <sha>`, fail-open null, execFileSync array args) — the resumable sync stamps `newest_content_at` against its pinned target commit, not whatever HEAD raced to. Pinned by `test/source-health.test.ts`.
- `src/core/git-remote.ts` — SSRF-hardened git invocations for remote-source `cloneRepo`, `pullRepo`, and `fetchRemote(repoPath, branch)` (the last added for the sync cost-estimator's fetch-first path, #2139, so a cost preview / dry-run fetches through the same hardened flags + `GIT_TERMINAL_PROMPT=0` as real sync rather than a less-protected route). Exports two distinct flag constants because `git`'s argv grammar treats them differently: `GIT_SSRF_FLAGS` (3 `-c` config flags — `protocol.allow=user`, `protocol.file.allow=never`, `http.allowRedirects=false`) is global config, spread BEFORE the subcommand verb; `GIT_SSRF_SUBCOMMAND_FLAGS = ['--no-recurse-submodules']` is subcommand-scoped, spread AFTER the verb (a combined array would spread `--no-recurse-submodules` before the verb where real git rejects it exit 129). `cloneRepo` argv: `git <GIT_SSRF_FLAGS> clone <GIT_SSRF_SUBCOMMAND_FLAGS> --depth=1 [--branch X] -- <url> <dir>`. `pullRepo` argv: `git <GIT_SSRF_FLAGS> -C <dir> pull <GIT_SSRF_SUBCOMMAND_FLAGS> --ff-only`. Pinned by `test/git-remote.test.ts` position-anchored regression guard (`argv.indexOf('--no-recurse-submodules') > argv.indexOf(verb)`). - `src/core/git-remote.ts` — SSRF-hardened git invocations for remote-source `cloneRepo`, `pullRepo`, and `fetchRemote(repoPath, branch)` (the last added for the sync cost-estimator's fetch-first path, #2139, so a cost preview / dry-run fetches through the same hardened flags + `GIT_TERMINAL_PROMPT=0` as real sync rather than a less-protected route). Exports two distinct flag constants because `git`'s argv grammar treats them differently: `GIT_SSRF_FLAGS` (3 `-c` config flags — `protocol.allow=user`, `protocol.file.allow=never`, `http.allowRedirects=false`) is global config, spread BEFORE the subcommand verb; `GIT_SSRF_SUBCOMMAND_FLAGS = ['--no-recurse-submodules']` is subcommand-scoped, spread AFTER the verb (a combined array would spread `--no-recurse-submodules` before the verb where real git rejects it exit 129). `cloneRepo` argv: `git <GIT_SSRF_FLAGS> clone <GIT_SSRF_SUBCOMMAND_FLAGS> --depth=1 [--branch X] -- <url> <dir>`. `pullRepo` argv: `git <GIT_SSRF_FLAGS> -C <dir> pull <GIT_SSRF_SUBCOMMAND_FLAGS> --ff-only`. Pinned by `test/git-remote.test.ts` position-anchored regression guard (`argv.indexOf('--no-recurse-submodules') > argv.indexOf(verb)`). Also exports the durability-side helpers that power `gbrain sources harden/pull`: `GIT_ENV_AUTH` (the no-prompt env minus the askpass `/bin/false` overrides, so an auth'd push/fetch can consult the repo's configured credential helper while `GIT_TERMINAL_PROMPT=0` still fails fast on a missing credential), `divergenceSafePull(repoPath, branch)` (fetch + `pull --rebase`; returns `skipped_dirty` on a dirty tree, `conflict_aborted` on a rebase conflict after `rebase --abort` so the tree is never left mid-rebase, else `up_to_date`/`advanced`), `detectDefaultBranch` (origin/HEAD → current branch → `main`), `pushProbe(repoPath, branch)` (authenticated `push --dry-run` that proves push access and classifies `auth`/`protected`/`unreachable`), and `isWorkingTreeDirty`. These auth'd paths route their `protocol.file.allow` through `GBRAIN_GIT_ALLOW_FILE_TRANSPORT` (default `never`; set `=1` for self-hosted filesystem remotes), unlike clone/pull which stay strict.
- `src/core/brain-repo-durability.ts` + `src/commands/sources-harden.ts` — brain-repo git durability. `hardenBrainRepo(opts)` makes a brain's working tree durable, idempotently: divergence-safe pull, a LOCAL untracked `.git/hooks/post-commit` auto-push safety net (never committed — a pulled commit can't rewrite executed code next to the token; installed into the active `core.hooksPath` dir and excluded via `.git/info/exclude` when that dir is tracked), a committed `scripts/brain-commit-push.sh` that refuses to exit 0 without a confirmed push (hook + helper render from ONE bash push-retry template — DRY at the TS source, not by the hook sourcing a repo-controlled script), durability rules patched into the active resolver file (`findResolverFile` → RESOLVER.md > AGENTS.md; taxonomy rendered from the bundled `_brain-filing-rules.json`), a minimal DB-free pull cron (launchd/crontab running `gbrain sources pull --path <dir>` so it never opens the PGLite single-writer lock), and a push-probe verify (no heartbeat commit). Credential is REPO-scoped (`acceptPat` from `--pat-file`/`GBRAIN_GITHUB_PAT`, warns on loose perms; reuses an existing repo-local `credential.helper`, else a `0600` store wired via repo-local config); the token is redacted everywhere via `redactSecretsInText` and never enters the repo, remote URL, logs, or `DurabilityReport`. `unhardenBrainRepo` removes the cron/hook/credential wiring (ownership-fingerprinted) and runs before `sources remove`. CLI: `gbrain sources harden <id|--all>` / `pull <id>|--path <dir>` / `unharden <id>`; auto-harden fires on `sources add --url ... --pat-file` for managed clones (`--no-harden` opts out). `sources pull --path` is dispatched in `src/cli.ts` BEFORE `connectEngine` so the cron stays DB-free. CLI-only (writes executables + an OS cron + a credential helper on the host); never exposed over MCP. Tests: `test/brain-repo-durability.serial.test.ts`, `test/git-remote-durable.serial.test.ts`, `test/brain-durability-hook.serial.test.ts`, `test/durability-cron.test.ts`.
- `src/commands/storage.ts``gbrain storage status [--repo P] [--json]`. Split into pure data (`getStorageStatus`) + JSON formatter + human formatter (ASCII-only) matching the `orphans.ts` pattern. `PageCountsByTier` and `DiskUsageByTier` are distinct nominal types so swaps fail at compile time. - `src/commands/storage.ts``gbrain storage status [--repo P] [--json]`. Split into pure data (`getStorageStatus`) + JSON formatter + human formatter (ASCII-only) matching the `orphans.ts` pattern. `PageCountsByTier` and `DiskUsageByTier` are distinct nominal types so swaps fail at compile time.
- `gbrain.yml` (brain repo root) — Optional storage tiering config. Top-level `storage:` section with `db_tracked:` and `db_only:` array-valued keys. `gbrain sync` auto-manages `.gitignore` for `db_only` paths on successful sync (skips on dry-run, blocked-by-failures, submodule context, or `GBRAIN_NO_GITIGNORE=1`). `gbrain export --restore-only [--repo P] [--type T] [--slug-prefix S]` repopulates missing `db_only` files from the database. - `gbrain.yml` (brain repo root) — Optional storage tiering config. Top-level `storage:` section with `db_tracked:` and `db_only:` array-valued keys. `gbrain sync` auto-manages `.gitignore` for `db_only` paths on successful sync (skips on dry-run, blocked-by-failures, submodule context, or `GBRAIN_NO_GITIGNORE=1`). `gbrain export --restore-only [--repo P] [--type T] [--slug-prefix S]` repopulates missing `db_only` files from the database.
- `src/core/supabase-admin.ts` — Supabase admin API (project discovery, pgvector check). - `src/core/supabase-admin.ts` — Supabase admin API (project discovery, pgvector check).
+52
View File
@@ -155,6 +155,58 @@ Reads span federated sources by default. Writes require a resolved
source (explicit, inferred, or default). The resolver never picks a source (explicit, inferred, or default). The resolver never picks a
source silently when ambiguous — it errors with a clear fix. source silently when ambiguous — it errors with a clear fix.
## Durability: keep a brain repo in sync (auto-harden)
A long-lived agent that writes to a knowledge-wiki git repo needs three
things to never lose work: pull before it edits, push every write, and not
go stale while it sits idle. `gbrain sources harden` installs all of that,
idempotently. The moment you add a brain repo with a token, it runs
automatically:
```bash
# Clone + register a GitHub repo, then auto-harden it for durability.
# Use a fine-grained PAT scoped to just this repo.
gbrain sources add wiki --url https://github.com/you/brain-wiki.git --pat-file ~/.secrets/wiki-pat
# → clones, then installs: local auto-push hook, scripts/brain-commit-push.sh,
# always-on durability rules in AGENTS.md/RESOLVER.md, a 30-min pull cron,
# and a repo-scoped credential. Verifies push works before declaring done.
# Run the same audit on an existing source any time (idempotent):
gbrain sources harden wiki --pat-file ~/.secrets/wiki-pat
# Pull on demand (the cron calls the --path form, which never opens the DB):
gbrain sources pull wiki
# Remove the durability scaffolding (also runs automatically on `sources remove`):
gbrain sources unharden wiki
```
What hardening guarantees:
- **Pull-first, conflict-safe.** Every pull is a divergence-safe rebase. A
dirty working tree is skipped (your in-progress edits are never touched); a
rebase conflict is aborted cleanly and flagged for attention, never left
half-applied.
- **Push is never deferred.** `scripts/brain-commit-push.sh "<msg>" <path>`
commits and pushes atomically and refuses to report success without a
confirmed push. The post-commit hook is a best-effort background fallback;
the helper is the guarantee.
- **No silent staleness.** A 30-minute background pull keeps an idle session
current. It runs DB-free, so it never contends with a live brain for the
PGLite single-writer lock.
Flags: `--no-cron` skips the scheduled pull, `--no-verify` skips the push
probe, `--dry-run` reports what would change, `--json` emits a machine
report, `--all` hardens every source with a remote (same-account only).
`--no-harden` on `sources add` opts out of auto-harden.
Security: the push automation is installed locally per machine (never
committed into the repo), the token is wired per-repo (an existing
credential helper is reused when present), and it never appears in the repo,
the remote URL, logs, or the JSON report. For a self-hosted git server
reachable only over a filesystem path, set `GBRAIN_GIT_ALLOW_FILE_TRANSPORT=1`
(default is HTTPS-only).
## Upgrading an existing brain ## Upgrading an existing brain
`gbrain upgrade` runs the v16 + v17 migrations automatically. Your `gbrain upgrade` runs the v16 + v17 migrations automatically. Your
+1 -1
View File
@@ -143,5 +143,5 @@
"bun": ">=1.3.10" "bun": ">=1.3.10"
}, },
"license": "MIT", "license": "MIT",
"version": "0.42.47.0" "version": "0.42.48.0"
} }
+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 // CLI-only commands
if (CLI_ONLY.has(command)) { if (CLI_ONLY.has(command)) {
await handleCliOnly(command, subArgs); 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 displayName: string | undefined;
let federated: boolean | null = null; let federated: boolean | null = null;
let cloneDir: string | undefined; let cloneDir: string | undefined;
let patFile: string | undefined;
let noHarden = false;
for (let i = 1; i < args.length; i++) { for (let i = 1; i < args.length; i++) {
const a = args[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 === '--federated') { federated = true; continue; }
if (a === '--no-federated') { federated = false; continue; } if (a === '--no-federated') { federated = false; continue; }
if (a === '--clone-dir') { cloneDir = args[++i]; 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}`); console.error(`Unknown flag: ${a}`);
process.exit(2); process.exit(2);
} }
@@ -181,6 +185,36 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
console.log( console.log(
` federated: ${fed}${fed ? ' — appears in cross-source default search' : ' — only searched when explicitly named via --source'}`, ` 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]); await engine.executeRaw(`DELETE FROM sources WHERE id = $1`, [id]);
const pageCount = impact?.pageCount ?? 0; const pageCount = impact?.pageCount ?? 0;
console.log(`Removed source "${id}" (${pageCount} pages + dependent rows cascaded).`); 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) // v0.40.3.0 contextual retrieval (from master)
case 'set-cr-mode': return runSetCrMode(engine, rest); case 'set-cr-mode': return runSetCrMode(engine, rest);
case 'audit': return runAudit(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 undefined:
case '--help': case '--help':
case '-h': case '-h':
@@ -1316,6 +1364,15 @@ Subcommands:
override (v0.40.3.0). Pass "unset" or override (v0.40.3.0). Pass "unset" or
"default" to clear (NULL falls through "default" to clear (NULL falls through
to the global search.mode bundle). 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. 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, // Confine to the gbrain SSRF model — no credential helpers, no SSH askpass,
// no GUI prompts. Inherit PATH so git itself is findable. // no GUI prompts. Inherit PATH so git itself is findable.
GIT_TERMINAL_PROMPT: '0', GIT_TERMINAL_PROMPT: '0',
@@ -149,6 +149,21 @@ const GIT_ENV = {
SSH_ASKPASS: '/bin/false', SSH_ASKPASS: '/bin/false',
} as const; } 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. * Clone a remote git repo with SSRF-defensive flags.
* - destDir must NOT exist or must be empty. * - destDir must NOT exist or must be empty.
@@ -287,3 +302,190 @@ export function validateRepoState(
} }
return 'healthy'; 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) };
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* End-to-end durability hook + helper (v0.42.44): the generated bash actually
* pushes. Real git, local bare remote. Validates the D13 guarantee (helper),
* the D9 self-contained local hook, and the D7 "one push-retry template" claim
* (the hook works even with the committed helper deleted).
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import { hardenBrainRepo } from '../src/core/brain-repo-durability.ts';
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', ['-C', cwd, '-c', 'protocol.file.allow=always', ...args], {
stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8',
}).trim();
}
function originHead(bare: string): string {
return git(bare, 'rev-parse', 'refs/heads/main');
}
async function waitForOrigin(bare: string, expectSha: string, ms = 8000): Promise<boolean> {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
try { if (originHead(bare) === expectSha) return true; } catch { /* */ }
await new Promise(r => setTimeout(r, 150));
}
return false;
}
let root: string, work: string, bare: string;
let oldHome: string | undefined, oldGbrainHome: string | undefined;
beforeEach(async () => {
root = mkdtempSync(join(tmpdir(), 'bdh-'));
oldHome = process.env.HOME; oldGbrainHome = process.env.GBRAIN_HOME;
process.env.HOME = mkdtempSync(join(root, 'home-'));
process.env.GBRAIN_HOME = join(process.env.HOME, '.gbrain');
process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT = '1';
bare = mkdtempSync(join(root, 'origin-')) + '.git';
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', bare], { stdio: 'ignore' });
work = mkdtempSync(join(root, 'work-'));
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, work], { stdio: 'ignore' });
git(work, 'config', 'user.email', 't@t.t'); git(work, 'config', 'user.name', 'tester');
writeFileSync(join(work, 'README.md'), 'init\n');
git(work, 'add', 'README.md'); git(work, 'commit', '-qm', 'init'); git(work, 'push', '-q', 'origin', 'main');
git(work, 'remote', 'set-head', 'origin', 'main');
await hardenBrainRepo({ repoPath: work, sourceId: 'wiki', pat: 'ghp_x', installCron: false });
});
afterEach(() => {
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
if (oldGbrainHome === undefined) delete process.env.GBRAIN_HOME; else process.env.GBRAIN_HOME = oldGbrainHome;
delete process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT;
rmSync(root, { recursive: true, force: true });
});
describe('brain-commit-push.sh (D13 guarantee)', () => {
test('add → commit → push lands on origin', () => {
mkdirSync(join(work, 'people'), { recursive: true });
writeFileSync(join(work, 'people', 'alice.md'), '# alice\n');
// helper requires explicit path; stages people/alice.md
execFileSync('bash', [join(work, 'scripts', 'brain-commit-push.sh'), 'add alice', 'people/alice.md'], {
cwd: work, stdio: ['ignore', 'pipe', 'pipe'], env: process.env,
});
expect(originHead(bare)).toBe(git(work, 'rev-parse', 'HEAD'));
// origin actually has the file
const verify = mkdtempSync(join(root, 'verify-'));
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, verify], { stdio: 'ignore' });
expect(existsSync(join(verify, 'people', 'alice.md'))).toBe(true);
});
test('refuses success when the push cannot land (exit non-zero)', () => {
git(work, 'remote', 'set-url', 'origin', join(root, 'gone.git'));
writeFileSync(join(work, 'x.md'), 'x\n');
let code = 0;
try {
execFileSync('bash', [join(work, 'scripts', 'brain-commit-push.sh'), 'msg', 'x.md'], {
cwd: work, stdio: ['ignore', 'pipe', 'pipe'], env: process.env,
});
} catch (e: any) { code = e.status ?? 1; }
expect(code).not.toBe(0); // committed but push failed → loud failure
});
test('refuses a blind add (no explicit path)', () => {
let code = 0;
try {
execFileSync('bash', [join(work, 'scripts', 'brain-commit-push.sh'), 'msg'], {
cwd: work, stdio: ['ignore', 'pipe', 'pipe'], env: process.env,
});
} catch (e: any) { code = e.status ?? 1; }
expect(code).toBe(2);
});
});
describe('post-commit hook (D9 local, D7 self-contained)', () => {
test('a direct commit auto-pushes in the background', async () => {
writeFileSync(join(work, 'note.md'), 'note\n');
git(work, 'add', 'note.md'); git(work, 'commit', '-qm', 'note'); // fires .git/hooks/post-commit
const head = git(work, 'rev-parse', 'HEAD');
expect(await waitForOrigin(bare, head)).toBe(true);
});
test('the hook works even with the committed helper deleted (self-contained)', async () => {
rmSync(join(work, 'scripts', 'brain-commit-push.sh'));
git(work, 'add', '-A'); git(work, 'commit', '-qm', 'remove helper');
const head = git(work, 'rev-parse', 'HEAD');
expect(await waitForOrigin(bare, head)).toBe(true);
});
test('logs a clear LOCAL-ONLY line when origin is unreachable', async () => {
git(work, 'remote', 'set-url', 'origin', join(root, 'gone2.git'));
writeFileSync(join(work, 'orphan.md'), 'o\n');
git(work, 'add', 'orphan.md'); git(work, 'commit', '-qm', 'orphan');
const log = join(process.env.GBRAIN_HOME!, 'brain-push.log');
const deadline = Date.now() + 8000;
let found = false;
while (Date.now() < deadline) {
if (existsSync(log) && readFileSync(log, 'utf-8').includes('NEEDS ATTENTION')) { found = true; break; }
await new Promise(r => setTimeout(r, 150));
}
expect(found).toBe(true);
});
});
+223
View File
@@ -0,0 +1,223 @@
/**
* brain-repo-durability core (v0.42.44): hardenBrainRepo / unhardenBrainRepo /
* acceptPat. Real git against a local bare remote. HOME + GBRAIN_HOME are
* redirected to a tmp dir; installCron:false so the suite never touches launchd.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, chmodSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import {
hardenBrainRepo, unhardenBrainRepo, acceptPat,
} from '../src/core/brain-repo-durability.ts';
const PAT = 'ghp_TESTSECRETTOKEN0123456789abcdef';
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', ['-C', cwd, '-c', 'protocol.file.allow=always', ...args], {
stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8',
}).trim();
}
function commitCount(work: string): number {
return parseInt(git(work, 'rev-list', '--count', 'HEAD'), 10);
}
/** git config read that returns '' instead of throwing when the key is unset. */
function cfg(work: string, key: string): string {
try { return git(work, 'config', '--local', '--get', key); } catch { return ''; }
}
let root: string;
let work: string;
let bare: string;
let oldHome: string | undefined;
let oldGbrainHome: string | undefined;
function makePair(): void {
bare = mkdtempSync(join(root, 'origin-')) + '.git';
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', bare], { stdio: 'ignore' });
work = mkdtempSync(join(root, 'work-'));
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, work], { stdio: 'ignore' });
git(work, 'config', 'user.email', 't@t.t');
git(work, 'config', 'user.name', 'tester');
writeFileSync(join(work, 'README.md'), 'init\n');
git(work, 'add', 'README.md'); git(work, 'commit', '-qm', 'init'); git(work, 'push', '-q', 'origin', 'main');
try { git(work, 'remote', 'set-head', 'origin', 'main'); } catch { /* */ }
}
async function harden(extra: Record<string, unknown> = {}) {
return hardenBrainRepo({ repoPath: work, sourceId: 'wiki', pat: PAT, installCron: false, ...extra });
}
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'brd-'));
oldHome = process.env.HOME; oldGbrainHome = process.env.GBRAIN_HOME;
process.env.HOME = mkdtempSync(join(root, 'home-'));
process.env.GBRAIN_HOME = join(process.env.HOME, '.gbrain');
process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT = '1';
makePair();
});
afterEach(() => {
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
if (oldGbrainHome === undefined) delete process.env.GBRAIN_HOME; else process.env.GBRAIN_HOME = oldGbrainHome;
delete process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT;
rmSync(root, { recursive: true, force: true });
});
describe('hardenBrainRepo', () => {
test('installs hook (local, untracked, +x), helper, and AGENTS rules', async () => {
const r = await harden();
// hook
const hookPath = join(work, '.git', 'hooks', 'post-commit');
expect(existsSync(hookPath)).toBe(true);
expect(readFileSync(hookPath, 'utf-8')).toContain('post-commit hook');
expect(statSync(hookPath).mode & 0o111).toBeTruthy(); // executable
// helper (committed, +x)
const helperPath = join(work, 'scripts', 'brain-commit-push.sh');
expect(existsSync(helperPath)).toBe(true);
expect(statSync(helperPath).mode & 0o111).toBeTruthy();
// AGENTS.md with managed block + taxonomy
const agents = readFileSync(join(work, 'AGENTS.md'), 'utf-8');
expect(agents).toContain('BEGIN gbrain-brain-durability');
expect(agents).toContain('people/');
expect(agents).toContain('brain-commit-push.sh');
// verify pushed scaffolding → clean against origin
expect(r.clean_against_origin).toBe(true);
expect(r.needs_attention).toEqual([]);
});
test('is idempotent — second run adds NO new commit', async () => {
await harden();
const after1 = commitCount(work);
const r2 = await harden();
expect(commitCount(work)).toBe(after1); // no churn
// every step is ok/skipped on the second pass (nothing left to fix)
expect(r2.steps.every(s => s.status === 'ok' || s.status === 'skipped')).toBe(true);
});
test('the post-commit hook is UNTRACKED (never committed)', async () => {
await harden();
const tracked = git(work, 'ls-files');
expect(tracked.includes('post-commit')).toBe(false);
expect(tracked).toContain('scripts/brain-commit-push.sh'); // helper IS tracked
});
test('D3 — patches RESOLVER.md when it exists, not AGENTS.md', async () => {
writeFileSync(join(work, 'RESOLVER.md'), '# my resolver\n\nuser content\n');
git(work, 'add', 'RESOLVER.md'); git(work, 'commit', '-qm', 'resolver');
await harden();
expect(readFileSync(join(work, 'RESOLVER.md'), 'utf-8')).toContain('BEGIN gbrain-brain-durability');
expect(existsSync(join(work, 'AGENTS.md'))).toBe(false);
});
test('AGENTS block patch preserves user content above and below', async () => {
writeFileSync(join(work, 'AGENTS.md'), '# Top\n\nkeep above\n\n## footer\nkeep below\n');
git(work, 'add', 'AGENTS.md'); git(work, 'commit', '-qm', 'agents');
await harden();
const body = readFileSync(join(work, 'AGENTS.md'), 'utf-8');
expect(body).toContain('keep above');
expect(body).toContain('keep below');
expect(body).toContain('BEGIN gbrain-brain-durability');
// patch-in-place: exactly one managed block
expect(body.split('BEGIN gbrain-brain-durability').length - 1).toBe(1);
});
test('D11 — writes a repo-scoped credential (0600 store, local config, ownership key)', async () => {
await harden();
const store = join(process.env.GBRAIN_HOME!, 'git-credentials');
expect(existsSync(store)).toBe(true);
expect(statSync(store).mode & 0o077).toBe(0); // not group/other readable
expect(git(work, 'config', '--local', '--get', 'credential.helper')).toContain('store --file');
expect(cfg(work, 'gbrain.durability.managedcredential')).toBe('true');
});
test('D11 — reuses an existing credential.helper (no plaintext store written)', async () => {
git(work, 'config', 'credential.helper', 'osxkeychain');
await harden();
const store = join(process.env.GBRAIN_HOME!, 'git-credentials');
expect(existsSync(store)).toBe(false);
expect(git(work, 'config', '--local', '--get', 'credential.helper')).toBe('osxkeychain');
});
test('PAT never appears in the serialized report', async () => {
const r = await harden();
expect(JSON.stringify(r).includes(PAT)).toBe(false);
});
test('detached HEAD → pull step needs_attention (refuses to push to a wrong ref)', async () => {
const sha = git(work, 'rev-parse', 'HEAD');
git(work, 'checkout', '-q', sha); // detached
const r = await harden({ verify: false });
const pull = r.steps.find(s => s.step === 'pull');
expect(pull?.status).toBe('needs_attention');
});
test('D10 — verify reports needs_attention when push-probe fails (read-only/unreachable)', async () => {
git(work, 'remote', 'set-url', 'origin', join(root, 'unreachable.git'));
const r = await harden();
const verify = r.steps.find(s => s.step === 'verify');
expect(verify?.status).toBe('needs_attention');
expect(r.clean_against_origin).toBe(false);
expect(r.needs_attention.length).toBeGreaterThan(0);
// No scaffolding commit when we can't confirm a push.
expect(r.steps.find(s => s.step === 'commit')).toBeUndefined();
});
test('dry-run makes no commit and writes no files', async () => {
const before = commitCount(work);
await harden({ dryRun: true });
expect(commitCount(work)).toBe(before);
expect(existsSync(join(work, 'scripts', 'brain-commit-push.sh'))).toBe(false);
});
});
describe('unhardenBrainRepo', () => {
test('removes hook + credential wiring; leaves committed content', async () => {
await harden();
const steps = await unhardenBrainRepo({ repoPath: work, sourceId: 'wiki' });
expect(existsSync(join(work, '.git', 'hooks', 'post-commit'))).toBe(false);
expect(cfg(work, 'gbrain.durability.managedcredential')).toBe('');
// committed helper stays
expect(existsSync(join(work, 'scripts', 'brain-commit-push.sh'))).toBe(true);
expect(steps.find(s => s.step === 'hook')?.status).toBe('fixed');
});
test('idempotent when not hardened (all skipped)', async () => {
const steps = await unhardenBrainRepo({ repoPath: work, sourceId: 'wiki' });
expect(steps.every(s => s.status === 'skipped')).toBe(true);
});
});
describe('acceptPat (D8)', () => {
test('reads + trims a pat-file', () => {
const p = join(root, 'pat.txt');
writeFileSync(p, `${PAT}\n`, { mode: 0o600 });
const r = acceptPat({ patFile: p });
expect(r?.token).toBe(PAT);
expect(r?.warnings).toEqual([]);
});
test('throws on a missing pat-file', () => {
expect(() => acceptPat({ patFile: join(root, 'nope.txt') })).toThrow();
});
test('throws on an empty pat-file', () => {
const p = join(root, 'empty.txt'); writeFileSync(p, ' \n', { mode: 0o600 });
expect(() => acceptPat({ patFile: p })).toThrow();
});
test('warns (but continues) on loose perms', () => {
const p = join(root, 'loose.txt'); writeFileSync(p, PAT); chmodSync(p, 0o644);
const r = acceptPat({ patFile: p });
expect(r?.token).toBe(PAT);
expect(r?.warnings.length).toBeGreaterThan(0);
});
test('falls back to GBRAIN_GITHUB_PAT env', () => {
const old = process.env.GBRAIN_GITHUB_PAT;
process.env.GBRAIN_GITHUB_PAT = PAT;
try { expect(acceptPat({})?.source).toBe('env:GBRAIN_GITHUB_PAT'); }
finally { if (old === undefined) delete process.env.GBRAIN_GITHUB_PAT; else process.env.GBRAIN_GITHUB_PAT = old; }
});
test('returns null when no PAT is available', () => {
const old = process.env.GBRAIN_GITHUB_PAT; delete process.env.GBRAIN_GITHUB_PAT;
try { expect(acceptPat({})).toBeNull(); }
finally { if (old !== undefined) process.env.GBRAIN_GITHUB_PAT = old; }
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* Durability cron generators (v0.42.44, D2 + D12): pure-string renderers.
* Asserts the cron is DB-free (gbrain sources pull --path, NOT `pull <id>`),
* secret-free, self-disabling, and that the launchd plist is periodic.
*/
import { describe, test, expect } from 'bun:test';
import { renderCronWrapper, generateBrainPullPlist } from '../src/core/brain-repo-durability.ts';
const TOKEN = 'ghp_SHOULD_NEVER_APPEAR';
describe('renderCronWrapper (D2 DB-free)', () => {
const w = renderCronWrapper('wiki', '/data/clones/wiki', 'main', '/usr/local/bin/gbrain', '/home/u/.gbrain/brain-push.log');
test('calls the DB-free path command, not the engine-opening one', () => {
expect(w).toContain("sources pull --path '/data/clones/wiki'");
expect(w).toContain("--branch 'main'");
expect(w).not.toMatch(/sources pull '?wiki'?(\s|$)/); // never `sources pull wiki`
});
test('self-disables when the captured checkout is gone', () => {
expect(w).toContain("if [ ! -d '/data/clones/wiki/.git' ]");
expect(w).toContain('path gone, skipping');
});
test('sources the shell profile (secret-free) and never bakes a token', () => {
expect(w).toContain('source ~/.zshenv');
expect(w.includes(TOKEN)).toBe(false);
});
});
describe('generateBrainPullPlist (D12 launchd)', () => {
const plist = generateBrainPullPlist('com.gbrain.brain-pull.wiki', '/home/u/.gbrain/brain-pull-wiki.sh', '/home/u', 1800);
test('is periodic (StartInterval), not a KeepAlive daemon', () => {
expect(plist).toContain('<key>StartInterval</key><integer>1800</integer>');
expect(plist).not.toContain('<key>KeepAlive</key>');
});
test('carries the per-source label and the wrapper path only (no secret)', () => {
expect(plist).toContain('<string>com.gbrain.brain-pull.wiki</string>');
expect(plist).toContain('/home/u/.gbrain/brain-pull-wiki.sh');
expect(plist.includes(TOKEN)).toBe(false);
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* git-remote durability helpers (v0.42.44): divergenceSafePull, detectDefaultBranch,
* pushProbe, isWorkingTreeDirty. Real git against local bare remotes.
*
* Local file transport is enabled via GBRAIN_GIT_ALLOW_FILE_TRANSPORT=1 (the
* documented escape hatch the durability paths honor).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import {
divergenceSafePull, detectDefaultBranch, pushProbe, isWorkingTreeDirty,
} from '../src/core/git-remote.ts';
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', ['-C', cwd, '-c', 'protocol.file.allow=always', ...args], {
stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8',
}).trim();
}
function gitIn(cwd: string, ...args: string[]): string {
return execFileSync('git', [...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' }).trim();
}
let root: string;
let bare: string;
/** A bare origin + a working clone with one commit on `main`. */
function makePair(): { bare: string; work: string } {
const b = mkdtempSync(join(root, 'origin-')) + '.git';
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', b], { stdio: 'ignore' });
const w = mkdtempSync(join(root, 'work-'));
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', b, w], { stdio: 'ignore' });
git(w, 'config', 'user.email', 't@t.t');
git(w, 'config', 'user.name', 'tester');
writeFileSync(join(w, 'README.md'), 'init\n');
git(w, 'add', 'README.md');
git(w, 'commit', '-qm', 'init');
git(w, 'push', '-q', 'origin', 'main');
// Set origin/HEAD so detectDefaultBranch can resolve it.
try { git(w, 'remote', 'set-head', 'origin', 'main'); } catch { /* */ }
return { bare: b, work: w };
}
function secondClone(b: string): string {
const w = mkdtempSync(join(root, 'work2-'));
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', b, w], { stdio: 'ignore' });
git(w, 'config', 'user.email', 'u@u.u');
git(w, 'config', 'user.name', 'tester2');
return w;
}
beforeAll(() => { process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT = '1'; });
afterAll(() => { delete process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT; });
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'gd-')); });
afterEach(() => { rmSync(root, { recursive: true, force: true }); });
describe('detectDefaultBranch', () => {
test('resolves origin/HEAD', () => {
const { work } = makePair();
expect(detectDefaultBranch(work)).toBe('main');
});
test('falls back to main when nothing resolves', () => {
const empty = mkdtempSync(join(root, 'bare-')); // not a git repo
expect(detectDefaultBranch(empty)).toBe('main');
});
});
describe('isWorkingTreeDirty', () => {
test('false when clean, true after an edit', () => {
const { work } = makePair();
expect(isWorkingTreeDirty(work)).toBe(false);
writeFileSync(join(work, 'README.md'), 'changed\n');
expect(isWorkingTreeDirty(work)).toBe(true);
});
});
describe('divergenceSafePull', () => {
test('up_to_date when already current', () => {
const { work } = makePair();
expect(divergenceSafePull(work, 'main').status).toBe('up_to_date');
});
test('advanced when origin has a new commit', () => {
const { bare, work } = makePair();
const other = secondClone(bare);
writeFileSync(join(other, 'b.txt'), 'b\n');
git(other, 'add', 'b.txt'); git(other, 'commit', '-qm', 'b'); git(other, 'push', '-q', 'origin', 'main');
const out = divergenceSafePull(work, 'main');
expect(out.status).toBe('advanced');
expect(existsSync(join(work, 'b.txt'))).toBe(true);
});
test('skipped_dirty when the working tree is dirty', () => {
const { work } = makePair();
writeFileSync(join(work, 'README.md'), 'local edit\n');
expect(divergenceSafePull(work, 'main').status).toBe('skipped_dirty');
});
test('rebases local commits over origin', () => {
const { bare, work } = makePair();
const other = secondClone(bare);
writeFileSync(join(other, 'remote.txt'), 'r\n');
git(other, 'add', 'remote.txt'); git(other, 'commit', '-qm', 'remote'); git(other, 'push', '-q', 'origin', 'main');
// local commit on a DIFFERENT file → clean rebase
writeFileSync(join(work, 'local.txt'), 'l\n');
git(work, 'add', 'local.txt'); git(work, 'commit', '-qm', 'local');
const out = divergenceSafePull(work, 'main');
expect(out.status).toBe('advanced');
expect(existsSync(join(work, 'remote.txt'))).toBe(true);
expect(existsSync(join(work, 'local.txt'))).toBe(true);
});
test('conflict_aborted leaves NO rebase state', () => {
const { bare, work } = makePair();
const other = secondClone(bare);
writeFileSync(join(other, 'README.md'), 'remote version\n');
git(other, 'add', 'README.md'); git(other, 'commit', '-qm', 'remote'); git(other, 'push', '-q', 'origin', 'main');
// local commit touching the SAME line → rebase conflict
writeFileSync(join(work, 'README.md'), 'local version\n');
git(work, 'add', 'README.md'); git(work, 'commit', '-qm', 'local');
const out = divergenceSafePull(work, 'main');
expect(out.status).toBe('conflict_aborted');
// The "never mid-rebase" invariant:
expect(existsSync(join(work, '.git', 'rebase-merge'))).toBe(false);
expect(existsSync(join(work, '.git', 'rebase-apply'))).toBe(false);
// Working tree is usable (HEAD is the local commit, not a conflicted state).
expect(gitIn(work, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe('main');
});
});
describe('pushProbe', () => {
test('ok against a writable remote', () => {
const { work } = makePair();
expect(pushProbe(work, 'main')).toEqual({ ok: true });
});
test('not ok when origin is unreachable', () => {
const { work } = makePair();
git(work, 'remote', 'set-url', 'origin', join(root, 'does-not-exist.git'));
const r = pushProbe(work, 'main');
expect(r.ok).toBe(false);
});
test('redactDetail scrubs a token from the failure detail', () => {
const { work } = makePair();
git(work, 'remote', 'set-url', 'origin', join(root, 'nope-ghp_SECRETTOKEN.git'));
const r = pushProbe(work, 'main', { redactDetail: (s) => s.replaceAll('ghp_SECRETTOKEN', '***') });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.detail.includes('ghp_SECRETTOKEN')).toBe(false);
});
});