mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 18:02:30 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48890e35be |
@@ -109,8 +109,9 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Real-agent door e2e: drives the ACTUAL `claude` + `codex` + `hermes`
|
||||
# binaries (no PATH shims) against a real gbrain over MCP. These pay real API
|
||||
# Real-agent door e2e: drives the ACTUAL `claude` + `codex` + `hermes` +
|
||||
# `grok` + `opencode` binaries (no PATH shims) against a real gbrain over
|
||||
# MCP. These pay real API
|
||||
# cost and need the binaries installed + authed, which a stock GitHub runner
|
||||
# does NOT have — so the tests self-SKIP (describe.skipIf on binary/auth) and
|
||||
# the job is a clean no-op here. It exists so a self-hosted /
|
||||
@@ -133,10 +134,13 @@ jobs:
|
||||
# a grok binary, which a stock runner does not have.
|
||||
GBRAIN_REAL_HERMES_E2E: '1'
|
||||
GBRAIN_REAL_GROK_E2E: '1'
|
||||
GBRAIN_REAL_OPENCODE_E2E: '1'
|
||||
# Pin so a provisioned runner's grok version-shape test asserts against
|
||||
# the supported version (and a colliding community `grok` binary fails
|
||||
# loud instead of running the keyless tier confusingly).
|
||||
GROK_VERSION: "1.0.4"
|
||||
# Same posture for opencode: a provisioned runner's version pin.
|
||||
OPENCODE_VERSION: "1.18.18"
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
@@ -156,7 +160,8 @@ jobs:
|
||||
test/e2e/bootstrap-real-claude.serial.test.ts \
|
||||
test/e2e/bootstrap-real-codex.serial.test.ts \
|
||||
test/e2e/install-real-hermes.serial.test.ts \
|
||||
test/e2e/install-real-grok.serial.test.ts; do
|
||||
test/e2e/install-real-grok.serial.test.ts \
|
||||
test/e2e/install-real-opencode.serial.test.ts; do
|
||||
[ -f "$f" ] && files+=("$f")
|
||||
done
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
@@ -195,7 +200,7 @@ jobs:
|
||||
HERMES_VERSION: "0.20.0"
|
||||
HERMES_GIT_TAG: "v2026.8.3"
|
||||
HERMES_GIT_COMMIT: "3c27eb6234bf91b8ceee9e9071591b31e9b148cb"
|
||||
HERMES_INSTALL_SHA256: "c118ff31618dc70339049ce71061b8f1351a1c70d9c2a236ed50d8a2550c550d"
|
||||
HERMES_INSTALL_SHA256: "868ed3a91e0fabbff6d7418b3ede82bf4833652ec4e77196a42852fb35a9e5b9"
|
||||
GBRAIN_REAL_HERMES_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
@@ -595,3 +600,268 @@ jobs:
|
||||
# hermetic homes carry no key file (env-only auth) but may hold
|
||||
# grok-derived credentials once the authed inventory lands.
|
||||
rm -rf /tmp/gb-grok-* 2>/dev/null || true
|
||||
|
||||
# opencode door e2e (SST opencode): PROVISIONS the real opencode binary via
|
||||
# the pinned npm package (wrapper + per-platform payload integrities
|
||||
# verified — both pins live in docs/mcp/OPENCODE-CLI-PIN.md, enforced
|
||||
# against this file by scripts/check-opencode-pin.sh in `bun run verify`).
|
||||
#
|
||||
# DAY-ONE FULL POSTURE (a step past grok's pre-secret gating, deliberate):
|
||||
# opencode's anonymous free tier drives MCP tool calls keyless (observed,
|
||||
# load-bearing — OPENCODE-CLI-PIN.md §One-shot), so the ENTIRE core door —
|
||||
# including the nonce SMOKE — runs with no secret; and the paid anthropic
|
||||
# leg rides the ANTHROPIC_API_KEY secret that already exists (hermes-door
|
||||
# consumes it). So this job takes the hermes-door triggers (nightly +
|
||||
# labels + dispatch, cadence policy: nightly for the NEWEST door agent)
|
||||
# with grok-door's internals (keyless-first ordering, secretless pinned
|
||||
# provisioning, sentinels, scrub triple, unconditional credential removal).
|
||||
# No dedicated dispatch input: any workflow_dispatch already passes the
|
||||
# non-PR arm, so an input would be dead yaml.
|
||||
opencode-door:
|
||||
name: opencode door e2e (real binary, keyless SMOKE)
|
||||
if: |
|
||||
github.event_name != 'pull_request' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'real-agent-e2e') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'heavy-tests')
|
||||
runs-on: ubuntu-latest
|
||||
# Measured local door wall-time: full 6-test run 35.8s + one-time
|
||||
# compiled gbrain build (~2-4 min) + npm install (~15s); free-tier +
|
||||
# paid turn budgets 2 x 240s each. 20 min = measured + >50% headroom.
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# Pin values documented in docs/mcp/OPENCODE-CLI-PIN.md — update them
|
||||
# together, deliberately, after reviewing upstream changes
|
||||
# (scripts/check-opencode-pin.sh fails `bun run verify` on drift).
|
||||
OPENCODE_VERSION: "1.18.18"
|
||||
OPENCODE_NPM_PACKAGE: "opencode-ai"
|
||||
OPENCODE_NPM_INTEGRITY: "sha512-J+5HFq8tf+wPBBpBpMPSNjSytF2/EkNWYfFZh4si1d9auFbQriqDyqZv+vFUsLWERfdMU32Eajwuiq3rKBvZLQ=="
|
||||
# Per-platform payload pins: the wrapper's integrity covers only the
|
||||
# wrapper tarball; the binary that EXECUTES is the platform sub-package.
|
||||
OPENCODE_NPM_LINUX_X64_INTEGRITY: "sha512-WmeUnhljYJ252wywKTiW4bNDzsas2njpjPUEh0jM6HKNI4vFxJtREtzaWViY4AKEAcOkLWT8Ll17ixvcHz3AnA=="
|
||||
OPENCODE_NPM_LINUX_ARM64_INTEGRITY: "sha512-e8D3g0qJEIzawEg2+ygW3vkZjAYL2ssyAx4GbihjwXwZFvlZZy5zRWWzdz5KLBoHSTl0FB73vNtnNeXONyHpVQ=="
|
||||
GBRAIN_REAL_OPENCODE_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
|
||||
- name: Prepare evidence dir
|
||||
run: |
|
||||
echo "GBRAIN_E2E_EVIDENCE_DIR=$RUNNER_TEMP/opencode-door-evidence" >> "$GITHUB_ENV"
|
||||
mkdir -p "$RUNNER_TEMP/opencode-door-evidence"
|
||||
|
||||
# Compile gbrain ONCE for both bun test invocations below.
|
||||
- name: Build gbrain (compile once for both door runs)
|
||||
run: |
|
||||
bun build --compile --outfile "$RUNNER_TEMP/gbrain-door-bin" src/cli.ts
|
||||
echo "GBRAIN_COMPILED_BIN=$RUNNER_TEMP/gbrain-door-bin" >> "$GITHUB_ENV"
|
||||
|
||||
# SECRETLESS provisioning, pack-verify-install: `npm pack` DOWNLOADS
|
||||
# each artifact and reports the integrity of the BYTES it wrote, so the
|
||||
# asserts below cover the tarballs actually held — closing the
|
||||
# view-then-install TOCTOU (two registry round-trips a payload-swapping
|
||||
# registry could split). The wrapper then installs FROM the verified
|
||||
# local tarball, not a fresh registry resolve of the name. Payload
|
||||
# resolution, honestly: that install still fetches the platform
|
||||
# sub-package (opencode-linux-*) over the network; after the pack step
|
||||
# byte-confirms the registry's payload artifact matches its pin, npm
|
||||
# validates the install-time fetch against the same packument
|
||||
# integrity. No --ignore-scripts: opencode-ai's postinstall places the
|
||||
# platform binary (verified locally — with the flag the CLI refuses to
|
||||
# run). Version assert lives here too — before any secret-bearing step.
|
||||
- name: Install opencode (pinned npm package, pack-verify-install)
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
packdir=$(mktemp -d)
|
||||
read_integrity() {
|
||||
node -e 'let d;try{d=JSON.parse(require("fs").readFileSync(0,"utf8"))}catch{d=[]}process.stdout.write((Array.isArray(d)&&d[0]&&d[0].integrity)||"")'
|
||||
}
|
||||
pushd "$packdir" >/dev/null
|
||||
served=$(npm pack "$OPENCODE_NPM_PACKAGE@$OPENCODE_VERSION" --json 2>/dev/null | read_integrity || true)
|
||||
if [ "$served" != "$OPENCODE_NPM_INTEGRITY" ]; then
|
||||
echo "::error::opencode npm integrity drift for $OPENCODE_NPM_PACKAGE@$OPENCODE_VERSION — packed tarball integrity '$served', pinned '$OPENCODE_NPM_INTEGRITY'. Re-pin deliberately: update the stamps in docs/mcp/OPENCODE-CLI-PIN.md + this workflow after reviewing upstream (see the pin doc's re-observation checklist)." >&2
|
||||
exit 1
|
||||
fi
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) plat_pkg="opencode-linux-x64"; plat_pin="$OPENCODE_NPM_LINUX_X64_INTEGRITY" ;;
|
||||
aarch64|arm64) plat_pkg="opencode-linux-arm64"; plat_pin="$OPENCODE_NPM_LINUX_ARM64_INTEGRITY" ;;
|
||||
*) echo "::error::unsupported runner arch for the opencode payload pin: $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
plat_served=$(npm pack "$plat_pkg@$OPENCODE_VERSION" --json 2>/dev/null | read_integrity || true)
|
||||
if [ "$plat_served" != "$plat_pin" ]; then
|
||||
echo "::error::opencode platform payload integrity drift for $plat_pkg@$OPENCODE_VERSION — packed tarball integrity '$plat_served', pinned '$plat_pin'. Re-pin deliberately (OPENCODE-CLI-PIN.md stamps + this workflow)." >&2
|
||||
exit 1
|
||||
fi
|
||||
npm install -g ./opencode-ai-*.tgz
|
||||
popd >/dev/null
|
||||
rm -rf "$packdir"
|
||||
if ! command -v opencode >/dev/null 2>&1; then
|
||||
echo "::error::opencode did not resolve on PATH after npm install" >&2
|
||||
exit 1
|
||||
fi
|
||||
version_output=$(opencode --version)
|
||||
echo "$version_output"
|
||||
# Observed shape: BARE semver (`1.18.18` — no name, no hash); the
|
||||
# SST-vs-claimant discriminator (OPENCODE-CLI-PIN.md §Pin).
|
||||
if [ "$(printf '%s' "$version_output" | tr -d '[:space:]')" != "$OPENCODE_VERSION" ]; then
|
||||
echo "::error::opencode version drift — expected bare '$OPENCODE_VERSION', got: $version_output (see docs/mcp/OPENCODE-CLI-PIN.md triage table)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# KEYLESS TIER FIRST — and on opencode that includes the nonce SMOKE
|
||||
# (free tier). ANTHROPIC_API_KEY is absent from this step by
|
||||
# construction, so the paid describe self-skips.
|
||||
- name: Run opencode door tests (keyless tier — SMOKE included)
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-opencode.serial.test.ts > door-keyless.txt 2>&1 || EXIT=$?
|
||||
tail -40 door-keyless.txt
|
||||
cp door-keyless.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
exit "$EXIT"
|
||||
fi
|
||||
# Exact expected shape for this tier: 5 keyless tests pass (T1, T2,
|
||||
# T2b, T3, T4-SMOKE), the 1 paid test skips. Zero/partial-pass
|
||||
# refuses green.
|
||||
pass_count=$(grep -Eo '[0-9]+ pass' door-keyless.txt | tail -1 | grep -Eo '^[0-9]+' || true)
|
||||
if [ -z "$pass_count" ] || [ "$pass_count" -lt 5 ]; then
|
||||
echo "::error::opencode door keyless tier expected 5 passing tests, summary shows '${pass_count:-none}' — refusing to go green (see docs/mcp/OPENCODE-CLI-PIN.md triage table)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Preconditions (secret present)
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
||||
echo "::error::ANTHROPIC_API_KEY secret is empty — the keyless tier above already ran (its coverage, including the SMOKE, is banked); the paid anthropic leg needs the secret hermes-door already consumes. Fork PRs get no secrets from GitHub." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Full run (paid anthropic leg included). The T5 models-gate inside the
|
||||
# suite is the named bad-pin tripwire: it validates the pinned model id
|
||||
# against the AUTHED `opencode models` list BEFORE any spend.
|
||||
- name: Run opencode door tests (full — paid anthropic leg included)
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-opencode.serial.test.ts > door.txt 2>&1 || EXIT=$?
|
||||
tail -40 door.txt
|
||||
cp door.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
exit "$EXIT"
|
||||
fi
|
||||
# PAID-SENTINEL: with the key present, a skipping paid tier must
|
||||
# never read as green (the split-gating false-green class). The
|
||||
# grep target is the suite's literal skip log — mirrored in
|
||||
# test/e2e/install-real-opencode.serial.test.ts (change together).
|
||||
if grep -q 'SKIP paid tier' door.txt; then
|
||||
echo "::error::opencode door paid tier skipped despite a present ANTHROPIC_API_KEY — hasOpencodeAuth() gate drift; refusing to go green" >&2
|
||||
exit 1
|
||||
fi
|
||||
pass_count=$(grep -Eo '[0-9]+ pass' door.txt | tail -1 | grep -Eo '^[0-9]+' || true)
|
||||
if [ -z "$pass_count" ] || [ "$pass_count" -lt 6 ]; then
|
||||
echo "::error::opencode door full run expected 6 passing tests, summary shows '${pass_count:-none}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auto-update tripwire: the DOUBLE kill (config seed + env var) is the
|
||||
# whole defense — a version that MOVED mid-job means it failed and the
|
||||
# pins above are no longer what just ran.
|
||||
- name: Version re-check (mid-job drift tripwire)
|
||||
if: always()
|
||||
run: |
|
||||
if command -v opencode >/dev/null 2>&1; then
|
||||
version_output=$(opencode --version || true)
|
||||
if [ "$(printf '%s' "$version_output" | tr -d '[:space:]')" != "$OPENCODE_VERSION" ]; then
|
||||
echo "::error::opencode version moved mid-job — auto-update kill failed (expected '$OPENCODE_VERSION', got: $version_output)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Scrub credentials from evidence (defensive)
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
# Same triple as the sibling doors, RE-KEYED for this lane: the
|
||||
# credential file candidate is opencode's auth.json and the content
|
||||
# grep sweeps ANTHROPIC_API_KEY (not XAI). Auth is env-only here —
|
||||
# the content grep is the layer that matters for opencode-written
|
||||
# logs on the failure path.
|
||||
find "$GBRAIN_E2E_EVIDENCE_DIR" -type f \( -name '.env' -o -name '*.env' -o -name 'auth.json' \) -exec rm -f {} + 2>/dev/null || true
|
||||
find "$GBRAIN_E2E_EVIDENCE_DIR" -type l -delete 2>/dev/null || true
|
||||
if [ -n "$ANTHROPIC_API_KEY" ]; then
|
||||
grep -rlF "$ANTHROPIC_API_KEY" "$GBRAIN_E2E_EVIDENCE_DIR" 2>/dev/null | while IFS= read -r f; do
|
||||
echo "::warning::removing evidence file containing the API key: ${f#"$GBRAIN_E2E_EVIDENCE_DIR"/}" >&2
|
||||
rm -f "$f"
|
||||
done
|
||||
fi
|
||||
- name: Upload opencode door evidence
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: opencode-door-evidence
|
||||
path: ${{ env.GBRAIN_E2E_EVIDENCE_DIR }}
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Auth travels env-only, but a future login flow would persist
|
||||
# auth.json — remove the known candidate unconditionally so nothing
|
||||
# outlives the job even on a future self-hosted runner.
|
||||
- name: Remove opencode credentials (unconditional)
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ~/.local/share/opencode/auth.json
|
||||
rm -rf /tmp/gb-opencode-* 2>/dev/null || true
|
||||
|
||||
# opencode canary: latest-version leg (schedule-scoped, continue-on-error,
|
||||
# own timeout — landed IN-WAVE, reversing the grok-style deferral, because
|
||||
# opencode ships near-continuously and a frozen pin goes stale in weeks;
|
||||
# the pinned lane above stays the deterministic gate while this tracks
|
||||
# what users actually run). Keyless tier only (incl. the free-tier SMOKE);
|
||||
# no secret ever reaches this job. A red here is a PIN-REFRESH SIGNAL
|
||||
# (OPENCODE-CLI-PIN.md §Pin-refresh cadence), never a gate.
|
||||
opencode-door-canary:
|
||||
name: opencode door canary (latest, keyless, non-gating)
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
continue-on-error: true
|
||||
env:
|
||||
GBRAIN_REAL_OPENCODE_E2E: '1'
|
||||
# Deliberately NO OPENCODE_VERSION pin: T1 asserts the bare-semver
|
||||
# SHAPE only, and the suite runs against whatever `latest` is today.
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
- name: Build gbrain
|
||||
run: |
|
||||
bun build --compile --outfile "$RUNNER_TEMP/gbrain-door-bin" src/cli.ts
|
||||
echo "GBRAIN_COMPILED_BIN=$RUNNER_TEMP/gbrain-door-bin" >> "$GITHUB_ENV"
|
||||
- name: Install opencode@latest (unpinned — the whole point)
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
npm install -g opencode-ai@latest
|
||||
command -v opencode >/dev/null 2>&1
|
||||
echo "canary version: $(opencode --version)"
|
||||
- name: Run opencode door tests (keyless tier against latest)
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-opencode.serial.test.ts > door-canary.txt 2>&1 || EXIT=$?
|
||||
tail -40 door-canary.txt
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
echo "::warning::opencode canary red against latest — pin-refresh signal (OPENCODE-CLI-PIN.md §Pin-refresh cadence); the pinned lane is the gate."
|
||||
exit "$EXIT"
|
||||
fi
|
||||
|
||||
+25
-12
@@ -1,16 +1,17 @@
|
||||
<!-- gbrain-runbook-stamp: 0.46.3.0 -->
|
||||
<!-- gbrain-runbook-stamp: 0.46.4.0 -->
|
||||
<!-- This stamp must equal the VERSION file at every release; CI enforces it
|
||||
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
|
||||
the installed binary and warns on skew. -->
|
||||
|
||||
# BOOTSTRAP_FOR_AGENTS.md — become your human's persistent agent
|
||||
|
||||
You are an AI agent (Claude Code or Codex) reading this because a human pasted a
|
||||
block asking you to set yourself up as their persistent personal agent, with gbrain
|
||||
as your memory. This runbook is the judgment layer; the deterministic work lives in
|
||||
`gbrain bootstrap` subcommands with exit codes. Follow it top to bottom.
|
||||
You are an AI agent (Claude Code, Codex, or opencode) reading this because a human
|
||||
pasted a block asking you to set yourself up as their persistent personal agent, with
|
||||
gbrain as your memory. This runbook is the judgment layer; the deterministic work lives
|
||||
in `gbrain bootstrap` subcommands with exit codes. Follow it top to bottom.
|
||||
|
||||
**Scope note:** this path is for Claude Code and Codex (desktop apps or CLIs).
|
||||
**Scope note:** this path is for Claude Code, Codex, and opencode (desktop apps or
|
||||
CLIs; opencode = the SST terminal agent, opencode.ai — not OpenClaw).
|
||||
Running OpenClaw or Hermes? Use `INSTALL_FOR_AGENTS.md` instead.
|
||||
|
||||
**End state:** this folder is your workspace — identity files rendered from your
|
||||
@@ -96,12 +97,16 @@ you needed; report the count at the end (it feeds the install-time measurement).
|
||||
3. **Interview.** `gbrain bootstrap interview --init`, then ask the questions from
|
||||
the bank (the CLI prints them) in three batches, recording each answer verbatim
|
||||
with `--set KEY "value"`. Push once on vague answers to the required questions.
|
||||
Claude Code only: with the final batch, also ask the ONE operational consent —
|
||||
MCP scope. It is not one of the 12 interview questions; consents ride alongside
|
||||
the bank. The choice: project (recommended — any other repo you open cannot
|
||||
read your brain) vs user (your agent everywhere, but any repo you open can
|
||||
reach it — read and write — and two open sessions contend for the database).
|
||||
Record it with
|
||||
Claude Code and opencode: with the final batch, also ask the ONE operational
|
||||
consent — MCP scope. It is not one of the 12 interview questions; consents ride
|
||||
alongside the bank. On Claude Code the choice: project (recommended — any other
|
||||
repo you open cannot read your brain) vs user (your agent everywhere, but any
|
||||
repo you open can reach it — read and write — and two open sessions contend for
|
||||
the database). On opencode the recommendation INVERTS: user-global is the
|
||||
default and the sharing-safe choice (opencode spawns project-config-defined
|
||||
servers with NO trust prompt, so a committed project entry executes on every
|
||||
collaborator's machine) — offer project only as a deliberate opt-in and state
|
||||
that consequence. Record it with
|
||||
`gbrain bootstrap interview --set MCP_SCOPE <project|user>` BEFORE the
|
||||
read-back, so the confirmation covers it. On Codex, skip this question
|
||||
entirely — the wiring step states the Codex reality instead.
|
||||
@@ -133,6 +138,14 @@ you needed; report the count at the end (it feeds the install-time measurement).
|
||||
on this machine can reach the brain (read and write) through its MCP
|
||||
tools; the off-ramps are `codex mcp remove gbrain` (registration only) or
|
||||
`gbrain bootstrap uninstall` (full teardown).
|
||||
- opencode: writes the MCP entry directly into opencode's JSONC config (no
|
||||
CLI exec needed) and relies on the AGENTS.md protocol, which opencode loads
|
||||
natively — say plainly that opencode gets pull-based context, not per-turn
|
||||
push. Scope follows the recorded MCP_SCOPE answer (user-global default; a
|
||||
project answer writes the committed-candidate `opencode.json` and the CLI
|
||||
prints the sharing warning). Restart opencode after wiring — it reads config
|
||||
at session start. Off-ramps: the entry's `"enabled": false`, or
|
||||
`gbrain bootstrap uninstall`.
|
||||
7. **Private repo.** `gbrain bootstrap repo` — creates a PRIVATE GitHub repo from
|
||||
the workspace, verifies the privacy bit through the API, pushes. If the human
|
||||
started from a repo they created themselves (create-repo-first: an EMPTY private
|
||||
|
||||
@@ -2,6 +2,74 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.46.4.0] - 2026-08-15
|
||||
|
||||
**opencode joins the supported-client roster — at full parity from day one.**
|
||||
(opencode is opencode.ai, SST's terminal agent — not OpenClaw.) Unlike earlier
|
||||
clients that started with a manual recipe, opencode lands with every install
|
||||
lane gbrain has: the paste-in workspace bootstrap, machine-level harness
|
||||
wiring, `gbrain connect`, a claw-test runner, and a real-binary e2e door in
|
||||
CI. Every asserted flag, config shape, and quirk was observed against a
|
||||
pinned install (opencode 1.18.18), recorded in a machine-checked pin
|
||||
document, and exercised against the real binary — including the part that
|
||||
makes opencode special: its keyless anonymous free tier drives MCP tool
|
||||
calls, so the end-to-end proof needs zero secrets.
|
||||
|
||||
### Added
|
||||
- **`gbrain bootstrap hooks --harness opencode`** — workspace-lane MCP
|
||||
registration via direct, comment-preserving JSONC writes (never a CLI
|
||||
exec, works offline). MCP scope is honored with a deliberately INVERTED
|
||||
default: user-global, because opencode spawns project-config servers with
|
||||
no trust prompt; project scope is an explicit opt-in that prints a sharing
|
||||
warning. A structural ownership fingerprint refuses to touch entries
|
||||
gbrain didn't write.
|
||||
- **`gbrain bootstrap harness --harness opencode`** — machine-level remote
|
||||
MCP wiring with an inline bearer written 0600, token rotation across URL
|
||||
changes, content-guarded rollback on failed smoke, `--status` and
|
||||
`--remove`.
|
||||
- **`gbrain connect --agent opencode [--install]`** — env-interpolated
|
||||
bearer (`{env:GBRAIN_REMOTE_TOKEN}`): the token never enters the config
|
||||
file. `--force` replaces a registration whose endpoint moved.
|
||||
- **`gbrain claw-test --agent opencode`** and a split-gated real-binary e2e
|
||||
door in CI: keyless tier (version pin, install + `mcp list` handshake,
|
||||
spawn-gate canary, writer parity, MCP SMOKE on the free tier) plus a paid
|
||||
Anthropic leg that model-gates before spending; npm supply-chain
|
||||
provisioning verifies the actual downloaded tarball bytes against pinned
|
||||
integrities; a schedule-only canary tracks the latest upstream release.
|
||||
- **Docs:** `docs/mcp/OPENCODE.md` install guide,
|
||||
`docs/mcp/OPENCODE-CLI-PIN.md` observation pin (with a verify-time drift
|
||||
guard and a pin-refresh cadence), roster updates across README / INSTALL /
|
||||
bootstrap guides. opencode reads the rendered AGENTS.md pull-protocol
|
||||
contract natively.
|
||||
|
||||
### Changed
|
||||
- The bootstrap config writers (Claude hooks JSON, Codex TOML, opencode
|
||||
JSONC) now share one atomic-write helper; symlinked configs — including
|
||||
dangling dotfile-manager links — survive writes as links.
|
||||
- The door-test family (binary resolution, hermetic child envs, one-shot
|
||||
spawns) extracted into shared factories; the hermes and grok runners were
|
||||
ported onto them, hermes child envs gained the GitHub step-metadata scrub,
|
||||
and the hermes installer pin was refreshed (its nightly door had gone red
|
||||
on upstream installer drift).
|
||||
- A new pin-doc privacy guard asserts every agent pin document ships with
|
||||
placeholder paths and no key material.
|
||||
|
||||
### Fixed
|
||||
- Security and robustness hardening from the pre-landing cross-model review
|
||||
pass: registration verification probes run isolated and time-bounded, and
|
||||
a hung probe is killed instead of abandoned; global config writes
|
||||
reconcile both opencode global filenames under the bootstrap lock; config
|
||||
backups are unique per operation with content-guarded restore; error
|
||||
paths never echo credentials; test-harness child processes drop CI
|
||||
credentials before spawning third-party binaries.
|
||||
|
||||
### To take advantage of v0.46.4.0
|
||||
opencode users: run `gbrain bootstrap hooks --harness opencode` in your
|
||||
brain workspace (or paste the standard bootstrap block into an opencode
|
||||
session). The keyless free tier is enough to verify the wiring end to end —
|
||||
`opencode mcp list` should show `✓ gbrain connected`. Existing installs:
|
||||
nothing changes; this release adds a client, it doesn't modify brain
|
||||
behavior.
|
||||
## [0.46.3.0] - 2026-08-15
|
||||
|
||||
**ZeroEntropy is shutting down on 2026-09-04 — gbrain now gets you off it
|
||||
|
||||
+16
-1
@@ -239,10 +239,25 @@ grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
The add is lazy (exit 0 without connecting) — verify with
|
||||
`grok mcp doctor gbrain`, which spawns the server and must report
|
||||
`7 tools discovered`. This is the brain-only install; the `gbrain bootstrap`
|
||||
personal-agent path does not support Grok yet (Claude Code/Codex only).
|
||||
personal-agent path does not support Grok yet (Claude Code, Codex, and opencode only).
|
||||
Verified against Grok Build v1.0.4. Full reference:
|
||||
[docs/mcp/GROK.md](docs/mcp/GROK.md).
|
||||
|
||||
**If you are opencode** (the SST terminal agent, opencode.ai — not OpenClaw):
|
||||
you are a bootstrap-supported harness — for the full persistent-personal-agent
|
||||
install, follow `BOOTSTRAP_FOR_AGENTS.md` instead of this page. For the
|
||||
brain-only MCP registration:
|
||||
|
||||
```bash
|
||||
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
The add is lazy (exit 0 without connecting) — verify with `opencode mcp list`,
|
||||
which spawns the server and must show `✓ gbrain connected` (the exit code is 0
|
||||
even on failure; read the output). Restart opencode afterwards — it reads
|
||||
config at session start. Verified against opencode v1.18.18. Full reference:
|
||||
[docs/mcp/OPENCODE.md](docs/mcp/OPENCODE.md).
|
||||
|
||||
Whether you scaffolded or not, read `skills/RESOLVER.md` (in your workspace, or the
|
||||
bundled copy at `~/gbrain/skills/RESOLVER.md` when running from the cloned repo). It's
|
||||
the skill dispatcher — tells you which skill to read for any task. Save this to your
|
||||
|
||||
@@ -174,6 +174,7 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
|
||||
- **[Cursor / Windsurf / any stdio MCP client](docs/mcp/CLAUDE_CODE.md)** — same shape, add `{"command": "gbrain", "args": ["serve"]}` to your MCP config.
|
||||
- **[Hermes](docs/mcp/HERMES.md)** — `printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve`. Keep `--args` last, and verify with `hermes mcp test gbrain` (the add exits 0 even on failure).
|
||||
- **[Grok Build](docs/mcp/GROK.md)** — `grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs`. The add is lazy (exit 0 without connecting) — verify with `grok mcp doctor gbrain`, which spawns the server and reports `7 tools discovered`. Verified against Grok Build v1.0.4.
|
||||
- **[opencode](docs/mcp/OPENCODE.md)** (opencode.ai / SST — not OpenClaw) — `opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs`, or let `gbrain bootstrap hooks --harness opencode` write the config for you (opencode is a bootstrap-supported harness — it reads AGENTS.md natively). The add is lazy — verify with `opencode mcp list`, which spawns the server (`✓ gbrain connected`). Remote: `gbrain connect https://your-host/mcp --token gbrain_xxx --agent opencode [--install]` — the config stores only the `{env:GBRAIN_REMOTE_TOKEN}` interpolation. Verified against opencode v1.18.18.
|
||||
- **[OpenClaw](docs/mcp/OPENCLAW.md)** — the ClawHub bundle plugin registers gbrain automatically (`openclaw.plugin.json` ships in this repo), or add `{"command": "gbrain", "args": ["serve"]}` to `~/.openclaw/config.json`'s `mcpServers`.
|
||||
- **[Claude Desktop (Cowork)](docs/mcp/CLAUDE_DESKTOP.md)** — Settings → Integrations → add the URL of your HTTP server. Remote only; the local `claude_desktop_config.json` does not work for remote servers.
|
||||
- **[Claude Cowork (team plan)](docs/mcp/CLAUDE_COWORK.md)** — org Owner adds the connector under Organization Settings → Connectors.
|
||||
|
||||
@@ -6056,6 +6056,39 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
|
||||
(`skillpack status`/`sync`, doctor `skill_currency`) already keeps the brain's skill
|
||||
set current on upgrade; this item is purely about semantic retrieval of skills.
|
||||
|
||||
## opencode wave follow-ups (filed at build time)
|
||||
|
||||
- [ ] **P2 — Watch the first opencode-door + canary dispatches.** The job is
|
||||
day-one full posture (nightly + labels; keyless SMOKE + paid anthropic leg
|
||||
on the existing secret) — after the wave merges, confirm the first nightly
|
||||
run goes green end-to-end and the canary leg's latest-version result, then
|
||||
update OPENCODE-CLI-PIN.md §Pending auth with anything the authed CI run
|
||||
observes (exact `opencode models` output, per-turn cost note). Effort: S.
|
||||
- [ ] **P3 — Wire opencode's plugin/event system** (the ambient-recall lane).
|
||||
opencode ships a JS plugin system with lifecycle events; `OPENCODE_HAS_HOOKS
|
||||
= false` in host-specs.ts marks the gap. Needs its own observation pass
|
||||
(plugin API shapes, event timing, context-injection surface) before design —
|
||||
would upgrade opencode from pull-protocol to per-turn push, above codex.
|
||||
Effort: M/L.
|
||||
- [ ] **P3 — BrainBench opencode adapter.** `src/eval/brainbench/adapters/` +
|
||||
`ALL_HARNESSES` entry — build together with the already-filed hermes + grok
|
||||
adapters (three pending; one eval wave). Effort: M.
|
||||
- [ ] **P3 — connect `--agent opencode --oauth`.** opencode's `mcp auth` is an
|
||||
authorization-code OAuth flow (not client-credentials) — a connect lane for
|
||||
it needs the interactive-grant plumbing the current `--oauth`
|
||||
(perplexity/generic client-credentials) path does not model. Effort: M.
|
||||
- [ ] **P3 — Re-observe the OPENCODE_CONFIG* env trio on version bumps.**
|
||||
Observed INERT in 1.18.18 (docs-contradiction pinned in OPENCODE-CLI-PIN.md
|
||||
§Path seams); host-specs resolves via XDG only. If a future release
|
||||
activates them, `opencodeConfigDir()` and the hermetic child-env deletes
|
||||
must move together. The pin doc's re-observation checklist carries the
|
||||
probe. Effort: S.
|
||||
- [ ] **P3 — opencode-install PTY promotion.** Same criterion as grok-install:
|
||||
2 consecutive stable dx-scenario runs ≥1 month apart with unchanged
|
||||
boot/first-run copy → promote to a PTY assertion test. opencode's keyless
|
||||
free tier means the scenario should COMPLETE the bootstrap, making it a
|
||||
stronger promotion candidate than grok's sign-in-wall early-stop. Effort: M.
|
||||
|
||||
## Transcripts-import follow-ups (filed from cathedral-4, `gbrain transcripts ingest`)
|
||||
|
||||
Scoped OUT of the cathedral-4 PR by the CEO review's cherry-pick ceremony and the
|
||||
@@ -6097,25 +6130,36 @@ covers DEAD logs; go-forward capture beyond Claude Code is deliberately absent.
|
||||
CLAUDE_CODE.md + GROK.md now recommend `--surface verbs`. Update the
|
||||
register one-liner + Direct config block (+ INSTALL_FOR_AGENTS hermes
|
||||
block) and re-verify against the pinned hermes. Effort: S.
|
||||
- [ ] **P2 — Backport the GITHUB_ENV/GITHUB_PATH/GITHUB_OUTPUT/GITHUB_STATE
|
||||
- [x] **P2 — Backport the GITHUB_ENV/GITHUB_PATH/GITHUB_OUTPUT/GITHUB_STATE
|
||||
deletion from `grokChildEnv` to `hermesChildEnv`** (and consider narrowing
|
||||
the `GITHUB_` ALLOW_PREFIX to the read-only metadata names) — the prefix
|
||||
rule forwards writable CI step-metadata files to untrusted agent children.
|
||||
Unit truth-table exists for the grok side to clone. Effort: S.
|
||||
DONE (opencode-support wave): `hermesChildEnv` now rides `makeAgentChildEnv`,
|
||||
which scrubs the GITHUB_* step-metadata files for every door agent; truth-table
|
||||
extended in `test/helpers/agent-harness.unit.test.ts`.
|
||||
- [ ] **P3 — Grok bootstrap-harness target.** `gbrain bootstrap` personal-agent
|
||||
support for Grok Build: `HarnessSelector` + `parseHarnessArgs`, a dated
|
||||
`TARGETS` spec in `host-specs.ts`, a `wireGrok` branch + TOML writer (grok
|
||||
config schema pinned; `codex-toml.ts` is the precedent), receipt/rollback/
|
||||
status handling, and the INSTALL_FOR_AGENTS honest-classification flip.
|
||||
Docs currently state "bootstrap does not support Grok yet". Effort: M.
|
||||
- [ ] **P3 — Door-adapter extraction + CI-tail composite action.** Trigger: the
|
||||
NEXT door agent (4th). Extract the agent-harness door family shape
|
||||
(resolve/auth/seed/childEnv/pin/turn) and hoist the shared workflow tail
|
||||
(evidence prep / scrub triple / upload / zero-pass grep / cred cleanup)
|
||||
into a composite action; port grok-door as first consumer. Until then the
|
||||
hermes-door/grok-door scrub blocks carry cross-reference comments. Also
|
||||
adopt a door CADENCE policy: nightly for the newest/most-churning agent,
|
||||
label-only after 2 stable monthly cycles per agent. Effort: M.
|
||||
- [x] **P3 — Door-adapter extraction (test-side) + door cadence policy.**
|
||||
Trigger FIRED at the 4th door agent (opencode, the opencode-support wave):
|
||||
`makeBinaryResolver`/`makeAgentChildEnv`/`runOneShotSpawn` extracted in
|
||||
`test/helpers/agent-harness.ts`, grok+hermes ported (hermes gained the
|
||||
GITHUB_* scrub + bounded drain), opencode landed as first consumer; the
|
||||
cadence policy is adopted in `docs/TESTING.md` (nightly for the newest
|
||||
agent, label-only after 2 stable monthly cycles).
|
||||
- [ ] **P3 — Door CI-tail composite action.** Trigger: the FIRST GREEN
|
||||
grok-door AND opencode-door dispatches (workflow yaml cannot be proven
|
||||
locally, and refactoring never-run jobs compounds risk — grok-door has
|
||||
never dispatched: its XAI_API_KEY secret does not exist yet). Hoist the
|
||||
shared workflow tail (evidence prep / scrub triple / upload / pass-count +
|
||||
paid sentinels / version re-check / cred cleanup) from
|
||||
hermes-door/grok-door/opencode-door into a composite action; port
|
||||
opencode-door as first consumer (it is the freshest copy). Until then the
|
||||
three doors' scrub blocks carry cross-reference comments. Effort: M.
|
||||
- [ ] **P3 — Promote grok-install to a PTY assertion test.** Criterion: 2
|
||||
consecutive stable runs ≥1 month apart of the dx scenario (pre-ship ritual
|
||||
on grok-touching waves) with unchanged boot/sign-in copy. Would be the
|
||||
@@ -6139,11 +6183,66 @@ covers DEAD logs; go-forward capture beyond Claude Code is deliberately absent.
|
||||
registry shape generalizes. Unify into one data-driven table AFTER the
|
||||
door-adapter extraction lands (earn it — don't freeze hermes-isms in).
|
||||
Effort: L.
|
||||
- [ ] **P3 — PIN-doc privacy-guard candidate.** GROK-CLI-PIN/HERMES-CLI-PIN
|
||||
carry verbatim observation transcripts; consider extending check-privacy.sh
|
||||
(or a dedicated check) to assert pin docs use `<tmp>`/placeholder paths and
|
||||
never carry key material or account ids. Effort: S.
|
||||
- [x] **P3 — PIN-doc privacy guard.** DONE (opencode-support wave):
|
||||
`scripts/check-pin-doc-privacy.sh` (in `bun run verify` + guards-manifest,
|
||||
fixture-tested) asserts every `docs/mcp/*-CLI-PIN.md` uses placeholder paths
|
||||
and carries no key-shaped material or non-example emails.
|
||||
- [x] **P3 — opencode-door npm view-vs-install TOCTOU.** DONE (adversarial-review
|
||||
fix wave): the door job's install step is now pack-verify-install — `npm pack
|
||||
<pkg>@<ver> --json` downloads the artifact and reports the integrity of the
|
||||
BYTES written; both the wrapper and the platform payload are asserted against
|
||||
their pins before `npm install -g ./opencode-ai-*.tgz` installs from the
|
||||
verified local tarball (no fresh registry resolve of the name; the payload's
|
||||
install-time fetch is npm-validated against the same byte-confirmed packument).
|
||||
Verified locally on darwin-arm64 (wrapper integrity == pin; `--ignore-scripts`
|
||||
breaks opencode's postinstall binary placement, so it is deliberately absent).
|
||||
- [x] **P3 — `opencode mcp list` probe spawns project-config servers.** DONE
|
||||
(adversarial-review fix wave): the user-scope probe spawns from a fresh EMPTY
|
||||
mkdtemp cwd (no project config can load), project scope SKIPS the live probe
|
||||
entirely with a printed note (parse-back is authoritative), and the probe now
|
||||
holds the real process handle so the 20s timeout actually kills the child
|
||||
(SIGTERM → SIGKILL) instead of abandoning it.
|
||||
- [ ] **P3 — dedupe the opencode read→parse→classify dance.** The
|
||||
read-config → parseOpencodeConfig → opencodeEntryKind sequence is spelled
|
||||
three times (bootstrap.ts runHooks pre-check, harness.ts apply expectUrl
|
||||
fallback, harness.ts remove ownership check); extract a
|
||||
`classifyOpencodeEntryAt(path, name, expect)` helper and drop the
|
||||
double-printed other-source warning (the caller AND the writer note it).
|
||||
Effort: S.
|
||||
|
||||
## opencode adversarial-review fix-wave follow-ups (filed at fix time)
|
||||
|
||||
- [ ] **P2 — per-harness MCP-scope consent key.** An interview MCP_SCOPE answer
|
||||
recorded for Claude Code (where 'project' is the privacy-SAFE default)
|
||||
currently authorizes opencode's INVERTED-risk scopes without fresh
|
||||
confirmation ('project' on opencode = committed file that auto-spawns on
|
||||
every collaborator machine, no trust gate), and an ABSENT answer defaults
|
||||
opencode to user-global exposure (any repo on the machine reaches the
|
||||
brain). Design a harness-specific consent confirm — either per-harness
|
||||
answer keys (MCP_SCOPE_OPENCODE) or a one-time "your recorded scope means
|
||||
something riskier here — confirm" gate on the opencode lane. Relates to the
|
||||
agent-bootstrap A8 consent-semantics TODO. Effort: M.
|
||||
- [ ] **P3 — opencodeEntryKind remote ownership: normalize the url compare.**
|
||||
Ownership uses exact string equality on the entry url vs the receipt/expect
|
||||
url — trailing-slash and host-case variants misclassify in BOTH directions
|
||||
(ours read as foreign → orphaned entry; a variant-url foreign endpoint
|
||||
never matches, fine, but the asymmetry is accidental). Consider URL
|
||||
normalization (scheme/host case-fold, trailing-slash) plus an
|
||||
Authorization-shape check before comparing. Effort: S.
|
||||
- [ ] **P2 — claw-test --live runners inherit real HOME/XDG.** The grok /
|
||||
hermes / opencode --live runners run against the operator's real
|
||||
HOME/XDG config surface and only WARN on a pre-existing global gbrain
|
||||
entry; a scripted run can mutate or exercise the operator's live wiring.
|
||||
Consider a fail-closed flag (refuse when a global gbrain registration
|
||||
exists unless --allow-live-config) or hermetic-by-default across the
|
||||
runner family. Effort: M.
|
||||
- [ ] **P3 — fixed-name `.bak` parity: codex-toml.ts + hooks.ts writers.**
|
||||
opencode-json.ts now takes UNIQUE `.bak-<hex>` backups per operation
|
||||
(overlapping runs can't clobber each other's snapshot; harness restores
|
||||
from the returned path and unlinks on success). The codex TOML writer and
|
||||
the hooks settings writers still use fixed-name backups with the same
|
||||
theoretical overlap window — port the unique-backup pattern (and the
|
||||
restore-guard compare) for parity. Effort: S/M.
|
||||
## Dream triage cascade follow-ups (#4152, filed at implementation)
|
||||
|
||||
- [ ] **P2 — Incremental submit-drain + deadline threading in synthesize
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"heic-decode": "^2.1.0",
|
||||
"js-yaml": "^3.15.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"marked": "^18.0.2",
|
||||
"openai": "^4.0.0",
|
||||
"pgvector": "^0.2.0",
|
||||
@@ -469,6 +470,8 @@
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"libheif-js": ["libheif-js@1.19.8", "", {}, "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ=="],
|
||||
|
||||
@@ -103,6 +103,7 @@ Per-client setup guides live in [`docs/mcp/`](mcp/):
|
||||
- [`docs/mcp/PERPLEXITY.md`](mcp/PERPLEXITY.md)
|
||||
- [`docs/mcp/HERMES.md`](mcp/HERMES.md) — Hermes (Nous Research CLI)
|
||||
- [`docs/mcp/GROK.md`](mcp/GROK.md) — Grok Build (xAI CLI)
|
||||
- [`docs/mcp/OPENCODE.md`](mcp/OPENCODE.md) — opencode (opencode.ai / SST terminal agent)
|
||||
- [`docs/mcp/OPENCLAW.md`](mcp/OPENCLAW.md) — OpenClaw (bundle plugin or stdio)
|
||||
- [`docs/mcp/CLAUDE_COWORK.md`](mcp/CLAUDE_COWORK.md) — Claude Cowork (team plan)
|
||||
- [`docs/mcp/DEPLOY.md`](mcp/DEPLOY.md) — production deploy patterns
|
||||
|
||||
@@ -420,6 +420,9 @@ E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `D
|
||||
- `test/e2e/workspace-generic-compat.test.ts` — always-on (PGLite, no binary): pins the INSTALL_FOR_AGENTS.md "any repo with a workspace" contract against `test/fixtures/generic-agents-workspace/` (Hermes is the motivating consumer): `cwd_walk_up` detection, the `GBRAIN_SKILLS_DIR` override, `check-resolvable` on a root AGENTS.md, and scaffold additivity + refuse-overwrite. The real Hermes-behavior proof is the door suite below.
|
||||
- `test/e2e/install-real-hermes.serial.test.ts` — the hermes "door": real `hermes` binary + real `hermes mcp add` handshake (full-catalog tool discovery; the count tracks the op catalog, so the test asserts discovery happened, not a number) + a paid `hermes -z` recall turn against a seeded brain. Triple-gated: `GBRAIN_REAL_HERMES_E2E=1` (explicit opt-in — run-e2e.sh scrubs GBRAIN_*, so it can never fire under `bun run test:e2e`) + resolvable binary + non-empty ANTHROPIC key (anthropic-pinned on purpose: a second provider key flips hermes provider-auto into a mis-routed 401). Hermetic HOME + HERMES_HOME with a tripwire on the operator's real config; evidence copies to `GBRAIN_E2E_EVIDENCE_DIR` for CI upload. Venue: heavy-tests.yml (`real-agent-e2e` + `hermes-door` jobs).
|
||||
- `test/e2e/install-real-grok.serial.test.ts` — the grok "door" (xAI Grok Build; every asserted shape observed against the pin in `docs/mcp/GROK-CLI-PIN.md`). SPLIT-GATED, a deliberate divergence from the hermes door: grok's `mcp add/list/doctor` run keyless, so the compat tier (version-shape pin, documented-shape `grok mcp add gbrain -- gbrain serve --surface verbs` via a PATH-staged bin dir, saved-TOML asserts via `Bun.TOML.parse`, `mcp doctor` handshake proving the seven-verb surface, vendor-fallback provenance guard, direct-TOML surface) needs only `GBRAIN_REAL_GROK_E2E=1` + a resolvable binary; the paid SMOKE additionally needs a non-empty `XAI_API_KEY` and asserts a PER-RUN NONCE fact (grok has fs/shell tools — the committed fact is greppable, so recall of it proves nothing) with web search disabled. `mcp add` is lazy (exit 0 always) — `mcp doctor <name> --json` is the honest discriminator (exit 0/1 observed). Hermetic HOME + GROK_HOME + tmp cwd on every spawn (grok reads vendor MCP configs for trusted folders and loads `.envrc` from cwd); bounded tripwire over the operator's real `~/.grok` config/credential files (volatile paths excluded — grok rewrites logs/sessions/bin/docs every run) + a checkout guard that no `.grok/`/`.mcp.json` appeared in the repo root. Venue: heavy-tests.yml (`real-agent-e2e` + `grok-door` jobs); run directly via `GBRAIN_REAL_GROK_E2E=1 bun test test/e2e/install-real-grok.serial.test.ts`.
|
||||
- `test/e2e/install-real-opencode.serial.test.ts` — the opencode "door" (SST opencode; every asserted shape observed against the pin in `docs/mcp/OPENCODE-CLI-PIN.md`). SPLIT-GATED a step past the grok door: opencode's anonymous FREE TIER drives MCP tool calls keyless, so even the nonce SMOKE runs in the keyless tier — T1 bare-semver version pin (the SST-vs-claimant discriminator), T2 documented-shape `opencode mcp add gbrain --env … -- gbrain serve --surface verbs` + the honest `opencode mcp list` discriminator (it SPAWNS every server; `✓/✗` text is the assertion surface — exit code is 0 even on failure, and `mcp debug` is OAuth-only), T2b spawn-gate CANARY (a project-config decoy is spawn-attempted with NO trust prompt — if this ever gates, the bootstrap user-global scope default's rationale changed: re-observe), T3 writer parity (gbrain's `opencode-json.ts` output handshakes through the real binary; cross-tool preservation both ways), T4 keyless SMOKE (per-run nonce + STRUCTURAL `gbrain_*` tool_use proof via `parseOpencodeJsonl`, `--format json`). The paid T5 anthropic leg additionally needs a non-empty `ANTHROPIC_API_KEY` and self-validates the pinned model id against the authed `opencode models` list BEFORE any spend. Hermetic HOME + both XDG dirs + tmp cwd on every spawn; `--pure` on every probe (`mcp list` autoloads plugins — a code-execution surface); bounded tripwire over the operator's real opencode configs/auth.json + a repo-root checkout guard. Venue: heavy-tests.yml (`real-agent-e2e` + `opencode-door` jobs, plus the schedule-only `opencode-door-canary` latest-version leg — continue-on-error, a pin-refresh signal, never a gate); run directly via `GBRAIN_REAL_OPENCODE_E2E=1 bun test test/e2e/install-real-opencode.serial.test.ts`.
|
||||
|
||||
**Door cadence policy** (adopted with the 4th door agent): the NEWEST door agent runs at nightly/schedule cadence (currently opencode, whose canary leg also tracks `latest`); a door drops to label-only (`real-agent-e2e`) after 2 stable monthly cycles with unchanged pins. Rationale: churn concentrates in the newest integration; steady-state doors pay for themselves on demand, not nightly.
|
||||
- `test/helpers/tty-harness.ts` + `test/tty-harness.test.ts` — the DX real-PTY harness (`Bun.spawn({terminal:})`): pure text/timing helpers unit-tested with zero subprocesses, plus three live PTY smokes against `sh` guarded by `describe.skipIf(!ptySupported())`. The harness itself is a dev instrument surface — its consumer `scripts/dx-explore.ts` never runs in CI (transcripts land in gitignored `.context/dx-runs/`); see `docs/guides/bootstrap.md` for the scenario runbook.
|
||||
- `test/e2e/search-swamp.test.ts` — reproduces the source-swamp case. Seeds a curated `originals/talks/article-outline-fat-code` page against two `<fork>/chat/` pages stuffed with the same multi-word phrase. Asserts the article wins keyword AND vector ranking, that `detail=high` lets the chat swamp re-surface, and that `source_id` passes through the two-stage CTE intact. PGLite in-memory.
|
||||
- `test/e2e/search-exclude.test.ts` — `test/` + `archive/` pages hidden by default, `include_slug_prefixes` opts back in, caller-supplied `exclude_slug_prefixes` adds to defaults. Both keyword and vector search paths.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -32,7 +32,7 @@ pure win. See the per-verb latency table in
|
||||
calls `context_pack` / `delta` over MCP (they are on `--surface verbs`) or the
|
||||
CLI (`gbrain context-pack`, `gbrain delta`) at the boundary and injects the
|
||||
returned `text` (or renders the structured arms). This is the portable path —
|
||||
no hooks required. It is the primary path for Codex (which has no hooks) and
|
||||
no hooks required. It is the primary path for Codex and opencode (no wired hooks) and
|
||||
for Postgres brains (which have no local IPC socket).
|
||||
- **Push (PGLite + Claude Code):** the bundled hook framework fires
|
||||
automatically at `SessionStart` (injects a warm pack — including the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# GBrain Bootstrap — your harness as your agent
|
||||
|
||||
`gbrain bootstrap` turns a Claude Code or Codex session into a persistent personal
|
||||
agent: identity files rendered from your own answers, a local PGLite brain,
|
||||
`gbrain bootstrap` turns a Claude Code, Codex, or opencode session into a
|
||||
persistent personal agent: identity files rendered from your own answers, a local PGLite brain,
|
||||
per-turn context, session-triggered schedules, and a private GitHub repo as the
|
||||
agent's durable, portable body. This guide is the full contract — what gets
|
||||
installed, what runs when, what it can and cannot do, and how to undo all of it.
|
||||
@@ -19,7 +19,7 @@ follows is `BOOTSTRAP_FOR_AGENTS.md` at the repo root, fetched at the
|
||||
| Identity files (SOUL/USER/MEMORY/AGENTS/CLAUDE/HEARTBEAT/ACCESS_POLICY/GITHUB) | your workspace folder | loaded at session start |
|
||||
| `agent.json` manifest + `brain/`, `memory/`, `skills/`, `state/` | workspace | — |
|
||||
| Local brain (PGLite) | `~/.gbrain/` (never in the repo) | while a session's MCP serve is open |
|
||||
| MCP registration (`gbrain serve`) | Claude Code: project scope by default; Codex: user-global (no scope flag) | spawned by your harness per session |
|
||||
| MCP registration (`gbrain serve`) | Claude Code: project scope by default; Codex: user-global (no scope flag); opencode: user-global by default (project scope is an explicit opt-in — see the degradation matrix) | spawned by your harness per session |
|
||||
| Hooks (Claude Code, ON by default) | local installs: `.claude/settings.local.json` (gitignored); cloud sandboxes: the COMMITTED `.claude/settings.json` (PATH-resolved, fail-open commands) | each prompt; fail-open; `--no-hooks` opts out at install, `GBRAIN_HOOKS=0` disables at runtime |
|
||||
| Per-turn persistence | Stop hook → debounced, detached scan-gated push (per workspace; 5 min default, every turn in cloud sandboxes) | after each assistant turn; `GBRAIN_STOP_PUSH=0` disables; `GBRAIN_STOP_PUSH_DEBOUNCE_MIN` / config `hooks.stop_push_debounce_min` tune it |
|
||||
| Session persistence | SessionEnd hook → scan-gated commit+push | at session end (note: the harness never fires SessionEnd on `/exit` — the per-turn push is what covers that) |
|
||||
@@ -155,6 +155,7 @@ you'd apply to any journal: write what you'd be comfortable persisting.
|
||||
| GitHub / `gh` | full local agent | off-machine durability (repo re-runnable later) |
|
||||
| Hooks (Claude Code) | pull protocol via AGENTS.md gates | automatic per-turn context + session-end persistence |
|
||||
| Codex (no wired hooks, no MCP scope flag) | pull protocol + MCP tools | per-turn push (stated plainly; not oversold — codex 0.147+ ships a hook system, but gbrain does not wire it yet) + the ability to confine MCP reach to one folder (`codex mcp add` is always user-global) |
|
||||
| opencode (no wired hooks; scope INVERTED: user-global by default) | pull protocol (opencode reads AGENTS.md natively) + MCP tools; project scope available as an explicit opt-in | per-turn push (opencode ships a plugin/event system, but gbrain does not wire it yet). The project-scope default is deliberately NOT offered: opencode spawns project-config servers with no trust prompt, so a committed entry would auto-execute on every collaborator machine |
|
||||
| Second simultaneous session | first session unaffected | second session's brain tools fail politely (one live serve per brain — v1 contract) |
|
||||
| Postgres brain (incl. harness mode) | MCP tools every session + pull protocol | per-turn hook injection (`no_pglite_path`: the hook IPC socket is PGLite-only today; hooks stay pre-wired and light up when the engine-uniform listener lands) |
|
||||
|
||||
@@ -188,6 +189,15 @@ mode wires them in one command, with no `agent.json` and no interview:
|
||||
INLINE in the codex config (0600) — framework-spawned codex inherits no
|
||||
shell profile, so the env-var lane the `connect` path uses would never
|
||||
reach it.
|
||||
- opencode: one managed `mcp.gbrain` remote entry with the bearer header
|
||||
INLINE in the user-global JSONC config (0600), written by the same
|
||||
comment-preserving editor the workspace lane uses — the `{env:…}`
|
||||
interpolation the `connect` path prefers would resolve empty under a
|
||||
framework-spawned opencode for the same no-shell-profile reason.
|
||||
Note: downgrading gbrain below the release that introduced opencode support
|
||||
after wiring it leaves the opencode entry in place for manual removal —
|
||||
edit the opencode config by hand, or re-upgrade and run
|
||||
`gbrain bootstrap harness --remove`.
|
||||
- Honesty on Postgres brains: per-turn injection is degraded (the matrix row
|
||||
above); MCP is the active seam and the summary says so.
|
||||
- `--status [--json]` probes the live truth (serve health, token validity via
|
||||
@@ -274,6 +284,11 @@ that changed shape, a harness that stopped calling our MCP server):
|
||||
a seeded, brain-only fact (falling back to a shell `gbrain query` if headless
|
||||
stdio-MCP is unavailable).
|
||||
|
||||
opencode's real-binary door lives in
|
||||
`test/e2e/install-real-opencode.serial.test.ts` (its writer-parity leg
|
||||
handshakes gbrain's direct JSONC registration through the actual binary);
|
||||
`docs/TESTING.md` carries the full door inventory and cadence policy.
|
||||
|
||||
These pay real API cost and take 30s–2min per turn, so they are NOT in the PR
|
||||
shard. Everything is hermetic (temp `HOME` / `CODEX_HOME` / `CLAUDE_CONFIG_DIR` /
|
||||
`GBRAIN_HOME` per test — the operator's real `~/.claude`, `~/.gbrain`, `~/.codex`
|
||||
@@ -294,7 +309,7 @@ bun test test/e2e/bootstrap-real-codex.serial.test.ts
|
||||
## DX exploration harness (developer instrument, not a test)
|
||||
|
||||
The door tests prove the install WORKS; they say nothing about how it FEELS.
|
||||
`test/helpers/tty-harness.ts` spawns any CLI (gbrain, `claude`, `codex`, `grok`) under a
|
||||
`test/helpers/tty-harness.ts` spawns any CLI (gbrain, `claude`, `codex`, `grok`, `opencode`) under a
|
||||
real pseudo-terminal (Bun's `terminal:` spawn option) and records every output
|
||||
burst with a millisecond timestamp, so unnecessary pauses become a measurable
|
||||
artifact (`computeStalls` → `stalls.md`) instead of a vibe. Same hermetic env as
|
||||
@@ -313,6 +328,7 @@ bun run scripts/dx-explore.ts help # comprehension surfaces (no key
|
||||
bun run scripts/dx-explore.ts init [--keyless] # interactive init, naive-user autopilot
|
||||
bun run scripts/dx-explore.ts claude-install # REAL claude running the paste-in bootstrap
|
||||
bun run scripts/dx-explore.ts codex-install # REAL codex, same
|
||||
bun run scripts/dx-explore.ts opencode-install # REAL opencode running the paste-in bootstrap
|
||||
bun run scripts/dx-explore.ts grok-install # REAL grok, brain-only GROK.md install (no bootstrap path)
|
||||
bun run scripts/dx-explore.ts drive -- gbrain init # manual: steer a live TUI via a file channel
|
||||
```
|
||||
|
||||
@@ -12,7 +12,7 @@ The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
|
||||
| `reflex` | automatic, inside the context engine | default-on for plugin hosts; nothing to call |
|
||||
| `op` | `gbrain volunteer-context` / MCP `volunteer_context` | agents without the plugin; one call per turn |
|
||||
| `watch` | `gbrain watch` | stream a transcript in, volunteered pages stream out |
|
||||
| `claude-code` / `codex` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
|
||||
| `claude-code` / `codex` / `opencode` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
|
||||
|
||||
## How it decides
|
||||
|
||||
@@ -74,7 +74,7 @@ this channel production-grade rather than spammy-and-invisible:
|
||||
- **The feedback loop.** The serve logs each DELIVERED block's volunteered
|
||||
pages and pointers to `context_volunteer_events` under the hook's channel
|
||||
(`claude-code` by default; a codex hook registration passes
|
||||
`--harness codex`). `gbrain volunteer-context --stats` then shows
|
||||
`--harness codex` / `--harness opencode`). `gbrain volunteer-context --stats` then shows
|
||||
per-harness precision, and `gbrain doctor`'s `volunteer_channels` check
|
||||
shows which channels actually fire, with guidance for the two quiet cases:
|
||||
"hook installed but never registered (restart the session)" and "registered
|
||||
|
||||
@@ -12,7 +12,10 @@ file, the workflow pins, and the affected assertions together.
|
||||
version stamp; CI installs the RELEASE TAG `v2026.8.3` = commit `3c27eb62` — the two
|
||||
differ by post-release main commits, same declared version. If a CI door run ever
|
||||
diverges from these notes, re-observe against the tag checkout.)
|
||||
- Installer sha256: `c118ff31618dc70339049ce71061b8f1351a1c70d9c2a236ed50d8a2550c550d`
|
||||
- Installer sha256: `868ed3a91e0fabbff6d7418b3ede82bf4833652ec4e77196a42852fb35a9e5b9`
|
||||
(refreshed 2026-08-15: upstream installer drifted past the prior pin —
|
||||
reviewed; the `--commit` payload-pin path the door depends on is intact,
|
||||
and the payload pins (tag+commit) are unchanged)
|
||||
(download https://hermes-agent.nousresearch.com/install.sh to a file first; verify; then run)
|
||||
- Installer flags used: `--skip-setup --non-interactive`; binary lands at `~/.local/bin/hermes`
|
||||
- Python 3.11.15 via uv
|
||||
@@ -93,7 +96,7 @@ non-interactive. `hermes cron tick` = run due jobs once and exit. `hermes cron l
|
||||
`git -C ~/.hermes/hermes-agent rev-parse HEAD` and loud-fails on any mismatch, so an
|
||||
installer that silently ignores unknown flags (or a moved checkout layout) can never
|
||||
run unpinned upstream code on a runner that later holds secrets.
|
||||
- `HERMES_INSTALL_SHA256: "c118ff31618dc70339049ce71061b8f1351a1c70d9c2a236ed50d8a2550c550d"`
|
||||
- `HERMES_INSTALL_SHA256: "868ed3a91e0fabbff6d7418b3ede82bf4833652ec4e77196a42852fb35a9e5b9"`
|
||||
- Door test asserts `hermes --version` output contains `v$HERMES_VERSION` when the env var is set.
|
||||
- `hermes --version` output shape: `Hermes Agent v0.20.0 (2026.8.3)` + install dir + python lines.
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
# opencode CLI pin — observed behavior notes (v1.18.18)
|
||||
|
||||
Dev-facing companion to [OPENCODE.md](OPENCODE.md): every fact below was OBSERVED
|
||||
against a real hermetic install (2026-08-15, macOS arm64), not researched from docs.
|
||||
The claw-test OpencodeRunner, the install door e2e, and the heavy-tests
|
||||
opencode-door CI job assert exactly these shapes — when opencode releases change
|
||||
them, update this file, the workflow pins, and the affected assertions together
|
||||
(`scripts/check-opencode-pin.sh` in `bun run verify` enforces the workflow-side
|
||||
match). Where an observation CONTRADICTS opencode's docs, the observation wins and
|
||||
the contradiction is called out inline.
|
||||
|
||||
Naming note: **opencode** (SST, opencode.ai, npm `opencode-ai`) is not **OpenClaw**
|
||||
(the agent platform gbrain ships a runner for) and not the original `opencode` CLI
|
||||
that was renamed Crush — see Troubleshooting in OPENCODE.md for the binary-name
|
||||
collision.
|
||||
|
||||
<!-- opencode-pin: distribution_kind=npm -->
|
||||
<!-- opencode-pin: npm_package=opencode-ai -->
|
||||
<!-- opencode-pin: npm_version=1.18.18 -->
|
||||
<!-- opencode-pin: npm_integrity=sha512-J+5HFq8tf+wPBBpBpMPSNjSytF2/EkNWYfFZh4si1d9auFbQriqDyqZv+vFUsLWERfdMU32Eajwuiq3rKBvZLQ== -->
|
||||
<!-- opencode-pin: npm_linux_x64_integrity=sha512-WmeUnhljYJ252wywKTiW4bNDzsas2njpjPUEh0jM6HKNI4vFxJtREtzaWViY4AKEAcOkLWT8Ll17ixvcHz3AnA== -->
|
||||
<!-- opencode-pin: npm_linux_arm64_integrity=sha512-e8D3g0qJEIzawEg2+ygW3vkZjAYL2ssyAx4GbihjwXwZFvlZZy5zRWWzdz5KLBoHSTl0FB73vNtnNeXONyHpVQ== -->
|
||||
<!-- opencode-pin: opencode_version=1.18.18 -->
|
||||
<!-- opencode-pin: observed_date=2026-08-15 -->
|
||||
|
||||
## Pin
|
||||
- **opencode v1.18.18**, `opencode --version` output shape: bare `1.18.18` —
|
||||
version only, NO binary-name prefix, NO build hash (unlike grok's
|
||||
`grok 1.0.4 (hash)`). The door's T1 shape assert is `/^\d+\.\d+\.\d+$/` on the
|
||||
trimmed output; SST identity is discriminated by the `mcp`+`debug` subcommands
|
||||
existing (`opencode debug paths` exits 0 and prints the path table below —
|
||||
the renamed-to-Crush ancestor and other claimants have neither).
|
||||
- **Provisioning (CI + local): pinned npm, pack-verify-install** —
|
||||
`opencode-ai@1.18.18`, registry integrity `sha512-J+5HFq…`. The CI job
|
||||
`npm pack`s the wrapper AND the runner's platform payload first (pack
|
||||
reports the integrity of the bytes it actually downloaded — closing the
|
||||
view-then-install TOCTOU), asserts both against the stamps above, then
|
||||
installs FROM the verified local wrapper tarball; the install-time platform
|
||||
sub-package fetch is validated by npm against the same packument integrity
|
||||
the pack step just byte-confirmed. The wrapper fans out to per-platform
|
||||
payloads (`opencode-{darwin,linux,windows}-{arm64,x64}[-baseline|-musl]`) as
|
||||
optionalDependencies at the same version; the LINUX payload integrities are
|
||||
pinned separately because the wrapper's integrity covers only the wrapper
|
||||
tarball. Darwin arm64 payload observed at
|
||||
`sha512-VkG+bz8u8Xqg9NzPK+2/71nEd4DKKlo2NLZurQ1eLAzDnmb1CMYZif/o6Shl8YFuTuYU/30k6yufl4Zr0Ij64g==`
|
||||
(informational — the CI runners are linux). Same npm version-immutability
|
||||
assumption as the grok pin, stated explicitly.
|
||||
- A curl installer (`https://opencode.ai/install`) exists but is NOT the pinned
|
||||
lane; npm is.
|
||||
|
||||
## Pin-refresh cadence (this CLI ships near-continuously)
|
||||
opencode releases far faster than grok (patch releases near-daily). The pinned
|
||||
lane is the deterministic gate; the **canary leg** in `opencode-door` (schedule-
|
||||
scoped, `continue-on-error`, installs `opencode-ai@latest`) exists to surface
|
||||
drift BEFORE it strands the pin. Policy: when the canary leg reds or the pin is
|
||||
>6 weeks old, run the re-observation checklist (bottom) against latest, bump the
|
||||
stamps + workflow env pins together, and note behavior deltas in this file.
|
||||
Do not chase every patch release; refresh on canary signal or the 6-week clock.
|
||||
|
||||
## Path seams — XDG honored; OPENCODE_CONFIG* env vars are INERT (verified)
|
||||
`opencode debug paths` is the authoritative dump. Observed under
|
||||
`HOME=<tmp> XDG_CONFIG_HOME=<tmp>/.config XDG_DATA_HOME=<tmp>/.local/share`:
|
||||
|
||||
```
|
||||
config <XDG_CONFIG_HOME>/opencode (opencode.json + opencode.jsonc)
|
||||
data <XDG_DATA_HOME>/opencode (auth.json, opencode.db*, log/, repos/)
|
||||
state <tmp>/.local/state/opencode (locks/)
|
||||
cache <tmp>/.cache/opencode (bin/)
|
||||
tmp /tmp/opencode
|
||||
```
|
||||
|
||||
- **HOME + XDG_CONFIG_HOME/XDG_DATA_HOME redirection works fully on macOS**
|
||||
(nothing was written outside the hermetic home across the whole observation
|
||||
run). The door uses HOME + both XDG vars, belt-and-suspenders.
|
||||
- **DOCS-CONTRADICTION: `OPENCODE_CONFIG`, `OPENCODE_CONFIG_DIR`, and
|
||||
`OPENCODE_CONFIG_CONTENT` had NO observable effect on config resolution in
|
||||
1.18.18** — probes registered via each were absent from `mcp list`, while the
|
||||
XDG-resolved global config was still read. gbrain's path helpers therefore
|
||||
resolve via XDG only and deliberately do NOT honor `OPENCODE_CONFIG*`;
|
||||
re-observe on version bump (if a future release activates them, the helpers
|
||||
and this section change together). Hermetic child envs still DELETE all three
|
||||
(defense against a future release activating them).
|
||||
- Volatile paths (tripwire exclusions): `opencode.db`, `opencode.db-shm`,
|
||||
`opencode.db-wal`, `log/`, `repos/` under data; `locks/` under state; `bin/`
|
||||
under cache. The tripwire hashes only `opencode.json(c)` + `auth.json`.
|
||||
- Vendor quirk: opencode writes a `.gitignore` (node_modules, package.json, …)
|
||||
into the CONFIG dir on first touch.
|
||||
|
||||
## Config format — JSONC everywhere, both filenames merge (verified)
|
||||
- `~/.config/opencode/opencode.jsonc` AND `~/.config/opencode/opencode.json`
|
||||
are BOTH read when both exist (servers from each appeared simultaneously in
|
||||
`mcp list`) — merge, not first-wins. opencode's own `mcp add` writes the
|
||||
`.jsonc` name.
|
||||
- **Comments parse in `.json`-named files too** (a `// comment` inside project
|
||||
`opencode.json` did not break resolution). JSONC is the effective grammar for
|
||||
every config file regardless of extension → gbrain's writer treats all
|
||||
opencode configs as JSONC (jsonc-parser surgical edits; comments survive).
|
||||
- Project config: `opencode.json` in the project root is read (lookup traverses
|
||||
up); a project-scope entry appears alongside global entries.
|
||||
- Unknown keys inside an `mcp.<name>` entry are TOLERATED in 1.18.18 (an
|
||||
`_gbrain` probe key neither errored nor hid the server). gbrain still does
|
||||
NOT write marker keys — ownership is judged by structural fingerprint — so a
|
||||
future strict-schema flip cannot brick a user's opencode.
|
||||
- `opencode debug config` prints the resolved merge (rendering has a doubled-
|
||||
line quirk; treat it as a debug view, not a parse surface).
|
||||
|
||||
## `opencode mcp add` — observed facts
|
||||
- Shape: `opencode mcp add <name> [--env KEY=VALUE]... -- <command> [args...]`
|
||||
(local) or `opencode mcp add <name> --url <URL> [--header KEY=VALUE]...`
|
||||
(remote). The `-- command` form is real but UNDOCUMENTED in `--help` (the
|
||||
help lists only `--url/--env/--header`; the error copy for a bare add says
|
||||
`Provide either --url <url> or a command after --`).
|
||||
- **Always writes the GLOBAL `opencode.jsonc`** — even when a project
|
||||
`opencode.json` with an `mcp` table exists in the cwd. There is NO scope
|
||||
flag. Project-scope registration requires writing the file directly (gbrain's
|
||||
writer does).
|
||||
- **Add is lazy**: exit 0, no spawn, no prompt — for unreachable URLs and
|
||||
nonexistent commands alike. Never treat add's exit code as a handshake.
|
||||
- **Rewrites preserve comments and foreign keys** (a seeded `// comment` and a
|
||||
`theme` key survived a subsequent add) — opencode uses a JSONC-preserving
|
||||
editor internally; gbrain's writer matches that bar.
|
||||
- `--header` values are stored verbatim, including `{env:VAR}` interpolation
|
||||
syntax (`Authorization=Bearer {env:GBRAIN_REMOTE_TOKEN}` round-trips).
|
||||
|
||||
## Saved config schema (verbatim, from real adds)
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"gbrain": {
|
||||
"type": "local",
|
||||
"command": ["gbrain", "serve", "--surface", "verbs"],
|
||||
"environment": { "GBRAIN_SOURCE": "workspace", "GBRAIN_HOME": "/tmp/<brain-home>" }
|
||||
},
|
||||
"gbrain-remote": {
|
||||
"type": "remote",
|
||||
"url": "https://brain.example/mcp",
|
||||
"headers": { "Authorization": "Bearer {env:GBRAIN_REMOTE_TOKEN}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
`enabled` is optional (absent = enabled). `oauth` was not written by the CLI and
|
||||
is omitted by gbrain's writer (no OAuth interference with bearer headers was
|
||||
observed). Local commands: an absolute `command[0]` works; PATH-resolved bare
|
||||
`gbrain` resolves via the SPAWNING process's PATH (the door verifies the staged
|
||||
bin-dir prepend).
|
||||
|
||||
## Probes — `mcp list` is the honest discriminator; `mcp debug` is NOT
|
||||
- **`opencode mcp list` SPAWNS every configured local server and connects every
|
||||
remote one**, then prints per-server status: `✓ <name> connected` or
|
||||
`✗ <name> failed` with a reason line (`Executable not found in $PATH:
|
||||
"gbrain"`, `SSE error: …`). THE door's keyless handshake proof. Caveats:
|
||||
**exit code is 0 even when servers fail** (parse the text, assert
|
||||
`✓ gbrain connected`), output is clack-style UI with ANSI codes, and there is
|
||||
no `--json`.
|
||||
- **`mcp list` is also a code-execution surface**: it spawned a PROJECT-defined
|
||||
`type:local` command from a fresh checkout with NO prompt and NO trust gate
|
||||
(verified with a touch-file probe). Two consequences: (1) gbrain's
|
||||
bootstrap default scope for opencode is USER-GLOBAL — a committed project
|
||||
entry would auto-spawn on every collaborator's machine; (2) any gbrain-run
|
||||
probe uses `--pure` (kills external plugin autoload) + `OPENCODE_DISABLE_AUTOUPDATE=1`.
|
||||
- `opencode mcp debug <name>` is OAUTH debugging only — on a local server it
|
||||
prints `MCP server <name> is not a remote server` and exits 0. Not a
|
||||
discriminator.
|
||||
- No tool-count line exists in `mcp list` (grok's `7 tools discovered` has no
|
||||
analog); tool discovery is proven by the SMOKE turn's `tool_use` events
|
||||
instead.
|
||||
|
||||
## One-shot (`opencode run`) — KEYLESS WORKS (anonymous free tier)
|
||||
- `opencode run "<msg>"` prints the ANSWER TEXT ALONE on stdout; the session
|
||||
banner (`> build · <model>`) and UI go to stderr. Exit 0 on success; exit 1
|
||||
with a structured JSON error (`"ref": "err_…"`) on failure (e.g. bogus
|
||||
model).
|
||||
- **Keyless runs WORK**: with zero credentials and no auth.json, `run` answers
|
||||
via opencode's anonymous free tier (default model observed:
|
||||
`opencode/big-pickle`; `opencode models` lists 8 keyless `opencode/*` models,
|
||||
most `-free` suffixed; `opencode stats` reports $0.00). There is no
|
||||
`Not signed in` wall in headless run mode.
|
||||
- **MCP tools fire in keyless run mode WITHOUT `--auto`** (verified: the free
|
||||
model called `gbrain_recall` and returned a seeded per-run nonce with
|
||||
`--auto` absent). `--auto` exists (`auto-approve permissions that are not
|
||||
explicitly denied (dangerous!)`) but the door does not need or use it.
|
||||
- MCP tool naming: `<server>_<tool>` (observed `gbrain_recall`).
|
||||
- `--format json` emits NDJSON events, every event
|
||||
`{type, timestamp, sessionID, part}`; types observed: `step_start`,
|
||||
`tool_use`, `text`, `step_finish`. Tool events carry
|
||||
`part: {type:"tool", tool:"gbrain_recall", callID, state:{status:"completed",
|
||||
input:{…}, output:"<stringified JSON>"}}` — `parseOpencodeJsonl` pins this.
|
||||
- Model flag: `-m/--model <provider/model>` (`opencode/big-pickle` confirmed;
|
||||
paid ids follow models.dev convention — see Pending auth).
|
||||
- Keyless SMOKE end-to-end (proven 2026-08-15): pinned opencode + free model +
|
||||
real `gbrain serve --surface verbs` (7 verbs banner) recalled a per-run nonce
|
||||
through MCP with zero credentials, keyless PGLite brain.
|
||||
|
||||
## Environment — detectHarness + child-env facts (verified)
|
||||
- Inside `run`'s bash tool, opencode sets **`OPENCODE=1`** and `OPENCODE_PID`
|
||||
in child processes → `gbrain bootstrap`'s `detectHarness()` probes
|
||||
`OPENCODE`.
|
||||
- Auto-update kill: `OPENCODE_DISABLE_AUTOUPDATE=1` env + `"autoupdate": false`
|
||||
config — the door seeds BOTH; version stayed pinned across every observed
|
||||
run. `opencode upgrade` is the manual updater.
|
||||
- Rules files: project `AGENTS.md` is loaded; a sibling `CLAUDE.md` is NOT
|
||||
double-loaded (nonce test: only the AGENTS.md nonce surfaced) — AGENTS.md
|
||||
wins per level, exactly as documented. gbrain's rendered pull-protocol
|
||||
contract works unchanged.
|
||||
- `.well-known/opencode` remote config: never observed to fire in any CLI run
|
||||
(docs list it atop the lookup order). No kill needed today; re-observe on
|
||||
version bump.
|
||||
|
||||
## Auth (only needed for PAID providers)
|
||||
- Anonymous free tier needs nothing on disk; `auth.json` is only created by
|
||||
`opencode auth login` at `<XDG_DATA_HOME>/opencode/auth.json`
|
||||
(`opencode providers`, alias `auth`, prints the path).
|
||||
- The optional paid door leg gates on `ANTHROPIC_API_KEY` (env-only) and
|
||||
self-validates the model id against the authed `opencode models` output
|
||||
before spending.
|
||||
|
||||
## When the door goes red (triage)
|
||||
| Failure class | Signature | Remediation |
|
||||
|---|---|---|
|
||||
| npm pin drift | install step: version/integrity mismatch | Re-pin deliberately: bump `npm_version`+`npm_integrity` (+ platform stamps), run the re-observation checklist, update workflow env pins (check-opencode-pin.sh enforces the pair) |
|
||||
| canary leg red, pinned leg green | latest-version leg fails install/asserts | Upstream changed shape — schedule a pin refresh; pinned lane still gates |
|
||||
| version drift mid-run | `opencode --version` re-check ≠ pinned | Auto-update engaged — verify BOTH kills (env + config seed); re-pin if deliberate |
|
||||
| `✗ gbrain failed` in `mcp list` | `Executable not found in $PATH` / spawn error | Staged bin dir missing from PATH, or abs path wrong — registration bug, not opencode drift |
|
||||
| free-tier drift | keyless SMOKE stops answering / new auth wall | Re-observe keyless posture; if the free tier is gated, flip the SMOKE to the ANTHROPIC leg and re-pin this section |
|
||||
| paid leg: model id unknown | models-gate assert fails before any spend | Update the pinned anthropic model id from the authed `opencode models` output |
|
||||
| tripwire fired | manifest mismatch on `opencode.json(c)`/`auth.json` only | True isolation breach — stop and inspect; volatile-path drift alone must NOT fire |
|
||||
| real door regression | handshake or nonce assert fails, pins intact | Bisect against the pinned version; file upstream if opencode-side |
|
||||
|
||||
Re-observation checklist on a version bump: npm pin captures (§Pin), help-surface
|
||||
diff (`--help`, `run --help`, `mcp --help`, `mcp add --help`), the
|
||||
add → saved-config → `mcp list` sequence (§add/§Probes), the keyless `run`
|
||||
posture (§One-shot — free tier presence, stdout purity, MCP-without---auto),
|
||||
`debug paths`, and the `OPENCODE_CONFIG*` inertness probe (§Path seams). The
|
||||
spawn-gate probe (§Probes) re-runs whenever release notes mention MCP trust or
|
||||
permissions.
|
||||
|
||||
## Pending auth (requires ANTHROPIC_API_KEY; the core door does NOT)
|
||||
Authed `opencode models` list + exact `anthropic/<model>` id confirmation,
|
||||
one paid one-shot smoke + per-turn cost note, `auth.json` verbatim shape after
|
||||
`opencode auth login` (feeds evidence exclusions + TTY secretPaths), and
|
||||
whether the authed TUI first-run differs from the keyless one pinned in the
|
||||
dx scenario. The opencode-door paid leg self-validates the model id before
|
||||
spending, so these pins harden the door but do not block it.
|
||||
|
||||
## Supported-version policy
|
||||
gbrain's opencode integration is verified against **opencode v1.18.18** (this
|
||||
pin). The canary CI leg tracks latest (continue-on-error); the pinned lane is
|
||||
the deterministic gate. Keyless free-tier behavior is a LOAD-BEARING
|
||||
observation (the SMOKE rides it) — treat free-tier changes as pin-refresh
|
||||
triggers, not flakes.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Connect GBrain to opencode
|
||||
|
||||
> This page is the MCP-registration reference for **opencode** — the SST
|
||||
> terminal coding agent (opencode.ai, npm `opencode-ai`; not OpenClaw, and not
|
||||
> the original `opencode` CLI that was renamed Crush — see Troubleshooting).
|
||||
> For the full brain install — CLI, engine, skills, dream cycle — follow
|
||||
> [INSTALL_FOR_AGENTS.md](../../INSTALL_FOR_AGENTS.md) first; this page wires
|
||||
> the finished brain into opencode over stdio MCP. opencode is a
|
||||
> **bootstrap-supported harness**: `gbrain bootstrap hooks --harness opencode`
|
||||
> registers the brain for you (and `gbrain connect --agent opencode` handles
|
||||
> remote brains — see below) — the commands on this page are the standalone
|
||||
> manual recipe. Bootstrap's own registration additionally pins the workspace
|
||||
> source (`GBRAIN_SOURCE`) and the full op surface, so the two are not
|
||||
> byte-identical.
|
||||
|
||||
opencode spawns `gbrain serve` as a local stdio subprocess. No server, no
|
||||
tunnel, no token needed. Works with both PGLite and Supabase engines — and
|
||||
because opencode natively reads `AGENTS.md`, a gbrain workspace's rendered
|
||||
brain contract loads with zero extra configuration.
|
||||
|
||||
## Register (recommended)
|
||||
|
||||
```bash
|
||||
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
`--surface verbs` exposes the seven-verb memory protocol (`recall`,
|
||||
`remember`, `entity`, `synthesize`, `forget`, `context_pack`, `delta` —
|
||||
[MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)) instead of the full
|
||||
100+-op catalog — the recommended starting surface for coding agents.
|
||||
Three facts about `opencode mcp add`, all observed:
|
||||
|
||||
- **The local-command form is `-- <command> [args...]` after the flags** —
|
||||
it's real but missing from `--help` (which shows only `--url/--env/--header`).
|
||||
`--env` is repeatable, one `KEY=VALUE` per flag.
|
||||
- **Registration is lazy.** The add writes config and exits 0 without
|
||||
connecting — even for a nonexistent command. Verify with `opencode mcp list`
|
||||
(below), never with the add's exit code.
|
||||
- **It always writes the USER-GLOBAL config**
|
||||
(`~/.config/opencode/opencode.jsonc`) — there is no scope flag. For a
|
||||
project-scoped entry, write the project `opencode.json` directly (next
|
||||
section) — but read the sharing warning first.
|
||||
|
||||
## Direct config (equally supported)
|
||||
|
||||
Global (`~/.config/opencode/opencode.jsonc`) or project (`opencode.json` in
|
||||
the repo root — opencode's lookup traverses up to the git root):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"gbrain": {
|
||||
"type": "local",
|
||||
"command": ["gbrain", "serve", "--surface", "verbs"],
|
||||
"environment": { "GBRAIN_HOME": "/home/alice-example" },
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Comments are fine — opencode parses JSONC in both `.json` and `.jsonc` files,
|
||||
and both filenames are read (merged) when both exist. To remove gbrain,
|
||||
delete the entry, or set `"enabled": false` to disable without losing it.
|
||||
|
||||
**Sharing warning for project config:** opencode spawns project-defined local
|
||||
MCP servers with **no trust prompt** — a committed `opencode.json` carrying a
|
||||
gbrain entry executes on every collaborator's machine. Teammates without
|
||||
gbrain get a failing spawn each session; teammates WITH gbrain attach their
|
||||
own `host` brain to your repo's context. Prefer the user-global config (the
|
||||
gbrain bootstrap default); if you do commit a project entry, use the
|
||||
PATH-resolved `"gbrain"` command form (never an absolute path) and tell
|
||||
collaborators `"enabled": false` is the opt-out.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
opencode mcp list # the real probe: SPAWNS the server
|
||||
```
|
||||
|
||||
`opencode mcp list` performs the actual spawn + handshake for every
|
||||
configured server — expect `✓ gbrain connected`. A broken registration shows
|
||||
`✗ gbrain failed` with the reason (e.g. `Executable not found in $PATH`).
|
||||
Because it spawns everything — including any project `opencode.json` entries
|
||||
in your cwd, with no trust prompt — run it from a directory you trust
|
||||
(gbrain's own bootstrap verification probe runs from an empty temp directory
|
||||
for exactly this reason, and skips the live probe entirely for project-scoped
|
||||
registrations).
|
||||
Two caveats: the exit code is 0 even when servers fail (read the output, not
|
||||
`$?`), and `opencode mcp debug` is OAuth-only diagnostics — it is NOT a
|
||||
handshake probe for local servers. Then one real round-trip:
|
||||
|
||||
```bash
|
||||
opencode run "use the gbrain recall tool to answer: what did I import most recently?"
|
||||
```
|
||||
|
||||
`opencode run` (headless one-shot) prints the final answer alone on stdout
|
||||
(UI goes to stderr). MCP tools work in run mode without any permission flags.
|
||||
|
||||
## Remote brains (`gbrain connect`)
|
||||
|
||||
For a brain served over HTTP on another machine:
|
||||
|
||||
```bash
|
||||
gbrain connect https://your-host/mcp --token gbrain_xxx --agent opencode [--install]
|
||||
```
|
||||
|
||||
Without `--install` it prints the config block to add; with `--install` it
|
||||
writes the entry directly into the user-global config (no opencode binary
|
||||
required — the JSONC write IS the registration) and smoke-tests the token.
|
||||
Either way the config stores only the `{env:GBRAIN_REMOTE_TOKEN}`
|
||||
interpolation — opencode resolves the env var at read time, so the token
|
||||
never lands in the file. Export `GBRAIN_REMOTE_TOKEN` in your shell profile.
|
||||
`--force` replaces a gbrain-managed entry whose endpoint moved (a rotated
|
||||
serve); an entry gbrain didn't write is never replaced — pick another
|
||||
`--name`. (Framework-spawned opencode inherits no shell profile;
|
||||
`gbrain bootstrap harness --harness opencode` covers that case with an
|
||||
inline-bearer entry written 0600.)
|
||||
|
||||
## Auth + model pin
|
||||
|
||||
- **Keyless works.** opencode ships an anonymous free tier (default model
|
||||
`opencode/big-pickle` at observation time) — headless runs and MCP tool
|
||||
calls work with zero credentials. For paid providers, export the provider
|
||||
key (e.g. `ANTHROPIC_API_KEY`) or run `opencode auth login` (credentials
|
||||
land in `~/.local/share/opencode/auth.json`).
|
||||
- **Model pin:** pass `-m <provider/model>` per call, or set `"model"` in the
|
||||
config. `opencode models` lists what your credentials can reach.
|
||||
- **Updates:** opencode self-updates by default. For pinned/reproducible
|
||||
environments, set BOTH `"autoupdate": false` in config AND
|
||||
`OPENCODE_DISABLE_AUTOUPDATE=1` in the environment.
|
||||
|
||||
## Pair with cron
|
||||
|
||||
opencode has no built-in cron; schedule headless one-shots with your system
|
||||
scheduler:
|
||||
|
||||
```bash
|
||||
# crontab: brain maintenance every 4 hours
|
||||
0 */4 * * * opencode run "Run gbrain sync and report anything unusual"
|
||||
```
|
||||
|
||||
See [docs/guides/cron-schedule.md](../guides/cron-schedule.md) for the full
|
||||
brain maintenance protocol (sync, embed, dream cycle).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Wrong `opencode` on PATH** — the name has prior claimants (the original
|
||||
`opencode` project was renamed Crush). The SST CLI answers
|
||||
`opencode --version` with a bare semver (`1.18.18`) and has `opencode mcp`
|
||||
+ `opencode debug paths` subcommands. Install it via
|
||||
`npm install -g opencode-ai` or `curl -fsSL https://opencode.ai/install | bash`.
|
||||
- **opencode ≠ OpenClaw** — opencode (opencode.ai / SST) is the terminal
|
||||
agent this page covers; OpenClaw is the agent platform with its own gbrain
|
||||
runner and docs ([OPENCLAW.md](OPENCLAW.md)).
|
||||
- **`✗ gbrain failed — Executable not found in $PATH`** — the registered
|
||||
command was the bare `"gbrain"` name and opencode's PATH doesn't carry it.
|
||||
Use the absolute binary path in the user-global config, or fix PATH.
|
||||
- **Registered but nothing changed mid-session** — opencode reads config at
|
||||
session start; restart opencode (or start a new session) after registering.
|
||||
- **`OPENCODE_CONFIG` seems ignored** — observed inert in v1.18.18: only
|
||||
`HOME`/`XDG_CONFIG_HOME` move the config location. Don't rely on it.
|
||||
- **Which config won?** — `opencode debug config` prints the resolved merge;
|
||||
`opencode debug paths` prints every directory opencode uses.
|
||||
- **Rules files** — opencode loads the project `AGENTS.md` (a sibling
|
||||
`CLAUDE.md` is NOT double-loaded; AGENTS.md wins). gbrain's rendered
|
||||
workspace contract rides this natively.
|
||||
|
||||
---
|
||||
|
||||
Verified against **opencode v1.18.18** (fast-moving project — the pin is
|
||||
enforced in CI, with a latest-version canary leg watching for drift).
|
||||
Dev-facing observed-behavior notes (exact flag semantics, exit-code caveats,
|
||||
config schema, CI pin values) live in [OPENCODE-CLI-PIN.md](OPENCODE-CLI-PIN.md).
|
||||
@@ -69,6 +69,11 @@ codex mcp add gbrain -- gbrain serve --surface verbs
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**opencode** (verify with `opencode mcp list` — the add is lazy, and list SPAWNS the server)
|
||||
```bash
|
||||
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**OpenClaw / any stdio MCP host** — register the server command
|
||||
`gbrain serve --surface verbs`. Remote brains: `gbrain serve --http` on the
|
||||
host, then `gbrain connect https://host/mcp --token gbrain_xxx --install` on
|
||||
|
||||
+24
-3
@@ -1259,10 +1259,25 @@ grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
The add is lazy (exit 0 without connecting) — verify with
|
||||
`grok mcp doctor gbrain`, which spawns the server and must report
|
||||
`7 tools discovered`. This is the brain-only install; the `gbrain bootstrap`
|
||||
personal-agent path does not support Grok yet (Claude Code/Codex only).
|
||||
personal-agent path does not support Grok yet (Claude Code, Codex, and opencode only).
|
||||
Verified against Grok Build v1.0.4. Full reference:
|
||||
[docs/mcp/GROK.md](docs/mcp/GROK.md).
|
||||
|
||||
**If you are opencode** (the SST terminal agent, opencode.ai — not OpenClaw):
|
||||
you are a bootstrap-supported harness — for the full persistent-personal-agent
|
||||
install, follow `BOOTSTRAP_FOR_AGENTS.md` instead of this page. For the
|
||||
brain-only MCP registration:
|
||||
|
||||
```bash
|
||||
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
The add is lazy (exit 0 without connecting) — verify with `opencode mcp list`,
|
||||
which spawns the server and must show `✓ gbrain connected` (the exit code is 0
|
||||
even on failure; read the output). Restart opencode afterwards — it reads
|
||||
config at session start. Verified against opencode v1.18.18. Full reference:
|
||||
[docs/mcp/OPENCODE.md](docs/mcp/OPENCODE.md).
|
||||
|
||||
Whether you scaffolded or not, read `skills/RESOLVER.md` (in your workspace, or the
|
||||
bundled copy at `~/gbrain/skills/RESOLVER.md` when running from the cloned repo). It's
|
||||
the skill dispatcher — tells you which skill to read for any task. Save this to your
|
||||
@@ -1787,6 +1802,7 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
|
||||
- **[Cursor / Windsurf / any stdio MCP client](docs/mcp/CLAUDE_CODE.md)** — same shape, add `{"command": "gbrain", "args": ["serve"]}` to your MCP config.
|
||||
- **[Hermes](docs/mcp/HERMES.md)** — `printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve`. Keep `--args` last, and verify with `hermes mcp test gbrain` (the add exits 0 even on failure).
|
||||
- **[Grok Build](docs/mcp/GROK.md)** — `grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs`. The add is lazy (exit 0 without connecting) — verify with `grok mcp doctor gbrain`, which spawns the server and reports `7 tools discovered`. Verified against Grok Build v1.0.4.
|
||||
- **[opencode](docs/mcp/OPENCODE.md)** (opencode.ai / SST — not OpenClaw) — `opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs`, or let `gbrain bootstrap hooks --harness opencode` write the config for you (opencode is a bootstrap-supported harness — it reads AGENTS.md natively). The add is lazy — verify with `opencode mcp list`, which spawns the server (`✓ gbrain connected`). Remote: `gbrain connect https://your-host/mcp --token gbrain_xxx --agent opencode [--install]` — the config stores only the `{env:GBRAIN_REMOTE_TOKEN}` interpolation. Verified against opencode v1.18.18.
|
||||
- **[OpenClaw](docs/mcp/OPENCLAW.md)** — the ClawHub bundle plugin registers gbrain automatically (`openclaw.plugin.json` ships in this repo), or add `{"command": "gbrain", "args": ["serve"]}` to `~/.openclaw/config.json`'s `mcpServers`.
|
||||
- **[Claude Desktop (Cowork)](docs/mcp/CLAUDE_DESKTOP.md)** — Settings → Integrations → add the URL of your HTTP server. Remote only; the local `claude_desktop_config.json` does not work for remote servers.
|
||||
- **[Claude Cowork (team plan)](docs/mcp/CLAUDE_COWORK.md)** — org Owner adds the connector under Organization Settings → Connectors.
|
||||
@@ -3946,7 +3962,7 @@ The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
|
||||
| `reflex` | automatic, inside the context engine | default-on for plugin hosts; nothing to call |
|
||||
| `op` | `gbrain volunteer-context` / MCP `volunteer_context` | agents without the plugin; one call per turn |
|
||||
| `watch` | `gbrain watch` | stream a transcript in, volunteered pages stream out |
|
||||
| `claude-code` / `codex` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
|
||||
| `claude-code` / `codex` / `opencode` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
|
||||
|
||||
## How it decides
|
||||
|
||||
@@ -4008,7 +4024,7 @@ this channel production-grade rather than spammy-and-invisible:
|
||||
- **The feedback loop.** The serve logs each DELIVERED block's volunteered
|
||||
pages and pointers to `context_volunteer_events` under the hook's channel
|
||||
(`claude-code` by default; a codex hook registration passes
|
||||
`--harness codex`). `gbrain volunteer-context --stats` then shows
|
||||
`--harness codex` / `--harness opencode`). `gbrain volunteer-context --stats` then shows
|
||||
per-harness precision, and `gbrain doctor`'s `volunteer_channels` check
|
||||
shows which channels actually fire, with guidance for the two quiet cases:
|
||||
"hook installed but never registered (restart the session)" and "registered
|
||||
@@ -4500,6 +4516,11 @@ codex mcp add gbrain -- gbrain serve --surface verbs
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**opencode** (verify with `opencode mcp list` — the add is lazy, and list SPAWNS the server)
|
||||
```bash
|
||||
opencode mcp add gbrain --env GBRAIN_HOME=$HOME -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**OpenClaw / any stdio MCP host** — register the server command
|
||||
`gbrain serve --surface verbs`. Remote brains: `gbrain serve --http` on the
|
||||
host, then `gbrain connect https://host/mcp --token gbrain_xxx --install` on
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "gbrain-context-engine",
|
||||
"name": "gbrain",
|
||||
"version": "0.46.3.0",
|
||||
"version": "0.46.4.0",
|
||||
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
|
||||
"family": "bundle-plugin",
|
||||
"configSchema": {
|
||||
|
||||
+4
-1
@@ -51,6 +51,8 @@
|
||||
"check:cli-exec": "bash scripts/check-cli-executable.sh",
|
||||
"check:engine-dynamic-import": "bash scripts/check-engine-dynamic-import.sh",
|
||||
"check:grok-pin": "bash scripts/check-grok-pin.sh",
|
||||
"check:opencode-pin": "bash scripts/check-opencode-pin.sh",
|
||||
"check:pin-doc-privacy": "bash scripts/check-pin-doc-privacy.sh",
|
||||
"check:gateway-routed": "bash scripts/check-gateway-routed-no-direct-anthropic.sh",
|
||||
"check:worker-pool-atomicity": "bash scripts/check-worker-pool-atomicity.sh",
|
||||
"check:doc-history": "bash scripts/check-key-files-current-state.sh",
|
||||
@@ -132,6 +134,7 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"heic-decode": "^2.1.0",
|
||||
"js-yaml": "^3.15.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"marked": "^18.0.2",
|
||||
"openai": "^4.0.0",
|
||||
"pgvector": "^0.2.0",
|
||||
@@ -157,7 +160,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.46.3.0",
|
||||
"version": "0.46.4.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -25,14 +25,16 @@
|
||||
# (d) Phase-list check [D5]: every `Phase: <name>` in BOOTSTRAP_FOR_AGENTS.md
|
||||
# must appear in src/core/bootstrap/status.ts (the TS phase list is the
|
||||
# single source; the runbook defers to it). Skips while either is absent.
|
||||
# (e) Harness-scoping counter-signal pins: the MCP-scope consent is Claude
|
||||
# Code only (Codex has no scope flag — `codex mcp add` is user-global).
|
||||
# Tripwires against accidental deletion of the load-bearing prose, not
|
||||
# proofs of placement: the runbook must carry the Codex bullet's
|
||||
# "Do NOT offer an MCP scope choice" and the phase-3 "Claude Code only"
|
||||
# scoping; questions.json's MCP_SCOPE.question must START WITH
|
||||
# "(Claude Code only". Intentional rewording updates these pins in the
|
||||
# same commit. Skips while the runbook/bank are absent.
|
||||
# (e) Harness-scoping counter-signal pins: the MCP-scope consent applies on
|
||||
# Claude Code and opencode (Codex has no scope flag — `codex mcp add` is
|
||||
# user-global; opencode DEFAULTS to user-global — no trust gate on
|
||||
# project-config servers). Tripwires against accidental deletion of the
|
||||
# load-bearing prose, not proofs of placement: the runbook must carry the
|
||||
# Codex bullet's "Do NOT offer an MCP scope choice" and the phase-3
|
||||
# "Claude Code and opencode" scoping; questions.json's MCP_SCOPE.question
|
||||
# must START WITH "(Claude Code and opencode". Intentional rewording
|
||||
# updates these pins in the same commit. Skips while the runbook/bank are
|
||||
# absent.
|
||||
#
|
||||
# BSD/GNU grep portable (no \t escapes). Uses `bun` for JSON parsing — the
|
||||
# check runs via `bun run verify`, so bun is always present.
|
||||
@@ -195,7 +197,7 @@ else
|
||||
echo "SKIP: phase-list check (runbook or src/core/bootstrap/status.ts absent)"
|
||||
fi
|
||||
|
||||
# ── (e) harness-scoping counter-signal pins (MCP scope is Claude Code only) ─
|
||||
# ── (e) harness-scoping counter-signal pins (scope = Claude Code + opencode) ─
|
||||
if [ -f "$RUNBOOK" ]; then
|
||||
if ! grep -qF 'Do NOT offer an MCP scope choice' "$RUNBOOK"; then
|
||||
fail=1
|
||||
@@ -204,11 +206,20 @@ if [ -f "$RUNBOOK" ]; then
|
||||
echo " without this line, Codex-door agents re-ask a dead question." >&2
|
||||
echo " Rewording intentionally? Update this pin in the same commit." >&2
|
||||
fi
|
||||
if ! grep -qF 'Claude Code only' "$RUNBOOK"; then
|
||||
if ! grep -qF 'Claude Code and opencode' "$RUNBOOK"; then
|
||||
fail=1
|
||||
echo "FAIL: BOOTSTRAP_FOR_AGENTS.md lost the 'Claude Code only' scoping on the" >&2
|
||||
echo " MCP-scope consent (phase 3). Without it the consent reads as" >&2
|
||||
echo " harness-blind and Codex-door agents ask it." >&2
|
||||
echo "FAIL: BOOTSTRAP_FOR_AGENTS.md lost the 'Claude Code and opencode' scoping" >&2
|
||||
echo " on the MCP-scope consent (phase 3). Without it the consent reads as" >&2
|
||||
echo " harness-blind: Codex-door agents ask a dead question and opencode" >&2
|
||||
echo " agents miss the inverted (user-global) default." >&2
|
||||
echo " Rewording intentionally? Update this pin in the same commit." >&2
|
||||
fi
|
||||
if ! grep -qF 'NO trust prompt' "$RUNBOOK"; then
|
||||
fail=1
|
||||
echo "FAIL: BOOTSTRAP_FOR_AGENTS.md lost the opencode spawn-gate rationale" >&2
|
||||
echo " ('NO trust prompt'). Without it agents recommend the Claude-style" >&2
|
||||
echo " project default on opencode — where a committed project entry" >&2
|
||||
echo " auto-executes on every collaborator machine." >&2
|
||||
echo " Rewording intentionally? Update this pin in the same commit." >&2
|
||||
fi
|
||||
else
|
||||
@@ -216,9 +227,9 @@ else
|
||||
fi
|
||||
if [ -f "$QUESTIONS" ] && command -v bun >/dev/null 2>&1; then
|
||||
if ! GBRAIN_QJSON="$QUESTIONS" bun -e \
|
||||
'const fs=require("fs");let b;try{b=JSON.parse(fs.readFileSync(process.env.GBRAIN_QJSON,"utf8"));}catch(e){process.exit(1);}if(!b.questions){process.exit(1);}const e=b.questions.MCP_SCOPE;const q=(e&&e.question)||"";process.exit(q.startsWith("(Claude Code only")&&e.phase==="interview"?0:1);'; then
|
||||
'const fs=require("fs");let b;try{b=JSON.parse(fs.readFileSync(process.env.GBRAIN_QJSON,"utf8"));}catch(e){process.exit(1);}if(!b.questions){process.exit(1);}const e=b.questions.MCP_SCOPE;const q=(e&&e.question)||"";process.exit(q.startsWith("(Claude Code and opencode")&&e.phase==="interview"?0:1);'; then
|
||||
fail=1
|
||||
echo "FAIL: questions.json MCP_SCOPE.question must start with '(Claude Code only'" >&2
|
||||
echo "FAIL: questions.json MCP_SCOPE.question must start with '(Claude Code and opencode'" >&2
|
||||
echo " AND MCP_SCOPE.phase must be 'interview' (the consent is recorded" >&2
|
||||
echo " pre-confirm during the interview; a 'wire' phase re-creates the" >&2
|
||||
echo " bank-vs-runbook contradiction). Also fails when the questions" >&2
|
||||
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/check-opencode-pin.sh — opencode pin consistency guard.
|
||||
#
|
||||
# OPENCODE-CLI-PIN.md is the single observed-behavior source for the opencode
|
||||
# integration; its pins fan out to the heavy-tests opencode-door job env, the
|
||||
# OpencodeRunner argv, and the door e2e assertions. The prose rule is "update
|
||||
# together" — this guard turns the workflow half of that rule into CI:
|
||||
#
|
||||
# 1. docs/mcp/OPENCODE-CLI-PIN.md carries a machine-stable stamp block
|
||||
# (`<!-- opencode-pin: key=value -->`, one per line) including
|
||||
# distribution_kind (npm | installer).
|
||||
# 2. The opencode-door job env in .github/workflows/heavy-tests.yml must carry
|
||||
# EXACTLY the pin set for the chosen distribution_kind:
|
||||
# npm: OPENCODE_VERSION==opencode_version, OPENCODE_NPM_PACKAGE==npm_package,
|
||||
# OPENCODE_NPM_INTEGRITY==npm_integrity; no OPENCODE_INSTALL_SHA256.
|
||||
# installer: OPENCODE_VERSION==opencode_version,
|
||||
# OPENCODE_INSTALL_SHA256==installer_sha256; no OPENCODE_NPM_INTEGRITY.
|
||||
# (The pin DOC may document both — the fallback path stays written down;
|
||||
# exclusivity is about which pins the WORKFLOW actually enforces.)
|
||||
#
|
||||
# Greps are anchored to the opencode-door job block so a future canary matrix leg
|
||||
# (or a second door job) cannot satisfy the check by accident.
|
||||
#
|
||||
# SKIP-GRACEFUL: missing pin doc, missing workflow, or no opencode-door job yet →
|
||||
# SKIP (exit 0), matching scripts/check-bootstrap-tag.sh. Test override:
|
||||
# GBRAIN_OPENCODE_PIN_GUARD_ROOT points file resolution at a fixture tree.
|
||||
# BSD/GNU portable (no \t escapes, no GNU-only flags).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${GBRAIN_OPENCODE_PIN_GUARD_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
PIN_FILE="$ROOT/docs/mcp/OPENCODE-CLI-PIN.md"
|
||||
WORKFLOW="$ROOT/.github/workflows/heavy-tests.yml"
|
||||
|
||||
if [ ! -f "$WORKFLOW" ]; then
|
||||
echo "check-opencode-pin: SKIP (no $WORKFLOW)"
|
||||
exit 0
|
||||
fi
|
||||
if ! grep -q '^ opencode-door:' "$WORKFLOW"; then
|
||||
echo "check-opencode-pin: SKIP (no opencode-door job in heavy-tests.yml yet)"
|
||||
exit 0
|
||||
fi
|
||||
# Once the opencode-door job EXISTS, a missing pin doc is a FAILURE, not a skip —
|
||||
# deleting/renaming the doc must not silently disable the supply-chain gate.
|
||||
if [ ! -f "$PIN_FILE" ]; then
|
||||
echo "check-opencode-pin: FAIL — opencode-door job exists but $PIN_FILE is missing (the pin doc is the gate's source of truth)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fail() {
|
||||
echo "check-opencode-pin: FAIL — $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 1. Parse the stamp block ------------------------------------------------
|
||||
stamp() {
|
||||
# First occurrence wins; a missing stamp yields the empty string (callers
|
||||
# decide whether that is a failure) — the `|| true` keeps set -e/pipefail
|
||||
# from treating grep's no-match exit as a script error.
|
||||
{ grep -E "^<!-- opencode-pin: $1=" "$PIN_FILE" || true; } | head -1 \
|
||||
| sed -e 's/^<!-- opencode-pin: [a-z0-9_]*=//' -e 's/ -->$//'
|
||||
}
|
||||
|
||||
# Duplicate stamps are drift bait (two values, which one is real?).
|
||||
dupes=$({ grep -E '^<!-- opencode-pin: ' "$PIN_FILE" || true; } | sed -e 's/^<!-- opencode-pin: //' -e 's/=.*$//' | sort | uniq -d)
|
||||
[ -n "$dupes" ] && fail "duplicate opencode-pin stamp(s) in OPENCODE-CLI-PIN.md: $dupes"
|
||||
|
||||
DIST_KIND=$(stamp distribution_kind)
|
||||
OPENCODE_VERSION_PIN=$(stamp opencode_version)
|
||||
[ -n "$DIST_KIND" ] || fail "OPENCODE-CLI-PIN.md is missing the distribution_kind stamp"
|
||||
[ -n "$OPENCODE_VERSION_PIN" ] || fail "OPENCODE-CLI-PIN.md is missing the opencode_version stamp"
|
||||
case "$DIST_KIND" in
|
||||
npm|installer) ;;
|
||||
*) fail "distribution_kind stamp must be npm or installer; got '$DIST_KIND'" ;;
|
||||
esac
|
||||
|
||||
# --- 2. Extract the opencode-door job block --------------------------------------
|
||||
# Jobs sit at 2-space indent; the block ends at the next 2-space-indented key.
|
||||
job_block=$(awk '
|
||||
/^ opencode-door:/ { f = 1; print; next }
|
||||
f && /^ [A-Za-z0-9_-]+:/ { exit }
|
||||
f { print }
|
||||
' "$WORKFLOW")
|
||||
[ -n "$job_block" ] || fail "could not extract the opencode-door job block"
|
||||
|
||||
wf_env() {
|
||||
# Strip either quote style: a YAML-formatter pass flipping double to single
|
||||
# quotes must not read as pin drift.
|
||||
{ printf '%s\n' "$job_block" | grep -E "^ $1:" || true; } | head -1 \
|
||||
| sed -e "s/^ $1:[[:space:]]*//" -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'\$//"
|
||||
}
|
||||
|
||||
WF_VERSION=$(wf_env OPENCODE_VERSION)
|
||||
WF_NPM_PACKAGE=$(wf_env OPENCODE_NPM_PACKAGE)
|
||||
WF_NPM_INTEGRITY=$(wf_env OPENCODE_NPM_INTEGRITY)
|
||||
WF_INSTALL_SHA=$(wf_env OPENCODE_INSTALL_SHA256)
|
||||
|
||||
[ -n "$WF_VERSION" ] || fail "opencode-door job env is missing OPENCODE_VERSION"
|
||||
[ "$WF_VERSION" = "$OPENCODE_VERSION_PIN" ] || fail "OPENCODE_VERSION drift — workflow '$WF_VERSION' vs pin-doc stamp '$OPENCODE_VERSION_PIN' (update together; see the pin doc's re-observation checklist)"
|
||||
|
||||
# EVERY OPENCODE_VERSION: env line in the WHOLE workflow (the real-agent-e2e
|
||||
# door job carries a second copy) must equal the stamp — bumping the door job
|
||||
# alone must never pass green. Env keys sit at line start after indentation,
|
||||
# so comments mentioning the name never match.
|
||||
all_wf_versions=$({ grep -E '^[[:space:]]*OPENCODE_VERSION:' "$WORKFLOW" || true; } \
|
||||
| sed -e 's/^[[:space:]]*OPENCODE_VERSION:[[:space:]]*//' -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'\$//")
|
||||
for v in $all_wf_versions; do
|
||||
[ "$v" = "$OPENCODE_VERSION_PIN" ] || fail "an OPENCODE_VERSION occurrence elsewhere in heavy-tests.yml ('$v') disagrees with the pin-doc stamp '$OPENCODE_VERSION_PIN' — every copy in the workflow moves with the stamp"
|
||||
done
|
||||
|
||||
if [ "$DIST_KIND" = "npm" ]; then
|
||||
NPM_PACKAGE_PIN=$(stamp npm_package)
|
||||
NPM_INTEGRITY_PIN=$(stamp npm_integrity)
|
||||
[ -n "$NPM_PACKAGE_PIN" ] || fail "distribution_kind=npm but OPENCODE-CLI-PIN.md is missing the npm_package stamp"
|
||||
[ -n "$NPM_INTEGRITY_PIN" ] || fail "distribution_kind=npm but OPENCODE-CLI-PIN.md is missing the npm_integrity stamp"
|
||||
[ -n "$WF_NPM_PACKAGE" ] || fail "distribution_kind=npm but the opencode-door job env is missing OPENCODE_NPM_PACKAGE"
|
||||
[ -n "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=npm but the opencode-door job env is missing OPENCODE_NPM_INTEGRITY"
|
||||
[ "$WF_NPM_PACKAGE" = "$NPM_PACKAGE_PIN" ] || fail "OPENCODE_NPM_PACKAGE drift — workflow '$WF_NPM_PACKAGE' vs stamp '$NPM_PACKAGE_PIN'"
|
||||
[ "$WF_NPM_INTEGRITY" = "$NPM_INTEGRITY_PIN" ] || fail "OPENCODE_NPM_INTEGRITY drift — workflow vs stamp mismatch"
|
||||
# npm_version is a documented near-duplicate of opencode_version — assert they
|
||||
# agree so bumping one alone can never pass green.
|
||||
NPM_VERSION_PIN=$(stamp npm_version)
|
||||
if [ -n "$NPM_VERSION_PIN" ] && [ "$NPM_VERSION_PIN" != "$OPENCODE_VERSION_PIN" ]; then
|
||||
fail "npm_version stamp ($NPM_VERSION_PIN) disagrees with opencode_version stamp ($OPENCODE_VERSION_PIN) — update together"
|
||||
fi
|
||||
# Platform-payload integrity stamps (the door job byte-pins the linux
|
||||
# sub-packages too): when the pin doc carries them, the job env must match.
|
||||
X64_PIN=$(stamp npm_linux_x64_integrity)
|
||||
if [ -n "$X64_PIN" ]; then
|
||||
WF_X64=$(wf_env OPENCODE_NPM_LINUX_X64_INTEGRITY)
|
||||
[ -n "$WF_X64" ] || fail "pin doc stamps npm_linux_x64_integrity but the opencode-door job env is missing OPENCODE_NPM_LINUX_X64_INTEGRITY"
|
||||
[ "$WF_X64" = "$X64_PIN" ] || fail "OPENCODE_NPM_LINUX_X64_INTEGRITY drift — workflow vs stamp mismatch"
|
||||
fi
|
||||
ARM64_PIN=$(stamp npm_linux_arm64_integrity)
|
||||
if [ -n "$ARM64_PIN" ]; then
|
||||
WF_ARM64=$(wf_env OPENCODE_NPM_LINUX_ARM64_INTEGRITY)
|
||||
[ -n "$WF_ARM64" ] || fail "pin doc stamps npm_linux_arm64_integrity but the opencode-door job env is missing OPENCODE_NPM_LINUX_ARM64_INTEGRITY"
|
||||
[ "$WF_ARM64" = "$ARM64_PIN" ] || fail "OPENCODE_NPM_LINUX_ARM64_INTEGRITY drift — workflow vs stamp mismatch"
|
||||
fi
|
||||
[ -z "$WF_INSTALL_SHA" ] || fail "distribution_kind=npm but the opencode-door job also pins OPENCODE_INSTALL_SHA256 — one provisioning mode only (mode exclusivity)"
|
||||
else
|
||||
INSTALL_SHA_PIN=$(stamp installer_sha256)
|
||||
[ -n "$INSTALL_SHA_PIN" ] || fail "distribution_kind=installer but OPENCODE-CLI-PIN.md is missing the installer_sha256 stamp"
|
||||
[ -n "$WF_INSTALL_SHA" ] || fail "distribution_kind=installer but the opencode-door job env is missing OPENCODE_INSTALL_SHA256"
|
||||
[ "$WF_INSTALL_SHA" = "$INSTALL_SHA_PIN" ] || fail "OPENCODE_INSTALL_SHA256 drift — workflow vs stamp mismatch"
|
||||
[ -z "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=installer but the opencode-door job also pins OPENCODE_NPM_INTEGRITY — one provisioning mode only (mode exclusivity)"
|
||||
fi
|
||||
|
||||
echo "check-opencode-pin: ok ($DIST_KIND mode, opencode $OPENCODE_VERSION_PIN)"
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/check-pin-doc-privacy.sh — PIN-doc privacy guard.
|
||||
#
|
||||
# The docs/mcp/*-CLI-PIN.md files carry VERBATIM observation transcripts from
|
||||
# real installs (help output, saved configs, error copy). That verbatim
|
||||
# discipline is the point — but it is also exactly how an operator path
|
||||
# (/Users/<name>/…), a key fragment, or an account id ends up committed and
|
||||
# shipped with every release. This guard asserts the placeholder discipline:
|
||||
#
|
||||
# 1. No operator home paths: /Users/<name>/ or /home/<name>/ must appear as
|
||||
# placeholders (<tmp>, $HOME, ~/) — never as a real username path.
|
||||
# Bare `~/.grok`-style spellings are fine (that IS the placeholder).
|
||||
# 2. No key material: long high-entropy tokens with known prefixes
|
||||
# (sk-…, xai-…, gbrain_<64+hex-ish>, ANTHROPIC/OPENAI/XAI key shapes).
|
||||
# npm `sha512-…` integrity pins are EXPECTED content — excluded.
|
||||
# 3. No obvious account ids: emails outside example.com/invalid domains.
|
||||
#
|
||||
# SKIP-GRACEFUL: no pin docs yet → SKIP (exit 0). Test override:
|
||||
# GBRAIN_PIN_PRIVACY_GUARD_ROOT points file resolution at a fixture tree.
|
||||
# BSD/GNU portable.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${GBRAIN_PIN_PRIVACY_GUARD_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
shopt -s nullglob
|
||||
PIN_DOCS=("$ROOT"/docs/mcp/*-CLI-PIN.md)
|
||||
shopt -u nullglob
|
||||
|
||||
if [ "${#PIN_DOCS[@]}" -eq 0 ]; then
|
||||
echo "check-pin-doc-privacy: SKIP (no docs/mcp/*-CLI-PIN.md yet)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fail=0
|
||||
|
||||
for doc in "${PIN_DOCS[@]}"; do
|
||||
rel="${doc#"$ROOT"/}"
|
||||
|
||||
# 1. Operator home paths (a real username after /Users/ or /home/).
|
||||
hits=$(grep -nE '(/Users|/home)/[A-Za-z][A-Za-z0-9._-]+/' "$doc" || true)
|
||||
if [ -n "$hits" ]; then
|
||||
fail=1
|
||||
echo "FAIL: $rel carries operator home path(s) — replace with <tmp>/\$HOME/~ placeholders:" >&2
|
||||
printf '%s\n' "$hits" | sed 's/^/ /' >&2
|
||||
fi
|
||||
|
||||
# 2. Key material. sha512- npm integrity pins are expected; exclude lines
|
||||
# carrying them before scanning for long secret-shaped runs.
|
||||
hits=$(grep -v 'sha512-' "$doc" | grep -nE '(sk-[A-Za-z0-9_-]{20,}|xai-[A-Za-z0-9_-]{20,}|gbrain_[A-Za-z0-9]{32,}|AKIA[0-9A-Z]{16})' || true)
|
||||
if [ -n "$hits" ]; then
|
||||
fail=1
|
||||
echo "FAIL: $rel carries key-shaped material — redact before committing:" >&2
|
||||
printf '%s\n' "$hits" | sed 's/^/ /' >&2
|
||||
fi
|
||||
|
||||
# 3. Emails outside the documentation-safe domains.
|
||||
hits=$(grep -nE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' "$doc" \
|
||||
| grep -vE '@(example\.(com|org|net)|[A-Za-z0-9.-]*invalid)' || true)
|
||||
if [ -n "$hits" ]; then
|
||||
fail=1
|
||||
echo "FAIL: $rel carries a non-placeholder email address:" >&2
|
||||
printf '%s\n' "$hits" | sed 's/^/ /' >&2
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "check-pin-doc-privacy: FAIL (pin docs ship with every release — placeholder discipline is the privacy IRON RULE)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "check-pin-doc-privacy: ok (${#PIN_DOCS[@]} pin doc(s))"
|
||||
@@ -28,6 +28,8 @@
|
||||
* so the interview completes unattended. Pays real API cost;
|
||||
* takes 10-25 min. Run in background and watch session/screen.txt.
|
||||
* codex-install Same for REAL `codex` (interactive TUI).
|
||||
* opencode-install Same for REAL `opencode` (bootstrap-supported; the keyless
|
||||
* run rides the anonymous free tier and should COMPLETE).
|
||||
* drive -- <cmd> Manual mode: spawn ANY command under the PTY and steer it
|
||||
* across separate shell calls via a file control channel:
|
||||
* watch: cat <dir>/session/screen.txt
|
||||
@@ -45,6 +47,7 @@
|
||||
* bun run scripts/dx-explore.ts init
|
||||
* bun run scripts/dx-explore.ts claude-install
|
||||
* bun run scripts/dx-explore.ts codex-install
|
||||
* bun run scripts/dx-explore.ts opencode-install [--keyless]
|
||||
* bun run scripts/dx-explore.ts drive [--no-hermetic-home] -- gbrain init
|
||||
* Options: --dir <out> transcript dir (default .context/dx-runs/<scenario>-<ts>)
|
||||
* --gbrain <bin> use an existing gbrain binary (default: compile+cache)
|
||||
@@ -833,6 +836,59 @@ async function scenarioGrokInstall(ctx: ScenarioCtx, args: CliArgs): Promise<voi
|
||||
});
|
||||
}
|
||||
|
||||
// ── scenario: opencode-install ───────────────────────────────────────────────
|
||||
|
||||
async function scenarioOpencodeInstall(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
const gbHome = tmp(ctx, 'gb-dx-gbhome-');
|
||||
const ws = tmp(ctx, 'gb-dx-ws-');
|
||||
const binDir = stageBinDir(ctx);
|
||||
|
||||
// Hermetic HOME + BOTH XDG dirs (config/auth/data all move — observed
|
||||
// v1.18.18, OPENCODE-CLI-PIN.md §Path seams), seeded with the config half
|
||||
// of the double autoupdate kill; the env half rides the session env below.
|
||||
const xdgConfig = path.join(home, '.config');
|
||||
const ocCfgDir = path.join(xdgConfig, 'opencode');
|
||||
fs.mkdirSync(ocCfgDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ocCfgDir, 'opencode.json'),
|
||||
JSON.stringify({ $schema: 'https://opencode.ai/config.json', autoupdate: false }, null, 2) + '\n',
|
||||
);
|
||||
// Auth travels env-only for the anthropic leg; a login flow would persist
|
||||
// auth.json — pre-register the known candidate for the scrub (rm of a file
|
||||
// that never appears is a no-op).
|
||||
ctx.secretPaths.push(path.join(home, '.local', 'share', 'opencode', 'auth.json'));
|
||||
spawnSync('git', ['init', '-q', ws]);
|
||||
spawnSync('git', ['-C', ws, 'config', 'user.email', 'dx@example.com']);
|
||||
spawnSync('git', ['-C', ws, 'config', 'user.name', 'DX Explore']);
|
||||
|
||||
log('REAL interactive opencode running the paste-in bootstrap (opencode is a bootstrap-supported harness)');
|
||||
log('keyless runs ride the anonymous free tier (observed) — the flow should COMPLETE keyless; a sign-in wall here is itself a pin-refresh signal');
|
||||
await runInstallSession(ctx, {
|
||||
argv: ['opencode'],
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
XDG_CONFIG_HOME: xdgConfig,
|
||||
XDG_DATA_HOME: path.join(home, '.local', 'share'),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: '1',
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
// Never let a first-run bounce the OPERATOR's browser for sign-in.
|
||||
BROWSER: '/usr/bin/false',
|
||||
},
|
||||
extraAllow: ['ANTHROPIC_API_KEY'],
|
||||
// --keyless drops provider keys AFTER extraAllow re-admission — on
|
||||
// opencode that measures the FREE-TIER path, not a wall (observed).
|
||||
dropEnv: args.keyless ? PROVIDER_KEY_NAMES : undefined,
|
||||
prompt: installPrompt(),
|
||||
meta: {
|
||||
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
|
||||
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local — opencode is bootstrap-supported)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── scenario: drive (manual control channel) ─────────────────────────────────
|
||||
|
||||
async function scenarioDrive(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
@@ -919,6 +975,7 @@ const SCENARIOS: Record<string, { needsGbrain: boolean; run: (ctx: ScenarioCtx,
|
||||
'claude-install': { needsGbrain: true, run: scenarioClaudeInstall },
|
||||
'codex-install': { needsGbrain: true, run: scenarioCodexInstall },
|
||||
'grok-install': { needsGbrain: true, run: scenarioGrokInstall },
|
||||
'opencode-install': { needsGbrain: true, run: scenarioOpencodeInstall },
|
||||
drive: { needsGbrain: true, run: scenarioDrive },
|
||||
};
|
||||
|
||||
|
||||
@@ -61,3 +61,5 @@ check-bootstrap-tag.sh repostate exempt VERSION stamp drift check
|
||||
check-cli-executable.sh repostate exempt file-mode check
|
||||
check-no-tracked-symlinks.sh repostate exempt git index state check
|
||||
check-grok-pin.sh repostate exempt pin-stamp drift check (GROK-CLI-PIN.md stamps vs heavy-tests grok-door env); own bun guard tests in test/check-bootstrap-guards.test.ts
|
||||
check-opencode-pin.sh repostate exempt pin-stamp drift check (OPENCODE-CLI-PIN.md stamps vs heavy-tests opencode-door env); own bun guard tests in test/check-bootstrap-guards.test.ts
|
||||
check-pin-doc-privacy.sh repostate exempt PIN-doc placeholder discipline (no operator paths/key material/emails in docs/mcp/*-CLI-PIN.md); own bun guard tests in test/check-bootstrap-guards.test.ts
|
||||
|
||||
|
+5
-3
@@ -87,11 +87,13 @@ mkdir -p "$E2E_TMP_HOME/.gbrain"
|
||||
# internals survive untouched. We keep GBRAIN_HOME (just set above for HOME
|
||||
# isolation); everything else GBRAIN_* is an operator override the suite must
|
||||
# not inherit — which also scrubs GBRAIN_REAL_HERMES_E2E and
|
||||
# GBRAIN_REAL_GROK_E2E, so the paid hermes/grok door suites structurally
|
||||
# GBRAIN_REAL_GROK_E2E / GBRAIN_REAL_OPENCODE_E2E, so the real-agent door
|
||||
# suites structurally
|
||||
# cannot fire under this runner (their venue is heavy-tests.yml's direct bun
|
||||
# test). GROK_ also drops an operator's GROK_BIN/GROK_HOME. Adapts GStack's
|
||||
# test). GROK_ also drops an operator's GROK_BIN/GROK_HOME; OPENCODE_ drops
|
||||
# OPENCODE_BIN and the OPENCODE_CONFIG* trio. Adapts GStack's
|
||||
# buildHermeticEnv() allowlist to gbrain's shell E2E runner.
|
||||
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|HERMES_|GROK_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
|
||||
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|HERMES_|GROK_|OPENCODE_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
|
||||
case "$_e2e_var" in
|
||||
GBRAIN_HOME) ;; # required for HOME isolation (set above) — keep
|
||||
GBRAIN_TEST_ALLOW_DATABASE_URL) ;; # #3485 preload opt-in (set above) — keep
|
||||
|
||||
@@ -69,6 +69,8 @@ CHECKS=(
|
||||
"check:batch-audit-site"
|
||||
"check:engine-dynamic-import"
|
||||
"check:grok-pin"
|
||||
"check:opencode-pin"
|
||||
"check:pin-doc-privacy"
|
||||
"check:worker-lock-renewal-shape"
|
||||
"check:bootstrap-tag"
|
||||
"check:bootstrap-templates"
|
||||
|
||||
+467
-40
@@ -27,8 +27,9 @@
|
||||
* B5 relay instruction), never a stack trace.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readdirSync } from 'node:fs';
|
||||
import { basename, isAbsolute, join, resolve } from 'node:path';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
import { VERSION } from '../version.ts';
|
||||
import { loadConfig, loadConfigFileOnly, toEngineConfig } from '../core/config.ts';
|
||||
@@ -77,7 +78,16 @@ import {
|
||||
statusHarness,
|
||||
type HarnessDeps,
|
||||
} from '../core/bootstrap/harness.ts';
|
||||
import { codexConfigPath } from '../core/bootstrap/host-specs.ts';
|
||||
import { codexConfigPath, opencodeConfigDir, opencodeGlobalConfigPath, opencodeProjectConfigPath } from '../core/bootstrap/host-specs.ts';
|
||||
import {
|
||||
opencodeEntryKind,
|
||||
opencodeEntrySnippet,
|
||||
opencodeRemoteEntryExists,
|
||||
parseOpencodeConfig,
|
||||
reconcileOpencodeSiblingGlobal,
|
||||
removeOpencodeMcpEntry,
|
||||
writeOpencodeMcpEntry,
|
||||
} from '../core/bootstrap/opencode-json.ts';
|
||||
import { promptLine } from '../core/cli-util.ts';
|
||||
import {
|
||||
appendInstallLog,
|
||||
@@ -88,7 +98,7 @@ import {
|
||||
} from '../core/bootstrap/status.ts';
|
||||
import { verifyWorkspace, deriveWorkspaceSourceId } from '../core/bootstrap/verify.ts';
|
||||
|
||||
export const BOOTSTRAP_HELP = `gbrain bootstrap — paste-in agent install (Claude Code / Codex)
|
||||
export const BOOTSTRAP_HELP = `gbrain bootstrap — paste-in agent install (Claude Code / Codex / opencode)
|
||||
|
||||
Usage: gbrain bootstrap <subcommand> [flags]
|
||||
|
||||
@@ -104,25 +114,29 @@ Subcommands (run \`gbrain bootstrap status\` first — it is the resume entrypoi
|
||||
render [--force] [--only F] [--minimal]
|
||||
Render identity files from the confirmed answers.
|
||||
Never clobbers; --force backs up first.
|
||||
hooks [--harness claude-code|codex] [--repair] [--no-hooks] [--gbrain-bin <path>]
|
||||
hooks [--harness claude-code|codex|opencode] [--repair] [--no-hooks] [--gbrain-bin <path>]
|
||||
Register MCP (+ per-turn hooks on Claude Code,
|
||||
ON by default; --no-hooks opts out, GBRAIN_HOOKS=0
|
||||
disables at runtime).
|
||||
disables at runtime). opencode registrations are
|
||||
written directly into its JSONC config (user-global
|
||||
by default; MCP_SCOPE=project is an explicit opt-in
|
||||
with a sharing warning).
|
||||
repo Create the dedicated PRIVATE GitHub repo (or adopt
|
||||
an EMPTY private repo you created under your own
|
||||
account), verify the privacy bit via the API, push.
|
||||
verify [--json] The whole install contract (round-trip, graph floor,
|
||||
magic moment, scans, hooks smoke). Exit 0 or not done.
|
||||
attach [--harness H] Machine two: adopt a cloned agent workspace.
|
||||
harness [--harness claude-code|codex|all] [--url U | --port N] [--source ID]
|
||||
harness [--harness claude-code|codex|opencode|all] [--url U | --port N] [--source ID]
|
||||
[--token-name NAME | --token TOK] [--name MCPNAME] [--project DIR]...
|
||||
[--no-hooks] [--no-capture] [--force] [--status] [--remove] [--yes] [--json]
|
||||
Wire framework-spawned Claude Code / Codex sessions to a
|
||||
RUNNING \`gbrain serve --http\` on this box (#4043): scoped
|
||||
bearer token, user-scope MCP + headless pre-approval,
|
||||
lifecycle hooks (user scope, or per --project dir), codex
|
||||
config block. No agent.json needed. Idempotent; --remove
|
||||
tears it down. (--local is an accepted no-op alias.)
|
||||
Wire framework-spawned Claude Code / Codex / opencode
|
||||
sessions to a RUNNING \`gbrain serve --http\` on this box
|
||||
(#4043): scoped bearer token, user-scope MCP + headless
|
||||
pre-approval, lifecycle hooks (user scope, or per --project
|
||||
dir), codex config block, opencode config entry. No
|
||||
agent.json needed. Idempotent; --remove tears it down.
|
||||
(--local is an accepted no-op alias.)
|
||||
cloud-setup-script Print the paste-ready cloud environment setup
|
||||
script (installs the gbrain binary into the
|
||||
environment snapshot; npm-based — bun fetching
|
||||
@@ -158,7 +172,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
||||
' Create the dedicated PRIVATE GitHub repo (or adopt an EMPTY private repo you created\n' +
|
||||
' under your own account), verify the privacy bit via the API, push.',
|
||||
hooks:
|
||||
'gbrain bootstrap hooks [--harness claude-code|codex] [--repair] [--no-hooks] [--gbrain-bin <path>]\n' +
|
||||
'gbrain bootstrap hooks [--harness claude-code|codex|opencode] [--repair] [--no-hooks] [--gbrain-bin <path>]\n' +
|
||||
' Register MCP (+ per-turn hooks on Claude Code, ON by default; --no-hooks opts out).',
|
||||
verify:
|
||||
'gbrain bootstrap verify [--json]\n' +
|
||||
@@ -244,12 +258,26 @@ function shellQuoteForDisplay(arg: string): string {
|
||||
|
||||
// ── Shared plumbing ─────────────────────────────────────────────────────────
|
||||
|
||||
type Harness = 'claude-code' | 'codex';
|
||||
type Harness = 'claude-code' | 'codex' | 'opencode';
|
||||
|
||||
/** Best-effort harness auto-detect; the --harness flag always wins. */
|
||||
/** Every workspace-lane harness — exhaustive-switch anchors key off this so
|
||||
* a future member is a COMPILE error at each dispatch site, not a silent
|
||||
* fall-through into another harness's branch (the union-widening trap: a
|
||||
* `harness === 'claude-code' ? A : B` ternary routes every new member down
|
||||
* B). */
|
||||
const HARNESSES = ['claude-code', 'codex', 'opencode'] as const satisfies readonly Harness[];
|
||||
|
||||
function isHarness(v: string | undefined): v is Harness {
|
||||
return (HARNESSES as readonly string[]).includes(v ?? '');
|
||||
}
|
||||
|
||||
/** Best-effort harness auto-detect; the --harness flag always wins.
|
||||
* opencode sets OPENCODE=1 (+OPENCODE_PID) in its bash-tool children —
|
||||
* verified against opencode 1.18.18 (OPENCODE-CLI-PIN.md §Environment). */
|
||||
export function detectHarness(env: Record<string, string | undefined> = process.env): Harness | null {
|
||||
if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return 'claude-code';
|
||||
if (env.CODEX_HOME || env.CODEX_SANDBOX || env.CODEX_CI) return 'codex';
|
||||
if (env.OPENCODE || env.OPENCODE_PID) return 'opencode';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -285,7 +313,16 @@ async function verifyMcpTargetsWorkspace(
|
||||
gbrainBin: string,
|
||||
sourceId: string,
|
||||
): Promise<'match' | 'mismatch' | 'unknown'> {
|
||||
const bin = harness === 'claude-code' ? 'claude' : 'codex';
|
||||
// Exec-lane harnesses only. opencode registrations go through the direct
|
||||
// JSONC writer whose 4-state fingerprint IS the [FIX7] check (structural,
|
||||
// no exec) — it never routes here; 'unknown' keeps a stray call honest.
|
||||
const EXEC_HARNESS_BIN = {
|
||||
'claude-code': 'claude',
|
||||
codex: 'codex',
|
||||
opencode: null,
|
||||
} as const satisfies Record<Harness, string | null>;
|
||||
const bin = EXEC_HARNESS_BIN[harness];
|
||||
if (bin === null) return 'unknown';
|
||||
let res;
|
||||
try {
|
||||
res = await runner([bin, 'mcp', 'get', name]);
|
||||
@@ -300,6 +337,121 @@ async function verifyMcpTargetsWorkspace(
|
||||
return hasBin && hasSource ? 'match' : 'mismatch';
|
||||
}
|
||||
|
||||
/** Wall-clock cap on the best-effort `opencode mcp list` probe: `mcp list`
|
||||
* SPAWNS every configured server, and a hung spawn must not hang the install
|
||||
* — on timeout the probe child is actually TERMINATED (SIGTERM, then SIGKILL
|
||||
* ~2s later) and the result degrades to the could-not-confirm branch (code
|
||||
* 124, repo-visibility's raced-runner convention). */
|
||||
const OPENCODE_PROBE_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Injectable probe-spawn seam (the door serial tests capture argv + cwd +
|
||||
* env and fake the child). The default holds the REAL process handle via
|
||||
* Bun.spawn — a Promise.race that merely abandons a hung `opencode mcp list`
|
||||
* leaves its spawned MCP servers running (including the just-registered
|
||||
* `gbrain serve`, which then squats the PGLite single-writer lock) and keeps
|
||||
* the CLI's event loop alive past flushThenExit. */
|
||||
export interface OpencodeProbeHandle {
|
||||
exited: Promise<number>;
|
||||
kill(force?: boolean): void;
|
||||
stdout: Promise<string>;
|
||||
stderr: Promise<string>;
|
||||
/** Detach the child + its pipes from the event loop (called when the probe
|
||||
* gives up on a hung child/grandchild so the CLI can still exit). */
|
||||
unref?: () => void;
|
||||
}
|
||||
export type OpencodeProbeSpawn = (
|
||||
argv: string[],
|
||||
opts: { cwd: string; env: Record<string, string | undefined> },
|
||||
) => OpencodeProbeHandle;
|
||||
|
||||
function defaultOpencodeProbeSpawn(
|
||||
argv: string[],
|
||||
opts: { cwd: string; env: Record<string, string | undefined> },
|
||||
): OpencodeProbeHandle {
|
||||
const proc = Bun.spawn(argv, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env as Record<string, string>,
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
return {
|
||||
exited: proc.exited,
|
||||
kill: (force?: boolean) => {
|
||||
try {
|
||||
proc.kill(force ? 9 : undefined);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
},
|
||||
stdout: new Response(proc.stdout).text().catch(() => ''),
|
||||
stderr: new Response(proc.stderr).text().catch(() => ''),
|
||||
unref: () => {
|
||||
try {
|
||||
proc.unref();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Run the opencode registration probe with OPENCODE_DISABLE_AUTOUPDATE=1 on
|
||||
* the spawn env (OPENCODE-CLI-PIN.md §Probes: the auto-updater must never
|
||||
* fire mid-probe) from an explicit `cwd` — callers pass a fresh EMPTY temp
|
||||
* dir, never the invoking cwd, because opencode merges a project
|
||||
* opencode.json from cwd and spawns its local servers with NO trust prompt
|
||||
* (a cloned malicious repo must not get code execution out of an install
|
||||
* probe). On timeout the child is killed (SIGTERM → SIGKILL) and the pipes
|
||||
* are drained BOUNDED (a spawned MCP-server grandchild can inherit the pipe
|
||||
* fds and hold them open past the direct child's death). Exported for the
|
||||
* timeout-kill unit test. */
|
||||
export async function runOpencodeProbe(
|
||||
argv: string[],
|
||||
opts: { cwd: string; spawn?: OpencodeProbeSpawn; timeoutMs?: number },
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
const spawnFn = opts.spawn ?? defaultOpencodeProbeSpawn;
|
||||
const timeoutMs = opts.timeoutMs ?? OPENCODE_PROBE_TIMEOUT_MS;
|
||||
const env: Record<string, string | undefined> = { ...process.env, OPENCODE_DISABLE_AUTOUPDATE: '1' };
|
||||
let handle: OpencodeProbeHandle;
|
||||
try {
|
||||
handle = spawnFn(argv, { cwd: opts.cwd, env });
|
||||
} catch (e) {
|
||||
// Bun.spawn throws synchronously when the binary is absent — map to the
|
||||
// shell's 127 convention so the caller's not-on-PATH branch fires.
|
||||
return { code: 127, stdout: '', stderr: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
// Bounded race helper that never leaves a live timer holding the loop.
|
||||
const raceMs = async <T>(p: Promise<T>, ms: number, fallback: T): Promise<T> => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([p, new Promise<T>((res) => { timer = setTimeout(() => res(fallback), ms); })]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
let code = await raceMs<number | null>(handle.exited, timeoutMs, null);
|
||||
const timedOut = code === null;
|
||||
if (code === null) {
|
||||
handle.kill(); // graceful first — opencode tears its servers down on TERM
|
||||
code = await raceMs<number | null>(handle.exited, 2_000, null);
|
||||
if (code === null) {
|
||||
handle.kill(true); // SIGKILL is not refusable; the wait below is paranoia-bounded
|
||||
code = await raceMs<number | null>(handle.exited, 2_000, null);
|
||||
}
|
||||
}
|
||||
const drainCap = timedOut ? 2_000 : 5_000;
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
raceMs(handle.stdout, drainCap, ''),
|
||||
raceMs(handle.stderr, drainCap, ''),
|
||||
]);
|
||||
if (timedOut || code === null) {
|
||||
handle.unref?.(); // a grandchild may still hold the pipes — never hold the CLI's exit
|
||||
return { code: 124, stdout, stderr: stderr || `timeout after ${timeoutMs}ms` };
|
||||
}
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
|
||||
async function withLock<T>(ws: string, fn: () => Promise<T>): Promise<T> {
|
||||
const handle = await acquireBootstrapLock(ws);
|
||||
try {
|
||||
@@ -768,11 +920,21 @@ async function runRepo(ws: string, rest: string[], home: string, runner: ExecRun
|
||||
});
|
||||
}
|
||||
|
||||
async function runHooks(ws: string, rest: string[], home: string, runner: ExecRunner): Promise<number> {
|
||||
const harnessFlag = flagValue(rest, '--harness') as Harness | undefined;
|
||||
const harness = harnessFlag ?? detectHarness();
|
||||
if (!harness || (harness !== 'claude-code' && harness !== 'codex')) {
|
||||
console.error('cannot auto-detect the harness — pass --harness claude-code or --harness codex');
|
||||
async function runHooks(
|
||||
ws: string,
|
||||
rest: string[],
|
||||
home: string,
|
||||
runner: ExecRunner,
|
||||
probeSpawn?: OpencodeProbeSpawn,
|
||||
): Promise<number> {
|
||||
const harnessFlag = flagValue(rest, '--harness');
|
||||
const harness = isHarness(harnessFlag) ? harnessFlag : harnessFlag ? null : detectHarness();
|
||||
if (!harness) {
|
||||
console.error(
|
||||
harnessFlag
|
||||
? `unknown --harness '${harnessFlag}' — pass --harness claude-code, codex, or opencode`
|
||||
: 'cannot auto-detect the harness — pass --harness claude-code, codex, or opencode',
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
// --repair is an idempotent-run alias: the same registration/write path as a
|
||||
@@ -804,18 +966,44 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
return 2;
|
||||
}
|
||||
|
||||
const mcpScope = ((consentAnswer(ws, 'MCP_SCOPE') ?? 'project').toLowerCase() === 'user' ? 'user' : 'project') as 'project' | 'user';
|
||||
// Raw (unbanked) MCP_SCOPE answer — several harness branches need to know
|
||||
// whether a human EXPLICITLY chose a scope vs the bank default filling in.
|
||||
// typeof guard: readInterviewState validates `answers` is an object but not
|
||||
// per-answer shapes — a hand-edited value of 3 must not throw.
|
||||
const rawScopeAnswer = (() => {
|
||||
const read = readInterviewState(ws);
|
||||
const raw = read.ok ? read.state.answers['MCP_SCOPE'] : undefined;
|
||||
// .trim(): a hand-edited or sloppily-recorded ' project' must not
|
||||
// silently resolve to the user-global default (scope answers are
|
||||
// security-relevant on opencode).
|
||||
return raw?.skipped !== true && typeof raw?.value === 'string' ? raw.value.trim().toLowerCase() : undefined;
|
||||
})();
|
||||
// Scope resolution is per-harness (exhaustive switch — see HARNESSES):
|
||||
// - claude-code: consent answer, bank default 'project' (the privacy-safe
|
||||
// default: any other repo you open cannot read the brain).
|
||||
// - codex: no scope flag exists; the value is ignored (note below).
|
||||
// - opencode: default 'user' — OPPOSITE of claude-code, because opencode
|
||||
// spawns project-config-defined servers with NO trust gate (verified,
|
||||
// OPENCODE-CLI-PIN.md §Probes): a committed project entry would auto-spawn
|
||||
// on every collaborator's machine. 'project' only via an EXPLICIT answer
|
||||
// (the sharing warning prints at write time).
|
||||
const mcpScope = ((): 'project' | 'user' => {
|
||||
switch (harness) {
|
||||
case 'claude-code':
|
||||
return (consentAnswer(ws, 'MCP_SCOPE') ?? 'project').toLowerCase() === 'user' ? 'user' : 'project';
|
||||
case 'codex':
|
||||
return 'project'; // ignored — codex registrations are user-global (no scope flag)
|
||||
case 'opencode':
|
||||
return rawScopeAnswer === 'project' ? 'project' : 'user';
|
||||
}
|
||||
})();
|
||||
// A persisted 'project' answer is meaningless on Codex (`codex mcp add` has no
|
||||
// scope flag) — reachable via attach from a Claude Code machine or a pre-fix
|
||||
// install. Fires on each hooks/repair run while the stale answer persists.
|
||||
// Raw read, NOT consentAnswer: the bank default is 'project', so the resolved
|
||||
// value would fire this note on every Codex install where no one was asked.
|
||||
if (harness === 'codex') {
|
||||
const read = readInterviewState(ws);
|
||||
const raw = read.ok ? read.state.answers['MCP_SCOPE'] : undefined;
|
||||
// typeof guard: readInterviewState validates `answers` is an object but not
|
||||
// per-answer shapes — a hand-edited value of 3 must not throw.
|
||||
if (raw?.skipped !== true && typeof raw?.value === 'string' && raw.value.toLowerCase() === 'project') {
|
||||
if (rawScopeAnswer === 'project') {
|
||||
console.error(
|
||||
"note: the recorded MCP_SCOPE answer 'project' has no effect on Codex — " +
|
||||
'`codex mcp add` has no scope flag; the registration is user-global (any repo ' +
|
||||
@@ -845,6 +1033,27 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
// Same ownership rule, opencode spelling: a REMOTE-type mcp.gbrain in the
|
||||
// user-global config is either the harness lane's (inline bearer) or
|
||||
// foreign — the stdio lane must not fight it in either case. BOTH global
|
||||
// filenames are checked: opencode merges opencode.json AND opencode.jsonc
|
||||
// when both exist, so a remote entry in EITHER file owns the name even
|
||||
// when the path resolver would pick the other for writing.
|
||||
if (
|
||||
harness === 'opencode' &&
|
||||
mcpScope === 'user' &&
|
||||
[join(opencodeConfigDir(), 'opencode.jsonc'), join(opencodeConfigDir(), 'opencode.json')].some((p) =>
|
||||
opencodeRemoteEntryExists(p, 'gbrain'),
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
"the 'gbrain' opencode MCP entry in the user-global config is a remote server (managed by " +
|
||||
'`gbrain bootstrap harness`, or foreign) — skipping the stdio registration. Run ' +
|
||||
'`gbrain bootstrap harness --remove` first (or remove the entry) if you want this ' +
|
||||
'workspace-lane stdio registration instead.',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return withLock(ws, async () => {
|
||||
// 0. source_id visibility seam: `hooks` is the last ENGINE-FREE phase
|
||||
@@ -889,6 +1098,157 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
// binary. The old early-return silently dropped hooks while the copy said
|
||||
// only "MCP registration skipped".
|
||||
let mcpSkipped = false;
|
||||
if (harness === 'opencode') {
|
||||
// Direct-writer lane (no exec): registrations land via the JSONC
|
||||
// writer whose 4-state fingerprint is the [FIX7] check. Scope resolves
|
||||
// to a FILE here — user → global config (absolute binary path),
|
||||
// project → committed-candidate opencode.json (PATH-resolved command;
|
||||
// no absolute machine paths in a file that travels, and no fail-open
|
||||
// analog exists — the sharing warning below is the mitigation).
|
||||
const configPath = mcpScope === 'project' ? opencodeProjectConfigPath(ws) : opencodeGlobalConfigPath();
|
||||
const command =
|
||||
mcpScope === 'project'
|
||||
? ['gbrain', 'serve', '--surface', 'full']
|
||||
: [gbrainBin, 'serve', '--surface', 'full'];
|
||||
const entry = {
|
||||
kind: 'local' as const,
|
||||
name: 'gbrain',
|
||||
command,
|
||||
environment: { GBRAIN_SOURCE: sourceId, ...(gbrainHome ? { GBRAIN_HOME: gbrainHome } : {}) },
|
||||
};
|
||||
try {
|
||||
// [X11] config-dir lock parity with the harness lane: the user-global
|
||||
// config is shared across workspaces AND homes, so gbrain writers
|
||||
// serialize on ITS directory. The project-scope file lives in the
|
||||
// workspace root, which withLock(ws) already holds — the lock is
|
||||
// non-reentrant, so the same-dir case skips the nested acquire.
|
||||
const ocCfgDir = dirname(configPath);
|
||||
let ocLock: { release(): void } | null = null;
|
||||
if (resolve(ocCfgDir) !== resolve(ws)) {
|
||||
mkdirSync(ocCfgDir, { recursive: true }); // the lock needs the dir; the writer mkdirs later anyway
|
||||
ocLock = await acquireBootstrapLock(ocCfgDir);
|
||||
}
|
||||
let w: ReturnType<typeof writeOpencodeMcpEntry>;
|
||||
try {
|
||||
// [FIX7] parity: an existing entry pointing at a DIFFERENT workspace
|
||||
// is warned about and replaced (same behavior as the exec lanes'
|
||||
// mismatch path); a FOREIGN entry refuses inside the writer. The
|
||||
// pre-check parse carries the same paste-by-hand snippet the writer
|
||||
// uses so a corrupt config never strands the user.
|
||||
const existingText = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
|
||||
const existingKind = opencodeEntryKind(
|
||||
parseOpencodeConfig(existingText, configPath, opencodeEntrySnippet(entry)),
|
||||
'gbrain',
|
||||
{ sourceId },
|
||||
);
|
||||
if (existingKind === 'ours-other-source') {
|
||||
console.error(`existing 'gbrain' opencode entry targets a DIFFERENT workspace — replacing it.`);
|
||||
}
|
||||
// Two-filename merge blind spot: opencode merges BOTH user-global
|
||||
// filenames, so a same-name gbrain entry in the SIBLING file would
|
||||
// survive this write as a shadow registration. Reconcile it under
|
||||
// the same config-dir lock (ours → removed with a note; foreign →
|
||||
// refuse loudly naming both files). User scope only — the project
|
||||
// file has no observed sibling semantics.
|
||||
if (mcpScope === 'user') {
|
||||
const sib = reconcileOpencodeSiblingGlobal(configPath, 'gbrain', { sourceId });
|
||||
for (const note of sib.notes) console.error(note);
|
||||
}
|
||||
w = writeOpencodeMcpEntry(configPath, entry, {
|
||||
expect: { sourceId },
|
||||
allowReplaceOtherSource: true,
|
||||
});
|
||||
} finally {
|
||||
ocLock?.release();
|
||||
}
|
||||
console.log(
|
||||
`MCP registered with opencode (scope: ${mcpScope === 'project' ? 'project (explicit opt-in)' : 'user-global'}) — ` +
|
||||
`wrote ${w.configPath}${w.replacedPrior ? ' (replaced prior gbrain entry)' : ''}; ` +
|
||||
'restart opencode (config is read at session start).',
|
||||
);
|
||||
for (const note of w.notes) console.error(note);
|
||||
if (mcpScope === 'project') {
|
||||
console.error(
|
||||
'SHARING WARNING: opencode spawns project-config-defined MCP servers with NO trust prompt — ' +
|
||||
'if this opencode.json is committed, every collaborator machine will spawn gbrain (teammates ' +
|
||||
'without gbrain see a failing spawn each session; teammates WITH gbrain attach THEIR host ' +
|
||||
'brain to this repo). The command is PATH-resolved ("gbrain" — requires gbrain on PATH); ' +
|
||||
'the teammate opt-out is `"enabled": false` on the entry. The user-global default avoids all of this.' +
|
||||
(gbrainHome
|
||||
? ` Also: the entry embeds this machine's GBRAIN_HOME path (${gbrainHome}) — it won't be portable to other machines.`
|
||||
: ''),
|
||||
);
|
||||
} else if (rawScopeAnswer === undefined) {
|
||||
console.log(
|
||||
"scope defaulted to user-global — opencode spawns project-defined servers with no trust gate, " +
|
||||
'so the committed-file scope is explicit-opt-in only (record MCP_SCOPE=project to choose it).',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error((e as Error).message);
|
||||
return 1;
|
||||
}
|
||||
// Registration smoke: the writer's post-render validation already
|
||||
// proved the config parses and carries exactly our entry (that is the
|
||||
// authoritative check). Best-effort live probe when the binary is on
|
||||
// PATH: `opencode mcp list` SPAWNS servers (the honest discriminator)
|
||||
// — run it with --pure (no external plugin autoload; `mcp list` is a
|
||||
// code-execution surface otherwise) and skip it entirely when a
|
||||
// plugin-bearing config is present (OPENCODE-CLI-PIN.md §Probes).
|
||||
try {
|
||||
const parsedCfg = parseOpencodeConfig(
|
||||
existsSync(configPath) ? readFileSync(configPath, 'utf8') : '',
|
||||
configPath,
|
||||
);
|
||||
if (mcpScope === 'project') {
|
||||
// SECURITY: opencode merges the project opencode.json from the
|
||||
// probe's cwd and spawns its local servers with NO trust prompt —
|
||||
// running `mcp list` inside this workspace would execute whatever
|
||||
// the (possibly just-cloned) repo's config names. Parse-back stays
|
||||
// the authoritative check; the human runs the live probe.
|
||||
console.log(
|
||||
'live `opencode mcp list` probe skipped for project scope — config parse-back is authoritative; ' +
|
||||
'run `opencode mcp list` yourself in this workspace to confirm.',
|
||||
);
|
||||
} else if (parsedCfg.plugin !== undefined) {
|
||||
console.log('live `opencode mcp list` probe skipped (plugin-bearing config) — config parse-back is the verification.');
|
||||
} else {
|
||||
// SECURITY: the probe spawns from a fresh EMPTY temp dir, never the
|
||||
// invoking cwd — no project opencode.json can load there (the same
|
||||
// no-trust-prompt spawn surface as the project-scope skip above).
|
||||
const probeCwd = mkdtempSync(join(tmpdir(), 'gbrain-opencode-probe-'));
|
||||
let probe: { code: number; stdout: string; stderr: string };
|
||||
try {
|
||||
probe = await runOpencodeProbe(['opencode', 'mcp', 'list', '--pure'], {
|
||||
cwd: probeCwd,
|
||||
...(probeSpawn ? { spawn: probeSpawn } : {}),
|
||||
});
|
||||
} finally {
|
||||
rmSync(probeCwd, { recursive: true, force: true });
|
||||
}
|
||||
// `mcp list` colorizes when a TTY-ish env leaks through — strip ANSI
|
||||
// escapes before matching, and anchor the name on whitespace/EOL so
|
||||
// a `gbrain-remote` entry can never satisfy a bare \bgbrain\b (\b
|
||||
// matches before the hyphen).
|
||||
const plain = probe.stdout.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
if (probe.code === 127) {
|
||||
console.log('`opencode` is not on PATH — registration written; the config activates when opencode next starts here.');
|
||||
} else if (probe.code === 0 && /✓\s+gbrain(\s|$)/.test(plain)) {
|
||||
console.log('`opencode mcp list` handshake: ✓ gbrain connected.');
|
||||
} else if (probe.code === 0 && /✗\s+gbrain(\s|$)/.test(plain)) {
|
||||
console.error(
|
||||
'WARNING: `opencode mcp list` reports ✗ gbrain failed — the spawn did not handshake ' +
|
||||
'(is the gbrain binary path valid on this machine?). The exit code of `mcp list` is 0 even ' +
|
||||
'on failure; this warning is from parsing its output.',
|
||||
);
|
||||
} else {
|
||||
console.log('MCP registration written; could not confirm via `opencode mcp list` (best-effort probe).');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* smoke is best-effort */
|
||||
}
|
||||
} else {
|
||||
const argvs =
|
||||
harness === 'claude-code'
|
||||
? registerClaudeMcp({ gbrainBin, scope: mcpScope, sourceId, ...(gbrainHome ? { gbrainHome } : {}) })
|
||||
@@ -997,6 +1357,7 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
} catch {
|
||||
/* smoke is best-effort */
|
||||
}
|
||||
} // end exec-lane registration (claude-code / codex)
|
||||
|
||||
// 3. Hooks (Claude Code only, consent-gated).
|
||||
let hooksWritten = false;
|
||||
@@ -1044,16 +1405,31 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
: 'hooks declined (HOOKS_CONSENT set to no) — the AGENTS.md pull protocol covers per-turn context instead; re-enable with `gbrain bootstrap hooks --harness claude-code`.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
} else if (harness === 'codex') {
|
||||
console.log('gbrain does not wire Codex hooks yet — per-turn context is the AGENTS.md pull protocol (stated plainly; the codex hook lane is a filed follow-up).');
|
||||
} else {
|
||||
console.log(
|
||||
'gbrain does not wire opencode\'s plugin/event system yet — per-turn context is the AGENTS.md ' +
|
||||
'pull protocol, which opencode loads natively (the opencode plugin lane is a filed follow-up).',
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Receipt registration record [CX2-12]. Detail records what actually
|
||||
// landed; nothing landed at all (127 + no hooks) → no receipt entry.
|
||||
if (!mcpSkipped || hooksWritten) {
|
||||
const receiptScope = ((): string => {
|
||||
switch (harness) {
|
||||
case 'claude-code':
|
||||
return mcpScope;
|
||||
case 'codex':
|
||||
return 'user'; // codex registrations are always user-global
|
||||
case 'opencode':
|
||||
return mcpScope; // user default; project only via explicit opt-in
|
||||
}
|
||||
})();
|
||||
appendReceiptRegistration(home, ws, {
|
||||
host: harness,
|
||||
scope: harness === 'claude-code' ? mcpScope : 'user',
|
||||
scope: receiptScope,
|
||||
detail: hooksWritten ? (mcpSkipped ? 'hooks' : 'mcp+hooks') : 'mcp',
|
||||
});
|
||||
}
|
||||
@@ -1252,15 +1628,63 @@ async function runUninstall(ws: string, rest: string[], home: string, runner: Ex
|
||||
|
||||
// Execute the structured host-registration removals the module returned.
|
||||
for (const reg of result.registration_removals) {
|
||||
if (reg.host === 'claude-code') {
|
||||
const r = removeClaudeHooks(ws);
|
||||
if (r.removed > 0) console.log(`removed ${r.removed} gbrain hook entr${r.removed === 1 ? 'y' : 'ies'} from ${r.settingsPath}`);
|
||||
for (const note of r.notes) console.error(note);
|
||||
const rm = await runner(['claude', 'mcp', 'remove', 'gbrain']);
|
||||
if (rm.code !== 0) console.error('note: `claude mcp remove gbrain` did not succeed — remove it by hand if it lingers.');
|
||||
} else {
|
||||
const rm = await runner(['codex', 'mcp', 'remove', 'gbrain']);
|
||||
if (rm.code !== 0) console.error('note: `codex mcp remove gbrain` did not succeed — remove it by hand if it lingers.');
|
||||
switch (reg.host) {
|
||||
case 'claude-code': {
|
||||
const r = removeClaudeHooks(ws);
|
||||
if (r.removed > 0) console.log(`removed ${r.removed} gbrain hook entr${r.removed === 1 ? 'y' : 'ies'} from ${r.settingsPath}`);
|
||||
for (const note of r.notes) console.error(note);
|
||||
const rm = await runner(['claude', 'mcp', 'remove', 'gbrain']);
|
||||
if (rm.code !== 0) console.error('note: `claude mcp remove gbrain` did not succeed — remove it by hand if it lingers.');
|
||||
break;
|
||||
}
|
||||
case 'codex': {
|
||||
const rm = await runner(['codex', 'mcp', 'remove', 'gbrain']);
|
||||
if (rm.code !== 0) console.error('note: `codex mcp remove gbrain` did not succeed — remove it by hand if it lingers.');
|
||||
break;
|
||||
}
|
||||
case 'opencode': {
|
||||
// Direct-writer removal (fingerprint-keyed; foreign entries refuse
|
||||
// inside the module). Every candidate file best-effort — the
|
||||
// receipt's scope names where the registration landed, but a stale
|
||||
// entry in another file costs nothing to sweep. BOTH global
|
||||
// filenames are swept: opencode merges opencode.json AND
|
||||
// opencode.jsonc when both exist, so sweeping only the resolver's
|
||||
// pick would strand a gbrain entry in the other file. The removal
|
||||
// is expectation-keyed on THIS workspace's source id — a gbrain
|
||||
// entry from a DIFFERENT workspace is skipped with a note, never
|
||||
// silently deleted (it is not this uninstall's to remove).
|
||||
const sweep = (p: string): void => {
|
||||
try {
|
||||
const r = removeOpencodeMcpEntry(p, 'gbrain', { sourceId: durabilitySourceId }, { skipOtherSource: true });
|
||||
if (r.removed) console.log(`removed the gbrain opencode MCP entry from ${p}`);
|
||||
for (const note of r.notes) console.error(note);
|
||||
} catch (e) {
|
||||
console.error(`note: could not remove the gbrain opencode entry from ${p}: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
// Global files run under the config-dir bootstrap lock (the writer
|
||||
// contract; harness.ts [X11] parity). Only when the dir exists — no
|
||||
// dir means no config, and uninstall must not create one just to
|
||||
// lock it.
|
||||
const ocDir = opencodeConfigDir();
|
||||
const globals = [join(ocDir, 'opencode.jsonc'), join(ocDir, 'opencode.json')].filter((p) => existsSync(p));
|
||||
if (globals.length > 0) {
|
||||
try {
|
||||
const ocLock = await acquireBootstrapLock(ocDir);
|
||||
try {
|
||||
for (const p of globals) sweep(p);
|
||||
} finally {
|
||||
ocLock.release();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`note: could not lock the opencode config dir (${(e as Error).message}) — entries left for a re-run.`);
|
||||
}
|
||||
}
|
||||
// The project file's dir IS the workspace, which withLock(ws)
|
||||
// already holds — the lock is non-reentrant, so no nested acquire.
|
||||
sweep(opencodeProjectConfigPath(ws));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1321,6 +1745,9 @@ async function runUninstall(ws: string, rest: string[], home: string, runner: Ex
|
||||
export interface RunBootstrapOpts {
|
||||
/** Exec seam for gh/claude/codex subprocesses (tests inject a recorder). */
|
||||
runner?: ExecRunner;
|
||||
/** Spawn seam for the opencode `mcp list` probe (tests capture argv, cwd,
|
||||
* and env; the default holds a real Bun.spawn handle so timeouts kill). */
|
||||
probeSpawn?: OpencodeProbeSpawn;
|
||||
}
|
||||
|
||||
/** Dispatch. Returns the process exit code (cli.ts passes it to setCliExitVerdict). */
|
||||
@@ -1386,7 +1813,7 @@ export async function runBootstrap(args: string[], opts: RunBootstrapOpts = {}):
|
||||
code = await runRepo(ws, rest, home, runner);
|
||||
break;
|
||||
case 'hooks':
|
||||
code = await runHooks(ws, rest, home, runner);
|
||||
code = await runHooks(ws, rest, home, runner, opts.probeSpawn);
|
||||
break;
|
||||
case 'verify':
|
||||
code = await runVerify(ws, rest, home);
|
||||
|
||||
@@ -29,12 +29,14 @@ import { resolveAgentRunner, listRegisteredAgents, registerAgentRunner, validate
|
||||
import { OpenClawRunner } from '../core/claw-test/runners/openclaw.ts';
|
||||
import { HermesRunner } from '../core/claw-test/runners/hermes.ts';
|
||||
import { GrokRunner } from '../core/claw-test/runners/grok.ts';
|
||||
import { OpencodeRunner } from '../core/claw-test/runners/opencode.ts';
|
||||
import { createTranscriptSink } from '../core/claw-test/transcript-capture.ts';
|
||||
|
||||
// Ensure built-in runners are registered.
|
||||
registerAgentRunner('openclaw', () => new OpenClawRunner());
|
||||
registerAgentRunner('hermes', () => new HermesRunner());
|
||||
registerAgentRunner('grok', () => new GrokRunner());
|
||||
registerAgentRunner('opencode', () => new OpencodeRunner());
|
||||
|
||||
interface HarnessOpts {
|
||||
scenario: string;
|
||||
@@ -417,6 +419,9 @@ const AGENT_INSTALL_HINTS: Record<string, string> = {
|
||||
// Official xAI CLI only — the community superagent-ai grok-cli ships a
|
||||
// colliding `grok` binary (docs/mcp/GROK-CLI-PIN.md).
|
||||
grok: 'install grok (npm: @xai-official/grok, or https://x.ai/cli/install.sh) or set GROK_BIN',
|
||||
// SST terminal agent — not OpenClaw, and not the renamed-to-Crush ancestor
|
||||
// that shares the binary name (docs/mcp/OPENCODE-CLI-PIN.md).
|
||||
opencode: 'install opencode (npm: opencode-ai, or https://opencode.ai/install) or set OPENCODE_BIN',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1003,6 +1008,7 @@ Examples:
|
||||
gbrain claw-test --scenario fresh-install
|
||||
gbrain claw-test --scenario upgrade-from-v0.18 --keep-tempdir
|
||||
gbrain claw-test --live --agent openclaw
|
||||
gbrain claw-test --live --agent opencode
|
||||
gbrain claw-test --live --agent hermes
|
||||
gbrain claw-test --live --agent grok`);
|
||||
}
|
||||
|
||||
+142
-9
@@ -9,7 +9,7 @@
|
||||
* needed for the connection.
|
||||
*
|
||||
* gbrain connect <mcp-url> [--token <bearer>] [--name gbrain]
|
||||
* [--agent claude-code|codex|perplexity|generic]
|
||||
* [--agent claude-code|codex|opencode|perplexity|generic]
|
||||
* [--oauth [--register | --client-id ID --client-secret SECRET] [--scopes "read write"]]
|
||||
* [--install] [--yes] [--json] [--show-token] [--force]
|
||||
* [--timeout-ms N]
|
||||
@@ -27,20 +27,34 @@
|
||||
* only; --install runs it).
|
||||
* - codex: `codex mcp add <name> --url <url> --bearer-token-env-var
|
||||
* GBRAIN_REMOTE_TOKEN` (bearer via env var; --install runs it).
|
||||
* - opencode: `opencode mcp add <name> --url <url> --header
|
||||
* "Authorization=Bearer {env:GBRAIN_REMOTE_TOKEN}"` (the interpolation is
|
||||
* stored literally; --install writes the entry directly via
|
||||
* opencode-json.ts — no binary needed).
|
||||
* - perplexity: GUI connector (Settings → Connectors). Supports bearer or
|
||||
* OAuth; no --install.
|
||||
* - generic: prints the connector fields for any other MCP client.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import type { ConnectProbeResult } from '../core/connect-probe.ts';
|
||||
import { probeBrainIdentity, DEFAULT_PROBE_TIMEOUT_MS } from '../core/connect-probe.ts';
|
||||
import { opencodeGlobalConfigPath } from '../core/bootstrap/host-specs.ts';
|
||||
import { acquireBootstrapLock } from '../core/bootstrap/lock.ts';
|
||||
import {
|
||||
GBRAIN_REMOTE_TOKEN_ENV,
|
||||
reconcileOpencodeSiblingGlobal,
|
||||
writeOpencodeMcpEntry,
|
||||
} from '../core/bootstrap/opencode-json.ts';
|
||||
import { promptLine } from '../core/cli-util.ts';
|
||||
import {
|
||||
NAME_RE,
|
||||
REDACTED,
|
||||
buildClaudeMcpAddArgv,
|
||||
buildCodexMcpAddArgv,
|
||||
buildOpencodeMcpAddArgv,
|
||||
cmdString,
|
||||
isValidName,
|
||||
issuerFromMcpUrl,
|
||||
@@ -58,6 +72,7 @@ export {
|
||||
REDACTED,
|
||||
buildClaudeMcpAddArgv,
|
||||
buildCodexMcpAddArgv,
|
||||
buildOpencodeMcpAddArgv,
|
||||
cmdString,
|
||||
isLinkLocalOrMetadata,
|
||||
issuerFromMcpUrl,
|
||||
@@ -69,7 +84,9 @@ export {
|
||||
type UrlResult,
|
||||
} from '../core/mcp-registration.ts';
|
||||
|
||||
export const ENV_VAR = 'GBRAIN_REMOTE_TOKEN';
|
||||
// Defined from the writer's exported constant so the printed interpolation and
|
||||
// the ownership fingerprint literal ({env:GBRAIN_REMOTE_TOKEN}) cannot drift.
|
||||
export const ENV_VAR = GBRAIN_REMOTE_TOKEN_ENV;
|
||||
export const PLACEHOLDER_TOKEN = '<paste-your-token>';
|
||||
export const PLACEHOLDER_SECRET = '<paste-your-client-secret>';
|
||||
export const DEFAULT_NAME = 'gbrain';
|
||||
@@ -77,12 +94,12 @@ export const DEFAULT_SCOPES = 'read write';
|
||||
// Single source of truth shared with the probe (was a duplicated 15_000 literal).
|
||||
const DEFAULT_TIMEOUT_MS = DEFAULT_PROBE_TIMEOUT_MS;
|
||||
|
||||
export type AgentId = 'claude-code' | 'codex' | 'perplexity' | 'generic';
|
||||
export type AgentId = 'claude-code' | 'codex' | 'opencode' | 'perplexity' | 'generic';
|
||||
|
||||
interface AgentSpec {
|
||||
id: AgentId;
|
||||
label: string; // human label for messages
|
||||
binary?: string; // CLI binary backing --install ('claude' | 'codex')
|
||||
binary?: string; // CLI binary backing --install ('claude' | 'codex'; opencode installs via the direct JSONC writer)
|
||||
installable: boolean;
|
||||
supportsOAuth: boolean; // accepts OAuth client-credentials connector fields
|
||||
}
|
||||
@@ -90,11 +107,14 @@ interface AgentSpec {
|
||||
export const AGENT_SPECS: Record<AgentId, AgentSpec> = {
|
||||
'claude-code': { id: 'claude-code', label: 'Claude Code', binary: 'claude', installable: true, supportsOAuth: false },
|
||||
codex: { id: 'codex', label: 'Codex', binary: 'codex', installable: true, supportsOAuth: false },
|
||||
// No `binary`: the opencode --install lane never execs a CLI (direct JSONC
|
||||
// write), and it branches before the exec lane's `spec.binary` read.
|
||||
opencode: { id: 'opencode', label: 'opencode', installable: true, supportsOAuth: false },
|
||||
perplexity: { id: 'perplexity', label: 'Perplexity Computer', installable: false, supportsOAuth: true },
|
||||
generic: { id: 'generic', label: 'your agent', installable: false, supportsOAuth: true },
|
||||
};
|
||||
|
||||
export const AGENT_IDS: AgentId[] = ['claude-code', 'codex', 'perplexity', 'generic'];
|
||||
export const AGENT_IDS: AgentId[] = ['claude-code', 'codex', 'opencode', 'perplexity', 'generic'];
|
||||
|
||||
// The named tools MUST be real MCP-exposed ops (verified by the round-trip
|
||||
// E2E). `capture` is intentionally absent: it's a CLI-only convenience wrapper,
|
||||
@@ -127,7 +147,7 @@ Usage:
|
||||
gbrain connect <mcp-url> [--token <bearer>] [flags]
|
||||
|
||||
Prints a copy-paste setup block for your agent, or wires it up directly with
|
||||
--install (claude-code + codex only). The MCP URL is your remote
|
||||
--install (claude-code, codex + opencode). The MCP URL is your remote
|
||||
'gbrain serve --http' endpoint; a bare host is rejected — pass an explicit
|
||||
https:// URL.
|
||||
|
||||
@@ -140,14 +160,15 @@ Auth:
|
||||
Flags:
|
||||
--token <bearer> Bearer token (else $${ENV_VAR}; from 'gbrain auth create')
|
||||
--name <id> MCP server name in the agent (default: ${DEFAULT_NAME})
|
||||
--agent <kind> claude-code (default) | codex | perplexity | generic
|
||||
--agent <kind> claude-code (default) | codex | opencode | perplexity | generic
|
||||
--oauth Use OAuth client credentials instead of a bearer token
|
||||
--register With --oauth: mint a client on the host (gbrain auth register-client)
|
||||
--client-id <id> With --oauth: use an existing OAuth client id
|
||||
--client-secret <s> With --oauth: use an existing OAuth client secret
|
||||
--scopes "<s>" With --oauth --register: client scopes (default: "${DEFAULT_SCOPES}")
|
||||
--install Run the agent's MCP-add command, then smoke-test the token
|
||||
(claude-code + codex only)
|
||||
(claude-code + codex + opencode; opencode installs via a direct
|
||||
config write — no binary needed, token stays out of the file)
|
||||
--yes Skip the install confirmation prompt
|
||||
--force On --install, replace an existing server of the same name
|
||||
--json Emit machine-readable JSON (secret redacted)
|
||||
@@ -158,6 +179,7 @@ Examples:
|
||||
gbrain connect https://brain.example.com/mcp --token gbrain_xxx
|
||||
gbrain connect https://brain.example.com:3131 --install --yes
|
||||
gbrain connect https://brain.example.com/mcp --token gbrain_xxx --agent codex
|
||||
gbrain connect https://brain.example.com/mcp --token gbrain_xxx --agent opencode --install
|
||||
gbrain connect https://brain.example.com/mcp --agent perplexity --oauth --register
|
||||
gbrain connect https://brain.example.com/mcp --agent perplexity --oauth \\
|
||||
--client-id gbrain_cl_xxx --client-secret gbrain_cs_xxx
|
||||
@@ -225,6 +247,31 @@ function codexBlock(p: { name: string; url: string; token: string | null }): str
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function opencodeBlock(p: { name: string; url: string; token: string | null }): string {
|
||||
const tokenValue = p.token ?? PLACEHOLDER_TOKEN;
|
||||
const cmd = cmdString('opencode', buildOpencodeMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR }));
|
||||
const lines = [
|
||||
'# Paste into opencode:',
|
||||
'',
|
||||
'Connect my knowledge brain, then learn what it can do:',
|
||||
'',
|
||||
` export ${ENV_VAR}=${shellQuote(tokenValue)}`,
|
||||
` ${cmd}`,
|
||||
'',
|
||||
];
|
||||
if (!p.token) lines.push(`Replace ${PLACEHOLDER_TOKEN} with a token from \`gbrain auth create "opencode"\` on the host.`, '');
|
||||
lines.push(
|
||||
`The config stores the literal \`{env:${ENV_VAR}}\` interpolation — opencode resolves it at read time, ` +
|
||||
`so keep that variable exported in your shell profile; the token never lands in the config file. ` +
|
||||
`Restart opencode after registering (config is read at session start).`,
|
||||
'',
|
||||
LEARN_INSTRUCTION,
|
||||
'',
|
||||
SECRET_NOTE,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function perplexityBearerBlock(p: { url: string; token: string | null }): string {
|
||||
const tokenValue = p.token ?? PLACEHOLDER_TOKEN;
|
||||
return [
|
||||
@@ -296,6 +343,7 @@ export function buildConnectBlock(p: { agent: AgentId; name: string; url: string
|
||||
switch (p.agent) {
|
||||
case 'claude-code': return claudeBlock(p);
|
||||
case 'codex': return codexBlock(p);
|
||||
case 'opencode': return opencodeBlock(p);
|
||||
case 'perplexity': return perplexityBearerBlock(p);
|
||||
case 'generic': return genericBearerBlock(p);
|
||||
}
|
||||
@@ -330,6 +378,10 @@ export function buildJson(p: { url: string; name: string; agent: AgentId; token:
|
||||
// Codex command carries no token (env-var name only), so it's safe verbatim.
|
||||
command_argv = buildCodexMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR });
|
||||
command = cmdString('codex', command_argv);
|
||||
} else if (p.agent === 'opencode') {
|
||||
// The literal {env:VAR} interpolation, not a token — safe verbatim.
|
||||
command_argv = buildOpencodeMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR });
|
||||
command = cmdString('opencode', command_argv);
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
@@ -363,6 +415,18 @@ export interface ConnectDeps {
|
||||
probe(url: string, token: string, timeoutMs: number): Promise<ConnectProbeResult>;
|
||||
env(name: string): string | undefined;
|
||||
registerOAuthClient(name: string, scopes: string): RegisterResult;
|
||||
/** opencode --install lane: direct JSONC write of a remote entry carrying
|
||||
* the literal `{env:GBRAIN_REMOTE_TOKEN}` interpolation (no binary execed,
|
||||
* no token on disk). Throws on a foreign same-name entry; an OURS entry at
|
||||
* a different url refuses unless `allowReplaceOtherSource` (connect maps
|
||||
* --force onto it). May be async: the default impl serializes on the
|
||||
* config-dir bootstrap lock (the writer contract); sync test fakes remain
|
||||
* assignable. */
|
||||
writeOpencodeRemoteEntry(
|
||||
name: string,
|
||||
url: string,
|
||||
opts?: { allowReplaceOtherSource?: boolean },
|
||||
): { configPath: string; replacedPrior: boolean } | Promise<{ configPath: string; replacedPrior: boolean }>;
|
||||
}
|
||||
|
||||
async function defaultPromptYesNo(question: string): Promise<boolean> {
|
||||
@@ -420,6 +484,31 @@ const defaultDeps: ConnectDeps = {
|
||||
probe: (url, token, timeoutMs) => probeBrainIdentity(url, token, { timeoutMs }),
|
||||
env: (name) => process.env[name],
|
||||
registerOAuthClient: defaultRegisterOAuthClient,
|
||||
writeOpencodeRemoteEntry: async (name, url, opts) => {
|
||||
// The writer's contract: callers hold acquireBootstrapLock on the config
|
||||
// dir (harness.ts [X11] parity) — the user-global file is shared across
|
||||
// workspaces and homes, so concurrent gbrain writers serialize here.
|
||||
const configPath = opencodeGlobalConfigPath();
|
||||
const cfgDir = dirname(configPath);
|
||||
mkdirSync(cfgDir, { recursive: true }); // the lock needs the dir; the writer mkdirs later anyway
|
||||
const lock = await acquireBootstrapLock(cfgDir);
|
||||
try {
|
||||
// Two-filename merge blind spot: opencode merges BOTH user-global
|
||||
// filenames, so a same-name gbrain entry in the SIBLING file would
|
||||
// survive this write as a shadow registration (ours → removed with a
|
||||
// note; foreign → refuse loudly naming both files).
|
||||
const sib = reconcileOpencodeSiblingGlobal(configPath, name, { url });
|
||||
for (const note of sib.notes) console.error(note);
|
||||
const r = writeOpencodeMcpEntry(
|
||||
configPath,
|
||||
{ kind: 'remote', name, url, tokenMode: 'env' },
|
||||
{ expect: { url }, ...(opts?.allowReplaceOtherSource ? { allowReplaceOtherSource: true } : {}) },
|
||||
);
|
||||
return { configPath: r.configPath, replacedPrior: r.replacedPrior };
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -595,8 +684,52 @@ export async function runConnect(args: string[], deps: ConnectDeps = defaultDeps
|
||||
// --install path. token is guaranteed literal here (install mode resolveToken).
|
||||
const realToken = token as string;
|
||||
if (!spec.installable) {
|
||||
fail(`--install supports claude-code and codex. ${spec.label} is set up through its own UI — drop --install to print the setup steps.`);
|
||||
fail(`--install supports claude-code, codex, and opencode. ${spec.label} is set up through its own UI — drop --install to print the setup steps.`);
|
||||
}
|
||||
|
||||
if (f.agent === 'opencode') {
|
||||
// Direct-writer lane: no opencode binary required (the JSONC write IS the
|
||||
// registration), and the config carries only the {env:VAR} interpolation
|
||||
// — the writer's fingerprint handles idempotent re-runs and refuses a
|
||||
// foreign same-name entry (--force cannot override THAT; pick --name).
|
||||
// --force maps to the writer's allowReplaceOtherSource so an OURS entry
|
||||
// at an old url (a rotated serve) is replaceable, mirroring the exec
|
||||
// lanes' documented --force semantics.
|
||||
if (!f.yes) {
|
||||
if (!deps.isTTY()) {
|
||||
fail('--install in a non-interactive shell requires --yes (refusing to register a credential-bearing MCP server without confirmation).');
|
||||
}
|
||||
const ok = await deps.promptYesNo(`Add MCP entry '${f.name}' -> ${url} to the opencode user-global config?`);
|
||||
if (!ok) fail('Aborted.');
|
||||
}
|
||||
let w: { configPath: string; replacedPrior: boolean };
|
||||
try {
|
||||
w = await deps.writeOpencodeRemoteEntry(f.name, url, { allowReplaceOtherSource: f.force });
|
||||
} catch (e) {
|
||||
fail(redactToken((e as Error).message, realToken));
|
||||
}
|
||||
console.error(
|
||||
`Added MCP entry '${f.name}' -> ${url} in ${w.configPath}` +
|
||||
`${w.replacedPrior ? ' (replaced the prior gbrain entry)' : ''}. Restart opencode (config is read at session start).`,
|
||||
);
|
||||
if (deps.env(ENV_VAR) !== realToken) {
|
||||
console.error(`opencode resolves {env:${ENV_VAR}} at read time. Add this to your shell profile so sessions can reach the brain:`);
|
||||
console.error(` export ${ENV_VAR}=<your-token>`);
|
||||
}
|
||||
const ocProbe = await deps.probe(url, realToken, f.timeoutMs);
|
||||
if (ocProbe.ok) {
|
||||
console.error(`Verified: ${ocProbe.identity || 'brain reachable'}`);
|
||||
console.error('');
|
||||
console.error(LEARN_INSTRUCTION);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`Warning: registered '${f.name}', but the smoke-test did not verify (${ocProbe.reason}): ${redactToken(ocProbe.message, realToken)}`,
|
||||
);
|
||||
console.error('The agent will likely hit 401/errors until the token or URL is fixed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const binary = spec.binary as string; // 'claude' | 'codex'
|
||||
if (!deps.hasBinary(binary)) {
|
||||
fail(`${spec.label} CLI ('${binary}') not found on PATH. Install ${spec.label}, or drop --install to print the command to run manually.`);
|
||||
|
||||
@@ -159,11 +159,11 @@ export interface HookIo {
|
||||
/** TEST SEAM: user-prompt deadline override (wall-clock flake control). */
|
||||
userPromptDeadlineMs?: number;
|
||||
/**
|
||||
* Feedback-loop attribution channel (`--harness <claude-code|codex>`).
|
||||
* Feedback-loop attribution channel (`--harness <claude-code|codex|opencode>`).
|
||||
* Default 'claude-code' — the only harness bootstrap registers hooks for
|
||||
* today; a codex hook registration passes the flag explicitly.
|
||||
* today; a codex/opencode hook registration passes the flag explicitly.
|
||||
*/
|
||||
harness?: 'claude-code' | 'codex';
|
||||
harness?: 'claude-code' | 'codex' | 'opencode';
|
||||
}
|
||||
|
||||
// ── Entry point ─────────────────────────────────────────────────────────────
|
||||
@@ -175,8 +175,8 @@ Events (wired into .claude/settings.local.json by gbrain bootstrap):
|
||||
push status, hook health) to stdout
|
||||
user-prompt read hook JSON on stdin, request per-turn context from a
|
||||
running 'gbrain serve' over IPC, print additionalContext JSON
|
||||
(--harness <claude-code|codex> sets the feedback-loop channel;
|
||||
default claude-code, unknown values fall back to the default)
|
||||
(--harness <claude-code|codex|opencode> sets the feedback-loop
|
||||
channel; default claude-code, unknown values fall back to the default)
|
||||
stop append to the per-session live buffer
|
||||
session-end ingest the session transcript into the dream corpus
|
||||
(secret-scanned), prune old corpus files, push the workspace
|
||||
@@ -195,13 +195,13 @@ export async function runHook(args: string[], io: HookIo = {}): Promise<number>
|
||||
write(io, USAGE + '\n');
|
||||
return 0;
|
||||
}
|
||||
// `--harness <claude-code|codex>` — feedback-loop channel attribution for
|
||||
// user-prompt. Unknown values fall back to the default (fail-open: a bad
|
||||
// registration must never break the hook contract).
|
||||
// `--harness <claude-code|codex|opencode>` — feedback-loop channel
|
||||
// attribution for user-prompt. Unknown values fall back to the default
|
||||
// (fail-open: a bad registration must never break the hook contract).
|
||||
const harnessIdx = args.indexOf('--harness');
|
||||
if (harnessIdx >= 0 && !io.harness) {
|
||||
const v = args[harnessIdx + 1];
|
||||
if (v === 'claude-code' || v === 'codex') io = { ...io, harness: v };
|
||||
if (v === 'claude-code' || v === 'codex' || v === 'opencode') io = { ...io, harness: v };
|
||||
}
|
||||
if (!event || !['session-start', 'user-prompt', 'stop', 'session-end', 'compact'].includes(event)) {
|
||||
process.stderr.write(USAGE + '\n');
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* atomic-write.ts — the ONE atomic config-file writer for bootstrap host
|
||||
* surfaces (rule-of-three extraction: hooks.ts settings JSON, codex-toml.ts
|
||||
* TOML text, opencode-json.ts JSONC text all swap through here).
|
||||
*
|
||||
* Semantics, hardened for shared user-scope targets [C10 / X11]:
|
||||
* - The SYMLINK TARGET is resolved first so a dotfile-manager-linked config
|
||||
* survives as a link (a bare rename would replace the link with a regular
|
||||
* file). DANGLING links are resolved too (readlink, hop by hop): the write
|
||||
* creates the missing target and the link survives.
|
||||
* - tmp file uses a random suffix and inherits the EXISTING file's mode; a
|
||||
* fresh file takes `freshMode` (caller's convention — secret-bearing
|
||||
* targets pass 0o600). `forceMode` overrides both (codex-toml forces 0600
|
||||
* because the file carries a bearer token regardless of its prior mode).
|
||||
* - chmod after write because writeFileSync's mode applies only on create.
|
||||
*
|
||||
* EOL and serialization stay caller-side: hooks.ts stringifies JSON,
|
||||
* codex-toml converts to CRLF when the original was CRLF, opencode-json
|
||||
* preserves EOLs naturally via jsonc-parser text splicing.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, isAbsolute, resolve } from 'node:path';
|
||||
|
||||
/** Resolve the write target through symlinks, INCLUDING dangling ones.
|
||||
* existsSync follows symlinks, so a DANGLING link reads "absent" and a bare
|
||||
* rename would replace the link itself with a regular file — instead the
|
||||
* link text is resolved hop by hop (relative to each link's dir, bounded
|
||||
* against loops) and the write lands at the final target, preserving the
|
||||
* link the same way the live-symlink realpath branch does. */
|
||||
function resolveWriteTarget(path: string): string {
|
||||
if (existsSync(path)) {
|
||||
// TOCTOU guard: the file can vanish between existsSync and realpathSync
|
||||
// (a concurrent unlink), which would throw a raw ENOENT out of a writer
|
||||
// that is perfectly able to proceed — fall through and treat the path as
|
||||
// fresh/dangling instead.
|
||||
try {
|
||||
return realpathSync(path); // live file / live symlink chain
|
||||
} catch {
|
||||
/* raced away — resolve below */
|
||||
}
|
||||
}
|
||||
let target = path;
|
||||
for (let hops = 0; hops < 40; hops++) {
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(target);
|
||||
} catch {
|
||||
return target; // truly absent — fresh-file target
|
||||
}
|
||||
if (!st.isSymbolicLink()) return target;
|
||||
const linkText = readlinkSync(target);
|
||||
target = isAbsolute(linkText) ? linkText : resolve(dirname(target), linkText);
|
||||
}
|
||||
return target; // pathological loop — bounded, last hop wins
|
||||
}
|
||||
|
||||
export function atomicWriteTextFile(
|
||||
path: string,
|
||||
text: string,
|
||||
opts?: { freshMode?: number; forceMode?: number },
|
||||
): void {
|
||||
const target = resolveWriteTarget(path);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
let mode: number | undefined;
|
||||
if (opts?.forceMode !== undefined) {
|
||||
mode = opts.forceMode;
|
||||
} else {
|
||||
try {
|
||||
mode = statSync(target).mode & 0o777;
|
||||
} catch {
|
||||
mode = opts?.freshMode;
|
||||
}
|
||||
}
|
||||
const tmp = `${target}.tmp-${randomBytes(6).toString('hex')}`;
|
||||
// Failure hygiene: a throwing write/chmod/rename (ENOSPC, EACCES, target
|
||||
// turned into a directory, …) must not leak the tmp file next to the
|
||||
// user's config — unlink it best-effort and rethrow the original error.
|
||||
try {
|
||||
writeFileSync(tmp, text, { encoding: 'utf8', ...(mode !== undefined ? { mode } : {}) });
|
||||
if (mode !== undefined) chmodSync(tmp, mode);
|
||||
renameSync(tmp, target);
|
||||
} catch (e) {
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch {
|
||||
/* best-effort — the original error is the one that matters */
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export interface AttachWorkspaceOptions {
|
||||
/** The gbrain home receiving the install receipt (default: configDir()). */
|
||||
gbrainHomeDir?: string;
|
||||
/** Target harness for the hooks/MCP steps' descriptions. */
|
||||
harness?: 'claude-code' | 'codex';
|
||||
harness?: 'claude-code' | 'codex' | 'opencode';
|
||||
/** Recorded as the receipt's created_by (the attaching binary's version). */
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
@@ -32,19 +32,8 @@
|
||||
* brick).
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { chmodSync, copyFileSync, existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { atomicWriteTextFile } from './atomic-write.ts';
|
||||
import { CODEX_TOML_BLOCK_BEGIN, CODEX_TOML_BLOCK_END } from './host-specs.ts';
|
||||
|
||||
export interface CodexHttpServerBlock {
|
||||
@@ -158,15 +147,11 @@ function renderBlock(block: CodexHttpServerBlock): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Atomic 0600 write preserving symlinks and the file's dominant EOL. */
|
||||
/** Atomic 0600 write preserving symlinks and the file's dominant EOL
|
||||
* (forceMode: the file carries a bearer token regardless of prior mode). */
|
||||
function atomicWriteToml(configPath: string, unixText: string, crlf: boolean): void {
|
||||
const target = existsSync(configPath) ? realpathSync(configPath) : configPath;
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
const tmp = `${target}.tmp-${randomBytes(6).toString('hex')}`;
|
||||
const out = crlf ? unixText.replace(/\n/g, '\r\n') : unixText;
|
||||
writeFileSync(tmp, out, { encoding: 'utf8', mode: 0o600 });
|
||||
chmodSync(tmp, 0o600);
|
||||
renameSync(tmp, target);
|
||||
atomicWriteTextFile(configPath, out, { forceMode: 0o600 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -130,7 +130,7 @@ export interface InstallReceipt {
|
||||
* corpus dir, …). Uninstall removes exactly these, nothing else. */
|
||||
created_paths: string[];
|
||||
/** Host registrations bootstrap performed (for marker-keyed removal). */
|
||||
registrations: Array<{ host: 'claude-code' | 'codex'; scope: string; detail?: string }>;
|
||||
registrations: Array<{ host: 'claude-code' | 'codex' | 'opencode'; scope: string; detail?: string }>;
|
||||
}
|
||||
|
||||
export function receiptPath(gbrainHomeDir: string): string {
|
||||
@@ -223,7 +223,7 @@ export type HarnessTargetKind = 'mcp' | 'permission' | 'hooks';
|
||||
export type HarnessTargetState = 'pending' | 'confirmed' | 'failed';
|
||||
|
||||
export interface HarnessTarget {
|
||||
host: 'claude-code' | 'codex';
|
||||
host: 'claude-code' | 'codex' | 'opencode';
|
||||
kind: HarnessTargetKind;
|
||||
state: HarnessTargetState;
|
||||
/** user scope or a --project dir (hooks); user for mcp/permission. */
|
||||
|
||||
+274
-19
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* harness.ts — `gbrain bootstrap harness` (#4043): default brain wiring for
|
||||
* agent-framework-driven coding (a downstream framework spawning Claude Code
|
||||
* `claude -p` / codex exec on a box that already hosts a brain + a running
|
||||
* `gbrain serve --http`).
|
||||
* `claude -p` / codex exec / opencode run on a box that already hosts a brain
|
||||
* + a running `gbrain serve --http`).
|
||||
*
|
||||
* What it wires, per harness:
|
||||
* - Claude Code: user-scope HTTP MCP registration (`claude mcp add --scope
|
||||
@@ -12,6 +12,10 @@
|
||||
* required anywhere.
|
||||
* - Codex: one managed `[mcp_servers.<name>]` TOML block with the inline
|
||||
* bearer token (codex-toml.ts — `codex mcp add` cannot express it).
|
||||
* - opencode: one managed `mcp.<name>` remote entry with the inline bearer
|
||||
* header in the user-global JSONC config (opencode-json.ts —
|
||||
* framework-spawned opencode inherits no shell env, so the `{env:…}`
|
||||
* interpolation the connect lane uses would resolve empty here).
|
||||
*
|
||||
* Contracts folded from the CEO review + outside voice (letters reference the
|
||||
* plan file):
|
||||
@@ -36,7 +40,7 @@
|
||||
* revoke defers with a typed message under a live PGLite serve.
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
import { VERSION } from '../../version.ts';
|
||||
@@ -66,6 +70,7 @@ import {
|
||||
type HarnessReceipt,
|
||||
type HarnessTarget,
|
||||
} from './format.ts';
|
||||
import { atomicWriteTextFile } from './atomic-write.ts';
|
||||
import {
|
||||
removeCodexHttpServerBlock,
|
||||
writeCodexHttpServerBlock,
|
||||
@@ -87,12 +92,22 @@ import {
|
||||
claudeUserSettingsPath,
|
||||
codexConfigPath,
|
||||
mcpPermissionEntry,
|
||||
opencodeConfigDir,
|
||||
opencodeGlobalConfigPath,
|
||||
type ClaudeHookEvent,
|
||||
} from './host-specs.ts';
|
||||
import {
|
||||
opencodeEntryKind,
|
||||
parseOpencodeConfig,
|
||||
parseOpencodeEntryBearer,
|
||||
reconcileOpencodeSiblingGlobal,
|
||||
removeOpencodeMcpEntry,
|
||||
writeOpencodeMcpEntry,
|
||||
} from './opencode-json.ts';
|
||||
|
||||
// ── Flags ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type HarnessSelector = 'claude-code' | 'codex' | 'all';
|
||||
export type HarnessSelector = 'claude-code' | 'codex' | 'opencode' | 'all';
|
||||
|
||||
export interface HarnessFlags {
|
||||
harness: HarnessSelector;
|
||||
@@ -143,8 +158,8 @@ export function parseHarnessArgs(rest: string[]): HarnessFlags {
|
||||
};
|
||||
const h = value('--harness');
|
||||
if (h !== undefined) {
|
||||
if (h !== 'claude-code' && h !== 'codex' && h !== 'all') {
|
||||
out.error = `unknown --harness '${h}' — pass claude-code, codex, or all`;
|
||||
if (h !== 'claude-code' && h !== 'codex' && h !== 'opencode' && h !== 'all') {
|
||||
out.error = `unknown --harness '${h}' — pass claude-code, codex, opencode, or all`;
|
||||
return out;
|
||||
}
|
||||
out.harness = h;
|
||||
@@ -230,6 +245,8 @@ export interface HarnessDeps {
|
||||
userSettingsPath?: string;
|
||||
/** Resolved codex config path (tests point at a temp CODEX_HOME). */
|
||||
codexConfig?: string;
|
||||
/** Resolved opencode config path (tests point at a temp XDG_CONFIG_HOME). */
|
||||
opencodeConfig?: string;
|
||||
/** Engine-backed mint; tests inject a fake. */
|
||||
mint?: (opts: {
|
||||
name: string;
|
||||
@@ -241,6 +258,7 @@ export interface HarnessDeps {
|
||||
pgliteLiveServe?: () => boolean;
|
||||
detectClaude?: () => boolean;
|
||||
detectCodex?: () => boolean;
|
||||
detectOpencode?: () => boolean;
|
||||
gbrainBin?: string | null;
|
||||
log?: (line: string) => void;
|
||||
logError?: (line: string) => void;
|
||||
@@ -256,6 +274,7 @@ function resolveDeps(deps: HarnessDeps): Required<Omit<HarnessDeps, 'gbrainBin'>
|
||||
probeIdentity: deps.probeIdentity ?? ((url, token) => probeBrainIdentity(url, token)),
|
||||
userSettingsPath: deps.userSettingsPath ?? claudeUserSettingsPath(),
|
||||
codexConfig: deps.codexConfig ?? codexConfigPath(),
|
||||
opencodeConfig: deps.opencodeConfig ?? opencodeGlobalConfigPath(),
|
||||
mint: deps.mint ?? defaultMint,
|
||||
revokeById: deps.revokeById ?? defaultRevokeById,
|
||||
pgliteLiveServe: deps.pgliteLiveServe ?? defaultPgliteLiveServe,
|
||||
@@ -263,6 +282,9 @@ function resolveDeps(deps: HarnessDeps): Required<Omit<HarnessDeps, 'gbrainBin'>
|
||||
detectCodex:
|
||||
deps.detectCodex ??
|
||||
(() => whichSafe('codex') !== null || existsSync(deps.codexConfig ?? codexConfigPath())),
|
||||
detectOpencode:
|
||||
deps.detectOpencode ??
|
||||
(() => whichSafe('opencode') !== null || existsSync(opencodeConfigDir())),
|
||||
gbrainBin: deps.gbrainBin !== undefined ? deps.gbrainBin : null,
|
||||
log: deps.log ?? ((l) => console.log(l)),
|
||||
logError: deps.logError ?? ((l) => console.error(l)),
|
||||
@@ -357,12 +379,14 @@ export function buildConsentBlock(p: {
|
||||
url: string;
|
||||
wireClaude: boolean;
|
||||
wireCodex: boolean;
|
||||
wireOpencode: boolean;
|
||||
hooks: boolean;
|
||||
capture: boolean;
|
||||
hookScope: string;
|
||||
name: string;
|
||||
userSettingsPath: string;
|
||||
codexConfig: string;
|
||||
opencodeConfig: string;
|
||||
}): string {
|
||||
const lines: string[] = [
|
||||
'gbrain bootstrap harness — wire framework-spawned coding sessions to this brain',
|
||||
@@ -402,14 +426,23 @@ export function buildConsentBlock(p: {
|
||||
`${p.codexConfig} (0600) — framework-spawned codex inherits no shell env, so an env-var token would not reach it.`,
|
||||
);
|
||||
}
|
||||
if (p.wireOpencode) {
|
||||
lines.push(
|
||||
` ${n++}. opencode (user-global): write the mcp.${p.name} remote entry with the bearer token INLINE into ` +
|
||||
`${p.opencodeConfig} (0600) — framework-spawned opencode inherits no shell env, so the {env:…} ` +
|
||||
`interpolation would resolve empty.`,
|
||||
);
|
||||
}
|
||||
// [X7] The reach statement matches what is ACTUALLY being wired — it must
|
||||
// never claim a host or a hook lane this invocation does not touch.
|
||||
const hosts =
|
||||
p.wireClaude && p.wireCodex
|
||||
? 'EVERY Claude Code and Codex session'
|
||||
: p.wireClaude
|
||||
? 'EVERY Claude Code session'
|
||||
: 'EVERY Codex session';
|
||||
// never claim a host or a hook lane this invocation does not touch. Joined
|
||||
// list, not a ternary tree: a fourth harness must be a compile-time nudge
|
||||
// here, not a silent mislabel.
|
||||
const hostNames = [
|
||||
...(p.wireClaude ? ['Claude Code'] : []),
|
||||
...(p.wireCodex ? ['Codex'] : []),
|
||||
...(p.wireOpencode ? ['opencode'] : []),
|
||||
];
|
||||
const hosts = `EVERY ${hostNames.join(' and ')} session`;
|
||||
const hookLine = !p.wireClaude || !p.hooks
|
||||
? 'No hooks are wired by this invocation.'
|
||||
: p.capture
|
||||
@@ -421,6 +454,7 @@ export function buildConsentBlock(p: {
|
||||
'`gbrain auth revoke --id <id>` (see auth list)',
|
||||
...(p.wireClaude ? [`\`claude mcp remove ${p.name} --scope user\``] : []),
|
||||
...(p.wireCodex ? ['edit the codex config'] : []),
|
||||
...(p.wireOpencode ? ['edit the opencode config'] : []),
|
||||
];
|
||||
lines.push(
|
||||
'',
|
||||
@@ -498,8 +532,42 @@ async function cleanupStalePriorTargets(
|
||||
);
|
||||
}
|
||||
} else if (pt.host === 'codex' && pt.kind === 'mcp') {
|
||||
const r = removeCodexHttpServerBlock(pt.path ?? d.codexConfig, pt.name ?? 'gbrain');
|
||||
if (r.removed) d.log(`stale codex managed block removed from ${pt.path ?? d.codexConfig} (no longer planned).`);
|
||||
// [X11] The caller holds only the claude config-dir lock; the codex
|
||||
// config is a DIFFERENT shared file, and its read-modify-write must
|
||||
// serialize on ITS directory's bootstrap lock too — a concurrent
|
||||
// codex-dir-locked writer interleaving here would have one rename
|
||||
// discard the other. Ordering matches apply/remove (claude/config
|
||||
// dir first, then codex dir), so no lock-order inversion; the lock
|
||||
// is non-reentrant, so the same-dir case skips the nested acquire.
|
||||
const codexPath = pt.path ?? d.codexConfig;
|
||||
const codexDir = dirname(codexPath);
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
const heldDir = resolve(dirname(d.userSettingsPath));
|
||||
const lk = resolve(codexDir) === heldDir ? null : await acquireBootstrapLock(codexDir);
|
||||
let r: ReturnType<typeof removeCodexHttpServerBlock>;
|
||||
try {
|
||||
r = removeCodexHttpServerBlock(codexPath, pt.name ?? 'gbrain');
|
||||
} finally {
|
||||
lk?.release();
|
||||
}
|
||||
if (r.removed) d.log(`stale codex managed block removed from ${codexPath} (no longer planned).`);
|
||||
} else if (pt.host === 'opencode' && pt.kind === 'mcp') {
|
||||
// Fingerprint-keyed against the PRIOR receipt's url — a foreign or
|
||||
// rotated-away entry refuses inside the module (never guess). Same
|
||||
// [X11] nested-lock discipline as the codex branch above (claude/
|
||||
// config dir held by the caller, then the opencode dir here).
|
||||
const ocPath = pt.path ?? d.opencodeConfig;
|
||||
const ocDir = dirname(ocPath);
|
||||
mkdirSync(ocDir, { recursive: true });
|
||||
const heldDir = resolve(dirname(d.userSettingsPath));
|
||||
const lk = resolve(ocDir) === heldDir ? null : await acquireBootstrapLock(ocDir);
|
||||
let r: ReturnType<typeof removeOpencodeMcpEntry>;
|
||||
try {
|
||||
r = removeOpencodeMcpEntry(ocPath, pt.name ?? 'gbrain', { url: prior.url });
|
||||
} finally {
|
||||
lk?.release();
|
||||
}
|
||||
if (r.removed) d.log(`stale opencode entry removed from ${ocPath} (no longer planned).`);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -539,14 +607,17 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
|
||||
// explicit (we exec `claude mcp add`; it owns ~/.claude.json).
|
||||
const wireClaude = (flags.harness === 'all' || flags.harness === 'claude-code') && d.detectClaude();
|
||||
const wireCodex = flags.harness === 'codex' || (flags.harness === 'all' && d.detectCodex());
|
||||
// opencode mirrors codex: an explicit --harness opencode FORCES wiring (the
|
||||
// JSONC writer needs no opencode CLI and creates the config file itself).
|
||||
const wireOpencode = flags.harness === 'opencode' || (flags.harness === 'all' && d.detectOpencode());
|
||||
if (flags.harness === 'claude-code' && !wireClaude) {
|
||||
d.logError('claude CLI not found on PATH — the user-scope MCP registration needs it (it owns ~/.claude.json).');
|
||||
return 2;
|
||||
}
|
||||
if (!wireClaude && !wireCodex) {
|
||||
if (!wireClaude && !wireCodex && !wireOpencode) {
|
||||
d.logError(
|
||||
'no harness detected on this box (claude CLI not on PATH; no codex install) — ' +
|
||||
'pass --harness claude-code|codex explicitly if detection is wrong.',
|
||||
'no harness detected on this box (claude CLI not on PATH; no codex install; no opencode install) — ' +
|
||||
'pass --harness claude-code|codex|opencode explicitly if detection is wrong.',
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
@@ -597,12 +668,14 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
|
||||
url,
|
||||
wireClaude,
|
||||
wireCodex,
|
||||
wireOpencode,
|
||||
hooks: wireHooks,
|
||||
capture: !flags.noCapture,
|
||||
hookScope,
|
||||
name: flags.name,
|
||||
userSettingsPath: d.userSettingsPath,
|
||||
codexConfig: d.codexConfig,
|
||||
opencodeConfig: d.opencodeConfig,
|
||||
});
|
||||
d.log(consent);
|
||||
if (!flags.yes) {
|
||||
@@ -719,6 +792,17 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
|
||||
mechanism: 'toml-block',
|
||||
});
|
||||
}
|
||||
if (wireOpencode) {
|
||||
targets.push({
|
||||
host: 'opencode',
|
||||
kind: 'mcp',
|
||||
state: 'pending',
|
||||
scope: 'user',
|
||||
path: d.opencodeConfig,
|
||||
name: flags.name,
|
||||
mechanism: 'jsonc-entry',
|
||||
});
|
||||
}
|
||||
// [X4] EVERY unrevoked prior minted id is carried — on the --token lane
|
||||
// too. A failed rotation must never forget the token before last.
|
||||
const carriedPreviousIds = [
|
||||
@@ -805,6 +889,15 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
|
||||
let oldClaudeReg: { url: string; token: string } | null = null;
|
||||
let claudeReplaced = false;
|
||||
let codexRollback: { path: string; backupPath: string | null; replacedPrior: boolean } | null = null;
|
||||
let opencodeRollback: {
|
||||
path: string;
|
||||
backupPath: string | null;
|
||||
replacedPrior: boolean;
|
||||
/** Exact text this run's writer landed — the rollback compares the LIVE
|
||||
* file against it before restoring (the lock is released before the
|
||||
* smoke, so a newer registration may have landed since). */
|
||||
writtenText: string;
|
||||
} | null = null;
|
||||
let cfgLock: Awaited<ReturnType<typeof acquireBootstrapLock>> | null = null;
|
||||
if (wireClaude) {
|
||||
const cfgDir = dirname(d.userSettingsPath);
|
||||
@@ -957,6 +1050,68 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
|
||||
}
|
||||
}
|
||||
|
||||
// 7b. opencode wiring — the managed JSONC entry is the single write
|
||||
// mechanism (opencode-json.ts; fingerprint-owned, comment-preserving).
|
||||
// Same [X11] lock discipline; same forced-wire posture as codex.
|
||||
for (const t of targets) {
|
||||
if (t.host !== 'opencode') continue;
|
||||
try {
|
||||
const ocDir = dirname(t.path!);
|
||||
mkdirSync(ocDir, { recursive: true });
|
||||
const ocLock = await acquireBootstrapLock(ocDir);
|
||||
let r: ReturnType<typeof writeOpencodeMcpEntry>;
|
||||
try {
|
||||
// Ownership [C8]: an entry at OUR new url (idempotent re-run) or at
|
||||
// the PRIOR receipt's url (rotation across a port change) is ours;
|
||||
// anything else under the name refuses inside the writer. The prior
|
||||
// url is offered as the expectation only when the current one does
|
||||
// not classify the entry as ours.
|
||||
let expectUrl = url;
|
||||
if (prior && prior.url !== url && existsSync(t.path!)) {
|
||||
try {
|
||||
const parsedExisting = parseOpencodeConfig(readFileSync(t.path!, 'utf8'), t.path!);
|
||||
if (
|
||||
opencodeEntryKind(parsedExisting, flags.name, { url }) === 'foreign' &&
|
||||
opencodeEntryKind(parsedExisting, flags.name, { url: prior.url }) === 'ours-same-source'
|
||||
) {
|
||||
expectUrl = prior.url;
|
||||
}
|
||||
} catch {
|
||||
/* the writer's own read path raises the real error below */
|
||||
}
|
||||
}
|
||||
// Two-filename merge blind spot: opencode merges BOTH user-global
|
||||
// filenames, so a same-name gbrain entry in the SIBLING file would
|
||||
// survive this write as a shadow registration. Reconcile under the
|
||||
// same config-dir lock (ours → removed with a note; foreign →
|
||||
// refuse loudly naming both files).
|
||||
const sib = reconcileOpencodeSiblingGlobal(t.path!, flags.name, { url: expectUrl });
|
||||
for (const note of sib.notes) d.log(note);
|
||||
r = writeOpencodeMcpEntry(
|
||||
t.path!,
|
||||
{ kind: 'remote', name: flags.name, url, tokenMode: 'inline', bearerToken: token },
|
||||
{ expect: { url: expectUrl }, allowReplaceOtherSource: true },
|
||||
);
|
||||
} finally {
|
||||
ocLock.release();
|
||||
}
|
||||
for (const note of r.notes) d.logError(note);
|
||||
opencodeRollback = { path: t.path!, backupPath: r.backupPath, replacedPrior: r.replacedPrior, writtenText: r.writtenText };
|
||||
confirm(t);
|
||||
d.log(
|
||||
`opencode wired: mcp.${flags.name} remote entry with inline bearer header in ${t.path} (0600). ` +
|
||||
'opencode ships a plugin/event system, but gbrain does not wire it yet — per-turn context on ' +
|
||||
'opencode is MCP tools + the pull protocol (AGENTS.md loads natively). Restart opencode: it ' +
|
||||
'reads config at session start.',
|
||||
);
|
||||
} catch (e) {
|
||||
// Redaction parity with the claude lane: the writer's refusal messages
|
||||
// can embed a paste-by-hand snippet, and the receipt + stderr must
|
||||
// never carry the live bearer under any error shape.
|
||||
failTarget(t, redactToken(e instanceof Error ? e.message : String(e), token));
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Smoke [C3-enriched message; X10 verbs-surface honesty]. An
|
||||
// unknown-tool tool_error means initialize + auth ALREADY succeeded — a
|
||||
// serve running a narrowed --surface (e.g. verbs) is verified, not broken.
|
||||
@@ -1010,7 +1165,9 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
|
||||
const rbLock = await acquireBootstrapLock(dirname(codexRollback.path)); // [X11] parity
|
||||
try {
|
||||
if (codexRollback.backupPath && existsSync(codexRollback.backupPath)) {
|
||||
copyFileSync(codexRollback.backupPath, codexRollback.path);
|
||||
// Atomic restore: a crash mid-copy must never leave a torn config
|
||||
// (the backup carries the previous bearer — 0600 stays forced).
|
||||
atomicWriteTextFile(codexRollback.path, readFileSync(codexRollback.backupPath, 'utf8'), { forceMode: 0o600 });
|
||||
} else if (!codexRollback.replacedPrior) {
|
||||
removeCodexHttpServerBlock(codexRollback.path, flags.name);
|
||||
}
|
||||
@@ -1023,6 +1180,42 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
|
||||
d.logError(`codex rollback failed: ${e instanceof Error ? e.message : String(e)} — re-run to converge.`);
|
||||
}
|
||||
}
|
||||
if (opencodeRollback) {
|
||||
try {
|
||||
let failNote = 'rolled back to the previous opencode config after the failed smoke';
|
||||
const rbLock = await acquireBootstrapLock(dirname(opencodeRollback.path)); // [X11] parity
|
||||
try {
|
||||
// Restore-guard: the config-dir lock was released before the smoke,
|
||||
// so a NEWER registration (another run's) may have replaced ours —
|
||||
// restoring this run's snapshot over it would clobber that newer
|
||||
// wiring. Only restore when the live file still carries the EXACT
|
||||
// text this run wrote; either way the fresh mint is revoked below.
|
||||
const current = existsSync(opencodeRollback.path)
|
||||
? readFileSync(opencodeRollback.path, 'utf8')
|
||||
: '';
|
||||
if (current !== opencodeRollback.writtenText) {
|
||||
failNote =
|
||||
'smoke failed; opencode rollback SKIPPED — the config changed after this run wrote it ' +
|
||||
'(a newer registration exists); this run\'s fresh mint is still revoked';
|
||||
d.log(failNote + '.');
|
||||
} else if (opencodeRollback.backupPath && existsSync(opencodeRollback.backupPath)) {
|
||||
// Atomic restore (codex-lane parity): never a torn config mid-crash.
|
||||
atomicWriteTextFile(opencodeRollback.path, readFileSync(opencodeRollback.backupPath, 'utf8'), { forceMode: 0o600 });
|
||||
// Consumed — the unique backup carries the previous bearer and
|
||||
// has no consumer once restored.
|
||||
try { rmSync(opencodeRollback.backupPath, { force: true }); } catch { /* best-effort */ }
|
||||
} else if (!opencodeRollback.replacedPrior) {
|
||||
removeOpencodeMcpEntry(opencodeRollback.path, flags.name, { url });
|
||||
}
|
||||
} finally {
|
||||
rbLock.release();
|
||||
}
|
||||
const ot = targets.find((t) => t.host === 'opencode' && t.kind === 'mcp');
|
||||
if (ot) failTarget(ot, failNote);
|
||||
} catch (e) {
|
||||
d.logError(`opencode rollback failed: ${e instanceof Error ? e.message : String(e)} — re-run to converge.`);
|
||||
}
|
||||
}
|
||||
const mt = targets.find((t) => t.host === 'claude-code' && t.kind === 'mcp');
|
||||
if (claudeReplaced && oldClaudeReg) {
|
||||
await d.runner(['claude', 'mcp', 'remove', flags.name, '--scope', 'user']);
|
||||
@@ -1080,6 +1273,18 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
|
||||
}
|
||||
}
|
||||
|
||||
// The unique opencode backup carries the PREVIOUS bearer; once the new
|
||||
// wiring is verified it has no consumer — unlink it so re-runs never
|
||||
// accumulate token-bearing snapshots (failed runs consume it via the
|
||||
// restore above; skipped restores leave it 0600 for manual recovery).
|
||||
if (smokeOk && opencodeRollback?.backupPath) {
|
||||
try {
|
||||
rmSync(opencodeRollback.backupPath, { force: true });
|
||||
} catch {
|
||||
/* best-effort — it is 0600 either way */
|
||||
}
|
||||
}
|
||||
|
||||
// 9. [X3] Convergence cleanup — AFTER the smoke, so prior working wiring is
|
||||
// never unwired on a run that failed to establish its replacement. Cleanup
|
||||
// failures append as failed targets (blocking the rotation gate below) so
|
||||
@@ -1286,6 +1491,47 @@ export async function removeHarness(flags: HarnessFlags, rawDeps: HarnessDeps):
|
||||
? `Codex managed block removed from ${codexPath}.`
|
||||
: `no managed block in ${codexPath} — counted as removed.`,
|
||||
);
|
||||
} else if (t.host === 'opencode') {
|
||||
const ocPath = t.path ?? d.opencodeConfig;
|
||||
// [C8] Ownership before removal: an entry now at a DIFFERENT url is
|
||||
// another install's — skip with a note, cleared from the receipt
|
||||
// (mirror of the claude not-ours branch; the module's foreign check
|
||||
// would THROW, which reads as a failure rather than a skip).
|
||||
let kind: ReturnType<typeof opencodeEntryKind> = 'absent';
|
||||
if (existsSync(ocPath)) {
|
||||
try {
|
||||
kind = opencodeEntryKind(
|
||||
parseOpencodeConfig(readFileSync(ocPath, 'utf8'), ocPath),
|
||||
t.name ?? 'gbrain',
|
||||
{ url: receipt.url },
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`opencode config unreadable: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
if (kind === 'absent') {
|
||||
d.log(`opencode entry '${t.name}' already gone — counted as removed.`); // [F2]
|
||||
} else if (kind !== 'ours-same-source') {
|
||||
d.log(
|
||||
`opencode entry '${t.name}' does not match this receipt's url (${receipt.url}) — owned by another ` +
|
||||
'install; skipping, cleared from the receipt.',
|
||||
);
|
||||
} else {
|
||||
const ocDir = dirname(ocPath);
|
||||
mkdirSync(ocDir, { recursive: true });
|
||||
const ocLock = resolve(ocDir) === resolve(rmCfgDir) ? null : await acquireBootstrapLock(ocDir);
|
||||
let r: ReturnType<typeof removeOpencodeMcpEntry>;
|
||||
try {
|
||||
r = removeOpencodeMcpEntry(ocPath, t.name ?? 'gbrain', { url: receipt.url });
|
||||
} finally {
|
||||
ocLock?.release();
|
||||
}
|
||||
d.log(
|
||||
r.removed
|
||||
? `opencode managed entry removed from ${ocPath}.`
|
||||
: `no managed entry in ${ocPath} — counted as removed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
anyFailed = true;
|
||||
@@ -1443,6 +1689,15 @@ export async function statusHarness(flags: HarnessFlags, rawDeps: HarnessDeps):
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!token) {
|
||||
const ocMcp = receipt.targets.find((t) => t.host === 'opencode' && t.kind === 'mcp');
|
||||
if (ocMcp?.path) {
|
||||
// [C8] url-matched inside the helper: a foreign/rotated entry's bearer
|
||||
// is never recovered (it was not issued for receipt.url).
|
||||
token = parseOpencodeEntryBearer(ocMcp.path, ocMcp.name ?? 'gbrain', receipt.url);
|
||||
if (token) tokenSource = 'opencode config entry';
|
||||
}
|
||||
}
|
||||
|
||||
let tokenLine: string;
|
||||
let tokenVerified: boolean | 'unavailable' = 'unavailable';
|
||||
|
||||
@@ -24,19 +24,9 @@
|
||||
* (and GBRAIN_HOME when isolated) ride the registration itself.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, isAbsolute, join } from 'node:path';
|
||||
import { copyFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { atomicWriteTextFile } from './atomic-write.ts';
|
||||
import {
|
||||
CLAUDE_COMMITTED_SETTINGS_FILE_RELPATH,
|
||||
CLAUDE_HOOK_DEFAULT_TIMEOUT_SECS,
|
||||
@@ -297,18 +287,9 @@ function stripOurEntries(groups: unknown[], marker: string = GBRAIN_HOOK_MARKER_
|
||||
* but not for user-global config).
|
||||
*/
|
||||
function atomicWriteJson(path: string, value: unknown, freshMode?: number): void {
|
||||
const target = existsSync(path) ? realpathSync(path) : path;
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
let mode: number | undefined;
|
||||
try {
|
||||
mode = statSync(target).mode & 0o777;
|
||||
} catch {
|
||||
mode = freshMode; // fresh file: caller's convention (user-scope → 0600) [X11]
|
||||
}
|
||||
const tmp = `${target}.tmp-${randomBytes(6).toString('hex')}`;
|
||||
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', ...(mode !== undefined ? { mode } : {}) });
|
||||
if (mode !== undefined) chmodSync(tmp, mode); // writeFileSync mode applies only on create
|
||||
renameSync(tmp, target);
|
||||
// Shared bootstrap atomic writer (symlink-resolving, mode-inheriting) —
|
||||
// fresh files take the caller's convention (user-scope → 0600) [X11].
|
||||
atomicWriteTextFile(path, `${JSON.stringify(value, null, 2)}\n`, { freshMode });
|
||||
}
|
||||
|
||||
/** Pre-write backup path per strategy; timestamped avoids the shared-slot loss. */
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
* gbrain code.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
|
||||
// ── Spec-target registry [ENG-7] ────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +39,7 @@ export interface HostSpecTarget {
|
||||
|
||||
export const CLAUDE_CODE_SPEC_ID = 'claude-code-2026-08';
|
||||
export const CODEX_SPEC_ID = 'codex-2026-08';
|
||||
export const OPENCODE_SPEC_ID = 'opencode-2026-08';
|
||||
|
||||
export const TARGETS: Record<string, HostSpecTarget> = {
|
||||
[CLAUDE_CODE_SPEC_ID]: {
|
||||
@@ -103,6 +105,43 @@ export const TARGETS: Record<string, HostSpecTarget> = {
|
||||
'has no hooks". Some codex builds gate HTTP MCP servers behind ' +
|
||||
'`experimental_use_rmcp_client = true` — probe at wiring time.',
|
||||
},
|
||||
[OPENCODE_SPEC_ID]: {
|
||||
id: OPENCODE_SPEC_ID,
|
||||
status: 'verified',
|
||||
verifiedAt: '2026-08-15',
|
||||
references: [
|
||||
'docs/mcp/OPENCODE-CLI-PIN.md',
|
||||
'https://opencode.ai/docs/mcp-servers/',
|
||||
'opencode-ai 1.18.18 (hermetic observation run, macOS arm64, 2026-08-15)',
|
||||
],
|
||||
note:
|
||||
'opencode (SST, opencode.ai — not OpenClaw). Config is JSONC everywhere: ' +
|
||||
'comments parse in .json-named files, and global opencode.json AND ' +
|
||||
'opencode.jsonc are BOTH read (merged) when both exist; `opencode mcp ' +
|
||||
'add` writes the user-global opencode.jsonc via a comment-preserving ' +
|
||||
'editor, so gbrain writes match that bar (jsonc-parser surgical edits, ' +
|
||||
'opencode-json.ts). MCP entries: {type:"local", command[], environment, ' +
|
||||
'enabled?} / {type:"remote", url, headers} — header values keep ' +
|
||||
'`{env:VAR}` interpolation verbatim; unknown keys tolerated in 1.18.18 ' +
|
||||
'but gbrain writes NO marker key (ownership is a structural ' +
|
||||
'fingerprint — a future strict-schema flip must not brick the host). ' +
|
||||
'`mcp add` has no scope flag (always user-global); project opencode.json ' +
|
||||
'is read but a project-defined LOCAL server spawns with NO trust gate ' +
|
||||
'(verified) — so gbrain defaults registration to USER scope and treats ' +
|
||||
'project scope as explicit opt-in with a sharing warning. `mcp list` is ' +
|
||||
'the honest discriminator (spawns servers; ✓/✗ text; exit 0 regardless); ' +
|
||||
'`mcp debug` is OAuth-only. Keyless anonymous free tier answers headless ' +
|
||||
'`run` AND drives MCP tool calls without --auto (load-bearing for the ' +
|
||||
'door SMOKE). DOCS-CONTRADICTION pinned: OPENCODE_CONFIG / _CONFIG_DIR / ' +
|
||||
'_CONFIG_CONTENT observed INERT in 1.18.18 — only HOME/XDG_CONFIG_HOME ' +
|
||||
'move the config; path helpers resolve via XDG only. opencode sets ' +
|
||||
'OPENCODE=1 (+OPENCODE_PID) in bash-tool children — detectHarness ' +
|
||||
'probes OPENCODE. AGENTS.md loads natively; CLAUDE.md is NOT ' +
|
||||
'double-loaded. opencode ships a JS plugin/event system — ' +
|
||||
'OPENCODE_HAS_HOOKS=false means "gbrain does not wire it yet" (follow-up ' +
|
||||
'filed), NOT "opencode has no hooks"; probes run with --pure + ' +
|
||||
'OPENCODE_DISABLE_AUTOUPDATE=1 because mcp list autoloads plugins.',
|
||||
},
|
||||
};
|
||||
|
||||
// ── Claude Code shapes the writers consume ──────────────────────────────────
|
||||
@@ -240,3 +279,74 @@ export const CODEX_HAS_HOOKS = false;
|
||||
export const CODEX_TOML_BLOCK_BEGIN =
|
||||
`# gbrain:${GBRAIN_HARNESS_MARKER_VALUE} begin - managed by \`gbrain bootstrap harness\`; do not edit inside`;
|
||||
export const CODEX_TOML_BLOCK_END = `# gbrain:${GBRAIN_HARNESS_MARKER_VALUE} end`;
|
||||
|
||||
// ── opencode shapes ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* opencode config DIRECTORY (user-global). Resolution mirrors what the real
|
||||
* binary was OBSERVED to do (OPENCODE-CLI-PIN.md §Path seams): XDG_CONFIG_HOME
|
||||
* else $HOME/.config, then /opencode. The OPENCODE_CONFIG / OPENCODE_CONFIG_DIR
|
||||
* / OPENCODE_CONFIG_CONTENT env vars are deliberately NOT honored here —
|
||||
* observed INERT in opencode 1.18.18 (probes registered through each were
|
||||
* invisible to `mcp list` while the XDG-resolved config was still read), so
|
||||
* honoring them would write registrations into a file opencode never reads (a
|
||||
* silent no-op install). HOME is read from the env explicitly because Bun's
|
||||
* homedir() reads the password database, not the HOME env var (the
|
||||
* claudeUserSettingsPath lesson).
|
||||
*/
|
||||
export function opencodeConfigDir(): string {
|
||||
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return join(xdg, 'opencode');
|
||||
const home = process.env.HOME?.trim();
|
||||
return join(home || homedir(), '.config', 'opencode');
|
||||
}
|
||||
|
||||
/**
|
||||
* User-global opencode config FILE. Both `opencode.json` and `opencode.jsonc`
|
||||
* are read (merged) by the host when both exist; gbrain edits the file that
|
||||
* already carries content, preferring `.jsonc` (the name `opencode mcp add`
|
||||
* itself writes) when both or neither exist — one-owner-per-file keeps the
|
||||
* merge unambiguous for `mcp.gbrain`.
|
||||
*/
|
||||
export function opencodeGlobalConfigPath(): string {
|
||||
const dir = opencodeConfigDir();
|
||||
const jsonc = join(dir, 'opencode.jsonc');
|
||||
const json = join(dir, 'opencode.json');
|
||||
if (existsSync(jsonc)) return jsonc;
|
||||
if (existsSync(json)) return json;
|
||||
return jsonc;
|
||||
}
|
||||
|
||||
/**
|
||||
* The OTHER member of the global filename pair for a given config path
|
||||
* (`opencode.json` ↔ `opencode.jsonc` in the same dir), or null when the
|
||||
* basename is not a pair member. opencode MERGES both files when both exist,
|
||||
* so global WRITERS must reconcile `mcp.<name>` across the pair — a same-name
|
||||
* entry left in the sibling survives as a shadow registration whose merge
|
||||
* winner is ambiguous (and a later removal of the primary "reveals" it).
|
||||
* Callers apply this to the USER-GLOBAL pair only; project-scope sibling
|
||||
* semantics are unobserved.
|
||||
*/
|
||||
export function opencodeGlobalSiblingPath(configPath: string): string | null {
|
||||
const dir = dirname(configPath);
|
||||
const base = basename(configPath);
|
||||
if (base === 'opencode.json') return join(dir, 'opencode.jsonc');
|
||||
if (base === 'opencode.jsonc') return join(dir, 'opencode.json');
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Project-scope opencode config (docs-canonical name; opencode's lookup
|
||||
* traverses up to the git root). Committed-file candidate — the writer's
|
||||
* PATH-resolved command + sharing-warning rules apply (OPENCODE.md). */
|
||||
export function opencodeProjectConfigPath(workspaceDir: string): string {
|
||||
return join(workspaceDir, 'opencode.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether gbrain WIRES opencode's hook/plugin system. False = not yet:
|
||||
* opencode ships a JS plugin/event system (and `--pure` to suppress it), but
|
||||
* gbrain's opencode plugin lane is a filed follow-up; per-turn context on
|
||||
* opencode rides the pull-protocol AGENTS.md gates, which opencode loads
|
||||
* natively (verified — and CLAUDE.md is NOT double-loaded alongside it).
|
||||
*/
|
||||
export const OPENCODE_HAS_HOOKS = false;
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* opencode-json.ts — managed `mcp.<name>` entry writer for opencode's JSONC
|
||||
* configs (see TARGETS['opencode-2026-08'] in host-specs.ts and
|
||||
* docs/mcp/OPENCODE-CLI-PIN.md for the verified format assumptions).
|
||||
*
|
||||
* Why a direct writer exists: `opencode mcp add` always targets the
|
||||
* user-global opencode.jsonc (no scope flag), cannot set file modes (the
|
||||
* harness lane's inline bearer needs 0600), and requires the binary on the
|
||||
* box — the writer covers project scope, secret hygiene, and offline/
|
||||
* pre-install registration with one code path.
|
||||
*
|
||||
* Safety invariants (codex-toml.ts analog, adapted for JSONC):
|
||||
* - ALL edits go through jsonc-parser `modify`/`applyEdits` — text splicing
|
||||
* that preserves comments, formatting, and EOLs byte-for-byte outside the
|
||||
* edited range. opencode's own `mcp add` preserves comments (observed);
|
||||
* gbrain matches that bar. JSON.parse is never used on config text.
|
||||
* - Ownership is a STRUCTURAL FINGERPRINT, not a marker key (unknown keys
|
||||
* are tolerated by opencode 1.18.18, but a future strict-schema flip must
|
||||
* not brick the user's opencode): a local entry is ours when command[0] is
|
||||
* gbrain-shaped AND environment.GBRAIN_SOURCE exists; source EQUALITY
|
||||
* (not mere presence) splits `ours-same-source` from `ours-other-source`
|
||||
* ([FIX7] parity with verifyMcpTargetsWorkspace) — callers warn before
|
||||
* overwriting another workspace's registration. A remote entry is ours
|
||||
* when its url matches the caller's receipt, or when its Authorization
|
||||
* header carries the `{env:GBRAIN_REMOTE_TOKEN}` interpolation (only the
|
||||
* connect lane writes that). Anything else under our name is FOREIGN —
|
||||
* refuse, never guess.
|
||||
* - Read-failure classes are distinct: ENOENT → fresh file; empty/whitespace
|
||||
* → treated as `{}`; unreadable (EACCES etc.) → refuse loudly (never
|
||||
* clobber what cannot be read). A file that fails even JSONC parsing →
|
||||
* refuse with a paste-by-hand snippet.
|
||||
* - Post-render validation before rename: the rendered text is re-parsed,
|
||||
* our entry deep-asserted, and every OTHER top-level key asserted to
|
||||
* survive; on any failure the original file is untouched.
|
||||
* - Secrets hygiene: when the entry carries an inline bearer the target is
|
||||
* forced 0600. Backups are UNIQUE per operation (`<config>.bak-<hex>`,
|
||||
* returned in the result) so two overlapping runs can never clobber each
|
||||
* other's snapshot, and a backup is chmod'd 0600 whenever the COPIED
|
||||
* content carries an inline bearer (write AND remove paths — on re-runs
|
||||
* the backup carries the PREVIOUS token). Token-free entries inherit the
|
||||
* file's existing mode.
|
||||
* - Concurrency: callers hold acquireBootstrapLock (config-dir →
|
||||
* opencode-dir ordering, mirroring the codex lanes in harness.ts) — the
|
||||
* writer itself is lock-free like codex-toml.ts.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { chmodSync, copyFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { applyEdits, modify, parse as parseJsonc, printParseErrorCode, type ParseError } from 'jsonc-parser';
|
||||
import { atomicWriteTextFile } from './atomic-write.ts';
|
||||
import { opencodeGlobalSiblingPath } from './host-specs.ts';
|
||||
|
||||
export const GBRAIN_REMOTE_TOKEN_ENV = 'GBRAIN_REMOTE_TOKEN';
|
||||
const ENV_INTERPOLATION = `{env:${GBRAIN_REMOTE_TOKEN_ENV}}`;
|
||||
|
||||
// ── Entry shapes ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OpencodeLocalEntry {
|
||||
kind: 'local';
|
||||
name: string;
|
||||
/** argv — command[0] is PATH-resolved "gbrain" (project scope, committed-
|
||||
* file candidate) or an absolute binary path (user scope). */
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface OpencodeRemoteEntry {
|
||||
kind: 'remote';
|
||||
name: string;
|
||||
url: string;
|
||||
/** 'inline' writes `Bearer <token>` (harness lane — framework-spawned
|
||||
* opencode inherits no shell profile; file forced 0600). 'env' writes the
|
||||
* `{env:GBRAIN_REMOTE_TOKEN}` interpolation (connect lane — token never
|
||||
* enters the file). */
|
||||
tokenMode: 'inline' | 'env';
|
||||
bearerToken?: string;
|
||||
}
|
||||
|
||||
export type OpencodeMcpEntry = OpencodeLocalEntry | OpencodeRemoteEntry;
|
||||
|
||||
export type OpencodeEntryKind =
|
||||
| 'absent'
|
||||
| 'ours-same-source'
|
||||
| 'ours-other-source'
|
||||
| 'foreign';
|
||||
|
||||
export interface OpencodeEntryExpectation {
|
||||
/** GBRAIN_SOURCE the caller is registering (local entries). */
|
||||
sourceId?: string;
|
||||
/** Serve url from the caller's receipt (remote entries). */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface WriteOpencodeEntryResult {
|
||||
configPath: string;
|
||||
/** True when a prior gbrain-owned entry was replaced (idempotent re-run). */
|
||||
replacedPrior: boolean;
|
||||
/** Kind of the pre-existing entry (what was there before this write). */
|
||||
priorKind: OpencodeEntryKind;
|
||||
/** Unique per-write backup (`<config>.bak-<hex>`) of the prior file, or
|
||||
* null on a fresh file. Callers that roll back restore from THIS path. */
|
||||
backupPath: string | null;
|
||||
/** The EXACT text this write landed — rollback callers compare the current
|
||||
* file content against it before restoring (a mismatch means a newer
|
||||
* registration exists and a restore would clobber it). */
|
||||
writtenText: string;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface RemoveOpencodeEntryResult {
|
||||
configPath: string;
|
||||
removed: boolean;
|
||||
backupPath: string | null;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
// ── Read + parse (failure classes are distinct) ─────────────────────────────
|
||||
|
||||
interface RawConfig {
|
||||
text: string;
|
||||
existed: boolean;
|
||||
}
|
||||
|
||||
function readConfigRaw(configPath: string): RawConfig {
|
||||
if (!existsSync(configPath)) return { text: '', existed: false };
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(configPath, 'utf8');
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`${configPath} exists but cannot be read (${(e as Error).message}) — ` +
|
||||
`refusing to touch a config that cannot be read back. Fix permissions and re-run.`,
|
||||
);
|
||||
}
|
||||
return { text, existed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse config text as JSONC (opencode's effective grammar for BOTH .json
|
||||
* and .jsonc files — OPENCODE-CLI-PIN.md §Config format). Empty/whitespace
|
||||
* text parses as `{}`. Text that fails even JSONC parsing throws with a
|
||||
* paste-by-hand snippet so the user is never stranded.
|
||||
*/
|
||||
export function parseOpencodeConfig(text: string, configPath: string, snippet?: string): Record<string, unknown> {
|
||||
if (text.trim() === '') return {};
|
||||
const errors: ParseError[] = [];
|
||||
const parsed = parseJsonc(text, errors, { allowTrailingComma: true }) as unknown;
|
||||
if (errors.length > 0) {
|
||||
const first = errors[0];
|
||||
throw new Error(
|
||||
`${configPath} does not parse as JSONC (${printParseErrorCode(first.error)} at offset ${first.offset}) — ` +
|
||||
`opencode itself cannot read it either. Fix the file, or add the entry by hand:\n${snippet ?? ''}`,
|
||||
);
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`${configPath} is valid JSONC but not an object — fix the file and re-run.`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Ownership fingerprint ───────────────────────────────────────────────────
|
||||
|
||||
function isGbrainShapedCommand(command: unknown): boolean {
|
||||
if (!Array.isArray(command) || command.length === 0) return false;
|
||||
const head = command[0];
|
||||
if (typeof head !== 'string') return false;
|
||||
if (head === 'gbrain') return true; // PATH-resolved (project scope)
|
||||
if (/[\\/]gbrain$/.test(head)) return true; // absolute binary path
|
||||
// bun-run wrapper shim lane: `bun run <...>/gbrain/src/cli.ts` etc. The
|
||||
// arg match is ANCHORED like the head-path lane: some arg must carry an
|
||||
// exact `gbrain` path segment (or a hyphen-suffixed `gbrain-*` one) — a
|
||||
// loose substring scan classified `bun run /opt/gbrainy-fork/src/cli.ts`
|
||||
// as ours (both the `gbrain` substring and a bare `src/cli.ts$` matched).
|
||||
// A gbrain-less `bun run /repo/src/cli.ts` is now NOT ours (fail-closed:
|
||||
// gbrain refuses to touch what it cannot prove it owns).
|
||||
if (head === 'bun' || head.endsWith('/bun')) {
|
||||
return command.some((a) => typeof a === 'string' && /(?:^|[\\/])gbrain(?:[\\/-]|$)/.test(a));
|
||||
}
|
||||
// staged shim named gbrain-<suffix> (e.g. gbrain-shim from stageBinDir) —
|
||||
// hyphen-anchored so a foreign /opt/bin/gbrainy is NOT ours.
|
||||
return /[\\/]gbrain-[^\\/]*$/.test(head);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the `mcp.<name>` entry in parsed config. The arbiter every lane
|
||||
* consults before writing or removing (codexBlockOwnsName analog).
|
||||
*/
|
||||
export function opencodeEntryKind(
|
||||
parsed: Record<string, unknown>,
|
||||
name: string,
|
||||
expect: OpencodeEntryExpectation = {},
|
||||
): OpencodeEntryKind {
|
||||
const mcp = parsed.mcp;
|
||||
if (typeof mcp !== 'object' || mcp === null) return 'absent';
|
||||
const entry = (mcp as Record<string, unknown>)[name];
|
||||
if (entry === undefined) return 'absent';
|
||||
if (typeof entry !== 'object' || entry === null) return 'foreign';
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
if (e.type === 'local') {
|
||||
if (!isGbrainShapedCommand(e.command)) return 'foreign';
|
||||
const env = e.environment;
|
||||
const src =
|
||||
typeof env === 'object' && env !== null
|
||||
? (env as Record<string, unknown>).GBRAIN_SOURCE
|
||||
: undefined;
|
||||
if (typeof src !== 'string' || src === '') return 'foreign';
|
||||
// Kind mismatch (red-team): a caller expecting a REMOTE entry (harness /
|
||||
// connect lanes pass expect.url) that finds a LOCAL gbrain entry is
|
||||
// looking at ANOTHER lane's registration (the workspace stdio lane's) —
|
||||
// never `ours-same-source`, or a silent replace (and a later --remove)
|
||||
// would eat it. `ours-other-source` fires the refuse/confirm machinery.
|
||||
if (expect.url !== undefined) return 'ours-other-source';
|
||||
if (expect.sourceId === undefined) return 'ours-same-source';
|
||||
return src === expect.sourceId ? 'ours-same-source' : 'ours-other-source';
|
||||
}
|
||||
|
||||
if (e.type === 'remote') {
|
||||
if (expect.url !== undefined && e.url === expect.url) return 'ours-same-source';
|
||||
const headers = e.headers;
|
||||
const auth =
|
||||
typeof headers === 'object' && headers !== null
|
||||
? (headers as Record<string, unknown>).Authorization
|
||||
: undefined;
|
||||
if (typeof auth === 'string' && auth.includes(ENV_INTERPOLATION)) {
|
||||
// Only the gbrain connect lane writes the {env:GBRAIN_REMOTE_TOKEN}
|
||||
// interpolation — unambiguously ours even without a receipt url. But a
|
||||
// url mismatch (another serve) OR a LOCAL expectation (expect.sourceId
|
||||
// — the workspace stdio lane; the kind-mismatch mirror of the local
|
||||
// branch above) is another lane's wiring: ours-other-source.
|
||||
return expect.url === undefined && expect.sourceId === undefined
|
||||
? 'ours-same-source'
|
||||
: 'ours-other-source';
|
||||
}
|
||||
return 'foreign';
|
||||
}
|
||||
|
||||
return 'foreign';
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────────────────────────────────────
|
||||
|
||||
function entryValue(entry: OpencodeMcpEntry): Record<string, unknown> {
|
||||
if (entry.kind === 'local') {
|
||||
return {
|
||||
type: 'local',
|
||||
command: entry.command,
|
||||
environment: entry.environment,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
const token =
|
||||
entry.tokenMode === 'inline'
|
||||
? `Bearer ${entry.bearerToken ?? ''}`
|
||||
: `Bearer ${ENV_INTERPOLATION}`;
|
||||
return {
|
||||
type: 'remote',
|
||||
url: entry.url,
|
||||
headers: { Authorization: token },
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Copy-pasteable snippet for the refusal paths (the user is never stranded).
|
||||
* SECURITY: an inline bearer is substituted with a literal placeholder — the
|
||||
* snippet rides thrown error messages (parse refusal, foreign refusal), and an
|
||||
* error path must never embed the real secret in text that lands in logs,
|
||||
* receipts, or stderr. Only the human-facing snippet changes; the write path
|
||||
* still renders the real token. */
|
||||
export function opencodeEntrySnippet(entry: OpencodeMcpEntry): string {
|
||||
const safe: OpencodeMcpEntry =
|
||||
entry.kind === 'remote' && entry.tokenMode === 'inline'
|
||||
? { ...entry, bearerToken: '<paste-token-here>' }
|
||||
: entry;
|
||||
return JSON.stringify({ mcp: { [safe.name]: entryValue(safe) } }, null, 2);
|
||||
}
|
||||
|
||||
function assertEntryName(name: string): void {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
|
||||
throw new Error(
|
||||
`MCP server name "${name}" is not a simple key ([A-Za-z0-9_-]+) — pick a simpler --name`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function entryCarriesSecret(entry: OpencodeMcpEntry): boolean {
|
||||
return entry.kind === 'remote' && entry.tokenMode === 'inline';
|
||||
}
|
||||
|
||||
/** True when config text carries an INLINE bearer credential (any
|
||||
* `Bearer <value>` that is not the `{env:…}` interpolation) — the rule that
|
||||
* decides whether a backup copy must be tightened to 0600. */
|
||||
export function textCarriesInlineBearer(text: string): boolean {
|
||||
return /Bearer\s+(?!\{env:)\S/.test(text);
|
||||
}
|
||||
|
||||
/** Unique-suffix backup (`<config>.bak-<hex>`): two overlapping runs can
|
||||
* never clobber each other's snapshot. The config-dir lock covers the WRITE,
|
||||
* but a backup must survive until the caller's post-write verification (the
|
||||
* harness network smoke) — which runs AFTER the lock is released, so a fixed
|
||||
* `.bak` name would let run B's writer overwrite run A's snapshot and a
|
||||
* failed run A would then restore (and revoke against) run B's state.
|
||||
* chmod 0600 whenever the copied content carries an inline bearer
|
||||
* (copyFileSync onto a fresh path takes the source mode, but a hand-loosened
|
||||
* source must not propagate a loose mode to a token-bearing backup). */
|
||||
function createUniqueBackup(configPath: string, priorText: string): string {
|
||||
const backupPath = `${configPath}.bak-${randomBytes(6).toString('hex')}`;
|
||||
copyFileSync(configPath, backupPath);
|
||||
if (textCarriesInlineBearer(priorText)) chmodSync(backupPath, 0o600);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
// No explicit eol: jsonc-parser detects and preserves the file's own EOLs
|
||||
// (verified: a CRLF config keeps CRLF through modify/applyEdits).
|
||||
const FORMATTING = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
|
||||
|
||||
// ── Write ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Idempotently write the managed `mcp.<name>` entry via a comment-preserving
|
||||
* surgical edit. Refuses foreign entries; replaces ours-same-source silently;
|
||||
* replaces ours-other-source only when `allowReplaceOtherSource` (callers
|
||||
* warn first). Validates the render before the atomic swap.
|
||||
*/
|
||||
export function writeOpencodeMcpEntry(
|
||||
configPath: string,
|
||||
entry: OpencodeMcpEntry,
|
||||
opts: { expect?: OpencodeEntryExpectation; allowReplaceOtherSource?: boolean } = {},
|
||||
): WriteOpencodeEntryResult {
|
||||
assertEntryName(entry.name);
|
||||
if (entry.kind === 'remote' && entry.tokenMode === 'inline' && !entry.bearerToken) {
|
||||
throw new Error('inline token mode requires a bearerToken');
|
||||
}
|
||||
const notes: string[] = [];
|
||||
const snippet = opencodeEntrySnippet(entry);
|
||||
|
||||
const { text, existed } = readConfigRaw(configPath);
|
||||
const parsed = parseOpencodeConfig(text, configPath, snippet);
|
||||
|
||||
const priorKind = opencodeEntryKind(parsed, entry.name, opts.expect);
|
||||
if (priorKind === 'foreign') {
|
||||
throw new Error(
|
||||
`mcp.${entry.name} in ${configPath} is not a gbrain-managed entry — refusing to overwrite it. ` +
|
||||
`Remove it (or pick another --name) and re-run.`,
|
||||
);
|
||||
}
|
||||
if (priorKind === 'ours-other-source' && !opts.allowReplaceOtherSource) {
|
||||
// Caller-appropriate refusal text: on the REMOTE path (expect.url — the
|
||||
// harness/connect lanes) no GBRAIN_SOURCE is involved, and connect's
|
||||
// documented escape hatch is --force; the GBRAIN_SOURCE wording belongs
|
||||
// to the local/workspace lane only.
|
||||
throw new Error(
|
||||
opts.expect?.url !== undefined
|
||||
? `mcp.${entry.name} in ${configPath} is a gbrain registration that does not match this endpoint ` +
|
||||
`(${opts.expect.url}) — another install or lane owns it; pass --force to replace it, or pick another --name.`
|
||||
: `mcp.${entry.name} in ${configPath} belongs to a DIFFERENT gbrain workspace ` +
|
||||
`(GBRAIN_SOURCE mismatch) — re-run with the overwrite confirmation to reroute it, or pick another --name.`,
|
||||
);
|
||||
}
|
||||
if (priorKind === 'ours-other-source') {
|
||||
notes.push(
|
||||
opts.expect?.url !== undefined
|
||||
? `replaced a gbrain registration that did not match this endpoint (url/lane mismatch).`
|
||||
: `replaced a gbrain registration that pointed at a different workspace (source mismatch).`,
|
||||
);
|
||||
}
|
||||
|
||||
const baseText = text.trim() === '' ? '{\n "$schema": "https://opencode.ai/config.json"\n}\n' : text;
|
||||
const edits = modify(baseText, ['mcp', entry.name], entryValue(entry), FORMATTING);
|
||||
const nextText = applyEdits(baseText, edits);
|
||||
|
||||
// Post-render validation: parse + deep-assert our entry + assert every
|
||||
// OTHER top-level key survives. Any failure leaves the original untouched.
|
||||
const rendered = parseOpencodeConfig(nextText, configPath, snippet);
|
||||
const renderedMcp = rendered.mcp as Record<string, unknown> | undefined;
|
||||
const ours = renderedMcp?.[entry.name];
|
||||
if (JSON.stringify(ours) !== JSON.stringify(entryValue(entry))) {
|
||||
throw new Error(
|
||||
`post-render validation failed: mcp.${entry.name} did not round-trip — original file left untouched.`,
|
||||
);
|
||||
}
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (key === 'mcp') continue;
|
||||
if (JSON.stringify(rendered[key]) !== JSON.stringify(parsed[key])) {
|
||||
throw new Error(
|
||||
`post-render validation failed: top-level key "${key}" changed — original file left untouched.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof parsed.mcp === 'object' && parsed.mcp !== null) {
|
||||
for (const key of Object.keys(parsed.mcp as Record<string, unknown>)) {
|
||||
if (key === entry.name) continue;
|
||||
const before = (parsed.mcp as Record<string, unknown>)[key];
|
||||
const after = renderedMcp?.[key];
|
||||
if (JSON.stringify(after) !== JSON.stringify(before)) {
|
||||
throw new Error(
|
||||
`post-render validation failed: mcp.${key} (not ours) changed — original file left untouched.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const secret = entryCarriesSecret(entry);
|
||||
let backupPath: string | null = null;
|
||||
if (existed) {
|
||||
backupPath = createUniqueBackup(configPath, text);
|
||||
if (secret) chmodSync(backupPath, 0o600); // re-runs: the backup carries the previous token
|
||||
}
|
||||
atomicWriteTextFile(configPath, nextText, secret ? { forceMode: 0o600 } : { freshMode: 0o644 });
|
||||
if (secret && existed) {
|
||||
notes.push(`${configPath} tightened to 0600 — it now carries a bearer token.`);
|
||||
}
|
||||
|
||||
return {
|
||||
configPath,
|
||||
replacedPrior: priorKind !== 'absent',
|
||||
priorKind,
|
||||
backupPath,
|
||||
writtenText: nextText,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Remove ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Remove the managed entry (fingerprint-keyed; everything else survives
|
||||
* byte-for-byte). Absent file / absent entry are calm no-ops. Foreign
|
||||
* entries refuse — removal never deletes what gbrain does not own.
|
||||
* `skipOtherSource` turns an `ours-other-source` match into a calm skip-with-
|
||||
* note instead of a removal (the uninstall sweep passes it: a gbrain entry
|
||||
* from a DIFFERENT workspace is not this uninstall's to delete).
|
||||
*/
|
||||
export function removeOpencodeMcpEntry(
|
||||
configPath: string,
|
||||
name: string,
|
||||
expect: OpencodeEntryExpectation = {},
|
||||
opts: { skipOtherSource?: boolean } = {},
|
||||
): RemoveOpencodeEntryResult {
|
||||
assertEntryName(name);
|
||||
const notes: string[] = [];
|
||||
if (!existsSync(configPath)) {
|
||||
return { configPath, removed: false, backupPath: null, notes: ['no opencode config — nothing to remove'] };
|
||||
}
|
||||
const { text } = readConfigRaw(configPath);
|
||||
const parsed = parseOpencodeConfig(text, configPath);
|
||||
|
||||
const kind = opencodeEntryKind(parsed, name, expect);
|
||||
if (kind === 'absent') {
|
||||
return { configPath, removed: false, backupPath: null, notes: ['no gbrain-managed entry — nothing to remove'] };
|
||||
}
|
||||
if (kind === 'foreign') {
|
||||
throw new Error(
|
||||
`mcp.${name} in ${configPath} is not a gbrain-managed entry — refusing to remove it.`,
|
||||
);
|
||||
}
|
||||
if (kind === 'ours-other-source' && opts.skipOtherSource) {
|
||||
return {
|
||||
configPath,
|
||||
removed: false,
|
||||
backupPath: null,
|
||||
notes: [
|
||||
`mcp.${name} in ${configPath} belongs to a DIFFERENT gbrain workspace (source mismatch) — left in place.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (kind === 'ours-other-source') {
|
||||
notes.push('removed a gbrain registration that pointed at a different workspace (source mismatch).');
|
||||
}
|
||||
|
||||
const edits = modify(text, ['mcp', name], undefined, FORMATTING);
|
||||
const nextText = applyEdits(text, edits);
|
||||
parseOpencodeConfig(nextText, configPath); // never leave opencode unreadable
|
||||
|
||||
// Unique backup, 0600 when the copied content carries an inline bearer —
|
||||
// the removed entry may BE the token-bearing one, and a fixed-name copy
|
||||
// onto a pre-existing loose-mode backup would keep the loose mode.
|
||||
const backupPath = createUniqueBackup(configPath, text);
|
||||
atomicWriteTextFile(configPath, nextText);
|
||||
return { configPath, removed: true, backupPath, notes };
|
||||
}
|
||||
|
||||
// ── Sibling-global reconcile (the two-filename merge blind spot) ────────────
|
||||
|
||||
/**
|
||||
* opencode merges the user-global `opencode.json` AND `opencode.jsonc` when
|
||||
* both exist. Before writing `mcp.<name>` into one of them, reconcile the
|
||||
* SIBLING file: an ours-classified entry there is removed (one owner per
|
||||
* name — left in place it survives as a shadow registration whose merge
|
||||
* winner is ambiguous, and a later removal of the primary "reveals" it); a
|
||||
* FOREIGN entry refuses loudly naming BOTH files (same refusal posture as
|
||||
* the primary-file foreign case — the merge winner is not ours to fight
|
||||
* over). No-op when the path is not a global-pair member or the sibling is
|
||||
* absent/entry-less. Callers hold the opencode config-dir bootstrap lock
|
||||
* (both files share the dir — one lock covers both) and call this ONLY for
|
||||
* user-global writes (project-scope sibling semantics are unobserved).
|
||||
*/
|
||||
export function reconcileOpencodeSiblingGlobal(
|
||||
configPath: string,
|
||||
name: string,
|
||||
expect: OpencodeEntryExpectation = {},
|
||||
): { siblingPath: string | null; removed: boolean; notes: string[] } {
|
||||
const siblingPath = opencodeGlobalSiblingPath(configPath);
|
||||
if (!siblingPath || !existsSync(siblingPath)) return { siblingPath, removed: false, notes: [] };
|
||||
const { text } = readConfigRaw(siblingPath);
|
||||
const parsed = parseOpencodeConfig(text, siblingPath);
|
||||
const kind = opencodeEntryKind(parsed, name, expect);
|
||||
if (kind === 'absent') return { siblingPath, removed: false, notes: [] };
|
||||
if (kind === 'foreign') {
|
||||
throw new Error(
|
||||
`mcp.${name} in ${siblingPath} is not a gbrain-managed entry — opencode merges ${siblingPath} AND ` +
|
||||
`${configPath} when both exist, so writing mcp.${name} into ${configPath} would fight it with an ` +
|
||||
`ambiguous merge winner. Remove it (or pick another --name) and re-run.`,
|
||||
);
|
||||
}
|
||||
const r = removeOpencodeMcpEntry(siblingPath, name, expect);
|
||||
const notes = [
|
||||
`removed the gbrain mcp.${name} entry from ${siblingPath} — opencode merges both global filenames, and the ` +
|
||||
`registration being written lands in ${configPath} (one owner per name).`,
|
||||
...r.notes,
|
||||
];
|
||||
return { siblingPath, removed: r.removed, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `mcp.<name>` exists as a REMOTE-type entry (regardless of
|
||||
* ownership). The workspace stdio lane consults this before writing a local
|
||||
* entry into the user-global config: a remote entry under our name is either
|
||||
* the harness lane's (bootstrap harness) or foreign — either way the stdio
|
||||
* lane must not fight it (the codexBlockOwnsName analog, #4043 ownership
|
||||
* rule). Best-effort: unreadable/unparseable configs return false (the write
|
||||
* path re-checks with full refusal semantics).
|
||||
*/
|
||||
export function opencodeRemoteEntryExists(configPath: string, name: string): boolean {
|
||||
try {
|
||||
const { text, existed } = readConfigRaw(configPath);
|
||||
if (!existed) return false;
|
||||
const parsed = parseOpencodeConfig(text, configPath);
|
||||
const mcp = parsed.mcp;
|
||||
if (typeof mcp !== 'object' || mcp === null) return false;
|
||||
const entry = (mcp as Record<string, unknown>)[name];
|
||||
return typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).type === 'remote';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status/recovery helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recover the inline bearer from OUR remote entry (harness `--status` token
|
||||
* liveness — the receipt never stores the token). Returns null when the file
|
||||
* or entry is absent, foreign, env-mode, or unreadable as JSONC.
|
||||
*/
|
||||
export function parseOpencodeEntryBearer(configPath: string, name: string, expectUrl?: string): string | null {
|
||||
try {
|
||||
const { text, existed } = readConfigRaw(configPath);
|
||||
if (!existed) return null;
|
||||
const parsed = parseOpencodeConfig(text, configPath);
|
||||
const kind = opencodeEntryKind(parsed, name, { url: expectUrl });
|
||||
if (kind !== 'ours-same-source') return null;
|
||||
const entry = (parsed.mcp as Record<string, unknown>)[name] as Record<string, unknown>;
|
||||
if (entry.type !== 'remote') return null;
|
||||
const auth = (entry.headers as Record<string, unknown> | undefined)?.Authorization;
|
||||
if (typeof auth !== 'string' || !auth.startsWith('Bearer ')) return null;
|
||||
const token = auth.slice('Bearer '.length);
|
||||
if (token.includes('{env:')) return null; // env-interpolated — no inline token to recover
|
||||
return token || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export const PHASES: PhaseSpec[] = [
|
||||
title: 'Identity interview (confirmed read-back)',
|
||||
resume_hint:
|
||||
'gbrain bootstrap interview --init, then --set each answer, then --confirm <hash>. ' +
|
||||
'Claude Code only: also record the MCP scope consent (--set MCP_SCOPE <project|user>) BEFORE --confirm',
|
||||
'Claude Code and opencode: also record the MCP scope consent (--set MCP_SCOPE <project|user>) BEFORE --confirm',
|
||||
detect: (ws) => {
|
||||
const exists = existsSync(interviewStatePath(ws));
|
||||
const st = interviewStatus(ws);
|
||||
@@ -265,8 +265,10 @@ export const PHASES: PhaseSpec[] = [
|
||||
// outside the harness being wired). Advisory prose; the grep pins in
|
||||
// scripts/check-bootstrap-templates.sh §(e) are the enforcement.
|
||||
resume_hint:
|
||||
'gbrain bootstrap hooks --harness <claude-code|codex> — MCP scope consent is ' +
|
||||
'Claude Code only (recorded during the interview, pre-confirm); Codex registrations are always user-global (no scope flag)',
|
||||
'gbrain bootstrap hooks --harness <claude-code|codex|opencode> — MCP scope consent applies on ' +
|
||||
'Claude Code and opencode (recorded during the interview, pre-confirm; opencode defaults to ' +
|
||||
'user-global — the sharing-safe choice, since it spawns project-config servers with no trust gate); ' +
|
||||
'Codex registrations are always user-global (no scope flag)',
|
||||
detect: (ws, ctx) => {
|
||||
const regs = ctx.receipt?.registrations ?? [];
|
||||
if (regs.length > 0) {
|
||||
|
||||
@@ -98,7 +98,7 @@ export function resolveBrainDataDir(gbrainHomeDir: string): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RegistrationRemovalRequest {
|
||||
host: 'claude-code' | 'codex';
|
||||
host: 'claude-code' | 'codex' | 'opencode';
|
||||
scope: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* opencode runner — invokes the real `opencode` binary (SST terminal agent,
|
||||
* opencode.ai) in a tempdir with a BRIEF.md prompt. Live mode only.
|
||||
*
|
||||
* Invocation pattern (verified against a pinned hermetic install, v1.18.18 —
|
||||
* see docs/mcp/OPENCODE-CLI-PIN.md):
|
||||
* opencode run "<brief>" --format default
|
||||
*
|
||||
* `run` is opencode's headless one-shot: prompt in, final answer text ALONE
|
||||
* on stdout (banner/UI on stderr), exit 0. `--format default` is passed
|
||||
* explicitly so an upstream default flip cannot silently change the
|
||||
* transcript shape. NO `--auto`: MCP tool calls fire in run mode without any
|
||||
* permission flag (verified — the keyless SMOKE recalled a nonce through
|
||||
* gbrain_recall with the flag absent). NO `-m`: live mode runs the
|
||||
* OPERATOR's configured opencode, whose model pin (or the anonymous free
|
||||
* tier) is the point of the measurement.
|
||||
*
|
||||
* Naming: opencode (SST, opencode.ai, npm `opencode-ai`) is not OpenClaw
|
||||
* (the platform with its own runner) and not the original `opencode` CLI
|
||||
* that became Crush — the version preamble below makes a mis-bound claimant
|
||||
* diagnosable (the SST CLI answers `--version` with a BARE semver).
|
||||
*
|
||||
* Hermeticity posture (deliberate): live mode runs the OPERATOR's configured
|
||||
* opencode — the real XDG config/data dirs are inherited unless
|
||||
* XDG_CONFIG_HOME/XDG_DATA_HOME point elsewhere — against a hermetic BRAIN.
|
||||
* opencode-specific contamination channel (observed): the user-global
|
||||
* opencode config is read for EVERY run, and a project opencode.json in the
|
||||
* cwd spawns its local MCP servers with NO trust gate. Live-mode workspaces
|
||||
* are harness-created tempdirs (no project config in reach), but a global
|
||||
* mcp.gbrain entry would bind the operator's REAL brain while the oracle
|
||||
* probes the hermetic one — `invoke()` logs a loud warning for that case.
|
||||
* The fully hermetic lane is the door e2e
|
||||
* (install-real-opencode.serial.test.ts): fresh HOME + XDG dirs.
|
||||
* OPENCODE_CONFIG_CONTENT (inline whole-config env) is deliberately NOT
|
||||
* forwarded — it is a config-shadowing channel, and it was observed inert in
|
||||
* 1.18.18 anyway (OPENCODE-CLI-PIN.md §Path seams).
|
||||
*
|
||||
* Binary resolution: $OPENCODE_BIN > `which opencode` > unavailable.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
BASE_ENV_ALLOWLIST,
|
||||
detectBinary,
|
||||
filterAllowlistEnv,
|
||||
type AgentRunner,
|
||||
type DetectResult,
|
||||
type InvokeOpts,
|
||||
type InvokeResult,
|
||||
} from '../agent-runner.ts';
|
||||
import { spawnWithCapture } from '../transcript-capture.ts';
|
||||
|
||||
/**
|
||||
* Allow-list for env propagation when spawning opencode. The base list
|
||||
* carries ONLY Anthropic + OpenAI provider keys — opencode's headline
|
||||
* feature is multi-provider, so the delta NAMES the additional provider
|
||||
* keys a live-lane operator may be running on (xAI, Google, OpenRouter);
|
||||
* anything not named here silently strips and reads as a misleading
|
||||
* agent-auth failure. Plus the opencode seams: XDG dirs (config/auth
|
||||
* relocation for hermetic callers), OPENCODE_CONFIG(_DIR) (observed inert
|
||||
* in 1.18.18 but forwarded so a future release that activates them behaves
|
||||
* the way the caller intended), and OPENCODE_DISABLE_AUTOUPDATE (the env
|
||||
* half of the double autoupdate kill).
|
||||
*/
|
||||
const ENV_ALLOWLIST = [
|
||||
...BASE_ENV_ALLOWLIST,
|
||||
'XAI_API_KEY',
|
||||
'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_DATA_HOME',
|
||||
'OPENCODE_CONFIG',
|
||||
'OPENCODE_CONFIG_DIR',
|
||||
'OPENCODE_DISABLE_AUTOUPDATE',
|
||||
];
|
||||
|
||||
export class OpencodeRunner implements AgentRunner {
|
||||
readonly name = 'opencode';
|
||||
|
||||
async detect(): Promise<DetectResult> {
|
||||
return detectBinary('OPENCODE_BIN', 'opencode');
|
||||
}
|
||||
|
||||
async invoke(opts: InvokeOpts): Promise<InvokeResult> {
|
||||
const detected = await this.detect();
|
||||
if (!detected.available || !detected.binPath) {
|
||||
throw new Error(`opencode runner unavailable: ${detected.reason ?? 'unknown'}`);
|
||||
}
|
||||
const args = ['run', opts.brief, '--format', 'default'];
|
||||
const env = filterAllowlistEnv(ENV_ALLOWLIST, opts.env);
|
||||
|
||||
this.warnOnGlobalGbrainEntry(env);
|
||||
|
||||
// Version preamble: recorded as a plain stdout transcript event. The SST
|
||||
// CLI answers with a BARE semver (`1.18.18` — no name, no build hash);
|
||||
// any other shape means a colliding `opencode` claimant is bound.
|
||||
// execFileSync (no shell) with the SAME filtered env as the turn itself.
|
||||
try {
|
||||
const version = execFileSync(detected.binPath, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 15_000,
|
||||
env: env as NodeJS.ProcessEnv,
|
||||
cwd: opts.cwd,
|
||||
}).trim();
|
||||
opts.transcriptSink.write({
|
||||
ts: Date.now(),
|
||||
channel: 'stdout',
|
||||
bytes: Buffer.from(`[opencode-runner preamble] version: ${version}\n`, 'utf-8'),
|
||||
});
|
||||
} catch {
|
||||
// Preamble is diagnostic only — never fail the run for it.
|
||||
}
|
||||
|
||||
const result = await spawnWithCapture(detected.binPath, args, {
|
||||
cwd: opts.cwd,
|
||||
env,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
transcriptSink: opts.transcriptSink,
|
||||
});
|
||||
|
||||
return { exitCode: result.exitCode, durationMs: result.durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loud tripwire for the global-config contamination channel: when the
|
||||
* config dir opencode will resolve carries an mcp.gbrain entry, a live
|
||||
* turn routes gbrain tool calls at the operator's REAL brain while the
|
||||
* oracle probes the hermetic one. Warning only (live mode deliberately
|
||||
* runs the operator's agent); the hermetic lane is the door e2e. Checks
|
||||
* BOTH filenames — opencode merges opencode.json AND opencode.jsonc.
|
||||
*/
|
||||
private warnOnGlobalGbrainEntry(env: Record<string, string>): void {
|
||||
try {
|
||||
const xdg = env.XDG_CONFIG_HOME ?? process.env.XDG_CONFIG_HOME;
|
||||
const home = env.HOME ?? process.env.HOME;
|
||||
const cfgDir = xdg ? join(xdg, 'opencode') : home ? join(home, '.config', 'opencode') : null;
|
||||
if (!cfgDir) return;
|
||||
for (const file of ['opencode.jsonc', 'opencode.json']) {
|
||||
const p = join(cfgDir, file);
|
||||
if (!existsSync(p)) continue;
|
||||
// Loose containment probe, not a parse: the global config is JSONC
|
||||
// (comments legal), and a substring hit is enough for a warning.
|
||||
const text = readFileSync(p, 'utf-8');
|
||||
if (/"gbrain"\s*:/.test(text) && /"mcp"\s*:/.test(text)) {
|
||||
console.warn(
|
||||
`[opencode-runner] WARNING: ${p} carries an mcp.gbrain entry. opencode reads the ` +
|
||||
"user-global config on every run, so this live turn may bind the OPERATOR'S REAL " +
|
||||
'brain instead of the hermetic one. Use a scratch HOME/XDG_CONFIG_HOME for clean ' +
|
||||
'measurements (docs/mcp/OPENCODE-CLI-PIN.md).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort tripwire — unreadable/invalid config is not an error here.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'backfill': ['--aliases', '--all', '--batch-size', '--brain', '--concurrency', '--dry-run', '--fresh', '--help', '--include-null-signature', '--json', '--keep-index', '--list', '--max-errors', '--max-rows', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin'],
|
||||
'bench': ['--baseline', '--brain', '--explain', '--force', '--from', '--help', '--json', '--label', '--lang', '--limit', '--markdown', '--multimodal', '--near-symbol', '--restore-only', '--source', '--stale', '--symbol-kind', '--thin', '--threshold-jaccard', '--threshold-latency-multiplier', '--threshold-top1', '--to', '--tool'],
|
||||
'book-mirror': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--author', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--budget-usd-per-day', '--by-mention', '--chapters-dir', '--content', '--context-file', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-turns', '--max-usd', '--mode', '--model', '--multimodal', '--no-confirm', '--no-embedding', '--no-extract', '--no-follow', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--timeout-ms', '--title', '--token-ttl', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'bootstrap': ['--abbrev-ref', '--abort', '--accept-visibility-change-consequences', '--active', '--all', '--allow-unverified-remote', '--brain', '--branch', '--cached', '--compile', '--confirm', '--count', '--delete-brain', '--diff-filter', '--env', '--error-unmatch', '--exclude-standard', '--fast', '--file', '--flag', '--force', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--git-path', '--harness', '--heads', '--help', '--home', '--hostname', '--http', '--id', '--init', '--install', '--is-inside-work-tree', '--isolated', '--jq', '--json', '--local', '--minimal', '--name', '--name-only', '--no-capture', '--no-cron', '--no-embedding', '--no-hooks', '--no-verify', '--once', '--only', '--others', '--pat-file', '--path', '--pglite', '--porcelain', '--port', '--private', '--project', '--push', '--push-only', '--quiet', '--rebase', '--remove', '--repair', '--scope', '--scopes', '--set', '--short', '--show', '--show-toplevel', '--skip', '--source', '--status', '--surface', '--token', '--token-name', '--token-ttl', '--unset-all', '--url', '--user-hooks', '--verify', '--version', '--visibility', '--workspace', '--yes'],
|
||||
'bootstrap': ['--abbrev-ref', '--abort', '--accept-visibility-change-consequences', '--active', '--all', '--allow-unverified-remote', '--auto', '--brain', '--branch', '--cached', '--compile', '--confirm', '--count', '--delete-brain', '--diff-filter', '--env', '--error-unmatch', '--exclude-standard', '--fast', '--file', '--flag', '--force', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--git-path', '--harness', '--heads', '--help', '--home', '--hostname', '--http', '--id', '--init', '--install', '--is-inside-work-tree', '--isolated', '--jq', '--json', '--local', '--minimal', '--name', '--name-only', '--no-capture', '--no-cron', '--no-embedding', '--no-hooks', '--no-verify', '--once', '--only', '--others', '--pat-file', '--path', '--pglite', '--porcelain', '--port', '--private', '--project', '--pure', '--push', '--push-only', '--quiet', '--rebase', '--remove', '--repair', '--scope', '--scopes', '--set', '--short', '--show', '--show-toplevel', '--skip', '--source', '--status', '--surface', '--token', '--token-name', '--token-ttl', '--unset-all', '--url', '--user-hooks', '--verify', '--version', '--visibility', '--workspace', '--yes'],
|
||||
'brainstorm': ['--aliases', '--all', '--brain', '--chunker-debug', '--code', '--compile', '--fast', '--file', '--fix', '--force', '--force-rechunk', '--force-resume', '--from-pages', '--full', '--help', '--http', '--include-null-signature', '--json', '--judge-model', '--lang', '--limit', '--list-runs', '--markdown', '--max-cost', '--max-far-set', '--max-ideas-per-judge-call', '--model', '--no-embed', '--no-embedding', '--no-extract', '--no-save', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--retry-failed', '--retry-judge', '--save', '--source', '--stale', '--strict-budget', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--yes'],
|
||||
'cache': ['--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--source', '--surface', '--token-ttl', '--yes'],
|
||||
'calibration': ['--ab', '--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--holder', '--http', '--image', '--include-null-signature', '--json', '--key-prefix', '--kind', '--lang', '--limit', '--markdown', '--max-usd', '--mode', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--phase', '--progress-interval', '--progress-json', '--quiet', '--regenerate', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scrub-gstack', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--token-ttl', '--trusted-extraction', '--undo-wave', '--url', '--with-calibration', '--with-db', '--yes'],
|
||||
@@ -32,13 +32,13 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'check-backlinks': ['--background', '--brain', '--brain-wide-max-cost-usd', '--dir', '--dry-run', '--explain', '--follow', '--help', '--include-frontmatter', '--json', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--source', '--stale', '--timeout', '--type'],
|
||||
'check-resolvable': ['--brain', '--dry-run', '--fix', '--help', '--json', '--skills-dir', '--source', '--strict', '--verbose'],
|
||||
'check-update': ['--all', '--brain', '--check', '--dim', '--ff-only', '--help', '--json', '--markdown', '--migrate-only', '--non-interactive', '--refresh-cache', '--source', '--swap-only', '--to', '--version', '--yes'],
|
||||
'claw-test': ['--ab', '--agent', '--all', '--auto-update', '--brain', '--break-lock', '--build-index', '--by-mention', '--compile', '--days', '--dir', '--exclusive', '--force', '--force-retry', '--force-schema', '--from-meetings', '--help', '--history', '--http', '--json', '--keep-tempdir', '--lang', '--list-agents', '--live', '--local', '--locks', '--markdown', '--max-age', '--message', '--multimodal', '--no-embed', '--no-embedding', '--no-extract', '--output-format', '--path', '--pglite', '--phase', '--priority', '--progress-json', '--refresh-unqualified', '--remediate', '--rollback', '--run-id', '--scenario', '--skip-verify', '--source', '--stale', '--surface', '--transcripts', '--undo-wave', '--use-captured-snapshot', '--version', '--with-calibration', '--yes'],
|
||||
'claw-test': ['--ab', '--agent', '--all', '--auto', '--auto-update', '--brain', '--break-lock', '--build-index', '--by-mention', '--compile', '--days', '--dir', '--exclusive', '--force', '--force-retry', '--force-schema', '--format', '--from-meetings', '--help', '--history', '--http', '--json', '--keep-tempdir', '--lang', '--list-agents', '--live', '--local', '--locks', '--markdown', '--max-age', '--message', '--multimodal', '--no-embed', '--no-embedding', '--no-extract', '--output-format', '--path', '--pglite', '--phase', '--priority', '--progress-json', '--refresh-unqualified', '--remediate', '--rollback', '--run-id', '--scenario', '--skip-verify', '--source', '--stale', '--surface', '--transcripts', '--undo-wave', '--use-captured-snapshot', '--version', '--with-calibration', '--yes'],
|
||||
'code-callees': ['--aliases', '--all', '--all-sources', '--brain', '--chunker-debug', '--clone-dir', '--confirm-destructive', '--federated', '--force', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--no-federated', '--no-json', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--url', '--url-managed', '--yes'],
|
||||
'code-callers': ['--aliases', '--all', '--all-sources', '--brain', '--chunker-debug', '--clone-dir', '--confirm-destructive', '--federated', '--force', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--no-federated', '--no-json', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--url', '--url-managed', '--yes'],
|
||||
'code-def': ['--aliases', '--all', '--brain', '--chunker-debug', '--help', '--include-null-signature', '--json', '--lang', '--limit', '--no-extract', '--no-json', '--pattern', '--pending', '--pretty', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--yes'],
|
||||
'code-refs': ['--aliases', '--all', '--brain', '--chunker-debug', '--help', '--include-null-signature', '--json', '--lang', '--limit', '--no-extract', '--no-json', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--yes'],
|
||||
'config': ['--aliases', '--all', '--brain', '--column', '--coverage-override', '--detail', '--embedding-dimensions', '--embedding-model', '--fast', '--federated-read', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--pattern', '--pending', '--pglite', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--yes'],
|
||||
'connect': ['--agent', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--force', '--grant-types', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--register', '--scope', '--scopes', '--show-token', '--source', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'],
|
||||
'connect': ['--agent', '--auto', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--delete-brain', '--env', '--force', '--grant-types', '--header', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--pure', '--register', '--remove', '--scope', '--scopes', '--show-token', '--source', '--status', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'],
|
||||
'conversation-parser': ['--aliases', '--all', '--brain', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
|
||||
'doctor': ['--ab', '--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--allow-shell-jobs', '--allow-unverified-remote', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--build-index', '--by-mention', '--by-type', '--cached', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--detail', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclude-standard', '--exclusive', '--explain', '--fast', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--is-inside-work-tree', '--job-isolation', '--jq', '--json', '--lang', '--limit', '--local', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-cron', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-mutate', '--no-verify', '--oauth-client-secret', '--older-than', '--once', '--others', '--overwrite', '--parallel', '--params', '--pat-file', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--project', '--push-only', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--remove', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--short', '--show-current', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--token', '--token-ttl', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--unset-all', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--verify', '--version', '--window', '--with-calibration', '--workers', '--yes'],
|
||||
'dream': ['--against', '--aliases', '--all', '--allow-regression', '--anchor', '--asof', '--audit-rejects', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--by-type', '--by-type-floor', '--cancel-unmatched', '--code', '--committed-baseline', '--compare', '--compile', '--concurrent', '--ctx-size', '--cycles', '--date', '--detail', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--expansion', '--explain', '--fast', '--federated', '--fix', '--fixtures', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--format', '--from', '--from-db', '--from-pages', '--gold', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--install', '--json', '--judge-model', '--justification', '--keyword-only', '--lang', '--limit', '--llm', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-tokens', '--max-usd', '--mcp-only', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--name-only', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-llm', '--no-mutate', '--no-trajectory', '--once', '--out', '--output', '--output-dir', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--phase', '--priority', '--progress-interval', '--progress-json', '--pull', '--quiet', '--receipt-dir', '--reconcile-queue', '--remediate', '--repo', '--reranking', '--reset', '--resolve', '--restore-only', '--resume-from', '--retrieval-only', '--rounds', '--rubric-version', '--save', '--seed', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--suite', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--take', '--task', '--thin', '--threshold', '--timeout', '--to', '--token-ttl', '--top-k', '--undo', '--unsafe-bypass-dream-guard', '--update-baseline', '--verify', '--version', '--window', '--yes'],
|
||||
@@ -56,7 +56,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'friction': ['--agent', '--base', '--brain', '--compare', '--help', '--hint', '--json', '--kind', '--message', '--no-redact', '--phase', '--redact', '--run-id', '--severity', '--source', '--transcript-path', '--transcripts'],
|
||||
'frontmatter': ['--aliases', '--all', '--allow-catch-all', '--brain', '--cached', '--diff-filter', '--dry-run', '--exclude-standard', '--fast', '--fix', '--force', '--from-pages', '--get', '--help', '--http', '--include-catch-all', '--include-null-signature', '--json', '--name-only', '--name-status', '--no-embedding', '--no-extract', '--no-verify', '--others', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--strategy', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--uninstall', '--write-back'],
|
||||
'graph-query': ['--aliases', '--all', '--brain', '--depth', '--direction', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-foreign', '--include-null-signature', '--json', '--lang', '--markdown', '--mcp-only', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--type'],
|
||||
'hook': ['--aliases', '--all', '--allow-unverified-remote', '--batch-limit', '--brain', '--budget-ms', '--cached', '--count', '--delete-brain', '--detach', '--diff-filter', '--end-of-options', '--env', '--exclude-standard', '--fast', '--force', '--from-pages', '--get', '--harness', '--help', '--http', '--include-null-signature', '--jq', '--json', '--name-only', '--no-embedding', '--no-extract', '--once', '--others', '--path', '--pattern', '--pending', '--porcelain', '--project', '--quiet', '--remove', '--reset', '--resolve', '--show-current', '--show-toplevel', '--source', '--stale', '--stats', '--status', '--supersessions', '--surface', '--thin', '--timeout', '--token', '--token-ttl'],
|
||||
'hook': ['--aliases', '--all', '--allow-unverified-remote', '--auto', '--batch-limit', '--brain', '--budget-ms', '--cached', '--count', '--delete-brain', '--detach', '--diff-filter', '--end-of-options', '--env', '--exclude-standard', '--fast', '--force', '--from-pages', '--get', '--harness', '--help', '--http', '--include-null-signature', '--jq', '--json', '--name-only', '--no-embedding', '--no-extract', '--once', '--others', '--path', '--pattern', '--pending', '--porcelain', '--project', '--pure', '--quiet', '--remove', '--reset', '--resolve', '--show-current', '--show-toplevel', '--source', '--stale', '--stats', '--status', '--supersessions', '--surface', '--thin', '--timeout', '--token', '--token-ttl'],
|
||||
'import': ['--aliases', '--all', '--asof', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--cached', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--fix', '--follow', '--force', '--force-rechunk', '--fresh', '--from-pages', '--full', '--help', '--http', '--include-gitignored', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--multimodal', '--name-status', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--older-than', '--others', '--path', '--pattern', '--pending', '--pglite', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--since', '--skip-failed', '--source', '--source-id', '--stale', '--strategy', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--url', '--workers'],
|
||||
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--embeddings', '--entity', '--expansion-model', '--fast', '--flag', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--reranking', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--to', '--token-ttl', '--touchpoint', '--url', '--version'],
|
||||
'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target', '--token-ttl'],
|
||||
|
||||
@@ -25,11 +25,11 @@ import { reflexPointerRationale } from './retrieval-reflex.ts';
|
||||
export const VOLUNTEER_EVENTS_TTL_DAYS = 90;
|
||||
|
||||
/** Single source of truth for channel values — type + guards derive from it. */
|
||||
export const VOLUNTEER_CHANNELS = ['op', 'reflex', 'watch', 'claude-code', 'codex'] as const;
|
||||
export const VOLUNTEER_CHANNELS = ['op', 'reflex', 'watch', 'claude-code', 'codex', 'opencode'] as const;
|
||||
export type VolunteerChannel = (typeof VOLUNTEER_CHANNELS)[number];
|
||||
|
||||
/** The harness subset — the ONLY channels a wire caller may claim. */
|
||||
export const HARNESS_CHANNELS = ['claude-code', 'codex'] as const;
|
||||
export const HARNESS_CHANNELS = ['claude-code', 'codex', 'opencode'] as const;
|
||||
export type HarnessChannel = (typeof HARNESS_CHANNELS)[number];
|
||||
|
||||
/** Wire fallback: the only harness bootstrap registers hooks for today. */
|
||||
|
||||
@@ -141,6 +141,16 @@ export function buildCodexMcpAddArgv(p: { name: string; url: string; envVar: str
|
||||
return ['mcp', 'add', p.name, '--url', p.url, '--bearer-token-env-var', p.envVar];
|
||||
}
|
||||
|
||||
/**
|
||||
* `opencode mcp add` argv for a remote HTTP server. The header value carries
|
||||
* opencode's `{env:VAR}` interpolation LITERALLY — opencode resolves it at
|
||||
* read time, so the token never enters argv or the config file (verified
|
||||
* against opencode 1.18.18; OPENCODE-CLI-PIN.md §mcp add).
|
||||
*/
|
||||
export function buildOpencodeMcpAddArgv(p: { name: string; url: string; envVar: string }): string[] {
|
||||
return ['mcp', 'add', p.name, '--url', p.url, '--header', `Authorization=Bearer {env:${p.envVar}}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX single-quote any arg that isn't already shell-safe, so `$()`, backticks,
|
||||
* etc. in a token are inert literals when the block is pasted into a shell
|
||||
|
||||
@@ -48,6 +48,14 @@ registration is always user-global and the tradeoff above is the standing
|
||||
state. Off-ramps: `codex mcp remove gbrain` removes just the registration;
|
||||
`gbrain bootstrap uninstall` is the full teardown.
|
||||
|
||||
opencode: the scope logic is INVERTED from Claude Code. opencode spawns
|
||||
servers from a project `opencode.json` with NO trust prompt, so a
|
||||
project-scoped registration in a repo you share means anyone who checks the
|
||||
repo out gets the entry executed on open. gbrain therefore defaults to a
|
||||
user-global registration; project scope is an explicit opt-in that prints a
|
||||
sharing warning. Off-ramps: `gbrain bootstrap uninstall` removes the entry
|
||||
from both scope files; deleting the `mcp.gbrain` key by hand also works.
|
||||
|
||||
## The transcript corpus
|
||||
|
||||
Session transcripts are retained locally (outside this repo, mode 0700, pruned
|
||||
|
||||
@@ -72,7 +72,7 @@ during long work reads as broken.
|
||||
|
||||
**Gate 2 — Recover missed context.** Scan the conversation for earlier messages that
|
||||
never got processed. Before sending the final reply, rescan for anything that
|
||||
arrived mid-turn. On a harness WITHOUT hooks (Codex — pull protocol), also run
|
||||
arrived mid-turn. On a harness WITHOUT hooks (Codex / opencode — pull protocol), also run
|
||||
`gbrain bootstrap status` once at the start of a conversation: it surfaces a
|
||||
failing or stale workspace push that hook-carrying harnesses would have shown
|
||||
automatically. If it reports the push FAILING, tell {{PRINCIPAL_NAME}} plainly —
|
||||
|
||||
@@ -10,8 +10,8 @@ remain private.
|
||||
anything matching the deny list in `.gitignore`.
|
||||
- **How it syncs:** `gbrain sources push` — a secret-scan-gated commit + push that
|
||||
refuses public remotes. On Claude Code it runs automatically per turn
|
||||
(debounced) and at session end via hooks; on Codex (no hook system) run it at
|
||||
natural stopping points (the AGENTS.md gate reminds you). If background
|
||||
(debounced) and at session end via hooks; on Codex or opencode (no wired hook
|
||||
system) run it at natural stopping points (the AGENTS.md gate reminds you). If background
|
||||
persistence is enabled, a git post-commit hook auto-pushes each commit and a
|
||||
30-minute pull job keeps multi-machine checkouts fresh. Run it by hand after
|
||||
meaningful changes on any harness.
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"MCP_SCOPE": {
|
||||
"consent": true,
|
||||
"phase": "interview",
|
||||
"question": "(Claude Code only. Codex has no scope flag — its registrations are always user-global; on Codex, state that plainly instead of asking.) Register the brain for THIS folder only (recommended — any other repo you open cannot read it), or for every session on this machine (your agent everywhere, but any repo you open can query your brain, and two open sessions will contend for the local database)?",
|
||||
"question": "(Claude Code and opencode. Codex has no scope flag — its registrations are always user-global; on Codex, state that plainly instead of asking. On opencode the DEFAULT is user-global — the sharing-safe choice, because opencode spawns project-config-defined servers with no trust gate; offer 'project' only as a deliberate opt-in and state the committed-file consequence.) Register the brain for THIS folder only (recommended on Claude Code — any other repo you open cannot read it), or for every session on this machine (your agent everywhere, but any repo you open can query your brain, and two open sessions will contend for the local database)?",
|
||||
"default": "project",
|
||||
"allowed": ["project", "user"],
|
||||
"maxLength": 8
|
||||
@@ -163,7 +163,7 @@
|
||||
"VOICE_BANNED": { "default": "- Opening with filler (\"Great question\", \"I'd be happy to help\", \"Absolutely\")\n- Hedging when a take exists\n- \"It's not X, it's Y\" constructions\n- Narrating the writing process inside a document", "maxLength": 1024, "shape": "list" },
|
||||
"SAFETY_RED_LINES": { "default": "- Never send money or make purchases without explicit per-instance approval\n- Never message third parties as the principal without sign-off on the exact text\n- Never delete data that cannot be restored\n- Never share the principal's private information with anyone but the principal", "maxLength": 2048, "shape": "list" },
|
||||
"QUIET_HOURS": { "default": "23:00-08:00 local", "maxLength": 64 },
|
||||
"SURFACE_PRIMARY": { "default": "this workspace (Claude Code / Codex)", "maxLength": 128 },
|
||||
"SURFACE_PRIMARY": { "default": "this workspace (Claude Code / Codex / opencode)", "maxLength": 128 },
|
||||
"SURFACE_MULTIUSER": { "default": "single-principal", "allowed": ["single-principal", "shared"], "maxLength": 32 },
|
||||
"MEMORY_WHAT_MATTERS": { "default": "- Corrections the principal makes (these become standing rules)\n- Commitments made in either direction, with dates\n- Preferences stated once that should never need restating\n- Facts about people and projects the principal works with", "maxLength": 2048, "shape": "list" },
|
||||
"PRINCIPAL_PROJECTS": { "default": "*(none recorded yet — add as they come up)*", "maxLength": 4096, "shape": "list" },
|
||||
|
||||
@@ -48,6 +48,14 @@ registration is always user-global and the tradeoff above is the standing
|
||||
state. Off-ramps: `codex mcp remove gbrain` removes just the registration;
|
||||
`gbrain bootstrap uninstall` is the full teardown.
|
||||
|
||||
opencode: the scope logic is INVERTED from Claude Code. opencode spawns
|
||||
servers from a project `opencode.json` with NO trust prompt, so a
|
||||
project-scoped registration in a repo you share means anyone who checks the
|
||||
repo out gets the entry executed on open. gbrain therefore defaults to a
|
||||
user-global registration; project scope is an explicit opt-in that prints a
|
||||
sharing warning. Off-ramps: `gbrain bootstrap uninstall` removes the entry
|
||||
from both scope files; deleting the `mcp.gbrain` key by hand also works.
|
||||
|
||||
## The transcript corpus
|
||||
|
||||
Session transcripts are retained locally (outside this repo, mode 0700, pruned
|
||||
|
||||
@@ -76,7 +76,7 @@ during long work reads as broken.
|
||||
|
||||
**Gate 2 — Recover missed context.** Scan the conversation for earlier messages that
|
||||
never got processed. Before sending the final reply, rescan for anything that
|
||||
arrived mid-turn. On a harness WITHOUT hooks (Codex — pull protocol), also run
|
||||
arrived mid-turn. On a harness WITHOUT hooks (Codex / opencode — pull protocol), also run
|
||||
`gbrain bootstrap status` once at the start of a conversation: it surfaces a
|
||||
failing or stale workspace push that hook-carrying harnesses would have shown
|
||||
automatically. If it reports the push FAILING, tell {{PRINCIPAL_NAME}} plainly —
|
||||
|
||||
@@ -10,8 +10,8 @@ remain private.
|
||||
anything matching the deny list in `.gitignore`.
|
||||
- **How it syncs:** `gbrain sources push` — a secret-scan-gated commit + push that
|
||||
refuses public remotes. On Claude Code it runs automatically per turn
|
||||
(debounced) and at session end via hooks; on Codex (no hook system) run it at
|
||||
natural stopping points (the AGENTS.md gate reminds you). If background
|
||||
(debounced) and at session end via hooks; on Codex or opencode (no wired hook
|
||||
system) run it at natural stopping points (the AGENTS.md gate reminds you). If background
|
||||
persistence is enabled, a git post-commit hook auto-pushes each commit and a
|
||||
30-minute pull job keeps multi-machine checkouts fresh. Run it by hand after
|
||||
meaningful changes on any harness.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# gbrain agent workspace — template
|
||||
|
||||
<!-- gbrain-template-stamp: 0.46.3.0 -->
|
||||
<!-- gbrain-template-stamp: 0.46.4.0 -->
|
||||
|
||||
This repository is the **"Use this template"** distribution artifact for a
|
||||
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
|
||||
|
||||
@@ -7,7 +7,7 @@ valence of what they said, stop and re-derive from their words.
|
||||
|
||||
- **Name:** {{PRINCIPAL_NAME}}
|
||||
- **Timezone:** America/Los_Angeles
|
||||
- **Primary surface:** this workspace (Claude Code / Codex)
|
||||
- **Primary surface:** this workspace (Claude Code / Codex / opencode)
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* atomic-write.ts — the ONE atomic config writer for bootstrap host surfaces.
|
||||
* Pins the symlink-preservation contract (live AND dangling links survive as
|
||||
* links; the write lands at the resolved target) plus the mode ladder:
|
||||
* forceMode > existing-file mode > freshMode.
|
||||
*
|
||||
* The dangling case is the red-team finding: existsSync FOLLOWS symlinks, so
|
||||
* a dangling link reads "absent" and a naive rename would replace the link
|
||||
* itself with a regular file — a dotfile-manager layout whose target was
|
||||
* cleaned up would silently stop being managed.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { atomicWriteTextFile } from '../src/core/bootstrap/atomic-write.ts';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'gbrain-atomic-write-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('atomicWriteTextFile — symlink preservation', () => {
|
||||
test('live symlink: write lands in the TARGET, the link survives as a link', () => {
|
||||
const target = join(dir, 'dotfiles', 'config.jsonc');
|
||||
mkdirSync(join(dir, 'dotfiles'), { recursive: true });
|
||||
writeFileSync(target, '{"old":true}');
|
||||
const link = join(dir, 'config.jsonc');
|
||||
symlinkSync(target, link);
|
||||
|
||||
atomicWriteTextFile(link, '{"new":true}');
|
||||
|
||||
expect(lstatSync(link).isSymbolicLink()).toBe(true);
|
||||
expect(readFileSync(target, 'utf8')).toBe('{"new":true}');
|
||||
expect(readFileSync(link, 'utf8')).toBe('{"new":true}');
|
||||
});
|
||||
|
||||
test('DANGLING symlink: the missing target is created (parent dir too) and the link survives', () => {
|
||||
const target = join(dir, 'dotfiles', 'nested', 'config.jsonc'); // dir does not exist either
|
||||
const link = join(dir, 'config.jsonc');
|
||||
symlinkSync(target, link);
|
||||
expect(existsSync(link)).toBe(false); // existsSync follows the link — the trap
|
||||
|
||||
atomicWriteTextFile(link, '{"created":true}', { freshMode: 0o600 });
|
||||
|
||||
expect(lstatSync(link).isSymbolicLink()).toBe(true); // NOT replaced by a regular file
|
||||
expect(readFileSync(target, 'utf8')).toBe('{"created":true}');
|
||||
expect(readFileSync(link, 'utf8')).toBe('{"created":true}');
|
||||
expect(statSync(target).mode & 0o777).toBe(0o600); // fresh target takes freshMode
|
||||
});
|
||||
|
||||
test('DANGLING symlink with RELATIVE link text resolves against the link dir', () => {
|
||||
const link = join(dir, 'config.jsonc');
|
||||
symlinkSync(join('sub', 'real.jsonc'), link); // relative, target absent
|
||||
|
||||
atomicWriteTextFile(link, 'relative-ok');
|
||||
|
||||
expect(lstatSync(link).isSymbolicLink()).toBe(true);
|
||||
expect(readFileSync(join(dir, 'sub', 'real.jsonc'), 'utf8')).toBe('relative-ok');
|
||||
expect(readFileSync(link, 'utf8')).toBe('relative-ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('atomicWriteTextFile — mode ladder', () => {
|
||||
test('fresh file (ENOENT) takes freshMode', () => {
|
||||
const p = join(dir, 'fresh.json');
|
||||
atomicWriteTextFile(p, '{}', { freshMode: 0o600 });
|
||||
expect(statSync(p).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
test('fresh file without freshMode gets the platform default (no chmod)', () => {
|
||||
const p = join(dir, 'fresh-default.json');
|
||||
atomicWriteTextFile(p, '{}');
|
||||
expect(existsSync(p)).toBe(true); // mode is umask-dependent; existence is the pin
|
||||
});
|
||||
|
||||
test('forceMode overrides an existing file\'s looser mode', () => {
|
||||
const p = join(dir, 'secret.toml');
|
||||
writeFileSync(p, 'old');
|
||||
chmodSync(p, 0o644); // a known loose mode first
|
||||
atomicWriteTextFile(p, 'new', { forceMode: 0o600 });
|
||||
expect(statSync(p).mode & 0o777).toBe(0o600);
|
||||
expect(readFileSync(p, 'utf8')).toBe('new');
|
||||
});
|
||||
|
||||
test('existing file\'s own mode is inherited when neither force nor fresh applies', () => {
|
||||
const p = join(dir, 'keep-mode.json');
|
||||
writeFileSync(p, 'old');
|
||||
chmodSync(p, 0o640);
|
||||
atomicWriteTextFile(p, 'new', { freshMode: 0o600 }); // freshMode must NOT apply — the file exists
|
||||
expect(statSync(p).mode & 0o777).toBe(0o640);
|
||||
expect(readFileSync(p, 'utf8')).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('atomicWriteTextFile — failure hygiene (no tmp litter)', () => {
|
||||
test('a failing rename does not leak the .tmp- file (target is a directory → rename throws)', () => {
|
||||
// The resolved target being a DIRECTORY makes writeFileSync of the tmp
|
||||
// succeed but renameSync(tmp, target) throw — the exact mid-sequence
|
||||
// failure shape (ENOSPC/EACCES class) that used to strand tmp litter
|
||||
// next to the user's config.
|
||||
const target = join(dir, 'config.jsonc');
|
||||
mkdirSync(target, { recursive: true });
|
||||
expect(() => atomicWriteTextFile(target, '{"x":1}')).toThrow();
|
||||
const litter = readdirSync(dir).filter((n) => n.includes('.tmp-'));
|
||||
expect(litter).toEqual([]);
|
||||
expect(lstatSync(target).isDirectory()).toBe(true); // target untouched
|
||||
});
|
||||
|
||||
test('a failing write in a read-only dir throws without leaving litter behind', () => {
|
||||
if (process.getuid?.() === 0) return; // root ignores modes
|
||||
const ro = join(dir, 'ro');
|
||||
mkdirSync(ro, { recursive: true });
|
||||
writeFileSync(join(ro, 'config.jsonc'), 'old');
|
||||
chmodSync(ro, 0o500);
|
||||
try {
|
||||
expect(() => atomicWriteTextFile(join(ro, 'config.jsonc'), 'new')).toThrow();
|
||||
} finally {
|
||||
chmodSync(ro, 0o700);
|
||||
}
|
||||
const litter = readdirSync(ro).filter((n) => n.includes('.tmp-'));
|
||||
expect(litter).toEqual([]);
|
||||
expect(readFileSync(join(ro, 'config.jsonc'), 'utf8')).toBe('old'); // original intact
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,17 @@ describe('bootstrap_harness_health (#4043)', () => {
|
||||
expect(c?.message).toMatch(/1 failed \/ 1 pending/);
|
||||
}, T);
|
||||
|
||||
test('host:opencode receipt flows through host-generic filtering — failed target still fails', async () => {
|
||||
const { parent, home } = makeHome();
|
||||
const receipt = harnessReceiptFixture([{ state: 'failed' }, { state: 'confirmed' }]) as Record<string, unknown>;
|
||||
for (const t of receipt.targets as Array<Record<string, unknown>>) t.host = 'opencode';
|
||||
writeHarnessReceipt(home, receipt as never);
|
||||
const checks = await run(parent);
|
||||
const c = byName(checks, 'bootstrap_harness_health');
|
||||
expect(c?.status).toBe('fail');
|
||||
expect(c?.message).toMatch(/1 failed/);
|
||||
}, T);
|
||||
|
||||
test('unconverged rotation (previous_id) → fail naming the revoke command', async () => {
|
||||
const { parent, home } = makeHome();
|
||||
const receipt = harnessReceiptFixture([{ state: 'confirmed' }]) as Record<string, unknown>;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -69,6 +69,7 @@ interface Fake {
|
||||
home: string;
|
||||
userSettings: string;
|
||||
codexConfig: string;
|
||||
opencodeConfig: string;
|
||||
}
|
||||
|
||||
function makeFake(opts: {
|
||||
@@ -84,6 +85,7 @@ function makeFake(opts: {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const userSettings = join(dir, 'claude-settings.json');
|
||||
const codexConfig = join(dir, 'codex-config.toml');
|
||||
const opencodeConfig = join(dir, 'opencode.jsonc');
|
||||
const calls: string[][] = [];
|
||||
const revoked: string[] = [];
|
||||
const mintCalls: Array<{ name: string; scopes: string[]; sourceGrant?: string[] }> = [];
|
||||
@@ -126,6 +128,7 @@ function makeFake(opts: {
|
||||
},
|
||||
userSettingsPath: userSettings,
|
||||
codexConfig,
|
||||
opencodeConfig,
|
||||
mint: async (o) => {
|
||||
mintCalls.push(o as { name: string; scopes: string[]; sourceGrant?: string[] });
|
||||
const m = mintQueue[Math.min(mintIdx++, mintQueue.length - 1)];
|
||||
@@ -138,11 +141,12 @@ function makeFake(opts: {
|
||||
pgliteLiveServe: () => opts.pgliteLive ?? false,
|
||||
detectClaude: () => true,
|
||||
detectCodex: () => true,
|
||||
detectOpencode: () => true,
|
||||
gbrainBin: '/opt/fake/gbrain',
|
||||
log: (l) => out.push(l),
|
||||
logError: (l) => err.push(l),
|
||||
};
|
||||
return { deps, calls, revoked, mintCalls, out, err, home, userSettings, codexConfig };
|
||||
return { deps, calls, revoked, mintCalls, out, err, home, userSettings, codexConfig, opencodeConfig };
|
||||
}
|
||||
|
||||
function flags(extra: string[] = []): HarnessFlags {
|
||||
@@ -197,6 +201,8 @@ describe('consent gate', () => {
|
||||
name: 'gbrain',
|
||||
userSettingsPath: '/u/settings.json',
|
||||
codexConfig: '/u/config.toml',
|
||||
wireOpencode: true,
|
||||
opencodeConfig: '/u/opencode.jsonc',
|
||||
});
|
||||
expect(block).toMatch(/Session-transcript capture: every Claude Code session/);
|
||||
expect(block).toMatch(/no-capture/);
|
||||
@@ -716,14 +722,42 @@ describe('outside-voice hardening (X-batch)', () => {
|
||||
name: 'gbrain',
|
||||
userSettingsPath: '/u/settings.json',
|
||||
codexConfig: '/u/config.toml',
|
||||
wireOpencode: false,
|
||||
opencodeConfig: '/u/opencode.jsonc',
|
||||
});
|
||||
expect(block).toMatch(/EVERY Codex session/);
|
||||
expect(block).not.toMatch(/EVERY Claude Code and Codex session/);
|
||||
expect(block).not.toMatch(/opencode/);
|
||||
expect(block).toMatch(/No hooks are wired by this invocation/);
|
||||
expect(block).toMatch(/written ONLY into the host registrations/);
|
||||
expect(block).not.toMatch(/never stored/);
|
||||
});
|
||||
|
||||
test('[X7] consent reach names opencode when (and only when) it is wired', () => {
|
||||
const block = buildConsentBlock({
|
||||
tokenName: 'bootstrap-harness',
|
||||
tokenSupplied: false,
|
||||
scopes: ['read', 'write'],
|
||||
url: URL,
|
||||
wireClaude: false,
|
||||
wireCodex: false,
|
||||
wireOpencode: true,
|
||||
hooks: false,
|
||||
capture: true,
|
||||
hookScope: 'user scope',
|
||||
name: 'gbrain',
|
||||
userSettingsPath: '/u/settings.json',
|
||||
codexConfig: '/u/config.toml',
|
||||
opencodeConfig: '/u/opencode.jsonc',
|
||||
});
|
||||
expect(block).toMatch(/EVERY opencode session/);
|
||||
expect(block).toMatch(/mcp\.gbrain remote entry with the bearer token INLINE/);
|
||||
expect(block).toMatch(/\{env:…\} interpolation would resolve empty/);
|
||||
expect(block).toMatch(/edit the opencode config/);
|
||||
expect(block).not.toMatch(/Claude Code session/);
|
||||
expect(block).not.toMatch(/Codex session/);
|
||||
});
|
||||
|
||||
test('[X8] a pre-existing permissions.allow entry is recorded as such and SURVIVES --remove', async () => {
|
||||
const f = makeFake();
|
||||
mkdirSync(join(f.userSettings, '..'), { recursive: true });
|
||||
@@ -852,3 +886,120 @@ describe('parse helpers', () => {
|
||||
expect(isServeOlderThanScopes('0.45.14')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencode harness target (managed JSONC entry)', () => {
|
||||
test('apply --harness opencode: inline-bearer remote entry written 0600, receipt confirmed, exit 0', async () => {
|
||||
const f = makeFake();
|
||||
expect(await applyHarness(flags(['--harness', 'opencode']), f.deps)).toBe(0);
|
||||
const text = readFileSync(f.opencodeConfig, 'utf8');
|
||||
const parsed = JSON.parse(text) as { mcp: Record<string, { type: string; url: string; headers: Record<string, string>; enabled: boolean } > };
|
||||
expect(parsed.mcp.gbrain.type).toBe('remote');
|
||||
expect(parsed.mcp.gbrain.url).toBe(URL);
|
||||
expect(parsed.mcp.gbrain.headers.Authorization).toBe(`Bearer ${TOKEN_A}`);
|
||||
expect(parsed.mcp.gbrain.enabled).toBe(true);
|
||||
expect(statSync(f.opencodeConfig).mode & 0o777).toBe(0o600);
|
||||
const state = readHarnessReceiptState(f.home);
|
||||
const receipt = (state as { receipt: { targets: Array<{ host: string; kind: string; state: string; mechanism?: string }> } }).receipt;
|
||||
const t = receipt.targets.find((x) => x.host === 'opencode');
|
||||
expect(t?.state).toBe('confirmed');
|
||||
expect(t?.mechanism).toBe('jsonc-entry');
|
||||
// No opencode CLI is ever execed — the writer IS the mechanism.
|
||||
expect(f.calls.some((argv) => argv[0] === 'opencode')).toBe(false);
|
||||
expect(f.out.join('\n')).toMatch(/opencode wired: mcp\.gbrain remote entry/);
|
||||
expect(f.out.join('\n')).toMatch(/Restart opencode/);
|
||||
});
|
||||
|
||||
test('rotation across a url change: the prior receipt url classifies the old entry as ours and it is replaced', async () => {
|
||||
const f = makeFake();
|
||||
expect(await applyHarness(flags(['--harness', 'opencode']), f.deps)).toBe(0);
|
||||
// Re-apply against a DIFFERENT serve url — the existing entry carries the
|
||||
// old url and must be recognized via the prior receipt, not refused.
|
||||
expect(await applyHarness(flags(['--harness', 'opencode', '--url', 'http://127.0.0.1:4242/mcp']), f.deps)).toBe(0);
|
||||
const parsed = JSON.parse(readFileSync(f.opencodeConfig, 'utf8')) as { mcp: Record<string, { url: string; headers: Record<string, string> }> };
|
||||
expect(parsed.mcp.gbrain.url).toBe('http://127.0.0.1:4242/mcp');
|
||||
expect(parsed.mcp.gbrain.headers.Authorization).toBe(`Bearer ${TOKEN_B}`); // rotated mint
|
||||
});
|
||||
|
||||
test('a FOREIGN mcp.gbrain entry refuses (failed target, exit 1) and survives untouched', async () => {
|
||||
const f = makeFake();
|
||||
const foreign = JSON.stringify({ mcp: { gbrain: { type: 'remote', url: 'https://other.example/mcp', headers: {} } } });
|
||||
writeFileSync(f.opencodeConfig, foreign);
|
||||
expect(await applyHarness(flags(['--harness', 'opencode']), f.deps)).toBe(1);
|
||||
expect(readFileSync(f.opencodeConfig, 'utf8')).toBe(foreign);
|
||||
expect(f.err.join('\n')).toMatch(/not a gbrain-managed entry/);
|
||||
// Impostor-guard economics hold: the fresh mint is revoked when wiring fails.
|
||||
const state = readHarnessReceiptState(f.home);
|
||||
const t = (state as { receipt: { targets: Array<{ host: string; state: string }> } }).receipt.targets.find((x) => x.host === 'opencode');
|
||||
expect(t?.state).toBe('failed');
|
||||
});
|
||||
|
||||
test('failed smoke rolls the fresh opencode entry back and revokes the fresh mint', async () => {
|
||||
const f = makeFake({ probeOk: false });
|
||||
expect(await applyHarness(flags(['--harness', 'opencode']), f.deps)).toBe(1);
|
||||
// Fresh add (no prior entry) → rollback removes it entirely.
|
||||
const parsed = JSON.parse(readFileSync(f.opencodeConfig, 'utf8')) as { mcp?: Record<string, unknown> };
|
||||
expect(parsed.mcp?.gbrain).toBeUndefined();
|
||||
expect(f.revoked).toContain(ID_A);
|
||||
expect(f.out.join('\n') + f.err.join('\n')).toMatch(/rolled back to the previous opencode config/);
|
||||
});
|
||||
|
||||
test('[X5] smoke failure on a RE-APPLY rolls the opencode config back byte-for-byte (codex-lane mirror)', async () => {
|
||||
const f = makeFake();
|
||||
expect(await applyHarness(flags(['--harness', 'opencode']), f.deps)).toBe(0); // entry with TOKEN_A
|
||||
const preRun = readFileSync(f.opencodeConfig, 'utf8');
|
||||
const f2deps: HarnessDeps = {
|
||||
...f.deps,
|
||||
probeIdentity: async () => ({ ok: false, reason: 'unreachable', message: 'boom' }),
|
||||
};
|
||||
expect(await applyHarness(flags(['--harness', 'opencode']), f2deps)).toBe(1); // mints B, smoke fails
|
||||
expect(readFileSync(f.opencodeConfig, 'utf8')).toBe(preRun); // TOKEN_A entry restored, byte-identical
|
||||
// The OLD token (ID_A) stays live and wired; the FRESH mint (ID_B) — sent
|
||||
// to the unverified endpoint — is retired immediately.
|
||||
expect(f.revoked).toEqual([ID_B]);
|
||||
});
|
||||
|
||||
test('a corrupt opencode config fails the target WITHOUT leaking the minted bearer (snippet placeholder + redaction)', async () => {
|
||||
const f = makeFake();
|
||||
writeFileSync(f.opencodeConfig, '{"mcp": {{{');
|
||||
expect(await applyHarness(flags(['--harness', 'opencode']), f.deps)).toBe(1);
|
||||
const err = f.err.join('\n');
|
||||
expect(err).toMatch(/does not parse as JSONC/);
|
||||
expect(err).not.toContain(TOKEN_A); // neither the snippet nor the message may carry the mint
|
||||
const state = readHarnessReceiptState(f.home);
|
||||
const t = (state as { receipt: { targets: Array<{ host: string; state: string; error?: string }> } }).receipt.targets.find(
|
||||
(x) => x.host === 'opencode',
|
||||
);
|
||||
expect(t?.state).toBe('failed');
|
||||
expect(t?.error ?? '').not.toContain(TOKEN_A); // the receipt is durable — no token in it either
|
||||
expect(readFileSync(f.opencodeConfig, 'utf8')).toBe('{"mcp": {{{'); // untouched
|
||||
});
|
||||
|
||||
test('--status recovers the bearer from the opencode entry (url-matched) and verifies it', async () => {
|
||||
const f = makeFake();
|
||||
expect(await applyHarness(flags(['--harness', 'opencode']), f.deps)).toBe(0);
|
||||
expect(await statusHarness(parseHarnessArgs(['--status']), f.deps)).toBe(0);
|
||||
const out = f.out.join('\n');
|
||||
expect(out).toMatch(/token: OK \('bootstrap-harness' via opencode config entry/);
|
||||
});
|
||||
|
||||
test('--remove removes OUR entry; an entry at a different url is skipped with a note', async () => {
|
||||
const f = makeFake();
|
||||
expect(await applyHarness(flags(['--harness', 'opencode']), f.deps)).toBe(0);
|
||||
expect(await removeHarness(parseHarnessArgs(['--remove']), f.deps)).toBe(0);
|
||||
const parsed = JSON.parse(readFileSync(f.opencodeConfig, 'utf8')) as { mcp?: Record<string, unknown> };
|
||||
expect(parsed.mcp?.gbrain).toBeUndefined();
|
||||
expect(f.out.join('\n')).toMatch(/opencode managed entry removed/);
|
||||
expect(f.revoked).toContain(ID_A);
|
||||
|
||||
// Second install; then the entry is retargeted by "another install".
|
||||
const g = makeFake();
|
||||
expect(await applyHarness(flags(['--harness', 'opencode']), g.deps)).toBe(0);
|
||||
const hijacked = JSON.parse(readFileSync(g.opencodeConfig, 'utf8')) as { mcp: Record<string, { url: string }> };
|
||||
hijacked.mcp.gbrain.url = 'http://127.0.0.1:9999/mcp';
|
||||
writeFileSync(g.opencodeConfig, JSON.stringify(hijacked));
|
||||
expect(await removeHarness(parseHarnessArgs(['--remove']), g.deps)).toBe(0);
|
||||
expect(g.out.join('\n')).toMatch(/does not match this receipt's url .* skipping/);
|
||||
const kept = JSON.parse(readFileSync(g.opencodeConfig, 'utf8')) as { mcp: Record<string, unknown> };
|
||||
expect(kept.mcp.gbrain).toBeDefined(); // never delete what is not provably ours
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* opencode door — workspace lane, end to end against the REAL runBootstrap
|
||||
* (engine-free; the config write is the direct JSONC writer, no opencode
|
||||
* binary involved). The load-bearing contracts:
|
||||
*
|
||||
* 1. detectHarness: opencode sets OPENCODE=1 (+OPENCODE_PID) in bash-tool
|
||||
* children (OPENCODE-CLI-PIN.md §Environment) — the probe must see it,
|
||||
* and claude-code/codex signals must win when both are present (their
|
||||
* paste-in flows run inside those harnesses).
|
||||
* 2. Scope inversion: with NO explicit MCP_SCOPE answer the registration is
|
||||
* USER-GLOBAL (opencode spawns project-config servers with no trust gate
|
||||
* — the committed-file scope is explicit-opt-in only). An explicit
|
||||
* 'project' answer writes the workspace opencode.json with a
|
||||
* PATH-resolved "gbrain" command (committed-candidate file: no absolute
|
||||
* machine paths) and prints the SHARING WARNING.
|
||||
* 3. Ownership: a remote-type mcp.gbrain in the global config (harness lane
|
||||
* or foreign) makes the stdio lane STEP ASIDE; a foreign local entry
|
||||
* refuses loudly.
|
||||
* 4. The rendered AGENTS.md pull protocol names opencode alongside Codex —
|
||||
* a hookless opencode agent's per-turn seam is that prose.
|
||||
*
|
||||
* Serial: mutates GBRAIN_HOME and XDG_CONFIG_HOME.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
detectHarness,
|
||||
runBootstrap,
|
||||
runOpencodeProbe,
|
||||
type OpencodeProbeSpawn,
|
||||
} from '../src/commands/bootstrap.ts';
|
||||
import type { ExecRunner } from '../src/core/bootstrap/repo.ts';
|
||||
import { readReceipt } from '../src/core/bootstrap/format.ts';
|
||||
import { opencodeGlobalConfigPath } from '../src/core/bootstrap/host-specs.ts';
|
||||
import { parseOpencodeConfig } from '../src/core/bootstrap/opencode-json.ts';
|
||||
import { initState, setAnswer, confirm, readBackHash } from '../src/core/bootstrap/interview.ts';
|
||||
|
||||
const REQUIRED_ANSWERS: Record<string, string> = {
|
||||
AGENT_NAME: 'Opencoder',
|
||||
PRINCIPAL_NAME: 'Alice Example',
|
||||
AGENT_PURPOSE: 'Maintain the research corpus and draft the weekly memo without re-briefing.',
|
||||
AGENT_TOP_JOBS: '- corpus upkeep\n- weekly memo\n- meeting prep',
|
||||
PRINCIPAL_CONTEXT: 'Runs a small research group; values signal over noise.',
|
||||
VOICE_REGISTER: 'Direct: three options, the second one wins.',
|
||||
};
|
||||
|
||||
let tmpParent: string;
|
||||
let home: string;
|
||||
let xdg: string;
|
||||
let prevHome: string | undefined;
|
||||
let prevXdg: string | undefined;
|
||||
const FAKE_BIN = '/opt/fake/bin/gbrain';
|
||||
|
||||
function makeRunner(): { runner: ExecRunner; calls: string[][] } {
|
||||
const calls: string[][] = [];
|
||||
const runner: ExecRunner = async (argv: string[]) => {
|
||||
calls.push(argv);
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
return { runner, calls };
|
||||
}
|
||||
|
||||
interface ProbeCall {
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string | undefined>;
|
||||
cwdWasEmptyDir: boolean;
|
||||
}
|
||||
|
||||
/** Fake for the probe-spawn seam. Empty stdout simulates opencode absent
|
||||
* (spawn throws → the probe maps it to the shell's 127); otherwise a
|
||||
* clean exit 0 with the given stdout. Captures argv + cwd + env. */
|
||||
function makeProbeSpawn(listStdout = ''): { spawn: OpencodeProbeSpawn; calls: ProbeCall[] } {
|
||||
const calls: ProbeCall[] = [];
|
||||
const spawn: OpencodeProbeSpawn = (argv, opts) => {
|
||||
calls.push({
|
||||
argv,
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
cwdWasEmptyDir: existsSync(opts.cwd) && readdirSync(opts.cwd).length === 0,
|
||||
});
|
||||
if (listStdout === '') throw new Error('Executable not found in $PATH: "opencode"');
|
||||
return {
|
||||
exited: Promise.resolve(0),
|
||||
kill: () => {},
|
||||
stdout: Promise.resolve(listStdout),
|
||||
stderr: Promise.resolve(''),
|
||||
};
|
||||
};
|
||||
return { spawn, calls };
|
||||
}
|
||||
|
||||
async function capture<T>(fn: () => Promise<T>): Promise<{ result: T; out: string; err: string }> {
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
let out = '';
|
||||
let err = '';
|
||||
console.log = (...args: unknown[]) => { out += args.map(String).join(' ') + '\n'; };
|
||||
console.error = (...args: unknown[]) => { err += args.map(String).join(' ') + '\n'; };
|
||||
try {
|
||||
const result = await fn();
|
||||
return { result, out, err };
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
}
|
||||
}
|
||||
|
||||
/** Interviewed + rendered workspace; optional explicit MCP_SCOPE answer. */
|
||||
async function readyWs(opts: { scope?: 'project' | 'user' } = {}): Promise<string> {
|
||||
const ws = mkdtempSync(join(tmpdir(), 'gb-opencode-door-ws-'));
|
||||
expect(initState(ws).ok).toBe(true);
|
||||
for (const [key, value] of Object.entries(REQUIRED_ANSWERS)) {
|
||||
const r = setAnswer(ws, key, value);
|
||||
if (!r.ok) throw new Error(r.message);
|
||||
}
|
||||
if (opts.scope) expect(setAnswer(ws, 'MCP_SCOPE', opts.scope).ok).toBe(true);
|
||||
const h = readBackHash(ws);
|
||||
if (!h.ok) throw new Error(h.message);
|
||||
expect(confirm(ws, h.hash).ok).toBe(true);
|
||||
const render = await capture(() => runBootstrap(['render', '--workspace', ws]));
|
||||
expect(render.result).toBe(0);
|
||||
return ws;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tmpParent = mkdtempSync(join(tmpdir(), 'gb-opencode-door-'));
|
||||
home = join(tmpParent, '.gbrain');
|
||||
mkdirSync(home, { recursive: true });
|
||||
xdg = join(tmpParent, 'xdg-config');
|
||||
prevHome = process.env.GBRAIN_HOME;
|
||||
prevXdg = process.env.XDG_CONFIG_HOME;
|
||||
process.env.GBRAIN_HOME = tmpParent;
|
||||
process.env.XDG_CONFIG_HOME = xdg;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (prevHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = prevHome;
|
||||
if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||
else process.env.XDG_CONFIG_HOME = prevXdg;
|
||||
rmSync(tmpParent, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('detectHarness — opencode env probe (OPENCODE-CLI-PIN.md §Environment)', () => {
|
||||
test('OPENCODE=1 and OPENCODE_PID each detect opencode', () => {
|
||||
expect(detectHarness({ OPENCODE: '1' })).toBe('opencode');
|
||||
expect(detectHarness({ OPENCODE_PID: '12345' })).toBe('opencode');
|
||||
});
|
||||
test('claude-code and codex signals win over opencode (nested-harness order)', () => {
|
||||
expect(detectHarness({ CLAUDECODE: '1', OPENCODE: '1' })).toBe('claude-code');
|
||||
expect(detectHarness({ CODEX_HOME: '/x', OPENCODE: '1' })).toBe('codex');
|
||||
});
|
||||
test('no signals → null', () => {
|
||||
expect(detectHarness({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('--harness flag validation', () => {
|
||||
test('unknown value names all three harnesses and exits 2', async () => {
|
||||
const ws = await readyWs();
|
||||
const r = await capture(() => runBootstrap(['hooks', '--workspace', ws, '--harness', 'grok']));
|
||||
expect(r.result).toBe(2);
|
||||
expect(r.err).toContain("unknown --harness 'grok'");
|
||||
expect(r.err).toContain('claude-code, codex, or opencode');
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencode workspace lane — default scope is USER-GLOBAL', () => {
|
||||
test('no explicit MCP_SCOPE → user-global config, absolute binary, rationale printed, receipt scope user', async () => {
|
||||
const ws = await readyWs();
|
||||
const { runner } = makeRunner();
|
||||
const probe = makeProbeSpawn(); // opencode absent → 127 branch
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], { runner, probeSpawn: probe.spawn }),
|
||||
);
|
||||
expect(r.result).toBe(0);
|
||||
|
||||
const cfgPath = opencodeGlobalConfigPath();
|
||||
expect(cfgPath.startsWith(join(xdg, 'opencode'))).toBe(true);
|
||||
const parsed = parseOpencodeConfig(readFileSync(cfgPath, 'utf8'), cfgPath);
|
||||
const entry = (parsed.mcp as Record<string, unknown>).gbrain as {
|
||||
type: string; command: string[]; environment: Record<string, string>; enabled: boolean;
|
||||
};
|
||||
expect(entry.type).toBe('local');
|
||||
expect(entry.command[0]).toBe(FAKE_BIN); // user scope: absolute path
|
||||
expect(entry.command).toContain('--surface');
|
||||
expect(entry.command).toContain('full');
|
||||
expect(entry.environment.GBRAIN_SOURCE).toBeDefined();
|
||||
expect(entry.enabled).toBe(true);
|
||||
|
||||
expect(r.out).toContain('scope: user-global');
|
||||
expect(r.out).toContain('scope defaulted to user-global');
|
||||
expect(r.out).toContain('restart opencode');
|
||||
// Pull protocol stated plainly, plugin lane named as a follow-up.
|
||||
expect(r.out).toContain('AGENTS.md');
|
||||
expect(r.out).toContain('plugin');
|
||||
|
||||
const receipt = readReceipt(home);
|
||||
const reg = receipt?.registrations.find((x) => x.host === 'opencode');
|
||||
expect(reg?.scope).toBe('user');
|
||||
expect(reg?.detail).toBe('mcp');
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('probe branches: ✓ connected logs the handshake; ✗ failed warns without failing the install', async () => {
|
||||
const okWs = await readyWs();
|
||||
const ok = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', okWs, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
probeSpawn: makeProbeSpawn('┌ MCP Servers\n✓ gbrain connected\n').spawn,
|
||||
}),
|
||||
);
|
||||
expect(ok.result).toBe(0);
|
||||
expect(ok.out).toContain('✓ gbrain connected');
|
||||
rmSync(okWs, { recursive: true, force: true });
|
||||
|
||||
const badWs = await readyWs();
|
||||
const bad = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', badWs, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
probeSpawn: makeProbeSpawn('┌ MCP Servers\n✗ gbrain failed\n Executable not found in $PATH\n').spawn,
|
||||
}),
|
||||
);
|
||||
expect(bad.result).toBe(0); // config parse-back is authoritative; the probe warns
|
||||
expect(bad.err).toContain('✗ gbrain failed');
|
||||
rmSync(badWs, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('probe hardening: ANSI-colored output still matches, and OPENCODE_DISABLE_AUTOUPDATE=1 rides the spawn env', async () => {
|
||||
const ws = await readyWs();
|
||||
const probe = makeProbeSpawn('\u001b[1m┌ MCP Servers\u001b[0m\n\u001b[32m✓\u001b[0m gbrain connected\n');
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
probeSpawn: probe.spawn,
|
||||
}),
|
||||
);
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('✓ gbrain connected'); // ANSI stripped before matching
|
||||
// The auto-updater kill rides the spawn's OWN env (no process.env staging).
|
||||
expect(probe.calls[0]?.env.OPENCODE_DISABLE_AUTOUPDATE).toBe('1');
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('probe hardening: a ✓ gbrain-remote row never false-positives the bare gbrain name', async () => {
|
||||
const ws = await readyWs();
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
probeSpawn: makeProbeSpawn('┌ MCP Servers\n✓ gbrain-remote connected\n✗ gbrain failed\n').spawn,
|
||||
}),
|
||||
);
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).not.toContain('✓ gbrain connected'); // gbrain-remote must NOT satisfy the success match
|
||||
expect(r.err).toContain('✗ gbrain failed');
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('SECURITY: the user-scope probe spawns from a fresh EMPTY temp dir, never the workspace cwd', async () => {
|
||||
const ws = await readyWs();
|
||||
const probe = makeProbeSpawn('┌ MCP Servers\n✓ gbrain connected\n');
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
probeSpawn: probe.spawn,
|
||||
}),
|
||||
);
|
||||
expect(r.result).toBe(0);
|
||||
expect(probe.calls.length).toBe(1);
|
||||
const call = probe.calls[0];
|
||||
expect(call.argv).toEqual(['opencode', 'mcp', 'list', '--pure']);
|
||||
// A hostile project opencode.json must never load into the probe:
|
||||
// opencode merges cwd config and spawns its servers with no trust gate.
|
||||
expect(call.cwd).not.toBe(ws);
|
||||
expect(call.cwd.startsWith(ws)).toBe(false);
|
||||
expect(call.cwd).not.toBe(process.cwd());
|
||||
expect(call.cwdWasEmptyDir).toBe(true);
|
||||
expect(existsSync(call.cwd)).toBe(false); // cleaned up after the probe
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('runOpencodeProbe timeout actually KILLS the child (SIGTERM then SIGKILL) and degrades to code 124', async () => {
|
||||
const kills: Array<boolean | undefined> = [];
|
||||
let resolveExit: (code: number) => void = () => {};
|
||||
const handle = {
|
||||
exited: new Promise<number>((res) => { resolveExit = res; }),
|
||||
kill: (force?: boolean) => {
|
||||
kills.push(force);
|
||||
if (force) resolveExit(137); // only SIGKILL lands — the child ignored SIGTERM
|
||||
},
|
||||
stdout: Promise.resolve(''),
|
||||
stderr: Promise.resolve(''),
|
||||
};
|
||||
const res = await runOpencodeProbe(['opencode', 'mcp', 'list', '--pure'], {
|
||||
cwd: tmpdir(),
|
||||
spawn: () => handle,
|
||||
timeoutMs: 50,
|
||||
});
|
||||
expect(kills).toEqual([undefined, true]); // graceful first, then SIGKILL after the grace window
|
||||
expect(res.code).toBe(124); // the existing could-not-confirm branch
|
||||
expect(res.stderr).toContain('timeout');
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe('opencode workspace lane — explicit project opt-in', () => {
|
||||
test('MCP_SCOPE=project → workspace opencode.json, PATH-resolved command, SHARING WARNING, live probe SKIPPED', async () => {
|
||||
const ws = await readyWs({ scope: 'project' });
|
||||
const { runner } = makeRunner();
|
||||
const probe = makeProbeSpawn('┌ MCP Servers\n✓ gbrain connected\n');
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner,
|
||||
probeSpawn: probe.spawn,
|
||||
}),
|
||||
);
|
||||
expect(r.result).toBe(0);
|
||||
|
||||
const cfgPath = join(ws, 'opencode.json');
|
||||
const parsed = parseOpencodeConfig(readFileSync(cfgPath, 'utf8'), cfgPath);
|
||||
const entry = (parsed.mcp as Record<string, unknown>).gbrain as { command: string[] };
|
||||
expect(entry.command[0]).toBe('gbrain'); // committed-candidate file: PATH-resolved, never absolute
|
||||
expect(r.out).toContain('project (explicit opt-in)');
|
||||
expect(r.err).toContain('SHARING WARNING');
|
||||
expect(r.err).toContain('"enabled": false');
|
||||
// GBRAIN_HOME is set in this suite → the committed-candidate entry embeds
|
||||
// a machine-specific path, and the warning must say so (portability).
|
||||
expect(r.err).toContain('GBRAIN_HOME');
|
||||
expect(r.err).toContain("won't be portable");
|
||||
|
||||
// SECURITY: project scope never runs the live probe — `opencode mcp list`
|
||||
// from this workspace would spawn the project config's servers with no
|
||||
// trust prompt. The skip note points the human at the manual check.
|
||||
expect(probe.calls.length).toBe(0);
|
||||
expect(r.out).toContain('probe skipped for project scope');
|
||||
expect(r.out).toContain('run `opencode mcp list` yourself');
|
||||
|
||||
const receipt = readReceipt(home);
|
||||
const regs = receipt?.registrations.filter((x) => x.host === 'opencode') ?? [];
|
||||
expect(regs.some((x) => x.scope === 'project')).toBe(true);
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("a whitespace-padded ' project ' MCP_SCOPE answer still resolves to project scope (trimmed)", async () => {
|
||||
const ws = await readyWs();
|
||||
// Hand-edit the recorded answer the way a user editing interview.json
|
||||
// might — set-time validation normally rejects padding, but the raw file
|
||||
// is user-editable and the scope switch must not silently fall through
|
||||
// to the user-global default.
|
||||
const statePath = join(ws, 'state', 'interview.json');
|
||||
const state = JSON.parse(readFileSync(statePath, 'utf8')) as { answers: Record<string, unknown> };
|
||||
state.answers.MCP_SCOPE = { value: ' project ' };
|
||||
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
||||
const probe = makeProbeSpawn('┌ MCP Servers\n✓ gbrain connected\n');
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
probeSpawn: probe.spawn,
|
||||
}),
|
||||
);
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('project (explicit opt-in)'); // NOT user-global
|
||||
expect(existsSync(join(ws, 'opencode.json'))).toBe(true);
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencode two-filename WRITE reconcile (sibling global file)', () => {
|
||||
test('a gbrain LOCAL entry in the SIBLING global file is cleaned when the write lands in the other (note printed)', async () => {
|
||||
const ws = await readyWs();
|
||||
const ocDir = join(xdg, 'opencode');
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
mkdirSync(ocDir, { recursive: true });
|
||||
// Ours-classified local entry (another workspace's) lives in opencode.json;
|
||||
// BOTH files exist so the resolver picks .jsonc for the write.
|
||||
writeFileSync(
|
||||
join(ocDir, 'opencode.json'),
|
||||
JSON.stringify({ mcp: { gbrain: { type: 'local', command: ['gbrain', 'serve'], environment: { GBRAIN_SOURCE: 'some-other-ws' } } } }),
|
||||
);
|
||||
writeFileSync(join(ocDir, 'opencode.jsonc'), '{}\n');
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
probeSpawn: makeProbeSpawn().spawn,
|
||||
}),
|
||||
);
|
||||
expect(r.result).toBe(0);
|
||||
// The write landed in .jsonc; the shadow entry in .json is GONE.
|
||||
const jsonc = parseOpencodeConfig(readFileSync(join(ocDir, 'opencode.jsonc'), 'utf8'), 'opencode.jsonc');
|
||||
expect((jsonc.mcp as Record<string, unknown>).gbrain).toBeDefined();
|
||||
const json = parseOpencodeConfig(readFileSync(join(ocDir, 'opencode.json'), 'utf8'), 'opencode.json');
|
||||
expect((json.mcp as Record<string, unknown> | undefined)?.gbrain).toBeUndefined();
|
||||
expect(r.err).toContain('opencode merges both global filenames');
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('a FOREIGN entry in the SIBLING global file refuses the write naming both files (exit 1, both untouched)', async () => {
|
||||
const ws = await readyWs();
|
||||
const ocDir = join(xdg, 'opencode');
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
mkdirSync(ocDir, { recursive: true });
|
||||
const foreign = JSON.stringify({ mcp: { gbrain: { type: 'local', command: ['npx', 'other-brain'], environment: {} } } });
|
||||
writeFileSync(join(ocDir, 'opencode.json'), foreign);
|
||||
writeFileSync(join(ocDir, 'opencode.jsonc'), '{}\n');
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
probeSpawn: makeProbeSpawn().spawn,
|
||||
}),
|
||||
);
|
||||
expect(r.result).toBe(1);
|
||||
expect(r.err).toContain('not a gbrain-managed entry');
|
||||
expect(r.err).toContain(join(ocDir, 'opencode.json'));
|
||||
expect(r.err).toContain(join(ocDir, 'opencode.jsonc'));
|
||||
expect(readFileSync(join(ocDir, 'opencode.json'), 'utf8')).toBe(foreign);
|
||||
expect(readFileSync(join(ocDir, 'opencode.jsonc'), 'utf8')).toBe('{}\n');
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencode ownership arbitration', () => {
|
||||
test('remote-type mcp.gbrain in the global config → stdio lane steps aside (exit 0, config untouched)', async () => {
|
||||
const ws = await readyWs();
|
||||
const cfgPath = join(xdg, 'opencode', 'opencode.jsonc');
|
||||
mkdirSync(join(xdg, 'opencode'), { recursive: true });
|
||||
const harnessOwned = JSON.stringify({
|
||||
mcp: { gbrain: { type: 'remote', url: 'http://127.0.0.1:7411/mcp', headers: { Authorization: 'Bearer tok' } } },
|
||||
});
|
||||
writeFileSync(cfgPath, harnessOwned);
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
}),
|
||||
);
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('bootstrap harness --remove');
|
||||
expect(readFileSync(cfgPath, 'utf8')).toBe(harnessOwned);
|
||||
rmSync(cfgPath, { force: true });
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('foreign local mcp.gbrain → refuse loudly (exit 1, config untouched)', async () => {
|
||||
const ws = await readyWs();
|
||||
const cfgPath = join(xdg, 'opencode', 'opencode.jsonc');
|
||||
mkdirSync(join(xdg, 'opencode'), { recursive: true });
|
||||
const foreign = JSON.stringify({ mcp: { gbrain: { type: 'local', command: ['npx', 'other-brain'], environment: {} } } });
|
||||
writeFileSync(cfgPath, foreign);
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
}),
|
||||
);
|
||||
expect(r.result).toBe(1);
|
||||
expect(r.err).toContain('not a gbrain-managed entry');
|
||||
expect(readFileSync(cfgPath, 'utf8')).toBe(foreign);
|
||||
rmSync(cfgPath, { force: true });
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('remote-type mcp.gbrain in the OTHER global filename also steps the stdio lane aside (merge blind spot)', async () => {
|
||||
// opencode merges opencode.json AND opencode.jsonc when both exist — a
|
||||
// harness-lane remote entry in opencode.json owns the name even when the
|
||||
// write resolver would pick opencode.jsonc.
|
||||
const ws = await readyWs();
|
||||
const ocDir = join(xdg, 'opencode');
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
mkdirSync(ocDir, { recursive: true });
|
||||
const harnessOwned = JSON.stringify({
|
||||
mcp: { gbrain: { type: 'remote', url: 'http://127.0.0.1:7411/mcp', headers: { Authorization: 'Bearer tok' } } },
|
||||
});
|
||||
writeFileSync(join(ocDir, 'opencode.json'), harnessOwned);
|
||||
writeFileSync(join(ocDir, 'opencode.jsonc'), '{}\n'); // resolver picks .jsonc — the gate must still see .json
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
}),
|
||||
);
|
||||
expect(r.result).toBe(0);
|
||||
expect(r.out).toContain('bootstrap harness --remove');
|
||||
expect(readFileSync(join(ocDir, 'opencode.json'), 'utf8')).toBe(harnessOwned);
|
||||
expect(readFileSync(join(ocDir, 'opencode.jsonc'), 'utf8')).toBe('{}\n'); // nothing written anywhere
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('corrupt global config: hooks lane exits 1 with the parse-refusal copy + snippet, file untouched', async () => {
|
||||
const ws = await readyWs();
|
||||
const ocDir = join(xdg, 'opencode');
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
mkdirSync(ocDir, { recursive: true });
|
||||
const cfgPath = join(ocDir, 'opencode.jsonc');
|
||||
writeFileSync(cfgPath, '{"mcp": {{{');
|
||||
const r = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
}),
|
||||
);
|
||||
expect(r.result).toBe(1);
|
||||
expect(r.err).toMatch(/does not parse as JSONC/);
|
||||
expect(r.err).toContain('add the entry by hand'); // the paste-by-hand refusal prose, never a stranding
|
||||
expect(readFileSync(cfgPath, 'utf8')).toBe('{"mcp": {{{'); // untouched
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencode uninstall sweep', () => {
|
||||
/** Workspace with an ISOLATED gbrain home INSIDE it (uninstall's HOME_GUARD
|
||||
* requires a contained, signature-bearing home when GBRAIN_HOME is set) —
|
||||
* interview + render run under that home so the receipt lands there. */
|
||||
async function isolatedWs(opts: { scope?: 'project' | 'user' } = {}): Promise<{ ws: string; isolatedHome: string }> {
|
||||
const ws = mkdtempSync(join(tmpdir(), 'gb-opencode-uninst-ws-'));
|
||||
process.env.GBRAIN_HOME = ws; // configDir() appends '.gbrain'
|
||||
const isolatedHome = join(ws, '.gbrain');
|
||||
mkdirSync(join(isolatedHome, 'brain.pglite'), { recursive: true });
|
||||
mkdirSync(join(isolatedHome, 'bootstrap'), { recursive: true });
|
||||
writeFileSync(join(isolatedHome, 'config.json'), '{"engine":"pglite"}');
|
||||
expect(initState(ws).ok).toBe(true);
|
||||
for (const [key, value] of Object.entries(REQUIRED_ANSWERS)) {
|
||||
const r = setAnswer(ws, key, value);
|
||||
if (!r.ok) throw new Error(r.message);
|
||||
}
|
||||
if (opts.scope) expect(setAnswer(ws, 'MCP_SCOPE', opts.scope).ok).toBe(true);
|
||||
const h = readBackHash(ws);
|
||||
if (!h.ok) throw new Error(h.message);
|
||||
expect(confirm(ws, h.hash).ok).toBe(true);
|
||||
const render = await capture(() => runBootstrap(['render', '--workspace', ws]));
|
||||
expect(render.result).toBe(0);
|
||||
return { ws, isolatedHome };
|
||||
}
|
||||
|
||||
function restoreSuiteHome(ws: string): void {
|
||||
process.env.GBRAIN_HOME = tmpParent;
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
test('user-scope install → uninstall removes mcp.gbrain from the global config — BOTH filenames swept even when resolution flips', async () => {
|
||||
const ocDir = join(xdg, 'opencode');
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
mkdirSync(ocDir, { recursive: true });
|
||||
// Only opencode.json exists at install time → the registration lands there.
|
||||
writeFileSync(join(ocDir, 'opencode.json'), '{}\n');
|
||||
const { ws, isolatedHome } = await isolatedWs();
|
||||
try {
|
||||
const hooks = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
probeSpawn: makeProbeSpawn().spawn,
|
||||
}),
|
||||
);
|
||||
expect(hooks.result).toBe(0);
|
||||
const installed = parseOpencodeConfig(readFileSync(join(ocDir, 'opencode.json'), 'utf8'), 'opencode.json');
|
||||
expect((installed.mcp as Record<string, unknown>).gbrain).toBeDefined();
|
||||
|
||||
// NOW an empty opencode.jsonc appears — the path resolver flips to it,
|
||||
// but opencode still merges BOTH files, so the sweep must hit .json too.
|
||||
writeFileSync(join(ocDir, 'opencode.jsonc'), '{}\n');
|
||||
|
||||
const uninst = await capture(() =>
|
||||
runBootstrap(['uninstall', '--workspace', ws, '--home', isolatedHome, '--yes'], {
|
||||
runner: makeRunner().runner,
|
||||
}),
|
||||
);
|
||||
expect(uninst.result).toBe(0);
|
||||
const after = parseOpencodeConfig(readFileSync(join(ocDir, 'opencode.json'), 'utf8'), 'opencode.json');
|
||||
expect((after.mcp as Record<string, unknown> | undefined)?.gbrain).toBeUndefined();
|
||||
expect(uninst.out).toContain('removed the gbrain opencode MCP entry');
|
||||
} finally {
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
restoreSuiteHome(ws);
|
||||
}
|
||||
});
|
||||
|
||||
test('project-scope install → uninstall sweeps the workspace opencode.json entry', async () => {
|
||||
const ocDir = join(xdg, 'opencode');
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
const { ws, isolatedHome } = await isolatedWs({ scope: 'project' });
|
||||
try {
|
||||
const hooks = await capture(() =>
|
||||
runBootstrap(['hooks', '--workspace', ws, '--harness', 'opencode', '--gbrain-bin', FAKE_BIN], {
|
||||
runner: makeRunner().runner,
|
||||
probeSpawn: makeProbeSpawn().spawn,
|
||||
}),
|
||||
);
|
||||
expect(hooks.result).toBe(0);
|
||||
const projPath = join(ws, 'opencode.json');
|
||||
expect((parseOpencodeConfig(readFileSync(projPath, 'utf8'), projPath).mcp as Record<string, unknown>).gbrain).toBeDefined();
|
||||
|
||||
const uninst = await capture(() =>
|
||||
runBootstrap(['uninstall', '--workspace', ws, '--home', isolatedHome, '--yes'], {
|
||||
runner: makeRunner().runner,
|
||||
}),
|
||||
);
|
||||
expect(uninst.result).toBe(0);
|
||||
const after = parseOpencodeConfig(readFileSync(projPath, 'utf8'), projPath);
|
||||
expect((after.mcp as Record<string, unknown> | undefined)?.gbrain).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(ocDir, { recursive: true, force: true });
|
||||
restoreSuiteHome(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencode door — rendered AGENTS.md pull protocol', () => {
|
||||
test('the hookless-harness gate names opencode alongside Codex, with the brain-first prose intact', async () => {
|
||||
const ws = await readyWs();
|
||||
const agents = readFileSync(join(ws, 'AGENTS.md'), 'utf8');
|
||||
expect(agents).toContain('Codex / opencode — pull protocol');
|
||||
expect(agents).toContain('Gate 3 — Entity lookup (brain first)');
|
||||
expect(agents).toContain('`recall` for hot');
|
||||
rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -64,9 +64,10 @@ describe('phase list is the single TS source of truth [D5]', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('wire hint carries the harness scope rule (Claude Code consent vs Codex user-global)', () => {
|
||||
test('wire hint carries the harness scope rule (Claude Code + opencode consent vs Codex user-global)', () => {
|
||||
const wire = PHASES.find((p) => p.id === 'wire');
|
||||
expect(wire?.resume_hint).toContain('MCP scope consent is Claude Code only');
|
||||
expect(wire?.resume_hint).toContain('MCP scope consent applies on Claude Code and opencode');
|
||||
expect(wire?.resume_hint).toContain('opencode defaults to user-global');
|
||||
expect(wire?.resume_hint).toContain('Codex registrations are always user-global (no scope flag)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ const BANK_JSON = JSON.stringify({
|
||||
MCP_SCOPE: {
|
||||
consent: true,
|
||||
phase: 'interview',
|
||||
question: '(Claude Code only. Codex has no scope flag.) Register for this folder or the whole machine?',
|
||||
question: '(Claude Code and opencode. Codex has no scope flag.) Register for this folder or the whole machine?',
|
||||
maxLength: 8,
|
||||
},
|
||||
UNUSED_OPTIONAL: { maxLength: 8 },
|
||||
@@ -75,7 +75,7 @@ const BANK_JSON = JSON.stringify({
|
||||
// Minimal runbook that satisfies the section (e) counter-signal pins; fixtures
|
||||
// exercising OTHER failure modes include it so they fail only for their own
|
||||
// reason.
|
||||
const RUNBOOK_PINS = 'Claude Code only\nDo NOT offer an MCP scope choice\n';
|
||||
const RUNBOOK_PINS = 'Claude Code and opencode\nDo NOT offer an MCP scope choice\nNO trust prompt\n';
|
||||
|
||||
describe('check-bootstrap-tag.sh', () => {
|
||||
test('exists and is executable', () => {
|
||||
@@ -307,7 +307,7 @@ describe('check-bootstrap-templates.sh', () => {
|
||||
'templates/bootstrap/questions.json': BANK_JSON,
|
||||
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n',
|
||||
'BOOTSTRAP_FOR_AGENTS.md':
|
||||
'<!-- gbrain-runbook-stamp: 1.2.3.4 -->\nClaude Code only\n(counter-signal deleted)\n',
|
||||
'<!-- gbrain-runbook-stamp: 1.2.3.4 -->\nClaude Code and opencode\nNO trust prompt\n(counter-signal deleted)\n',
|
||||
},
|
||||
(dir) => {
|
||||
const r = runGuard(TPL_GUARD, dir);
|
||||
@@ -317,18 +317,34 @@ describe('check-bootstrap-templates.sh', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("(e) fails when the runbook loses the 'Claude Code only' consent scoping", () => {
|
||||
test("(e) fails when the runbook loses the 'Claude Code and opencode' consent scoping", () => {
|
||||
withFixture(
|
||||
{
|
||||
'templates/bootstrap/questions.json': BANK_JSON,
|
||||
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n',
|
||||
'BOOTSTRAP_FOR_AGENTS.md':
|
||||
'<!-- gbrain-runbook-stamp: 1.2.3.4 -->\nDo NOT offer an MCP scope choice\n',
|
||||
'<!-- gbrain-runbook-stamp: 1.2.3.4 -->\nDo NOT offer an MCP scope choice\nNO trust prompt\n',
|
||||
},
|
||||
(dir) => {
|
||||
const r = runGuard(TPL_GUARD, dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain("'Claude Code only'");
|
||||
expect(r.out).toContain("'Claude Code and opencode'");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("(e) fails when the runbook loses the opencode 'no trust prompt' rationale", () => {
|
||||
withFixture(
|
||||
{
|
||||
'templates/bootstrap/questions.json': BANK_JSON,
|
||||
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n',
|
||||
'BOOTSTRAP_FOR_AGENTS.md':
|
||||
'<!-- gbrain-runbook-stamp: 1.2.3.4 -->\nClaude Code and opencode\nDo NOT offer an MCP scope choice\n',
|
||||
},
|
||||
(dir) => {
|
||||
const r = runGuard(TPL_GUARD, dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('spawn-gate rationale');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -343,7 +359,7 @@ describe('check-bootstrap-templates.sh', () => {
|
||||
(dir) => {
|
||||
const r = runGuard(TPL_GUARD, dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain("must start with '(Claude Code only'");
|
||||
expect(r.out).toContain("must start with '(Claude Code and opencode'");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -368,7 +384,7 @@ describe('check-bootstrap-templates.sh', () => {
|
||||
(dir) => {
|
||||
const r = runGuard(TPL_GUARD, dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain("must start with '(Claude Code only'");
|
||||
expect(r.out).toContain("must start with '(Claude Code and opencode'");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -385,7 +401,7 @@ describe('check-bootstrap-templates.sh', () => {
|
||||
MCP_SCOPE: {
|
||||
consent: true,
|
||||
phase: 'wire',
|
||||
question: '(Claude Code only. Codex has no scope flag.) Register for this folder or the whole machine?',
|
||||
question: '(Claude Code and opencode. Codex has no scope flag.) Register for this folder or the whole machine?',
|
||||
maxLength: 8,
|
||||
},
|
||||
},
|
||||
@@ -425,7 +441,7 @@ describe('check-bootstrap-templates.sh', () => {
|
||||
(dir) => {
|
||||
const r = runGuard(TPL_GUARD, dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain("must start with '(Claude Code only'");
|
||||
expect(r.out).toContain("must start with '(Claude Code and opencode'");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -685,3 +701,244 @@ describe('check-grok-pin.sh', () => {
|
||||
expect(pkg).toContain('"check:grok-pin": "bash scripts/check-grok-pin.sh"');
|
||||
});
|
||||
});
|
||||
|
||||
// ── check-opencode-pin.sh ────────────────────────────────────────────────────
|
||||
|
||||
const OPENCODE_PIN_GUARD = join(ROOT, 'scripts/check-opencode-pin.sh');
|
||||
|
||||
function runOpencodePinGuard(fixtureRoot: string): { status: number | null; out: string } {
|
||||
const r = spawnSync('bash', [OPENCODE_PIN_GUARD], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GBRAIN_OPENCODE_PIN_GUARD_ROOT: fixtureRoot },
|
||||
});
|
||||
return { status: r.status, out: `${r.stdout}\n${r.stderr}` };
|
||||
}
|
||||
|
||||
const OPENCODE_PIN_STAMPS_NPM = [
|
||||
'<!-- opencode-pin: distribution_kind=npm -->',
|
||||
'<!-- opencode-pin: npm_package=opencode-ai -->',
|
||||
'<!-- opencode-pin: npm_version=1.18.18 -->',
|
||||
'<!-- opencode-pin: npm_integrity=sha512-BBB= -->',
|
||||
'<!-- opencode-pin: opencode_version=1.18.18 -->',
|
||||
].join('\n');
|
||||
|
||||
function opencodeDoorWorkflow(envLines: string[]): string {
|
||||
return [
|
||||
'jobs:',
|
||||
' other-job:',
|
||||
' steps: []',
|
||||
' opencode-door:',
|
||||
' env:',
|
||||
...envLines.map((l) => ` ${l}`),
|
||||
' steps: []',
|
||||
' opencode-door-canary:',
|
||||
' env:',
|
||||
' GBRAIN_REAL_OPENCODE_E2E: "1"', // deliberately UNPINNED — must not satisfy the check
|
||||
' steps: []',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const OPENCODE_NPM_ENV_OK = [
|
||||
'OPENCODE_VERSION: "1.18.18"',
|
||||
'OPENCODE_NPM_PACKAGE: "opencode-ai"',
|
||||
'OPENCODE_NPM_INTEGRITY: "sha512-BBB="',
|
||||
];
|
||||
|
||||
describe('check-opencode-pin.sh', () => {
|
||||
test('ok: npm mode with matching workflow pins (anchored to the opencode-door block, canary leg ignored)', () => {
|
||||
withFixture({
|
||||
'docs/mcp/OPENCODE-CLI-PIN.md': `# pin\n${OPENCODE_PIN_STAMPS_NPM}\n`,
|
||||
'.github/workflows/heavy-tests.yml': opencodeDoorWorkflow(OPENCODE_NPM_ENV_OK),
|
||||
}, (dir) => {
|
||||
const r = runOpencodePinGuard(dir);
|
||||
expect(r.out).toContain('check-opencode-pin: ok');
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('SKIP-graceful pre-landing; FAIL-closed once the door job exists without the pin doc', () => {
|
||||
withFixture({
|
||||
'.github/workflows/heavy-tests.yml': opencodeDoorWorkflow(OPENCODE_NPM_ENV_OK),
|
||||
}, (dir) => {
|
||||
const r = runOpencodePinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('pin doc is the gate');
|
||||
});
|
||||
withFixture({
|
||||
'docs/mcp/OPENCODE-CLI-PIN.md': `# pin\n${OPENCODE_PIN_STAMPS_NPM}\n`,
|
||||
'.github/workflows/heavy-tests.yml': 'jobs:\n other-job:\n steps: []\n',
|
||||
}, (dir) => {
|
||||
const r = runOpencodePinGuard(dir);
|
||||
expect(r.out).toContain('SKIP');
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: workflow OPENCODE_VERSION drifts from the opencode_version stamp', () => {
|
||||
withFixture({
|
||||
'docs/mcp/OPENCODE-CLI-PIN.md': `# pin\n${OPENCODE_PIN_STAMPS_NPM}\n`,
|
||||
'.github/workflows/heavy-tests.yml': opencodeDoorWorkflow([
|
||||
'OPENCODE_VERSION: "1.18.19"',
|
||||
'OPENCODE_NPM_PACKAGE: "opencode-ai"',
|
||||
'OPENCODE_NPM_INTEGRITY: "sha512-BBB="',
|
||||
]),
|
||||
}, (dir) => {
|
||||
const r = runOpencodePinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('OPENCODE_VERSION drift');
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: npm_version stamp disagreeing with opencode_version can never pass green', () => {
|
||||
const stamps = OPENCODE_PIN_STAMPS_NPM.replace(
|
||||
'<!-- opencode-pin: npm_version=1.18.18 -->',
|
||||
'<!-- opencode-pin: npm_version=1.18.19 -->',
|
||||
);
|
||||
withFixture({
|
||||
'docs/mcp/OPENCODE-CLI-PIN.md': `# pin\n${stamps}\n`,
|
||||
'.github/workflows/heavy-tests.yml': opencodeDoorWorkflow(OPENCODE_NPM_ENV_OK),
|
||||
}, (dir) => {
|
||||
const r = runOpencodePinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('npm_version stamp');
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: linux platform-integrity stamps must match the job env (missing AND drifted)', () => {
|
||||
const stampsWithLinux = [
|
||||
OPENCODE_PIN_STAMPS_NPM,
|
||||
'<!-- opencode-pin: npm_linux_x64_integrity=sha512-X64= -->',
|
||||
'<!-- opencode-pin: npm_linux_arm64_integrity=sha512-ARM= -->',
|
||||
].join('\n');
|
||||
// Missing from the job env → fail.
|
||||
withFixture({
|
||||
'docs/mcp/OPENCODE-CLI-PIN.md': `# pin\n${stampsWithLinux}\n`,
|
||||
'.github/workflows/heavy-tests.yml': opencodeDoorWorkflow(OPENCODE_NPM_ENV_OK),
|
||||
}, (dir) => {
|
||||
const r = runOpencodePinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('OPENCODE_NPM_LINUX_X64_INTEGRITY');
|
||||
});
|
||||
// Present but drifted → fail.
|
||||
withFixture({
|
||||
'docs/mcp/OPENCODE-CLI-PIN.md': `# pin\n${stampsWithLinux}\n`,
|
||||
'.github/workflows/heavy-tests.yml': opencodeDoorWorkflow([
|
||||
...OPENCODE_NPM_ENV_OK,
|
||||
'OPENCODE_NPM_LINUX_X64_INTEGRITY: "sha512-X64="',
|
||||
'OPENCODE_NPM_LINUX_ARM64_INTEGRITY: "sha512-DRIFT="',
|
||||
]),
|
||||
}, (dir) => {
|
||||
const r = runOpencodePinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('drift');
|
||||
});
|
||||
// Both matching → ok.
|
||||
withFixture({
|
||||
'docs/mcp/OPENCODE-CLI-PIN.md': `# pin\n${stampsWithLinux}\n`,
|
||||
'.github/workflows/heavy-tests.yml': opencodeDoorWorkflow([
|
||||
...OPENCODE_NPM_ENV_OK,
|
||||
'OPENCODE_NPM_LINUX_X64_INTEGRITY: "sha512-X64="',
|
||||
'OPENCODE_NPM_LINUX_ARM64_INTEGRITY: "sha512-ARM="',
|
||||
]),
|
||||
}, (dir) => {
|
||||
const r = runOpencodePinGuard(dir);
|
||||
expect(r.out).toContain('check-opencode-pin: ok');
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: a second OPENCODE_VERSION copy elsewhere in the workflow disagreeing with the stamp', () => {
|
||||
// The real workflow carries a second OPENCODE_VERSION in the
|
||||
// real-agent-e2e job — every copy must move with the stamp.
|
||||
const workflow = [
|
||||
'jobs:',
|
||||
' real-agent-e2e:',
|
||||
' env:',
|
||||
' OPENCODE_VERSION: "1.18.19"', // drifted second copy
|
||||
' steps: []',
|
||||
' opencode-door:',
|
||||
' env:',
|
||||
...OPENCODE_NPM_ENV_OK.map((l) => ` ${l}`),
|
||||
' steps: []',
|
||||
'',
|
||||
].join('\n');
|
||||
withFixture({
|
||||
'docs/mcp/OPENCODE-CLI-PIN.md': `# pin\n${OPENCODE_PIN_STAMPS_NPM}\n`,
|
||||
'.github/workflows/heavy-tests.yml': workflow,
|
||||
}, (dir) => {
|
||||
const r = runOpencodePinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('every copy in the workflow moves with the stamp');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── check-pin-doc-privacy.sh ─────────────────────────────────────────────────
|
||||
|
||||
const PIN_PRIVACY_GUARD = join(ROOT, 'scripts/check-pin-doc-privacy.sh');
|
||||
|
||||
function runPinPrivacyGuard(fixtureRoot: string): { status: number | null; out: string } {
|
||||
const r = spawnSync('bash', [PIN_PRIVACY_GUARD], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GBRAIN_PIN_PRIVACY_GUARD_ROOT: fixtureRoot },
|
||||
});
|
||||
return { status: r.status, out: `${r.stdout}\n${r.stderr}` };
|
||||
}
|
||||
|
||||
describe('check-pin-doc-privacy.sh', () => {
|
||||
test('ok: placeholder-disciplined pin doc (tmp paths, sha512 pins, example.com emails)', () => {
|
||||
withFixture({
|
||||
'docs/mcp/FAKE-CLI-PIN.md': [
|
||||
'# fake pin',
|
||||
'<!-- fake-pin: npm_integrity=sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== -->',
|
||||
'Config lands at `<tmp>/.config/fake/config.json`; auth at `~/.local/share/fake/auth.json`.',
|
||||
'Support: docs@example.com; probe url https://fake.invalid/mcp.',
|
||||
].join('\n'),
|
||||
}, (dir) => {
|
||||
const r = runPinPrivacyGuard(dir);
|
||||
expect(r.out).toContain('check-pin-doc-privacy: ok');
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('SKIP-graceful when no pin docs exist', () => {
|
||||
withFixture({ 'docs/mcp/OTHER.md': '# not a pin doc\n' }, (dir) => {
|
||||
const r = runPinPrivacyGuard(dir);
|
||||
expect(r.out).toContain('SKIP');
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: operator home path in a verbatim transcript', () => {
|
||||
withFixture({
|
||||
'docs/mcp/FAKE-CLI-PIN.md': 'observed: wrote /Users/alicesmith/.config/fake/config.json\n',
|
||||
}, (dir) => {
|
||||
const r = runPinPrivacyGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('operator home path');
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: key-shaped material outside a sha512 integrity line', () => {
|
||||
withFixture({
|
||||
'docs/mcp/FAKE-CLI-PIN.md': `observed header: Bearer sk-${'a'.repeat(24)}\n`,
|
||||
}, (dir) => {
|
||||
const r = runPinPrivacyGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('key-shaped material');
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: a real-looking email address; example.com/invalid domains stay fine', () => {
|
||||
withFixture({
|
||||
'docs/mcp/FAKE-CLI-PIN.md': 'signed in as operator@realmail.com\n',
|
||||
}, (dir) => {
|
||||
const r = runPinPrivacyGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('email');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -605,3 +605,178 @@ describe('spawnWithCapture — stdin EOF (no payload)', () => {
|
||||
// module dropped its registrations. The HONEST integration check lives in
|
||||
// test/e2e/claw-test.test.ts ("--list-agents reports both built-in runners"),
|
||||
// which spawns the real CLI and asserts both runner lines.
|
||||
|
||||
describe('OpencodeRunner detection (reliable on box without opencode)', () => {
|
||||
test('detect returns the contract shape when OPENCODE_BIN unset', async () => {
|
||||
const orig = process.env.OPENCODE_BIN;
|
||||
delete process.env.OPENCODE_BIN;
|
||||
try {
|
||||
const { OpencodeRunner } = await import('../src/core/claw-test/runners/opencode.ts');
|
||||
const d = await new OpencodeRunner().detect();
|
||||
expect(typeof d.available).toBe('boolean');
|
||||
if (!d.available) expect(typeof d.reason).toBe('string');
|
||||
else expect(d.binPath?.startsWith('/')).toBe(true);
|
||||
} finally {
|
||||
if (orig !== undefined) process.env.OPENCODE_BIN = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('detect rejects relative / ..-segment / metachar OPENCODE_BIN (through-runner injection pin)', async () => {
|
||||
const orig = process.env.OPENCODE_BIN;
|
||||
const { OpencodeRunner } = await import('../src/core/claw-test/runners/opencode.ts');
|
||||
try {
|
||||
process.env.OPENCODE_BIN = 'relative/opencode';
|
||||
expect((await new OpencodeRunner().detect()).reason).toMatch(/OPENCODE_BIN must be absolute/);
|
||||
process.env.OPENCODE_BIN = '/tmp/foo/../opencode';
|
||||
expect((await new OpencodeRunner().detect()).reason).toMatch(/'\.\.' segments/);
|
||||
process.env.OPENCODE_BIN = '/tmp/open"code';
|
||||
expect((await new OpencodeRunner().detect()).reason).toMatch(/quotes, backslashes, dollar signs/);
|
||||
} finally {
|
||||
if (orig !== undefined) process.env.OPENCODE_BIN = orig;
|
||||
else delete process.env.OPENCODE_BIN;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpencodeRunner invoke argv/env (shim — no opencode binary needed)', () => {
|
||||
test('argv is the pinned run shape; multi-provider keys + XDG dirs propagate; unlisted env does not', async () => {
|
||||
const orig = {
|
||||
OPENCODE_BIN: process.env.OPENCODE_BIN,
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
||||
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE,
|
||||
OPENCODE_CONFIG_CONTENT: process.env.OPENCODE_CONFIG_CONTENT,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
XAI_API_KEY: process.env.XAI_API_KEY,
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
|
||||
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
|
||||
LEAK_CANARY: process.env.LEAK_CANARY,
|
||||
GBRAIN_DATABASE_URL: process.env.GBRAIN_DATABASE_URL,
|
||||
};
|
||||
const shim = join(tmp, 'opencode-shim');
|
||||
// The runner first execs the shim with --version (transcript preamble),
|
||||
// then spawns the real turn — the shim answers both. Bare-semver output
|
||||
// mirrors the SST CLI's real shape.
|
||||
writeFileSync(shim, '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "9.9.9"; exit 0; fi\nprintf "ARGV:%s\\n" "$@"\nprintf "XDGC:[%s] XDGD:[%s] AUP:[%s] INLINE:[%s] ANT:[%s] XAI:[%s] GGL:[%s] OR:[%s] CANARY:[%s] DBURL:[%s]\\n" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$OPENCODE_DISABLE_AUTOUPDATE" "$OPENCODE_CONFIG_CONTENT" "$ANTHROPIC_API_KEY" "$XAI_API_KEY" "$GOOGLE_GENERATIVE_AI_API_KEY" "$OPENROUTER_API_KEY" "$LEAK_CANARY" "$GBRAIN_DATABASE_URL"\n', 'utf-8');
|
||||
chmodSync(shim, 0o755);
|
||||
process.env.OPENCODE_BIN = shim;
|
||||
process.env.XDG_CONFIG_HOME = '/tmp/xdgc-canary';
|
||||
process.env.XDG_DATA_HOME = '/tmp/xdgd-canary';
|
||||
process.env.OPENCODE_DISABLE_AUTOUPDATE = '1';
|
||||
// The config-shadowing channel: deliberately NOT in the allowlist.
|
||||
process.env.OPENCODE_CONFIG_CONTENT = '{"mcp":{}}';
|
||||
// Multi-provider: opencode's headline feature — BASE carries only
|
||||
// Anthropic+OpenAI; the delta must name the rest (EV6) or a live-lane
|
||||
// operator on xai/google/openrouter models sees a misleading auth failure.
|
||||
process.env.ANTHROPIC_API_KEY = 'ant-sentinel-77aa';
|
||||
process.env.XAI_API_KEY = 'xai-sentinel-77bb';
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY = 'ggl-sentinel-77cc';
|
||||
process.env.OPENROUTER_API_KEY = 'or-sentinel-77dd';
|
||||
process.env.LEAK_CANARY = 'must-not-leak';
|
||||
process.env.GBRAIN_DATABASE_URL = 'postgres://must-not-leak';
|
||||
try {
|
||||
const { OpencodeRunner } = await import('../src/core/claw-test/runners/opencode.ts');
|
||||
const chunks: Buffer[] = [];
|
||||
const result = await new OpencodeRunner().invoke({
|
||||
cwd: tmp,
|
||||
brief: 'BRIEF BODY sentinel-4c2f',
|
||||
env: {},
|
||||
timeoutMs: 10_000,
|
||||
transcriptSink: {
|
||||
write: (e) => { if (e.channel === 'stdout') chunks.push(e.bytes); },
|
||||
nextOffset: () => 0,
|
||||
close: async () => {},
|
||||
},
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const stdout = Buffer.concat(chunks).toString('utf-8');
|
||||
expect(stdout).toContain('[opencode-runner preamble] version: 9.9.9');
|
||||
// Pinned argv: run + brief + explicit default format (an upstream
|
||||
// default flip must not silently change the transcript shape); no
|
||||
// --auto (MCP tools fire without it — OPENCODE-CLI-PIN.md §One-shot).
|
||||
expect(stdout).toContain('ARGV:run\nARGV:BRIEF BODY sentinel-4c2f\nARGV:--format\nARGV:default');
|
||||
expect(stdout).not.toContain('ARGV:--auto');
|
||||
// Allowlist held: XDG + autoupdate kill + all four provider deltas
|
||||
// pass; the inline-config shadow channel, the canary, and the
|
||||
// deliberately-delisted GBRAIN_DATABASE_URL do not.
|
||||
expect(stdout).toContain('XDGC:[/tmp/xdgc-canary]');
|
||||
expect(stdout).toContain('XDGD:[/tmp/xdgd-canary]');
|
||||
expect(stdout).toContain('AUP:[1]');
|
||||
expect(stdout).toContain('ANT:[ant-sentinel-77aa]');
|
||||
expect(stdout).toContain('XAI:[xai-sentinel-77bb]');
|
||||
expect(stdout).toContain('GGL:[ggl-sentinel-77cc]');
|
||||
expect(stdout).toContain('OR:[or-sentinel-77dd]');
|
||||
expect(stdout).toContain('INLINE:[] ');
|
||||
expect(stdout).toContain('CANARY:[] DBURL:[]');
|
||||
} finally {
|
||||
for (const [k, v] of Object.entries(orig)) {
|
||||
if (v !== undefined) process.env[k] = v;
|
||||
else delete process.env[k];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpencodeRunner global-config tripwire + preamble resilience (shim)', () => {
|
||||
async function invokeWithXdg(xdgConfig: string): Promise<{ warns: string[]; exitCode: number; stdout: string }> {
|
||||
const shim = join(tmp, 'opencode-shim-tripwire');
|
||||
writeFileSync(shim, '#!/bin/sh\nif [ "$1" = "--version" ]; then exit 1; fi\necho ok\n', 'utf-8');
|
||||
chmodSync(shim, 0o755);
|
||||
const origBin = process.env.OPENCODE_BIN;
|
||||
process.env.OPENCODE_BIN = shim;
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (m: unknown) => { warns.push(String(m)); };
|
||||
try {
|
||||
const { OpencodeRunner } = await import('../src/core/claw-test/runners/opencode.ts');
|
||||
const chunks: Buffer[] = [];
|
||||
const result = await new OpencodeRunner().invoke({
|
||||
cwd: tmp,
|
||||
brief: 'brief',
|
||||
env: { XDG_CONFIG_HOME: xdgConfig },
|
||||
timeoutMs: 10_000,
|
||||
transcriptSink: {
|
||||
write: (e) => { if (e.channel === 'stdout') chunks.push(e.bytes); },
|
||||
nextOffset: () => 0,
|
||||
close: async () => {},
|
||||
},
|
||||
});
|
||||
return { warns, exitCode: result.exitCode, stdout: Buffer.concat(chunks).toString('utf-8') };
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
if (origBin !== undefined) process.env.OPENCODE_BIN = origBin;
|
||||
else delete process.env.OPENCODE_BIN;
|
||||
}
|
||||
}
|
||||
|
||||
test('warns loudly when the global config carries mcp.gbrain (checks BOTH merged filenames, JSONC-tolerant)', async () => {
|
||||
const xdg = mkdtempSync(join(tmpdir(), 'opencode-vendor-'));
|
||||
try {
|
||||
mkdirSync(join(xdg, 'opencode'), { recursive: true });
|
||||
// .jsonc name + a comment: the tripwire must not require strict JSON.
|
||||
writeFileSync(
|
||||
join(xdg, 'opencode', 'opencode.jsonc'),
|
||||
'{\n // operator config\n "mcp": { "gbrain": { "type": "local", "command": ["gbrain", "serve"] } }\n}\n',
|
||||
);
|
||||
const r = await invokeWithXdg(xdg);
|
||||
expect(r.warns.some((w) => w.includes("OPERATOR'S REAL"))).toBe(true);
|
||||
// A failing --version preamble never fails the run.
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toContain('ok');
|
||||
} finally {
|
||||
rmSync(xdg, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('no warning for a gbrain-free global config', async () => {
|
||||
const xdg = mkdtempSync(join(tmpdir(), 'opencode-clean-'));
|
||||
try {
|
||||
mkdirSync(join(xdg, 'opencode'), { recursive: true });
|
||||
writeFileSync(join(xdg, 'opencode', 'opencode.json'), '{"mcp":{"other":{"type":"remote","url":"https://x/mcp"}}}');
|
||||
const r = await invokeWithXdg(xdg);
|
||||
expect(r.warns.length).toBe(0);
|
||||
} finally {
|
||||
rmSync(xdg, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+203
-3
@@ -1,4 +1,11 @@
|
||||
import { test, expect, describe } from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
parseOpencodeConfig,
|
||||
writeOpencodeMcpEntry,
|
||||
} from '../src/core/bootstrap/opencode-json.ts';
|
||||
import {
|
||||
normalizeMcpUrl,
|
||||
isLinkLocalOrMetadata,
|
||||
@@ -7,6 +14,7 @@ import {
|
||||
isValidName,
|
||||
buildClaudeMcpAddArgv,
|
||||
buildCodexMcpAddArgv,
|
||||
buildOpencodeMcpAddArgv,
|
||||
cmdString,
|
||||
redactToken,
|
||||
buildConnectBlock,
|
||||
@@ -499,6 +507,7 @@ function installDeps(over: Partial<ConnectDeps> = {}): ConnectDeps {
|
||||
probe: async () => ({ ok: true, identity: 'brain: alice-example' }),
|
||||
env: () => undefined, // tests control the env; real GBRAIN_REMOTE_TOKEN must not leak in
|
||||
registerOAuthClient: () => ({ ok: true, clientId: 'gbrain_cl_minted', clientSecret: 'gbrain_cs_minted' }),
|
||||
writeOpencodeRemoteEntry: (name) => ({ configPath: `/tmp/xdg/opencode/opencode.jsonc (${name})`, replacedPrior: false }),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -619,7 +628,7 @@ describe('runConnect --install', () => {
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/--install supports claude-code and codex/);
|
||||
expect(r.err.join('\n')).toMatch(/--install supports claude-code, codex, and opencode/);
|
||||
});
|
||||
|
||||
test('--install with --agent perplexity is rejected (GUI connector)', async () => {
|
||||
@@ -752,8 +761,8 @@ describe('runConnect print mode', () => {
|
||||
});
|
||||
|
||||
describe('AGENT_IDS', () => {
|
||||
test('exposes the four supported agents', () => {
|
||||
expect(AGENT_IDS).toEqual(['claude-code', 'codex', 'perplexity', 'generic']);
|
||||
test('exposes the five supported agents', () => {
|
||||
expect(AGENT_IDS).toEqual(['claude-code', 'codex', 'opencode', 'perplexity', 'generic']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -864,3 +873,194 @@ describe('runConnect --oauth', () => {
|
||||
expect(r.err.join('\n')).toMatch(/--install is not supported with --oauth/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencode lane', () => {
|
||||
test('buildOpencodeMcpAddArgv stores the literal {env:} interpolation — no token in argv', () => {
|
||||
const argv = buildOpencodeMcpAddArgv({ name: 'gbrain', url: 'https://h.example/mcp', envVar: 'GBRAIN_REMOTE_TOKEN' });
|
||||
expect(argv).toEqual([
|
||||
'mcp', 'add', 'gbrain',
|
||||
'--url', 'https://h.example/mcp',
|
||||
'--header', 'Authorization=Bearer {env:GBRAIN_REMOTE_TOKEN}',
|
||||
]);
|
||||
});
|
||||
|
||||
test('print block: export line + opencode mcp add one-liner + restart note; placeholder without token', async () => {
|
||||
const cap = captureConsole();
|
||||
try {
|
||||
await runConnect(['https://brain.example.com/mcp', '--agent', 'opencode'], installDeps());
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
const out = cap.out.join('\n');
|
||||
expect(out).toContain('# Paste into opencode:');
|
||||
expect(out).toContain('export GBRAIN_REMOTE_TOKEN=');
|
||||
expect(out).toContain('opencode mcp add gbrain --url https://brain.example.com/mcp --header');
|
||||
expect(out).toContain('{env:GBRAIN_REMOTE_TOKEN}');
|
||||
expect(out).toContain('Restart opencode');
|
||||
expect(out).toContain('<paste-your-token>');
|
||||
expect(out).toContain(LEARN_INSTRUCTION);
|
||||
});
|
||||
|
||||
test('--json: command is the safe opencode one-liner (interpolation, not the token)', async () => {
|
||||
const cap = captureConsole();
|
||||
try {
|
||||
await runConnect(['https://brain.example.com/mcp', '--agent', 'opencode', '--token', 'gbrain_tok', '--json'], installDeps());
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
const doc = JSON.parse(cap.out.join('\n')) as { agent: string; command: string; command_argv: string[]; token_redacted: boolean };
|
||||
expect(doc.agent).toBe('opencode');
|
||||
expect(doc.command).toContain('opencode mcp add gbrain');
|
||||
expect(doc.command).toContain('{env:GBRAIN_REMOTE_TOKEN}');
|
||||
expect(doc.command).not.toContain('gbrain_tok');
|
||||
expect(doc.token_redacted).toBe(true);
|
||||
});
|
||||
|
||||
test('--install writes through the injected deps member (no binary required) and smoke-tests', async () => {
|
||||
const writes: Array<{ name: string; url: string }> = [];
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--agent', 'opencode', '--install', '--yes'],
|
||||
installDeps({
|
||||
hasBinary: () => false, // opencode lane must not require any binary
|
||||
writeOpencodeRemoteEntry: (name, url) => {
|
||||
writes.push({ name, url });
|
||||
return { configPath: '/tmp/xdg/opencode/opencode.jsonc', replacedPrior: false };
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
expect(writes).toEqual([{ name: 'gbrain', url: 'https://brain.example.com/mcp' }]);
|
||||
const err = r.err.join('\n');
|
||||
expect(err).toMatch(/Added MCP entry 'gbrain'/);
|
||||
expect(err).toMatch(/Restart opencode/);
|
||||
expect(err).toMatch(/export GBRAIN_REMOTE_TOKEN/); // env not set in fixture → profile note
|
||||
expect(err).toMatch(/Verified/);
|
||||
});
|
||||
|
||||
test('--install surfaces a foreign-entry refusal from the writer, token-redacted', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_sekrit', '--agent', 'opencode', '--install', '--yes'],
|
||||
installDeps({
|
||||
writeOpencodeRemoteEntry: () => {
|
||||
throw new Error('mcp.gbrain in /cfg is not a gbrain-managed entry — refusing (token gbrain_sekrit should never print)');
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const all = [...r.out, ...r.err].join('\n');
|
||||
expect(all).toMatch(/not a gbrain-managed entry/);
|
||||
expect(all).not.toContain('gbrain_sekrit');
|
||||
});
|
||||
|
||||
test('--install probe failure warns + exit 1 (entry stays; agent will 401 until fixed)', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--agent', 'opencode', '--install', '--yes'],
|
||||
installDeps({ probe: async () => ({ ok: false, reason: 'auth', message: 'HTTP 401' }) }),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect([...r.out, ...r.err].join('\n')).toMatch(/did not verify \(auth\)/);
|
||||
});
|
||||
|
||||
test('url rotation WITHOUT --force → exit 1 with the caller-appropriate refusal (url + --force, no GBRAIN_SOURCE)', async () => {
|
||||
// Real-writer pass-through (mirrors the default deps minus the lock) so
|
||||
// the test pins the ACTUAL refusal text and the --force mapping.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gb-connect-ocforce-'));
|
||||
const cfg = join(dir, 'opencode.jsonc');
|
||||
try {
|
||||
// Seed: connect-lane entry ({env:} interpolation = ours) at the OLD url.
|
||||
writeOpencodeMcpEntry(cfg, { kind: 'remote', name: 'gbrain', url: 'https://old.example/mcp', tokenMode: 'env' });
|
||||
const writeThrough: ConnectDeps['writeOpencodeRemoteEntry'] = (name, url, o) => {
|
||||
const r = writeOpencodeMcpEntry(
|
||||
cfg,
|
||||
{ kind: 'remote', name, url, tokenMode: 'env' },
|
||||
{ expect: { url }, ...(o?.allowReplaceOtherSource ? { allowReplaceOtherSource: true } : {}) },
|
||||
);
|
||||
return { configPath: r.configPath, replacedPrior: r.replacedPrior };
|
||||
};
|
||||
const r1 = await runWithExitCapture(
|
||||
['https://new.example/mcp', '--token', 'gbrain_tok', '--agent', 'opencode', '--install', '--yes'],
|
||||
installDeps({ writeOpencodeRemoteEntry: writeThrough }),
|
||||
);
|
||||
expect(r1.exitCode).toBe(1);
|
||||
const all1 = [...r1.out, ...r1.err].join('\n');
|
||||
expect(all1).toContain('does not match this endpoint');
|
||||
expect(all1).toContain('--force');
|
||||
expect(all1).not.toContain('GBRAIN_SOURCE'); // remote path — no source involved
|
||||
// Old entry untouched.
|
||||
const before = parseOpencodeConfig(readFileSync(cfg, 'utf8'), cfg);
|
||||
expect(((before.mcp as Record<string, unknown>).gbrain as { url: string }).url).toBe('https://old.example/mcp');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('url rotation WITH --force → replaced, note printed', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gb-connect-ocforce2-'));
|
||||
const cfg = join(dir, 'opencode.jsonc');
|
||||
try {
|
||||
writeOpencodeMcpEntry(cfg, { kind: 'remote', name: 'gbrain', url: 'https://old.example/mcp', tokenMode: 'env' });
|
||||
const writeThrough: ConnectDeps['writeOpencodeRemoteEntry'] = (name, url, o) => {
|
||||
const r = writeOpencodeMcpEntry(
|
||||
cfg,
|
||||
{ kind: 'remote', name, url, tokenMode: 'env' },
|
||||
{ expect: { url }, ...(o?.allowReplaceOtherSource ? { allowReplaceOtherSource: true } : {}) },
|
||||
);
|
||||
return { configPath: r.configPath, replacedPrior: r.replacedPrior };
|
||||
};
|
||||
const r = await runWithExitCapture(
|
||||
['https://new.example/mcp', '--token', 'gbrain_tok', '--agent', 'opencode', '--install', '--yes', '--force'],
|
||||
installDeps({ writeOpencodeRemoteEntry: writeThrough }),
|
||||
);
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
expect(r.err.join('\n')).toContain('replaced the prior gbrain entry'); // the note
|
||||
const after = parseOpencodeConfig(readFileSync(cfg, 'utf8'), cfg);
|
||||
expect(((after.mcp as Record<string, unknown>).gbrain as { url: string }).url).toBe('https://new.example/mcp');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('--oauth is refused for opencode (bearer path only)', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--agent', 'opencode', '--oauth', '--register'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/connector-style agents/);
|
||||
});
|
||||
|
||||
test('--install non-TTY without --yes → exit 1, requires --yes, writer NEVER called', async () => {
|
||||
let called = false;
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--agent', 'opencode', '--install'],
|
||||
installDeps({
|
||||
isTTY: () => false,
|
||||
writeOpencodeRemoteEntry: () => {
|
||||
called = true;
|
||||
return { configPath: '/tmp/xdg/opencode/opencode.jsonc', replacedPrior: false };
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/requires --yes/);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
test('--install TTY prompt declined → Aborted, writer NEVER called', async () => {
|
||||
let called = false;
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--agent', 'opencode', '--install'],
|
||||
installDeps({
|
||||
isTTY: () => true,
|
||||
promptYesNo: async () => false,
|
||||
writeOpencodeRemoteEntry: () => {
|
||||
called = true;
|
||||
return { configPath: '/tmp/xdg/opencode/opencode.jsonc', replacedPrior: false };
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/Aborted/);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('gbrain claw-test --scenario fresh-install (scripted)', () => {
|
||||
});
|
||||
|
||||
describe('gbrain claw-test --list-agents', () => {
|
||||
test('reports all three built-in runners (available or not — both valid states)', () => {
|
||||
test('reports all four built-in runners (available or not — both valid states)', () => {
|
||||
// *_BIN vars point at a nonexistent path so the output shape is
|
||||
// deterministic regardless of what's installed on the box (detect
|
||||
// rejects a non-stat-able absolute path with a specific reason).
|
||||
@@ -134,6 +134,7 @@ describe('gbrain claw-test --list-agents', () => {
|
||||
HERMES_BIN: '/nonexistent/hermes',
|
||||
OPENCLAW_BIN: '/nonexistent/openclaw',
|
||||
GROK_BIN: '/nonexistent/grok',
|
||||
OPENCODE_BIN: '/nonexistent/opencode',
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 60_000,
|
||||
@@ -141,10 +142,13 @@ describe('gbrain claw-test --list-agents', () => {
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/^grok: unavailable: /m);
|
||||
expect(result.stdout).toMatch(/^hermes: unavailable: /m);
|
||||
expect(result.stdout).toMatch(/^opencode: unavailable: /m);
|
||||
expect(result.stdout).toMatch(/^openclaw: unavailable: /m);
|
||||
// Alphabetical print order (the awaited-detection fix pins this).
|
||||
// NB: 'openclaw' sorts BEFORE 'opencode' ('l' < 'o' at position 5).
|
||||
expect(result.stdout.indexOf('grok:')).toBeLessThan(result.stdout.indexOf('hermes:'));
|
||||
expect(result.stdout.indexOf('hermes:')).toBeLessThan(result.stdout.indexOf('openclaw:'));
|
||||
expect(result.stdout.indexOf('openclaw:')).toBeLessThan(result.stdout.indexOf('opencode:'));
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* install-real-opencode door e2e — drives the REAL `opencode` binary (SST,
|
||||
* opencode.ai) against THIS checkout's gbrain over stdio MCP.
|
||||
*
|
||||
* WHAT THIS PROVES: gbrain WIRED INTO opencode via the DOCUMENTED command
|
||||
* shape (`opencode mcp add gbrain --env … -- gbrain serve --surface verbs`,
|
||||
* bare command resolved via a PATH-staged bin dir), the direct-JSONC-writer
|
||||
* parity lane (gbrain's own opencode-json.ts output handshakes through the
|
||||
* real binary), and recall through MCP. Every asserted shape was observed
|
||||
* against v1.18.18 — see docs/mcp/OPENCODE-CLI-PIN.md; update that file, the
|
||||
* heavy-tests opencode-door pins, and these assertions together.
|
||||
*
|
||||
* SPLIT GATING (a step beyond the grok door, deliberate): opencode's
|
||||
* anonymous FREE TIER answers headless runs AND drives MCP tool calls with
|
||||
* zero credentials (observed — load-bearing), so even the nonce SMOKE runs
|
||||
* keyless. Two describes:
|
||||
* - keyless tier (T1 version pin, T2 INSTALL + list handshake, T2b
|
||||
* spawn-gate canary, T3 writer parity + preservation, T4 free-tier
|
||||
* SMOKE): GBRAIN_REAL_OPENCODE_E2E=1 + resolvable binary.
|
||||
* - paid tier (T5 anthropic leg): additionally hasOpencodeAuth()
|
||||
* (non-empty ANTHROPIC_API_KEY; blank CI secret ⇒ skip, never a paid
|
||||
* failure). Self-validating: the authed `opencode models` list must
|
||||
* carry the pinned model id BEFORE any spend.
|
||||
*
|
||||
* Isolation: every child gets HOME=<tmp> + XDG_CONFIG_HOME + XDG_DATA_HOME
|
||||
* under the tmp home (full XDG honoring verified on macOS) and an explicit
|
||||
* tmp cwd on EVERY spawn (a project opencode.json in the cwd spawns its
|
||||
* local servers with NO trust gate — observed; fresh cwds keep that channel
|
||||
* ours to control). A bounded tripwire hashes the operator's real
|
||||
* ~/.config/opencode configs + ~/.local/share/opencode/auth.json
|
||||
* before/after (NOT the volatile set — the opencode.db family plus the log
|
||||
* and repos dirs churn every run), and a checkout guard asserts no
|
||||
* opencode.json or .opencode/ appeared in the repo root.
|
||||
*
|
||||
* Observed-reality notes (docs/mcp/OPENCODE-CLI-PIN.md, v1.18.18):
|
||||
* - `opencode mcp add` is LAZY (exit 0, no spawn, even for a nonexistent
|
||||
* command) and ALWAYS writes the user-global opencode.jsonc.
|
||||
* - THE honest discriminator: `opencode mcp list` SPAWNS every configured
|
||||
* server — `✓ <name> connected` / `✗ <name> failed` per server — but
|
||||
* exits 0 REGARDLESS; the text is the assertion surface, never $?.
|
||||
* `mcp debug` is OAuth-only diagnostics (NOT a handshake probe).
|
||||
* - `--format json` events: {type, timestamp, sessionID, part} with
|
||||
* part.text (text) / part.tool + part.state (tool_use) —
|
||||
* parseOpencodeJsonl pins these; the SMOKE asserts tool mediation
|
||||
* STRUCTURALLY (a gbrain_* tool_use event), not just the nonce.
|
||||
* - autoupdate double kill: `"autoupdate": false` seed + the env var.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
resolveOpencodeBinary,
|
||||
hasOpencodeAuth,
|
||||
seedOpencodeConfig,
|
||||
opencodeChildEnv,
|
||||
opencodeOneShotTurn,
|
||||
parseOpencodeJsonl,
|
||||
hermeticChildEnv,
|
||||
stageGbrainBinDir,
|
||||
ensureCompiledGbrain,
|
||||
seedBrainForAgent,
|
||||
} from '../helpers/agent-harness.ts';
|
||||
import { writeOpencodeMcpEntry, parseOpencodeConfig } from '../../src/core/bootstrap/opencode-json.ts';
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, '..', '..');
|
||||
const CLI = join(REPO_ROOT, 'src', 'cli.ts');
|
||||
const OPENCODE_BIN = resolveOpencodeBinary();
|
||||
const CAN_RUN_KEYLESS = process.env.GBRAIN_REAL_OPENCODE_E2E === '1' && !!OPENCODE_BIN;
|
||||
const CAN_RUN_PAID = CAN_RUN_KEYLESS && hasOpencodeAuth();
|
||||
|
||||
/** Pinned anthropic model for the paid leg (provider/model, models.dev
|
||||
* convention). PROVISIONAL until the authed models list confirms it — the
|
||||
* T5 models-gate asserts presence BEFORE any spend and names the fix. */
|
||||
const PAID_MODEL = 'anthropic/claude-haiku-4-5';
|
||||
|
||||
if (!CAN_RUN_KEYLESS) {
|
||||
const why = process.env.GBRAIN_REAL_OPENCODE_E2E !== '1'
|
||||
? 'GBRAIN_REAL_OPENCODE_E2E is not 1 (explicit opt-in required)'
|
||||
: 'opencode binary not found';
|
||||
console.warn(`[install-real-opencode] SKIP (keyless tier): ${why}`);
|
||||
} else if (!CAN_RUN_PAID) {
|
||||
console.warn('[install-real-opencode] keyless tier runs; SKIP paid tier: no non-empty ANTHROPIC_API_KEY');
|
||||
}
|
||||
|
||||
const ENV_KEYS = [
|
||||
'GBRAIN_HOME', 'GBRAIN_DATABASE_URL', 'DATABASE_URL', 'GBRAIN_BRAIN_ID',
|
||||
'GBRAIN_SOURCE', 'GBRAIN_HOOKS',
|
||||
'XDG_CONFIG_HOME', 'XDG_DATA_HOME',
|
||||
'OPENCODE_CONFIG', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG_CONTENT',
|
||||
];
|
||||
const SAVED_ENV: Record<string, string | undefined> = {};
|
||||
|
||||
// Bounded tripwire over the operator's REAL opencode state: config +
|
||||
// credential files ONLY (opencode.db*/log/repos churn on every run —
|
||||
// volatile inventory per OPENCODE-CLI-PIN.md §Path seams).
|
||||
const REAL_OC_CONFIG_DIR = join(homedir(), '.config', 'opencode');
|
||||
const REAL_OC_AUTH = join(homedir(), '.local', 'share', 'opencode', 'auth.json');
|
||||
const TRIPWIRE_FILES = [
|
||||
join(REAL_OC_CONFIG_DIR, 'opencode.json'),
|
||||
join(REAL_OC_CONFIG_DIR, 'opencode.jsonc'),
|
||||
REAL_OC_AUTH,
|
||||
];
|
||||
let realManifestBefore: string | null = null;
|
||||
function opencodeStateManifest(): string | null {
|
||||
try {
|
||||
const h = createHash('sha256');
|
||||
for (const p of TRIPWIRE_FILES) {
|
||||
h.update(p);
|
||||
h.update(existsSync(p) ? readFileSync(p) : Buffer.from('<absent>'));
|
||||
}
|
||||
return h.digest('hex');
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Checkout guard: an opencode child whose cwd escaped to the repo root would
|
||||
// read (and a registration bug could write) a project opencode.json there.
|
||||
const CHECKOUT_MARKERS = [join(REPO_ROOT, 'opencode.json'), join(REPO_ROOT, '.opencode')];
|
||||
let checkoutMarkersBefore: boolean[] = [];
|
||||
|
||||
const EVIDENCE_DIR = process.env.GBRAIN_E2E_EVIDENCE_DIR;
|
||||
const createdHomes: { label: string; home: string }[] = [];
|
||||
function trackHome(label: string): string {
|
||||
const home = mkdtempSync(join(tmpdir(), `gb-opencode-${label}-`));
|
||||
createdHomes.push({ label, home });
|
||||
return home;
|
||||
}
|
||||
function copyEvidence(): void {
|
||||
if (!EVIDENCE_DIR) return;
|
||||
for (const { label, home } of createdHomes) {
|
||||
try {
|
||||
const dst = join(EVIDENCE_DIR, label);
|
||||
mkdirSync(dst, { recursive: true });
|
||||
// Copy-allowlist: configs + door transcripts only. auth.json is
|
||||
// defensively excluded (the paid leg is env-only, but a future login
|
||||
// flow must never land credentials in an artifact).
|
||||
for (const sub of ['.config/opencode/opencode.json', '.config/opencode/opencode.jsonc', 'door-turn.jsonl']) {
|
||||
const src = join(home, sub);
|
||||
if (existsSync(src)) {
|
||||
try { cpSync(src, join(dst, sub.replace(/\//g, '_')), { recursive: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
// Same content-grep the CI scrub applies: an opencode-written file
|
||||
// that embeds the key must never land in an artifact.
|
||||
const key = process.env.ANTHROPIC_API_KEY?.trim();
|
||||
if (key && key.length >= 8) {
|
||||
const walk = (d: string): string[] => {
|
||||
try {
|
||||
return require('node:fs').readdirSync(d, { withFileTypes: true }).flatMap((e: { name: string; isDirectory(): boolean; isFile(): boolean }) => {
|
||||
const p = join(d, e.name);
|
||||
return e.isDirectory() ? walk(p) : e.isFile() ? [p] : [];
|
||||
});
|
||||
} catch { return []; }
|
||||
};
|
||||
for (const f of walk(dst)) {
|
||||
try {
|
||||
if (readFileSync(f, 'utf8').includes(key)) rmSync(f, { force: true });
|
||||
} catch { /* unreadable → leave */ }
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
for (const k of ENV_KEYS) SAVED_ENV[k] = process.env[k];
|
||||
for (const k of ENV_KEYS) delete process.env[k];
|
||||
realManifestBefore = opencodeStateManifest();
|
||||
checkoutMarkersBefore = CHECKOUT_MARKERS.map((p) => existsSync(p));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
copyEvidence();
|
||||
for (const { home } of createdHomes) {
|
||||
try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
for (const k of ENV_KEYS) {
|
||||
if (SAVED_ENV[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = SAVED_ENV[k];
|
||||
}
|
||||
// Tripwires LAST — scream, don't silently mutate the operator's agent.
|
||||
const after = opencodeStateManifest();
|
||||
if (realManifestBefore !== after) {
|
||||
throw new Error(
|
||||
"HERMETICITY BREACH: the operator's real opencode config/credential files changed during " +
|
||||
'the door run — XDG isolation failed; investigate before trusting this suite again. ' +
|
||||
'(Volatile paths are excluded from this manifest; a fire means a config or auth.json moved.)',
|
||||
);
|
||||
}
|
||||
const markersAfter = CHECKOUT_MARKERS.map((p) => existsSync(p));
|
||||
for (let i = 0; i < CHECKOUT_MARKERS.length; i++) {
|
||||
if (markersAfter[i] && !checkoutMarkersBefore[i]) {
|
||||
throw new Error(`HERMETICITY BREACH: ${CHECKOUT_MARKERS[i]} appeared in the checkout during the door run.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Strip ANSI + clack UI glyphs so text assertions see plain content
|
||||
* (`mcp list` output is a styled tree — observed). */
|
||||
function plain(s: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
/** Run the real opencode binary under a hermetic home with a REQUIRED tmp
|
||||
* cwd (never the checkout: the project-config channel). `--pure` on every
|
||||
* non-turn spawn: `mcp list` autoloads external plugins otherwise (it is a
|
||||
* code-execution surface — OPENCODE-CLI-PIN.md §Probes). */
|
||||
function runOpencode(
|
||||
home: string,
|
||||
cwd: string,
|
||||
argv: string[],
|
||||
binDir?: string,
|
||||
): { code: number | null; stdout: string; stderr: string } {
|
||||
// `--pure` must land on OPENCODE's argv, never inside a `--`-delimited
|
||||
// server command (a trailing append after `mcp add … -- gbrain serve`
|
||||
// registers `--pure` as a gbrain flag — caught live).
|
||||
const sep = argv.indexOf('--');
|
||||
const withPure = sep === -1 ? [...argv, '--pure'] : [...argv.slice(0, sep), '--pure', ...argv.slice(sep)];
|
||||
const res = spawnSync(OPENCODE_BIN!, withPure, {
|
||||
cwd,
|
||||
env: opencodeChildEnv(home, binDir ? { binDir } : undefined),
|
||||
encoding: 'utf8',
|
||||
timeout: 180_000,
|
||||
});
|
||||
return { code: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
|
||||
}
|
||||
|
||||
/** Keyless PGLite brain init (compiled binary preferred, bun-run fallback). */
|
||||
function initBrain(home: string): { code: number | null; stderr: string } {
|
||||
const { binPath } = ensureCompiledGbrain(REPO_ROOT);
|
||||
const argv = binPath
|
||||
? [binPath, 'init', '--pglite', '--no-embedding', '--non-interactive']
|
||||
: ['bun', 'run', CLI, 'init', '--pglite', '--no-embedding', '--non-interactive'];
|
||||
const res = spawnSync(argv[0], argv.slice(1), {
|
||||
cwd: REPO_ROOT,
|
||||
env: hermeticChildEnv({ HOME: home, GBRAIN_HOME: home, GBRAIN_SKIP_STARTUP_HOOKS: '1' }),
|
||||
encoding: 'utf8',
|
||||
timeout: 180_000,
|
||||
});
|
||||
return { code: res.status, stderr: `${res.stdout ?? ''}\n${res.stderr ?? ''}` };
|
||||
}
|
||||
|
||||
/** Documented registration shape (OPENCODE.md): bare `gbrain` via staged
|
||||
* PATH; `--env` repeatable; the `-- command` form (real but absent from
|
||||
* --help — observed). */
|
||||
function registerGbrainIntoOpencode(
|
||||
home: string,
|
||||
cwd: string,
|
||||
binDir: string,
|
||||
gbrainHome: string,
|
||||
sourceId: string,
|
||||
): { code: number | null; stdout: string; stderr: string } {
|
||||
return runOpencode(home, cwd, [
|
||||
'mcp', 'add', 'gbrain',
|
||||
'--env', `GBRAIN_HOME=${gbrainHome}`,
|
||||
'--env', `GBRAIN_SOURCE=${sourceId}`,
|
||||
'--', 'gbrain', 'serve', '--surface', 'verbs',
|
||||
], binDir);
|
||||
}
|
||||
|
||||
/** THE honest discriminator: `mcp list` spawns every server; parse the text
|
||||
* (exit 0 even on failure — observed). */
|
||||
function listProbe(home: string, cwd: string, binDir?: string): { code: number | null; text: string } {
|
||||
const res = runOpencode(home, cwd, ['mcp', 'list'], binDir);
|
||||
return { code: res.code, text: plain(`${res.stdout}\n${res.stderr}`) };
|
||||
}
|
||||
|
||||
function readGlobalConfig(home: string): Record<string, unknown> {
|
||||
const dir = join(home, '.config', 'opencode');
|
||||
for (const f of ['opencode.jsonc', 'opencode.json']) {
|
||||
const p = join(dir, f);
|
||||
if (existsSync(p)) return parseOpencodeConfig(readFileSync(p, 'utf-8'), p);
|
||||
}
|
||||
throw new Error(`no opencode config found under ${dir}`);
|
||||
}
|
||||
|
||||
describe.skipIf(!CAN_RUN_KEYLESS)('install real-opencode door — keyless tier (serial e2e)', () => {
|
||||
test('T1 version pin: bare semver; matches OPENCODE_VERSION when the CI pin is set', () => {
|
||||
const pinned = process.env.OPENCODE_VERSION;
|
||||
const home = trackHome('ver');
|
||||
seedOpencodeConfig(home);
|
||||
const res = runOpencode(home, home, ['--version']);
|
||||
expect(res.code).toBe(0);
|
||||
const version = res.stdout.trim();
|
||||
// BARE semver — no binary name, no build hash (the SST-vs-claimant
|
||||
// discriminator: colliding `opencode` binaries answer differently).
|
||||
expect(version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
if (pinned) expect(version).toBe(pinned);
|
||||
}, 120_000);
|
||||
|
||||
test('T2 INSTALL: keyless init → documented-shape `opencode mcp add` → JSONC carries entry + env → `mcp list` handshakes ✓ connected', () => {
|
||||
const home = trackHome('install');
|
||||
seedOpencodeConfig(home);
|
||||
const ws = join(home, 'ws');
|
||||
mkdirSync(ws, { recursive: true });
|
||||
|
||||
const init = initBrain(home);
|
||||
expect(init.code).toBe(0);
|
||||
expect(existsSync(join(home, '.gbrain', 'brain.pglite'))).toBe(true);
|
||||
|
||||
const binDir = join(home, 'staged-bin');
|
||||
const staged = stageGbrainBinDir(REPO_ROOT, binDir);
|
||||
console.warn(`[install-real-opencode] staged gbrain kind=${staged.kind}`);
|
||||
|
||||
const add = registerGbrainIntoOpencode(home, ws, binDir, home, 'default');
|
||||
// Add is LAZY (observed): exit 0 proves only that the config was
|
||||
// written. The handshake proof is `mcp list` below.
|
||||
expect(add.code).toBe(0);
|
||||
|
||||
const cfg = readGlobalConfig(home);
|
||||
const mcp = cfg.mcp as Record<string, { type?: string; command?: string[]; environment?: Record<string, string> }>;
|
||||
expect(mcp.gbrain).toBeDefined();
|
||||
expect(mcp.gbrain.type).toBe('local');
|
||||
expect(mcp.gbrain.command).toEqual(['gbrain', 'serve', '--surface', 'verbs']);
|
||||
expect(mcp.gbrain.environment?.GBRAIN_HOME).toBe(home);
|
||||
expect(mcp.gbrain.environment?.GBRAIN_SOURCE).toBe('default');
|
||||
// Seeded kill-switch survived registration (observed: add rewrites via a
|
||||
// comment/key-preserving editor).
|
||||
expect(cfg.autoupdate).toBe(false);
|
||||
|
||||
const probe = listProbe(home, ws, binDir);
|
||||
expect(probe.code).toBe(0);
|
||||
expect(probe.text).toMatch(/✓\s+gbrain\s+connected/);
|
||||
expect(probe.text).not.toMatch(/✗\s+gbrain/);
|
||||
}, 300_000);
|
||||
|
||||
test('T2b spawn-gate canary: a PROJECT-config local server is spawn-attempted with NO trust prompt (the scope-inversion fact)', () => {
|
||||
const home = trackHome('gate');
|
||||
seedOpencodeConfig(home);
|
||||
const ws = join(home, 'ws');
|
||||
mkdirSync(ws, { recursive: true });
|
||||
// Project-scope decoy with a nonexistent command: if opencode still has
|
||||
// no trust gate (observed 1.18.18), `mcp list` ATTEMPTS the spawn and
|
||||
// reports ✗ failed. If this ever flips to a gated/untrusted state,
|
||||
// gbrain's user-global-default rationale changed — re-observe and
|
||||
// revisit the bootstrap scope default (OPENCODE-CLI-PIN.md §Probes).
|
||||
writeFileSync(
|
||||
join(ws, 'opencode.json'),
|
||||
JSON.stringify({ mcp: { 'gb-door-decoy': { type: 'local', command: ['/nonexistent/gb-door-decoy-bin'] } } }),
|
||||
);
|
||||
const probe = listProbe(home, ws);
|
||||
expect(probe.code).toBe(0);
|
||||
expect(probe.text).toContain('gb-door-decoy');
|
||||
expect(probe.text).toMatch(/✗\s+gb-door-decoy\s+failed/);
|
||||
}, 180_000);
|
||||
|
||||
test('T3 writer parity: gbrain\'s direct JSONC writer output handshakes through the real binary; cross-tool preservation both ways', () => {
|
||||
const home = trackHome('writer');
|
||||
seedOpencodeConfig(home);
|
||||
const ws = join(home, 'ws');
|
||||
mkdirSync(ws, { recursive: true });
|
||||
|
||||
const init = initBrain(home);
|
||||
expect(init.code).toBe(0);
|
||||
|
||||
const binDir = join(home, 'staged-bin');
|
||||
stageGbrainBinDir(REPO_ROOT, binDir);
|
||||
|
||||
// The bootstrap workspace lane's exact write path: PATH-resolved command
|
||||
// (the committed-candidate posture) + env binding, into the config file
|
||||
// the real binary then reads.
|
||||
const cfgPath = join(home, '.config', 'opencode', 'opencode.json');
|
||||
const w = writeOpencodeMcpEntry(cfgPath, {
|
||||
kind: 'local',
|
||||
name: 'gbrain',
|
||||
command: ['gbrain', 'serve', '--surface', 'verbs'],
|
||||
environment: { GBRAIN_HOME: home, GBRAIN_SOURCE: 'default' },
|
||||
}, { expect: { sourceId: 'default' } });
|
||||
expect(w.replacedPrior).toBe(false);
|
||||
|
||||
const probe = listProbe(home, ws, binDir);
|
||||
expect(probe.text).toMatch(/✓\s+gbrain\s+connected/);
|
||||
|
||||
// Cross-tool preservation: a subsequent CLI `mcp add` of another server
|
||||
// must not clobber the writer's entry or the autoupdate seed…
|
||||
const add2 = runOpencode(home, ws, ['mcp', 'add', 'door-second', '--url', 'https://door-second.invalid/mcp']);
|
||||
expect(add2.code).toBe(0);
|
||||
const cfg = readGlobalConfig(home);
|
||||
const mcp = cfg.mcp as Record<string, { command?: string[] }>;
|
||||
expect(mcp.gbrain?.command).toEqual(['gbrain', 'serve', '--surface', 'verbs']);
|
||||
expect((cfg.autoupdate as boolean)).toBe(false);
|
||||
// …and the writer must preserve the CLI-written entry in return.
|
||||
writeOpencodeMcpEntry(cfgPath, {
|
||||
kind: 'local',
|
||||
name: 'gbrain',
|
||||
command: ['gbrain', 'serve', '--surface', 'verbs'],
|
||||
environment: { GBRAIN_HOME: home, GBRAIN_SOURCE: 'default' },
|
||||
}, { expect: { sourceId: 'default' } });
|
||||
const cfg2 = readGlobalConfig(home);
|
||||
expect((cfg2.mcp as Record<string, unknown>)['door-second']).toBeDefined();
|
||||
}, 300_000);
|
||||
|
||||
test('T4 SMOKE (keyless free tier): per-run nonce recalled via a STRUCTURAL gbrain tool_use event', async () => {
|
||||
const home = trackHome('smoke');
|
||||
seedOpencodeConfig(home);
|
||||
const ws = join(home, 'ws');
|
||||
mkdirSync(ws, { recursive: true });
|
||||
|
||||
// Per-run nonce: opencode has fs/shell tools, so a committed fixture
|
||||
// string would be greppable from the checkout — recall of a nonce
|
||||
// seeded into THIS run's brain proves tool mediation, not memory.
|
||||
const nonce = `kestrel-${Math.random().toString(16).slice(2, 10)}`;
|
||||
await seedBrainForAgent(home, 'default', {
|
||||
entity: 'Door Probe',
|
||||
fact: `The secret door codeword is ${nonce}.`,
|
||||
query: 'What is the secret door codeword?',
|
||||
slug: 'facts/door-probe',
|
||||
});
|
||||
|
||||
const binDir = join(home, 'staged-bin');
|
||||
stageGbrainBinDir(REPO_ROOT, binDir);
|
||||
const add = registerGbrainIntoOpencode(home, ws, binDir, home, 'default');
|
||||
expect(add.code).toBe(0);
|
||||
|
||||
// list preflight gates the turn — never burn a model turn (even a free
|
||||
// one) on a broken registration.
|
||||
const pre = listProbe(home, ws, binDir);
|
||||
expect(pre.text).toMatch(/✓\s+gbrain\s+connected/);
|
||||
|
||||
const prompt =
|
||||
'You have gbrain memory tools available over MCP. Use them to find the secret door ' +
|
||||
'codeword (search or recall: door codeword). Reply with ONLY the codeword string. ' +
|
||||
'If you have no gbrain tools at all, reply exactly: NO-GBRAIN-TOOL';
|
||||
|
||||
// Two attempts with backoff: the free tier rides opencode's gateway and
|
||||
// a transient 5xx must not red the door; asserts NEVER soften.
|
||||
let finalText = '';
|
||||
let toolCalls: string[] = [];
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
const turn = await opencodeOneShotTurn({
|
||||
prompt,
|
||||
cwd: ws,
|
||||
home,
|
||||
timeoutMs: 240_000,
|
||||
format: 'json',
|
||||
binDir,
|
||||
});
|
||||
const parsed = parseOpencodeJsonl(turn.finalText.split('\n'));
|
||||
finalText = parsed.finalText;
|
||||
toolCalls = parsed.toolCalls;
|
||||
try { writeFileSync(join(home, 'door-turn.jsonl'), turn.finalText); } catch { /* evidence */ }
|
||||
if (turn.exitCode === 0 && finalText.includes(nonce)) break;
|
||||
if (attempt === 1) await new Promise((r) => setTimeout(r, 3_000));
|
||||
}
|
||||
|
||||
expect(finalText).toContain(nonce);
|
||||
expect(finalText).not.toContain('NO-GBRAIN-TOOL');
|
||||
// Structural tool-mediation proof: at least one gbrain MCP tool fired
|
||||
// (MCP tool naming `<server>_<tool>` observed — e.g. gbrain_recall).
|
||||
expect(toolCalls.some((t) => t.startsWith('gbrain_'))).toBe(true);
|
||||
}, 600_000);
|
||||
});
|
||||
|
||||
describe.skipIf(!CAN_RUN_PAID)('install real-opencode door — paid tier (anthropic leg, serial e2e)', () => {
|
||||
test('T5 models-gate + paid turn: the pinned model id exists in the authed list BEFORE any spend, then recalls the nonce', async () => {
|
||||
const home = trackHome('paid');
|
||||
seedOpencodeConfig(home, { defaultModel: PAID_MODEL });
|
||||
const ws = join(home, 'ws');
|
||||
mkdirSync(ws, { recursive: true });
|
||||
|
||||
// Self-validating gate: never spend against a guessed model id. When
|
||||
// this fails, update PAID_MODEL from this authed list + the PIN doc.
|
||||
const models = runOpencode(home, ws, ['models']);
|
||||
expect(models.code).toBe(0);
|
||||
if (!models.stdout.includes(PAID_MODEL)) {
|
||||
throw new Error(
|
||||
`pinned paid model '${PAID_MODEL}' is not in the authed \`opencode models\` output — ` +
|
||||
'update the pin here and in OPENCODE-CLI-PIN.md §Pending auth (no spend attempted).',
|
||||
);
|
||||
}
|
||||
|
||||
const nonce = `heron-${Math.random().toString(16).slice(2, 10)}`;
|
||||
await seedBrainForAgent(home, 'default', {
|
||||
entity: 'Door Probe Paid',
|
||||
fact: `The paid-lane door codeword is ${nonce}.`,
|
||||
query: 'What is the paid-lane door codeword?',
|
||||
slug: 'facts/door-probe-paid',
|
||||
});
|
||||
const binDir = join(home, 'staged-bin');
|
||||
stageGbrainBinDir(REPO_ROOT, binDir);
|
||||
expect(registerGbrainIntoOpencode(home, ws, binDir, home, 'default').code).toBe(0);
|
||||
expect(listProbe(home, ws, binDir).text).toMatch(/✓\s+gbrain\s+connected/);
|
||||
|
||||
const turn = await opencodeOneShotTurn({
|
||||
prompt:
|
||||
'Use your gbrain memory tools to find the paid-lane door codeword. Reply with ONLY the ' +
|
||||
'codeword string. If you have no gbrain tools at all, reply exactly: NO-GBRAIN-TOOL',
|
||||
cwd: ws,
|
||||
home,
|
||||
timeoutMs: 240_000,
|
||||
model: PAID_MODEL,
|
||||
binDir,
|
||||
});
|
||||
expect(turn.exitCode).toBe(0);
|
||||
expect(turn.finalText).toContain(nonce);
|
||||
expect(turn.finalText).not.toContain('NO-GBRAIN-TOOL');
|
||||
}, 600_000);
|
||||
});
|
||||
+361
-140
@@ -150,66 +150,93 @@ function firstExecutable(candidates: string[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Door-family binary resolver factory (the rule-of-three extraction, fired by
|
||||
* the 4th door agent — opencode). One shape for every agent:
|
||||
* $<envVar> (FAIL-CLOSED validation when set) > Bun.which > landing spots
|
||||
* (+ optional nvm/PATH sweeps) > null.
|
||||
* Fail-closed on a set-but-invalid override on purpose (the grok lesson):
|
||||
* silently falling through to `which` could bind a colliding same-name
|
||||
* binary DESPITE the operator's explicit pin.
|
||||
*/
|
||||
export function makeBinaryResolver(spec: {
|
||||
envVar?: string;
|
||||
binName: string;
|
||||
candidates: (home: string) => string[];
|
||||
nvmSweep?: boolean;
|
||||
pathSweep?: boolean;
|
||||
}): () => string | null {
|
||||
return () => {
|
||||
if (spec.envVar) {
|
||||
const fromEnv = process.env[spec.envVar]?.trim();
|
||||
if (fromEnv) {
|
||||
if (!fromEnv.startsWith('/') || fromEnv.split('/').includes('..')) return null;
|
||||
return firstExecutable([fromEnv]);
|
||||
}
|
||||
}
|
||||
const which = whichBin(spec.binName);
|
||||
if (which) return which;
|
||||
const home = process.env.HOME ?? os.homedir();
|
||||
const candidates = [...spec.candidates(home)];
|
||||
if (spec.nvmSweep) {
|
||||
try {
|
||||
const nvmBase = path.join(home, '.nvm', 'versions', 'node');
|
||||
for (const v of fs.readdirSync(nvmBase)) {
|
||||
candidates.push(path.join(nvmBase, v, 'bin', spec.binName));
|
||||
}
|
||||
} catch {
|
||||
/* no nvm */
|
||||
}
|
||||
}
|
||||
if (spec.pathSweep) {
|
||||
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
||||
if (dir) candidates.push(path.join(dir, spec.binName));
|
||||
}
|
||||
}
|
||||
return firstExecutable(candidates);
|
||||
};
|
||||
}
|
||||
|
||||
/** Locate the real `claude` binary. Bun.which first, then known install dirs. */
|
||||
export function resolveClaudeBinary(): string | null {
|
||||
const which = whichBin('claude');
|
||||
if (which) return which;
|
||||
const home = process.env.HOME ?? os.homedir();
|
||||
return firstExecutable([
|
||||
export const resolveClaudeBinary = makeBinaryResolver({
|
||||
binName: 'claude',
|
||||
candidates: (home) => [
|
||||
'/opt/homebrew/bin/claude',
|
||||
'/usr/local/bin/claude',
|
||||
`${home}/.local/bin/claude`,
|
||||
`${home}/.bun/bin/claude`,
|
||||
`${home}/.npm-global/bin/claude`,
|
||||
]);
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
/** Locate the real `hermes` binary (NousResearch hermes-agent). Bun.which
|
||||
* first, then the installer's known landing spots. */
|
||||
export function resolveHermesBinary(): string | null {
|
||||
const which = whichBin('hermes');
|
||||
if (which) return which;
|
||||
const home = process.env.HOME ?? os.homedir();
|
||||
const candidates = [
|
||||
export const resolveHermesBinary = makeBinaryResolver({
|
||||
binName: 'hermes',
|
||||
candidates: (home) => [
|
||||
'/opt/homebrew/bin/hermes',
|
||||
'/usr/local/bin/hermes',
|
||||
`${home}/.local/bin/hermes`, // where the official installer symlinks (observed v0.20.0)
|
||||
`${home}/.hermes/bin/hermes`,
|
||||
];
|
||||
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
||||
if (dir) candidates.push(path.join(dir, 'hermes'));
|
||||
}
|
||||
return firstExecutable(candidates);
|
||||
}
|
||||
],
|
||||
pathSweep: true,
|
||||
});
|
||||
|
||||
/** Locate the real `codex` binary. Bun.which first, then known install dirs
|
||||
* (adds ~/.nvm + common node bin dirs where the npm global lands). */
|
||||
export function resolveCodexBinary(): string | null {
|
||||
const which = whichBin('codex');
|
||||
if (which) return which;
|
||||
const home = process.env.HOME ?? os.homedir();
|
||||
const candidates = [
|
||||
export const resolveCodexBinary = makeBinaryResolver({
|
||||
binName: 'codex',
|
||||
candidates: (home) => [
|
||||
'/opt/homebrew/bin/codex',
|
||||
'/usr/local/bin/codex',
|
||||
`${home}/.local/bin/codex`,
|
||||
`${home}/.bun/bin/codex`,
|
||||
`${home}/.npm-global/bin/codex`,
|
||||
`${home}/.cargo/bin/codex`,
|
||||
];
|
||||
// ~/.nvm/versions/node/*/bin/codex and any dir already on PATH.
|
||||
try {
|
||||
const nvmBase = path.join(home, '.nvm', 'versions', 'node');
|
||||
for (const v of fs.readdirSync(nvmBase)) {
|
||||
candidates.push(path.join(nvmBase, v, 'bin', 'codex'));
|
||||
}
|
||||
} catch {
|
||||
/* no nvm */
|
||||
}
|
||||
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
||||
if (dir) candidates.push(path.join(dir, 'codex'));
|
||||
}
|
||||
return firstExecutable(candidates);
|
||||
}
|
||||
],
|
||||
nvmSweep: true,
|
||||
pathSweep: true,
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Auth probes (drive skipIf in the door tests)
|
||||
@@ -369,6 +396,42 @@ export function parseCodexJsonl(lines: string[]): ParsedCodexJsonl {
|
||||
return { finalText: outputParts.join('\n'), toolCalls, reasoning };
|
||||
}
|
||||
|
||||
export interface ParsedOpencodeJsonl {
|
||||
/** Concatenated text-part content, in stream order. */
|
||||
finalText: string;
|
||||
/** Tool names from tool events, in order (MCP tools: `<server>_<tool>`,
|
||||
* e.g. `gbrain_recall` — observed v1.18.18). */
|
||||
toolCalls: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `opencode run --format json` NDJSON. Every event is
|
||||
* `{type, timestamp, sessionID, part}` (observed v1.18.18 —
|
||||
* OPENCODE-CLI-PIN.md §One-shot): `text` events carry `part.text`;
|
||||
* `tool_use` events carry `part.tool` + `part.state.{status,input,output}`.
|
||||
* Skips malformed lines and unknown event types.
|
||||
*/
|
||||
export function parseOpencodeJsonl(lines: string[]): ParsedOpencodeJsonl {
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: string[] = [];
|
||||
for (const line of lines) {
|
||||
let evt: unknown;
|
||||
try {
|
||||
evt = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (typeof evt !== 'object' || evt === null) continue;
|
||||
const e = evt as { type?: string; part?: { text?: unknown; tool?: unknown } };
|
||||
if (e.type === 'text' && typeof e.part?.text === 'string') {
|
||||
textParts.push(e.part.text);
|
||||
} else if (e.type === 'tool_use' && typeof e.part?.tool === 'string') {
|
||||
toolCalls.push(e.part.tool);
|
||||
}
|
||||
}
|
||||
return { finalText: textParts.join('\n').trim(), toolCalls };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 5. Real-binary turns
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -541,6 +604,113 @@ export async function codexExecTurn(opts: CodexTurnOpts): Promise<CodexTurnResul
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 5a-core. Door-family shared core (childEnv factory + one-shot spawn) —
|
||||
// the rule-of-three extraction, fired by the 4th door agent
|
||||
// (opencode). hermes/grok/opencode build on these; behavior for the
|
||||
// ported agents is pinned by their existing unit truth-tables.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Writable CI step-metadata files the GITHUB_ prefix rule would otherwise
|
||||
* forward to an UNTRUSTED agent child: appending to any of them poisons
|
||||
* later workflow steps (ENV/PATH/OUTPUT/STATE) or the run summary UI. */
|
||||
const GITHUB_STEP_META_KEYS = [
|
||||
'GITHUB_ENV', 'GITHUB_PATH', 'GITHUB_OUTPUT', 'GITHUB_STATE',
|
||||
'GITHUB_STEP_SUMMARY', 'GITHUB_ACTION_PATH',
|
||||
] as const;
|
||||
|
||||
/** CI credential material that must never reach an UNTRUSTED agent child:
|
||||
* GITHUB_TOKEN rides the GITHUB_ prefix rule, and the ACTIONS_* runtime/OIDC
|
||||
* tokens are scrubbed unconditionally as defense-in-depth (an exfiltrated
|
||||
* workflow token is repo write access; the OIDC request token mints cloud
|
||||
* credentials). */
|
||||
const CI_CREDENTIAL_KEYS = [
|
||||
'GITHUB_TOKEN', 'ACTIONS_RUNTIME_TOKEN', 'ACTIONS_ID_TOKEN_REQUEST_TOKEN',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Per-agent hermetic child-env factory: hermeticChildEnv + the agent's home
|
||||
* overrides, then key deletion (single-auth-source discipline), the
|
||||
* GITHUB_* step-metadata scrub, and an optional staged-bin-dir PATH prepend
|
||||
* (agents whose MCP registration uses the documented bare `gbrain` command
|
||||
* need every spawn that may start the server to resolve it).
|
||||
*/
|
||||
export function makeAgentChildEnv(spec: {
|
||||
overrides: (home: string) => Record<string, string | undefined>;
|
||||
deleteKeys?: readonly string[];
|
||||
}): (home: string, opts?: { binDir?: string }) => NodeJS.ProcessEnv {
|
||||
return (home, opts) => {
|
||||
const env = hermeticChildEnv(spec.overrides(home));
|
||||
for (const k of spec.deleteKeys ?? []) delete env[k];
|
||||
for (const k of GITHUB_STEP_META_KEYS) delete env[k];
|
||||
for (const k of CI_CREDENTIAL_KEYS) delete env[k];
|
||||
if (opts?.binDir) env.PATH = `${opts.binDir}:${env.PATH ?? ''}`;
|
||||
return env;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OneShotSpawnResult {
|
||||
/** One-shot modes print the final response text alone on stdout. */
|
||||
finalText: string;
|
||||
exitCode: number | null;
|
||||
timedOut: boolean;
|
||||
stderrText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared one-shot spawn core: timeout → kill, kill(9) escalation after 5s
|
||||
* (agents may leave a daemon/MCP-server child holding the pipes open past
|
||||
* the parent's death), and a BOUNDED stream drain (timeout + 30s cap) so a
|
||||
* grandchild holding the pipe fds can never hang a retry loop. Exit 124 on
|
||||
* timeout.
|
||||
*/
|
||||
export async function runOneShotSpawn(opts: {
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
timeoutMs: number;
|
||||
}): Promise<OneShotSpawnResult> {
|
||||
const proc = Bun.spawn(opts.argv, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdin: 'ignore',
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
setTimeout(() => { try { proc.kill(9); } catch { /* already dead */ } }, 5_000);
|
||||
}, opts.timeoutMs);
|
||||
|
||||
const drainCap = opts.timeoutMs + 30_000;
|
||||
// The cap timer is CLEARED when the real promise wins — an uncancelled
|
||||
// drainCap timer (timeoutMs + 30s) would keep Bun's event loop alive for
|
||||
// minutes after every successful door run.
|
||||
const bounded = <T>(p: Promise<T>, fallback: T): Promise<T> => {
|
||||
let capTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const cap = new Promise<T>((r) => {
|
||||
capTimer = setTimeout(() => r(fallback), drainCap);
|
||||
});
|
||||
return Promise.race([p, cap]).finally(() => clearTimeout(capTimer)) as Promise<T>;
|
||||
};
|
||||
const [stdout, stderrText] = await Promise.all([
|
||||
bounded(new Response(proc.stdout).text(), ''),
|
||||
bounded(new Response(proc.stderr).text().catch(() => ''), ''),
|
||||
]);
|
||||
const exitCode = await bounded(proc.exited, 124);
|
||||
clearTimeout(timer);
|
||||
|
||||
return {
|
||||
finalText: stdout.trim(),
|
||||
exitCode: timedOut ? 124 : exitCode,
|
||||
timedOut,
|
||||
stderrText,
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 5a-bis. Hermes home seeding + one-shot turn (mirror of the codex trio)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -581,12 +751,14 @@ export function seedHermesHome(home: string, opts?: SeedHermesHomeOpts): string
|
||||
* Hermetic env for spawning hermes itself: standard scrub + HOME/HERMES_HOME
|
||||
* overrides, then ALL provider keys deleted so the seeded .env is the single
|
||||
* auth source (provider-auto determinism — see HERMES_ALL_PROVIDER_KEYS).
|
||||
* Via the shared factory, hermes now ALSO gets the GITHUB_* step-metadata
|
||||
* scrub (the filed backport from grokChildEnv — the prefix rule forwarded
|
||||
* writable CI step files to an untrusted agent child).
|
||||
*/
|
||||
export function hermesChildEnv(home: string): NodeJS.ProcessEnv {
|
||||
const env = hermeticChildEnv({ HOME: home, HERMES_HOME: path.join(home, '.hermes') });
|
||||
for (const k of HERMES_ALL_PROVIDER_KEYS) delete env[k];
|
||||
return env;
|
||||
}
|
||||
export const hermesChildEnv = makeAgentChildEnv({
|
||||
overrides: (home) => ({ HOME: home, HERMES_HOME: path.join(home, '.hermes') }),
|
||||
deleteKeys: HERMES_ALL_PROVIDER_KEYS,
|
||||
});
|
||||
|
||||
/**
|
||||
* Non-interactive model/provider pin for a hermetic hermes home. A virgin
|
||||
@@ -632,42 +804,22 @@ export interface HermesTurnResult {
|
||||
export async function hermesOneShotTurn(opts: HermesTurnOpts): Promise<HermesTurnResult> {
|
||||
const bin = resolveHermesBinary();
|
||||
if (!bin) throw new Error('hermesOneShotTurn: hermes binary not found');
|
||||
const timeoutMs = opts.timeoutMs ?? 240_000;
|
||||
|
||||
const argv = [bin, '-z', opts.prompt, ...(opts.usageFile ? ['--usage-file', opts.usageFile] : [])];
|
||||
const proc = Bun.spawn(argv, {
|
||||
// Shared spawn core: hermes gains the kill(9) escalation + bounded drain
|
||||
// the grok lane proved out (strictly-safer; nothing pinned the old
|
||||
// unbounded drain).
|
||||
const r = await runOneShotSpawn({
|
||||
argv: [bin, '-z', opts.prompt, ...(opts.usageFile ? ['--usage-file', opts.usageFile] : [])],
|
||||
cwd: opts.cwd,
|
||||
env: hermesChildEnv(opts.home),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdin: 'ignore',
|
||||
timeoutMs: opts.timeoutMs ?? 240_000,
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
}, timeoutMs);
|
||||
|
||||
const [stdout, stderrText] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text().catch(() => ''),
|
||||
]);
|
||||
const exitCode = await proc.exited;
|
||||
clearTimeout(timer);
|
||||
|
||||
let usage: unknown;
|
||||
if (opts.usageFile) {
|
||||
try { usage = JSON.parse(fs.readFileSync(opts.usageFile, 'utf-8')); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
return {
|
||||
finalText: stdout.trim(),
|
||||
exitCode: timedOut ? 124 : exitCode,
|
||||
timedOut,
|
||||
stderrText,
|
||||
usage,
|
||||
};
|
||||
return { ...r, usage };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -680,31 +832,18 @@ export async function hermesOneShotTurn(opts: HermesTurnOpts): Promise<HermesTur
|
||||
* Bun.which, then the npm-global and installer landing spots. Collision
|
||||
* note: the community superagent-ai grok-cli ships a colliding `grok`
|
||||
* binary — the door's version-shape pin (T1) is the discriminator. */
|
||||
export function resolveGrokBinary(): string | null {
|
||||
// FAIL-CLOSED on a set-but-invalid GROK_BIN (matching the runner's
|
||||
// detectBinary posture): silently falling through to `which grok` could
|
||||
// bind the colliding community grok-cli binary DESPITE the operator's
|
||||
// explicit pin — the exact mis-bind the pin exists to prevent.
|
||||
const fromEnv = process.env.GROK_BIN?.trim();
|
||||
if (fromEnv) {
|
||||
if (!fromEnv.startsWith('/') || fromEnv.split('/').includes('..')) return null;
|
||||
return firstExecutable([fromEnv]);
|
||||
}
|
||||
const which = whichBin('grok');
|
||||
if (which) return which;
|
||||
const home = process.env.HOME ?? os.homedir();
|
||||
const candidates = [
|
||||
export const resolveGrokBinary = makeBinaryResolver({
|
||||
envVar: 'GROK_BIN', // fail-closed override — see makeBinaryResolver
|
||||
binName: 'grok',
|
||||
candidates: (home) => [
|
||||
'/opt/homebrew/bin/grok',
|
||||
'/usr/local/bin/grok',
|
||||
`${home}/.local/bin/grok`,
|
||||
`${home}/.npm-global/bin/grok`,
|
||||
`${home}/.bun/bin/grok`,
|
||||
];
|
||||
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
||||
if (dir) candidates.push(path.join(dir, 'grok'));
|
||||
}
|
||||
return firstExecutable(candidates);
|
||||
}
|
||||
],
|
||||
pathSweep: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Grok is usable BY THE PAID TIER if a NON-EMPTY XAI_API_KEY is exported.
|
||||
@@ -728,23 +867,14 @@ export function hasGrokAuth(): boolean {
|
||||
* rule would forward these CI step-metadata files to an UNTRUSTED agent
|
||||
* child, which could append to them and poison later workflow steps.
|
||||
*/
|
||||
export function grokChildEnv(home: string, opts?: { binDir?: string }): NodeJS.ProcessEnv {
|
||||
const env = hermeticChildEnv({
|
||||
export const grokChildEnv = makeAgentChildEnv({
|
||||
overrides: (home) => ({
|
||||
HOME: home,
|
||||
GROK_HOME: path.join(home, '.grok'),
|
||||
XAI_API_KEY: process.env.XAI_API_KEY?.trim() || undefined,
|
||||
});
|
||||
for (const k of ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY']) delete env[k];
|
||||
// Writable step-metadata files the GITHUB_ prefix rule would otherwise
|
||||
// forward: appending to any of them poisons later workflow steps (ENV/
|
||||
// PATH/OUTPUT/STATE) or the run summary UI (STEP_SUMMARY).
|
||||
for (const k of ['GITHUB_ENV', 'GITHUB_PATH', 'GITHUB_OUTPUT', 'GITHUB_STATE', 'GITHUB_STEP_SUMMARY', 'GITHUB_ACTION_PATH']) delete env[k];
|
||||
// PATH-prepend the staged gbrain bin dir when given — the MCP registration
|
||||
// uses the DOCUMENTED bare `gbrain` command, so EVERY grok spawn that may
|
||||
// start the server (doctor probes AND the paid turn) must resolve it.
|
||||
if (opts?.binDir) env.PATH = `${opts.binDir}:${env.PATH ?? ''}`;
|
||||
return env;
|
||||
}
|
||||
}),
|
||||
deleteKeys: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Seed a hermetic <home>/.grok/config.toml BEFORE any grok spawn:
|
||||
@@ -824,49 +954,140 @@ export interface GrokTurnResult {
|
||||
export async function grokOneShotTurn(opts: GrokTurnOpts): Promise<GrokTurnResult> {
|
||||
const bin = resolveGrokBinary();
|
||||
if (!bin) throw new Error('grokOneShotTurn: grok binary not found');
|
||||
const timeoutMs = opts.timeoutMs ?? 240_000;
|
||||
|
||||
const argv = [
|
||||
bin, '-p', opts.prompt, '--output-format', 'plain',
|
||||
...(opts.model ? ['-m', opts.model] : []),
|
||||
...(opts.disableWebSearch ? ['--disable-web-search'] : []),
|
||||
];
|
||||
const proc = Bun.spawn(argv, {
|
||||
return runOneShotSpawn({
|
||||
argv: [
|
||||
bin, '-p', opts.prompt, '--output-format', 'plain',
|
||||
...(opts.model ? ['-m', opts.model] : []),
|
||||
...(opts.disableWebSearch ? ['--disable-web-search'] : []),
|
||||
],
|
||||
cwd: opts.cwd,
|
||||
env: grokChildEnv(opts.home, { binDir: opts.binDir }),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdin: 'ignore',
|
||||
timeoutMs: opts.timeoutMs ?? 240_000,
|
||||
});
|
||||
}
|
||||
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
// grok may leave its leader daemon / MCP server child holding the pipes
|
||||
// open past the parent's death — escalate so the stream drain below
|
||||
// cannot hang the retry loop indefinitely.
|
||||
setTimeout(() => { try { proc.kill(9); } catch { /* already dead */ } }, 5_000);
|
||||
}, timeoutMs);
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 5a-opencode. opencode (SST, opencode.ai) — all shapes observed against
|
||||
// v1.18.18 (docs/mcp/OPENCODE-CLI-PIN.md). First consumer of the
|
||||
// 5a-core door-family factories. opencode ≠ OpenClaw ≠ the
|
||||
// renamed-to-Crush ancestor sharing the binary name.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Bounded drain: even a SIGKILLed parent can leave a grandchild holding
|
||||
// the pipe fds; cap the post-timeout wait instead of awaiting EOF forever.
|
||||
const drainCap = timeoutMs + 30_000;
|
||||
const bounded = <T>(p: Promise<T>, fallback: T): Promise<T> =>
|
||||
Promise.race([p, new Promise<T>((r) => setTimeout(() => r(fallback), drainCap))]);
|
||||
const [stdout, stderrText] = await Promise.all([
|
||||
bounded(new Response(proc.stdout).text(), ''),
|
||||
bounded(new Response(proc.stderr).text().catch(() => ''), ''),
|
||||
]);
|
||||
const exitCode = await bounded(proc.exited, 124);
|
||||
clearTimeout(timer);
|
||||
/** Locate the real `opencode` binary. $OPENCODE_BIN (fail-closed) first —
|
||||
* the binary name has colliding claimants, and the bare-semver `--version`
|
||||
* shape (T1) is the runtime discriminator. */
|
||||
export const resolveOpencodeBinary = makeBinaryResolver({
|
||||
envVar: 'OPENCODE_BIN',
|
||||
binName: 'opencode',
|
||||
candidates: (home) => [
|
||||
'/opt/homebrew/bin/opencode',
|
||||
'/usr/local/bin/opencode',
|
||||
`${home}/.local/bin/opencode`,
|
||||
`${home}/.npm-global/bin/opencode`,
|
||||
`${home}/.bun/bin/opencode`,
|
||||
],
|
||||
nvmSweep: true,
|
||||
pathSweep: true,
|
||||
});
|
||||
|
||||
return {
|
||||
finalText: stdout.trim(),
|
||||
exitCode: timedOut ? 124 : exitCode,
|
||||
timedOut,
|
||||
stderrText,
|
||||
/**
|
||||
* The opencode door's PAID leg gates on a NON-EMPTY ANTHROPIC key (GSTACK_
|
||||
* promotion applies). Deliberately NOT an "is opencode usable" probe — the
|
||||
* keyless anonymous free tier answers headless runs AND drives MCP tool
|
||||
* calls (observed; the door's core SMOKE rides it), so the paid leg is an
|
||||
* optional hardening tier, and a blank CI secret ⇒ skip, never a paid
|
||||
* failing test. auth.json probing is deliberately absent until its shape is
|
||||
* observed post-login (OPENCODE-CLI-PIN.md §Pending auth).
|
||||
*/
|
||||
export function hasOpencodeAuth(): boolean {
|
||||
return Boolean(promotedEnv(process.env).ANTHROPIC_API_KEY?.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hermetic env for spawning opencode itself: HOME + BOTH XDG dirs redirected
|
||||
* (config/auth/data all move — verified on macOS; belt-and-suspenders), the
|
||||
* env half of the double autoupdate kill, ANTHROPIC_API_KEY re-admitted
|
||||
* explicitly for the paid leg (default-deny stays intact for every other
|
||||
* child). Deletes the OTHER providers' keys (single-auth-source discipline —
|
||||
* the paid leg pins an anthropic/* model) and the OPENCODE_CONFIG* trio
|
||||
* (observed inert in 1.18.18, but a future release activating them must not
|
||||
* let ambient values shadow the hermetic config). GITHUB_* step-metadata
|
||||
* scrub via the shared factory.
|
||||
*/
|
||||
export const opencodeChildEnv = makeAgentChildEnv({
|
||||
overrides: (home) => ({
|
||||
HOME: home,
|
||||
XDG_CONFIG_HOME: path.join(home, '.config'),
|
||||
XDG_DATA_HOME: path.join(home, '.local', 'share'),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: '1',
|
||||
ANTHROPIC_API_KEY: promotedEnv(process.env).ANTHROPIC_API_KEY?.trim() || undefined,
|
||||
}),
|
||||
deleteKeys: [
|
||||
'OPENAI_API_KEY', 'XAI_API_KEY', 'OPENROUTER_API_KEY',
|
||||
'GOOGLE_GENERATIVE_AI_API_KEY', 'GEMINI_API_KEY', 'ANTHROPIC_AUTH_TOKEN',
|
||||
'OPENCODE_CONFIG', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG_CONTENT',
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Seed a hermetic <XDG_CONFIG_HOME>/opencode/opencode.json BEFORE any
|
||||
* opencode spawn:
|
||||
* - `"autoupdate": false` — the config half of the double kill (the env
|
||||
* half rides opencodeChildEnv); a door run must never self-update
|
||||
* mid-suite and break the version pin.
|
||||
* - `"model": <model>` when given (provider/model form). Per-call `-m`
|
||||
* stays authoritative; the seed covers spawns that take no flag.
|
||||
* Plain JSON (comments legal but not needed here); `opencode mcp add`
|
||||
* preserves pre-existing keys (observed), so the seed survives registration.
|
||||
* No credentials are written: paid auth travels via ANTHROPIC_API_KEY only.
|
||||
*/
|
||||
export function seedOpencodeConfig(home: string, opts?: { defaultModel?: string }): string {
|
||||
const cfgDir = path.join(home, '.config', 'opencode');
|
||||
fs.mkdirSync(cfgDir, { recursive: true });
|
||||
const doc: Record<string, unknown> = {
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
autoupdate: false,
|
||||
...(opts?.defaultModel ? { model: opts.defaultModel } : {}),
|
||||
};
|
||||
const cfgPath = path.join(cfgDir, 'opencode.json');
|
||||
fs.writeFileSync(cfgPath, `${JSON.stringify(doc, null, 2)}\n`, 'utf-8');
|
||||
return cfgPath;
|
||||
}
|
||||
|
||||
export interface OpencodeTurnOpts {
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
home: string;
|
||||
timeoutMs?: number;
|
||||
/** Per-call model pin (provider/model — authoritative over the config seed). */
|
||||
model?: string;
|
||||
/** 'json' emits the NDJSON event stream parseOpencodeJsonl pins. */
|
||||
format?: 'default' | 'json';
|
||||
/** Staged gbrain bin dir — PATH-prepended so a PATH-resolved registration
|
||||
* resolves when opencode spawns the server during the turn. */
|
||||
binDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive one `opencode run` turn against a hermetic HOME + XDG dirs. Default
|
||||
* format prints the final answer ALONE on stdout (banner/UI on stderr —
|
||||
* observed); no permission flag is needed for MCP tool calls (observed:
|
||||
* --auto not passed, deliberately). Shared spawn core: kill(9) escalation +
|
||||
* bounded drain.
|
||||
*/
|
||||
export async function opencodeOneShotTurn(opts: OpencodeTurnOpts): Promise<OneShotSpawnResult> {
|
||||
const bin = resolveOpencodeBinary();
|
||||
if (!bin) throw new Error('opencodeOneShotTurn: opencode binary not found');
|
||||
return runOneShotSpawn({
|
||||
argv: [
|
||||
bin, 'run', opts.prompt,
|
||||
'--format', opts.format ?? 'default',
|
||||
...(opts.model ? ['-m', opts.model] : []),
|
||||
],
|
||||
cwd: opts.cwd,
|
||||
env: opencodeChildEnv(opts.home, { binDir: opts.binDir }),
|
||||
timeoutMs: opts.timeoutMs ?? 240_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
parseDotenvFile,
|
||||
seedHermesHome,
|
||||
seedGrokConfig,
|
||||
opencodeChildEnv,
|
||||
hasOpencodeAuth,
|
||||
seedOpencodeConfig,
|
||||
parseOpencodeJsonl,
|
||||
runOneShotSpawn,
|
||||
} from './agent-harness.ts';
|
||||
import { withEnv } from './with-env.ts';
|
||||
|
||||
@@ -262,6 +267,24 @@ describe('hermesChildEnv — the single-auth-source enforcement point', () => {
|
||||
expect(env.HERMES_HOME).toBe('/tmp/hermes-child-test/.hermes');
|
||||
});
|
||||
});
|
||||
|
||||
test('GITHUB_* step-metadata files are deleted (the backported grok scrub, via the shared factory)', async () => {
|
||||
await withEnv({
|
||||
GITHUB_ENV: '/tmp/gh-env-file',
|
||||
GITHUB_PATH: '/tmp/gh-path-file',
|
||||
GITHUB_OUTPUT: '/tmp/gh-output-file',
|
||||
GITHUB_STATE: '/tmp/gh-state-file',
|
||||
GITHUB_STEP_SUMMARY: '/tmp/gh-summary-file',
|
||||
GITHUB_ACTIONS: 'true',
|
||||
}, () => {
|
||||
const env = hermesChildEnv('/tmp/hermes-child-test');
|
||||
for (const k of ['GITHUB_ENV', 'GITHUB_PATH', 'GITHUB_OUTPUT', 'GITHUB_STATE', 'GITHUB_STEP_SUMMARY']) {
|
||||
expect(env[k]).toBeUndefined();
|
||||
}
|
||||
// Read-only CI metadata stays allowed (prefix rule intact).
|
||||
expect(env.GITHUB_ACTIONS).toBe('true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDotenvFile', () => {
|
||||
@@ -468,3 +491,169 @@ describe('resolveGrokBinary — fail-closed GROK_BIN handling', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencodeChildEnv — XDG redirection + explicit anthropic re-admission (5a-core factory)', () => {
|
||||
test('ANTHROPIC_API_KEY survives via explicit override; HOME + both XDG dirs point at the temp home; autoupdate env kill set', async () => {
|
||||
await withEnv({ ANTHROPIC_API_KEY: 'ant-child-sentinel' }, () => {
|
||||
const env = opencodeChildEnv('/tmp/opencode-child-test');
|
||||
expect(env.ANTHROPIC_API_KEY).toBe('ant-child-sentinel');
|
||||
expect(env.HOME).toBe('/tmp/opencode-child-test');
|
||||
expect(env.XDG_CONFIG_HOME).toBe('/tmp/opencode-child-test/.config');
|
||||
expect(env.XDG_DATA_HOME).toBe('/tmp/opencode-child-test/.local/share');
|
||||
expect(env.OPENCODE_DISABLE_AUTOUPDATE).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
test('other provider keys, the OPENCODE_CONFIG* shadow trio, and GITHUB_* step files are deleted', async () => {
|
||||
await withEnv({
|
||||
ANTHROPIC_API_KEY: 'ant-x',
|
||||
OPENAI_API_KEY: 'oai-must-not-leak',
|
||||
XAI_API_KEY: 'xai-must-not-leak',
|
||||
OPENROUTER_API_KEY: 'or-must-not-leak',
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: 'ggl-must-not-leak',
|
||||
OPENCODE_CONFIG: '/operator/custom.json',
|
||||
OPENCODE_CONFIG_DIR: '/operator/cfgdir',
|
||||
OPENCODE_CONFIG_CONTENT: '{"mcp":{}}',
|
||||
GITHUB_ENV: '/tmp/gh-env-file',
|
||||
GITHUB_ACTIONS: 'true',
|
||||
}, () => {
|
||||
const env = opencodeChildEnv('/tmp/opencode-child-test');
|
||||
for (const k of [
|
||||
'OPENAI_API_KEY', 'XAI_API_KEY', 'OPENROUTER_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
'OPENCODE_CONFIG', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG_CONTENT',
|
||||
'GITHUB_ENV',
|
||||
]) {
|
||||
expect(env[k]).toBeUndefined();
|
||||
}
|
||||
expect(env.GITHUB_ACTIONS).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
test('binDir PATH-prepends (staged bare-`gbrain` registrations must resolve in every spawn)', async () => {
|
||||
await withEnv({}, () => {
|
||||
const env = opencodeChildEnv('/tmp/opencode-child-test', { binDir: '/tmp/staged-bin' });
|
||||
expect(env.PATH?.startsWith('/tmp/staged-bin:')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CI credential scrub — no door agent child ever sees the workflow/OIDC tokens (5a-core factory)', () => {
|
||||
test('GITHUB_TOKEN, ACTIONS_RUNTIME_TOKEN, ACTIONS_ID_TOKEN_REQUEST_TOKEN are deleted for EVERY agent child env', async () => {
|
||||
await withEnv({
|
||||
GITHUB_TOKEN: 'ghs_must-not-leak', // rides the GITHUB_ prefix allowlist without the scrub
|
||||
ACTIONS_RUNTIME_TOKEN: 'art-must-not-leak',
|
||||
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'oidc-must-not-leak',
|
||||
GITHUB_ACTIONS: 'true',
|
||||
}, () => {
|
||||
const envs: Array<[string, NodeJS.ProcessEnv]> = [
|
||||
['hermes', hermesChildEnv('/tmp/agent-cred-scrub')],
|
||||
['grok', grokChildEnv('/tmp/agent-cred-scrub')],
|
||||
['opencode', opencodeChildEnv('/tmp/agent-cred-scrub')],
|
||||
];
|
||||
for (const [label, env] of envs) {
|
||||
expect(`${label}:${env.GITHUB_TOKEN ?? ''}`).toBe(`${label}:`);
|
||||
expect(`${label}:${env.ACTIONS_RUNTIME_TOKEN ?? ''}`).toBe(`${label}:`);
|
||||
expect(`${label}:${env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? ''}`).toBe(`${label}:`);
|
||||
// Benign CI metadata still flows — the scrub is credential-shaped, not prefix-wide.
|
||||
expect(env.GITHUB_ACTIONS).toBe('true');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasOpencodeAuth — the PAID leg gate only (keyless free tier carries the core SMOKE)', () => {
|
||||
test('non-empty ANTHROPIC_API_KEY (or its GSTACK_ promotion) → true; blank/absent → false', async () => {
|
||||
await withEnv({ ANTHROPIC_API_KEY: 'ant-key', GSTACK_ANTHROPIC_API_KEY: undefined }, () => {
|
||||
expect(hasOpencodeAuth()).toBe(true);
|
||||
});
|
||||
await withEnv({ ANTHROPIC_API_KEY: undefined, GSTACK_ANTHROPIC_API_KEY: 'promoted-key' }, () => {
|
||||
expect(hasOpencodeAuth()).toBe(true);
|
||||
});
|
||||
await withEnv({ ANTHROPIC_API_KEY: ' ', GSTACK_ANTHROPIC_API_KEY: undefined }, () => {
|
||||
expect(hasOpencodeAuth()).toBe(false);
|
||||
});
|
||||
await withEnv({ ANTHROPIC_API_KEY: undefined, GSTACK_ANTHROPIC_API_KEY: undefined }, () => {
|
||||
expect(hasOpencodeAuth()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedOpencodeConfig — the autoupdate kill-switch seed (config half of the double kill)', () => {
|
||||
test('writes autoupdate:false + optional model pin, valid JSON, never credentials', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'opencode-seed-'));
|
||||
try {
|
||||
const cfgPath = seedOpencodeConfig(home, { defaultModel: 'anthropic/claude-haiku-4-5' });
|
||||
const doc = JSON.parse(readFileSync(cfgPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(doc.autoupdate).toBe(false);
|
||||
expect(doc.model).toBe('anthropic/claude-haiku-4-5');
|
||||
expect(doc.$schema).toBe('https://opencode.ai/config.json');
|
||||
expect(JSON.stringify(doc)).not.toMatch(/api[_-]?key|bearer/i);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('without a model opt, no model key is written', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'opencode-seed-'));
|
||||
try {
|
||||
const cfgPath = seedOpencodeConfig(home);
|
||||
const doc = JSON.parse(readFileSync(cfgPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(doc.model).toBeUndefined();
|
||||
expect(doc.autoupdate).toBe(false);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOpencodeJsonl (pure — fixtures pinned to observed v1.18.18 shapes)', () => {
|
||||
test('collects text parts and tool names; skips malformed lines and unknown types', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'step_start', timestamp: 1, sessionID: 's', part: {} }),
|
||||
JSON.stringify({ type: 'tool_use', timestamp: 2, sessionID: 's', part: { type: 'tool', tool: 'gbrain_recall', callID: 'c1', state: { status: 'completed', input: { query: 'door codeword' }, output: '{}' } } }),
|
||||
'NOT JSON {{{',
|
||||
JSON.stringify({ type: 'mystery_event', part: { text: 'must-not-count' } }),
|
||||
JSON.stringify({ type: 'text', timestamp: 3, sessionID: 's', part: { id: 'p1', text: 'kestrel-3f82e013', time: {} } }),
|
||||
JSON.stringify({ type: 'step_finish', part: {} }),
|
||||
];
|
||||
const parsed = parseOpencodeJsonl(lines);
|
||||
expect(parsed.finalText).toBe('kestrel-3f82e013');
|
||||
expect(parsed.toolCalls).toEqual(['gbrain_recall']);
|
||||
});
|
||||
|
||||
test('empty/garbage input → empty result, never throws', () => {
|
||||
expect(parseOpencodeJsonl([])).toEqual({ finalText: '', toolCalls: [] });
|
||||
expect(parseOpencodeJsonl(['{', 'null', '42'])).toEqual({ finalText: '', toolCalls: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOneShotSpawn — drain-cap timer hygiene', () => {
|
||||
test('returns promptly on a fast child and clears every drain-cap timer (no post-run event-loop hold)', async () => {
|
||||
// The bounded() drain caps are timeoutMs + 30s each; uncleared they keep
|
||||
// Bun alive for minutes after a successful door run. Spy on clearTimeout:
|
||||
// the main kill timer + the three bounded races (stdout, stderr, exited)
|
||||
// must ALL clear when the real promises win.
|
||||
const realClear = globalThis.clearTimeout;
|
||||
let clears = 0;
|
||||
globalThis.clearTimeout = ((id: Parameters<typeof clearTimeout>[0]) => {
|
||||
clears++;
|
||||
return realClear(id);
|
||||
}) as typeof clearTimeout;
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const res = await runOneShotSpawn({
|
||||
argv: ['echo', 'door-timer-hygiene'],
|
||||
cwd: tmpdir(),
|
||||
env: process.env,
|
||||
timeoutMs: 240_000, // door-scale timeout — the leak would be ~270s of hold
|
||||
});
|
||||
expect(res.exitCode).toBe(0);
|
||||
expect(res.finalText).toBe('door-timer-hygiene');
|
||||
expect(res.timedOut).toBe(false);
|
||||
expect(Date.now() - t0).toBeLessThan(10_000); // returned promptly, no cap wait
|
||||
expect(clears).toBeGreaterThanOrEqual(4); // kill timer + 3 bounded drain caps
|
||||
} finally {
|
||||
globalThis.clearTimeout = realClear;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,7 +376,7 @@ describe('user-prompt', () => {
|
||||
expect(prior.split('pages/dup').length - 1).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('--harness codex flags the channel; unknown values fall back to the default', async () => {
|
||||
test('--harness codex/opencode flags the channel; unknown values fall back to the default', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
writePgliteConfig(dataDir);
|
||||
const seen: TurnContextRequest[] = [];
|
||||
@@ -386,13 +386,21 @@ describe('user-prompt', () => {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ prompt: 'hello Acme' }),
|
||||
});
|
||||
// opencode widening (v0.45.x): pins the hook.ts flag parse — a regression
|
||||
// there silently rebadges opencode deliveries as claude-code (the wire
|
||||
// guard half is pinned in volunteer-events-delivery.test.ts).
|
||||
await runHook(['user-prompt', '--harness', 'opencode'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ prompt: 'hello Acme' }),
|
||||
});
|
||||
await runHook(['user-prompt', '--harness', 'vim'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ prompt: 'hello Acme' }),
|
||||
});
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen).toHaveLength(3);
|
||||
expect(seen[0].channel).toBe('codex');
|
||||
expect(seen[1].channel).toBe('claude-code'); // fail-open to the default
|
||||
expect(seen[1].channel).toBe('opencode');
|
||||
expect(seen[2].channel).toBe('claude-code'); // fail-open to the default
|
||||
});
|
||||
|
||||
test('hook ∈ STARTUP_HOOK_SKIP_COMMANDS (source grep — maybeEmitUpdateMarker no-ops under NODE_ENV=test, so no runtime test can pin this)', () => {
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* opencode-json.test.ts — pins the opencode config writer's safety
|
||||
* invariants (mirror of codex-toml.test.ts for the JSONC surface):
|
||||
* comment preservation via jsonc-parser surgical edits, the 4-state
|
||||
* ownership fingerprint (GBRAIN_SOURCE equality, [FIX7] parity), distinct
|
||||
* read-failure classes (ENOENT/empty/unreadable), foreign refusal on write
|
||||
* AND remove, post-render validation keeping the original on failure, 0600
|
||||
* hygiene only for inline-bearer entries, and CRLF/byte preservation
|
||||
* outside the edited range. Host-spec path helpers (XDG-only resolution,
|
||||
* jsonc-over-json preference) are pinned here too.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import {
|
||||
opencodeEntryKind,
|
||||
opencodeEntrySnippet,
|
||||
parseOpencodeConfig,
|
||||
parseOpencodeEntryBearer,
|
||||
reconcileOpencodeSiblingGlobal,
|
||||
removeOpencodeMcpEntry,
|
||||
textCarriesInlineBearer,
|
||||
writeOpencodeMcpEntry,
|
||||
type OpencodeLocalEntry,
|
||||
type OpencodeRemoteEntry,
|
||||
} from '../src/core/bootstrap/opencode-json.ts';
|
||||
import {
|
||||
opencodeConfigDir,
|
||||
opencodeGlobalConfigPath,
|
||||
opencodeGlobalSiblingPath,
|
||||
opencodeProjectConfigPath,
|
||||
OPENCODE_SPEC_ID,
|
||||
TARGETS,
|
||||
} from '../src/core/bootstrap/host-specs.ts';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'gbrain-opencode-json-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const localEntry = (over: Partial<OpencodeLocalEntry> = {}): OpencodeLocalEntry => ({
|
||||
kind: 'local',
|
||||
name: 'gbrain',
|
||||
command: ['/abs/path/gbrain', 'serve', '--surface', 'full'],
|
||||
environment: { GBRAIN_SOURCE: 'ws-a' },
|
||||
...over,
|
||||
});
|
||||
|
||||
const remoteInline = (over: Partial<OpencodeRemoteEntry> = {}): OpencodeRemoteEntry => ({
|
||||
kind: 'remote',
|
||||
name: 'gbrain',
|
||||
url: 'http://127.0.0.1:7411/mcp',
|
||||
tokenMode: 'inline',
|
||||
bearerToken: 'gbt_secret_token_1',
|
||||
...over,
|
||||
});
|
||||
|
||||
function cfg(name = 'opencode.json'): string {
|
||||
return join(dir, name);
|
||||
}
|
||||
|
||||
describe('host-specs opencode path helpers', () => {
|
||||
test('spec target is registered and verified', () => {
|
||||
const t = TARGETS[OPENCODE_SPEC_ID];
|
||||
expect(t).toBeDefined();
|
||||
expect(t.status).toBe('verified');
|
||||
expect(t.references.join(' ')).toContain('OPENCODE-CLI-PIN.md');
|
||||
});
|
||||
|
||||
test('config dir resolves via XDG_CONFIG_HOME, then HOME — OPENCODE_CONFIG* deliberately inert', async () => {
|
||||
await withEnv({
|
||||
OPENCODE_CONFIG: join(dir, 'somewhere-else.json'),
|
||||
OPENCODE_CONFIG_DIR: join(dir, 'somewhere-else'),
|
||||
XDG_CONFIG_HOME: join(dir, 'xdg'),
|
||||
}, () => {
|
||||
expect(opencodeConfigDir()).toBe(join(dir, 'xdg', 'opencode'));
|
||||
});
|
||||
await withEnv({
|
||||
OPENCODE_CONFIG: join(dir, 'somewhere-else.json'),
|
||||
OPENCODE_CONFIG_DIR: join(dir, 'somewhere-else'),
|
||||
XDG_CONFIG_HOME: undefined,
|
||||
HOME: join(dir, 'home'),
|
||||
}, () => {
|
||||
expect(opencodeConfigDir()).toBe(join(dir, 'home', '.config', 'opencode'));
|
||||
});
|
||||
});
|
||||
|
||||
test('global path prefers the file that exists; .jsonc wins ties and fresh dirs (vendor parity)', async () => {
|
||||
await withEnv({ XDG_CONFIG_HOME: join(dir, 'xdg') }, () => {
|
||||
const ocDir = join(dir, 'xdg', 'opencode');
|
||||
// Fresh: .jsonc (what `opencode mcp add` itself writes).
|
||||
expect(opencodeGlobalConfigPath()).toBe(join(ocDir, 'opencode.jsonc'));
|
||||
// Only .json exists → edit it (one owner per entry, no split-brain).
|
||||
mkdirSync(ocDir, { recursive: true });
|
||||
writeFileSync(join(ocDir, 'opencode.json'), '{}');
|
||||
expect(opencodeGlobalConfigPath()).toBe(join(ocDir, 'opencode.json'));
|
||||
// Both exist → .jsonc wins.
|
||||
writeFileSync(join(ocDir, 'opencode.jsonc'), '{}');
|
||||
expect(opencodeGlobalConfigPath()).toBe(join(ocDir, 'opencode.jsonc'));
|
||||
});
|
||||
});
|
||||
|
||||
test('project path is the docs-canonical opencode.json in the workspace root', () => {
|
||||
expect(opencodeProjectConfigPath('/ws')).toBe(join('/ws', 'opencode.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-failure classes are distinct', () => {
|
||||
test('ENOENT → fresh file created with $schema + our entry', () => {
|
||||
const res = writeOpencodeMcpEntry(cfg(), localEntry());
|
||||
expect(res.replacedPrior).toBe(false);
|
||||
expect(res.priorKind).toBe('absent');
|
||||
expect(res.backupPath).toBeNull();
|
||||
const parsed = parseOpencodeConfig(readFileSync(cfg(), 'utf8'), cfg());
|
||||
expect(parsed.$schema).toBe('https://opencode.ai/config.json');
|
||||
const entry = (parsed.mcp as Record<string, unknown>).gbrain as Record<string, unknown>;
|
||||
expect(entry.type).toBe('local');
|
||||
expect(entry.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('empty/whitespace file → treated as {} (strict JSON.parse would wrongly refuse)', () => {
|
||||
writeFileSync(cfg(), ' \n\n');
|
||||
const res = writeOpencodeMcpEntry(cfg(), localEntry());
|
||||
expect(res.priorKind).toBe('absent');
|
||||
const parsed = parseOpencodeConfig(readFileSync(cfg(), 'utf8'), cfg());
|
||||
expect((parsed.mcp as Record<string, unknown>).gbrain).toBeDefined();
|
||||
});
|
||||
|
||||
test('unreadable file → refuse loudly, never clobber', () => {
|
||||
if (process.getuid?.() === 0) return; // root ignores modes
|
||||
writeFileSync(cfg(), '{"mcp":{}}');
|
||||
chmodSync(cfg(), 0o000);
|
||||
try {
|
||||
expect(() => writeOpencodeMcpEntry(cfg(), localEntry())).toThrow(/cannot be read/);
|
||||
} finally {
|
||||
chmodSync(cfg(), 0o644);
|
||||
}
|
||||
expect(readFileSync(cfg(), 'utf8')).toBe('{"mcp":{}}');
|
||||
});
|
||||
|
||||
test('file that fails even JSONC parsing → refuse with a paste-by-hand snippet', () => {
|
||||
writeFileSync(cfg(), '{"mcp": {{{');
|
||||
expect(() => writeOpencodeMcpEntry(cfg(), localEntry())).toThrow(/does not parse as JSONC[\s\S]*"mcp"/);
|
||||
expect(readFileSync(cfg(), 'utf8')).toBe('{"mcp": {{{');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSONC comment + byte preservation (the opencode mcp add bar)', () => {
|
||||
test('comments, user keys, and foreign servers survive a write', () => {
|
||||
writeFileSync(
|
||||
cfg('opencode.jsonc'),
|
||||
`{
|
||||
// user comment: do not lose me
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"theme": "dark", // trailing comment
|
||||
"autoupdate": false,
|
||||
"mcp": {
|
||||
"other-server": { "type": "remote", "url": "https://other.example/mcp" }
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
const res = writeOpencodeMcpEntry(cfg('opencode.jsonc'), localEntry());
|
||||
expect(res.priorKind).toBe('absent');
|
||||
const text = readFileSync(cfg('opencode.jsonc'), 'utf8');
|
||||
expect(text).toContain('// user comment: do not lose me');
|
||||
expect(text).toContain('// trailing comment');
|
||||
const parsed = parseOpencodeConfig(text, cfg('opencode.jsonc'));
|
||||
expect(parsed.theme).toBe('dark');
|
||||
expect(parsed.autoupdate).toBe(false);
|
||||
const mcp = parsed.mcp as Record<string, unknown>;
|
||||
expect((mcp['other-server'] as Record<string, unknown>).url).toBe('https://other.example/mcp');
|
||||
expect((mcp.gbrain as Record<string, unknown>).type).toBe('local');
|
||||
});
|
||||
|
||||
test('CRLF config keeps CRLF (jsonc-parser detects the file EOL)', () => {
|
||||
writeFileSync(cfg(), '{\r\n "theme": "dark"\r\n}\r\n');
|
||||
writeOpencodeMcpEntry(cfg(), localEntry());
|
||||
const text = readFileSync(cfg(), 'utf8');
|
||||
expect(text).toContain('"theme": "dark",\r\n'); // comma added by the edit; CRLF preserved
|
||||
expect(text).not.toMatch(/[^\r]\n/); // no LF-only lines crept in
|
||||
const parsed = parseOpencodeConfig(text, cfg());
|
||||
expect((parsed.mcp as Record<string, unknown>).gbrain).toBeDefined();
|
||||
});
|
||||
|
||||
test('second write is idempotent (replacedPrior, single entry, no growth)', () => {
|
||||
writeOpencodeMcpEntry(cfg(), localEntry());
|
||||
const first = readFileSync(cfg(), 'utf8');
|
||||
const res = writeOpencodeMcpEntry(cfg(), localEntry());
|
||||
expect(res.replacedPrior).toBe(true);
|
||||
expect(res.priorKind).toBe('ours-same-source');
|
||||
expect(readFileSync(cfg(), 'utf8')).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencodeEntryKind — the ownership arbiter truth table', () => {
|
||||
const table: Array<{
|
||||
label: string;
|
||||
entry: unknown;
|
||||
expectArg: { sourceId?: string; url?: string };
|
||||
want: string;
|
||||
}> = [
|
||||
// local × command shapes (all with matching source)
|
||||
{ label: 'local abs path + same source', entry: { type: 'local', command: ['/opt/bin/gbrain', 'serve'], environment: { GBRAIN_SOURCE: 'ws-a' } }, expectArg: { sourceId: 'ws-a' }, want: 'ours-same-source' },
|
||||
{ label: 'local PATH-resolved + same source', entry: { type: 'local', command: ['gbrain', 'serve'], environment: { GBRAIN_SOURCE: 'ws-a' } }, expectArg: { sourceId: 'ws-a' }, want: 'ours-same-source' },
|
||||
{ label: 'local bun-run wrapper + same source (gbrain path segment)', entry: { type: 'local', command: ['bun', 'run', '/repo/gbrain/src/cli.ts', 'serve'], environment: { GBRAIN_SOURCE: 'ws-a' } }, expectArg: { sourceId: 'ws-a' }, want: 'ours-same-source' },
|
||||
// [anchored bun-arg fingerprint] a loose substring scan classified a
|
||||
// gbrainy-fork checkout as ours; the anchor requires an exact `gbrain`
|
||||
// (or `gbrain-*`) path segment in some arg — a gbrain-less cli path is
|
||||
// fail-closed NOT ours.
|
||||
{ label: 'local bun-run of a gbrainy-fork cli is NOT ours', entry: { type: 'local', command: ['bun', 'run', '/opt/gbrainy-fork/src/cli.ts', 'serve'], environment: { GBRAIN_SOURCE: 'ws-a' } }, expectArg: { sourceId: 'ws-a' }, want: 'foreign' },
|
||||
{ label: 'local bun-run with NO gbrain-ish segment is NOT ours (fail-closed)', entry: { type: 'local', command: ['bun', 'run', '/repo/src/cli.ts', 'serve'], environment: { GBRAIN_SOURCE: 'ws-a' } }, expectArg: { sourceId: 'ws-a' }, want: 'foreign' },
|
||||
{ label: 'local bun-run of a gbrain-shim path is ours', entry: { type: 'local', command: ['bun', 'run', '/tmp/stage/gbrain-shim', 'serve'], environment: { GBRAIN_SOURCE: 'ws-a' } }, expectArg: { sourceId: 'ws-a' }, want: 'ours-same-source' },
|
||||
{ label: 'local staged shim + same source', entry: { type: 'local', command: ['/tmp/stage/gbrain', 'serve'], environment: { GBRAIN_SOURCE: 'ws-a' } }, expectArg: { sourceId: 'ws-a' }, want: 'ours-same-source' },
|
||||
// source equality, not presence ([FIX7])
|
||||
{ label: 'local + OTHER source', entry: { type: 'local', command: ['gbrain', 'serve'], environment: { GBRAIN_SOURCE: 'ws-b' } }, expectArg: { sourceId: 'ws-a' }, want: 'ours-other-source' },
|
||||
{ label: 'local + no expectation → ours', entry: { type: 'local', command: ['gbrain', 'serve'], environment: { GBRAIN_SOURCE: 'ws-b' } }, expectArg: {}, want: 'ours-same-source' },
|
||||
// foreign local shapes
|
||||
{ label: 'local non-gbrain command', entry: { type: 'local', command: ['npx', 'other-mcp'], environment: { GBRAIN_SOURCE: 'ws-a' } }, expectArg: { sourceId: 'ws-a' }, want: 'foreign' },
|
||||
{ label: 'local gbrain command but NO GBRAIN_SOURCE', entry: { type: 'local', command: ['gbrain', 'serve'], environment: {} }, expectArg: { sourceId: 'ws-a' }, want: 'foreign' },
|
||||
{ label: 'local gbrain-prefixed foreign binary is NOT ours', entry: { type: 'local', command: ['/opt/bin/gbrainy', 'serve'], environment: { GBRAIN_SOURCE: 'ws-a' } }, expectArg: { sourceId: 'ws-a' }, want: 'foreign' },
|
||||
// kind mismatch (red-team): a remote expectation finding a LOCAL gbrain
|
||||
// entry (and vice versa) is ANOTHER lane's registration — never a silent
|
||||
// same-source match (which would replace it, and --remove would delete it).
|
||||
{ label: 'local entry + remote expectation (url) → ours-other-source, never silently replaceable', entry: { type: 'local', command: ['gbrain', 'serve'], environment: { GBRAIN_SOURCE: 'ws-a' } }, expectArg: { url: 'http://h:1/mcp' }, want: 'ours-other-source' },
|
||||
{ label: 'remote env-interpolated entry + local expectation (sourceId) → ours-other-source', entry: { type: 'remote', url: 'http://h:9/mcp', headers: { Authorization: 'Bearer {env:GBRAIN_REMOTE_TOKEN}' } }, expectArg: { sourceId: 'ws-a' }, want: 'ours-other-source' },
|
||||
// remote
|
||||
{ label: 'remote url matches receipt', entry: { type: 'remote', url: 'http://h:1/mcp', headers: { Authorization: 'Bearer abc' } }, expectArg: { url: 'http://h:1/mcp' }, want: 'ours-same-source' },
|
||||
{ label: 'remote url differs from receipt, opaque token', entry: { type: 'remote', url: 'http://h:2/mcp', headers: { Authorization: 'Bearer abc' } }, expectArg: { url: 'http://h:1/mcp' }, want: 'foreign' },
|
||||
{ label: 'remote env-interpolated token, no receipt → connect-lane ours', entry: { type: 'remote', url: 'http://h:9/mcp', headers: { Authorization: 'Bearer {env:GBRAIN_REMOTE_TOKEN}' } }, expectArg: {}, want: 'ours-same-source' },
|
||||
{ label: 'remote env-interpolated token + expect url DIFFERENT from entry url → ours-other-source', entry: { type: 'remote', url: 'http://h:9/mcp', headers: { Authorization: 'Bearer {env:GBRAIN_REMOTE_TOKEN}' } }, expectArg: { url: 'http://h:1/mcp' }, want: 'ours-other-source' },
|
||||
{ label: 'remote plain foreign', entry: { type: 'remote', url: 'https://other.example/mcp', headers: {} }, expectArg: {}, want: 'foreign' },
|
||||
// degenerate shapes
|
||||
{ label: 'unknown type', entry: { type: 'websocket', url: 'x' }, expectArg: {}, want: 'foreign' },
|
||||
{ label: 'non-object entry', entry: 'oops', expectArg: {}, want: 'foreign' },
|
||||
];
|
||||
|
||||
for (const row of table) {
|
||||
test(row.label, () => {
|
||||
const parsed = { mcp: { gbrain: row.entry } } as Record<string, unknown>;
|
||||
expect(opencodeEntryKind(parsed, 'gbrain', row.expectArg)).toBe(row.want as never);
|
||||
});
|
||||
}
|
||||
|
||||
test('absent: no mcp table / no entry', () => {
|
||||
expect(opencodeEntryKind({}, 'gbrain')).toBe('absent');
|
||||
expect(opencodeEntryKind({ mcp: {} }, 'gbrain')).toBe('absent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('foreign + other-source gating on write', () => {
|
||||
test('foreign same-name entry → refuse, file untouched', () => {
|
||||
writeFileSync(cfg(), '{"mcp":{"gbrain":{"type":"local","command":["npx","other"],"environment":{}}}}');
|
||||
const before = readFileSync(cfg(), 'utf8');
|
||||
expect(() => writeOpencodeMcpEntry(cfg(), localEntry())).toThrow(/not a gbrain-managed entry/);
|
||||
expect(readFileSync(cfg(), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
test('ours-other-source → refuse without the confirmation flag, replace with it', () => {
|
||||
writeFileSync(cfg(), '{"mcp":{"gbrain":{"type":"local","command":["gbrain","serve"],"environment":{"GBRAIN_SOURCE":"ws-b"}}}}');
|
||||
expect(() =>
|
||||
writeOpencodeMcpEntry(cfg(), localEntry(), { expect: { sourceId: 'ws-a' } }),
|
||||
).toThrow(/DIFFERENT gbrain workspace/);
|
||||
const res = writeOpencodeMcpEntry(cfg(), localEntry(), {
|
||||
expect: { sourceId: 'ws-a' },
|
||||
allowReplaceOtherSource: true,
|
||||
});
|
||||
expect(res.priorKind).toBe('ours-other-source');
|
||||
expect(res.notes.join(' ')).toContain('different workspace');
|
||||
const parsed = parseOpencodeConfig(readFileSync(cfg(), 'utf8'), cfg());
|
||||
const env = ((parsed.mcp as Record<string, unknown>).gbrain as { environment: Record<string, string> }).environment;
|
||||
expect(env.GBRAIN_SOURCE).toBe('ws-a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('secret hygiene (inline bearer only)', () => {
|
||||
test('inline bearer → file forced 0600, .bak 0600 on re-runs', () => {
|
||||
writeFileSync(cfg(), '{"theme":"dark"}');
|
||||
chmodSync(cfg(), 0o644);
|
||||
const res1 = writeOpencodeMcpEntry(cfg(), remoteInline());
|
||||
expect(statSync(cfg()).mode & 0o777).toBe(0o600);
|
||||
expect(res1.notes.join(' ')).toContain('0600');
|
||||
// Re-run replaces the token; the .bak carries the PREVIOUS one → 0600.
|
||||
const res2 = writeOpencodeMcpEntry(cfg(), remoteInline({ bearerToken: 'gbt_secret_token_2' }), {
|
||||
expect: { url: 'http://127.0.0.1:7411/mcp' },
|
||||
});
|
||||
expect(res2.replacedPrior).toBe(true);
|
||||
expect(res2.backupPath).not.toBeNull();
|
||||
expect(statSync(res2.backupPath as string).mode & 0o777).toBe(0o600);
|
||||
expect(readFileSync(res2.backupPath as string, 'utf8')).toContain('gbt_secret_token_1');
|
||||
expect(readFileSync(cfg(), 'utf8')).toContain('gbt_secret_token_2');
|
||||
});
|
||||
|
||||
test('env token mode → no token anywhere, literal {env:} interpolation, mode untouched', () => {
|
||||
const res = writeOpencodeMcpEntry(cfg(), remoteInline({ tokenMode: 'env', bearerToken: undefined }));
|
||||
const text = readFileSync(cfg(), 'utf8');
|
||||
expect(text).toContain('Bearer {env:GBRAIN_REMOTE_TOKEN}');
|
||||
expect(text).not.toContain('gbt_secret');
|
||||
expect(statSync(cfg()).mode & 0o777).not.toBe(0o600);
|
||||
expect(res.priorKind).toBe('absent');
|
||||
});
|
||||
|
||||
test('inline mode without a token throws before touching anything', () => {
|
||||
expect(() => writeOpencodeMcpEntry(cfg(), remoteInline({ bearerToken: undefined }))).toThrow(/requires a bearerToken/);
|
||||
expect(existsSync(cfg())).toBe(false);
|
||||
});
|
||||
|
||||
test('parseOpencodeEntryBearer recovers the inline token, never the env interpolation', () => {
|
||||
writeOpencodeMcpEntry(cfg(), remoteInline());
|
||||
expect(parseOpencodeEntryBearer(cfg(), 'gbrain', 'http://127.0.0.1:7411/mcp')).toBe('gbt_secret_token_1');
|
||||
expect(parseOpencodeEntryBearer(cfg(), 'gbrain', 'http://other:1/mcp')).toBeNull();
|
||||
const envCfg = join(dir, 'env.json');
|
||||
writeOpencodeMcpEntry(envCfg, remoteInline({ tokenMode: 'env', bearerToken: undefined }));
|
||||
expect(parseOpencodeEntryBearer(envCfg, 'gbrain')).toBeNull();
|
||||
expect(parseOpencodeEntryBearer(join(dir, 'absent.json'), 'gbrain')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unique backups (overlapping-run clobber guard)', () => {
|
||||
test('each write takes a UNIQUE .bak-<hex> snapshot — two runs can never clobber each other\'s backup', () => {
|
||||
writeOpencodeMcpEntry(cfg(), remoteInline());
|
||||
const r1 = writeOpencodeMcpEntry(cfg(), remoteInline({ bearerToken: 'gbt_secret_token_2' }), {
|
||||
expect: { url: 'http://127.0.0.1:7411/mcp' },
|
||||
});
|
||||
const r2 = writeOpencodeMcpEntry(cfg(), remoteInline({ bearerToken: 'gbt_secret_token_3' }), {
|
||||
expect: { url: 'http://127.0.0.1:7411/mcp' },
|
||||
});
|
||||
expect(r1.backupPath).not.toBeNull();
|
||||
expect(r2.backupPath).not.toBeNull();
|
||||
expect(r1.backupPath).not.toBe(r2.backupPath);
|
||||
expect(r1.backupPath as string).toMatch(/\.bak-[0-9a-f]+$/);
|
||||
// BOTH snapshots survive: the fixed-name scheme would have overwritten r1's.
|
||||
expect(readFileSync(r1.backupPath as string, 'utf8')).toContain('gbt_secret_token_1');
|
||||
expect(readFileSync(r2.backupPath as string, 'utf8')).toContain('gbt_secret_token_2');
|
||||
});
|
||||
|
||||
test('writtenText in the result is the exact bytes on disk (rollback compare seam)', () => {
|
||||
const r = writeOpencodeMcpEntry(cfg(), remoteInline());
|
||||
expect(r.writtenText).toBe(readFileSync(cfg(), 'utf8'));
|
||||
});
|
||||
|
||||
test('remove path: backup is 0600 whenever the copied content carries an inline bearer', () => {
|
||||
writeOpencodeMcpEntry(cfg(), remoteInline());
|
||||
chmodSync(cfg(), 0o644); // hand-loosened source must not propagate to a token-bearing backup
|
||||
const r = removeOpencodeMcpEntry(cfg(), 'gbrain', { url: 'http://127.0.0.1:7411/mcp' });
|
||||
expect(r.removed).toBe(true);
|
||||
expect(r.backupPath).toMatch(/\.bak-[0-9a-f]+$/);
|
||||
expect(statSync(r.backupPath as string).mode & 0o777).toBe(0o600);
|
||||
expect(readFileSync(r.backupPath as string, 'utf8')).toContain('gbt_secret_token_1');
|
||||
});
|
||||
|
||||
test('remove path: env-interpolated (token-free) content does NOT force the backup to 0600', () => {
|
||||
writeOpencodeMcpEntry(cfg(), remoteInline({ tokenMode: 'env', bearerToken: undefined }));
|
||||
chmodSync(cfg(), 0o644);
|
||||
const r = removeOpencodeMcpEntry(cfg(), 'gbrain');
|
||||
expect(r.removed).toBe(true);
|
||||
expect(statSync(r.backupPath as string).mode & 0o777).not.toBe(0o600);
|
||||
});
|
||||
|
||||
test('textCarriesInlineBearer: inline bearer yes, {env:} interpolation no', () => {
|
||||
expect(textCarriesInlineBearer('"Authorization": "Bearer gbt_x"')).toBe(true);
|
||||
expect(textCarriesInlineBearer('"Authorization": "Bearer {env:GBRAIN_REMOTE_TOKEN}"')).toBe(false);
|
||||
expect(textCarriesInlineBearer('{"theme":"dark"}')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sibling-global reconcile (two-filename merge blind spot)', () => {
|
||||
test('opencodeGlobalSiblingPath pairs the two global names; non-members are null', () => {
|
||||
expect(opencodeGlobalSiblingPath(join(dir, 'opencode.json'))).toBe(join(dir, 'opencode.jsonc'));
|
||||
expect(opencodeGlobalSiblingPath(join(dir, 'opencode.jsonc'))).toBe(join(dir, 'opencode.json'));
|
||||
expect(opencodeGlobalSiblingPath(join(dir, 'config.json'))).toBeNull();
|
||||
});
|
||||
|
||||
test('sibling carrying OUR entry is cleaned (note printed) so exactly one global file owns the name', () => {
|
||||
const primary = cfg('opencode.jsonc');
|
||||
const sibling = cfg('opencode.json');
|
||||
writeFileSync(sibling, '{"mcp":{"gbrain":{"type":"local","command":["gbrain","serve"],"environment":{"GBRAIN_SOURCE":"ws-b"}}}}');
|
||||
const r = reconcileOpencodeSiblingGlobal(primary, 'gbrain', { sourceId: 'ws-a' });
|
||||
expect(r.removed).toBe(true);
|
||||
expect(r.siblingPath).toBe(sibling);
|
||||
expect(r.notes.join(' ')).toContain('opencode merges both global filenames');
|
||||
const parsed = parseOpencodeConfig(readFileSync(sibling, 'utf8'), sibling);
|
||||
expect((parsed.mcp as Record<string, unknown> | undefined)?.gbrain).toBeUndefined();
|
||||
});
|
||||
|
||||
test('sibling carrying a FOREIGN entry refuses loudly naming BOTH files', () => {
|
||||
const primary = cfg('opencode.jsonc');
|
||||
const sibling = cfg('opencode.json');
|
||||
const foreign = '{"mcp":{"gbrain":{"type":"local","command":["npx","other"],"environment":{}}}}';
|
||||
writeFileSync(sibling, foreign);
|
||||
let message = '';
|
||||
try {
|
||||
reconcileOpencodeSiblingGlobal(primary, 'gbrain', { sourceId: 'ws-a' });
|
||||
} catch (e) {
|
||||
message = (e as Error).message;
|
||||
}
|
||||
expect(message).toContain('not a gbrain-managed entry');
|
||||
expect(message).toContain(sibling);
|
||||
expect(message).toContain(primary);
|
||||
expect(readFileSync(sibling, 'utf8')).toBe(foreign); // untouched
|
||||
});
|
||||
|
||||
test('absent sibling / absent entry / non-pair paths are calm no-ops', () => {
|
||||
expect(reconcileOpencodeSiblingGlobal(cfg('opencode.jsonc'), 'gbrain').removed).toBe(false);
|
||||
writeFileSync(cfg('opencode.json'), '{"mcp":{}}');
|
||||
expect(reconcileOpencodeSiblingGlobal(cfg('opencode.jsonc'), 'gbrain').removed).toBe(false);
|
||||
expect(reconcileOpencodeSiblingGlobal(join(dir, 'not-a-pair.json'), 'gbrain').removed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote-path refusal text is caller-appropriate (url, not GBRAIN_SOURCE)', () => {
|
||||
test('ours entry at a DIFFERENT url refuses mentioning the url mismatch and --force', () => {
|
||||
writeOpencodeMcpEntry(cfg(), remoteInline({ tokenMode: 'env', bearerToken: undefined, url: 'https://old.example/mcp' }));
|
||||
let message = '';
|
||||
try {
|
||||
writeOpencodeMcpEntry(
|
||||
cfg(),
|
||||
remoteInline({ tokenMode: 'env', bearerToken: undefined, url: 'https://new.example/mcp' }),
|
||||
{ expect: { url: 'https://new.example/mcp' } },
|
||||
);
|
||||
} catch (e) {
|
||||
message = (e as Error).message;
|
||||
}
|
||||
expect(message).toContain('does not match this endpoint');
|
||||
expect(message).toContain('https://new.example/mcp');
|
||||
expect(message).toContain('--force');
|
||||
expect(message).not.toContain('GBRAIN_SOURCE');
|
||||
// With the overwrite confirmation the rotation lands.
|
||||
const r = writeOpencodeMcpEntry(
|
||||
cfg(),
|
||||
remoteInline({ tokenMode: 'env', bearerToken: undefined, url: 'https://new.example/mcp' }),
|
||||
{ expect: { url: 'https://new.example/mcp' }, allowReplaceOtherSource: true },
|
||||
);
|
||||
expect(r.priorKind).toBe('ours-other-source');
|
||||
expect(r.notes.join(' ')).toContain('url/lane mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
test('absent file and absent entry are calm no-ops', () => {
|
||||
const r1 = removeOpencodeMcpEntry(cfg(), 'gbrain');
|
||||
expect(r1.removed).toBe(false);
|
||||
writeFileSync(cfg(), '{"mcp":{}}');
|
||||
const r2 = removeOpencodeMcpEntry(cfg(), 'gbrain');
|
||||
expect(r2.removed).toBe(false);
|
||||
});
|
||||
|
||||
test('ours removed; comments and foreign servers survive byte-meaningfully', () => {
|
||||
writeFileSync(
|
||||
cfg('opencode.jsonc'),
|
||||
`{
|
||||
// keep me
|
||||
"theme": "dark",
|
||||
"mcp": {
|
||||
"other-server": { "type": "remote", "url": "https://other.example/mcp" },
|
||||
"gbrain": { "type": "local", "command": ["gbrain", "serve"], "environment": { "GBRAIN_SOURCE": "ws-a" } }
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
const res = removeOpencodeMcpEntry(cfg('opencode.jsonc'), 'gbrain', { sourceId: 'ws-a' });
|
||||
expect(res.removed).toBe(true);
|
||||
const text = readFileSync(cfg('opencode.jsonc'), 'utf8');
|
||||
expect(text).toContain('// keep me');
|
||||
const parsed = parseOpencodeConfig(text, cfg('opencode.jsonc'));
|
||||
const mcp = parsed.mcp as Record<string, unknown>;
|
||||
expect(mcp.gbrain).toBeUndefined();
|
||||
expect((mcp['other-server'] as Record<string, unknown>).url).toBe('https://other.example/mcp');
|
||||
});
|
||||
|
||||
test('foreign entry under our name → refuse to remove', () => {
|
||||
writeFileSync(cfg(), '{"mcp":{"gbrain":{"type":"local","command":["npx","other"],"environment":{}}}}');
|
||||
expect(() => removeOpencodeMcpEntry(cfg(), 'gbrain')).toThrow(/refusing to remove/);
|
||||
expect(readFileSync(cfg(), 'utf8')).toContain('npx');
|
||||
});
|
||||
|
||||
test('skipOtherSource: a DIFFERENT workspace\'s entry is left in place with a note (uninstall-sweep shape)', () => {
|
||||
const before = '{"mcp":{"gbrain":{"type":"local","command":["gbrain","serve"],"environment":{"GBRAIN_SOURCE":"ws-b"}}}}';
|
||||
writeFileSync(cfg(), before);
|
||||
const r = removeOpencodeMcpEntry(cfg(), 'gbrain', { sourceId: 'ws-a' }, { skipOtherSource: true });
|
||||
expect(r.removed).toBe(false);
|
||||
expect(r.notes.join(' ')).toMatch(/DIFFERENT gbrain workspace.*left in place/);
|
||||
expect(readFileSync(cfg(), 'utf8')).toBe(before);
|
||||
// Without the flag the pre-existing behavior holds: removed with a note.
|
||||
const r2 = removeOpencodeMcpEntry(cfg(), 'gbrain', { sourceId: 'ws-a' });
|
||||
expect(r2.removed).toBe(true);
|
||||
expect(r2.notes.join(' ')).toContain('different workspace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('misc', () => {
|
||||
test('entry names are simple keys only', () => {
|
||||
expect(() => writeOpencodeMcpEntry(cfg(), localEntry({ name: 'bad name!' }))).toThrow(/simpler --name/);
|
||||
});
|
||||
|
||||
test('snippet is valid JSON carrying the exact entry (paste-by-hand path)', () => {
|
||||
const snippet = opencodeEntrySnippet(localEntry());
|
||||
const parsed = JSON.parse(snippet) as { mcp: Record<string, { type: string }> };
|
||||
expect(parsed.mcp.gbrain.type).toBe('local');
|
||||
});
|
||||
|
||||
test('snippet NEVER embeds an inline bearer — placeholder only (error paths render the snippet)', () => {
|
||||
const snippet = opencodeEntrySnippet(remoteInline());
|
||||
expect(snippet).not.toContain('gbt_secret_token_1');
|
||||
expect(snippet).toContain('Bearer <paste-token-here>');
|
||||
// The write path is unaffected: the real token still lands in the file.
|
||||
writeOpencodeMcpEntry(cfg(), remoteInline());
|
||||
expect(readFileSync(cfg(), 'utf8')).toContain('gbt_secret_token_1');
|
||||
});
|
||||
|
||||
test('parse-refusal error text carries the placeholder snippet, not the token', () => {
|
||||
writeFileSync(cfg(), '{"mcp": {{{');
|
||||
let message = '';
|
||||
try {
|
||||
writeOpencodeMcpEntry(cfg(), remoteInline());
|
||||
} catch (e) {
|
||||
message = (e as Error).message;
|
||||
}
|
||||
expect(message).toMatch(/does not parse as JSONC/);
|
||||
expect(message).toContain('<paste-token-here>');
|
||||
expect(message).not.toContain('gbt_secret_token_1');
|
||||
});
|
||||
});
|
||||
@@ -40,8 +40,8 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('isVolunteerChannel', () => {
|
||||
test('accepts exactly the five known channels; rejects everything else', () => {
|
||||
for (const ok of ['op', 'reflex', 'watch', 'claude-code', 'codex']) {
|
||||
test('accepts exactly the six known channels; rejects everything else', () => {
|
||||
for (const ok of ['op', 'reflex', 'watch', 'claude-code', 'codex', 'opencode']) {
|
||||
expect(isVolunteerChannel(ok)).toBe(true);
|
||||
}
|
||||
for (const bad of ['vim', '', null, undefined, 42, {}, 'CLAUDE-CODE', 'hook']) {
|
||||
@@ -121,6 +121,9 @@ describe('channel guards', () => {
|
||||
const { isHarnessChannel } = await import('../src/core/context/volunteer-events.ts');
|
||||
expect(isHarnessChannel('claude-code')).toBe(true);
|
||||
expect(isHarnessChannel('codex')).toBe(true);
|
||||
// opencode widening (v0.45.x): without this membership a hook delivery
|
||||
// attributed `--harness opencode` silently rebadges as claude-code.
|
||||
expect(isHarnessChannel('opencode')).toBe(true);
|
||||
for (const internal of ['op', 'reflex', 'watch']) expect(isHarnessChannel(internal)).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -242,5 +242,27 @@ printf '%s\n' "$codex_out" | grep -Fq "verified targeting this workspace" \
|
||||
grep -Fq "GBRAIN_SOURCE=" "$GB_CODEX_STATE" || fail "codex registration did not bind GBRAIN_SOURCE"
|
||||
grep -Fq "serve --surface full" "$GB_CODEX_STATE" || fail "codex registration did not pin the full op surface"
|
||||
|
||||
# ── opencode door: direct-writer registration, NO binary at all ─────────────
|
||||
# The opencode lane needs no CLI (the JSONC writer is the registration), so
|
||||
# the offline container exercises it with nothing faked except the config
|
||||
# location (XDG_CONFIG_HOME → scratch). Asserts: user-global default scope
|
||||
# (no MCP_SCOPE answer recorded → the sharing-safe inversion), the entry
|
||||
# shape (absolute binary, GBRAIN_SOURCE bound, full surface), and no Claude
|
||||
# hooks written.
|
||||
step "opencode MCP registration (direct JSONC writer, no binary)"
|
||||
export XDG_CONFIG_HOME="$SCRATCH/xdg-config"
|
||||
opencode_out="$(gbrain bootstrap hooks --workspace "$WS" --harness opencode --gbrain-bin "$FAKE_GBRAIN" 2>&1)"
|
||||
printf '%s\n' "$opencode_out"
|
||||
printf '%s\n' "$opencode_out" | grep -Fq "scope: user-global" \
|
||||
|| fail "opencode registration did not default to user-global scope"
|
||||
OC_CFG="$XDG_CONFIG_HOME/opencode/opencode.jsonc"
|
||||
[ -f "$OC_CFG" ] || fail "opencode writer did not create the user-global config"
|
||||
grep -Fq '"GBRAIN_SOURCE"' "$OC_CFG" || fail "opencode registration did not bind GBRAIN_SOURCE"
|
||||
grep -Fq '"--surface"' "$OC_CFG" || fail "opencode registration did not pin a surface"
|
||||
grep -Fq "\"$FAKE_GBRAIN\"" "$OC_CFG" || fail "opencode user-global entry must carry the absolute binary path"
|
||||
printf '%s\n' "$opencode_out" | grep -Fq "AGENTS.md" \
|
||||
|| fail "opencode wiring did not state the pull protocol plainly"
|
||||
unset XDG_CONFIG_HOME
|
||||
|
||||
echo
|
||||
echo "PASS: offline bootstrap e2e (interview -> render -> repo -> abort/resume -> keyless verify -> codex MCP)"
|
||||
echo "PASS: offline bootstrap e2e (interview -> render -> repo -> abort/resume -> keyless verify -> codex MCP -> opencode MCP)"
|
||||
|
||||
Reference in New Issue
Block a user