diff --git a/.agent/.actors/actor.via.slug=.default/brain/.claude/settings.json b/.agent/.actors/actor.via.slug=.default/brain/.claude/settings.json index 8e182345..8bf6db97 100644 --- a/.agent/.actors/actor.via.slug=.default/brain/.claude/settings.json +++ b/.agent/.actors/actor.via.slug=.default/brain/.claude/settings.json @@ -7,20 +7,20 @@ { "type": "command", "command": "./node_modules/.bin/rhx route.drive --when hook.onBoot", - "timeout": 60, + "timeout": 5, "author": "repo=bhrain/role=driver" }, { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/sessionstart.notify-permissions", - "timeout": 5, - "author": "repo=ehmpathy/role=mechanic" + "command": "./node_modules/.bin/rhachet run --repo bhuild --role behaver --init claude.hooks/sessionstart.boot-behavior", + "timeout": 10, + "author": "repo=bhuild/role=behaver" }, { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo bhuild --role behaver --init claude.hooks/sessionstart.boot-behavior", + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/sessionstart.notify-permissions.sh", "timeout": 10, - "author": "repo=bhuild/role=behaver" + "author": "repo=ehmpathy/role=mechanic" } ] }, @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/postcompact.trust-but-verify", + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/postcompact.trust-but-verify.sh", "timeout": 30, "author": "repo=ehmpathy/role=mechanic" } @@ -48,14 +48,14 @@ }, { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.forbid-terms.gerunds", - "timeout": 5, + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/pretooluse.forbid-terms.gerunds.sh", + "timeout": 10, "author": "repo=ehmpathy/role=mechanic" }, { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.forbid-terms.blocklist", - "timeout": 5, + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/pretooluse.forbid-terms.blocklist.sh", + "timeout": 10, "author": "repo=ehmpathy/role=mechanic" } ] @@ -76,39 +76,39 @@ "hooks": [ { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.forbid-test-background", + "command": "./node_modules/.bin/rhx route.foreground.guard --mode hook", "timeout": 5, - "author": "repo=ehmpathy/role=mechanic" + "author": "repo=bhrain/role=driver" }, { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.forbid-sedreplace-special-chars", - "timeout": 5, + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/pretooluse.forbid-test-background.sh", + "timeout": 10, "author": "repo=ehmpathy/role=mechanic" }, { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.forbid-suspicious-shell-syntax", - "timeout": 5, + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/pretooluse.forbid-sedreplace-special-chars.sh", + "timeout": 10, "author": "repo=ehmpathy/role=mechanic" }, { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.forbid-stderr-redirect", - "timeout": 5, + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/pretooluse.forbid-suspicious-shell-syntax.sh", + "timeout": 10, "author": "repo=ehmpathy/role=mechanic" }, { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.check-permissions", - "timeout": 5, + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/pretooluse.forbid-stderr-redirect.sh", + "timeout": 10, "author": "repo=ehmpathy/role=mechanic" }, { "type": "command", - "command": "./node_modules/.bin/rhx route.foreground.guard --mode hook", - "timeout": 5, - "author": "repo=bhrain/role=driver" + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/pretooluse.check-permissions.sh", + "timeout": 10, + "author": "repo=ehmpathy/role=mechanic" } ] }, @@ -117,8 +117,8 @@ "hooks": [ { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.forbid-tmp-writes", - "timeout": 5, + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/pretooluse.forbid-tmp-writes.sh", + "timeout": 10, "author": "repo=ehmpathy/role=mechanic" } ] @@ -128,8 +128,8 @@ "hooks": [ { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.forbid-planmode", - "timeout": 5, + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/pretooluse.forbid-planmode.sh", + "timeout": 10, "author": "repo=ehmpathy/role=mechanic" } ] @@ -139,7 +139,7 @@ "hooks": [ { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/posttooluse.guardBorder.onWebfetch", + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/posttooluse.guardBorder.onWebfetch.sh", "timeout": 60, "author": "repo=ehmpathy/role=mechanic" } @@ -150,8 +150,8 @@ "hooks": [ { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.forbid-shouted-readme", - "timeout": 5, + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/pretooluse.forbid-shouted-readme.sh", + "timeout": 10, "author": "repo=ehmpathy/role=mechanic" } ] @@ -172,8 +172,8 @@ "hooks": [ { "type": "command", - "command": "./node_modules/.bin/rhachet run --repo ehmpathy --role mechanic --init claude.hooks/pretooluse.forbid-cross-repo-access", - "timeout": 5, + "command": "bash .agent/repo=ehmpathy/role=mechanic/inits/claude.hooks/pretooluse.forbid-cross-repo-access.sh", + "timeout": 10, "author": "repo=ehmpathy/role=mechanic" } ] @@ -186,7 +186,7 @@ { "type": "command", "command": "./node_modules/.bin/rhx route.drive --when hook.onStop", - "timeout": 60, + "timeout": 5, "author": "repo=bhrain/role=driver" } ] diff --git a/.agent/repo=.this/role=any/briefs/define.brain-dir-repo-vs-actor.md b/.agent/repo=.this/role=any/briefs/define.brain-dir-repo-vs-actor.md index 210bdc1f..ac368471 100644 --- a/.agent/repo=.this/role=any/briefs/define.brain-dir-repo-vs-actor.md +++ b/.agent/repo=.this/role=any/briefs/define.brain-dir-repo-vs-actor.md @@ -36,6 +36,23 @@ boot.md # the rendered role corpus β€” ours | the repo's | the repo's default roles | | the actor's | that actor's enrolled roles | +## .credentials + +> **the config is the actor's. the login is the box's.** + +a brain dir holds boot, settings, and transcripts. it never holds the claude login. + +| what | where | why | +|---|---|---| +| the login (`.credentials.json`) | `~/.claude`, one per box | it derives from the human's login, never from `{ brain, roles }` | +| its write lock and its refresh lock | `~/.claude`, beside it | one lock set, so a refresh loser waits, re-reads, and adopts the winner's token | + +- **how.** every clone spawns with `CLAUDE_SECURESTORAGE_CONFIG_DIR=''` beside its per-actor `CLAUDE_CONFIG_DIR`. in claude-code, `''` keys the login file and both locks by `~/.claude`; unset keys them by the config dir. rhachet sets the var after the caller env, so a caller value never reopens the split. +- πŸ”΄ **never link or copy the login into a brain dir.** N actors over one linked file means N lock sets: a refresh winner renames over its link, and the next loser blanks the shared file for the whole box. that is the rhachet 1.48.0 outage. +- **a dead login refuses the enroll.** a login whose refresh token claude-code emptied exits 2 and names `/login`. one `/login` refills the one file, and every live clone recovers with no respawn. an env credential (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`) outranks the file and is never refused. +- **a 1.48.0 leftover in a brain dir** is kept while a clone of the actor lives, then removed. a live real file is first adopted into `~/.claude` only when the shared login is dead or absent β€” a live shared login may be refreshed by any peer at any instant, so it is never overwritten. +- **the clamp.** `blackbox/cli/enroll.shared-brain-auth.acceptance.test.ts` runs a real claude-code through rhachet's spawn env. if a claude-code release stops to honor the var, it goes red. + ## .invariants - **no third place.** a boot never comes from `~/.claude`, and the human's own user-scope memory never loads into a clone. the relocation alone does not hold this β€” the cli still loads a literal `~/.claude/CLAUDE.md` β€” so the enroll artifact's `claudeMdExcludes` names it. diff --git a/.agent/repo=.this/role=any/skills/__snapshots__/claude.cli.skills.integration.test.ts.snap b/.agent/repo=.this/role=any/skills/__snapshots__/claude.cli.skills.integration.test.ts.snap new file mode 100644 index 00000000..365722d3 --- /dev/null +++ b/.agent/repo=.this/role=any/skills/__snapshots__/claude.cli.skills.integration.test.ts.snap @@ -0,0 +1,193 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`claude.cli probe skills given: [case] claude.cli.secstore.trial against a claude bin that prints no version when: [t0] the trial is run then: it fails loud with exit 1, before any scenario 1`] = ` +"πŸ’₯ MalfunctionError: the probe claude did not print a version (exit 0) +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.secstore.trial when: [t0] an unknown arg is passed then: it refuses with exit 2 and names the arg 1`] = ` +"βœ‹ ConstraintError: claude.cli.secstore.trial: unknown arg '--bogus' +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.secstore.trial when: [t1] --in is absent then: it refuses with exit 2 and shows the usage 1`] = ` +"βœ‹ ConstraintError: claude.cli.secstore.trial needs --in + usage: claude.cli.secstore.trial --in +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.secstore.trial when: [t2] --in names no probe install then: it refuses with exit 2 and names the install command 1`] = ` +"βœ‹ ConstraintError: no probe install at '.temp/absent-probe' + fix: rhx claude.cli.probe --into .temp/absent-probe +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.secstore.trial when: [t3] --in sits outside the repo then: it refuses with exit 2, and reads no file there 1`] = ` +"βœ‹ ConstraintError: --in must sit inside the repo () +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.secstore.trial when: [t4] --help is passed then: it prints the header and exits 0 1`] = ` +"###################################################################### +# .what = run a probe claude-code against a fake home inside the repo, +# and report which credential file it reads and which it +# writes, with and without CLAUDE_SECURESTORAGE_CONFIG_DIR="" +# +# .why = proves by a run (not by a read of the code) where the +# credential store lands under a per-actor CLAUDE_CONFIG_DIR β€” +# the premise of the shared-credential-blank cure. it never +# touches the real ~/.claude: HOME is a temp dir in the repo, +# and every token it plants is a fake it made itself +# +# usage: +# rhx claude.cli.probe --into .temp/claude-cli.probe --version 2.1.280 +# rhx claude.cli.secstore.trial --in .temp/claude-cli.probe +# +# options: +# --in the probe prefix \`claude.cli.probe --into\` made; must sit +# inside the repo (required) +# +# requires: jq on the PATH (it reads each credential); refused if absent +# +# the scenarios: +# read.unset β€” the home store holds a valid fake login; the actor +# dir is empty; the var is unset +# read.empty β€” same, with CLAUDE_SECURESTORAGE_CONFIG_DIR="" +# read.link β€” the actor's credential is a symlink to the home store +# (the rhachet 1.48.0 topology); the var is unset +# write.link β€” the home store holds an EXPIRED fake login, the actor +# links to it, the var is unset. claude refreshes, the +# server rejects the fake token (invalid_grant), and +# claude-code's dead-token clear blanks the file under +# its secure-storage dir. where the blank lands = where +# a write lands +# write.empty β€” same expired login, no link, var = "" +# +# guarantee: +# - HOME, the config dirs and every file live under .temp/ in the repo +# - the env is built from empty (env -i): no real token, api key, or +# config dir leaks in from the caller +# - a credential is reported as planted | blank | other, never a value +# - write.* sends one fake refresh token to the oauth endpoint +# - exit 0 = trial ran (the verdicts are the result) +# - exit 1 = malfunction (the probe did not run) +# - exit 2 = constraint (bad args, prefix outside the repo, no install, +# no jq) +###################################################################### + +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.secstore.trial when: [t5] --in is a dir in the repo with no claude-code install then: it refuses with exit 2 and names the install command 1`] = ` +"βœ‹ ConstraintError: no claude bin under '.temp/claude.cli.skills.test/empty' + fix: rhx claude.cli.probe --into .temp/claude.cli.skills.test/empty +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings against a fake claude-code package in the repo when: [t0] --radius is not a whole number then: it refuses with exit 2 1`] = ` +"βœ‹ ConstraintError: --radius and --limit must be whole numbers +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings against a fake claude-code package in the repo when: [t1] --pattern is not a valid extended regex then: it refuses with exit 2, never a zero-match report 1`] = ` +"βœ‹ ConstraintError: --pattern is not a valid extended regex: 'lock(' +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings against a fake claude-code package in the repo when: [t2] the pattern matches, in a text file and in a binary then: it exits 0 and reports each distinct match once 1`] = ` +"πŸ”­ claude.cli.strings --in .temp/claude.cli.skills.test/probe --pattern 'storage-write' --radius 8 --limit 20 + β”œβ”€ package: @anthropic-ai/claude-code@9.9.9 + β”œβ”€ matches: 2 distinct + └─ shown: first 2 + +──── +...bin.storage-write.end.. +──── +(dir, ".storage-write.lock"); +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings against a fake claude-code package in the repo when: [t3] the pattern matches no file then: it exits 0 and reports zero matches 1`] = ` +"πŸ”­ claude.cli.strings --in .temp/claude.cli.skills.test/probe --pattern 'absentword' --radius 200 --limit 20 + β”œβ”€ package: @anthropic-ai/claude-code@9.9.9 + β”œβ”€ matches: 0 distinct + └─ crickets +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings against a package link that resolves outside the repo when: [t0] a search is run then: it refuses with exit 2, and reads no file there 1`] = ` +"βœ‹ ConstraintError: the claude-code package lands outside the repo (/usr) +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings against a package with an unreadable file when: [t0] a search is run then: it fails loud with exit 1, never a zero-match report 1`] = ` +"πŸ’₯ MalfunctionError: unreadable file .temp/claude.cli.skills.test/locked/node_modules/@anthropic-ai/claude-code/locked.js +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings when: [t0] an unknown arg is passed then: it refuses with exit 2 and names the arg 1`] = ` +"βœ‹ ConstraintError: claude.cli.strings: unknown arg '--bogus' +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings when: [t1] --in is absent then: it refuses with exit 2 and shows the usage 1`] = ` +"βœ‹ ConstraintError: claude.cli.strings needs --in and --pattern + usage: claude.cli.strings --in --pattern [--radius N] [--limit N] +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings when: [t2] --in names no probe install then: it refuses with exit 2 and names the install command 1`] = ` +"βœ‹ ConstraintError: no probe install at '.temp/absent-probe' + fix: rhx claude.cli.probe --into .temp/absent-probe +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings when: [t3] --in sits outside the repo then: it refuses with exit 2, and reads no file there 1`] = ` +"βœ‹ ConstraintError: --in must sit inside the repo () +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings when: [t4] --help is passed then: it prints the header and exits 0 1`] = ` +"###################################################################### +# .what = search the readable text embedded in a claude-code install +# (its native binary included) for a pattern, and print each +# match with the code around it +# +# .why = claude-code ships as a native binary with its js embedded as +# strings. to learn how it behaves (a lock path, a refresh +# flow, an env var it reads) a mechanic must search those +# strings; a raw grep -a over a binary needs a permission grant +# each time. this skill bounds that read to a probe install +# inside the repo +# +# usage: +# rhx claude.cli.probe --into .temp/claude-cli.probe --version 2.1.280 +# rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'credentials\\.json' +# rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'lockSync' --radius 400 --limit 5 +# +# options: +# --in the probe prefix \`claude.cli.probe --into\` made; must sit +# inside the repo (required) +# --pattern an extended regex to find (required) +# --radius chars of context on each side of a match (default: 200) +# --limit max distinct matches to print (default: 20) +# +# guarantee: +# - reads only files under the probe's claude-code package, which +# must sit inside the repo +# - non-printable bytes are shown as '.', so a binary match prints +# as one readable line +# - duplicate matches are printed once; the total is reported +# - exit 0 = searched (zero matches is a result, not an error) +# - exit 1 = malfunction (the package or its files are unreadable) +# - exit 2 = constraint (bad args, prefix outside the repo, no install) +###################################################################### + +" +`; + +exports[`claude.cli probe skills given: [case] claude.cli.strings when: [t5] --in is a dir in the repo with no claude-code install then: it refuses with exit 2 and names the install command 1`] = ` +"βœ‹ ConstraintError: no @anthropic-ai/claude-code under '.temp/claude.cli.skills.test/empty' + fix: rhx claude.cli.probe --into .temp/claude.cli.skills.test/empty +" +`; diff --git a/.agent/repo=.this/role=any/skills/claude.cli.secstore.trial.sh b/.agent/repo=.this/role=any/skills/claude.cli.secstore.trial.sh new file mode 100755 index 00000000..c378db9e --- /dev/null +++ b/.agent/repo=.this/role=any/skills/claude.cli.secstore.trial.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +###################################################################### +# .what = run a probe claude-code against a fake home inside the repo, +# and report which credential file it reads and which it +# writes, with and without CLAUDE_SECURESTORAGE_CONFIG_DIR="" +# +# .why = proves by a run (not by a read of the code) where the +# credential store lands under a per-actor CLAUDE_CONFIG_DIR β€” +# the premise of the shared-credential-blank cure. it never +# touches the real ~/.claude: HOME is a temp dir in the repo, +# and every token it plants is a fake it made itself +# +# usage: +# rhx claude.cli.probe --into .temp/claude-cli.probe --version 2.1.280 +# rhx claude.cli.secstore.trial --in .temp/claude-cli.probe +# +# options: +# --in the probe prefix `claude.cli.probe --into` made; must sit +# inside the repo (required) +# +# requires: jq on the PATH (it reads each credential); refused if absent +# +# the scenarios: +# read.unset β€” the home store holds a valid fake login; the actor +# dir is empty; the var is unset +# read.empty β€” same, with CLAUDE_SECURESTORAGE_CONFIG_DIR="" +# read.link β€” the actor's credential is a symlink to the home store +# (the rhachet 1.48.0 topology); the var is unset +# write.link β€” the home store holds an EXPIRED fake login, the actor +# links to it, the var is unset. claude refreshes, the +# server rejects the fake token (invalid_grant), and +# claude-code's dead-token clear blanks the file under +# its secure-storage dir. where the blank lands = where +# a write lands +# write.empty β€” same expired login, no link, var = "" +# +# guarantee: +# - HOME, the config dirs and every file live under .temp/ in the repo +# - the env is built from empty (env -i): no real token, api key, or +# config dir leaks in from the caller +# - a credential is reported as planted | blank | other, never a value +# - write.* sends one fake refresh token to the oauth endpoint +# - exit 0 = trial ran (the verdicts are the result) +# - exit 1 = malfunction (the probe did not run) +# - exit 2 = constraint (bad args, prefix outside the repo, no install, +# no jq) +###################################################################### + +set -euo pipefail + +# parse args +IN="" +while [[ $# -gt 0 ]]; do + case "$1" in + --in) IN="$2"; shift 2 ;; + # the rhx runner forwards its own dispatch flags; skip them + --skill|--repo|--role) shift 2 ;; + --help|-h) + sed -n '2,48p' "$0" + exit 0 + ;; + *) + echo "βœ‹ ConstraintError: claude.cli.secstore.trial: unknown arg '$1'" >&2 + exit 2 + ;; + esac +done +if [[ -z "$IN" ]]; then + echo "βœ‹ ConstraintError: claude.cli.secstore.trial needs --in" >&2 + echo " usage: claude.cli.secstore.trial --in " >&2 + exit 2 +fi + +# the probe prefix must sit inside the repo +REPO_ROOT="$(git rev-parse --show-toplevel)" +if [[ ! -d "$IN" ]]; then + echo "βœ‹ ConstraintError: no probe install at '$IN'" >&2 + echo " fix: rhx claude.cli.probe --into $IN" >&2 + exit 2 +fi +PREFIX="$(cd "$IN" && pwd -P)" +case "$PREFIX/" in + "$REPO_ROOT"/*) ;; + *) + echo "βœ‹ ConstraintError: --in must sit inside the repo ($REPO_ROOT)" >&2 + exit 2 + ;; +esac +CLAUDE_BIN="$PREFIX/node_modules/.bin/claude" +if [[ ! -x "$CLAUDE_BIN" ]]; then + echo "βœ‹ ConstraintError: no claude bin under '$IN'" >&2 + echo " fix: rhx claude.cli.probe --into $IN" >&2 + exit 2 +fi +if ! command -v jq >/dev/null; then + echo "βœ‹ ConstraintError: claude.cli.secstore.trial needs jq on the PATH, to read each credential" >&2 + echo " fix: install jq (e.g. apt install jq)" >&2 + exit 2 +fi + +# .what = the first line of a text +# usage: first_line_of text= +first_line_of() { + local text="" arg + for arg in "$@"; do + case "$arg" in + text=*) text="${arg#text=}" ;; + *) echo "πŸ’₯ MalfunctionError: first_line_of: unknown arg '$arg'" >&2; exit 1 ;; + esac + done + printf '%s' "${text%%$'\n'*}" +} + +# .what = the last line of a text, cut to a max width +# usage: last_line_of text= width= +last_line_of() { + local text="" width="" line arg + for arg in "$@"; do + case "$arg" in + text=*) text="${arg#text=}" ;; + width=*) width="${arg#width=}" ;; + *) echo "πŸ’₯ MalfunctionError: last_line_of: unknown arg '$arg'" >&2; exit 1 ;; + esac + done + text="${text%$'\n'}" + line="${text##*$'\n'}" + printf '%s' "${line:0:$width}" +} + +VERSION_OUT="" +VERSION_STATUS=0 +VERSION_OUT="$(env -i PATH=/usr/bin:/bin HOME="$PREFIX" "$CLAUDE_BIN" --version 2>/dev/null)" || VERSION_STATUS=$? +VERSION="$(first_line_of text="$VERSION_OUT")" +if [[ "$VERSION_STATUS" -ne 0 || -z "$VERSION" ]]; then + echo "πŸ’₯ MalfunctionError: the probe claude did not print a version (exit $VERSION_STATUS)" >&2 + exit 1 +fi + +# the trial root, fresh each run, inside the repo; keyed by this run's pid, so a +# concurrent run never clears the scenario dirs of another +TRIAL_ROOT="$REPO_ROOT/.temp/claude.secstore.trial/run.$$" +case "$TRIAL_ROOT/" in + "$REPO_ROOT"/.temp/*) ;; + *) echo "πŸ’₯ MalfunctionError: trial root escaped .temp" >&2; exit 1 ;; +esac +rm -rf "$TRIAL_ROOT" +mkdir -p "$TRIAL_ROOT" + +# a fake login; the refresh token is a marker we made, so it is safe to compare +# usage: plant_credential path= marker= expires_ms= +plant_credential() { + local path="" marker="" expires_ms="" arg + for arg in "$@"; do + case "$arg" in + path=*) path="${arg#path=}" ;; + marker=*) marker="${arg#marker=}" ;; + expires_ms=*) expires_ms="${arg#expires_ms=}" ;; + *) echo "πŸ’₯ MalfunctionError: plant_credential: unknown arg '$arg'" >&2; exit 1 ;; + esac + done + mkdir -p "$(dirname "$path")" + printf '{"claudeAiOauth":{"accessToken":"fake-access-%s","refreshToken":"%s","expiresAt":%s,"scopes":["user:inference","user:profile"],"subscriptionType":"max"}}\n' \ + "$marker" "$marker" "$expires_ms" > "$path" + chmod 600 "$path" +} + +# .what = a path under the trial root, shown relative to it +# usage: as_trial_relative path= +as_trial_relative() { + local path="" arg + for arg in "$@"; do + case "$arg" in + path=*) path="${arg#path=}" ;; + *) echo "πŸ’₯ MalfunctionError: as_trial_relative: unknown arg '$arg'" >&2; exit 1 ;; + esac + done + printf '%s' "${path#"$TRIAL_ROOT"/}" +} + +# .what = epoch milliseconds, offset from now by a count of seconds +# usage: epoch_ms_from_now seconds= +epoch_ms_from_now() { + local seconds="" arg + for arg in "$@"; do + case "$arg" in + seconds=*) seconds="${arg#seconds=}" ;; + *) echo "πŸ’₯ MalfunctionError: epoch_ms_from_now: unknown arg '$arg'" >&2; exit 1 ;; + esac + done + echo "$(( ($(date +%s) + seconds) * 1000 ))" +} + +# describe one credential path: absent | symlink β†’ where | file; then planted | BLANK | other +# usage: describe_credential path= marker= +describe_credential() { + local path="" marker="" kind rt arg + for arg in "$@"; do + case "$arg" in + path=*) path="${arg#path=}" ;; + marker=*) marker="${arg#marker=}" ;; + *) echo "πŸ’₯ MalfunctionError: describe_credential: unknown arg '$arg'" >&2; exit 1 ;; + esac + done + if [[ -L "$path" ]]; then + kind="symlink β†’ $(as_trial_relative path="$(readlink "$path")")" + [[ -e "$path" ]] || kind="$kind (target absent)" + elif [[ -f "$path" ]]; then + kind="file" + else + echo "absent" + return + fi + rt="$(jq -r '.claudeAiOauth.refreshToken // "βˆ…"' "$path" 2>/dev/null)" || rt="?" + if [[ "$rt" == "$marker" ]]; then echo "$kind, planted" + elif [[ "$rt" == "" ]]; then echo "$kind, BLANK" + else echo "$kind, other" + fi +} + +# the env a clone would get: built from empty +# usage: run_claude scenario= home= config_dir= secstore= -- +# .note = claude's own exits (0 = answered, 1 = refused, e.g. no login) are results the +# trial reports; a timeout (124), an exec fault (126, 127) or a signal (>128) means the +# probe did not run, and fails loud rather than render as a scenario row +run_claude() { + local scenario="" home="" config_dir="" secstore="" + while [[ $# -gt 0 && "$1" != "--" ]]; do + case "$1" in + scenario=*) scenario="${1#scenario=}" ;; + home=*) home="${1#home=}" ;; + config_dir=*) config_dir="${1#config_dir=}" ;; + secstore=*) secstore="${1#secstore=}" ;; + *) echo "πŸ’₯ MalfunctionError: run_claude: unknown arg '$1'" >&2; exit 1 ;; + esac + shift + done + [[ "${1:-}" == "--" ]] && shift + local env_args=(PATH=/usr/bin:/bin HOME="$home" CLAUDE_CONFIG_DIR="$config_dir" DISABLE_AUTOUPDATER=1 TERM=dumb) + if [[ "$secstore" != "unset" ]]; then env_args+=(CLAUDE_SECURESTORAGE_CONFIG_DIR="$secstore"); fi + local status=0 + env -i "${env_args[@]}" timeout 90 "$CLAUDE_BIN" "$@" < /dev/null 2>&1 || status=$? + if [[ "$status" -gt 1 ]]; then + echo "πŸ’₯ MalfunctionError: the probe claude did not run in scenario '$scenario' (exit $status)" >&2 + return 1 + fi +} + +FAR_MS="$(epoch_ms_from_now seconds=$(( 30 * 86400 )))" +PAST_MS="$(epoch_ms_from_now seconds=-3600)" + +echo "πŸ”­ claude.cli.secstore.trial --in $IN" +echo " β”œβ”€ claude: $VERSION" +echo " β”œβ”€ root: .temp/claude.secstore.trial" +echo " β”‚" + +# ---- read scenarios: which file does `auth status` read? +for scenario in read.unset read.empty read.link; do + s_root="$TRIAL_ROOT/$scenario" + home="$s_root/home" + actor="$s_root/actor/brain/.claude" + marker="fake-refresh-$scenario" + mkdir -p "$actor" + plant_credential path="$home/.claude/.credentials.json" marker="$marker" expires_ms="$FAR_MS" + secstore="unset" + [[ "$scenario" == "read.empty" ]] && secstore="" + [[ "$scenario" == "read.link" ]] && ln -s "$home/.claude/.credentials.json" "$actor/.credentials.json" + out="$(run_claude scenario="$scenario" home="$home" config_dir="$actor" secstore="$secstore" -- auth status --json)" + logged_in="$(printf '%s' "$out" | jq -r '.loggedIn | tostring' 2>/dev/null)" || logged_in="? ($(last_line_of text="$out" width=160))" + echo " β”œβ”€ $scenario (secstore var: ${secstore:-\"\"})" + echo " β”‚ └─ auth status β†’ loggedIn: $logged_in" +done +echo " β”‚" + +# ---- write scenarios: where does claude-code's dead-token clear land? +for scenario in write.link write.empty; do + s_root="$TRIAL_ROOT/$scenario" + home="$s_root/home" + actor="$s_root/actor/brain/.claude" + marker="fake-refresh-$scenario" + mkdir -p "$actor" + plant_credential path="$home/.claude/.credentials.json" marker="$marker" expires_ms="$PAST_MS" + secstore="unset" + [[ "$scenario" == "write.empty" ]] && secstore="" + [[ "$scenario" == "write.link" ]] && ln -s "$home/.claude/.credentials.json" "$actor/.credentials.json" + out="$(run_claude scenario="$scenario" home="$home" config_dir="$actor" secstore="$secstore" -- -p "reply with ok" --max-turns 1)" + echo " β”œβ”€ $scenario (secstore var: ${secstore:-\"\"})" + echo " β”‚ β”œβ”€ claude said: $(last_line_of text="$out" width=140)" + echo " β”‚ β”œβ”€ home .credentials.json: $(describe_credential path="$home/.claude/.credentials.json" marker="$marker")" + echo " β”‚ β”œβ”€ actor .credentials.json: $(describe_credential path="$actor/.credentials.json" marker="$marker")" + echo " β”‚ β”œβ”€ home .claude/ holds: $(cd "$home/.claude" && ls -A | tr '\n' ' ')" + echo " β”‚ └─ actor brain/.claude/ holds: $(cd "$actor" && ls -A | tr '\n' ' ')" +done +echo " β”‚" +echo " └─ done β€” no real credential was read or written" diff --git a/.agent/repo=.this/role=any/skills/claude.cli.skills.integration.test.ts b/.agent/repo=.this/role=any/skills/claude.cli.skills.integration.test.ts new file mode 100644 index 00000000..aab85730 --- /dev/null +++ b/.agent/repo=.this/role=any/skills/claude.cli.skills.integration.test.ts @@ -0,0 +1,347 @@ +import { spawnSync } from 'child_process'; +import { + chmodSync, + mkdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'fs'; +import { join } from 'path'; +import { given, then, useThen, when } from 'test-fns'; + +/** + * .what = the refusal, help, and search paths of the claude-code probe skills, snapped + * .why = these are the surfaces a human meets first β€” a typo, an absent flag, a prefix + * outside the repo. each must refuse with exit 2 and name the fix, and the snapshot lets + * a reviewer read the words. the strings search runs against a fake package in the repo + * + * .note = only the paths that need no real claude-code install and no network are + * exercised here; the trial itself spends one fake oauth refresh against the live + * endpoint, which is a byhand probe and never a suite step β€” the verdict it reports is + * proven by `blackbox/cli/enroll.shared-brain-auth.acceptance.test.ts` + * .note = an integration test: it spawns bash and reads the filesystem + */ + +/** this file sits at `.agent/repo=.this/role=any/skills/`, so four hops reach the repo root */ +const REPO_ROOT = join(__dirname, '..', '..', '..', '..'); +const SKILLS_DIR = __dirname; + +/** + * .what = run one skill with args, from the repo root + * .why = the skills read `--in` relative to the cwd, as they do under `rhx` + */ +const runSkill = (input: { + skill: string; + args: string[]; +}): { status: number | null; stdout: string; stderr: string } => { + const result = spawnSync( + 'bash', + [join(SKILLS_DIR, `${input.skill}.sh`), ...input.args], + { cwd: REPO_ROOT, encoding: 'utf-8' }, + ); + const mask = (text: string): string => text.split(REPO_ROOT).join(''); + return { + status: result.status, + stdout: mask(result.stdout), + stderr: mask(result.stderr), + }; +}; + +/** + * .what = a fake probe prefix in the repo: a claude-code package with one text file and + * one binary file, each with the string `storage-write`; plus a dir with no package + * .why = the strings search and its success output must be snapped with no real install + * and no network; a byte outside the printable set proves the binary read path + */ +const FIXTURE_ROOT_REL = '.temp/claude.cli.skills.test'; +const FIXTURE_PROBE_REL = `${FIXTURE_ROOT_REL}/probe`; +const FIXTURE_EMPTY_REL = `${FIXTURE_ROOT_REL}/empty`; +const FIXTURE_LOCKED_REL = `${FIXTURE_ROOT_REL}/locked`; +const FIXTURE_MUTE_REL = `${FIXTURE_ROOT_REL}/mute`; +const FIXTURE_ESCAPE_REL = `${FIXTURE_ROOT_REL}/escape`; +beforeAll(() => { + const root = join(REPO_ROOT, FIXTURE_ROOT_REL); + rmSync(root, { recursive: true, force: true }); + mkdirSync(join(REPO_ROOT, FIXTURE_EMPTY_REL), { recursive: true }); + const pkg = join( + REPO_ROOT, + FIXTURE_PROBE_REL, + 'node_modules', + '@anthropic-ai', + 'claude-code', + ); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(pkg, 'package.json'), + `${JSON.stringify({ name: '@anthropic-ai/claude-code', version: '9.9.9' })}\n`, + ); + writeFileSync( + join(pkg, 'cli.js'), + 'const lockPath = join(dir, ".storage-write.lock");\n', + ); + writeFileSync( + join(pkg, 'claude'), + Buffer.concat([ + Buffer.from([0x00, 0x01, 0xff]), + Buffer.from('bin\x02storage-write\x03end'), + Buffer.from([0xfe, 0x00]), + ]), + ); + + // a package with one file no reader may open: the strings search must fail loud + const pkgLocked = join( + REPO_ROOT, + FIXTURE_LOCKED_REL, + 'node_modules', + '@anthropic-ai', + 'claude-code', + ); + mkdirSync(pkgLocked, { recursive: true }); + writeFileSync( + join(pkgLocked, 'package.json'), + `${JSON.stringify({ name: '@anthropic-ai/claude-code', version: '9.9.9' })}\n`, + ); + writeFileSync(join(pkgLocked, 'locked.js'), 'storage-write\n'); + chmodSync(join(pkgLocked, 'locked.js'), 0o000); + + // a package link that resolves outside the repo: the strings search must refuse it + // (the target is /usr, which the skill only resolves, never reads) + const scopeEscape = join( + REPO_ROOT, + FIXTURE_ESCAPE_REL, + 'node_modules', + '@anthropic-ai', + ); + mkdirSync(scopeEscape, { recursive: true }); + symlinkSync('/usr', join(scopeEscape, 'claude-code')); + + // a claude bin that prints no version: the trial must fail loud before any scenario + const binMute = join(REPO_ROOT, FIXTURE_MUTE_REL, 'node_modules', '.bin'); + mkdirSync(binMute, { recursive: true }); + writeFileSync(join(binMute, 'claude'), '#!/bin/sh\nexit 0\n'); + chmodSync(join(binMute, 'claude'), 0o755); +}); + +const SKILLS = [ + { + skill: 'claude.cli.strings', + argsValid: ['--pattern', 'x'], + }, + { + skill: 'claude.cli.secstore.trial', + argsValid: [], + }, +] as const; + +describe('claude.cli probe skills', () => { + SKILLS.map((thisSkill) => + given(`[case] ${thisSkill.skill}`, () => { + when('[t0] an unknown arg is passed', () => { + const result = useThen('it runs', async () => + runSkill({ skill: thisSkill.skill, args: ['--bogus', 'x'] }), + ); + then('it refuses with exit 2 and names the arg', () => { + expect(result.status).toEqual(2); + expect(result.stderr).toContain('βœ‹ ConstraintError:'); + expect(result.stderr).toContain("'--bogus'"); + expect(result.stderr).toMatchSnapshot(); + }); + }); + + when('[t1] --in is absent', () => { + const result = useThen('it runs', async () => + runSkill({ skill: thisSkill.skill, args: [...thisSkill.argsValid] }), + ); + then('it refuses with exit 2 and shows the usage', () => { + expect(result.status).toEqual(2); + expect(result.stderr).toContain('usage:'); + expect(result.stderr).toMatchSnapshot(); + }); + }); + + when('[t2] --in names no probe install', () => { + const result = useThen('it runs', async () => + runSkill({ + skill: thisSkill.skill, + args: ['--in', '.temp/absent-probe', ...thisSkill.argsValid], + }), + ); + then('it refuses with exit 2 and names the install command', () => { + expect(result.status).toEqual(2); + expect(result.stderr).toContain('rhx claude.cli.probe --into'); + expect(result.stderr).toMatchSnapshot(); + }); + }); + + when('[t3] --in sits outside the repo', () => { + const result = useThen('it runs', async () => + runSkill({ + skill: thisSkill.skill, + args: ['--in', '/', ...thisSkill.argsValid], + }), + ); + then('it refuses with exit 2, and reads no file there', () => { + expect(result.status).toEqual(2); + expect(result.stderr).toContain('--in must sit inside the repo'); + expect(result.stdout).toEqual(''); + expect(result.stderr).toMatchSnapshot(); + }); + }); + + when('[t4] --help is passed', () => { + const result = useThen('it runs', async () => + runSkill({ skill: thisSkill.skill, args: ['--help'] }), + ); + then('it prints the header and exits 0', () => { + expect(result.status).toEqual(0); + expect(result.stdout).toContain('.what ='); + expect(result.stdout).toContain('usage:'); + expect(result.stdout).toMatchSnapshot(); + }); + }); + + when('[t5] --in is a dir in the repo with no claude-code install', () => { + const result = useThen('it runs', async () => + runSkill({ + skill: thisSkill.skill, + args: ['--in', FIXTURE_EMPTY_REL, ...thisSkill.argsValid], + }), + ); + then('it refuses with exit 2 and names the install command', () => { + expect(result.status).toEqual(2); + expect(result.stderr).toContain('rhx claude.cli.probe --into'); + expect(result.stdout).toEqual(''); + expect(result.stderr).toMatchSnapshot(); + }); + }); + }), + ); + + given('[case] claude.cli.strings against a fake claude-code package in the repo', () => { + when('[t0] --radius is not a whole number', () => { + const result = useThen('it runs', async () => + runSkill({ + skill: 'claude.cli.strings', + args: ['--in', FIXTURE_PROBE_REL, '--pattern', 'x', '--radius', 'abc'], + }), + ); + then('it refuses with exit 2', () => { + expect(result.status).toEqual(2); + expect(result.stderr).toContain('whole numbers'); + expect(result.stderr).toMatchSnapshot(); + }); + }); + + when('[t1] --pattern is not a valid extended regex', () => { + const result = useThen('it runs', async () => + runSkill({ + skill: 'claude.cli.strings', + args: ['--in', FIXTURE_PROBE_REL, '--pattern', 'lock('], + }), + ); + then('it refuses with exit 2, never a zero-match report', () => { + expect(result.status).toEqual(2); + expect(result.stderr).toContain('not a valid extended regex'); + expect(result.stdout).not.toContain('matches:'); + expect(result.stderr).toMatchSnapshot(); + }); + }); + + when('[t2] the pattern matches, in a text file and in a binary', () => { + const result = useThen('it runs', async () => + runSkill({ + skill: 'claude.cli.strings', + args: [ + '--in', + FIXTURE_PROBE_REL, + '--pattern', + 'storage-write', + '--radius', + '8', + ], + }), + ); + then('it exits 0 and reports each distinct match once', () => { + expect(result.status).toEqual(0); + expect(result.stdout).toContain('@anthropic-ai/claude-code@9.9.9'); + expect(result.stdout).toContain('matches: 2 distinct'); + expect(result.stdout).toMatchSnapshot(); + }); + then('a binary byte prints as a dot', () => { + expect(result.stdout).toContain('bin.storage-write.end'); + }); + }); + + when('[t3] the pattern matches no file', () => { + const result = useThen('it runs', async () => + runSkill({ + skill: 'claude.cli.strings', + args: ['--in', FIXTURE_PROBE_REL, '--pattern', 'absentword'], + }), + ); + then('it exits 0 and reports zero matches', () => { + expect(result.status).toEqual(0); + expect(result.stdout).toContain('matches: 0 distinct'); + expect(result.stdout).toMatchSnapshot(); + }); + }); + }); + + /** + * .note = the strings skill's "the search failed" malfunction is not exercised: it + * fires only when find, sort, xargs, tr or awk themselves fail, which no fixture in + * the repo can provoke without a fault injected into the host tools + */ + given('[case] claude.cli.strings against a package with an unreadable file', () => { + when('[t0] a search is run', () => { + const result = useThen('it runs', async () => + runSkill({ + skill: 'claude.cli.strings', + args: ['--in', FIXTURE_LOCKED_REL, '--pattern', 'storage-write'], + }), + ); + then('it fails loud with exit 1, never a zero-match report', () => { + expect(result.status).toEqual(1); + expect(result.stderr).toContain('πŸ’₯ MalfunctionError: unreadable file'); + expect(result.stdout).not.toContain('matches:'); + expect(result.stderr).toMatchSnapshot(); + }); + }); + }); + + given('[case] claude.cli.strings against a package link that resolves outside the repo', () => { + when('[t0] a search is run', () => { + const result = useThen('it runs', async () => + runSkill({ + skill: 'claude.cli.strings', + args: ['--in', FIXTURE_ESCAPE_REL, '--pattern', 'x'], + }), + ); + then('it refuses with exit 2, and reads no file there', () => { + expect(result.status).toEqual(2); + expect(result.stderr).toContain( + 'the claude-code package lands outside the repo', + ); + expect(result.stdout).toEqual(''); + expect(result.stderr).toMatchSnapshot(); + }); + }); + }); + + given('[case] claude.cli.secstore.trial against a claude bin that prints no version', () => { + when('[t0] the trial is run', () => { + const result = useThen('it runs', async () => + runSkill({ + skill: 'claude.cli.secstore.trial', + args: ['--in', FIXTURE_MUTE_REL], + }), + ); + then('it fails loud with exit 1, before any scenario', () => { + expect(result.status).toEqual(1); + expect(result.stderr).toContain('did not print a version'); + expect(result.stdout).toEqual(''); + expect(result.stderr).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/.agent/repo=.this/role=any/skills/claude.cli.strings.sh b/.agent/repo=.this/role=any/skills/claude.cli.strings.sh new file mode 100755 index 00000000..15895b2c --- /dev/null +++ b/.agent/repo=.this/role=any/skills/claude.cli.strings.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +###################################################################### +# .what = search the readable text embedded in a claude-code install +# (its native binary included) for a pattern, and print each +# match with the code around it +# +# .why = claude-code ships as a native binary with its js embedded as +# strings. to learn how it behaves (a lock path, a refresh +# flow, an env var it reads) a mechanic must search those +# strings; a raw grep -a over a binary needs a permission grant +# each time. this skill bounds that read to a probe install +# inside the repo +# +# usage: +# rhx claude.cli.probe --into .temp/claude-cli.probe --version 2.1.280 +# rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'credentials\.json' +# rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'lockSync' --radius 400 --limit 5 +# +# options: +# --in the probe prefix `claude.cli.probe --into` made; must sit +# inside the repo (required) +# --pattern an extended regex to find (required) +# --radius chars of context on each side of a match (default: 200) +# --limit max distinct matches to print (default: 20) +# +# guarantee: +# - reads only files under the probe's claude-code package, which +# must sit inside the repo +# - non-printable bytes are shown as '.', so a binary match prints +# as one readable line +# - duplicate matches are printed once; the total is reported +# - exit 0 = searched (zero matches is a result, not an error) +# - exit 1 = malfunction (the package or its files are unreadable) +# - exit 2 = constraint (bad args, prefix outside the repo, no install) +###################################################################### + +set -euo pipefail + +# parse args +IN="" +PATTERN="" +RADIUS="200" +LIMIT="20" +while [[ $# -gt 0 ]]; do + case "$1" in + --in) IN="$2"; shift 2 ;; + --pattern) PATTERN="$2"; shift 2 ;; + --radius) RADIUS="$2"; shift 2 ;; + --limit) LIMIT="$2"; shift 2 ;; + # the rhx runner forwards its own dispatch flags; skip them + --skill|--repo|--role) shift 2 ;; + --help|-h) + sed -n '2,36p' "$0" + exit 0 + ;; + *) + echo "βœ‹ ConstraintError: claude.cli.strings: unknown arg '$1'" >&2 + exit 2 + ;; + esac +done + +# validate the args +if [[ -z "$IN" || -z "$PATTERN" ]]; then + echo "βœ‹ ConstraintError: claude.cli.strings needs --in and --pattern" >&2 + echo " usage: claude.cli.strings --in --pattern [--radius N] [--limit N]" >&2 + exit 2 +fi +if ! [[ "$RADIUS" =~ ^[0-9]+$ && "$LIMIT" =~ ^[0-9]+$ ]]; then + echo "βœ‹ ConstraintError: --radius and --limit must be whole numbers" >&2 + exit 2 +fi + +# the probe prefix must sit inside the repo +REPO_ROOT="$(git rev-parse --show-toplevel)" +if [[ ! -d "$IN" ]]; then + echo "βœ‹ ConstraintError: no probe install at '$IN'" >&2 + echo " fix: rhx claude.cli.probe --into $IN" >&2 + exit 2 +fi +PREFIX="$(cd "$IN" && pwd -P)" +case "$PREFIX/" in + "$REPO_ROOT"/*) ;; + *) + echo "βœ‹ ConstraintError: --in must sit inside the repo ($REPO_ROOT)" >&2 + exit 2 + ;; +esac + +# follow pnpm's symlink to the claude-code package, and keep it in the repo +PKG_LINK="$PREFIX/node_modules/@anthropic-ai/claude-code" +if [[ ! -e "$PKG_LINK" ]]; then + echo "βœ‹ ConstraintError: no @anthropic-ai/claude-code under '$IN'" >&2 + echo " fix: rhx claude.cli.probe --into $IN" >&2 + exit 2 +fi +PKG="$(cd "$PKG_LINK" && pwd -P)" +case "$PKG/" in + "$REPO_ROOT"/*) ;; + *) + echo "βœ‹ ConstraintError: the claude-code package lands outside the repo ($PKG)" >&2 + exit 2 + ;; +esac +# sed quits at its first match, so no `| head` can SIGPIPE it under pipefail +VERSION="$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p;T;q' "$PKG/package.json")" + +echo "πŸ”­ claude.cli.strings --in $IN --pattern '$PATTERN' --radius $RADIUS --limit $LIMIT" +echo " β”œβ”€ package: @anthropic-ai/claude-code@${VERSION:-unknown}" + +# every file in the package must be readable, or a quiet grep would read as zero matches +# (find stops at the first one via -quit, so no `| head` can SIGPIPE it under pipefail) +UNREADABLE="$(find "$PKG" -path "$PKG/node_modules" -prune -o -type f ! -readable -print -quit)" +if [[ -n "$UNREADABLE" ]]; then + echo "πŸ’₯ MalfunctionError: unreadable file ${UNREADABLE#"$REPO_ROOT"/}" >&2 + exit 1 +fi + +# a pattern grep cannot compile must fail loud, never read as zero matches +# (grep exits 2 on a bad regex; 1 on no match; 0 on a match) +GREP_CHECK_STATUS=0 +printf '' | grep -E "$PATTERN" >/dev/null 2>&1 || GREP_CHECK_STATUS=$? +if [[ "$GREP_CHECK_STATUS" -ge 2 ]]; then + echo "βœ‹ ConstraintError: --pattern is not a valid extended regex: '$PATTERN'" >&2 + exit 2 +fi + +# search every regular file in the package, the native binary included +# (xargs exits 123 when a grep batch exits 1-125: a batch with no match exits 1, and +# grep's other exit, 2, is closed off above β€” a bad regex by the check, an unreadable +# file by the scan. so 123 is a clean result; any other nonzero status β€” find, xargs, +# tr, or awk failed β€” is a failed search and fails loud. +# LC_ALL=C lets '.' match any byte β€” a utf-8 locale skips a binary's invalid bytes. +# the files are sorted by path, so the same install always reports its matches in one order) +SEARCH_STATUS=0 +MATCHES="$( + find "$PKG" -path "$PKG/node_modules" -prune -o -type f -print0 \ + | LC_ALL=C sort -z \ + | LC_ALL=C xargs -0 grep -a -o -h -E ".{0,$RADIUS}($PATTERN).{0,$RADIUS}" \ + | LC_ALL=C tr -c '[:print:]\n' '.' \ + | awk '!seen[$0]++' +)" || SEARCH_STATUS=$? +if [[ "$SEARCH_STATUS" -ne 0 && "$SEARCH_STATUS" -ne 123 ]]; then + echo "πŸ’₯ MalfunctionError: the search failed with exit $SEARCH_STATUS; no match count is reported" >&2 + exit 1 +fi + +# .what = the count of lines in a text; 0 for an empty text +# .why = wc never exits nonzero on an empty input, so no status must be swallowed +# usage: count_lines text= +count_lines() { + local text="" arg + for arg in "$@"; do + case "$arg" in + text=*) text="${arg#text=}" ;; + *) echo "πŸ’₯ MalfunctionError: count_lines: unknown arg '$arg'" >&2; exit 1 ;; + esac + done + if [[ -z "$text" ]]; then + echo 0 + return + fi + printf '%s\n' "$text" | wc -l | tr -d ' ' +} + +# .what = the lesser of two whole numbers +# usage: min_of a= b= +min_of() { + local a="" b="" arg + for arg in "$@"; do + case "$arg" in + a=*) a="${arg#a=}" ;; + b=*) b="${arg#b=}" ;; + *) echo "πŸ’₯ MalfunctionError: min_of: unknown arg '$arg'" >&2; exit 1 ;; + esac + done + if [[ "$a" -lt "$b" ]]; then echo "$a"; else echo "$b"; fi +} + +# report +TOTAL="$(count_lines text="$MATCHES")" +echo " β”œβ”€ matches: $TOTAL distinct" +if [[ "$TOTAL" -eq 0 ]]; then + echo " └─ crickets" + exit 0 +fi +echo " └─ shown: first $(min_of a="$TOTAL" b="$LIMIT")" +echo "" +# awk consumes all input, so the producer never meets a closed pipe +# (a `| head` here SIGPIPEs printf under pipefail and exits 141 on a broad pattern) +printf '%s\n' "$MATCHES" | awk -v limit="$LIMIT" 'NR <= limit { print "────"; print }' diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.bind/beav.fix-shared-brain-credential-blanking.flag b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.bind/beav.fix-shared-brain-credential-blanking.flag new file mode 100644 index 00000000..56a51988 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.bind/beav.fix-shared-brain-credential-blanking.flag @@ -0,0 +1,2 @@ +branch: beav/fix-shared-brain-credential-blanking +bound_by: init.behavior skill diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums._.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums._.md new file mode 100644 index 00000000..f1149d33 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums._.md @@ -0,0 +1,25 @@ +# inventory.of=fulcrums + +the calls this route best-guessed, for the council at the close. + +axis: `case` β€” one occurrence per fulcrum, ordinal + slug. + +| case | title | rework | status | confidence | +|---|---|---|---|---| +| [F1](./inventory.of=fulcrums.case=F1-clones-authenticate-via-the-setup-token-env.md) | clones authenticate via the `setup-token` env token | β€” | superseded β€” the wisher chose O3 (S1, S6) | β€” | +| [F2](./inventory.of=fulcrums.case=F2-token-source-is-the-env-only.md) | the token source is the enroller's env only | β€” | superseded with F1 | β€” | +| [F3](./inventory.of=fulcrums.case=F3-token-less-fleet-falls-back-with-a-hint.md) | a token-less enroll links the shared login, with a hint | β€” | superseded β€” under O3 no fleet is token-less | β€” | +| [F4](./inventory.of=fulcrums.case=F4-enroll-unlinks-its-own-stale-symlink.md) | enroll removes an actor's brain-dir credential once no clone of the actor lives | clean | open | 80% | +| [F5](./inventory.of=fulcrums.case=F5-a-blanked-shared-login-refuses-the-spawn.md) | a blanked `~/.claude` login refuses the spawn, exit 2, unless an env credential ranks above it | clean | open | 85% | +| [F6](./inventory.of=fulcrums.case=F6-no-live-probe-of-the-env-token.md) | no live probe of the env token | β€” | superseded with F1 | β€” | +| [F7](./inventory.of=fulcrums.case=F7-adopt-a-later-actor-credential-on-removal.md) | on removal, a live brain-dir credential is adopted into `~/.claude` only when the shared login is dead or absent | clean | open | 85% | +| [F8](./inventory.of=fulcrums.case=F8-rhachet-owns-the-secure-storage-var.md) | rhachet sets `CLAUDE_SECURESTORAGE_CONFIG_DIR=""` after the caller env, over any caller value | clean | open | 75% | +| [F9](./inventory.of=fulcrums.case=F9-journey-failures-ride-an-unfunded-test-key.md) | the journey's local failures are an unfunded test key, left to ci's run | clean | open | 85% | +| [F10](./inventory.of=fulcrums.case=F10-the-clamp-proves-the-store-not-the-race.md) | the regression clamp proves where the login lock set lives, not a live N-process race | clean | open | 75% | +| [F11](./inventory.of=fulcrums.case=F11-the-trial-success-output-is-a-byhand-probe.md) | the secstore trial's success output is a byhand probe; its verdict is proven by the enroll acceptance clamp | clean | open | 80% | +| [F12](./inventory.of=fulcrums.case=F12-names-follow-the-enrolled-dir-precedent.md) | new `assert*` and uncardinaled `get*` names follow the `actor/enrolled/` dir's precedent | clean | open | 70% | +| [F13](./inventory.of=fulcrums.case=F13-the-auth-sequence-stays-two-named-calls.md) | enroll's credential sequence stays two named calls, clamped by acceptance, not a wrapper | clean | open | 80% | +| [F14](./inventory.of=fulcrums.case=F14-older-errno-checks-converge-on-touch.md) | older errno checks converge onto the named checks as they are touched, not in this diff | clean | open | 85% | +| [F15](./inventory.of=fulcrums.case=F15-the-login-lock-speaks-the-protocol-in-house.md) | the login write lock speaks proper-lockfile's protocol in house, not via the package | clean | open | 75% | + +gaps: none owed at vision. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F1-clones-authenticate-via-the-setup-token-env.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F1-clones-authenticate-via-the-setup-token-env.md new file mode 100644 index 00000000..796bddfd --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F1-clones-authenticate-via-the-setup-token-env.md @@ -0,0 +1,25 @@ +# F1 β€” clones authenticate via the `setup-token` env token + +## .the fork + +the wish lists five directions: serialize refresh (a box-wide lock), per-actor real logins, a refresh broker, atomic writes that never persist a blank, detect + self-heal. a sixth, not listed: take clones out of refresh entirely, via claude-code's documented long-lived credential `CLAUDE_CODE_OAUTH_TOKEN` (minted by `claude setup-token`). + +## .taken, and why at the time + +the sixth. it is the only direction that leaves **zero** clone refreshers, and it rides a documented claude-code contract ("for CI pipelines and scripts", precedence 5 above `/login` at 7). the others each fight claude-code's internals: +- a lock: claude-code's own refresh lock (where it exists) is taken on the config dir, which rhachet relocated per actor β€” rhachet cannot make claude-code honor a second lock +- a broker: rhachet would call the oauth token endpoint with claude-code's client id β€” undocumented, fragile +- per-actor logins: N browser handshakes, the cost the wish names +- atomic write / self-heal: rhachet is not the writer, and a revoked token family cannot be healed + +## .rework + +clean β€” the vision contract (enroll prefers an env token; refuses a blanked login) survives a swap of the direction beneath it; only case 1's mechanism would change. + +## .confidence, and why it is low + +75%. two unverified third-party reports cloud it (`anthropics/claude-code#24317`): a March 2026 comment says the server returned ~8h rather than one year for a `setup-token` token, and another says a February 2026 server-side enforcement blocked such tokens "outside the official Claude Code interactive flow". the current docs (fetched 2026-09-29) still state one year, and a clone IS claude-code. β‡’ verification must measure the token's real lifetime across β‰₯2 of the human login's 8h cycles before the done test counts. + +## .where + +`1.vision.yield.md` β€Ί the contract; case 1, case 2. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F10-the-clamp-proves-the-store-not-the-race.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F10-the-clamp-proves-the-store-not-the-race.md new file mode 100644 index 00000000..e3c5a4a1 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F10-the-clamp-proves-the-store-not-the-race.md @@ -0,0 +1,26 @@ +# F10 β€” the regression clamp proves where the login lock set lives, not a live N-process race + +## .the fork + +the wish's done test 4 asks for a clamp that reproduces the concurrent refresh over one shared credential. the clamp may (a) prove the mechanism the race depends on β€” every clone's login writes land in the one `~/.claude` store, under its one lock set β€” with one real claude-code through the real enroll spawn; or (b) drive two or more real claude-code refreshers at one file in the same refresh window. + +## .taken, and why at the time + +(a). + +- the 1.48.0 defect was never the concurrency itself: claude-code already serializes refresh under its `.storage-write` lock. the defect was that each actor's lock set sat in its own brain dir, over one symlinked file, so the locks did not exclude each other. the fix routes every clone's store and lock set to `~/.claude`. so the invariant a regression breaks is **where the store lives**, and the clamp asserts exactly that: claude-code's own write lands in `~/.claude`, and the actor's brain dir holds no login +- a regression that re-splits the lock set (a spawn path that drops `CLAUDE_SECURESTORAGE_CONFIG_DIR=''`, or a claude-code change to how it keys the store) moves that write back to the brain dir, and the clamp goes red. that is the 1.48.0 topology, caught +- (b) needs a real refresh to succeed for the winner, so the suite would spend a real refresh token. the constraints forbid that: no real token is read, planted or sent. with fake tokens every refresh fails `invalid_grant`, so an N-process run shows N rejections and cannot show "one winner, peers keep the login" +- the lock the adoption path takes is clamped separately, with real contention: `withBrainAuthWriteLock.integration.test.ts` holds claude-code's lock dir and proves a writer waits it out, and clears a stale lock + +## .rework + +clean β€” an N-process clamp is additive, a new acceptance case beside the one that stands. + +## .confidence, and why it is low + +75%. the argument rests on claude-code's refresh lock staying correct once every clone shares it. that is claude-code's code, read via `rhx claude.cli.strings` (proper-lockfile on `~/.claude/.storage-write`), not measured under a live race. the grove's post-release uptime is the field test. + +## .where + +`blackbox/cli/enroll.shared-brain-auth.acceptance.test.ts`; wish β€Ί done test 4; review i002 r008 blocker.2. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F11-the-trial-success-output-is-a-byhand-probe.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F11-the-trial-success-output-is-a-byhand-probe.md new file mode 100644 index 00000000..543681c9 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F11-the-trial-success-output-is-a-byhand-probe.md @@ -0,0 +1,25 @@ +# F11 β€” the secstore trial's success output is a byhand probe, never a suite snapshot + +## .the fork + +`claude.cli.secstore.trial` renders its point on the happy path: five scenario rows, each a real claude-code run against a fake home. its success output may (a) stay unsnapped, a byhand probe, with every refusal and `--help` snapped; or (b) be snapped with the volatile spans masked. + +## .taken, and why at the time + +(a), for the trial only. `claude.cli.strings` took (b): its success and zero-match output are now snapped against a fake package in the repo. + +- the trial's rows ARE the output of a real claude-code install and a live oauth endpoint. the `write.*` rows exist to report what claude-code does after the server rejects a fake refresh token: `claude said:`, which file went blank, what each dir holds. a mask over those spans masks the whole verdict; what remains is a frame of static `echo` lines, and a snapshot of that proves no behavior +- to snap it live, the suite would install claude-code and spend a network round trip to anthropic's oauth endpoint on every run, and the snapshot would move with each claude-code release's text. the verdict the trial reports is already proven, in the suite, by `enroll.shared-brain-auth.acceptance.test.ts`, which asserts the same fact (the clear lands in `~/.claude`, the brain dir holds no login) through rhachet's real spawn +- the trial is a dev tool for a mechanic who asks "where does claude-code write?", in this repo's own `.agent/repo=.this/role=any/skills/`. no caller parses its output + +## .rework + +clean β€” a masked frame snapshot is additive. + +## .confidence, and why it is low + +80%. the frame's words can drift unseen. that drift is cosmetic: the trial is read by a human who runs it on purpose. + +## .where + +`.agent/repo=.this/role=any/skills/claude.cli.secstore.trial.sh`; `claude.cli.skills.integration.test.ts`; review i002 r008 blocker.1, r009 blocker.2. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F12-names-follow-the-enrolled-dir-precedent.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F12-names-follow-the-enrolled-dir-precedent.md new file mode 100644 index 00000000..05325d0e --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F12-names-follow-the-enrolled-dir-precedent.md @@ -0,0 +1,25 @@ +# F12 β€” the new operations' names follow the `actor/enrolled/` dir's own precedent + +## .the fork + +`rule.require.get-set-gen-verbs` sanctions `get/set/gen/del` and `as*/is*`, and asks `get` to carry `One`/`All`. the new operations may (a) follow the names their peers in `src/domain.operations/actor/enrolled/` already carry β€” `assertBrainAuthNotDead` beside `assertBrainCliVersionFloor` and `assertBrainCliPassthroughLeavesSystemPromptOwned`; `getBrainAuthPath` and `getBrainDirAuthPath` beside `getActorOndiskDir`, `getBrainOndiskDir`, `getActorsRootDir`, `getHomeDir`; or (b) take the rule's form, and so differ from every peer the reader meets beside them. + +## .taken, and why at the time + +(a). + +- an `assert*` guard here is an imperative refusal: it throws a `ConstraintError` with the fix, or returns void. the dir already has two, and the enroll flow calls them at the same boundary. an `is*` form would move the throw into `invokeEnroll`, where the refusal text would sit apart from the check it explains +- the path getters derive one path from one input, the same shape as the uncardinaled path getters beside them. `getFileContentOrNull` and `getFileStatOrNull` sit in `src/infra/`, outside the rule's stated scope, and name their null in the name +- a rename of three new names would leave the dir with two conventions. a rename of the whole dir is a sweep outside this route's wish, which belongs in its own pr + +## .rework + +clean β€” a rename via `sedreplace` touches only callers, and no published contract exports these names. + +## .confidence, and why it is low + +70%. the rule is explicit, and the reviewers graded each at nitpick on the same precedent this cites. a council may prefer the rule's form and a dir-wide rename. + +## .where + +`src/domain.operations/actor/enrolled/`; review i002 r004 nitpick.1 + nitpick.2, r006 nitpick.1. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F13-the-auth-sequence-stays-two-named-calls.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F13-the-auth-sequence-stays-two-named-calls.md new file mode 100644 index 00000000..01de5c9e --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F13-the-auth-sequence-stays-two-named-calls.md @@ -0,0 +1,25 @@ +# F13 β€” enroll's credential sequence stays two named calls, not a wrapper + +## .the fork + +before a spawn, `invokeEnroll.ts` runs two operations in a fixed order: `setBrainDirAuthMigrated` (adopt or remove the brain-dir login), then `assertBrainAuthNotDead` (refuse a dead shared login). the order may (a) stay as two named calls inline in the orchestrator, or (b) move into one wrapper operation that owns the sequence as its own contract. + +## .taken, and why at the time + +(a). + +- each call is its own named operation with its own tests; the orchestrator reads as two sentences in order +- the order is clamped at contract grain: `enroll.shared-brain-auth.acceptance.test.ts` `[t4]` adopts a live brain-dir login beside a dead shared one, then must pass the dead check. a swap of the two calls fails that case +- a wrapper for two lines adds a file and a name and no guarantee the acceptance case lacks + +## .rework + +clean β€” an extract of two adjacent calls into one operation touches one orchestrator and adds one file. + +## .confidence, and why it is low + +80%. the reviewer asks for a unit that owns the sequence so the order is proven below acceptance grain; a council that weights fast feedback may prefer that. + +## .where + +`src/contract/cli/invokeEnroll.ts`; review i006 r011 point.2. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F14-older-errno-checks-converge-on-touch.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F14-older-errno-checks-converge-on-touch.md new file mode 100644 index 00000000..abab4acd --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F14-older-errno-checks-converge-on-touch.md @@ -0,0 +1,25 @@ +# F14 β€” older errno checks converge as they are touched, not in this diff + +## .the fork + +this route adds `isErrnoEexist` beside `isErrnoEnoent` in `src/infra/filesystem/`. about fifteen older call sites elsewhere in the repo still read `error.code` through an ad-hoc cast. the route may (a) use the named checks in its own code and leave the older sites, or (b) sweep every older site onto the named checks in this diff. + +## .taken, and why at the time + +(a). + +- the older sites sit in files this change does not otherwise touch; a sweep widens the diff into unrelated modules and their reviewers +- the wish is the shared-login blank; the errno idiom is unrelated to it +- the named checks exist and are tested, so each older site converges cheaply the next time its file is opened + +## .rework + +clean β€” each site is a one-line swap; a later sweep via `sedreplace` touches no contract. + +## .confidence, and why it is low + +85%. two idioms for one check coexist until the sweep lands; a council may prefer to pay it now. + +## .where + +`src/infra/filesystem/isErrnoEexist.ts`, `isErrnoEnoent.ts`; review i006 r011 point.3. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F15-the-login-lock-speaks-the-protocol-in-house.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F15-the-login-lock-speaks-the-protocol-in-house.md new file mode 100644 index 00000000..4368ee01 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F15-the-login-lock-speaks-the-protocol-in-house.md @@ -0,0 +1,26 @@ +# F15 β€” the login write lock speaks proper-lockfile's protocol in house, not via the package + +## .the fork + +`withBrainAuthWriteLock` must exclude claude-code's own writers of `~/.claude/.credentials.json`, which lock `~/.claude/.storage-write` with proper-lockfile (`realpath: false`, `stale: 15000`). the route may (a) speak that directory-lock protocol in a small in-house operation, or (b) add `proper-lockfile@4.1.2` as a pinned dependency and call it. + +## .taken, and why at the time + +(a). + +- the protocol is three moves: an atomic `mkdir`, a stale bound on mtime, and an mtime refresh while held. each is clamped in `withBrainAuthWriteLock.integration.test.ts` (cases 1 through 6) +- the in-house form knows each lock dir by inode, so a release or a stale clear never removes a peer's fresh lock, and that guard is clamped by case 4 +- the package's documented default `onCompromised` throws, which from its update timer is the same process-crash class review i007 r011 raised against the heartbeat; a caller must override it to stay safe +- a new runtime dependency on the enroll path widens the release for a fix that needs to ship fast + +## .rework + +clean β€” one file calls the lock; a swap to the package touches that file, its test, and `package.json`. + +## .confidence, and why it is low + +75%. two implementations of one protocol can drift; if claude-code changes its lock options, the in-house form must follow by hand, where a shared package would at least share defaults. + +## .where + +`src/domain.operations/brain/auth/withBrainAuthWriteLock.ts`; review i007 r011 nitpick.1. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F2-token-source-is-the-env-only.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F2-token-source-is-the-env-only.md new file mode 100644 index 00000000..ec11a172 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F2-token-source-is-the-env-only.md @@ -0,0 +1,21 @@ +# F2 β€” the token source is the enroller's env only + +## .the fork + +where does enroll find the long-lived token: (a) `CLAUDE_CODE_OAUTH_TOKEN` in the enroller's env, which `asBrainCliSpawnEnv` already passes through; (b) a rhachet-owned host store (a 0600 file, or a keyrack key) that enroll injects; (c) both. + +## .taken, and why at the time + +(a). it needs no new store and no new secret surface, and it already reaches every clone today β€” the spawn env is the caller's env minus the parent-session markers. a keyrack key would expire with its unlock (~9h), which defeats a one-year token; a bespoke file is a new secret store to own. + +## .rework + +clean β€” a store in (b) is additive: enroll would read it only when the env lacks the variable. + +## .confidence, and why it is low + +80%. headless grove crews spawn from tmux and cron, whose env may not carry a shell-profile export. if the grove spawner cannot set it, (b) earns its place β€” and that spawner lives in another repo (`git.grove.auth`). + +## .where + +`1.vision.yield.md` β€Ί the contract; case 2. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F3-token-less-fleet-falls-back-with-a-hint.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F3-token-less-fleet-falls-back-with-a-hint.md new file mode 100644 index 00000000..7fc9362d --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F3-token-less-fleet-falls-back-with-a-hint.md @@ -0,0 +1,23 @@ +# F3 β€” a token-less enroll still links the shared login, with a one-line hint + +## .the fork + +with no env token and a healthy human login: (a) link as today, silent; (b) link, plus one stderr hint that names the fleet hazard and `claude setup-token`; (c) refuse once another live clone already shares the link; (d) refuse always. + +## .taken, and why at the time + +(b). a solo human with one clone is safe enough and must not be broken (d); (c) needs a live-clone census inside enroll and still races; (a) leaves the next grove to rediscover the outage. one line at spawn time is the cheapest move that teaches the cure before the outage. + +## .rework + +clean β€” the branch is one conditional in enroll; a flip to (c) or (d) is local. + +## .confidence, and why it is low + +65%. the wish's outcome is fleet-wide; (b) leaves a token-less fleet exposed by choice. the wisher may prefer (c) for groves. and the hint is noise on a stream rhachet keeps quiet on success. + +the cost of (b), named: the outage cell has no fail-safe at its edge (case 9). the experience-coverage peer review graded that a blocker, and the vision now states the cell as a gap owed this decision rather than a demo. (c) is the fail-safe at the edge: fast, exit 2, one cure. its open part is the census β€” the host runtime dir sees every socket clone box-wide, but not a socketless plain clone. + +## .where + +cases 4 and 9; `1.vision.yield.md` β€Ί open questions, awkward. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F4-enroll-unlinks-its-own-stale-symlink.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F4-enroll-unlinks-its-own-stale-symlink.md new file mode 100644 index 00000000..cc05e776 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F4-enroll-unlinks-its-own-stale-symlink.md @@ -0,0 +1,27 @@ +# F4 β€” enroll removes an actor's brain-dir credential once no clone of the actor lives + +## .the fork + +when enroll finds a 1.48.0 credential in an actor's brain dir (a symlink to `~/.claude`, or a real file): (a) leave it; (b) remove it at once; (c) keep it while a clone of the actor lives, else remove it. + +## .taken, and why + +(c). + +- under O3 no clone reads a brain-dir credential: `CLAUDE_SECURESTORAGE_CONFIG_DIR=''` points every clone at `~/.claude`. a leftover is dead weight for new clones, and a trap for a reader who takes it for the login +- a pre-cure clone still alive reads and refreshes that file under its own lock set. to pull the file from under it makes its next refresh write a private login, which strands the shared one. so the leftover stays while any clone of the actor is LIVE or DEAF (`getCloneReachState`, the gate `define.brain-dir-repo-vs-actor` uses for "active") +- once none lives, a symlink is removed as a link (the shared login untouched), and a real file is removed after the F7 adoption check + +no spawn path bypasses this step: `invokeEnroll` is the only caller of `genCloneOndisk`, and the migration runs before the spawn. + +## .rework + +clean β€” one migration step before the spawn. + +## .confidence, and why it is not higher + +80%. the live count costs a reach-state probe per enroll, but only when a leftover exists. a pre-cure clone that outlives every enroll keeps its file until an enroll runs after it exits; the enroll output names that count and the respawn cure. + +## .where + +case 3; `setBrainDirAuthMigrated`. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F5-a-blanked-shared-login-refuses-the-spawn.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F5-a-blanked-shared-login-refuses-the-spawn.md new file mode 100644 index 00000000..0ae9ba2a --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F5-a-blanked-shared-login-refuses-the-spawn.md @@ -0,0 +1,23 @@ +# F5 β€” a blanked shared login refuses the spawn, exit 2 + +## .the fork + +when there is no env token and the human's file has empty secrets beside a live `refreshTokenExpiresAt`: (a) spawn anyway, as today; (b) spawn, plus a warn line; (c) refuse before the spawn, exit 2, both cures named. + +## .taken, and why at the time + +(c). the shape is known-dead: a claude.ai login with no refresh token cannot recover without a human. a spawn yields a clone that looks alive and is not β€” the exact confident-wrong state the wish flags in the fleet poll. a refusal is what a supervisor can act on. + +scope, widened at the experience-coverage review: the check reads whichever file the clone would use, whether the human's through the link or the actor's own (case 5 `[t1]`). it stands down when an env credential that claude-code ranks above `/login` is set (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`), because then the file is not the clone's credential (case 3 `[t2]`). + +## .rework + +clean. + +## .confidence, and why it is low + +85%. the shape test reads a file claude-code owns; a future claude-code release may change the field names, and the check must then fall through to (a), never refuse a healthy login. enroll must read only field presence and length, never log a value. + +## .where + +case 3; case 5 `[t1]`. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F6-no-live-probe-of-the-env-token.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F6-no-live-probe-of-the-env-token.md new file mode 100644 index 00000000..8a0517bb --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F6-no-live-probe-of-the-env-token.md @@ -0,0 +1,26 @@ +# F6 β€” enroll does no live probe of the env token, unless a dead token stalls + +## .the fork + +when `CLAUDE_CODE_OAUTH_TOKEN` is set, enroll may (a) trust it and spawn; or (b) check it before the spawn, and refuse if the server rejects it. + +## .taken, and why at the time + +(a), conditioned on a measurement, with (b) specified now as the branch the measurement may pick (case 8, branch B). + +- (a) holds if a dead token fails fast as claude-code's own auth error: a check costs latency, and possibly a model request, on every spawn, and needs network at enroll; the token is opaque, so a shape check proves naught; the failure is box-wide but rare, and its cure is one step for the box +- (b) holds if claude-code's 401-wait makes a dead token stall. a stall is not a fail-safe, so rhachet takes the check to its own boundary + +whether a dead token fails fast is unmeasured; the fork would have been decided by verification. superseded with F1 β€” under O3 no clone authenticates via the env token. + +## .rework + +clean β€” a check is an additive gate before the spawn. + +## .confidence, and why it is low + +70%. a machine spawner cannot parse a clone's pane, so under (a) a dead token looks to a supervisor the way the wish's outage did: crews that seem alive. that parse gap is `nheuron`'s poll health (the reseed dream), but a check would close it at the rhachet boundary. and if F1's ~8h doubt holds, deaths become daily, and (b) earns its cost regardless of the stall. + +## .where + +case 8; `1.vision.yield.md` β€Ί open questions. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F7-adopt-a-later-actor-credential-on-removal.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F7-adopt-a-later-actor-credential-on-removal.md new file mode 100644 index 00000000..6f4eb078 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F7-adopt-a-later-actor-credential-on-removal.md @@ -0,0 +1,23 @@ +# F7 β€” adopt an actor credential on removal, only into a dead or absent shared login + +## .the fork + +when enroll removes a 1.48.0 brain-dir credential that is a real file: (a) discard it; (b) adopt it into `~/.claude` if its `expiresAt` is later; (c) adopt it always; (d) adopt it only when the shared login is dead or absent and the brain-dir login is live. + +## .taken, and why + +(d). a real file in a brain dir may be a refresh winner's β€” the only live token on the box, while `~/.claude` holds the spent one. (a) throws that token away. (b) and (c) write over a LIVE shared login with no lock, and a peer clone on another actor may refresh that login at the same instant: the unlocked write rolls its refresh back, and the rolled-back refresh token is already spent β€” the very blank this route cures. (d) writes only where no peer refreshes: a dead login (`refreshToken: ''`) is never refreshed, and an absent one has no reader to race. a live shared login is left alone, whatever its expiry; the brain-dir login is then removed. + +adopt runs only once no clone of the actor lives, so no pre-cure process still refreshes the brain-dir file. + +## .rework + +clean. + +## .confidence, and why it is not higher + +85%. (d) can discard a live brain-dir login when the shared one is also live, even where the brain-dir token is the newer. that costs at most one `/login`, and only if the shared login later dies; a race-free write is worth that. a file from a different account is still adoptable into a dead or absent store. no value is ever logged. + +## .where + +case 3 `[t2]`; `isBrainDirAuthAdoptable`. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F8-rhachet-owns-the-secure-storage-var.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F8-rhachet-owns-the-secure-storage-var.md new file mode 100644 index 00000000..16ff8c99 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F8-rhachet-owns-the-secure-storage-var.md @@ -0,0 +1,21 @@ +# F8 β€” rhachet owns the secure-storage var + +## .the fork + +when the caller env already sets `CLAUDE_SECURESTORAGE_CONFIG_DIR`: (a) keep the caller's value; (b) set `""` after the caller env, over it. + +## .taken, and why at the time + +(b). a stray value splits the store and restores the race silently; `CLAUDE_CONFIG_DIR` is already set the same way. claude-code's own teammate spawn sets the var unconditionally too. + +## .rework + +clean β€” a per-subscription store (S2) would change the value rhachet sets, not the ownership. + +## .confidence, and why it is low + +75%. a human who set the var on purpose loses it in clones with no warn. + +## .where + +case 1 `[t0]`; `asBrainCliSpawnEnv`. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F9-journey-failures-ride-an-unfunded-test-key.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F9-journey-failures-ride-an-unfunded-test-key.md new file mode 100644 index 00000000..299546ae --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F9-journey-failures-ride-an-unfunded-test-key.md @@ -0,0 +1,29 @@ +# F9 β€” the journey's local failures are an unfunded test key, not this diff + +## .the fork + +`brain-dir-boot.journey.acceptance.test.ts` fails 16 of 79 locally. (a) hold the stone until they pass locally; (b) pass the stone with the cause cited, and let ci's run judge them. + +## .taken, and why + +(b). + +- the logged cause is `Credit balance too low Β· Add funds` on the test `ANTHROPIC_API_KEY`, in each failed `claude -p` launch (the yield cites the log path) +- the failed launches are bare `claude -p` calls in the journey's own harness. they never pass through rhachet's spawn env, so `CLAUDE_SECURESTORAGE_CONFIG_DIR` β€” the whole diff β€” cannot reach them +- `bootChars: 19039`, so a context-window cause is ruled out +- the last green run of this suite is release/v1.48.1 on 2026-09-26, run 36220484628 +- to fund the key is a human lever. no change a driver may make closes it + +if ci's journey goes red on the same message, the ask to the human is: fund the test key. + +## .rework + +clean β€” a rerun once the key holds credit. + +## .confidence, and why it is not higher + +85%. the cause is read from the record, not inferred. a second defect may hide behind the credit wall until the key is funded; ci's run is where it would surface. + +## .where + +`5.1.execution.from_vision.yield.md` β€Ί verification; peer concerns r008 nitpick.1, r009 blocker.3. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/.gitignore b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/.gitignore new file mode 100644 index 00000000..819676dc --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/.gitignore @@ -0,0 +1,3 @@ +# ignore all peer-review files +* +!.gitignore diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.route/.bind.beav.fix-shared-brain-credential-blanking.flag b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.route/.bind.beav.fix-shared-brain-credential-blanking.flag new file mode 100644 index 00000000..35768e79 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.route/.bind.beav.fix-shared-brain-credential-blanking.flag @@ -0,0 +1,2 @@ +branch: beav/fix-shared-brain-credential-blanking +bound_by: route.bind skill diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.route/.gitignore b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.route/.gitignore new file mode 100644 index 00000000..62a8c8b9 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.route/.gitignore @@ -0,0 +1,5 @@ +# ignore all except passage.jsonl and .bind flags +* +!.gitignore +!passage.jsonl +!.bind.* diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.route/passage.jsonl b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.route/passage.jsonl new file mode 100644 index 00000000..d59b07b4 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.route/passage.jsonl @@ -0,0 +1,235 @@ +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-grounded-in-reality"} +{"stone":"1.vision","status":"promised","reason":"promised review.self: has-grounded-in-reality"} +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-experience-coverage"} +{"stone":"1.vision","status":"promised","reason":"promised review.self: has-experience-coverage"} +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-questioned-requirements"} +{"stone":"1.vision","status":"promised","reason":"promised review.self: has-questioned-requirements"} +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-questioned-assumptions"} +{"stone":"1.vision","status":"promised","reason":"promised review.self: has-questioned-assumptions"} +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-questioned-questions"} +{"stone":"1.vision","status":"promised","reason":"promised review.self: has-questioned-questions"} +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"poured","level":1} +{"stone":"1.vision","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (4 > 0); wait for human approval"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i001.9268339782186a5ccb.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition blocker.1 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"blocker.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i001.9268339782186a5ccb.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition blocker.2 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"blocker.3","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i001.9268339782186a5ccb.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition blocker.3 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i001.9268339782186a5ccb.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition nitpick.1 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i001.9268339782186a5ccb.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition nitpick.2 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"nitpick.3","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i001.9268339782186a5ccb.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition nitpick.3 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"experience-coverage","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i001.9268339782186a5ccb.r002._.given.by_peer.experience-coverage.md","severity":"urgent","reason":"conceded review.peer: experience-coverage blocker.1 (urgent)"} +{"stone":"1.vision","status":"conceded","reviewer":"experience-coverage","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i001.9268339782186a5ccb.r002._.given.by_peer.experience-coverage.md","severity":"better","reason":"conceded review.peer: experience-coverage nitpick.1 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"experience-coverage","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i001.9268339782186a5ccb.r002._.given.by_peer.experience-coverage.md","severity":"better","reason":"conceded review.peer: experience-coverage nitpick.2 (better)"} +{"stone":"1.vision","status":"absorbed","reason":"absorbed review.peer: dimensional-decomposition"} +{"stone":"1.vision","status":"absorbed","reason":"absorbed review.peer: experience-coverage"} +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (1 > 0); wait for human approval"} +{"stone":"1.vision","status":"conceded","reviewer":"experience-coverage","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i002.23340b219468fa6d06.r002._.given.by_peer.experience-coverage.md","severity":"urgent","reason":"conceded review.peer: experience-coverage blocker.1 (urgent)"} +{"stone":"1.vision","status":"conceded","reviewer":"experience-coverage","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i002.23340b219468fa6d06.r002._.given.by_peer.experience-coverage.md","severity":"better","reason":"conceded review.peer: experience-coverage nitpick.1 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"experience-coverage","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i002.23340b219468fa6d06.r002._.given.by_peer.experience-coverage.md","severity":"better","reason":"conceded review.peer: experience-coverage nitpick.2 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i002.23340b219468fa6d06.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition nitpick.1 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i002.23340b219468fa6d06.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition nitpick.2 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"nitpick.3","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i002.23340b219468fa6d06.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition nitpick.3 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"nitpick.4","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i002.23340b219468fa6d06.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition nitpick.4 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"nitpick.5","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i002.23340b219468fa6d06.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition nitpick.5 (better)"} +{"stone":"1.vision","status":"absorbed","reason":"absorbed review.peer: experience-coverage"} +{"stone":"1.vision","status":"absorbed","reason":"absorbed review.peer: dimensional-decomposition"} +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (1 > 0); wait for human approval"} +{"stone":"1.vision","status":"conceded","reviewer":"experience-coverage","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i003.dea28eec959537b4d5.r002._.given.by_peer.experience-coverage.md","severity":"better","reason":"conceded review.peer: experience-coverage blocker.1 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"experience-coverage","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i003.dea28eec959537b4d5.r002._.given.by_peer.experience-coverage.md","severity":"better","reason":"conceded review.peer: experience-coverage nitpick.1 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"experience-coverage","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i003.dea28eec959537b4d5.r002._.given.by_peer.experience-coverage.md","severity":"better","reason":"conceded review.peer: experience-coverage nitpick.2 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i003.dea28eec959537b4d5.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition nitpick.1 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i003.dea28eec959537b4d5.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition nitpick.2 (better)"} +{"stone":"1.vision","status":"conceded","reviewer":"dimensional-decomposition","about":"nitpick.3","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i003.dea28eec959537b4d5.r001._.given.by_peer.dimensional-decomposition.md","severity":"better","reason":"conceded review.peer: dimensional-decomposition nitpick.3 (better)"} +{"stone":"1.vision","status":"absorbed","reason":"absorbed review.peer: experience-coverage"} +{"stone":"1.vision","status":"absorbed","reason":"absorbed review.peer: dimensional-decomposition"} +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"blocked","blocker":"approval","reason":"wait for human approval"} +{"stone":"1.vision","status":"approved"} +{"stone":"1.vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"1.vision","status":"passed"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-pruned-yagni"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-pruned-yagni"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: has-pruned-yagni"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-pruned-backcompat"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-pruned-backcompat"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-pruned-backcompat"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: has-pruned-backcompat"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-consistent-mechanisms"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-consistent-mechanisms"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-consistent-mechanisms"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: has-consistent-mechanisms"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: has-consistent-conventions"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: has-consistent-conventions"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: behavior-declaration-coverage"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: behavior-declaration-coverage"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: behavior-declaration-adherance"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: behavior-declaration-adherance"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: role-standards-adherance"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: role-standards-adherance"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.self","reason":"review.self required: role-standards-coverage"} +{"stone":"5.1.execution.from_vision","status":"promised","reason":"promised review.self: role-standards-coverage"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"poured","level":1} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.peer","reason":"blockers exceed threshold (14 > 0)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"repo-rules","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r001._.given.by_peer.repo-rules.md","severity":"better","reason":"conceded review.peer: repo-rules blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"repo-rules","about":"blocker.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r001._.given.by_peer.repo-rules.md","severity":"better","reason":"conceded review.peer: repo-rules blocker.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"mech-failhides","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r002._.given.by_peer.mech-failhides.md","severity":"better","reason":"conceded review.peer: mech-failhides blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"mech-failhides","about":"blocker.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r002._.given.by_peer.mech-failhides.md","severity":"better","reason":"conceded review.peer: mech-failhides blocker.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"mech-decode-friction","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r003._.given.by_peer.mech-decode-friction.md","severity":"better","reason":"conceded review.peer: mech-decode-friction blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"mech-decode-friction","about":"blocker.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r003._.given.by_peer.mech-decode-friction.md","severity":"better","reason":"conceded review.peer: mech-decode-friction blocker.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"repo-rules","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r001._.given.by_peer.repo-rules.md","severity":"better","reason":"conceded review.peer: repo-rules nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"repo-rules","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r001._.given.by_peer.repo-rules.md","severity":"better","reason":"conceded review.peer: repo-rules nitpick.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"repo-rules","about":"nitpick.3","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r001._.given.by_peer.repo-rules.md","severity":"better","reason":"conceded review.peer: repo-rules nitpick.3 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"repo-rules","about":"nitpick.4","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r001._.given.by_peer.repo-rules.md","severity":"better","reason":"conceded review.peer: repo-rules nitpick.4 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"repo-rules","about":"nitpick.5","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r001._.given.by_peer.repo-rules.md","severity":"better","reason":"conceded review.peer: repo-rules nitpick.5 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-opport-decomposition","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r004._.given.by_peer.arch-opport-decomposition.md","severity":"better","reason":"conceded review.peer: arch-opport-decomposition blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-opport-decomposition","about":"blocker.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r004._.given.by_peer.arch-opport-decomposition.md","severity":"better","reason":"conceded review.peer: arch-opport-decomposition blocker.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-opport-decomposition","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r004._.given.by_peer.arch-opport-decomposition.md","severity":"better","reason":"conceded review.peer: arch-opport-decomposition nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"mech-failhides","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r002._.given.by_peer.mech-failhides.md","severity":"better","reason":"conceded review.peer: mech-failhides nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"mech-decode-friction","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r003._.given.by_peer.mech-decode-friction.md","severity":"better","reason":"conceded review.peer: mech-decode-friction nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance nitpick.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"nitpick.3","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance nitpick.3 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"nitpick.4","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance nitpick.4 (better)"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"arch-hazards-maintenance","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r006._.given.by_peer.arch-hazards-maintenance.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F5-a-blanked-shared-login-refuses-the-spawn.md","reason":"disputed review.peer: arch-hazards-maintenance nitpick.1"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-behavior","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r007._.given.by_peer.arch-hazards-behavior.md","severity":"urgent","reason":"conceded review.peer: arch-hazards-behavior blocker.1 (urgent)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-behavior","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r007._.given.by_peer.arch-hazards-behavior.md","severity":"better","reason":"conceded review.peer: arch-hazards-behavior nitpick.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"arch-hazards-behavior","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r007._.given.by_peer.arch-hazards-behavior.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F4-enroll-unlinks-its-own-stale-symlink.md","reason":"disputed review.peer: arch-hazards-behavior nitpick.1"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"arch-hazards-behavior","about":"nitpick.3","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r007._.given.by_peer.arch-hazards-behavior.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F7-adopt-a-later-actor-credential-on-removal.md","reason":"disputed review.peer: arch-hazards-behavior nitpick.3"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"behavior-intent-coverage","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r008._.given.by_peer.behavior-intent-coverage.md","severity":"better","reason":"conceded review.peer: behavior-intent-coverage blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"behavior-intent-coverage","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r008._.given.by_peer.behavior-intent-coverage.md","severity":"better","reason":"conceded review.peer: behavior-intent-coverage nitpick.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"behavior-intent-coverage","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r008._.given.by_peer.behavior-intent-coverage.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F9-journey-failures-ride-an-unfunded-test-key.md","reason":"disputed review.peer: behavior-intent-coverage nitpick.1"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"ergo-friction-hazards","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r009._.given.by_peer.ergo-friction-hazards.md","severity":"better","reason":"conceded review.peer: ergo-friction-hazards blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"ergo-friction-hazards","about":"blocker.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r009._.given.by_peer.ergo-friction-hazards.md","severity":"better","reason":"conceded review.peer: ergo-friction-hazards blocker.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"ergo-friction-hazards","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r009._.given.by_peer.ergo-friction-hazards.md","severity":"better","reason":"conceded review.peer: ergo-friction-hazards nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"ergo-friction-hazards","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r009._.given.by_peer.ergo-friction-hazards.md","severity":"better","reason":"conceded review.peer: ergo-friction-hazards nitpick.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"ergo-friction-hazards","about":"blocker.3","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i001.0f9e33c58105704c16.r009._.given.by_peer.ergo-friction-hazards.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F9-journey-failures-ride-an-unfunded-test-key.md","reason":"disputed review.peer: ergo-friction-hazards blocker.3"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: repo-rules"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: mech-failhides"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: mech-decode-friction"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-opport-decomposition"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-maintenance"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-behavior"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: behavior-intent-coverage"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: ergo-friction-hazards"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"malfunction"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"mech-decode-friction","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r003._.given.by_peer.mech-decode-friction.md","severity":"better","reason":"conceded review.peer: mech-decode-friction nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"arch-opport-decomposition","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r004._.given.by_peer.arch-opport-decomposition.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F12-names-follow-the-enrolled-dir-precedent.md","reason":"disputed review.peer: arch-opport-decomposition nitpick.1"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"arch-opport-decomposition","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r004._.given.by_peer.arch-opport-decomposition.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F12-names-follow-the-enrolled-dir-precedent.md","reason":"disputed review.peer: arch-opport-decomposition nitpick.2"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-opport-decomposition","about":"nitpick.3","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r004._.given.by_peer.arch-opport-decomposition.md","severity":"better","reason":"conceded review.peer: arch-opport-decomposition nitpick.3 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"arch-hazards-maintenance","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r006._.given.by_peer.arch-hazards-maintenance.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F12-names-follow-the-enrolled-dir-precedent.md","reason":"disputed review.peer: arch-hazards-maintenance nitpick.1"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance nitpick.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"nitpick.3","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance nitpick.3 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"nitpick.4","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance nitpick.4 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-behavior","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r007._.given.by_peer.arch-hazards-behavior.md","severity":"urgent","reason":"conceded review.peer: arch-hazards-behavior blocker.1 (urgent)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-behavior","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r007._.given.by_peer.arch-hazards-behavior.md","severity":"better","reason":"conceded review.peer: arch-hazards-behavior nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"behavior-intent-coverage","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r008._.given.by_peer.behavior-intent-coverage.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F11-the-trial-success-output-is-a-byhand-probe.md","reason":"disputed review.peer: behavior-intent-coverage blocker.1"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"behavior-intent-coverage","about":"blocker.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r008._.given.by_peer.behavior-intent-coverage.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F10-the-clamp-proves-the-store-not-the-race.md","reason":"disputed review.peer: behavior-intent-coverage blocker.2"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"behavior-intent-coverage","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r008._.given.by_peer.behavior-intent-coverage.md","severity":"better","reason":"conceded review.peer: behavior-intent-coverage nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"behavior-intent-coverage","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r008._.given.by_peer.behavior-intent-coverage.md","severity":"better","reason":"conceded review.peer: behavior-intent-coverage nitpick.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"ergo-friction-hazards","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r009._.given.by_peer.ergo-friction-hazards.md","severity":"better","reason":"conceded review.peer: ergo-friction-hazards blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"ergo-friction-hazards","about":"blocker.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i002.2bdceddb08f8c780de.r009._.given.by_peer.ergo-friction-hazards.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F11-the-trial-success-output-is-a-byhand-probe.md","reason":"disputed review.peer: ergo-friction-hazards blocker.2"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: mech-decode-friction"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-opport-decomposition"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-maintenance"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-behavior"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: behavior-intent-coverage"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: ergo-friction-hazards"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked","blocker":"review.peer.unabsorbed","reason":"peer review awaits feedbackAbsorption: repo-rules"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: repo-rules"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"malfunction"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-behavior","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r007._.given.by_peer.arch-hazards-behavior.md","severity":"urgent","reason":"conceded review.peer: arch-hazards-behavior blocker.1 (urgent)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-behavior","about":"blocker.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r007._.given.by_peer.arch-hazards-behavior.md","severity":"better","reason":"conceded review.peer: arch-hazards-behavior blocker.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-behavior","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r007._.given.by_peer.arch-hazards-behavior.md","severity":"better","reason":"conceded review.peer: arch-hazards-behavior nitpick.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"mech-decode-friction","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r003._.given.by_peer.mech-decode-friction.md","severity":"better","reason":"conceded review.peer: mech-decode-friction blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"mech-decode-friction","about":"blocker.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r003._.given.by_peer.mech-decode-friction.md","severity":"better","reason":"conceded review.peer: mech-decode-friction blocker.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"mech-decode-friction","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r003._.given.by_peer.mech-decode-friction.md","severity":"better","reason":"conceded review.peer: mech-decode-friction nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance nitpick.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-maintenance","about":"nitpick.3","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r006._.given.by_peer.arch-hazards-maintenance.md","severity":"better","reason":"conceded review.peer: arch-hazards-maintenance nitpick.3 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"ergo-friction-hazards","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r009._.given.by_peer.ergo-friction-hazards.md","severity":"better","reason":"conceded review.peer: ergo-friction-hazards blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"ergo-friction-hazards","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r009._.given.by_peer.ergo-friction-hazards.md","severity":"better","reason":"conceded review.peer: ergo-friction-hazards nitpick.2 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"arch-hazards-behavior","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r007._.given.by_peer.arch-hazards-behavior.md","severity":"better","reason":"conceded review.peer: arch-hazards-behavior nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"ergo-friction-hazards","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r009._.given.by_peer.ergo-friction-hazards.md","severity":"better","reason":"conceded review.peer: ergo-friction-hazards nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: repo-rules"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: mech-decode-friction"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-opport-decomposition"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-maintenance"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-behavior"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: ergo-friction-hazards"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"poured","level":3} +{"stone":"5.1.execution.from_vision","status":"malfunction"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: repo-rules"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-opport-decomposition"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-behavior"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: enroll-impl-behavior-intent"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: enroll-impl-arch-defects"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"malfunction"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: repo-rules"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-behavior"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: enroll-impl-behavior-intent"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: enroll-impl-arch-defects"} +{"stone":"5.1.execution.from_vision","status":"blocked"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"malfunction"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: enroll-impl-behavior-intent"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: repo-rules"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-behavior"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"enroll-impl-arch-defects","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i006.d92a04aca0289f3ed1.r011._.given.by_peer.enroll-impl-arch-defects.md","severity":"better","reason":"conceded review.peer: enroll-impl-arch-defects blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"enroll-impl-arch-defects","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i006.d92a04aca0289f3ed1.r011._.given.by_peer.enroll-impl-arch-defects.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F13-the-auth-sequence-stays-two-named-calls.md","reason":"disputed review.peer: enroll-impl-arch-defects nitpick.1"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"enroll-impl-arch-defects","about":"nitpick.2","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i006.d92a04aca0289f3ed1.r011._.given.by_peer.enroll-impl-arch-defects.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F14-older-errno-checks-converge-on-touch.md","reason":"disputed review.peer: enroll-impl-arch-defects nitpick.2"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: enroll-impl-arch-defects"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"malfunction"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"enroll-impl-arch-defects","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i007.9effd7bcc1d618a0d3.r011._.given.by_peer.enroll-impl-arch-defects.md","severity":"better","reason":"conceded review.peer: enroll-impl-arch-defects blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"disputed","reviewer":"enroll-impl-arch-defects","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i007.9effd7bcc1d618a0d3.r011._.given.by_peer.enroll-impl-arch-defects.md","fulcrum":".behavior/v2026_09_29.fix-shared-brain-credential-blanking/.fulcrums/inventory.of=fulcrums.case=F15-the-login-lock-speaks-the-protocol-in-house.md","reason":"disputed review.peer: enroll-impl-arch-defects nitpick.1"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: enroll-impl-arch-defects"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: enroll-impl-behavior-intent"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: repo-rules"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-behavior"} +{"stone":"5.1.execution.from_vision","status":"blocked"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"malfunction"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"enroll-impl-behavior-intent","about":"blocker.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i008.d07b1b7e88b9c3a9b2.r010._.given.by_peer.enroll-impl-behavior-intent.md","severity":"better","reason":"conceded review.peer: enroll-impl-behavior-intent blocker.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"conceded","reviewer":"enroll-impl-behavior-intent","about":"nitpick.1","given":"/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i008.d07b1b7e88b9c3a9b2.r010._.given.by_peer.enroll-impl-behavior-intent.md","severity":"better","reason":"conceded review.peer: enroll-impl-behavior-intent nitpick.1 (better)"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: enroll-impl-behavior-intent"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: repo-rules"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-behavior"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"malfunction"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: repo-rules"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-behavior"} +{"stone":"5.1.execution.from_vision","status":"blocked"} +{"stone":"5.1.execution.from_vision","status":"arrived","reason":"entered guard reviews"} +{"stone":"5.1.execution.from_vision","status":"blocked"} +{"stone":"5.1.execution.from_vision","status":"malfunction"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: repo-rules"} +{"stone":"5.1.execution.from_vision","status":"absorbed","reason":"absorbed review.peer: arch-hazards-behavior"} +{"stone":"5.1.execution.from_vision","status":"blocked"} diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds._.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds._.md new file mode 100644 index 00000000..9331f8be --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds._.md @@ -0,0 +1,18 @@ +# inventory of seeds + +axis: `case` β€” one wisher utterance (or tight burst) that changed the work, in the order said. + +| case | title | status | +|---|---|---| +| S1 | [eliminate the concurrent wipes](./inventory.of=seeds.case=S1-eliminate-the-concurrent-wipes.md) | settled | +| S2 | [one subscription per reach, via keyrack](./inventory.of=seeds.case=S2-one-subscription-per-reach-via-keyrack.md) | settled, unlanded | +| S3 | [rotate live clones on our timeline](./inventory.of=seeds.case=S3-rotate-live-clones-on-our-timeline.md) | settled, unlanded | +| S4 | [rate limits are per subscription](./inventory.of=seeds.case=S4-rate-limits-are-per-subscription.md) | settled, unlanded | +| S5 | [a credential daemon apart from keyrack](./inventory.of=seeds.case=S5-a-credential-daemon-apart-from-keyrack.md) | settled | +| S6 | [shared secure storage first, the symlink watcher second](./inventory.of=seeds.case=S6-shared-secure-storage-first-watcher-second.md) | settled | +| S7 | [the winner's token survives, and clones recover without a respawn](./inventory.of=seeds.case=S7-the-winners-token-survives-and-clones-recover.md) | settled | + +## .gaps + +- S2/S3 reopen the auth-scheme question S1 closed. the tension is the next fulcrum: + a rotatable per-reach credential is a new credential source, which S1 declined diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S1-eliminate-the-concurrent-wipes.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S1-eliminate-the-concurrent-wipes.md new file mode 100644 index 00000000..f093769c --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S1-eliminate-the-concurrent-wipes.md @@ -0,0 +1,18 @@ +# seed S1: eliminate the concurrent wipes + +## .said + +> no dude just eliminate the concurrent wipes + +> i dont want us to pivot to a new auth scheme + +> i want us to stop the failures + +## .settled + +the defect is the concurrent refresh that blanks a shared credential. the cure removes the race; +it does not trade the fleet onto a different auth scheme to route around it. + +## .landed + +- `refs/` β€” the lock-path findings that locate the race diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S2-one-subscription-per-reach-via-keyrack.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S2-one-subscription-per-reach-via-keyrack.md new file mode 100644 index 00000000..816a85d1 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S2-one-subscription-per-reach-via-keyrack.md @@ -0,0 +1,20 @@ +# seed S2: one subscription per reach, held in keyrack + +## .said + +> hey actually, lets consider the earlier idea once more. lets say we set the setup tokens into +> keyrack. lets say we set a setup token per reach, per subscription we have. we have 7. how will it +> know which one to use out of the 7? would we set the default one === the reach one and auth just +> uses the default one for the org and then default one for the machine in waterfall? and when an +> auth swap happens, how soon does that propagate ? + +## .settled + +- a host holds several subscription credentials at once β€” one per reach (org), seven today +- they live in keyrack, set once per host through rhachet +- a clone's credential is chosen by waterfall: the org's credential, else the machine default +- the propagation latency of a swap is a first-class property of the design, not an afterthought + +## .landed + +- (unlanded) the vision rework diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S3-rotate-live-clones-on-our-timeline.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S3-rotate-live-clones-on-our-timeline.md new file mode 100644 index 00000000..6733a5b4 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S3-rotate-live-clones-on-our-timeline.md @@ -0,0 +1,34 @@ +# seed S3: rotate live clones on our timeline + +## .said + +> ok so whats that api key helper? + +> how would that work ? + +> thatd be cool + +> if we can just give it our own operation to run + +> then you just setup your creds via rhachet into keyrack once and rotate easily on the machine? + +> cause midspawn rotation is the most important thing + +> we want to be able to rotate on our timeline + +> and have it propagate within a 15min ttl OR as soon as theres a session usage limit exceeded. +> knawmean ? + +## .settled + +- **mid-spawn rotation is the top requirement.** a credential swap must reach clones that are + already live β€” never only the next spawn +- rotation happens on the operator's timeline, not the vendor's refresh schedule +- a swap propagates to every live clone within a **15-minute ttl**, OR **at once** when a clone + hits a session usage limit β€” whichever comes first +- the mechanism of choice is a credential source claude-code re-reads through an operation we + supply (a rhachet/keyrack command), so creds are set once per host and rotated in one place + +## .landed + +- (unlanded) the vision rework diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S4-rate-limits-are-per-subscription.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S4-rate-limits-are-per-subscription.md new file mode 100644 index 00000000..853ec728 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S4-rate-limits-are-per-subscription.md @@ -0,0 +1,17 @@ +# seed S4: rate limits are per subscription + +## .said + +> anthropic explicitly suggest folks get multiple subscriptions to avoid these ratelimits. they +> ratelimit tokens per subscription, not total tokens + +## .settled + +- the usage limit is metered per subscription, so a fleet that holds several subscriptions and + routes load across them is in scope for this design +- a swap to another subscription when one hits its limit (S3's usage-limit trigger) is a wanted + behavior, not a fulcrum to hold back + +## .landed + +- (unlanded) the vision rework diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S5-a-credential-daemon-apart-from-keyrack.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S5-a-credential-daemon-apart-from-keyrack.md new file mode 100644 index 00000000..2ea3f73f --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S5-a-credential-daemon-apart-from-keyrack.md @@ -0,0 +1,17 @@ +# seed S5: a credential daemon apart from keyrack + +## .said + +> we'd use a separate daemon for this cause one day keyrack will be a dependency of rhachet, but +> not combined + +## .settled + +- a credential watcher or rotator runs as its own daemon +- it may consume keyrack, but it does not live inside the keyrack daemon +- keyrack is headed out of rhachet into a separate dependency, so rhachet's credential machinery + must not be coupled into it + +## .landed + +- `refs/inventory.of=options.case=O10-adopt-the-winner.md` β€” the watcher is its own daemon diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S6-shared-secure-storage-first-watcher-second.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S6-shared-secure-storage-first-watcher-second.md new file mode 100644 index 00000000..718a8888 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S6-shared-secure-storage-first-watcher-second.md @@ -0,0 +1,23 @@ +# seed S6: shared secure storage first, the symlink watcher second + +## .said + +> ok so we could then precommend CLAUDE_SECURESTORAGE_CONFIG_DIR='' prefered as the lowest effort, the ninotify symlink as the second option? + +> in order of preference second that is + +> dont build the fallback if the preferred is proven to work + +## .settled + +- preferred cure: spawn each clone with `CLAUDE_SECURESTORAGE_CONFIG_DIR=''`, so the credential file + and its locks live in `~/.claude` while the config dir stays per actor. it is the lowest effort +- second, in order of preference: an inotify watch that catches an actor's credential symlink + swapped for a real file, adopts it into the global file, and relinks +- the fallback is documented, not built. it is built only if the preferred cure is proven not to work + +## .landed + +- `refs/inventory.of=options._.md` β€” `.recommendation` +- `refs/inventory.of=options.case=O3-relocate-the-secure-storage-dir.md` +- `refs/inventory.of=options.case=O10-adopt-the-winner.md` diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S7-the-winners-token-survives-and-clones-recover.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S7-the-winners-token-survives-and-clones-recover.md new file mode 100644 index 00000000..d2b0a2e5 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/.seeds/inventory.of=seeds.case=S7-the-winners-token-survives-and-clones-recover.md @@ -0,0 +1,23 @@ +# seed S7: the winner's token survives, and a clone recovers without a respawn + +## .said + +> i.e., upon successful auth, put the correct one in global again, then rm the broken ones and resymlink all + +> winners token survives, i've experienced that. and login does recover without respawn + +## .settled + +- the fallback repair, in order: on a won refresh, adopt the winner's credential into the global + file; remove every real credential file in the actor dirs (the winner's and each blank); relink + every actor to the global file +- a won refresh's token stays valid after the losers reuse the old refresh token β€” the server does + not revoke the whole family +- a live clone that shows `Login expired` recovers once its credential file holds a live token again, + with no respawn + +## .landed + +- `refs/inventory.of=options.case=O10-adopt-the-winner.md` +- `refs/inventory.of=options._.md` β€” `.gaps` +- `refs/define.invariant.a-blank-proves-the-winner-missed-the-global-file.md` β€” `.scope` diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/0.wish.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/0.wish.md new file mode 100644 index 00000000..488251f5 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/0.wish.md @@ -0,0 +1,191 @@ +wish = + +# 🌊 wish: a refresh by one clone must not de-auth every clone on the box + +## .the outcome wanted + +> **one clone's oauth refresh β€” success or failure β€” leaves every other clone on the box +> authenticated.** + +today it does the opposite: a single refresh attempt blanks one shared credential file and +every enrolled clone on the grove meets `● Login expired Β· Please run /login` in the same +instant. measured twice in one day on `grove-ahbode-v20260901`, ~11h apart, with ~18 live +clones killed each time. + +⚠️ this wish names an **outcome**, not an implementation. the candidate directions at the +bottom are context for the driver, never a prescription β€” pick the fix the domain actually +wants. + +## .the blast radius + +a grove runs one crew per tree and many trees at once. one blanking is not one stuck clone, +it is **the whole box**. recovery is a human in a browser doing a two-leg oauth handshake +(`rhx git.grove.auth`), so the fleet is down until a person is available. measured cost on +2026-09-28: two full outages, four human round trips, and every clone's turn lost mid-flight. + +## .what was measured + +all of the below is first-party, on `grove-ahbode-v20260901`, rhachet `1.48.0`, +claude-code `2.1.280`. + +### 1. the topology β€” 18 clones share ONE credential file + +``` +find ~/git -maxdepth 9 -path "*brain/.claude*" -name ".credentials.json" + β†’ 20 results + 18 Γ— symlink β†’ /home/camper/.claude/.credentials.json ← ONE shared file + 2 Γ— a real file of the actor's own +``` + +created by `findsertBrainCredentialSymlink.ts`, called from `invokeEnroll.ts:407`. + +### 2. the blanking fires in the REFRESH WINDOW, not at expiry + +| event | time (UTC) | +|---|---| +| credential restored by a fresh auth | 13h30m36s | +| πŸ”΄ **shared file blanked** | **21h26m56s** | +| its access token would have expired | 21h30m20s | + +**3m24s before expiry** β€” the pre-expiry refresh window. so the write that destroys the +credential *is* the refresh attempt. this is not an expiry and not a timeout. + +### 3. the shape β€” secrets zeroed, metadata preserved + +```json +{ "accessToken": "", // length 0 + "refreshToken": "", // length 0 + "expiresAt": 0, + "refreshTokenExpiresAt": 1793062432536, // ← 28 days out, SURVIVED + "rateLimitTier": "default_claude_max_20x", // ← SURVIVED + "scopes": [ …6 scopes… ], // ← SURVIVED + "subscriptionType": "max" } // ← SURVIVED +``` + +⚠️ **this is why "it expired, let it refresh" is the wrong read.** an expiry leaves the token +strings intact and moves `expiresAt` into the past. here the strings are **empty**, so no +refresh token remains to refresh *with* β€” the credential cannot self-heal, at all. only a new +browser handshake restores it. + +the selective survival is the signal: something serialized a credential object whose secret +fields were default-initialized while the non-secret fields were carried over. + +### 4. πŸ”΄ the control group β€” an own-file actor refreshed FINE, 86 seconds earlier + +two actors happened to hold a real `.credentials.json` instead of a symlink (the +`status: 'kept'` branch). at the same moment, on the same box, same claude version: + +| actor | credential | outcome | expiresAt | +|---|---|---|---| +| 18 actors | symlink β†’ shared | πŸ”΄ **blanked** 21h26m56s | `0` | +| `b6519bf1` | **its own file** | βœ… **refreshed** 21h25m30s | 7.9h out, tokens 108 chars | +| `07bb9428` | **its own file** | βœ… intact | 4.5h out, tokens 108 chars | + +β‡’ **the sharing is the variable.** an own-file clone refreshed successfully 86 seconds before +the shared file was destroyed. this is as close to a controlled experiment as production +offers, and it points at the topology rather than at the oauth flow, the network, or the +account. + +### 5. rhachet does not write the blank β€” it builds the topology that spreads it + +`rhx git.repo.get lines --in ehmpathy/rhachet --words 'claudeAiOauth'` β†’ **0 matches.** + +rhachet holds no code that serializes a `claudeAiOauth` object, so **rhachet is not the +writer.** the writer is claude-code's own refresh path. + +⚠️ so read the ownership precisely: + +| | owns | +|---|---| +| claude-code | the bad write β€” a blanked credential on a failed refresh | +| **rhachet** | the **amplifier** β€” the topology that turns one process's bad write into a fleet-wide outage | + +the upstream bug is not ours to fix and may not be fixed soon. **the amplification is ours, +and it is sufficient on its own** β€” even a correct claude-code will have transient refresh +failures, and a shared file means any one of them can take the box down. + +## .the premise to revisit + +`findsertBrainCredentialSymlink.ts:12-13` states the design intent: + +> ``` +> * .why = a relocated config dir holds no login of its own, so a clone would meet +> * "Not logged in"; a symlink shares the human's login and follows its refresh +> ``` + +the **first** clause is sound and still holds β€” a relocated `CLAUDE_CONFIG_DIR` genuinely has +no login, and sharing solves that. + +πŸ”΄ **the second clause β€” "and follows its refresh" β€” is the defect.** it treats refresh as a +read. it is a **write**, by N independent processes, to one file, with no coordination. the +symlink does not make them follow one refresh; it makes them all *perform* one, onto the same +inode. + +β‡’ the brief that documents this relocation (`define.brain-dir-repo-vs-actor.md`) covers the +**boot** thoroughly and carries not one note of the **credential**. the credential sharing is +undocumented, which is part of why it went unexamined. + +## .the done test + +a driver has finished when, on a box with β‰₯5 enrolled clones sharing an account: + +1. **the fleet survives a refresh cycle.** run past an access-token expiry with β‰₯5 clones live. + every clone is still authenticated afterward. run it across β‰₯2 consecutive expiries. +2. **a forced refresh failure is contained.** induce a failure for one clone (a stale or + corrupted refresh token for that clone alone). that clone may degrade; **no other clone + loses auth.** +3. **no credential is ever left with empty secrets + a live `refreshTokenExpiresAt`.** that + state is unrecoverable-without-a-human and should be impossible, or detected and repaired + rather than persisted. +4. **a clamp exists** that fails before the fix and passes after, reproducing the concurrent + refresh over a shared credential. the 2026-09-28 shape is the fixture: N processes, one + file, refresh window. +5. **the credential story is written down** wherever the brain-dir relocation is documented β€” + today `define.brain-dir-repo-vs-actor.md` covers the boot and is silent on auth. + +## .candidate directions β€” context, NOT a prescription + +⚠️ each of these has a real cost. they are listed so the driver need not rediscover the option +space, and explicitly **not** ranked as a recommendation. + +| direction | the cost to weigh | +|---|---| +| serialize refresh β€” one writer at a time, a lock or a lease around the refresh | needs a lock the whole box honors; a stale lock must not wedge every clone | +| per-actor real credentials (what the 2 healthy actors have, by accident) | multiplies logins β€” a human may face N browser handshakes instead of one | +| a refresh broker β€” one process owns refresh, clones read | a new long-lived component, and a new single point of failure | +| write atomically + never persist a blanked secret | narrows the window, does not close the race; may be a cheap partial worth taking regardless | +| detect + self-heal the blanked state | a mitigation, not a fix β€” but it could end the human-in-the-loop recovery | + +🟑 the accidental control group is suggestive of direction 2 but is **not** evidence it is +right β€” those two actors were unshared by accident, and nobody has priced the N-logins cost. + +## .what remains UNPROVEN + +stated plainly so the driver does not inherit a guess dressed as a finding: + +- **the precise failure mechanism inside claude-code.** the shape and timing are consistent + with oauth refresh-token rotation β€” first refresher rotates, the rest hold a stale token, + fail, and the failure path serializes a default-initialized credential. **not verified.** i + did not read claude-code's refresh implementation and did not capture a failing refresh in + the act. +- **the racer count.** 18 symlinks were found; how many were live processes attempting refresh + in that window was never measured. +- **whether rhachet 1.48.0 introduced or merely inherited this.** `findsertBrainCredentialSymlink` + exists on `origin/main`; i did not establish when it landed or whether behavior changed + recently. a `git log -S findsertBrainCredentialSymlink` would settle it. +- **the `org:create_api_key` scope discrepancy** β€” requested in the authorize url, absent from + the stored scopes. noticed, unexplained, possibly normal, possibly unrelated. + +## .a second defect this surfaced β€” the fleet poll reports these clones HEALTHY + +`rhx git.crew.poll --all --live` rendered every one of these de-authed crews as +`😢 crew: at work` while all of them were dead on auth. a confident wrong verdict, which is +the class `surgoal.polish-the-supervisor-and-prioritizer-tools` exists to close. + +the signal is cheap and unambiguous β€” `accessToken` length 0, or the literal +`Login expired` banner in the pane. **this belongs to `nheuron`, not to rhachet**, and should +be seeded separately rather than folded into this wish. + +--- + +written by human + beaver 🦫 diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=1.fleet-shares-one-login-through-the-window.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=1.fleet-shares-one-login-through-the-window.md new file mode 100644 index 00000000..1bd0a388 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=1.fleet-shares-one-login-through-the-window.md @@ -0,0 +1,20 @@ +# case 1 β€” a fleet shares one login through the refresh window + +feel: happy Β· care: critipath Β· cell: `shared` Γ— `live` Γ— `solo` Β· `cured` Γ— any + +## .the narrative + +eighteen crews on a grove, every one enrolled after the fix. each has its own brain dir for boot and transcripts; none holds a credential. the access token nears expiry and the window opens for all eighteen at once. they queue on the one refresh lock in `~/.claude`. the first refreshes and writes the new pair; each of the rest takes the lock, re-reads the file, sees a fresh token, and goes back to work. the human's own `claude` sits in the same queue. + +## .the sketch + +``` +given('[case1] 3 clones enrolled after the fix, one live login in ~/.claude') + when('[t0] each clone spawns') + then('its env carries CLAUDE_SECURESTORAGE_CONFIG_DIR=""') + then('its brain dir holds no .credentials.json') + then('its .claude.json, projects, and sessions sit in its brain dir') + when('[t1] the access token passes the refresh window') + then('~/.claude/.credentials.json holds one fresh pair') + then('every clone stays logged in') +``` diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=2.a-dead-login-refuses-and-one-login-recovers.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=2.a-dead-login-refuses-and-one-login-recovers.md new file mode 100644 index 00000000..18992876 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=2.a-dead-login-refuses-and-one-login-recovers.md @@ -0,0 +1,24 @@ +# case 2 β€” a dead login refuses the spawn, and one `/login` recovers the fleet + +feel: sharp Β· care: critipath Β· cells: `shared` Γ— `dead` Γ— any; the event `shared` Γ— `live` Γ— `cured` Γ— `window` where the server revokes the login + +## .the narrative + +the server revokes the human's login. the next refresh meets `invalid_grant` and claude-code clears the one file β€” correctly, since the login is dead for every clone. every pane shows `Login expired` together. + +a supervisor tries to enroll a fresh crew. enroll reads the shape of `~/.claude/.credentials.json` β€” empty secrets beside a live `refreshTokenExpiresAt` β€” and refuses before the spawn, exit 2: `βœ‹ ConstraintError: the box's login is dead`, with the cure `/login` (or `rhx git.grove.auth`) and a note that live clones recover on the refill. + +the human runs `/login` once. the file refills; every live clone re-reads it and recovers, no respawn (S7). one human step heals the whole fleet. + +## .the sketch + +``` +given('[case2] ~/.claude/.credentials.json blanked') + when('[t0] a clone is enrolled, no env credential set') + then('exit 2, a ConstraintError that names /login') + then('no clone spawns') + when('[t1] CLAUDE_CODE_OAUTH_TOKEN is set') + then('enroll spawns β€” the file is not the clone credential') + when('[t2] ANTHROPIC_API_KEY is set') + then('enroll spawns β€” the file is not the clone credential') +``` diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=3.migrate-a-live-grove.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=3.migrate-a-live-grove.md new file mode 100644 index 00000000..34f1eaf8 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=3.migrate-a-live-grove.md @@ -0,0 +1,26 @@ +# case 3 β€” a live 1.48.0 grove moves onto the shared store + +feel: sharp Β· care: critipath Β· cells: `shared` Γ— `live` Γ— `precure` Γ— any + +## .the narrative + +the day after the outage, eighteen crews still run under 1.48.0: each brain dir holds a credential symlink, two hold a real file (the last refresh winners). the human upgrades rhachet. new clones spawn on the shared store and ignore the brain-dir file. the old clones keep their env β€” no process can change another's β€” so they still race on their own lock sets until respawned. + +enroll does not make the handover worse. it keeps an actor's brain-dir credential while any clone of that actor lives, and says so: the file was kept, how many clones of the actor live, and that the box stays in the old race until they are respawned. once no clone of the actor lives, enroll removes the file β€” and if it is a real file whose `expiresAt` is later than `~/.claude`'s, adopts it into `~/.claude` first, since it may hold the only live token. + +## .the sketch + +``` +given('[case3] an actor brain dir that holds a 1.48.0 credential symlink') + when('[t0] a clone of the actor lives, and a new clone is enrolled') + then('the new clone env carries CLAUDE_SECURESTORAGE_CONFIG_DIR=""') + then('the symlink is kept, and the output names the live pre-cure count and the respawn cure') + when('[t1] no clone of the actor lives, and a clone is enrolled') + then('the symlink is removed') +given('an actor brain dir that holds a real credential with a later expiresAt than ~/.claude') + when('[t2] no clone of the actor lives, and a clone is enrolled') + then('~/.claude/.credentials.json holds that credential, mode 0600') + then('the brain dir holds no .credentials.json') +``` + +⚠️ until every pre-cure clone is respawned and every tree on the box runs the fix, the old race stays open for them. the brief names that cure: upgrade each tree, respawn each crew. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=4.a-claude-code-bump-drops-the-variable.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=4.a-claude-code-bump-drops-the-variable.md new file mode 100644 index 00000000..235b5b0b --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=4.a-claude-code-bump-drops-the-variable.md @@ -0,0 +1,21 @@ +# case 4 β€” a claude-code bump drops the variable, and the clamp says so + +feel: sharp Β· care: critipath (maintainer) Β· crosses every `shared` cell: the claude-code version + +## .the narrative + +`CLAUDE_SECURESTORAGE_CONFIG_DIR` is undocumented (#79223). a future claude-code release may stop to honor it. then `_S()` falls back to the per-actor config dir, and the 1.48.0 race returns silently. + +the clamp catches it: an acceptance test spawns a real claude-code through rhachet's own spawn env over a fake `HOME`, plants an expired fake login in `~/.claude`, and asserts the dead-token clear lands in `~/.claude` and not in the brain dir. on the bump that drops the variable, it goes red, and its failure names the fallback: O10, documented in `refs/inventory.of=options.case=O10-adopt-the-winner.md`. + +## .the sketch + +``` +given('[case4] a fake HOME with an expired fake login in ~/.claude, a real claude-code') + when('[t0] a clone runs one prompt through the rhachet spawn env') + then('~/.claude/.credentials.json holds the blank') + then('the brain dir holds no .credentials.json') + then('the brain dir holds .claude.json') +``` + +under 1.48.0 the same test is red: the clear lands in the brain dir and `~/.claude` stays planted. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=_.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=_.md new file mode 100644 index 00000000..4857a942 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.case=_.md @@ -0,0 +1,30 @@ +# 1.vision.experience.case=_ β€” the inventory + +every cell of `1.vision.experience.dimensions.md`, verdicted. `any` = flat on that axis. + +| D1 | D2 | D3 | D4 | verdict | feel | care | case | +|---|---|---|---|---|---|---|---| +| `key` | live | any | any | **demoed** β€” no false refusal on a blanked file | happy | alterpath | [2](./1.vision.experience.case=2.a-dead-login-refuses-and-one-login-recovers.md) `[t2]` | +| `key` | dead | any | any | **itemized** β€” claude-code's own auth error | sharp | alterpath | β€” | +| `env` | live | any | any | **demoed** β€” no false refusal on a blanked file | happy | alterpath | [2](./1.vision.experience.case=2.a-dead-login-refuses-and-one-login-recovers.md) `[t1]` | +| `env` | dead | any | any | **itemized** β€” claude-code's own auth error | sharp | alterpath | β€” | +| `shared` | live | solo Β· cured | any | **demoed** | happy | critipath β€” the grove's daily state | [1](./1.vision.experience.case=1.fleet-shares-one-login-through-the-window.md) | +| `shared` | live | precure | fresh Β· past | **demoed** | sharp | critipath β€” every 1.48.0 grove walks it once | [3](./1.vision.experience.case=3.migrate-a-live-grove.md) | +| `shared` | live | precure | window | **demoed** β€” ⚠️ the residual race, open until the respawn | sharp | critipath | [3](./1.vision.experience.case=3.migrate-a-live-grove.md) | +| `shared` | dead | any | any | **demoed** β€” refuse, then one `/login` heals every live clone | sharp | critipath | [2](./1.vision.experience.case=2.a-dead-login-refuses-and-one-login-recovers.md) `[t0]` | +| `none` | β€” | any | any | **itemized** β€” the absent line, which now names `~/.claude` | sharp | alterpath | β€” | + +crosses every `shared` cell: a claude-code bump that drops the variable β†’ [case 4](./1.vision.experience.case=4.a-claude-code-bump-drops-the-variable.md). + +## .impossible cells + +`none` Γ— `live` or `dead` β€” no credential, so no health. + +## .D5 β€” the machine reader + +| sharp cell | what the machine sees | +|---|---| +| `shared` Γ— `dead` | exit 2, the `ConstraintError` in its json error shape | +| `shared` Γ— `precure` Γ— `window` | naught at enroll; the kept-file line gives the live pre-cure count. accepted gap: a live pane's state is the fleet poll's (out of scope, the reseed dream) | +| `key` / `env` Γ— `dead` | claude-code's own auth error in the pane | +| case 4 | the clamp's red test in ci | diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.dimensions.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.dimensions.md new file mode 100644 index 00000000..7b06e214 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.experience.dimensions.md @@ -0,0 +1,33 @@ +# 1.vision.experience.dimensions + +the experience space of "a clone authenticates, and stays authenticated, beside its peers". + +## .the dimensions + +| # | dimension | positions | +|---|---|---| +| D1 | **source** β€” the highest-ranked credential rhachet can observe, by claude-code's precedence | `key` β€” `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` Β· `env` β€” `CLAUDE_CODE_OAUTH_TOKEN` Β· `shared` β€” `~/.claude/.credentials.json`, via the shared store Β· `none` | +| D2 | **health** | `live` Β· `dead` β€” revoked, or blanked (empty secrets beside a live `refreshTokenExpiresAt`) | +| D3 | **peers** β€” the other live processes on the box that refresh the login | `solo` Β· `cured` β€” on the shared store: clones after the fix, the human's `claude`, trees on pre-1.48.0 rhachet Β· `precure` β€” 1.48.0 clones, each on its own lock set | +| D4 | **phase** β€” of a refreshable, live login | `fresh` Β· `window` Β· `past` | +| D5 | **who spawns** | `human` Β· `machine` β€” grades the sharp cells: a machine must get an exit code, never a parked pane | + +`none` has no health. D4 is flat wherever no refresh can happen (`key`, `env`, `dead`). + +## .the walked product + +`key`, `env`, `shared`: 3 Γ— 2 Γ— 3 Γ— 3 = 54 cells; `none`: 3 Γ— 3 = 9; 63 in all. + +- `key` Γ— `live` β†’ flat on D3, D4 β†’ case 2 `[t2]` (9) +- `key` Γ— `dead` β†’ itemized, claude-code's own auth error (9) +- `env` Γ— `live` β†’ flat β†’ case 2 `[t1]` (9) +- `env` Γ— `dead` β†’ itemized, claude-code's own auth error; rhachet neither mints nor judges an env token (9) +- `shared` Γ— `live` Γ— `solo` Β· `cured` β†’ case 1 (6) +- `shared` Γ— `live` Γ— `precure` Γ— `fresh` Β· `past` β†’ case 3 `[t0]` (2) +- `shared` Γ— `live` Γ— `precure` Γ— `window` β†’ case 3 ⚠️, the residual race until respawn (1) +- `shared` Γ— `dead` β†’ case 2 `[t0]` (9) +- `none` β†’ itemized, the absent line (9) + +check: 9 + 9 + 9 + 9 + 6 + 2 + 1 + 9 + 9 = 63. + +the claude-code version crosses every `shared` cell β€” case 4. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.guard b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.guard new file mode 100644 index 00000000..5ababdea --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.guard @@ -0,0 +1,144 @@ +# provenance = self-referential source template; enables `rhx route.guard.upgrade` idempotency +provenance: + uri: node_modules/rhachet-roles-bhuild/dist/domain.operations/behavior/init/templates/1.vision.guard.light + +# guard for vision stone +# +# requires human approval before stone can be marked as passed +# because the self-review prompts require human feedback, +# the process needs to halt here for human review + +judges: + # peer reviews must clear (0 blockers) before the human approves + - $rhachet run --repo bhrain --skill route.stone.judge --mechanism reviewed? --stone $stone --route $route --allow-blockers 0 --allow-nitpicks 7 + - $rhachet run --repo bhrain --skill route.stone.judge --mechanism approved? --stone $stone --route $route + +reviews: + self: + - slug: has-grounded-in-reality + say: | + a junior recently modified files in this repo. we need to carefully + review the vision due to this. + + did the junior ground the vision in reality, or make things up? + + check the groundwork section: + + for external references (APIs, services, docs): + - did they actually verify these exist? + - did they cite what they checked? + - or did they assume without sanity check? + + for internal references (extant behavior, patterns, code): + - did they actually verify what that behavior is? + - did they verify extant contracts to conform to? (interfaces, types, signatures) + - did they verify extant vocab to reuse? (domain terms, name patterns) + - did they verify extant stdouts to match? (cli output patterns, error formats) + - did they cite specific files/lines? + - or did they assume the code works a certain way without verification? + + this is not about exhaustive research β€” just sanity checks. + the question is: is this vision coherent with reality, or built on assumptions? + + - slug: has-experience-coverage + say: | + a junior recently modified files in this repo. we need to carefully + review the vision due to this. + + did the junior demonstrate the experiences the wish implies, or only narrate them? + + an experience is an actor's lived encounter with the behavior. one behavior has many + experiences β€” different actors encounter it differently. each sits on two axes: feel + (happy vs sharp) and care (critipath vs alterpath). read `define.experience`, + `define.experience._.metric=boundary-density`, `howto.experience.enumerate`, and the worked + `define.experience._.demo.surf-school` before you grade. + + walk the wish through the same three steps the stone asked of the junior: + + 1. enumerate β€” for each actor that matters, walk their happy and sharp paths. list + every experience the wish implies, then name each one's feel (happy / sharp) + and care (critipath / alterpath). do not enumerate only critipaths β€” enumerate all, + then let the care decide severity. + 2. demonstrate β€” is there a `1.vision.experience.case=N.$slug.md` per experience, + rendered both as a narrative (actor's pov) and a bdd `[tn]` timeline? does each + sharp critipath prove it fails safe (fast, loud, teaches the fix) β€” not just that + the edge exists? + 3. catalog β€” does `1.vision.experience.case=_.md` list them all, name each cell's feel and care, + and link to its case file? did the junior hunt out and center the densest experiences + β€” the ones that cross the most boundaries in one walk (highest value and highest + risk)? a vision of only sterile single-boundary demos is thin. + + an absent critipath is a blocker. a sharp critipath shown unhandled (no fail-safe) is a + blocker β€” write or repair the case now, before you continue. an absent alterpath, or + thin bonus coverage, is a nitpick β€” flag it. + + a wish with no user-visible experience declares "no experiences β€” internal only". + + - slug: has-questioned-requirements + say: | + a junior recently modified files in this repo. we need to carefully + review the vision due to this. + + are there any requirements that should be questioned? + + for each requirement, ask: + - who said this was needed? when? why? + - what evidence supports this requirement? + - what if we didn't do this β€” what would happen? + - is the scope too large, too small, or misdirected? + - could we achieve the goal in a simpler way? + + challenge each requirement and justify why it belongs. + + - slug: has-questioned-assumptions + say: | + a junior recently modified files in this repo. we need to carefully + review the vision due to this. + + are there any hidden assumptions the junior took as requirements? + + for each assumption, ask: + - what do we assume here without evidence? + - what evidence supports this assumption? + - what if the opposite were true? + - did the wisher actually say this, or did we infer it? + - what exceptions or counterexamples exist? + + surface all hidden assumptions and question each one. + + - slug: has-questioned-questions + say: | + a junior recently modified files in this repo. we need to carefully + review the vision due to this. + + are there any open questions? triage them: + + for each question, ask: + - can this be answered via logic now? if so, answer it now. + - can this be answered via extant docs or code now? if so, answer it now. + - should this be answered via external research later? if so, mark it for research. + - does only the wisher know the answer? if so, ask the wisher. + + for each question, ensure it is clearly marked as either: + - [answered] β€” resolved now + - [research] β€” to be answered in the research phase + - [wisher] β€” requires wisher input + + ensure they're enumerated within the vision under "open questions & assumptions" + + peer: + # dimensional decomposition β€” light-enrolled, scoped to one concern: + # are the experience dimensions orthogonal + exhaustive, and is the product fully walked? + # this is the discovery check β€” it grades the space, not the demos. it catches the + # critipaths a free-list would miss, and closes the self-authored-yardstick gap. + - slug: dimensional-decomposition + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/rule.require.dimensional-decomposition.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/howto.experience.decompose.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/define.experience.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/define.experience._.axis=care.path=criti-vs-alter.md' --refs '.agent/repo=ehmpathy/role=architect/briefs/practices/domain.discovery/howto.dimensional-decomposition.md' --refs '$route/0.wish.md' --diffs since-main --paths-with '$route/1.vision.*.md' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + # experience coverage β€” light-enrolled, scoped to one concern: + # are the critipaths (happy + sharp) each demonstrated, and do sharp ones fail safe? + - slug: experience-coverage + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/rule.require.experience-coverage.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/define.experience.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/define.experience._.axis=feel.path=happy-vs-sharp.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/define.experience._.axis=care.path=criti-vs-alter.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/define.experience._.metric=boundary-density.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/howto.experience.enumerate.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/define.experience._.demo.surf-school.md' --refs '$route/0.wish.md' --diffs since-main --paths-with '$route/1.vision.*.md' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.stamp b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.stamp new file mode 100644 index 00000000..7de2ed34 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.stamp @@ -0,0 +1,29 @@ +πŸ¦‰ the way speaks for itself + +πŸ—Ώ route.stone.set + β”œβ”€ stone = 1.vision + β”œβ”€ passage = allowed + β”œβ”€ guard + β”‚ β”œβ”€ artifacts + β”‚ β”œβ”€ reviews + β”‚ β”‚ β”œβ”€ r1: dimensional-decomposition (l1, 3/3) + β”‚ β”‚ β”‚ β”œβ”€ approved, cached + β”‚ β”‚ β”‚ β”œβ”€ 0 blockers βœ“ + β”‚ β”‚ β”‚ β”œβ”€ 3 nitpicks 🟠 + β”‚ β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i003.dea28eec959537b4d5.r001._.given.by_peer.dimensional-decomposition.md + β”‚ β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i003.dea28eec959537b4d5.r001._.taken.by_self.dimensional-decomposition.md + β”‚ β”‚ └─ r2: experience-coverage (l1, 3/3) + β”‚ β”‚ β”œβ”€ exhausted πŸŒ™, cached + β”‚ β”‚ β”œβ”€ terminal β€” does not block higher levels + β”‚ β”‚ β”œβ”€ 1 blocker πŸ”΄ + β”‚ β”‚ β”œβ”€ 2 nitpicks 🟠 + β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i003.dea28eec959537b4d5.r002._.given.by_peer.experience-coverage.md + β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/1.vision._.review.i003.dea28eec959537b4d5.r002._.taken.by_self.experience-coverage.md + β”‚ └─ judges + β”‚ β”œβ”€ j1: $rhachet run --repo bhrain --skill route.stone.judge --mechanism reviewed? --stone $stone --route $route --allow-blockers 0 --allow-nitpicks 7 + β”‚ β”‚ └─ finished 1.0s βœ“ + β”‚ └─ j2: $rhachet run --repo bhrain --skill route.stone.judge --mechanism approved? --stone $stone --route $route + β”‚ └─ finished 0.7s βœ“ + β”‚ + └─ the way continues, run + └─ rhx route.drive \ No newline at end of file diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.stone b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.stone new file mode 100644 index 00000000..779c8816 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.stone @@ -0,0 +1,73 @@ +illustrate the vision implied in the wish .behavior/v2026_09_29.fix-shared-brain-credential-blanking/0.wish.md + +emit into .behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.yield.md + +--- + +paint a picture of what the world looks like when this wish is fulfilled + +testdrive the contract we propose via realworld examples + +specifically, + +## the outcome world + +- what does a day-in-the-life look like with this in place? +- what's the before/after contrast? +- what's the "aha" moment where the value clicks? + +## the experiences + +**demonstrate every critipath the wish implies** β€” discover them first, do not free-list: + +- **decompose** β€” factor the experience space into orthogonal dimensions, walk the product +- **itemize** β€” verdict every cell into the inventory; name each real experience's feel Γ— care +- **demonstrate** β€” a narrative + bdd `[tn]` sketch per critipath (+ chosen alterpaths) + +β†’ see `howto.experience.demonstrate` (the method, the artifacts to emit, why `[tn]` is a sketch) + +## mental model + +- how would users describe this to a friend? +- what analogies or metaphors fit? +- what terms would they use vs what terms would we use? + +## evaluation + +- how well does it solve the goals? +- what are the pros? the cons? +- what edgecases exist and how do our contracts keep users in a pit of success? + +## open questions & assumptions + +- what assumptions have we made? +- what questions remain unanswered? +- what must we validate with the wisher before we proceed? +- what must we research externally? + +## groundwork + +sanity check the vision against reality. not exhaustive research β€” just enough to know the vision isn't built on false assumptions. + +### external research + +if the wish references external apis, services, or docs: +- did you verify they exist and work the way you assume? +- cite what you checked (links, key constraints noted) +- if none referenced, say "none β€” no external dependencies" + +### internal research + +if the wish references extant behavior, patterns, or code: +- did you verify what that behavior actually is? +- contracts: interfaces, types, signatures to conform to +- vocab: domain terms, name patterns to reuse +- stdouts: cli output patterns, error formats to match +- cite what you checked (file paths, line numbers) +- if none referenced, say "none β€” no extant behavior extended" + +## what is awkward? + +- what feels off or forced? +- where does the design fight the user's mental model? +- what tradeoffs feel uncomfortable? diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.yield.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.yield.md new file mode 100644 index 00000000..31c48166 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.yield.md @@ -0,0 +1,82 @@ +# 1.vision β€” a refresh by one clone never de-auths the box + +## .the answer, in one breath + +**every clone shares one credential store and one lock set: the box's `~/.claude`.** enroll spawns each clone with `CLAUDE_SECURESTORAGE_CONFIG_DIR=""` beside its per-actor `CLAUDE_CONFIG_DIR`, and stops to symlink the credential into the brain dir. the credential file and every lock that guards it move back to `~/.claude`. boot, settings, and transcripts stay per actor. same oauth login, same refresh, no new auth scheme (S1, S6). + +## .why this, from what the evidence says + +| fact | source | +|---|---| +| claude-code keys the credential file, the store write lock, and the oauth refresh lock on `_S()`, which reads `CLAUDE_SECURESTORAGE_CONFIG_DIR` first; `""` means `~/.claude`, unset means the config dir | `refs/inventory.of=options.case=O3-…md` `.citations` (2.1.280) | +| under 1.48.0 (#553) the var is unset, so N actors hold N lock sets over one symlinked file; a won refresh renames over the symlink, detaches the winner, and leaves the shared file on the spent token for the next loser to blank | `refs/define.invariant.a-blank-proves-the-winner-missed-the-global-file.md` | +| with `""`, the read and the write land in `~/.claude`, and `.claude.json`, `projects`, `sessions` stay in the actor dir | the run: `rhx claude.cli.secstore.trial` (O3 `.the run`) | +| claude-code spawns its own agent-team teammates with `CLAUDE_SECURESTORAGE_CONFIG_DIR=` | O3 `.citations` | +| a blanked clone recovers once its credential file holds a live token again, no respawn | first-party, S7 | + +β‡’ one file, one lock set for every process on the box β€” clones, the human's `claude`, trees on older rhachet. a refresh loser waits on the lock, re-reads, and finds the winner's token. the race 1.48.0 created is gone, not narrowed. + +## .the outcome world + +**before** β€” 21h26m56s. eighteen crews, eighteen lock sets, one file. one crew wins and detaches; the next loser blanks the shared file; every pane flips to `Login expired`. + +**after** β€” the window opens; eighteen crews and the human's `claude` queue on one lock. one refreshes, the rest re-read and continue. 21h26m56s passes unnoticed. + +**the aha** β€” the login was always the box's, not the actor's. the fix puts it back where it belongs. + +## .the contract + +| enroll finds | enroll does | case | +|---|---|---| +| any spawn | sets `CLAUDE_SECURESTORAGE_CONFIG_DIR=""` in the clone env, after the caller env (F8); links no credential | [1](./1.vision.experience.case=1.fleet-shares-one-login-through-the-window.md) | +| `~/.claude/.credentials.json` blanked β€” empty secrets, live `refreshTokenExpiresAt` β€” and no env credential ranks above it | refuses before the spawn, exit 2, names `/login` and that live clones recover on the refill, no respawn (F5) | [2](./1.vision.experience.case=2.a-dead-login-refuses-and-one-login-recovers.md) | +| a `.credentials.json` in the actor's brain dir (a 1.48.0 leftover) | inert for the new clone. kept while a clone of the actor lives; once none lives, removed β€” and first adopted into `~/.claude` if it is a real file with a later `expiresAt` (F4, F7) | [3](./1.vision.experience.case=3.migrate-a-live-grove.md) | +| no login at all | as today, the absent line β€” it now names `~/.claude` | β€” itemized | + +the shape test reads field presence, length, and `expiresAt` only. no token value ever reaches a log. + +## .the fallback β€” documented, not built + +if a claude-code release stops to honor the variable, the clamp (case 4) goes red. the fallback is O10: a watch that adopts the refresh winner's detached file into `~/.claude`, removes every real actor credential, and relinks all. it is viable β€” the winner's token survives and blanked clones recover on the refill (S7) β€” and it stays unbuilt while O3 holds (S6). [case 4](./1.vision.experience.case=4.a-claude-code-bump-drops-the-variable.md) Β· `refs/inventory.of=options.case=O10-adopt-the-winner.md`. + +## .the done test, mapped + +| wish done test | how the vision meets it | +|---|---| +| 1. the fleet survives β‰₯2 refresh cycles | case 1 β€” a live playtest once every pre-cure clone is respawned and every tree on the box runs the fix (case 3) | +| 2. a forced failure is contained | a per-clone failure no longer exists: every clone reads one file. a real revoke is box-wide by nature, and one `/login` recovers every live clone with no respawn (case 2, S7) | +| 3. no blanked credential persists undetected | case 2 β€” enroll detects the shape and refuses | +| 4. a clamp, red before, green after | the secstore trial through rhachet's own spawn env and a real claude-code: red under 1.48.0 (the clear lands in the actor dir), green after (it lands in `~/.claude`, the brain dir holds no credential) β€” case 4 | +| 5. the credential story is written down | a `.credentials` section in `define.brain-dir-repo-vs-actor.md`: the config is the actor's, the login is the box's | + +## .mental model + +*"each crew has its own desk, but the house has one key box."* the credential derives from the human's login, never from `{ brain, roles }`, so it sits at the human's grain β€” no per-clone or per-actor copy (`rule.forbid.per-clone-config`). + +## .evaluation + +- **pros** β€” the cause is removed; one env var and one deleted call; no daemon, no human step; the pre-1.48.0 topology; a dead login heals for the whole fleet with one `/login` +- **cons** β€” the variable is undocumented (#79223), held only by claude-code's own teammate spawn and our clamp; live 1.48.0 clones race until respawned +- **pit of success** β€” no setup; the default spawn is the safe one + +## .open questions & assumptions + +- **[research]** two live processes on the one lock β€” the code says the loser waits and adopts; verification observes it +- **[answered]** does the winner's token survive, and do clones recover without a respawn? yes, first-party (S7) +- **[answered]** does a leftover brain-dir credential affect a new clone? no β€” `_S()` never reads it under `""` +- **[answered]** does a live 1.48.0 clone gain the fix? no β€” no process can change another's env; it races until respawned (case 3) +- **[out of scope]** per-subscription reach and live rotation (S2–S4) β€” O3 composes with them; a dedicated feature pr, shaped in `dreams/v2026_09_29.feat.per-subscription-reach-and-live-rotation.md` + +the fulcrums: [`.fulcrums/inventory.of=fulcrums._.md`](./.fulcrums/inventory.of=fulcrums._.md). the options: [`refs/inventory.of=options._.md`](./refs/inventory.of=options._.md). + +## .the experiences + +- dimensions: [`1.vision.experience.dimensions.md`](./1.vision.experience.dimensions.md) +- inventory: [`1.vision.experience.case=_.md`](./1.vision.experience.case=_.md) +- demos: cases [1](./1.vision.experience.case=1.fleet-shares-one-login-through-the-window.md) Β· [2](./1.vision.experience.case=2.a-dead-login-refuses-and-one-login-recovers.md) Β· [3](./1.vision.experience.case=3.migrate-a-live-grove.md) Β· [4](./1.vision.experience.case=4.a-claude-code-bump-drops-the-variable.md) + +## .groundwork + +- `src/domain.operations/enroll/asBrainCliSpawnEnv.ts:47-58` β€” the one owner of the spawn env for every spawn path; the variable lands beside `CLAUDE_CONFIG_DIR` +- `src/contract/cli/invokeEnroll.ts:406-410` β€” the `findsertBrainCredentialSymlink` call to drop +- `refs/` β€” the claude-code reads, the options inventory, the invariant diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.guard b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.guard new file mode 100644 index 00000000..19768741 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.guard @@ -0,0 +1,222 @@ +# provenance = self-referential source template; enables `rhx route.guard.upgrade` idempotency +provenance: + uri: node_modules/rhachet-roles-bhuild/dist/domain.operations/behavior/init/templates/5.1.execution.from_vision.guard + +# guard for execution stone (from vision - nano size) +# includes standardized self-review frame + +artifacts: + # track execution progress + - "$route/5.1.execution.from_vision.yield.md" + # track actual implementation in src/ + - "src/**/*" + +reviews: + self: + # 1. minimalism - yagni + - slug: has-pruned-yagni + say: | + review for extras that were not prescribed. + + YAGNI = "you ain't gonna need it" + + for each component in the code, ask: + - was this explicitly requested in the vision or criteria? + - is this the minimum viable way to satisfy the requirement? + - did we add abstraction "for future flexibility"? + - did we add features "while we're here"? + - did we optimize before we knew it was needed? + + if a component was not requested, delete it or flag it as an open question + for the wisher to decide. + + # 2. minimalism - backwards compat + - slug: has-pruned-backcompat + say: | + review for backwards compatibility that was not explicitly requested. + + for each backwards-compat concern in the code, ask: + - did the wisher explicitly say to maintain this compatibility? + - is there evidence this backwards compat is needed? + - or did we assume it "to be safe"? + + if backwards compat was not explicitly requested: + 1. flag it as an open question for the wisher + 2. eliminate it if not confirmed as required + 3. make the open question very clearly reported + + # 3. consistency - mechanisms + - slug: has-consistent-mechanisms + say: | + review for new mechanisms that duplicate extant functionality. + + unless the ask was to refactor, be consistent with extant mechanisms. + + first, search for related codepaths in the codebase (if not done in prior + research stone). look for extant utilities and patterns. + + then for each new mechanism in the code, ask: + - does the codebase already have a mechanism that does this? + - do we duplicate extant utilities or patterns? + - could we reuse an extant component instead of a new one? + + if a new mechanism duplicates extant functionality: + 1. replace with the extant mechanism + 2. or flag as an open question if unsure + + # 4. consistency - conventions + - slug: has-consistent-conventions + say: | + review for divergence from extant names and patterns. + + unless the ask was to refactor, be consistent with extant conventions. + + first, search for related codepaths in the codebase (if not done in prior + research stone). identify extant name conventions and patterns. + + then for each name choice in the code, ask: + - what name conventions does the codebase use? + - do we use a different namespace, prefix, or suffix pattern? + - do we introduce new terms when extant terms exist? + - does our structure match extant patterns? + + if we diverge from extant conventions: + 1. align with the extant convention + 2. or flag as an open question if the extant convention seems wrong + + # 5. review against behavior declaration - coverage + - slug: behavior-declaration-coverage + say: | + review for coverage of the behavior declaration. + + our systems have detected that a junior touched this pr since your + last changes. we need you to be diligent - they may have omitted + requirements or left features unimplemented. + + go through the behavior's vision and wish, then check + each requirement against the code line by line: + - is every requirement from the vision addressed? + - did the junior skip or forget any part of the spec? + + fix all gaps before you continue. + + # 6. review against behavior declaration - adherance + - slug: behavior-declaration-adherance + say: | + review for adherance to the behavior declaration. + + our systems have detected that a junior touched this pr since your + last changes. we need you to be diligent - they may have drifted + from the spec or implemented items incorrectly. + + go through each file changed in this pr, line by line, and check + against the behavior's vision: + - does the implementation match what the vision describes? + - did the junior misinterpret or deviate from the spec? + + fix all gaps before you continue. + + # 7. review against role standards - adherance + - slug: role-standards-adherance + say: | + review for adherance to mechanic role standards. + + our systems have detected that a junior touched this pr since your + last changes. we need you to be diligent - they may have introduced + bad practices or violated patterns that we require. + + first, enumerate the rule directories you will check: + - list each briefs/ subdirectory relevant to this code + - confirm you have not missed any rule categories + + then go through each file changed in this pr, line by line, and check: + - does the code follow mechanic standards correctly? + - are there violations of required patterns? + - did the junior introduce anti-patterns, bad practices, or deviations from our conventions? + + fix all gaps before you continue. + + # 8. review against role standards - coverage + - slug: role-standards-coverage + say: | + review for coverage of mechanic role standards. + + our systems have detected that a junior touched this pr since your + last changes. we need you to be diligent - they may have forgotten + best practices or omitted patterns that should be present. + + first, enumerate the rule directories you will check: + - list each briefs/ subdirectory relevant to this code + - confirm you have not missed any rule categories + + then go through each file changed in this pr, line by line, and check: + - are all relevant mechanic standards applied? + - are there patterns that should be present but are absent? + - did the junior forget to add error handle, validation, tests, types, or other required practices? + + fix all gaps before you continue. + + peer: + # --- level 1: cheap reviewers (run first, in parallel) --- + + - slug: repo-rules + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=.this/**/rule.*.md' --optional rules --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --paths-without '.behavior/**' --paths-without '**/__snapshots__/**' --paths-without '**/*.md' --paths-without '**/*.test.ts' --paths-without 'blackbox/**' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-failhides + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.{prod,test}/pitofsuccess.errors/rule.*.md' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-decode-friction + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=ehmpathy/role=architect/briefs/practices/domain.operations/rule.{forbid.decode-friction-in-orchestrators,require.orchestrators-as-narrative}.md' --rules '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/readable.narrative/rule.{forbid.inline-decode-friction,require.named-transformers}.md' --refs '.agent/repo=ehmpathy/role=architect/briefs/practices/domain.operations/{define.domain-operation-grains,philosophy.transformer-orchestrator-separation.[philosophy]}.md' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --paths-without '**/*.test.ts' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + # --- architect reviews (level 1) --- + + - slug: arch-opport-decomposition + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.prefer.decomposable-architecture.md' --refs '.agent/repo=ehmpathy/role=architect/briefs/practices/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.architecture/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.procedures/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.domain.operations/*.md.min' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: arch-smell-scopeleaks + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.forbid.scope-leaks.md' --refs '.agent/repo=ehmpathy/role=architect/briefs/practices/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.architecture/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.procedures/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.domain.operations/*.md.min' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: arch-hazards-maintenance + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.forbid.maintenance-hazards.md' --refs '.agent/repo=ehmpathy/role=architect/briefs/practices/rule.require.solve-at-cause.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/pitofsuccess.errors/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/pitofsuccess.procedures/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/pitofsuccess.typedefs/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/readable.narrative/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/readable.comments/*.md.min' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: arch-hazards-behavior + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.forbid.behavior-hazards.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/pitofsuccess.procedures/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.prod/evolvable.procedures/*.md.min' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --paths-without '.behavior/**' --paths-without '**/__snapshots__/**' --paths-without '**/*.md' --paths-without '**/*.test.ts' --paths-without 'blackbox/**' --join intersect --conversation $conversation --output "$output" + budget: 5 + level: 1 + + - slug: behavior-intent-coverage + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.require.behavior-intent-coverage.md' --refs '$route/0.wish.md' --refs '$route/1.vision.yield.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/*.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.criteria/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.coverage/*.md' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: ergo-friction-hazards + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/architect/rule.forbid.friction-hazards.md' --refs '.agent/repo=ehmpathy/role=ergonomist/briefs/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/lang.tones/*.md.min' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/*.md' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + # --- level 3: expensive reviewers (run after level 1 is terminal) --- + + - slug: enroll-impl-behavior-intent + run: $rhx enroll claude --model 'claude-sonnet-5[1m]' --roles reviewer,behaver,architect,mechanic -p 'review the current implementation for omissions or divergences from the wished and envisioned behavioral intent. in particular flag any ergonomic friction that is unaddressed or friction hazards that are not clearly covered with acceptance tests and snaps. read the prior peer-review conversation at $conversation to catch up on context.' + budget: 3 + level: 3 + + - slug: enroll-impl-arch-defects + run: $rhx enroll claude --model 'claude-sonnet-5[1m]' --roles reviewer,behaver,architect,mechanic -p 'review the current implementation for architectural defects and omissions; opports to decompose for recompose, prevent scope leaks, and eliminate maintenance and behavior hazards structurally. read the prior peer-review conversation at $conversation to catch up on context.' + budget: 3 + level: 3 + +judges: + - $rhachet run --repo bhrain --skill route.stone.judge --mechanism reviewed? --stone $stone --route $route --allow-blockers 0 --allow-nitpicks 7 diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.stamp b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.stamp new file mode 100644 index 00000000..85998fd4 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.stamp @@ -0,0 +1,231 @@ +πŸ¦‰ the way speaks for itself + +πŸ—Ώ route.stone.set + β”œβ”€ stone = 5.1.execution.from_vision + β”œβ”€ passage = malfunction + β”œβ”€ reason = reviewer or judge malfunctioned + β”œβ”€ options + β”‚ β”œβ”€ converge with the reviewer β€” yours to run, and the levels above stay unlocked + β”‚ β”‚ └─ rhx route.stone.set --stone 5.1.execution.from_vision --as absorbed --that + β”‚ β”œβ”€ overrule the malfunction β€” a human must grant + β”‚ β”‚ └─ rhx route.stone.set --stone 5.1.execution.from_vision --as overruled + β”‚ β”œβ”€ or fix the reviewer, then retry + β”‚ └─ a broken level never halts the drive β€” converge the level above it + └─ guard + β”œβ”€ artifacts + β”‚ β”œβ”€ $route/5.1.execution.from_vision.yield.md + β”‚ └─ src/**/* + β”œβ”€ reviews + β”‚ β”œβ”€ r1: repo-rules (l1, 1/3) + β”‚ β”‚ β”œβ”€ malfunction πŸ’₯ + β”‚ β”‚ β”œβ”€ terminal β€” does not block higher levels + β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i010.6f9e405e345e7a319d.r001._.given.by_peer.repo-rules.md + β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i010.6f9e405e345e7a319d.r001._.taken.by_self.repo-rules.md + β”‚ β”œβ”€ r2: mech-failhides (l1, 3/3) + β”‚ β”‚ β”œβ”€ approved, cached + β”‚ β”‚ β”œβ”€ 0 blockers βœ“ + β”‚ β”‚ β”œβ”€ 3 nitpicks 🟠 + β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r002._.given.by_peer.mech-failhides.md + β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r002._.taken.by_self.mech-failhides.md + β”‚ β”œβ”€ r3: mech-decode-friction (l1, 3/3) + β”‚ β”‚ β”œβ”€ exhausted πŸŒ™, cached + β”‚ β”‚ β”œβ”€ terminal β€” does not block higher levels + β”‚ β”‚ β”œβ”€ 2 blockers πŸ”΄ + β”‚ β”‚ β”œβ”€ 1 nitpick 🟠 + β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r003._.given.by_peer.mech-decode-friction.md + β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r003._.taken.by_self.mech-decode-friction.md + β”‚ β”œβ”€ r4: arch-opport-decomposition (l1, 3/3) + β”‚ β”‚ β”œβ”€ approved, cached + β”‚ β”‚ β”œβ”€ 0 blockers βœ“ + β”‚ β”‚ β”œβ”€ 4 nitpicks 🟠 + β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i004.5d958fd6a814a011b0.r004._.given.by_peer.arch-opport-decomposition.md + β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i004.5d958fd6a814a011b0.r004._.taken.by_self.arch-opport-decomposition.md + β”‚ β”œβ”€ r5: arch-smell-scopeleaks (l1, 3/3) + β”‚ β”‚ β”œβ”€ approved, cached + β”‚ β”‚ β”œβ”€ 0 blockers βœ“ + β”‚ β”‚ β”œβ”€ 3 nitpicks 🟠 + β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r005._.given.by_peer.arch-smell-scopeleaks.md + β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r005._.taken.by_self.arch-smell-scopeleaks.md + β”‚ β”œβ”€ r6: arch-hazards-maintenance (l1, 3/3) + β”‚ β”‚ β”œβ”€ exhausted πŸŒ™, cached + β”‚ β”‚ β”œβ”€ terminal β€” does not block higher levels + β”‚ β”‚ β”œβ”€ 1 blocker πŸ”΄ + β”‚ β”‚ β”œβ”€ 3 nitpicks 🟠 + β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r006._.given.by_peer.arch-hazards-maintenance.md + β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r006._.taken.by_self.arch-hazards-maintenance.md + β”‚ β”œβ”€ r7: arch-hazards-behavior (l1, 4/5) + β”‚ β”‚ β”œβ”€ malfunction πŸ’₯ + β”‚ β”‚ β”œβ”€ terminal β€” does not block higher levels + β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i010.6f9e405e345e7a319d.r007._.given.by_peer.arch-hazards-behavior.md + β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i010.6f9e405e345e7a319d.r007._.taken.by_self.arch-hazards-behavior.md + β”‚ β”œβ”€ r8: behavior-intent-coverage (l1, 3/3) + β”‚ β”‚ β”œβ”€ approved, cached + β”‚ β”‚ β”œβ”€ 0 blockers βœ“ + β”‚ β”‚ β”œβ”€ 1 nitpick 🟠 + β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r008._.given.by_peer.behavior-intent-coverage.md + β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r008._.taken.by_self.behavior-intent-coverage.md + β”‚ β”œβ”€ r9: ergo-friction-hazards (l1, 3/3) + β”‚ β”‚ β”œβ”€ exhausted πŸŒ™, cached + β”‚ β”‚ β”œβ”€ terminal β€” does not block higher levels + β”‚ β”‚ β”œβ”€ 1 blocker πŸ”΄ + β”‚ β”‚ β”œβ”€ 2 nitpicks 🟠 + β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r009._.given.by_peer.ergo-friction-hazards.md + β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i003.1e364897c09acb6e5f.r009._.taken.by_self.ergo-friction-hazards.md + β”‚ β”œβ”€ r10: enroll-impl-behavior-intent (l3, 3/3) + β”‚ β”‚ β”œβ”€ exhausted πŸŒ™, cached + β”‚ β”‚ β”œβ”€ terminal β€” does not block higher levels + β”‚ β”‚ β”œβ”€ 1 blocker πŸ”΄ + β”‚ β”‚ β”œβ”€ 1 nitpick 🟠 + β”‚ β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i008.d07b1b7e88b9c3a9b2.r010._.given.by_peer.enroll-impl-behavior-intent.md + β”‚ β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i008.d07b1b7e88b9c3a9b2.r010._.taken.by_self.enroll-impl-behavior-intent.md + β”‚ └─ r11: enroll-impl-arch-defects (l3, 3/3) + β”‚ β”œβ”€ exhausted πŸŒ™, cached + β”‚ β”œβ”€ terminal β€” does not block higher levels + β”‚ β”œβ”€ 1 blocker πŸ”΄ + β”‚ β”œβ”€ 1 nitpick, 1 disputed β†’ 0 tallied βœ“ + β”‚ β”œβ”€ tallied by reviewer@fireworks/deepseek/v4-flash + β”‚ β”œβ”€ given: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i007.9effd7bcc1d618a0d3.r011._.given.by_peer.enroll-impl-arch-defects.md + β”‚ └─ taken: .behavior/v2026_09_29.fix-shared-brain-credential-blanking/.reviews/peer/5.1.execution.from_vision._.review.i007.9effd7bcc1d618a0d3.r011._.taken.by_self.enroll-impl-arch-defects.md + └─ judges + └─ j1: $rhachet run --repo bhrain --skill route.stone.judge --mechanism reviewed? --stone $stone --route $route --allow-blockers 0 --allow-nitpicks 7 + β”œβ”€ finished 1.0s βœ— + └─ reason: nitpicks exceed threshold (11 > 7) + +──────────────────────────────────────────────────────────────── + +πŸ”Ž review 1 + β”œβ”€ stdout + β”‚ β”œβ”€ + β”‚ β”‚ + β”‚ β”‚ πŸͺ¨ run solid skill repo=bhrain/role=reviewer/skill=review + β”‚ β”‚ + β”‚ β”‚ πŸ¦‰ let's review + β”‚ β”‚ β”œβ”€ brain: fireworks/deepseek/v4-flash + β”‚ β”‚ β”œβ”€ focus: push + β”‚ β”‚ └─ scope + β”‚ β”‚ β”œβ”€ diffs: since-main + β”‚ β”‚ β”œβ”€ paths-with: **/*.{ts,sh,md,snap} + β”‚ β”‚ β”œβ”€ paths-wout: .behavior/**, **/__snapshots__/**, **/*.md, **/*.test.ts, blackbox/** + β”‚ β”‚ └─ join: intersect + β”‚ β”‚ + β”‚ β”‚ πŸ”­ metrics.expected + β”‚ β”‚ β”œβ”€ files + β”‚ β”‚ β”‚ β”œβ”€ rules: 69 + β”‚ β”‚ β”‚ └─ targets: 25 + β”‚ β”‚ β”œβ”€ tokens + β”‚ β”‚ β”‚ β”œβ”€ estimate: 214,565 + β”‚ β”‚ β”‚ └─ context: 20.5% + β”‚ β”‚ └─ cost + β”‚ β”‚ └─ estimate: $0.061365920 + β”‚ β”‚ + β”‚ β”‚ πŸͺ΅ logs + β”‚ β”‚ β”œβ”€ scope: .log/bhrain/review/2026-10-01T05-56-32-934Z.pid2710077.3c53a2dd-f3c8-49fb-9622-b3cd2940215a/input.scope.json + β”‚ β”‚ β”œβ”€ metrics: .log/bhrain/review/2026-10-01T05-56-32-934Z.pid2710077.3c53a2dd-f3c8-49fb-9622-b3cd2940215a/metrics.expected.json + β”‚ β”‚ └─ tokens: .log/bhrain/review/2026-10-01T05-56-32-934Z.pid2710077.3c53a2dd-f3c8-49fb-9622-b3cd2940215a/tokens.expected.md + β”‚ β”‚ β”œβ”€ rules: .agent (82.0k, 100%) + β”‚ β”‚ └─ targets: src (25.3k, 72.62%), .agent (9.5k, 27.38%) + β”‚ β”‚ + β”‚ └─ + β”œβ”€ stderr + β”‚ β”œβ”€ + β”‚ β”‚ + β”‚ β”‚ πŸͺ¨ run solid skill repo=bhrain/role=reviewer/skill=review + β”‚ β”‚ └─ πŸ’₯ failed with an error + β”‚ β”‚ + β”‚ β”‚ /home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/openai@5.8.2_zod@4.3.4/node_modules/openai/client.js:263 + β”‚ β”‚ throw new Errors.APIConnectionTimeoutError(); + β”‚ β”‚ ^ + β”‚ β”‚ + β”‚ β”‚ APIConnectionTimeoutError: Request timed out. + β”‚ β”‚ at OpenAI.makeRequest (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/openai@5.8.2_zod@4.3.4/node_modules/openai/client.js:263:23) + β”‚ β”‚ at async /home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-brains-fireworksai@0.2.3_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_seedrandom@3.0.5__rhachet@/node_modules/rhachet-brains-fireworksai/dist/domain.operations/atom/genBrainAtom.js:163:28 + β”‚ β”‚ at async BrainAtom.ask (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-brains-fireworksai@0.2.3_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_seedrandom@3.0.5__rhachet@/node_modules/rhachet-brains-fireworksai/dist/domain.operations/atom/genBrainAtom.js:161:30) + β”‚ β”‚ at async Object.ask (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/dist/domain.operations/brain/asBrainAtomWithContextBound.js:20:24) + β”‚ β”‚ at async Object.operation (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-roles-bhrain@0.38.0_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_see_efcd1faefd50d82b0d6798adc1a6ecec/node_modules/rhachet-roles-bhrain/dist/domain.operations/review/stepReview.js:672:32) + β”‚ β”‚ at async withSpinner (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-roles-bhrain@0.38.0_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_see_efcd1faefd50d82b0d6798adc1a6ecec/node_modules/rhachet-roles-bhrain/dist/domain.operations/review/stepReview.js:151:28) + β”‚ β”‚ at async stepReview (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-roles-bhrain@0.38.0_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_see_efcd1faefd50d82b0d6798adc1a6ecec/node_modules/rhachet-roles-bhrain/dist/domain.operations/review/stepReview.js:670:26) + β”‚ β”‚ at async Module.review (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-roles-bhrain@0.38.0_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_see_efcd1faefd50d82b0d6798adc1a6ecec/node_modules/rhachet-roles-bhrain/dist/contract/cli/review.js:349:9) { + β”‚ β”‚ status: undefined, + β”‚ β”‚ headers: undefined, + β”‚ β”‚ requestID: undefined, + β”‚ β”‚ error: undefined, + β”‚ β”‚ code: undefined, + β”‚ β”‚ param: undefined, + β”‚ β”‚ type: undefined + β”‚ β”‚ } + β”‚ β”‚ + β”‚ β”‚ Node.js v22.21.0 + β”‚ β”‚ + β”‚ └─ + └─ passage blocked + β”œβ”€ blocked by malfunction + └─ exit code: 1 πŸ’₯ + +πŸ”Ž review 7 + β”œβ”€ stdout + β”‚ β”œβ”€ + β”‚ β”‚ + β”‚ β”‚ πŸͺ¨ run solid skill repo=bhrain/role=reviewer/skill=review + β”‚ β”‚ + β”‚ β”‚ πŸ¦‰ let's review + β”‚ β”‚ β”œβ”€ brain: fireworks/deepseek/v4-flash + β”‚ β”‚ β”œβ”€ focus: push + β”‚ β”‚ └─ scope + β”‚ β”‚ β”œβ”€ diffs: since-main + β”‚ β”‚ β”œβ”€ paths-with: **/*.{ts,sh,md,snap} + β”‚ β”‚ β”œβ”€ paths-wout: .behavior/**, **/__snapshots__/**, **/*.md, **/*.test.ts, blackbox/** + β”‚ β”‚ └─ join: intersect + β”‚ β”‚ + β”‚ β”‚ πŸ”­ metrics.expected + β”‚ β”‚ β”œβ”€ files + β”‚ β”‚ β”‚ β”œβ”€ rules: 1 + β”‚ β”‚ β”‚ β”œβ”€ refs: 20 + β”‚ β”‚ β”‚ └─ targets: 25 + β”‚ β”‚ β”œβ”€ tokens + β”‚ β”‚ β”‚ β”œβ”€ estimate: 139,679 + β”‚ β”‚ β”‚ └─ context: 13.3% + β”‚ β”‚ └─ cost + β”‚ β”‚ └─ estimate: $0.039948260 + β”‚ β”‚ + β”‚ β”‚ πŸͺ΅ logs + β”‚ β”‚ β”œβ”€ scope: .log/bhrain/review/2026-10-01T05-56-32-934Z.pid2710078.16f62bb5-218c-4d41-95e9-562c926f11e4/input.scope.json + β”‚ β”‚ β”œβ”€ metrics: .log/bhrain/review/2026-10-01T05-56-32-934Z.pid2710078.16f62bb5-218c-4d41-95e9-562c926f11e4/metrics.expected.json + β”‚ β”‚ └─ tokens: .log/bhrain/review/2026-10-01T05-56-32-934Z.pid2710078.16f62bb5-218c-4d41-95e9-562c926f11e4/tokens.expected.md + β”‚ β”‚ β”œβ”€ rules: .agent (985, 100%) + β”‚ β”‚ └─ targets: src (25.3k, 72.62%), .agent (9.5k, 27.38%) + β”‚ β”‚ + β”‚ └─ + β”œβ”€ stderr + β”‚ β”œβ”€ + β”‚ β”‚ + β”‚ β”‚ πŸͺ¨ run solid skill repo=bhrain/role=reviewer/skill=review + β”‚ β”‚ └─ πŸ’₯ failed with an error + β”‚ β”‚ + β”‚ β”‚ /home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/openai@5.8.2_zod@4.3.4/node_modules/openai/client.js:263 + β”‚ β”‚ throw new Errors.APIConnectionTimeoutError(); + β”‚ β”‚ ^ + β”‚ β”‚ + β”‚ β”‚ APIConnectionTimeoutError: Request timed out. + β”‚ β”‚ at OpenAI.makeRequest (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/openai@5.8.2_zod@4.3.4/node_modules/openai/client.js:263:23) + β”‚ β”‚ at async /home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-brains-fireworksai@0.2.3_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_seedrandom@3.0.5__rhachet@/node_modules/rhachet-brains-fireworksai/dist/domain.operations/atom/genBrainAtom.js:163:28 + β”‚ β”‚ at async BrainAtom.ask (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-brains-fireworksai@0.2.3_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_seedrandom@3.0.5__rhachet@/node_modules/rhachet-brains-fireworksai/dist/domain.operations/atom/genBrainAtom.js:161:30) + β”‚ β”‚ at async Object.ask (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/dist/domain.operations/brain/asBrainAtomWithContextBound.js:20:24) + β”‚ β”‚ at async Object.operation (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-roles-bhrain@0.38.0_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_see_efcd1faefd50d82b0d6798adc1a6ecec/node_modules/rhachet-roles-bhrain/dist/domain.operations/review/stepReview.js:672:32) + β”‚ β”‚ at async withSpinner (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-roles-bhrain@0.38.0_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_see_efcd1faefd50d82b0d6798adc1a6ecec/node_modules/rhachet-roles-bhrain/dist/domain.operations/review/stepReview.js:151:28) + β”‚ β”‚ at async stepReview (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-roles-bhrain@0.38.0_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_see_efcd1faefd50d82b0d6798adc1a6ecec/node_modules/rhachet-roles-bhrain/dist/domain.operations/review/stepReview.js:670:26) + β”‚ β”‚ at async Module.review (/home/camper/git/ehmpathy/_worktrees/rhachet.beav.fix-shared-brain-credential-blanking/node_modules/.pnpm/rhachet-roles-bhrain@0.38.0_@huggingface+transformers@3.8.1_@tensorflow+tfjs@4.22.0_see_efcd1faefd50d82b0d6798adc1a6ecec/node_modules/rhachet-roles-bhrain/dist/contract/cli/review.js:349:9) { + β”‚ β”‚ status: undefined, + β”‚ β”‚ headers: undefined, + β”‚ β”‚ requestID: undefined, + β”‚ β”‚ error: undefined, + β”‚ β”‚ code: undefined, + β”‚ β”‚ param: undefined, + β”‚ β”‚ type: undefined + β”‚ β”‚ } + β”‚ β”‚ + β”‚ β”‚ Node.js v22.21.0 + β”‚ β”‚ + β”‚ └─ + └─ passage blocked + β”œβ”€ blocked by malfunction + └─ exit code: 1 πŸ’₯ \ No newline at end of file diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.stone b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.stone new file mode 100644 index 00000000..f029437c --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.stone @@ -0,0 +1,15 @@ +bootup your mechanic's role via `./node_modules/.bin/rhachet roles boot --repo ehmpathy --role mechanic` + +then, execute the vision directly +- .behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.md + +ref: +- .behavior/v2026_09_29.fix-shared-brain-credential-blanking/0.wish.md + + +--- + +track your progress + +emit todos and check them off into +- .behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.yield.md diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.yield.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.yield.md new file mode 100644 index 00000000..05fa44a6 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.1.execution.from_vision.yield.md @@ -0,0 +1,69 @@ +# 5.1.execution.from_vision + +execution of `1.vision.yield.md` β€” the cure is O3: every clone spawns with +`CLAUDE_SECURESTORAGE_CONFIG_DIR=''`, so claude-code keys its login file, its write lock and its +refresh lock by `~/.claude`. one login, one lock set, for every clone on the box. + +## .todos + +### case 1 β€” the fleet shares one login through the refresh window +- [x] `asBrainCliSpawnEnv` sets `CLAUDE_SECURESTORAGE_CONFIG_DIR: ''` beside the per-actor `CLAUDE_CONFIG_DIR` +- [x] rhachet overrides any caller value of the var (F8) +- [x] `findsertBrainCredentialSymlink` deleted β€” no per-actor link to the shared login +- [x] plain-spawn fallback carries the var too (`genBrainCliPlainClone.integration.test`) +- [x] the absent-login line (`asBrainAuthAbsentLine`) names `brainAuthPath` = `$HOME/.claude/.credentials.json` +- [x] one term for the concept: every operation on the login is named `BrainAuth`, after `brainAuthPath` + +### case 2 β€” a dead login refuses, and one /login recovers every clone +- [x] `asBrainAuthState` β€” `absent | dead | present`; dead = `claudeAiOauth.refreshToken === ''` +- [x] `isBrainAuthViaEnv` β€” `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` +- [x] `assertBrainAuthNotDead` β€” pure; exit 2 `ConstraintError`, hint names `/login` and the env escape +- [x] wired into `invokeEnroll` after the async detach and before any spawn; the login is read once, so the refusal and the absent line judge one state +- [x] acceptance: a second enroll against the blanked login exits 2, names `/login`, spawns no clone; the refusal is snapshotted + +### case 3 β€” migrate a live 1.48.0 grove +- [x] `setBrainDirAuthMigrated` β€” `none | kept | removed | adopted` + - kept while a clone of the actor lives (the live count is probed only when a leftover exists) + - removed once none live; a symlink is removed as a link, the shared login untouched + - a live brain-dir login is adopted into `~/.claude` (via `setFileAtomic`, mode 0600) **only when the shared login is dead or absent** β€” no peer refreshes a login in either state, so the write races no one + - a live shared login is never overwritten, however old its expiry: a peer clone may refresh it at any instant, and an unlocked write over it would roll back that refresh +- [x] `isBrainDirAuthAdoptable` + `isBrainAuthLive` β€” the adoption rule as two pure checks, unit-tested +- [x] `asBrainAuthOauth` β€” the one parse of a login file, with no `as` cast; `asBrainAuthState` reads through it +- [x] `getFileContentOrNull` / `getFileStatOrNull` (`src/infra/filesystem`) β€” ENOENT reads as null, all else is thrown +- [x] `asBrainDirAuthMigratedLine` β€” names the live pre-cure count and the respawn cure + +### case 4 β€” the clamp against a claude-code bump +- [x] `enroll.shared-brain-auth.acceptance.test.ts` β€” a real claude-code, a fake HOME, an expired fake login +- [x] proven to bite: var removed β†’ red (`Expected "blank", Received "planted"`); var restored β†’ green +- [x] docblock names O10 (adopt the winner, then relink) as the fallback if the clamp goes red + +### done test 5 β€” the credential story is written down +- [x] `define.brain-dir-repo-vs-actor.md` gains a `.credentials` section: the config is the actor's, the login is the box's + +## .verification + +| tier | scope | result | +|---|---|---| +| types | whole repo | βœ… passed | +| lint | whole repo | βœ… passed | +| unit | `src/domain.operations/actor/enrolled` | βœ… 77/77 | +| integration | `setBrainDirAuthMigrated` | βœ… 10/10 | +| integration | `src/infra/filesystem/getFile*` | βœ… passed | +| integration | `claude.cli.skills` (probe skill refusals + help, snapped) | βœ… 20/20 | +| acceptance | `enroll.shared-brain-auth` | βœ… 9/9 | +| acceptance | `brain-dir-boot.journey` | ⚠️ 63 passed, 16 failed β€” see below | + +⚠️ the 16 journey failures are every real-haiku ask, **bare `claude -p` included** β€” a launch +that never passes through rhachet's spawn, so no change in this route reaches it. the record +names the cause: the one clone screen the run logged reads `Credit balance too low Β· Add funds` +on the test `ANTHROPIC_API_KEY` +(`.log/role=mechanic/skill=git.repo.test/what=acceptance/2026-09-29T12-43-09Z.stderr.log`). the +journey's own launch measured `bootChars: 19039`, so the context window is not the cause. the +tests this route edited in that file β€” the [t3.2] absent-login line and its no-brain-dir-login +check β€” need no inference. the last green `test` run carried this journey (release/v1.48.1, +2026-09-26, run 36220484628). if ci's journey goes red on the same message, the cure is to fund +the test key: a human lever, never a code change. + +## .followups +- retire `.dream/v2026_09_29.reseed.grove-crews-should-spawn-with-the-setup-token.md` once released +- after release: upgrade every tree and respawn crews β€” a live 1.48.0 clone keeps its own lock set until respawned. then playtest done tests 1 and 2 on the live fleet (two refresh cycles pass; a revoke heals with one `/login`) diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.3.verification.guard b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.3.verification.guard new file mode 100644 index 00000000..0c14ceaf --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.3.verification.guard @@ -0,0 +1,297 @@ +# provenance = self-referential source template; enables `rhx route.guard.upgrade` idempotency +provenance: + uri: node_modules/rhachet-roles-bhuild/dist/domain.operations/behavior/init/templates/5.3.verification.guard + +artifacts: + # track verification progress + - "$route/5.3.verification.yield.md" + # track actual implementation in src/ + - "src/**/*" + +reviews: + self: + - slug: has-behavior-coverage + say: | + double-check: does the verification checklist show every behavior from wish/vision has a test? + + - is every behavior in 0.wish.md covered? + - is every behavior in 1.vision.md covered? + - can you point to each test file in the checklist? + + **if any behavior lacks a test, write the test NOW.** do not pass this gate with gaps. + + - slug: has-zero-test-skips + say: | + double-check: did you verify zero skips β€” and REMOVE any you found? + + - no .skip() or .only() found? + - no silent credential bypasses? + - no prior failures carried forward? + + **if you found skips, did you remove them and make those tests pass?** + + this is buttonup. skips are gaps. gaps get fixed, not noted. + + - slug: has-all-tests-passed + say: | + double-check: did all tests pass? prove it with a VERBATIM terminal block. + + **zero unproven claims. a claim is not proof β€” only pasted output is.** + + for EACH test suite you must paste, verbatim from your terminal: + - the exact command you ran + - the final summary line (counts) + - the exit code + + required proof shape β€” paste your real run, not this template: + ``` + $ npm run test:unit + > 47 passed, 0 failed, 0 skipped + > exit 0 + ``` + + the articulation is REJECTED if it: + - says "all tests pass" without a pasted block + - paraphrases the result instead of a verbatim paste + - pastes a partial run (one suite) and claims the rest + - shows any non-zero exit, any failure, or any skip + + if you did not paste the command AND its output AND the exit code, you did + not prove it β€” and an unproven pass is a feigned review. + + zero tolerance for extant failures: + - "it was already broken" is not an excuse β€” fix it + - "it's unrelated to my changes" is not an excuse β€” fix it + - flaky tests must be stabilized, not tolerated + - every failure is your responsibility now + + zero tolerance for flakes β€” when a test flakes, deflake it: + - a test that passes on re-run is not "fine" β€” it is a live defect + - do NOT retry until green and move on; that hides the flake for the next run + - run the structured deflake workflow: rhx cicd.deflake init + - diagnose the root cause (race, shared state, clock, unmasked volatile output), + repair it, and prove the fix holds across re-runs + - a masked volatile output (see the snapshot rules) is the usual fix for an + output-driven flake + + zero tolerance for fake tests: + - tests that always pass are fraud + - tests that mock the system under test prove no behavior + - tests must verify real behavior + + zero tolerance for credential excuses: + - "i don't have creds" means get them or mock them + - silent bypasses are forbidden + - if creds block tests, that is a BLOCKER β€” not a deferral + + - slug: has-preserved-test-intentions + say: | + double-check: did you preserve test intentions? + + for every test you touched: + - what did this test verify before? + - does it still verify the same behavior after? + - did you change what the test asserts, or fix why it failed? + + forbidden: + - weaken assertions to make tests pass + - remove test cases that "no longer apply" + - change expected values to match broken output + - delete tests that fail instead of fix code + + the test knew a truth. if it failed, either: + - the code is wrong β€” fix the code + - the test has a bug β€” fix the bug, keep the intention + - requirements changed β€” document why, get approval + + to "fix tests" via changed intent is not a fix β€” it is at worst + malicious deception, at best reckless negligence. unacceptable. + + - slug: has-snap-changes-rationalized + say: | + double-check: is every `.snap` file change intentional and justified? + + for each `.snap` file in git diff: + 1. what changed? (added, modified, deleted) + 2. was this change intended or accidental? + 3. if intended: what is the rationale? + 4. if accidental: revert it or explain why the new output is an improvement + + common regressions caught here: + - output format degraded (lost alignment, lost structure) + - error messages became less helpful + - timestamps or ids leaked into snapshots (flaky) + - extra output added unintentionally + + forbidden: + - "updated snapshots" without per-file rationale + - bulk snapshot updates without review + - regressions accepted without justification + + every snap change tells a story. make sure the story is intentional. + + - slug: has-critical-paths-frictionless + say: | + double-check: are the critical paths frictionless in practice? prove it with a + pasted runthrough, not a claim of "smooth". + + "frictionless" is not a vibe β€” it is a concrete rubric. read the ergonomist + brief `def.frictionless` (in your booted ergonomist briefs) and score against it. + + find the critical paths. if a repros artifact exists, take them from it: + - .behavior/v2026_09_29.fix-shared-brain-credential-blanking/3.2.distill.repros.experience.*.md + if no repros artifact exists, take the critical paths straight from the + wish + vision (the primary usecases a human runs) β€” absence of repros is + NOT an excuse to skip this review. + + for EACH critical path, paste verbatim from your terminal: + - the exact command(s) you ran to walk the path + - the real output they produced + + then score that pasted output against every criterion in def.frictionless, + one line each. + + the articulation is REJECTED if it: + - claims "smooth" or "frictionless" without a pasted runthrough + - paraphrases the outcome instead of a verbatim paste + - walks only one path and claims the rest + - skips the review because repros is absent + + a claim of frictionless is not proof β€” a pasted session scored against + def.frictionless is. if the paste shows friction, fix it NOW; do not note it. + + - slug: has-ergonomics-validated + say: | + double-check: is the actual input/output ergonomic? prove it with a pasted + capture of the real i/o, not a claim of "matches". + + "ergonomics" is not a vibe β€” it is a concrete rubric. read the ergonomist + brief `def.ergonomic` (in your booted ergonomist briefs) and score against it. + + for EACH critical path, capture the real i/o verbatim from the built + artifact's run and paste it. + + then choose ONE frame: + - if a repros artifact exists (.behavior/v2026_09_29.fix-shared-brain-credential-blanking/3.2.distill.repros.experience.*.md), + paste a two-column comparison β€” planned i/o (from repros) vs actual i/o + (captured) β€” and flag any drift between them. + - if no repros artifact exists, score the captured i/o directly against every + criterion in def.ergonomic, one line each. absence of repros is NOT an + excuse to skip this review. + + the articulation is REJECTED if it: + - claims "matches" or "ergonomic" without the pasted capture + - fills the actual/captured column with a paraphrase instead of real output + - skips the review because repros is absent + + if the ergonomics fall short, either: + - update repros to reflect the better design (when repros exists), or + - fix the implementation to meet the def.ergonomic rubric + + drift you cannot see in a paste is drift you did not check. + + - slug: has-fixed-all-gaps + say: | + final buttonup check: did you FIX every gap you found, or just detect it? + + **this is the buttonup phase. detection is not enough β€” you must fix.** + + look back at all the reviews above. for every gap you identified: + - absent test coverage β†’ did you WRITE the test? + - absent prod coverage β†’ did you IMPLEMENT the behavior? + - failed test β†’ did you FIX the code or test? + - skipped test β†’ did you REMOVE the skip and make it pass? + + **zero omissions.** if any review above surfaced a gap, that gap must be fixed before you pass this gate. + + ask yourself: + - did i just note the gap, or did i actually fix it? + - is there any item marked "todo" or "later"? (forbidden) + - is there any coverage marked incomplete? (forbidden) + + **if you detected it, you fixed it.** prove it β€” a bare "all fixed" is a feigned review. + + this review is the closer. the articulation must ENUMERATE, not summarize. + for EACH gap any review above surfaced, write one line: + + - the gap (which review found it, what it was) + - the fix (the exact file + what changed, or the commit/diff reference) + + if a review above found no gap, say so per review β€” do not skip it silently. + + the articulation is REJECTED if it: + - says "all gaps fixed" without the per-gap enumeration + - references a fix with no file/diff pointer + - leaves any surfaced gap unaddressed + + this is the final self-review. you are about to hand off to peer review. + prove every item above was addressed β€” with a pointer β€” not deferred. + + peer: + # --- level 1: cheap reviewers (run first, in parallel) --- + + - slug: repo-rules + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=.this/**/rule.*.md' --optional rules --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: ergo-contract-snapshots + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/rule.require.contract-snapshot-exhaustiveness.md' --refs '.agent/repo=ehmpathy/role=ergonomist/briefs/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.coverage/*.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/*.md' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-external-contracts + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/rule.require.external-contract-integration-tests.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.coverage/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.unit/rule.forbid.remote-boundaries.md' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: ergo-acceptance-journey-coverage + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/ergonomist/rule.require.acceptance-journey-coverage.md' --refs '.agent/repo=ehmpathy/role=ergonomist/briefs/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/frames.behavior/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.coverage/*.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/*.md' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: behavior-experience-coverage + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/rule.require.experience-{coverage,catalog-evolution}.md' --refs '$route/1.vision.experience.case=*.md' --refs '$route/1.vision.experience.dimensions.md' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.vision/define.experience.md' --diffs since-main --paths-with '**/*.test.ts' --paths-with '**/*.snap' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: ergo-snapshot-visual-blemishes + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.execution/ergonomist/rule.forbid.snapshot-visual-blemishes.md' --refs '.agent/repo=ehmpathy/role=ergonomist/briefs/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/lang.tones/*.md.min' --refs '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/rule.require.contract-snapshot-exhaustiveness.md' --diffs since-main --paths-with '**/*.{ts,sh,md,snap}' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-given-when-then + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/frames.behavior/rule.require.given-when-then.md' --refs '.agent/repo=ehmpathy/role=architect/briefs/criteria.given_when_then.[seed].v3.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/frames.behavior/*.md.min' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/lessons.howto/*.md.min' --diffs since-main --paths-with '**/*.test.ts' --paths-with '**/*.snap' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-test-intent + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=bhuild/role=behaver/briefs/practices/behavior.verification/rule.forbid.test-intent-violations.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.coverage/*.md' --refs '.agent/repo=ehmpathy/role=mechanic/briefs/practices/work.flow/refactor/rule.require.review-test-changes.md.min' --diffs since-main --paths-with '**/*.test.ts' --paths-with '**/*.snap' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + - slug: mech-test-scope-purity + run: $rhx review --repo bhrain --mode hard --rules '.agent/repo=ehmpathy/role=mechanic/briefs/practices/code.test/scope.*/rule.*.md' --diffs since-main --paths-with '{src,blackbox}/**/*.test.ts' --join intersect --conversation $conversation --output "$output" + budget: 3 + level: 1 + + # --- level 3: expensive reviewers (run after level 1 is terminal) --- + + - slug: enroll-verif-snapshot-coverage + run: $rhx enroll claude --model 'claude-sonnet-5[1m]' --roles reviewer,behaver,ergonomist,mechanic -p 'review the diff for snapshot coverage on contract endpoints and acceptance test user journey coverage. read the prior peer-review conversation at $conversation to catch up on context.' + budget: 3 + level: 3 + + - slug: enroll-verif-snapshot-blemishes + run: $rhx enroll claude --model 'claude-sonnet-5[1m]' --roles reviewer,behaver,ergonomist,mechanic -p 'review the diff for experiential and visual blemishes in snapshotted acceptance test journeys. read the prior peer-review conversation at $conversation to catch up on context.' + budget: 3 + level: 3 + + - slug: enroll-verif-test-intent + run: $rhx enroll claude --model 'claude-sonnet-5[1m]' --roles reviewer,behaver,architect,mechanic -p 'review the current implementation for test intent violation diffs. if any of the diffs related to tests loosened assertions or changed the criteria, this is a blocker. tests were added for a reason. we have to maintain the behavior they locked in. read the prior peer-review conversation at $conversation to catch up on context.' + budget: 3 + level: 3 + +judges: + # enforce peer reviews pass with zero blockers + - $rhachet run --repo bhrain --skill route.stone.judge --mechanism reviewed? --stone $stone --route $route --allow-blockers 0 --allow-nitpicks 7 diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.3.verification.stone b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.3.verification.stone new file mode 100644 index 00000000..e38f3249 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.3.verification.stone @@ -0,0 +1,304 @@ +prove the deliverable works via test verification β€” fix all gaps + +--- + +## .what + +this is the verification gate β€” the **buttonup phase**. + +you cannot pass execution without proof that all tests pass. more importantly: **test coverage enforces prod coverage**. if tests are absent, prod is incomplete. if prod is incomplete, fix it. + +## .the buttonup mandate: zero omissions + +**this is not a detection phase. this is a completion phase.** + +when you find a gap, you do not note it and move on. you **fix it**. + +| gap type | action | +|----------|--------| +| absent test coverage | write the test NOW | +| absent prod coverage | implement the behavior NOW | +| failed test | fix the code or fix the test NOW | +| skipped test | remove the skip and make it pass NOW | + +**if you detect it, you fix it. no exceptions.** + +## .strictness: zero tolerance, zero exceptions + +**this gate enforces absolute standards. there is no leniency.** + +| constraint | definition | +|------------|------------| +| zero deferrals | you cannot defer any test to later. all tests pass now or you fail. | +| zero fake tests | tests must verify real behavior. assertions that always pass are fraud. | +| zero unproven claims | every claim of "test passes" must cite the exact command run and output. | +| zero credential excuses | "i don't have creds" is not an excuse. get them, mock them, or fail. | +| zero skips | .skip() and .only() are forbidden. silent bypasses are forbidden. | +| zero exceptions | there are no special cases. the rules apply to all. | +| zero omissions | if you find a gap in coverage, you fix it β€” test or prod. | + +**if a blocker prevents tests from run, that is a BLOCKER. full stop.** + +you do not proceed. you do not defer. you fix it or you fail the gate. + +## .why + +**why does this gate exist?** + +your crew is about to review a pr you wrote. they need proof it works β€” not words, proof. tests are that proof. + +**test coverage drives prod coverage.** if a behavior lacks a test, that behavior is unproven. unproven behaviors are incomplete deliverables. the test proves the implementation exists and works. + +without this gate: +- tests might fail and nobody notices +- tests might be skipped and nobody notices +- behaviors might lack coverage and nobody notices +- broken code ships to peers +- incomplete implementations slip through + +with this gate: +- every test passes or you fix it +- every behavior has coverage or you add it +- every skip is removed or justified +- proven code ships to peers +- **gaps get fixed, not deferred** + +**the cardinal rules**: +1. never leave behavior without true, dependable test coverage +2. never offload work onto your crew unless there is truly, fundamentally no other option +3. never claim a test passes without cite of the exact command and output + +you fix it yourself. you exhaust every option: debug, research, try alternatives. only when you hit a wall that is physically impossible to climb alone β€” credentials only the foreman possesses, access only they can grant β€” only then may you ask for help. + +## .how + +reference the below for full context +- .behavior/v2026_09_29.fix-shared-brain-credential-blanking/0.wish.md +- .behavior/v2026_09_29.fix-shared-brain-credential-blanking/1.vision.md +- .behavior/v2026_09_29.fix-shared-brain-credential-blanking/2.1.criteria.blackbox.md (if declared) +- .behavior/v2026_09_29.fix-shared-brain-credential-blanking/3.2.distill.repros.experience.*.md (if declared) ← **repros artifact** + +--- + +### step 1: emit verification checklist + +emit to +- .behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.3.verification.yield.md + +this is your roadmap. emit it first, then work through it step by step. + +**checklist structure:** + +``` +## verification checklist + +### behavior coverage (with reference to repros) + +for each journey sketched in repros, verify it was implemented with snapshots. + +| journey (from repros) | test file | snapshots? | critical path? | ergonomics ok? | status | +|-----------------------|-----------|------------|----------------|----------------|--------| +| {journey 1} | {path} | βœ“ / βœ— | βœ“ frictionless / needs work | βœ“ natural / needs work | ⏳ | +| {journey 2} | {path} | βœ“ / βœ— | βœ“ frictionless / needs work | βœ“ natural / needs work | ⏳ | +... + +### zero skips verified +- [ ] no .skip() or .only() found +- [ ] no silent credential bypasses +- [ ] no prior failures carried forward + +### snapshot coverage for contract outputs + +each public contract needs dedicated snapshots that demonstrate its stdout for: +- **vibechecks in prs** β€” reviewers see actual output without executing code +- **drift detection** β€” changes to output surface in diffs over time + +| contract | output variants | snapshot file | status | +|----------|-----------------|---------------|--------| +| {command 1} | success, error, help | {path.snap} | ⏳ | +| {command 2} | success, error, help | {path.snap} | ⏳ | +... + +checklist: +- [ ] every new cli command has `.snap` snapshots for stdout/stderr +- [ ] every new app screen has `.snap` snapshots for screenshots +- [ ] every new sdk method has `.snap` snapshots for responses +- [ ] each output variant is exercised (success, error, edge cases) +- [ ] snapshots demonstrate actual output, not just "it ran" + +### snapshot change rationalization + +for each `.snap` file changed, rationalize whether the change was intended or accidental: + +| snap file | change type | intended? | rationale | +|-----------|-------------|-----------|-----------| +| {path.snap} | added / modified / deleted | yes / no | {why this change is correct} | +... + +checklist: +- [ ] every `.snap` change has been reviewed +- [ ] intended changes have clear rationale +- [ ] accidental changes have been reverted or justified as improvements + +### tests executed β€” with proof + +**every test run must be proven with exact command and output.** + +| test suite | command run | result | proof (exit code + summary) | +|------------|-------------|--------|----------------------------| +| types | `npm run test:types` | βœ“ / βœ— | exit 0, no errors | +| lint | `npm run test:lint` | βœ“ / βœ— | exit 0, no errors | +| format | `npm run test:format` | βœ“ / βœ— | exit 0, no errors | +| unit | `npm run test:unit` | βœ“ / βœ— | exit 0, N tests passed | +| integration | `npm run test:integration` | βœ“ / βœ— | exit 0, N tests passed | +| acceptance | `npm run test:acceptance` | βœ“ / βœ— | exit 0, N tests passed | + +checklist: +- [ ] every test command was run (not "i think it passed") +- [ ] every test command output was observed (not assumed) +- [ ] the exact exit code was verified (0 = pass, non-zero = fail) +- [ ] no tests were skipped, mocked, or faked + +**zero unproven claims.** if you claim a test passes, cite the command and output. + +### contract output snapshot exhaustiveness + +**every user-faced contract must have exhaustive snapshot coverage.** + +| contract type | contract | positive path snapped? | negative path snapped? | edge cases snapped? | +|---------------|----------|------------------------|------------------------|---------------------| +| cli | {command} | βœ“ / βœ— | βœ“ / βœ— | βœ“ / βœ— | +| api | {endpoint} | βœ“ / βœ— | βœ“ / βœ— | βœ“ / βœ— | +| sdk | {method} | βœ“ / βœ— | βœ“ / βœ— | βœ“ / βœ— | + +checklist: +- [ ] every cli command has stdout/stderr snapshots for success, error, and help +- [ ] every api endpoint has response snapshots for success and error codes +- [ ] every sdk method has return value snapshots for success and error +- [ ] every contract has edge case snapshots (empty input, invalid input, boundary) + +**zero gaps in caller experience.** the reviewer must see exactly what callers see. + +### blockers +- none (or list handoff references) +``` + +update the checklist as you complete each step below. + +--- + +### step 2: verify AND FIX behavior coverage + +walk through wish and vision: +- every behavior promised must have an acceptance test +- for each behavior, you can point to the test file +- no behavior left untested + +**why?** your crew trusts the test suite. if a behavior isn't tested, it isn't proven. untested behaviors are unverified promises. + +**test coverage enforces prod coverage.** if a test is absent, either: +1. the behavior was not implemented β†’ IMPLEMENT IT NOW +2. the behavior was implemented without a test β†’ WRITE THE TEST NOW + +if a behavior lacks a test, **write one NOW**. do not move on with gaps. update your checklist when done. + +--- + +### step 3: verify AND FIX zero skips + +scan for forbidden patterns: +- `.skip()` or `.only()` in test files +- `if (!credentials) return` or similar silent bypasses +- prior failures carried forward (known-broken tests) + +**why?** failures are better than skips. skips hide problems. failures expose them. a skipped test is a lie β€” it pretends coverage exists when it doesn't. + +**this is buttonup.** if you find skips: +1. REMOVE the skip +2. MAKE the test pass (fix the code or fix the test) +3. update your checklist + +do not note skips and move on. fix them. all tests must run. + +--- + +### step 4: run all tests AND FIX all failures + +run each test suite and **cite the exact command and output**. + +```bash +npm run test:types # cite exit code +npm run test:lint # cite exit code +npm run test:format # cite exit code +npm run test:unit # cite exit code + test count +npm run test:integration # cite exit code + test count +npm run test:acceptance # cite exit code + test count +``` + +all must pass β€” no exceptions. no deferrals. no "i'll fix it later." + +**this is buttonup.** if tests fail, fix them. that is the job. + +failures indicate one of: +1. prod code is broken β†’ FIX THE PROD CODE +2. test has a bug β†’ FIX THE TEST BUG (preserve intention) +3. coverage gap exists β†’ FILL THE GAP + +**consider all failures as defects from this pr.** there are no "prior failures." + +if a test was broken before you started β€” fix it. if a test is flaky β€” fix it. if a test fails for reasons unrelated to your changes β€” fix it anyway. you do not get to say "that was already broken." you are here now. you fix it. + +**take initiative. take ownership.** + +**preserve test intentions.** when you fix a test, you fix why it failed β€” not what it tests. to change what a test verifies is not a fix. it is at worst malicious deception, at best reckless negligence. the test knew a truth. if it fails, either the code is wrong or the test has a bug. fix the cause, not the assertion. + +**zero fake tests.** a test that always passes is fraud. a test that skips is a lie. a test that mocks the system under test proves nothingness. tests must verify real behavior against real code. + +**escalation path:** +1. debug the failure β€” read the error, understand the cause +2. research β€” search for similar issues, read docs +3. try alternatives β€” different approach, different tool +4. ask for help β€” other resources, other clones +5. deeper research β€” exhaust every option +6. only if insurmountable β€” emit handoff (see step 5) + +**ask yourself at each level:** +- did i read the error message carefully? +- did i search for similar issues? +- did i try a different approach? +- did i isolate the problem? +- did i ask for help? +- did i exhaust every option? + +you move to handoff only when you can answer "yes" to all of the above and still cannot proceed. + +update your checklist when all tests pass. + +--- + +### step 5: handoff (only if insurmountable) + +a handoff is a document that transfers work to your foreman because you hit a wall that is physically impossible to climb alone. + +**foreman-only blockers:** +- credentials only the foreman possesses +- external access only the foreman can grant +- approval that requires foreman authority + +handoff is the absolute last resort. you must exhaust every option before you consider it. + +if you need to emit a handoff: + +emit to +- .behavior/v2026_09_29.fix-shared-brain-credential-blanking/5.3.verification.handoff.v$N.to_foreman.md + +**handoff must include:** +1. what you tried (list every approach you attempted) +2. why each approach failed (be specific) +3. what makes this fundamentally impossible without foreman intervention +4. is this truly a "foreman possesses the key" situation? +5. rewind instruction: `rhx route.stone.set --stone 5.3.verification --as rewound` + +your crew should read your handoff and think: "yes, there was truly no other way." + +update your checklist to reference the handoff. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/blocker/5.1.execution.from_vision.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/blocker/5.1.execution.from_vision.md new file mode 100644 index 00000000..e11a2289 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/blocker/5.1.execution.from_vision.md @@ -0,0 +1,55 @@ +# blocker: 5.1.execution.from_vision + +## what blocks you + +two gates, and neither is a lever the driver holds. + +### 1. the two fireworks lanes cannot get an answer + +| lane | rounds that timed out | prompt at last try | +|---|---|---| +| repo-rules (r001, 1/3) | i004, i006, i007, i008, i009 | ~212k tokens | +| arch-hazards-behavior (r007, 4/5) | i004, i006, i007, i008, i009 | ~137k tokens | + +every round fails with `APIConnectionTimeoutError: Request timed out.` + +the guard edits you granted were made. both lanes now skip: + +- `.behavior/**` +- `**/__snapshots__/**` +- `**/*.md` +- `**/*.test.ts` +- `blackbox/**` + +so each lane reads only the prod source and the skills: 25 files, ~35k tokens of targets. + +the prompt shrank from ~264k to ~137k tokens, and every round still timed out. in i003, fireworks lanes on this stone finished at similar sizes. so the cause is the endpoint or the client timeout, not the lane size. + +### 2. the judge counts blockers on lanes that are spent + +`judge.1: blockers exceed threshold` (allowance is 0). the lanes that hold those blockers are all exhausted, so none can re-read to confirm a fix: + +- **enroll-impl-behavior-intent (r010, 3/3)**, blocker.1: the lock-timeout path had no test + - repaired: `withBrainAuthWriteLock.integration.test.ts` case7, snapped, 28/28 pass + - its nitpick is repaired too: the error now carries a `hint` +- **enroll-impl-arch-defects (r011, 3/3)**, blocker.1: an ENOENT in the lock heartbeat + - repaired and clamped (case5 t1) +- the l1 lanes that ran out of rounds in i003 (r3, r6, r9): each blocker was answered in its i003 `.taken` + +each blocker has a `.taken`, a concession graded `better`, and an absorb. none names a shipped harm, so the driver may not top up the budget itself. + +## what you tried + +- per-point `.taken`s and absorbs on every lane, every round +- two guard edits to narrow the fireworks lanes (i008, i009) +- types, lint, format, and the scoped unit, integration and acceptance suites all pass + +## what you need + +one of: + +1. **overrule** β€” you accept the claude-lane rounds and the green suites as coverage: + `rhx route.stone.set --stone 5.1.execution.from_vision --as overruled` +2. **a one-round top-up** on the two l3 claude lanes, so they can confirm the repairs, then a fireworks retry once its endpoint answers: + `rhx route.guard.budget --for review --add 1 --peer enroll-impl-behavior-intent --stone 5.1.execution.from_vision` + `rhx route.guard.budget --for review --add 1 --peer enroll-impl-arch-defects --stone 5.1.execution.from_vision` diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.feat.clamp-n-real-refreshers-on-one-shared-login.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.feat.clamp-n-real-refreshers-on-one-shared-login.md new file mode 120000 index 00000000..c7f813b7 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.feat.clamp-n-real-refreshers-on-one-shared-login.md @@ -0,0 +1 @@ +../../../.dream/v2026_09_29.feat.clamp-n-real-refreshers-on-one-shared-login.md \ No newline at end of file diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.feat.per-subscription-reach-and-live-rotation.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.feat.per-subscription-reach-and-live-rotation.md new file mode 120000 index 00000000..89e1ad5f --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.feat.per-subscription-reach-and-live-rotation.md @@ -0,0 +1 @@ +../../../.dream/v2026_09_29.feat.per-subscription-reach-and-live-rotation.md \ No newline at end of file diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.reseed.fleet-poll-reports-a-login-expired-crew-as-at-work.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.reseed.fleet-poll-reports-a-login-expired-crew-as-at-work.md new file mode 120000 index 00000000..61b6d4a0 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.reseed.fleet-poll-reports-a-login-expired-crew-as-at-work.md @@ -0,0 +1 @@ +../../../.dream/v2026_09_29.reseed.fleet-poll-reports-a-login-expired-crew-as-at-work.md \ No newline at end of file diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.reseed.grove-crews-should-spawn-with-the-setup-token.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.reseed.grove-crews-should-spawn-with-the-setup-token.md new file mode 120000 index 00000000..88030dcd --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_29.reseed.grove-crews-should-spawn-with-the-setup-token.md @@ -0,0 +1 @@ +../../../.dream/v2026_09_29.reseed.grove-crews-should-spawn-with-the-setup-token.md \ No newline at end of file diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_30.playtest.live-fleet-survives-refresh-cycles.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_30.playtest.live-fleet-survives-refresh-cycles.md new file mode 120000 index 00000000..cb2564c2 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/dreams/v2026_09_30.playtest.live-fleet-survives-refresh-cycles.md @@ -0,0 +1 @@ +../../../.dream/v2026_09_30.playtest.live-fleet-survives-refresh-cycles.md \ No newline at end of file diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/define.invariant.a-blank-proves-the-winner-missed-the-global-file.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/define.invariant.a-blank-proves-the-winner-missed-the-global-file.md new file mode 100644 index 00000000..826bceb0 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/define.invariant.a-blank-proves-the-winner-missed-the-global-file.md @@ -0,0 +1,109 @@ +# define.invariant: a blank proves the refresh winner missed the global file + +## .what + +when `~/.claude/.credentials.json` is blanked, the refresh winner's fresh token is **not** in it, +and never was. it sits in the winner's own actor dir, where the write replaced the actor's symlink +with a real file. + +## .kind β€” nature + +it follows from the code of claude-code `2.1.280` (build `80abbfe7`) and from posix `rename(2)`. +no decision of ours could make it otherwise. a claude-code release that changes the write or the +clear can change it β€” re-read the code below on each claude-code bump. + +## .invariant + +``` +global file blanked ⟹ the winner's write did not land in the global file + ⟹ every non-blank version the global file ever held predates the winner +``` + +## .the proof, in four steps + +**1. the store path is the config dir's own path β€” no realpath.** + +```js +function i(){let e=_S(),r=".credentials.json";return{storageDir:e,storagePath:V(e,".credentials.json")}} +... async write(e){ ... let{storageDir:r,storagePath:n}=i(); ... await Cn(n,S(e),384) ... } +``` + +`_S()` is `CLAUDE_CONFIG_DIR`. under #553 that is `/brain/.claude`, so the path handed to +the write is `/brain/.claude/.credentials.json` β€” the symlink itself. + +**2. the write puts a temp file beside that path, then renames it over the path.** + +```js +async function Cn(e,t,n,r){return cQ(e,t,{mode:n,renameFn:r})} // no followSymlinks +function ZC(e){return`${e}.tmp.${Oe(4).toString("hex")}`} // temp = .tmp. +async function cQ(e,t,n){ ... g=await Le(e,y) ... // no stagingDir β†’ g = e + ... else m=await TM(g,t,P),W=!0 ... // write temp, flag "wx" + ... let l=w??ne; await zo(p,e,(h,u)=>(x(),l(h,u))) ... } // ne = fs.promises.rename +``` + +the in-place fallback (`H`) runs only when the rename throws `EXDEV|EPERM|EEXIST|EBUSY` (`kS`), and +it opens with `O_NOFOLLOW` (`A=f===!0?0:o.O_NOFOLLOW`), so it cannot write through a symlink either. + +**3. `rename(temp, path)` replaces the directory entry at `path`.** posix: when `path` is a symlink, +the symlink is replaced; its target is not touched. so the winner's actor dir now holds a real file +with the fresh token R2, and the global file still holds R1. + +**4. the loser's clear is compare-and-clear, against the file on disk.** + +```js +async function RDn(e,n){ ... mutate((g)=>{let h=g.claudeAiOauth;if(!h||h.refreshToken!==e)return g; + return r=!0,{...g,claudeAiOauth:{...h,refreshToken:"",accessToken:"",expiresAt:0}}}) ... } +``` + +a loser refreshes with R1, is rejected, and runs `RDn(R1)`. `mutate` reads the file fresh; it holds +R1, so it blanks β€” **the file under the loser's own `_S()`**. for a 1.48.0 clone that write replaces +its own link (step 3), so the global file is blanked only by a loser whose `_S()` is `~/.claude`. +measured: `rhx claude.cli.secstore.trial` β€” a linked actor's clear left a blank real file in the +actor dir and the global file planted (`inventory.of=options.case=O3-…md`, `.the run`). + +## .the contrapositive β€” why the blank itself is the proof + +suppose the winner's write **had** landed in the global file. the global file would hold R2. the +loser's `RDn(R1)` would compare R2 against R1, find them unequal, and return the file unchanged. +**no blank.** so every observed blank is evidence that the winner's write missed the global file. + +β‡’ "the winner wrote successfully, the peers failed and blanked" is true. what the winner wrote +successfully is its **own** actor file, not the shared one. + +## .what follows + +| claim | holds? | why | +|---|---|---| +| revert the global file to its last non-blank version | β›” restores R1, the dead token | the global file never held R2 (step 3) | +| revert to the newest non-blank version across **all** credential files | 🟒 restores R2, if R2 survives | R2 is in the winner's actor file (steps 2–3) | +| on the symlinkβ†’file swap, adopt that file into the global file and relink | 🟒 same, and before any loser blanks | `case=O10` | + +## .scope β€” what this does not cover + +- whether R2 **survives** β€” that is a fact about the server, not the code. first-party: it does, + and a blanked clone recovers once R2 is back in its file (`../.seeds/…case=S7`) +- a winner with no actor (an unenrolled `claude` on `~/.claude`) writes the global file directly; + that race blanks no one, per the contrapositive +- a second store module in the same build opens the credential with `O_NOFOLLOW` on **read** and + returns `refused-symlink` on `ELOOP`. which builds route through it was not traced; if a future + build does, a symlinked actor credential reads as refused, not as the global file + +## .field corroboration + +the wish's topology on `grove-ahbode-v20260901`: 18 actor credentials are symlinks, **2 are real +files** β€” the actor dirs where a refresh won. + +## .the litigation + +argued: the winner writes the fresh token through its symlink into the shared file, so the shared +file's history holds the good token and a revert to the last non-blank version restores it. + +settled by the four steps above: the write path is the symlink's own path, the rename replaces the +symlink, and the compare-and-clear could not blank a file that held the winner's token. so the +shared file's history never held it; the winner's actor file does. + +## .what would overturn it + +a claude-code build that realpaths the store path, passes `followSymlinks`, or blanks without the +compare. re-read `i()`, `Cn`, `cQ`, and `RDn` via +`rhx claude.cli.strings --in .temp/claude-cli.probe --pattern ''` on each bump. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options._.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options._.md new file mode 100644 index 00000000..4de4f8ad --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options._.md @@ -0,0 +1,69 @@ +# inventory of options β€” stop the shared-credential blank + +axis: `case` β€” one candidate cure per entry, in the order it surfaced. + +every source citation is from claude-code `2.1.280` (build `80abbfe7`), read via +`rhx claude.cli.strings --in .temp/claude-cli.probe --pattern ''`. identifiers are minified +names in that build; they drift across releases. + +## .the root, in one breath + +#553 gave each actor `CLAUDE_CONFIG_DIR=/brain/.claude` and symlinked its +`.credentials.json` to `~/.claude/.credentials.json`. both refresh locks are keyed on the config +dir, so N actors hold N locks over one file. and the credential write refuses to follow a symlink, +so a refresh winner's fresh token lands in its own actor dir while the shared file keeps the dead +one β€” which the next refresh then blanks for every clone. proven from the code in +`define.invariant.a-blank-proves-the-winner-missed-the-global-file.md`. + +## .the cases + +| case | option | verdict | live rotation (S3) | normal auth path | +|---|---|---|---|---| +| O1 | [symlink the lock dir to global](./inventory.of=options.case=O1-symlink-the-lock-to-global.md) | β›” dead β€” proper-lockfile cannot hold a symlinked lock | n/a | βœ… | +| O2 | [one shared CLAUDE_CONFIG_DIR, boot delivered another way](./inventory.of=options.case=O2-one-shared-config-dir.md) | β›” ruled out β€” one config dir breaks actors | ❌ | βœ… | +| O3 | [relocate the credential store via CLAUDE_SECURESTORAGE_CONFIG_DIR](./inventory.of=options.case=O3-relocate-the-secure-storage-dir.md) | 🟒 verified from the code and by a run β€” the root cure for S1: `""` moves the file and every lock to `~/.claude` | ❌ (βœ… via S2 per-subscription dir + mtime re-read) | βœ… | +| O4 | [host-managed credentials (HOST_CREDS_FILE, SDK host refresh)](./inventory.of=options.case=O4-host-managed-credentials.md) | β›” gated to desktop / vscode / sdk hosts | β€” | βœ… | +| O5 | [setup token per reach, injected as CLAUDE_CODE_OAUTH_TOKEN](./inventory.of=options.case=O5-setup-token-via-env.md) | 🟑 kills the race; no live rotation | ❌ spawn only | βœ… | +| O6 | [apiKeyHelper with a keyrack command](./inventory.of=options.case=O6-api-key-helper.md) | β›” api-key channel, bills the console, not a subscription | βœ… 5min / on 401 | ❌ | +| O7 | [per-actor credential file that rhachet writes](./inventory.of=options.case=O7-per-actor-credential-file.md) | 🟒 lead candidate for S2+S3 | βœ… next read | βœ… | +| O8 | [local rotation proxy via ANTHROPIC_BASE_URL](./inventory.of=options.case=O8-rotation-proxy.md) | 🟑 works; a daemon in every request path | βœ… per request | ❌ | +| O9 | [backup-and-revert of the global file](./inventory.of=options.case=O9-backup-and-revert.md) | β›” on one file (its last good copy is the dead token, by `RDn`); 🟒 across every credential file | β€” | βœ… | +| O10 | [adopt the winner: repair the broken symlink](./inventory.of=options.case=O10-adopt-the-winner.md) | 🟒 the documented fallback β€” the family survives and clones recover on refill (S7); not built while O3 holds | ❌ | βœ… | +| O11 | [a central refresher ahead of the window](./inventory.of=options.case=O11-central-pre-window-refresher.md) | β›” shrinks the race window, leaves the race β€” still N locks | ❌ | βœ… | +| O12 | [a hardlink in place of the symlink](./inventory.of=options.case=O12-hardlink-in-place-of-symlink.md) | β›” rename replaces a hardlink too | ❌ | βœ… | + +## .recommendation β€” for S1 + +| rank | option | why | +|---|---|---| +| 1 β€” preferred | `case=O3`: spawn with `CLAUDE_SECURESTORAGE_CONFIG_DIR=""`, drop the credential symlink | lowest effort β€” one env var at spawn plus a migration. removes the cause: one file and one lock set, the pre-1.48.0 topology. no daemon | +| 2 β€” fallback, documented, not built | `case=O10`: an inotify watch that adopts the winner's real file into the global file, removes every real actor credential, and relinks all | built only if O3 fails. viable: the winner's token survives and a blanked clone recovers on refill with no respawn (S7). it treats the symptom: a watch per box | + +β‡’ re-verify O3's citations (`_S()`, the locks) on each claude-code bump; if a release stops to +honor the variable, fall back to O10. + +## .gaps β€” each owes a measurement before the vision settles + +- **two live processes on the one lock** β€” the run (`case=O3`, `.the run`) proves where the read and + the write land; that a loser then waits on the shared lock and adopts rests on the code + (`ref.claude-code.credential-store.refresh.md`), not yet on a run +- **which `~/.claude` process wrote the 2026-09-28 blank** β€” the run settled that a 1.48.0 clone + could not (its clear replaced its own link, the shared file stayed planted). the writer ran with + `_S()` = `~/.claude`: a tree on older rhachet, a grove tool, or the human's `claude` + (`ref.claude-code.credential-store.incident-read.md`). O3 cures all three +- **O7's shape** β€” does claude-code accept a stored credential with no `refreshToken`, the + inference scope only, and a far `expiresAt`? and what does a live clone do when that token is + revoked mid-session? +- **usage-limit signal** (S3, S4) β€” how rhachet learns a clone hit its limit: the transcript, the + `anthropic-ratelimit-unified-*` headers, or a probe + +## .settled + +- **family revocation** β€” none: the winner's token survives a reuse of the old refresh token, and a + blanked clone recovers on refill with no respawn (first-party, `../.seeds/…case=S7`). O10 stands + as a viable fallback + +## .see also + +- `../.seeds/inventory.of=seeds._.md` β€” S1..S4, the requirements these options answer +- `../0.wish.md` β€” the measured topology (18 symlinks + 2 real files) diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O1-symlink-the-lock-to-global.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O1-symlink-the-lock-to-global.md new file mode 100644 index 00000000..477b852f --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O1-symlink-the-lock-to-global.md @@ -0,0 +1,32 @@ +# option O1: symlink the lock dir to global + +## .what + +point each actor's `.oauth_refresh.lock` (and the legacy `.lock`) at one shared path, so +N actors serialize on one lock. + +## .verdict β€” β›” dead + +proper-lockfile takes a lock via `mkdir(lockfilePath)` and releases it via `rmdir(lockfilePath)`. +a symlink at the lock path makes `mkdir` fail with `EEXIST` forever (the lock reads as held), and +`rmdir` on a symlink fails with `ENOTDIR`. + +## .citations + +the lock options, keyed on the config dir `e`: + +```js +function q_r(e,n){return{lockfilePath:ad(e,".oauth_refresh.lock"),realpath:!1,stale:60000,update:5000,onCompromised:...}} +``` + +both locks taken in `wuo(e)`: + +```js +g=await ei(e,q_r(e,s)), y=`${await DM(e).catch(()=>e)}.lock`, E=await ei(y,{...q_r(e,s),lockfilePath:y}) +``` + +the refresh path takes them at `wuo(_S())`, where `_S()` is the config dir: + +```js +let D=_S();await le().mkdir(D);F=await wuo(D) +``` diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O10-adopt-the-winner.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O10-adopt-the-winner.md new file mode 100644 index 00000000..2856375d --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O10-adopt-the-winner.md @@ -0,0 +1,110 @@ +# option O10: adopt the winner β€” repair the broken symlink + +## .what + +a watcher β€” its own daemon, never folded into the keyrack daemon (keyrack is to become a separate +dependency of rhachet; see `../.seeds/…case=S5`) β€” sees an actor's `.credentials.json` turn from a +symlink into a real file. that real file holds the refresh winner's fresh token. the watcher: + +1. copies its content into `~/.claude/.credentials.json` (temp + rename, mode 0600) +2. removes every real `.credentials.json` in an actor dir β€” the winner's, now adopted, and each + loser's blank β€” and relinks each one to `~/.claude/.credentials.json` + +every clone then reads the fresh token on its next mtime check (`case=O7`, `fD`). + +## .adopt first, then relink β€” never relink alone + +the run (`case=O3`, `.the run`) proves a linked actor's write β€” a won refresh or a dead-token +clear β€” lands in its own actor dir and leaves `~/.claude` untouched. so every real file in an actor +dir is one of two: + +| the actor file holds | what it is | the repair | +|---|---|---| +| a blank refresh token | a loser's clear, contained to that actor | discard it, relink | +| a real refresh token | the winner's fresh token β€” the only live one on the box | adopt it into `~/.claude`, then relink | + +⚠️ a relink alone points the losers back at the shared file, which still holds the token the winner +just spent. they refresh with it, meet `invalid_grant`, and blank themselves again. the adopt must +come first. + +## .verdict β€” 🟒 the documented fallback for S1; not built while O3 holds + +- βœ… normal auth path, no new scheme β€” S1's constraint holds +- βœ… aims O9's instinct at the file that holds the live token +- βœ… **the winner's token survives** β€” the server does not revoke the family when the losers reuse + the old refresh token (first-party, `../.seeds/…case=S7`) +- βœ… **a blanked clone recovers with no respawn** β€” a clone that shows `Login expired` picks up a + refilled credential file (first-party, S7). so a blank the watcher repairs costs a moment, never a + fleet restart +- ❌ no live rotation across subscriptions β€” S2 and S3 still need O5 or O7 +- ⚠️ a brief blank window remains: a loser can blank itself before the adopt lands. per S7 it + recovers on the relink, so the window costs a failed turn at most, never an outage +- ⚠️ it treats the symptom β€” a watch per box that O3 does not need. hence rank 2 (S6) + +## .when to build it + +only if O3 fails: a claude-code release stops to honor `CLAUDE_SECURESTORAGE_CONFIG_DIR`, or the +O3 clamp goes red on a bump. until then this entry is the plan, not the code (S6). + +## .the trigger β€” what kicks off the adopt + +linux has no bare "run this on file change" hook; some process must hold the inotify watch. the +choices, from lightest on rhachet: + +| trigger | who holds the watch | latency | fit | +|---|---|---|---| +| systemd `.path` unit (`--user`) β†’ oneshot `.service` | systemd | ~ms | 🟒 no daemon of ours. a template unit per actor dir, installed by enroll. needs user linger on the grove box (rhachet targets linux only) | +| incron | `incrond` | ~ms | 🟑 a daemon anyway, and rarely installed | +| a claude-code hook in each clone (e.g. PreToolUse) that `lstat`s its own credential | the clone | next tool call | β›” too slow β€” the loser refreshes within seconds, and an idle clone never fires | +| our own inotify daemon | rhachet | ~ms | 🟑 the baseline, per S5 | + +no poll is needed in any row: inotify is event-driven, the kernel wakes the watcher. + +### .one watch on the global file β€” the simplest variant + +one systemd `.path` unit with `PathChanged=%h/.claude/.credentials.json`, whatever the actor count. +the global file changes at exactly one moment in this failure β€” the loser's blank β€” so the unit +fires then, and its oneshot: + +1. reads the global file; if its refresh token is non-empty, exits (not a blank) +2. finds the actor dir whose `.credentials.json` is a real file with a non-empty refresh token β€” + the winner +3. adopts it into the global file (temp + rename, 0600) and relinks that actor's symlink + +| | one watch on the global file | a watch per actor dir | +|---|---|---| +| units | 1 | 1 per actor, installed at enroll | +| fires on | the blank | the winner's write | +| blank window | a brief one β€” every clone sees the blank until the adopt lands; each recovers on the refill (S7) | none, if the adopt beats the loser's refresh | +| unverified | naught β€” recovery on refill is first-party (S7) | the race against the loser's retry loop; moot, since a loser that blanks recovers too | + +⚠️ a raw inotify watch must sit on the **dir**, not the file (systemd's `PathChanged=` does this for +you): the winner's write replaces the directory entry, so the +event is an `IN_MOVED_TO` / `IN_CREATE` on `brain/.claude/`, and a watch on the old symlink dies +with it. a systemd `PathChanged=` on the dir covers this; add a `TriggerLimitIntervalSec=` so a burst +of writes cannot rate-limit the unit into failure. + +### .why the order of adopt vs blank does not matter + +the watch fires on the winner's rename β€” `IN_MOVED_TO .credentials.json` in its `brain/.claude/`. + +- **adopt lands first** β†’ the global file holds R2, so a loser's `RDn(R1)` compares R2 β‰  R1 and + blanks naught +- **blank lands first** β†’ the adopt overwrites the blank with R2; every clone re-reads it on its + next mtime check (`fD`), and a clone that already showed `Login expired` recovers with no + respawn (S7) + +the watcher must skip its own relink: the relink is itself a rename into the same dir, so act only +when `lstat` shows a regular file with a non-empty refresh token. + +## .citations + +see `case=O9` for the write that breaks the symlink and the compare-and-clear that blanks the +global file. + +## .evidence + +family survival and recovery without respawn are first-party, from the wisher's own fleet (S7). +if O10 is ever built, its acceptance test reproduces both: a real file with a live token in one +actor dir, a blank in the global file, the adopt, then `auth status` in every actor reads +`loggedIn: true` β€” presence and the auth outcome only, never the token value. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O11-central-pre-window-refresher.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O11-central-pre-window-refresher.md new file mode 100644 index 00000000..2d82461d --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O11-central-pre-window-refresher.md @@ -0,0 +1,15 @@ +# option O11: a central refresher that keeps the shared file fresh ahead of the window + +## .what + +one process on the box refreshes `~/.claude/.credentials.json` before any clone's 5-minute window +opens, so no clone ever reaches its own refresh. + +## .verdict β€” β›” a residual of the same defect + +- ❌ still N locks β€” each actor keeps its own `_S()` locks over the symlinked file +- ❌ a clone that wakes inside the window (a slow refresher, a clock skew, a failed refresh) still + races the others with no lock between them +- ❌ still the symlink β€” the first clone that does refresh detaches (`ref.claude-code.credential-store.write.md` claim 2) + +β‡’ it shrinks the window the race needs; it does not remove the race. `case=O3` removes it. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O12-hardlink-in-place-of-symlink.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O12-hardlink-in-place-of-symlink.md new file mode 100644 index 00000000..a9b245a0 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O12-hardlink-in-place-of-symlink.md @@ -0,0 +1,13 @@ +# option O12: a hardlink in place of the credential symlink + +## .what + +link each actor's `.credentials.json` to `~/.claude/.credentials.json` by hardlink, not symlink, so +the store's `O_NOFOLLOW` and refused-symlink paths see a regular file. + +## .verdict β€” β›” detaches on the first write + +- the write stages a temp beside the path and renames it over the path + (`ref.claude-code.credential-store.write.md` claim 2). rename replaces the directory entry, so a + hardlink detaches exactly as a symlink does +- ❌ still N locks β€” the locks key on `_S()`, which stays per actor diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O2-one-shared-config-dir.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O2-one-shared-config-dir.md new file mode 100644 index 00000000..c0aa21db --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O2-one-shared-config-dir.md @@ -0,0 +1,21 @@ +# option O2: one shared CLAUDE_CONFIG_DIR, boot delivered another way + +## .what + +every clone runs with the same `CLAUDE_CONFIG_DIR` (as before #553), so one lock serializes every +refresh. the per-actor boot corpus reaches the clone some other way. + +## .verdict β€” β›” ruled out by the wisher + +one config dir for every clone breaks actors. the brain dir is the actor's +(`define.brain-dir-repo-vs-actor`): its settings, hooks, and user-scope `CLAUDE.md` boot differ per +actor, and a shared dir collapses them into one. + +## .citations + +the re-read before the lock, so a loser adopts a winner's fresh token β€” the mechanism a single +shared dir would have relied on: + +```js +let M=await Ha(y); if(M.accessToken!==P) return "refreshed" // race_resolved +``` diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O3-relocate-the-secure-storage-dir.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O3-relocate-the-secure-storage-dir.md new file mode 100644 index 00000000..88c7dde2 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O3-relocate-the-secure-storage-dir.md @@ -0,0 +1,99 @@ +# option O3: relocate the credential store via CLAUDE_SECURESTORAGE_CONFIG_DIR + +## .what + +keep a per-actor `CLAUDE_CONFIG_DIR`, and spawn every clone with `CLAUDE_SECURESTORAGE_CONFIG_DIR=""`. +stop the credential symlink. the credential file **and every lock that guards it** move back to +`~/.claude`, while settings and boot stay per actor. + +## .verdict β€” 🟒 verified from the code and by a run; the root cure for S1 + +the run (`.the run`, below) proves the read and the write land in `~/.claude` while the config stays +per actor. what no run has shown yet: two live processes that contend for the one lock. + +- βœ… one file, one write lock, one refresh lock for every process on the box β€” the pre-1.48.0 + topology. a loser waits on the lock, re-reads, and finds the winner's token +- βœ… no symlink, so no rename can replace one (`define.invariant.a-blank-proves-the-winner-missed-the-global-file.md`) +- βœ… shared with a bare `claude` and with trees on older rhachet, since they already use `~/.claude` +- βœ… same oauth login, same refresh β€” S1's "no new auth scheme" holds +- βœ… precedent: claude-code spawns its own agent-team teammates with `CLAUDE_SECURESTORAGE_CONFIG_DIR=` +- βœ… makes the adopt watcher (`case=O10`) unnecessary as a cure + +## .citations β€” claude-code 2.1.280, build 80abbfe7 + +`_S()` reads the variable first. set but empty β†’ `~/.claude`; unset β†’ the config dir: + +```js +function _S(){let n=process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR; + if(n!==void 0)return(n||l(o(),".claude")).normalize("NFC");return we()} +``` + +every credential path and lock keys on `_S()`: + +| what | code | +|---|---| +| the file | `function i(){let e=_S(),r=".credentials.json";return{storageDir:e,storagePath:V(e,".credentials.json")}}` | +| the store write lock | `let a=_S();await le().mkdir(a);let o=await ei(F(a,".storage-write"),{realpath:!1,...})` | +| the oauth refresh lock (both refresh paths) | `let D=_S();await le().mkdir(D);... F=await wuo(D)` Β· `let r=_S();... s=await wuo(r)` | +| the mtime re-read | `await od(ad(_S(),".credentials.json"))` | +| the second store module | `function f(){let e=_S();return{storeDir:e,storePath:D(e,".credentials.json")}}` | + +the teammate spawn precedent: the literal `CLAUDE_SECURESTORAGE_CONFIG_DIR=` sits beside +`CLAUDECODE=1` and `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` in the teammate launch env. + +β‡’ why today's topology races: under #553 the variable is unset, so `_S()` = `we()` = the per-actor +config dir, and each actor gets its own locks over one symlinked file. + +the full mechanism, claim by claim, is the `ref.claude-code.credential-store._.md` cluster. + +## .why `""` and not a path + +`""` is the one value that means the default store on linux and macos alike. on macos the keychain +entry name gains a `-sha256(dir)[0:8]` suffix whenever the var holds a path β€” even `~/.claude` +spelled out β€” so only `""` reaches the human's login (`ref.claude-code.credential-store.dir.md` +claim 4). it is also the idiom other tools use (claim 6). + +## .the risks + +| risk | answer | +|---|---| +| the var is undocumented ([anthropics/claude-code#79223](https://github.com/anthropics/claude-code/issues/79223)) | claude-code's own teammate spawn depends on it; it has held 2.1.206 β†’ 2.1.280. a clamp on its effect catches a regression | +| a real `invalid_grant` (the server revoked the login) still blanks the one file | correct β€” that login is dead for every clone. enroll names the cure (vision case 3) | + +## .the run + +`rhx claude.cli.secstore.trial --in .temp/claude-cli.probe` β€” claude-code 2.1.280, a fake `HOME` under +`.temp/`, an env built from empty, fake tokens only. run 2026-09-29: + +| scenario | `CLAUDE_SECURESTORAGE_CONFIG_DIR` | result | +|---|---|---| +| read, actor dir empty, login in `~/.claude` | unset | `loggedIn: false` β€” reads the actor dir | +| same | `""` | `loggedIn: true` β€” reads `~/.claude` | +| read, actor links to `~/.claude` (1.48.0) | unset | `loggedIn: true` β€” the read follows the link | +| expired login, actor links to it (1.48.0) β†’ refresh fails β†’ the dead-token clear | unset | the blank lands in the **actor** dir as a new real file; the link is gone; `~/.claude` stays **planted** | +| same expired login, no link | `""` | the blank lands in **`~/.claude`**; no credential in the actor dir; `.claude.json`, `projects`, `sessions` stay in the actor dir | + +β‡’ with `""` the credential read and write go to `~/.claude` and all else stays per actor β€” O3's +whole premise, observed. and row 4 settles the dispute in the summary's gaps: a 1.48.0 clone cannot +blank the shared file; its write replaces its own link. + +the same trial, run through rhachet's spawn instead of a bare env, is the red/green clamp: before the +fix the clear lands in the actor dir, after it in `~/.claude`. + +## .migration owed + +- the actor dirs that already hold a **real** `.credentials.json` may hold the only live token + (the last refresh winner). before those files are removed, adopt the one with the latest + `expiresAt` into `~/.claude/.credentials.json` if it is later than the global file's. compare + `expiresAt` only; never print a token +- live clones spawned before the fix keep the old env until respawn. the race stays open for them + until then + +## .scope + +- `CLAUDE_SECURESTORAGE_CONFIG_DIR` is dropped from a project's `settings.json` env, so it must be + set in the spawn env β€” which is where rhachet sets `CLAUDE_CONFIG_DIR` today +- children of a clone (nested `claude`, subagents) inherit the env, so they share the file too +- S2 composes with it: a per-subscription secure-storage dir (`CLAUDE_SECURESTORAGE_CONFIG_DIR=`) gives every clone of that subscription one file and one lock, and a write + to that file reaches live clones via the mtime re-read diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O4-host-managed-credentials.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O4-host-managed-credentials.md new file mode 100644 index 00000000..449bee17 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O4-host-managed-credentials.md @@ -0,0 +1,30 @@ +# option O4: host-managed credentials + +## .what + +let a host process own the credential, and have each clone read it from the host: +`CLAUDE_CODE_HOST_CREDS_FILE`, `CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH`, +`CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH`, `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST`. + +## .verdict β€” β›” gated + +- the sdk oauth refresh is gated to the claude-desktop, local-agent, and claude-vscode entrypoints + (`SDK_OAUTH_REFRESH_ENTRYPOINTS`, `Rnt()`) +- `CLAUDE_CODE_HOST_CREDS_FILE` must be owner-only; otherwise it is ignored with "is set but no + usable host credentials were read" +- to spoof an entrypoint to unlock these is off the normal path + +## .citations + +the refresh strategy selector: + +```js +function z_r(){if(Ese()!==null||Rnt())return"sdk_host_refresh";if(Vs())return"runner_rotation";return buo()?"none":void 0} +``` + +`runner_rotation` β€” a 401 waits up to 60s for a new token to appear β€” is for anthropic's remote +runners only (`CLAUDE_CODE_REMOTE_SESSION_ID`): + +```js +function pD(){let e=a.CLAUDE_CODE_OAUTH_401_WAIT_MS;if(e!==void 0)return e;return Vs()?60000:0} +``` diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O5-setup-token-via-env.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O5-setup-token-via-env.md new file mode 100644 index 00000000..7ca68d32 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O5-setup-token-via-env.md @@ -0,0 +1,32 @@ +# option O5: setup token per reach, injected as CLAUDE_CODE_OAUTH_TOKEN + +## .what + +a human mints one long-lived token per subscription (`claude setup-token`) and sets it into +keyrack, one per reach (S2). enroll resolves the reach's token by waterfall β€” the org's key, else +the machine default, else the `/login` file β€” and injects it as `CLAUDE_CODE_OAUTH_TOKEN`. + +## .verdict β€” 🟑 kills the race, fails S3 + +- βœ… a clone on an env token never refreshes and never writes the credential file: no race +- βœ… S2's waterfall fits keyrack's org scope +- ❌ the env is read at spawn. a swap reaches new spawns only β€” live clones keep the old token until + respawn. S3 names live rotation the top requirement +- ⚠️ each token is re-minted by a human in a browser, about once a year + +## .citations + +the env token enters as a credential with no refresh token: + +```js +if(a.CLAUDE_CODE_OAUTH_TOKEN)return{accessToken:a.CLAUDE_CODE_OAUTH_TOKEN,refreshToken:null,expiresAt:null,scopes:zy(),...} +``` + +and its refresh strategy is `none`: + +```js +function buo(){return Boolean(a.CLAUDE_CODE_OAUTH_TOKEN)&&!Vs()&&!a.ANTHROPIC_UNIX_SOCKET} +``` + +docs: β€” `claude setup-token`, subscription use in +scripts and ci. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O6-api-key-helper.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O6-api-key-helper.md new file mode 100644 index 00000000..25c7b448 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O6-api-key-helper.md @@ -0,0 +1,36 @@ +# option O6: apiKeyHelper with a keyrack command + +## .what + +set the `apiKeyHelper` key in settings to a command such as `rhx keyrack get …`. claude-code +re-runs it on a ttl (`CLAUDE_CODE_API_KEY_HELPER_TTL_MS`, default 5 minutes) and on a 401. + +## .verdict β€” β›” wrong channel + +the helper feeds the **api key** resolver. its output is sent as an api key, billed per token to a +console org β€” never as a subscription token. it also outranks the `/login` subscription, so to set +it moves the fleet off its subscriptions. + +## .citations + +the api-key resolver β€” the helper sits beside `ANTHROPIC_API_KEY`: + +```js +function u_(e={}){ ... if(d_()){ ... return{key:vDn(),source:"apiKeyHelper"}} ... } +``` + +the text shown to a human names it as api-key auth (verbatim from the build): + +``` +Your organization has disabled API key authentication Β· Unset the apiKeyHelper setting and run /login to sign in with your claude.ai account +``` + +output is validated as an api key: + +``` +apiKeyHelper output rejected: not a printable-ASCII token +returned output that cannot be used as an API key +``` + +docs: β€” the helper outputs an api key; a +configured helper outranks the subscription login. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O7-per-actor-credential-file.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O7-per-actor-credential-file.md new file mode 100644 index 00000000..58ba16f5 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O7-per-actor-credential-file.md @@ -0,0 +1,49 @@ +# option O7: per-actor credential file that rhachet writes + +## .what + +each actor dir holds a **real** `.credentials.json` β€” no symlink. rhachet writes into it the +reach's setup token (S2's waterfall, from keyrack) with a far `expiresAt` and no `refreshToken`. +to rotate (S3), rhachet rewrites the file; claude-code sees the new mtime and re-reads it on its +next request. + +## .verdict β€” 🟒 lead candidate for S2 + S3 + +- βœ… no clone ever refreshes, so no race and no blank +- βœ… live rotation: a rewrite reaches a live clone on its next read β€” inside the 15-minute ttl +- βœ… normal auth path: claude-code talks straight to anthropic under its stored-login code path +- βœ… per-actor file fits `rule.forbid.per-clone-config` β€” the credential derives from the reach, + so it is the actor's +- ⚠️ the usage-limit trigger (S3, S4) is rhachet's to detect; claude-code has no swap hook +- ❓ the unverified shape β€” see below + +## .citations + +the store re-reads when the file's mtime moves: + +```js +async function fD(e,n){ ... let{mtimeMs:r}=await od(ad(_S(),".credentials.json"));if(r!==e.lastCredentialsMtimeMs)e.lastCredentialsMtimeMs=r,Gk() ... } +``` + +a save of a token with no `refreshToken` is short-circuited (a hint the shape is tolerated, and a +reason claude-code will not overwrite rhachet's file): + +```js +if(!e.refreshToken||!e.expiresAt)return i("tengu_oauth_tokens_inference_only",{}),{success:!0}; +``` + +the refresh window opens 5 minutes before `expiresAt`, so a far `expiresAt` never opens it: + +```js +function ZO(e,n=Date.now()){return n+300000>=e} +``` + +community precedent for a file swap: β€” it swaps +the same `.credentials.json` claude-code reads. + +## .unverified + +- does a live clone accept a stored credential with `refreshToken` absent and inference scope only? +- what does a live clone do when the token is revoked mid-session β€” re-read, or stall on + `Login expired`? +- is a far `expiresAt` honored, or clamped? diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O8-rotation-proxy.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O8-rotation-proxy.md new file mode 100644 index 00000000..bde7ae91 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O8-rotation-proxy.md @@ -0,0 +1,28 @@ +# option O8: local rotation proxy via ANTHROPIC_BASE_URL + +## .what + +each clone spawns with `ANTHROPIC_BASE_URL` at a local proxy that holds every subscription's token. +per request, the proxy swaps in a subscription's auth, reads the `anthropic-ratelimit-unified-*` +response headers for the 5-hour and weekly usage, and on a 429 retries on another subscription. + +## .verdict β€” 🟑 works, heavy + +- βœ… rotation per request; a usage limit is seen before it hits, from the headers +- βœ… clones hold no real credential, so no refresh race +- β›” a daemon in every request path β€” if it stalls, the whole box stalls, the same blast radius as + today's blank +- ⚠️ it must relay streamed replies faithfully, plus cancel and timeout +- ⚠️ claude-code skips some lookups under a custom base url (below) +- ⚠️ `/status` shows the spawn login while another subscription is billed +- ⚠️ the header swap sits closest to what tool authors report as enforced: subscription + credentials run through third-party clients + +## .citations + +``` +not fetched with a custom ANTHROPIC_BASE_URL +``` + +precedent: , + diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O9-backup-and-revert.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O9-backup-and-revert.md new file mode 100644 index 00000000..0d84b3e2 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O9-backup-and-revert.md @@ -0,0 +1,59 @@ +# option O9: backup-and-revert of the global file + +## .what + +on each non-blank write of `~/.claude/.credentials.json`, keep the last 10 versions; when the file +turns blank, restore the latest non-blank one. + +## .verdict β€” β›” as posed + +the last non-blank version of the **global** file holds the refresh token that just died. to +restore it restores a dead token, and the next refresh blanks it again. the fresh token is not in +the global file's history at all β€” it sits in the winner's actor dir. `case=O10` is this idea +aimed at the right file. + +## .why β€” proven from the clear alone, whatever the symlink does + +the blank is written by `RDn`, and `RDn` blanks only while the file's refresh token **equals** the +one the server just rejected. so the last non-blank version of a blanked file is, by construction, +the rejected token. a revert of that same file always restores a dead refresh token (its access +token has at most the 5-minute refresh window left). this holds with or without the symlink +hypothesis below. + +## .the variant that works β€” track every credential file, restore the newest live one + +widen the history from one file to all of them: the global file **and** each actor's +`brain/.claude/.credentials.json`. an inotify watch (a systemd `.path` unit per dir, or one watcher) +keeps the last 10 non-blank versions across the set. when the global file stays blank past a +grace (15s), restore the newest version whose refresh token **differs** from the one just blanked +β€” that is the winner's, if one exists β€” then relink any actor file the winner broke. + +this is `case=O10` with a history buffer: the buffer also covers a winner's file that a later +actor-dir write overwrote. + +⚠️ the grace buys naught β€” no process self-heals the blank β€” so it should be as short as the +watcher can confirm a real blank rather than a mid-write glimpse. + +## .citations β€” why the global file holds the dead token + +the credential write refuses to follow a symlink (`O_NOFOLLOW` unless `followSymlinks`), so the +winner's write lands in its own actor dir β€” the symlink is replaced by a real file: + +```js +async function Cn(e,t,n,r){return cQ(e,t,{mode:n,renameFn:r})} +async function cQ(e,t,n){ ... A=f===!0?0:o.O_NOFOLLOW ... } +``` + +the dead-token clear is a compare-and-clear β€” it blanks the file only while the file still holds +the dead refresh token: + +```js +async function RDn(e,n){ ... mutate((g)=>{let h=g.claudeAiOauth;if(!h||h.refreshToken!==e)return g; + return r=!0,{...g,claudeAiOauth:{...h,refreshToken:"",accessToken:"",expiresAt:0}}}) ... } +``` + +field corroboration, from the wish's measured topology: 18 symlinks and **2 real files** in actor +dirs β€” the shape this mechanism predicts. + +βœ… proven end to end β€” the store path, the temp-then-rename write, and the contrapositive live in +`define.invariant.a-blank-proves-the-winner-missed-the-global-file.md`. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store._.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store._.md new file mode 100644 index 00000000..d404664b --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store._.md @@ -0,0 +1,64 @@ +# ref: claude-code's credential store β€” and how a per-actor CLAUDE_CONFIG_DIR split it + +> source: `@anthropic-ai/claude-code@2.1.280`, read from the text its native binary embeds. +> every claim in this cluster carries the command that reproduces its excerpt. the minified names +> (`_S`, `we`, `td`, `wuo`, `cQ`) belong to this build alone; search by the string literals +> (`CLAUDE_SECURESTORAGE_CONFIG_DIR`, `.oauth_refresh.lock`, `tengu_oauth_token_refresh_race_resolved`), +> which survive a rebuild. + +## .the answer in one screen + +claude-code keeps two dirs, not one: + +| dir | fn | env | holds | +|---|---|---|---| +| config dir | `we()` | `CLAUDE_CONFIG_DIR`, else `~/.claude` | settings, `CLAUDE.md`, projects, `.claude.json` | +| secure-storage dir | `_S()` | `CLAUDE_SECURESTORAGE_CONFIG_DIR`, else `we()` | `.credentials.json`, and every lock that guards it | + +- rhachet 1.48.0 (#553) set `CLAUDE_CONFIG_DIR` per actor and left `CLAUDE_SECURESTORAGE_CONFIG_DIR` + unset. so `_S()` fell back to the per-actor dir, and every refresh lock moved per actor β€” while a + symlink kept one credential file for the whole box. N locks over one file: the lock serializes no one. +- a credential write stages a temp file and renames it over the target. rename(2) replaces a symlink + rather than follow it, so a clone's first write detaches it: a private file with the new tokens, and + a shared file that still holds the refresh token the server just rotated out. +- `CLAUDE_SECURESTORAGE_CONFIG_DIR=""` in each clone's spawn env points `_S()` back at the one + machine dir while `CLAUDE_CONFIG_DIR` stays per actor: one file, one set of locks, no symlink. + claude-code already forwards this var to the teammates it spawns. + +## .index + +| ref | holds | +|---|---| +| `ref.claude-code.credential-store.dir.md` | `_S()` vs `we()`; the env var's three cases; the macOS keychain name | +| `ref.claude-code.credential-store.refresh.md` | the 5-min window; the double-checked lock; the `race_resolved` adopt | +| `ref.claude-code.credential-store.locks.md` | the four lock paths; the mkdir lock; why a symlinked lock cannot work | +| `ref.claude-code.credential-store.write.md` | temp + rename replaces a symlink; the store that refuses a symlink | +| `ref.claude-code.credential-store.dead-token-clear.md` | `RDn`: the blank is claude-code's own clear after `invalid_grant`, field for field | +| `ref.claude-code.credential-store.incident-read.md` | the 2026-09-28 timeline read against the code | +| `inventory.of=options._.md` | the options, ranked; the cure these refs support is `case=O3` | + +## .how to reproduce a citation + +```sh +rhx claude.cli.probe --into .temp/claude-cli.probe --version 2.1.280 +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern '' --radius 600 +``` + +`claude.cli.strings` reads only the probe's package, inside the repo, and prints each match with the +code around it; an unprintable byte shows as `.`. no excerpt in this cluster holds a token value β€” +each is code. + +## .what stays unproven + +- **which process wrote the blank.** the WHAT is settled: `RDn`, claude-code's dead-token clear after + `invalid_grant` (`.dead-token-clear.md`). the WHO is narrowed, not named: no write path in `.write.md` + writes through a symlink, so the writer had `_S()` = `~/.claude` β€” a clone from a tree on rhachet + < 1.48.0, a grove tool, or the human's own `claude` (`.incident-read.md`). the cure is the same for all +- **which store 2.1.280 used on the grove.** the plain store follows a symlink on read and replaces it + on write; the handle store refuses it on both. the clones read through their symlinks for hours, + which fits the plain store +- **where the two "own file" actors got their files.** the wish reads them as the enroll's `kept` + branch. the rename predicts a second source: each is a clone that won a refresh and detached. + `b6519bf1` refreshed 86s before the blank. the grove's file times would settle it +- **that the server revokes on refresh-token reuse.** consistent with the blank at 3m24s before + expiry and with claude-code's own dead-token set (`known_dead_refresh_token`); not measured diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.dead-token-clear.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.dead-token-clear.md new file mode 100644 index 00000000..c10e0656 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.dead-token-clear.md @@ -0,0 +1,64 @@ +# ref: the blank is claude-code's deliberate dead-token clear + +> zoom-in of `ref.claude-code.credential-store._.md`. source: `@anthropic-ai/claude-code@2.1.280`. +> resolves the wish's first unproven item: *"the precise failure mechanism inside claude-code"*. + +## .claim 1 β€” on `invalid_grant`, claude-code blanks the secrets and keeps the rest + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'RDn\(' --radius 900 --limit 5 +``` + +```js +async function RDn(e,n){Eo.add(e),i("tengu_oauth_refresh_token_marked_dead_invalid_grant",{});try{let r=!1,s=await Fn().mutate((g)=>{let h=g.claudeAiOauth;if(!h||h.refreshToken!==e)return g;return r=!0,{...g,claudeAiOauth:{...h,refreshToken:"",accessToken:"",expiresAt:0}}},n);if(r&&s.success)i("tengu_oauth_refresh_token_cleared_on_disk",{});…} +``` + +| the measured blank (wish Β§3) | `RDn`'s output | +|---|---| +| `accessToken: ""` Β· `refreshToken: ""` Β· `expiresAt: 0` | `refreshToken:"",accessToken:"",expiresAt:0` | +| `refreshTokenExpiresAt`, `rateLimitTier`, `scopes`, `subscriptionType` survived | `{...h, …}` β€” every other field spread through | + +β‡’ **not a default object β€” a deliberate clear.** field for field, the shape is `RDn`'s. + +the guard `h.refreshToken!==e` clears only if disk still holds the token that failed. + +## .claim 2 β€” `invalid_grant` is the only error that triggers it + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'function goe\(|function hDn\(' --radius 500 --limit 4 +``` + +```js +function goe(e){if(!ut.isAxiosError(e)||!e.response)return!1;let n=e.response.status;if(n!==400&&n!==401)return!1;return Us(e.response.data).code==="invalid_grant"&&kxt(e.response.data)===null} +``` + +a network error, a timeout, a 5xx: no clear. only the token endpoint's `invalid_grant` β€” the server +says this refresh token is spent or revoked. + +## .claim 3 β€” the refresh calls it after one last re-read + +same command as claim 1: + +```js +Gk();let G=await Ha(y);if(G&&G.accessToken!==P)return Wt(),i("tengu_oauth_token_refresh_race_recovered",{}),"refreshed";if(goe(X)&&L)await RDn(L,y); +``` + +## .claim 4 β€” every process on that store then sees a dead login + +same command as claim 1: + +```js +function db(e){if(e){let n=e.refreshToken;return n===""||!!n&&Eo.has(n)}…}function cb(e){return e?.claudeAiOauth?.refreshToken===""} +``` + +an empty refresh token reads as dead, to every process on that store. 18 clones that read one file +through links become 18 dead clones β€” the wish's "same instant" (the mtime check in `.refresh.md` +claim 2 makes each notice on its next request). + +## .what this means for the fix + +- the clear is correct behavior for a token the server truly revoked. **the defect is the + `invalid_grant`**: a refresh token posted after another process already spent it. +- claude-code prevents the second post with its lock (`.refresh.md`), inside one store. rhachet's + topology split the store's locks while it shared the store's file. +- β‡’ restore one store per box, and the double post cannot happen. no change to the clear is needed. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.dir.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.dir.md new file mode 100644 index 00000000..19664cf8 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.dir.md @@ -0,0 +1,107 @@ +# ref: the two dirs β€” config (`we()`) vs secure storage (`_S()`) + +> zoom-in of `ref.claude-code.credential-store._.md`. source: `@anthropic-ai/claude-code@2.1.280`. + +## .claim 1 β€” the secure-storage dir reads its own var and falls back to the config dir + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'CLAUDE_SECURESTORAGE_CONFIG_DIR' --radius 350 --limit 12 +``` + +```js +import{homedir as o,userInfo as u}from"os";import{join as l}from"path";var Oen="-credentials"; +function _S(){let n=process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;if(n!==void 0)return(n||l(o(),".claude")).normalize("NFC");return we()} +``` + +| `CLAUDE_SECURESTORAGE_CONFIG_DIR` | `_S()` returns | +|---|---| +| unset | `we()` β€” the config dir. **this is the 1.48.0 clone: `_S()` = the actor's brain dir** | +| `""` | `join(homedir(), ".claude")` β€” the machine dir, whatever `CLAUDE_CONFIG_DIR` says | +| a path | that path | + +## .claim 2 β€” the config dir reads `CLAUDE_CONFIG_DIR`; so does the global config file + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'env\.CLAUDE_CONFIG_DIR' --radius 250 --limit 12 +``` + +```js +function s(){return process.env.CLAUDE_CONFIG_DIR}var we=Yo(()=>(s()??a(g(),".claude")).normalize("NFC"),s); +function kFn(){let t=`.claude${Nz()}.json`;return S(process.env.CLAUDE_CONFIG_DIR||Dt(),t)} +``` + +β‡’ `.claude.json` (the global config) is per actor under 1.48.0 β€” no cross-actor write lands there. + +## .claim 3 β€” what lives under which dir + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'credentials\.json' --radius 600 --limit 8 +``` + +```js +var dEt=".credentials.json",pEt=".device-keys.json";function hxn(){return vm(aQ(),".claude",pEt).normalize("NFC")} +…vm(we(),wur),vm(_S(),dEt),hxn()]} +``` + +| file | dir | shared across actors under 1.48.0? | +|---|---|---| +| `.credentials.json` | `_S()` | only by rhachet's symlink | +| `.oauth_refresh.lock`, `<_S()>.lock`, `.storage-write`, `.design_oauth_refresh.lock` | `_S()` | **no β€” one set per actor** (`.locks.md`) | +| user settings, `CLAUDE.md`, `boot.md` | `we()` | no, by design (`define.brain-dir-repo-vs-actor.md`) | +| `.claude.json` | `CLAUDE_CONFIG_DIR`, else home | no | +| `.device-keys.json` | `~/.claude`, always | yes β€” not touched by either var | + +β‡’ **the credential store is the one machine-global state a clone writes.** the per-actor config dir keeps +its boot either way. + +## .claim 4 β€” on macOS the keychain entry name follows the same var + +same command as claim 1: + +```js +function q0e(n=""){let e=process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,t=e!==void 0?!e:!process.env.CLAUDE_CONFIG_DIR,r=e!==void 0?e.normalize("NFC"):we(),c=t?"":`-${a("sha256").update(r).digest("hex").substring(0,8)}`;return`Claude Code${nn().OAUTH_FILE_SUFFIX}${n}${c}`} +``` + +| env | keychain service suffix | reaches the human's login? | +|---|---|---| +| neither var | none | βœ… | +| `CLAUDE_CONFIG_DIR` only (a 1.48.0 clone) | `-sha256(config dir)[0:8]` | πŸ”΄ a per-actor entry, empty | +| `CLAUDE_SECURESTORAGE_CONFIG_DIR=""` | none | βœ… | +| `CLAUDE_SECURESTORAGE_CONFIG_DIR=/home/x/.claude` | `-sha256(path)[0:8]` | πŸ”΄ even the default path, spelled out, is a new entry | + +β‡’ **`""` is the one value that means "the default store" on linux and macos alike.** + +## .claim 5 β€” claude-code treats the var as a supported relocation + +same command as claim 1. teammates inherit it, and an empty value survives: + +```js +let o=process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;if(o!==void 0)n.push(`CLAUDE_SECURESTORAGE_CONFIG_DIR=${eo([o])}`);return n.join(" ") +if(o===""&&n!=="CLAUDE_SECURESTORAGE_CONFIG_DIR")continue;e[n]=o +``` + +a project or local settings file cannot set it β€” it must come from the process env (which is how +rhachet already sets `CLAUDE_CONFIG_DIR`): + +```js +…"CLAUDE_CONFIG_DIR","CLAUDE_SECURESTORAGE_CONFIG_DIR","CLAUDE_CODE_TMPDIR",… +var B=new Set(["projectSettings","localSettings"]);function u(e,n,o){if(!e||!B.has(n))return e;…if(!k(r))continue;… +``` + +## .claim 6 β€” public status: undocumented, relied on, `""` is the known idiom + +- [anthropics/claude-code#79223](https://github.com/anthropics/claude-code/issues/79223), opened + 2026-07-19, open, labels `area:auth` `area:docs`: *"Document CLAUDE_SECURESTORAGE_CONFIG_DIR + (credential-store location override β€” widely used by ecosystem tooling, currently undocumented)"*. +- the same issue: *"Empty string β‰  unset: an empty value pins the default credential store even when + `CLAUDE_CONFIG_DIR` is set; merely unsetting the variable does not, because `CLAUDE_CONFIG_DIR` then + drives the derivation."* β€” verified there against 2.1.206/2.1.207 and 2.1.215; claim 1 above + verifies it against 2.1.280. +- the same issue lists tools that pin it to `""` *"in their container/provider setups to force the + default store"*: claudex-switch, agent-fleet, claude-pod, switchroom, among others. +- related reports: [sbigstar0310/cc-donut#84](https://github.com/sbigstar0310/cc-donut/issues/84), + [mhelbich/VS-Code-Claude-Usage#38](https://github.com/mhelbich/VS-Code-Claude-Usage/pull/38). + +⚠️ **the risk this carries:** an undocumented var can change without a changelog line. it has held +from 2.1.206 to 2.1.280, and claude-code's own teammate spawn depends on it (claim 5). a clamp that +checks its effect would catch a regression. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.incident-read.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.incident-read.md new file mode 100644 index 00000000..e78ab5a3 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.incident-read.md @@ -0,0 +1,57 @@ +# ref: the 2026-09-28 outage, read against the code + +> zoom-in of `ref.claude-code.credential-store._.md`. the timeline is the wish's (`0.wish.md`, Β§2–§3, +> first-party on `grove-ahbode-v20260901`, rhachet 1.48.0, claude-code 2.1.280). the mechanism is the +> other refs'. **this is an inference that fits every measured fact; the act itself was not caught.** + +## .the facts + +| time | fact (wish) | +|---|---| +| 13h30m36s | the shared file `~/.claude/.credentials.json` restored; `expiresAt` 21h30m20s | +| 21h25m20s | the 5-minute window opens for every clone that reads the shared file (`.refresh.md` claim 1) | +| 21h25m30s | actor `b6519bf1`'s own file refreshed β€” 7.9h out | +| 21h26m56s | the shared file blanked, in `RDn`'s exact shape (`.dead-token-clear.md` claim 1) | +| after | 18 symlinks intact; every linked clone shows `Login expired` | +| β€” | actor `07bb9428` holds its own file, 4.5h out, intact | + +## .the read + +1. **21h25m20s** β€” every clone enters the window together. under 1.48.0 each actor locks its own + `_S()` (`.locks.md` claim 1), so clones of different actors race with no lock between them. +2. **21h25m30s** β€” a `b6519bf1` clone wins: it POSTs the shared refresh token, gets new tokens, and + saves. the save renames a temp over its symlink (`.write.md` claim 2): `b6519bf1` now holds a + private file with the new tokens, 7.9h out. **the shared file still holds the spent token.** +3. **21h25m30s β†’ 21h26m56s** β€” the losers re-read under their own lock and find no new token, since the + winner never wrote the shared file (`.refresh.md`, the 1.48.0 table). +4. **21h26m56s** β€” a loser POSTs the spent token β†’ the server answers `invalid_grant` β†’ `RDn` clears + the file that loser writes (`.dead-token-clear.md` claim 3). +5. the shared file is blank; every linked clone reads it on its next mtime check β†’ 18 dead clones. + +## .the one step the code narrows but does not name + +step 4's writer blanked the **shared** file, and 18 symlinks stayed intact. a symlinked clone that +clears detaches into a private blank (`.write.md` claim 2's table) and leaves the shared file alone. so +the writer read and wrote `~/.claude/.credentials.json` **directly**: its `_S()` was `~/.claude`. + +| candidate with `_S()` = `~/.claude` | fits? | +|---|---| +| a clone from a tree on rhachet < 1.48.0 (no `CLAUDE_CONFIG_DIR`) | βœ… β€” the grove runs many trees; an older one spawns with the machine dir | +| a grove tool that runs `claude` bare | βœ… | +| the human's own `claude` | βœ… if one ran at 21:26 | + +β‡’ every candidate is a process that locks `~/.claude`, which no 1.48.0 clone locks. the race it lost +was to a clone behind a different lock. the fix (`inventory.of=options.case=O3-relocate-the-secure-storage-dir.md`) +puts every process on the box behind that one lock, so which candidate it was does not change the cure. + +## .the two private files + +- `b6519bf1` β€” the refresh at 21h25m30s fits step 2: a won refresh that detached. +- `07bb9428` β€” 4.5h out, so it refreshed near 17:30 (a 8h token): an earlier winner by the same + mechanism, or the enroll's `kept` branch. the grove's file birth times would settle it. + +## .why this began with 1.48.0 + +before #553 no clone set `CLAUDE_CONFIG_DIR`, so every `_S()` on the box was `~/.claude`: one file, +one lock, and every loser adopted (`.refresh.md` claim 2). #553 moved `we()` per actor and, with no +`CLAUDE_SECURESTORAGE_CONFIG_DIR`, moved `_S()` β€” and its locks β€” with it. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.locks.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.locks.md new file mode 100644 index 00000000..5d222bdf --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.locks.md @@ -0,0 +1,66 @@ +# ref: the locks β€” all keyed on `_S()`, all mkdir-based, none shareable by symlink + +> zoom-in of `ref.claude-code.credential-store._.md`. source: `@anthropic-ai/claude-code@2.1.280`. + +## .claim 1 β€” the refresh takes two locks, both derived from `_S()` + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'oauth_refresh\.lock' --radius 700 --limit 4 +``` + +```js +function q_r(e,n){return{lockfilePath:ad(e,".oauth_refresh.lock"),realpath:!1,stale:60000,update:5000,onCompromised:…}} +…y=`${await DM(e).catch(()=>e)}.lock`… +``` + +| lock | path | 1.48.0 clone | pre-1.48.0 clone, human's `claude` | +|---|---|---|---| +| refresh | `<_S()>/.oauth_refresh.lock` | `/brain/.claude/.oauth_refresh.lock` | `~/.claude/.oauth_refresh.lock` | +| legacy refresh | `realpath(<_S()>) + ".lock"` | `/brain/.claude.lock` | `~/.claude.lock` | + +β‡’ **two clones of different actors hold two different locks over the one shared file.** claude-code's +own guard never sees the other racer. + +`stale:60000` β€” a crashed holder's lock is broken after 60s, so one dead process cannot wedge a shared +lock for good. + +## .claim 2 β€” every credential write takes a third lock, also under `_S()` + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'storagePath:V\(e,' --radius 1600 --limit 2 +``` + +```js +…ei(F(a,".storage-write"),{realpath:!1,retries:{retries:10,minTimeout:100,maxTimeout:1000},stale:15000,… +function g(e,r,n){return Fwr(async()=>{e.invalidateCache?.();let a=await(e.readAsyncStrict?.(n)??e.readAsync(n));if(a===au)return{success:!1,transient:!0};let o=a??{},s=r(o);return s===o?{success:!0}:await e.update(s,n)})} +``` + +every `mutate` (the save in `.refresh.md` claim 3, the clear in `.dead-token-clear.md`) is a +read-modify-write under `<_S()>/.storage-write`. per actor, that too serializes no one across actors. + +a fourth, `.design_oauth_refresh.lock`, sits under `_S()` as well: + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'design_oauth_refresh' --radius 300 --limit 3 +``` + +## .claim 3 β€” the lock is a directory made with `mkdir`, so a symlinked lock cannot work + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'lockfilePath\|\|' --radius 900 --limit 3 +``` + +```js +function ne(e,r){return r.lockfilePath||`${e}.lock`} +function De(e,r,n){let i=ne(e,r);r.fs.mkdir(i,(u)=>{…if(u.code!=="EEXIST")return n(u);…r.fs.stat(i,(c,p)=>{…if(!rt(p,r))return n(Object.assign(Error("Lock file is already being held"),{code:"ELOCKED",file:e}));nt(e,r,…)… +function rt(e,r){return e.mtime.getTime()/brain/.claude/.oauth_refresh.lock`: + +- `mkdir` on it β†’ `EEXIST` (the link exists) β†’ `stat` follows it +- target absent (no one holds the real lock) β†’ `ENOENT` β†’ retry with `stale:0` β†’ `EEXIST` again β†’ **`ELOCKED` forever**. the clone never refreshes +- target present and stale β†’ `rmdir` on the link β†’ fails (`ENOTDIR`). the lock never frees +- and the legacy lock path is `realpath(_S()) + ".lock"` β€” a path beside the actor dir, a second link to plant + +β‡’ **refuted: a symlinked lock wedges the clone.** only one `_S()` shares the locks. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.refresh.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.refresh.md new file mode 100644 index 00000000..d9166617 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.refresh.md @@ -0,0 +1,86 @@ +# ref: the refresh β€” a double-checked lock that works only if every racer shares one `_S()` + +> zoom-in of `ref.claude-code.credential-store._.md`. source: `@anthropic-ai/claude-code@2.1.280`. + +## .claim 1 β€” the window opens 5 minutes before expiry + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'function ZC\(|kS=new Set\(|function ZO\(|function fD\(' --radius 350 --limit 8 +``` + +```js +function ZO(e,n=Date.now()){if(e===null)return!1;return n+300000>=e} +``` + +the measured blank at 21h26m56s sat inside the window of an expiry at 21h30m20s (opened 21h25m20s). +every clone on the box shares that expiry, so every clone enters the window together β€” the race is +built in, and claude-code's protocol below exists to absorb it. + +## .claim 2 β€” every refresher first stats the file, then re-reads under a lock + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'lastCredentialsMtimeMs' --radius 600 --limit 4 +``` + +```js +async function fD(e,n){…try{let{mtimeMs:r}=await od(ad(_S(),".credentials.json"));if(r!==e.lastCredentialsMtimeMs)e.lastCredentialsMtimeMs=r,Gk()}catch{await jy(e,n)}} +``` + +a changed mtime drops the in-memory copy (`Gk()`), so the next read hits disk. a stat follows a +symlink, so a symlinked clone sees the shared file's writes β€” which is also how one blank reached +all 18 clones at once. + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'lock_busy' --radius 1200 --limit 3 +``` + +```js +if(X.code==="ELOCKED"){let G=s??await Qu(X,D);if(n<5){i("tengu_oauth_token_refresh_lock_retry",{retryCount:n+1});let fe=1000+Math.random()*1000;return await ee(fe),td(e,n+1,r+fe,G,g,P,y,E,A)}…return se?"lock_busy":"lock_timeout"} +…let G=await Ha(y);if(!G?.refreshToken)return"no_refresh_token";if(L=G.refreshToken,G.accessToken!==P)return Wt(),i("tengu_oauth_token_refresh_race_resolved",{}),"refreshed";if(!g&&!ZO(G.expiresAt))return"not_needed";if(Eo.has(G.refreshToken))return"known_dead_refresh_token";…ne=await moe(G.refreshToken,{…}) +``` + +the protocol, in order: + +1. read the credential. not in the window β†’ `not_needed` +2. re-read. the access token changed since step 1 β†’ a peer refreshed β†’ **adopt it** (`race_resolved`) +3. take the lock on `_S()`. held β†’ wait 1–2s and start over, up to 5 times, then give up for now +4. re-read **under the lock**. changed β†’ adopt. still the same β†’ POST the refresh token + +β‡’ **step 4 is the whole guarantee: the loser waits on the lock, then finds the winner's tokens on +disk and adopts them.** a loser is never meant to fail. it holds only if both racers lock the same +path AND read the same file. + +## .claim 3 β€” the save is a compare-and-swap on the refresh token that was posted + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'adopted_sibling' --radius 1400 --limit 3 +``` + +```js +async function ADn({isCompromised:e,postedRefreshToken:n,refreshedTokens:r,credentials:s}){…for(let M=0;M<3;M++){…w=await Fn().mutate((D)=>{let F=D.claudeAiOauth?.refreshToken;if(!(D.claudeAiOauth!==void 0&&D.claudeAiOauth!==null&&(F===""||F===n)))return E=!0,D;return{...D,claudeAiOauth:lb(D.claudeAiOauth,y)}},s)…} +…else if(E)i("tengu_oauth_refresh_save_adopted_newer_write",{});if(E)return Wt(),"adopted_sibling";return w.success?"saved":"save_failed"} +``` + +the new tokens land only if disk still holds the refresh token that was posted; else the peer's +newer write wins. claude-code expects concurrent refreshers β€” **within one store**. + +## .claim 4 β€” a failed refresh re-reads once more, then may clear the token + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'RDn\(' --radius 900 --limit 5 +``` + +```js +Gk();let G=await Ha(y);if(G&&G.accessToken!==P)return Wt(),i("tengu_oauth_token_refresh_race_recovered",{}),"refreshed";if(goe(X)&&L)await RDn(L,y); +``` + +a peer's write seen now β†’ recover. none seen and the server said `invalid_grant` β†’ `RDn`, the +dead-token clear (`.dead-token-clear.md`). + +## .what 1.48.0 did to this protocol + +| step | one `_S()` for the box (pre-1.48.0) | `_S()` per actor + symlink (1.48.0) | +|---|---|---| +| 3. lock | one lock; racers queue | N locks; racers of different actors never meet | +| 4. re-read under lock | finds the winner's tokens β†’ adopt | reads the shared file, which the winner **did not write** (`.write.md`) β†’ POSTs the spent token | +| catch re-read | finds the winner's tokens β†’ recover | same miss β†’ `invalid_grant` β†’ clear | diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.write.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.write.md new file mode 100644 index 00000000..4166a2fd --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/ref.claude-code.credential-store.write.md @@ -0,0 +1,79 @@ +# ref: the write β€” temp + rename replaces the symlink, so the refresh winner detaches + +> zoom-in of `ref.claude-code.credential-store._.md`. source: `@anthropic-ai/claude-code@2.1.280`. + +## .claim 1 β€” the plaintext store reads through a symlink and writes over it + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'storagePath:V\(e,' --radius 1600 --limit 2 +``` + +```js +function i(){let e=_S(),r=".credentials.json";return{storageDir:e,storagePath:V(e,".credentials.json")}} +var Z={async read(){let{storagePath:e}=i();try{let r=await le().readFile(e,…);return J(r)}catch{return null}},… +async write(e){try{let{storageDir:r,storagePath:n}=i();return await le().mkdir(r),await Cn(n,S(e),384),await Y(n,384),{success:!0,…}}catch{return{success:!1}}…}, +async remove(){let{storagePath:e}=i();try{return await le().unlink(e),!0}…}}; +``` + +- `read` β†’ `readFile(path)` follows a symlink β†’ a 1.48.0 clone reads the shared file +- `write` β†’ `Cn(path, …, 0o600)` β†’ claim 2 +- `remove` β†’ `unlink(path)` β†’ removes the link, not the shared file + +## .claim 2 β€” `Cn` puts a temp beside the path and renames it onto the path + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'function Le\(e,t\)' --radius 700 --limit 8 +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'function ZC\(|kS=new Set\(|function ZO\(|function fD\(' --radius 350 --limit 8 +``` + +```js +async function Cn(e,t,n,r){return cQ(e,t,{mode:n,renameFn:r})} +async function Le(e,t){if(t===void 0)return e;…return Te(t,be(e))} +kS=new Set(["EXDEV","EPERM","EEXIST","EBUSY"]);function ZC(e){return`${e}.tmp.${Oe(4).toString("hex")}`} +``` + +- `Cn` passes no temp dir β†’ `Le` returns the path itself β†’ the temp is `/brain/.claude/.credentials.json.tmp.` +- then `rename(temp, /brain/.claude/.credentials.json)`. rename(2) on a symlink replaces the + link, never its target +- `cQ` opens with `O_NOFOLLOW` unless a caller opts in to follow links, and `Cn` does not; its + in-place fallback (only on `EXDEV`/`EPERM`/`EEXIST`/`EBUSY`) rejects a non-regular target + +β‡’ **no write path in this store reaches the shared file through a symlink.** the first time a 1.48.0 +clone saves a credential, its link becomes a private file: + +| after | the clone's file | the shared file | +|---|---|---| +| it wins a refresh | a private file with the NEW tokens | still the OLD tokens β€” a refresh token the server just spent | +| it clears a dead token | a private blank | untouched | + +β‡’ the "2 Γ— a real file of the actor's own" in the wish need not be the enroll's `kept` branch. a +won refresh makes one (`.incident-read.md`). + +## .claim 3 β€” the other store rejects a symlink outright + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'refused-symlink' --radius 900 --limit 3 +``` + +```js +case"refused-symlink":case"read-failed":return au; +…function m(e){return N()&&e!==void 0?ee(e):Z} +``` + +under the handle store (`ee`), a symlinked credential reads as a failed read. the grove's clones read +their links for hours, so they ran the plaintext store (`Z`). either way: **claude-code supports no +credential file shared by symlink.** + +## .claim 4 β€” claude-code's own precedent for a derived dir copies, and strips the refresh token + +```sh +rhx claude.cli.strings --in .temp/claude-cli.probe --pattern 'claudeAiOauth\.refreshToken' --radius 600 --limit 6 +``` + +```js +if(n?.claudeAiOauth?.refreshToken)delete n.claudeAiOauth.refreshToken,r=S(n)}catch{}await Gt(s,r,{mode:384})} +…`claude-resume-${Vs()}`… +``` + +when claude-code builds a temp config dir of its own (`claude-resume-*`), it copies the credential +and deletes the refresh token, so the copy can never race the source's refresh. diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/template.[feedback].v1.[given].by_human.md b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/template.[feedback].v1.[given].by_human.md new file mode 100644 index 00000000..c5af8950 --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/template.[feedback].v1.[given].by_human.md @@ -0,0 +1,27 @@ +emit your response to the feedback into +- .behavior/v2026_09_29.fix-shared-brain-credential-blanking/$BEHAVIOR_REF_NAME.[feedback].v$FEEDBACK_VERSION.[taken].by_robot.md + +1. emit your response checklist +2. exec your response plan +3. emit your response checkoffs into the checklist + +--- + +first, bootup your mechanics briefs again + +./node_modules/.bin/rhachet roles boot --repo ehmpathy --role mechanic + +--- +--- +--- + + +# blocker.1 + +--- + +# nitpick.2 + +--- + +# blocker.3 diff --git a/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/review/self/.gitignore b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/review/self/.gitignore new file mode 100644 index 00000000..c6959e2c --- /dev/null +++ b/.behavior/v2026_09_29.fix-shared-brain-credential-blanking/review/self/.gitignore @@ -0,0 +1,3 @@ +# ignore all self-review files +* +!.gitignore diff --git a/.dream/v2026_09_29.feat.clamp-n-real-refreshers-on-one-shared-login.md b/.dream/v2026_09_29.feat.clamp-n-real-refreshers-on-one-shared-login.md new file mode 100644 index 00000000..a5148a51 --- /dev/null +++ b/.dream/v2026_09_29.feat.clamp-n-real-refreshers-on-one-shared-login.md @@ -0,0 +1,26 @@ +# πŸŒ™ dream: clamp N real claude-code refreshers on one shared login + +- **kind** = feat +- **owner** = `rhachet` β€” the enroll acceptance tier +- **caught** = 2026-09-29 + +## .the cue that fired + +the shared-credential-blank cure (`.behavior/v2026_09_29.fix-shared-brain-credential-blanking`) ships with a clamp that proves WHERE claude-code's login writes land (`~/.claude`, never the actor brain dir), not a live race of N refreshers. a peer reviewer asked, across two rounds, that the residual risk be a titled follow-up rather than a fulcrum note (fulcrum F10, 75% confidence). + +## .the gap, precisely + +- the cure rests on claude-code's own `.storage-write` lock, which must still hold once every clone shares one store; that lock was read from the binary, never measured under contention +- no test drives two or more real claude-code processes to refresh one login in one window and asserts that each peer still holds a live login after +- with fake tokens every refresh fails `invalid_grant`, so a fake-token N-process run shows N rejections and cannot show "one winner, peers keep the login" + +## .the shape of the fix + +- a real-brain acceptance tier (beside `clone.realbrain`) that holds a dedicated, disposable subscription login with a short-lived access token +- spawn N (e.g. 4) enrolled print-mode clones against it in the same second, each forced to refresh +- assert: every clone answered, and the shared login after the run parses with a non-empty refresh token (report only presence and `expiresAt`, never a value) +- run it on a schedule, not per push, since each run spends real refreshes + +## .why it is not done in this round + +it needs a real, disposable oauth login in CI, which the round's constraints forbid (no real token read, planted, or sent) and which a human must provision. the grove's post-release uptime is the interim field test. diff --git a/.dream/v2026_09_29.feat.per-subscription-reach-and-live-rotation.md b/.dream/v2026_09_29.feat.per-subscription-reach-and-live-rotation.md new file mode 100644 index 00000000..7eaa490a --- /dev/null +++ b/.dream/v2026_09_29.feat.per-subscription-reach-and-live-rotation.md @@ -0,0 +1,27 @@ +# πŸŒ™ dream: a clone authenticates as its reach's subscription, and a swap reaches live clones + +kind: feat Β· owner: rhachet (enroll + keyrack) Β· caught 2026-09-29 + +## .the cue that fired + +the fix-shared-brain-credential-blanking route settled seeds S2–S4 (one subscription per reach, held in keyrack; rotate live clones on the operator's timeline; rate limits are per subscription) and landed none of them. the route's peer review (repo-rules, nitpick.5) flagged the bare `[out of scope]` note. + +## .the gap, precisely + +- every clone on a box reads one `~/.claude` login β€” one subscription for the whole fleet +- the usage limit is metered per subscription, so one busy reach throttles every reach +- a swap reaches no live clone: a clone keeps whatever login its process holds until respawn + +## .the shape of the fix + +one dedicated feature pr: + +1. **the store** β€” keyrack holds one credential per reach (org), plus a machine default. the choice is a waterfall: the org's credential, else the default +2. **the channel** β€” clones read the credential through an operation claude-code re-reads (its api-key helper), backed by a `rhx keyrack get` of the reach's key. no file under `~/.claude` is written per reach, so the O3 one-lock-set invariant holds for the file path +3. **the propagation** β€” the helper's ttl is 15 minutes; a session-usage-limit error triggers an immediate re-read and a swap to the next subscription +4. **the proof** β€” an acceptance test that swaps a reach's key while a clone lives and asserts the next request uses the new one within the ttl + +## .why it is not done in this round + +- **not clean:** it opens a new credential source, which S1 declined for this route. it touches keyrack, enroll, the spawn env and claude-code's helper contract β€” none of them in this diff +- this route cures an outage (a refresh blanks the whole fleet); the rotation work is a feature on top, and it composes with O3 rather than replaces it diff --git a/.dream/v2026_09_29.reseed.fleet-poll-reports-a-login-expired-crew-as-at-work.md b/.dream/v2026_09_29.reseed.fleet-poll-reports-a-login-expired-crew-as-at-work.md new file mode 100644 index 00000000..99f5774b --- /dev/null +++ b/.dream/v2026_09_29.reseed.fleet-poll-reports-a-login-expired-crew-as-at-work.md @@ -0,0 +1,25 @@ +# πŸŒ™ dream: the fleet poll reports a `Login expired` crew as `at work` + +- **kind** = reseed +- **owner** = `nheuron` β€” its fleet/supervisor poll +- **caught** = 2026-09-29 + +## .the cue that fired + +the `v2026_09_29.fix-shared-brain-credential-blanking` wish measured two box-wide outages on `grove-ahbode-v20260901`. through both, every pane showed `● Login expired Β· Please run /login`, while the supervisor's poll still reported each crew `at work`. the wish scoped this out and asked for it to be seeded separately. + +## .the gap, precisely + +- the poll reads liveness (the process or pane exists), not health (the brain can make a model request) +- a de-authed clone is a live process that consumes turns and produces naught, so it reads as busy +- β‡’ the outage was found by a human who looked at a pane, not by the poll that exists to notice it + +## .the shape of the fix + +- classify a pane whose tail shows claude-code's auth notices (`Login expired`, `Not logged in`, `Please run /login`, a 401) as a distinct state, e.g. `de-authed`, never `at work` +- when N crews on one box flip to `de-authed` at once, raise one box-level alert that names the shared-credential cause and the cure, not N crew alerts +- rhachet's side of the cure: enroll now refuses a blanked credential (exit 2), so a respawn loop sees a constraint error rather than a new ghost crew; the poll should surface that exit code as-is + +## .why it is not done in this round + +it is `nheuron`'s code, and the wish scoped it out explicitly. diff --git a/.dream/v2026_09_29.reseed.grove-crews-should-spawn-with-the-setup-token.md b/.dream/v2026_09_29.reseed.grove-crews-should-spawn-with-the-setup-token.md new file mode 100644 index 00000000..9e606249 --- /dev/null +++ b/.dream/v2026_09_29.reseed.grove-crews-should-spawn-with-the-setup-token.md @@ -0,0 +1,25 @@ +# πŸŒ™ dream: grove crews should spawn with `CLAUDE_CODE_OAUTH_TOKEN` in their env + +- **kind** = reseed +- **owner** = the repo that owns `rhx git.grove.auth` and the grove crew spawner (not in the `ehmpathy` org; an org search for `git.grove.auth` found none) +- **caught** = 2026-09-29 + +## .the cue that fired + +the `v2026_09_29.fix-shared-brain-credential-blanking` vision chose, as its cure, clones that authenticate from a long-lived `claude setup-token` token in `CLAUDE_CODE_OAUTH_TOKEN` rather than a refreshable login shared by symlink. rhachet's half is in place: the caller's env reaches a clone on all three spawn paths (pty, plain, the `--async` host). the other half, whether the grove spawner puts the variable in a crew's env, lives in another repo (F2). + +## .the gap, precisely + +- `rhx git.grove.auth` performs the two-leg browser `/login` handshake. that handshake restores the shared login, which the fix takes clones off of +- grove crews spawn from tmux and cron, whose env may not carry a shell-profile export. if the variable never reaches `rhx enroll`, the enroll falls back to the shared login (case 4) and the fleet keeps the 1.48.0 hazard + +## .the shape of the fix + +1. `git.grove.auth` gains a mode that runs `claude setup-token` once and stores the token in a host secret the grove owns (0600 file, or a vault with a lifetime that does not undercut the token's year; a keyrack unlock of ~9h does) +2. the crew spawner exports `CLAUDE_CODE_OAUTH_TOKEN` from that store into each crew's env before it calls `rhx enroll` +3. on a live grove, respawn each pre-cure crew once (case 7), then the box is off the refresh race +4. never print or log the token value + +## .why it is not done in this round + +it is another repo's code. a tree adopts only what is scoped to itself. diff --git a/.dream/v2026_09_30.playtest.live-fleet-survives-refresh-cycles.md b/.dream/v2026_09_30.playtest.live-fleet-survives-refresh-cycles.md new file mode 100644 index 00000000..81ba7dba --- /dev/null +++ b/.dream/v2026_09_30.playtest.live-fleet-survives-refresh-cycles.md @@ -0,0 +1,23 @@ +# πŸŒ™ dream: playtest the live fleet across two refresh cycles, after the shared-login fix ships + +kind: playtest Β· owner: the driver of the rhachet release that ships the O3 fix Β· caught 2026-09-30 + +## .the cue that fired + +peer review i007 (behavior-intent lane) noted that the wish's done test 1 β€” β‰₯5 clones survive β‰₯2 refresh cycles on a real grove β€” sat only as a bullet in the execution yield's followups, with no tracked artifact to make it happen. + +## .the gap, precisely + +the fix is proven at contract grain by `blackbox/cli/enroll.shared-brain-auth.acceptance.test.ts` (one clone, a synthetic login) and at the lock grain by `withBrainAuthWriteLock.integration.test.ts`. no run has yet shown a real fleet, on real oauth, live through a refresh window with every clone still logged in. + +## .the shape of the fix + +1. release rhachet with the O3 fix; upgrade every tree on `grove-ahbode-v20260901` +2. respawn every crew, so each clone carries `CLAUDE_SECURESTORAGE_CONFIG_DIR=""` (a live clone keeps its old env until respawn) +3. confirm no actor brain dir holds a `.credentials.json` +4. leave β‰₯5 clones live across two access-token expiries (~8h each); after each, `rhx clone get` every crew and look for `Login expired` +5. record the result in the route's playtest stone, or reopen the wish if any clone was de-authed + +## .why it is not done in this round + +it needs the released build on the live grove and two real expiry windows β€” hours of wall time on a fleet this worktree does not drive. the N-process lock clamp that would move part of it into ci is its own dream: `v2026_09_29.feat.clamp-n-real-refreshers-on-one-shared-login.md`. diff --git a/blackbox/.test/infra/invokeRhachetCliBinary.ts b/blackbox/.test/infra/invokeRhachetCliBinary.ts index 356e5de0..45951e72 100644 --- a/blackbox/.test/infra/invokeRhachetCliBinary.ts +++ b/blackbox/.test/infra/invokeRhachetCliBinary.ts @@ -438,10 +438,10 @@ export const invokeRhachetCliBinaryChain = (input: { /** * .what = asSnapshotSafe, minus the one enroll line that depends on the host's claude login - * .why = enroll prints `β„Ή no claude credential to link …` only on a host with no login, so - * a snapshot of enroll stderr differs between a dev box and ci. the line is its own + * .why = enroll prints `β„Ή no claude login at …` only on a host with no login, so a + * snapshot of enroll stderr differs between a dev box and ci. the line is its own * concern, clamped where the journey pins a HOME with no credential; elsewhere it is * host noise */ export const asSnapshotSafeOfHostLogin = (output: string): string => - asSnapshotSafe(output).replace(/^β„Ή no claude credential to link into .*\n?/gm, ''); + asSnapshotSafe(output).replace(/^β„Ή no claude login at .*\n?/gm, ''); diff --git a/blackbox/cli/__snapshots__/brain-dir-boot.journey.acceptance.test.ts.snap b/blackbox/cli/__snapshots__/brain-dir-boot.journey.acceptance.test.ts.snap index d9ec2ef0..7950a435 100644 --- a/blackbox/cli/__snapshots__/brain-dir-boot.journey.acceptance.test.ts.snap +++ b/blackbox/cli/__snapshots__/brain-dir-boot.journey.acceptance.test.ts.snap @@ -85,7 +85,7 @@ exports[`brain dir boot journey (acceptance, real haiku) given: [case1] a repo w exports[`brain dir boot journey (acceptance, real haiku) given: [case1] a repo with native roles shaper, glasser, sander and a package role waxer when: [t3.2] an unattended enroll of sander under a HOME with no credential then: an absent credential is no refusal β€” enroll spawns the brain (D13) 1`] = `"{"outcome":"baked","serial":"__SERIAL__","slug":null,"socketEligible":true}"`; -exports[`brain dir boot journey (acceptance, real haiku) given: [case1] a repo with native roles shaper, glasser, sander and a package role waxer when: [t3.2] an unattended enroll of sander under a HOME with no credential then: stderr names the brain dir and both fixes 1`] = `"β„Ή no claude credential to link into /TMP_TEST_DIR/.agent/.actors/actor.via.hash=6fdfcc44/brain/.claude β€” run /login inside the clone, or set ANTHROPIC_API_KEY"`; +exports[`brain dir boot journey (acceptance, real haiku) given: [case1] a repo with native roles shaper, glasser, sander and a package role waxer when: [t3.2] an unattended enroll of sander under a HOME with no credential then: stderr names the shared login path and both fixes 1`] = `"β„Ή no claude login at $HOME/.claude/.credentials.json β€” run /login inside the clone, or set ANTHROPIC_API_KEY"`; exports[`brain dir boot journey (acceptance, real haiku) given: [case1] a repo with native roles shaper, glasser, sander and a package role waxer when: [t3] rhx enroll claude --roles shaper, real cli, temp HOME then: the enroll handoff line is locked 1`] = `"{"outcome":"baked","serial":"__SERIAL__","slug":null,"socketEligible":true}"`; @@ -159,6 +159,7 @@ exports[`brain dir boot journey (acceptance, real haiku) given: [case1] a repo w }, "cache_creation_input_tokens": "__NUMBER__", "cache_read_input_tokens": "__NUMBER__", + "fallback_credit": null, "inference_geo": "__STRING__", "input_tokens": "__NUMBER__", "iterations": "__ARRAY__", diff --git a/blackbox/cli/__snapshots__/enroll.shared-brain-auth.acceptance.test.ts.snap b/blackbox/cli/__snapshots__/enroll.shared-brain-auth.acceptance.test.ts.snap new file mode 100644 index 00000000..ebef1114 --- /dev/null +++ b/blackbox/cli/__snapshots__/enroll.shared-brain-auth.acceptance.test.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`rhx enroll vs a real claude-code: one shared login store given: [case1] a HOME whose ~/.claude holds an expired login, and no env credential when: [t1] a second clone is enrolled against the now-dead shared login then: the refusal a human reads is locked to a snapshot 1`] = ` +" +βœ‹ ConstraintError: the box's claude login is dead + +{ + "hint": "run /login in any claude on this box (or \`rhx git.grove.auth\` on a grove); every live clone recovers on the refill, with no respawn. or set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY for this clone", + "brainAuthPath": "/PATH_STRIPPED" +} + +" +`; diff --git a/blackbox/cli/brain-dir-boot.journey.acceptance.test.ts b/blackbox/cli/brain-dir-boot.journey.acceptance.test.ts index 6a5e4b6e..3b98c4ca 100644 --- a/blackbox/cli/brain-dir-boot.journey.acceptance.test.ts +++ b/blackbox/cli/brain-dir-boot.journey.acceptance.test.ts @@ -769,16 +769,34 @@ describe('brain dir boot journey (acceptance, real haiku)', () => { expect(result.status).not.toEqual(2); }); - then('stderr names the brain dir and both fixes', () => { + then('stderr names the shared login path and both fixes', () => { const line = result.stderr .split('\n') - .find((row) => row.includes('no claude credential to link')) ?? ''; + .find((row) => row.includes('no claude login at')) ?? ''; expect(line).toContain('/login'); expect(line).toContain('ANTHROPIC_API_KEY'); - const brainDir = line.match(/link into (\S+) β€”/)?.[1] ?? ''; - expect(existsSync(join(brainDir, '.credentials.json'))).toBe(false); - expect(asSnapshotSafe(line)).toMatchSnapshot(); + const brainAuthPath = line.match(/login at (\S+) β€”/)?.[1] ?? ''; + expect(brainAuthPath).toMatch(/\/\.claude\/\.credentials\.json$/); + expect(existsSync(brainAuthPath)).toBe(false); + // .note = not asSnapshotSafe: it strips this path to `/PATH_STRIPPED`, which + // hides which file the line names; the one masked span is the temp HOME + expect( + line.replace( + /\S+\/\.claude\/\.credentials\.json/, + '$HOME/.claude/.credentials.json', + ), + ).toMatchSnapshot(); + }); + + then('no actor brain dir holds a login of its own β€” every clone shares ~/.claude', () => { + // .note = lstat, not exists: a 1.48.0 symlink to an absent login still counts + const actorsDir = join(scene.dir, '.agent', '.actors'); + const brainDirLogins = readdirSync(actorsDir) + .filter((name) => name.startsWith('actor.via.')) + .map((name) => join(actorsDir, name, 'brain', '.claude', '.credentials.json')) + .filter((path) => lstatSync(path, { throwIfNoEntry: false }) !== undefined); + expect(brainDirLogins).toEqual([]); }); }); diff --git a/blackbox/cli/enroll.shared-brain-auth.acceptance.test.ts b/blackbox/cli/enroll.shared-brain-auth.acceptance.test.ts new file mode 100644 index 00000000..b6401d8e --- /dev/null +++ b/blackbox/cli/enroll.shared-brain-auth.acceptance.test.ts @@ -0,0 +1,319 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { delimiter, join } from 'node:path'; +import { genTempDir, given, then, useBeforeAll, when } from 'test-fns'; + +import { envIsolated } from '@/blackbox/.test/infra/envIsolated'; +import { getCachedClaudeCliBin } from '@/blackbox/.test/infra/genIsolatedClaudeHome'; +import { + asSnapshotSafe, + invokeRhachetCliBinary, +} from '@/blackbox/.test/infra/invokeRhachetCliBinary'; +import { setupRoleFixtureRepo } from '@/blackbox/.test/infra/roleFixtureRepo'; + +jest.setTimeout(300_000); + +/** + * .what = the regression clamp for the shared-credential blank: an enrolled REAL claude-code + * refreshes its login in the shared ~/.claude store, never in its actor's brain dir + * .why = + * - every clone of every actor must read and refresh ONE login under ONE lock set. a + * per-actor store (or a per-actor lock set over one symlinked file, as in 1.48.0) lets + * one clone's refresh spend the token a peer then posts, and claude-code's dead-token + * clear blanks the login for the whole box + * - the store claude-code writes to is decided by CLAUDE_SECURESTORAGE_CONFIG_DIR, which + * rhachet sets to '' at spawn. this test proves the var reaches a real claude through + * the real enroll path, by where claude's own write lands + * + * .how = a fake HOME holds an EXPIRED fake login. claude-code refreshes it, the server + * rejects the fake refresh token (invalid_grant), and claude-code clears the login in the + * store it keys by its secure-storage dir. where that blank lands = where every write lands + * + * .note = no real credential is read, planted, or sent: every token here is a fake this + * test made. a login is only ever reported as planted | blank | absent | other + * .note = if this clamp goes red, the env var no longer routes claude-code's store to + * ~/.claude (a claude-code change, or a spawn path that drops the var). the documented + * fallback is O10 β€” adopt the winner, then relink: + * .behavior/v2026_09_29.fix-shared-brain-credential-blanking/refs/inventory.of=options.case=O10-adopt-the-winner.md + */ + +// a fake login whose access token has expired, so the first turn must refresh it +const FAKE_LOGIN_EXPIRED = { + claudeAiOauth: { + accessToken: 'sk-ant-oat01-rhachet-test-fake-access', + refreshToken: 'sk-ant-ort01-rhachet-test-fake-refresh', + expiresAt: Date.now() - 60 * 60 * 1000, + refreshTokenExpiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, + scopes: ['user:inference', 'user:profile'], + subscriptionType: 'max', + }, +}; + +// a fake login whose access token is still live, as a 1.48.0 refresh winner would hold +const FAKE_LOGIN_LIVE = { + claudeAiOauth: { + ...FAKE_LOGIN_EXPIRED.claudeAiOauth, + accessToken: 'sk-ant-oat01-rhachet-test-fake-access-live', + refreshToken: 'sk-ant-ort01-rhachet-test-fake-refresh-live', + expiresAt: Date.now() + 60 * 60 * 1000, + }, +}; + +// a fake env credential; the server rejects it, which is all a fake can prove +const FAKE_ENV_TOKEN = 'sk-ant-oat01-rhachet-test-fake-env'; + +/** + * .what = the state of a login file, in words that never carry a token + */ +const asLoginState = (input: { + path: string; +}): 'absent' | 'planted' | 'blank' | 'other' => { + if (!existsSync(input.path)) return 'absent'; + const parsed: { + claudeAiOauth?: { refreshToken?: unknown }; + } = JSON.parse(readFileSync(input.path, 'utf-8')); + const refreshToken = parsed.claudeAiOauth?.refreshToken; + if (refreshToken === '') return 'blank'; + if (refreshToken === FAKE_LOGIN_EXPIRED.claudeAiOauth.refreshToken) return 'planted'; + return 'other'; +}; + +describe('rhx enroll vs a real claude-code: one shared login store', () => { + given('[case1] a HOME whose ~/.claude holds an expired login, and no env credential', () => { + const scene = useBeforeAll(async () => { + const { binDir } = getCachedClaudeCliBin(); + + // the fake HOME: an expired login in ~/.claude, and a first-run state already settled + const home = genTempDir({ slug: 'enroll-shared-auth-home' }); + mkdirSync(join(home, '.claude'), { recursive: true }); + const loginShared = join(home, '.claude', '.credentials.json'); + writeFileSync(loginShared, JSON.stringify(FAKE_LOGIN_EXPIRED), { mode: 0o600 }); + writeFileSync( + join(home, '.claude.json'), + `${JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }, null, 2)}\n`, + ); + + // a decoy the caller points the store at β€” rhachet must override it (F8) + const decoy = genTempDir({ slug: 'enroll-shared-auth-decoy' }); + + // the env: no credential of any kind, the cached claude-code first on PATH + const env = { + ...envIsolated(home), + PATH: `${binDir}${delimiter}${process.env.PATH ?? ''}`, + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_AUTH_TOKEN: undefined, + CLAUDE_CODE_OAUTH_TOKEN: undefined, + CLAUDE_CONFIG_DIR: undefined, + CLAUDE_SECURESTORAGE_CONFIG_DIR: decoy, + }; + + // a repo with roles linked, so enroll has a roleset to render + const dir = genTempDir({ slug: 'enroll-shared-auth-repo' }); + setupRoleFixtureRepo({ dir }); + invokeRhachetCliBinary({ + args: ['init', '--roles', 'mechanic'], + cwd: dir, + env, + }); + + return { home, loginShared, decoy, dir, env }; + }); + + when('[t0] a print-mode clone is enrolled and must refresh its login', () => { + const run = useBeforeAll(async () => { + const enrolled = invokeRhachetCliBinary({ + args: ['enroll', 'claude', '--model', 'haiku', '--await', '-p', 'reply ok'], + cwd: scene.dir, + env: scene.env, + timeoutMs: 180_000, + logOnError: false, + }); + + // the one brain dir the enroll prepared for its actor + const actorsDir = join(scene.dir, '.agent', '.actors'); + const actorDirNames = readdirSync(actorsDir).filter((name) => + name.startsWith('actor.via.hash='), + ); + const brainDirs = actorDirNames.map((name) => + join(actorsDir, name, 'brain', '.claude'), + ); + return { enrolled, brainDirs }; + }); + + then('enroll prepared exactly one actor brain dir', () => { + expect(run.brainDirs).toHaveLength(1); + }); + + then("claude-code's refresh wrote to the shared ~/.claude login (O10 is the fallback if red)", () => { + // the fake refresh token is rejected, so claude-code clears the login it keys by its + // secure-storage dir. a blank HERE proves that dir is ~/.claude + expect(asLoginState({ path: scene.loginShared })).toEqual('blank'); + }); + + then('the actor brain dir holds no login of its own', () => { + expect(existsSync(join(run.brainDirs[0]!, '.credentials.json'))).toBe(false); + }); + + then('the actor brain dir still holds its own config', () => { + // config stays per actor: only the login store is shared + expect(existsSync(join(run.brainDirs[0]!, '.claude.json'))).toBe(true); + }); + + then("a caller's own secure-storage dir is overridden, never written (F8)", () => { + expect(existsSync(join(scene.decoy, '.credentials.json'))).toBe(false); + }); + }); + + when('[t1] a second clone is enrolled against the now-dead shared login', () => { + const run = useBeforeAll(async () => { + const clonesBefore = asCloneDirNames({ dir: scene.dir }); + const enrolled = invokeRhachetCliBinary({ + args: ['enroll', 'claude', '--model', 'haiku', '--await', '-p', 'reply ok'], + cwd: scene.dir, + env: scene.env, + timeoutMs: 180_000, + logOnError: false, + }); + const clonesAfter = asCloneDirNames({ dir: scene.dir }); + return { enrolled, clonesBefore, clonesAfter }; + }); + + then('enroll refuses with a constraint exit', () => { + expect(run.enrolled.status).toEqual(2); + }); + + then('the refusal names the dead login and the /login cure', () => { + const output = `${run.enrolled.stdout}\n${run.enrolled.stderr}`; + expect(output).toContain("the box's claude login is dead"); + expect(output).toContain('/login'); + }); + + then('no clone was spawned', () => { + expect(run.clonesAfter).toEqual(run.clonesBefore); + }); + + then('the refusal a human reads is locked to a snapshot', () => { + expect(asSnapshotSafe(run.enrolled.stderr)).toMatchSnapshot(); + }); + }); + + when('[t2] a machine enrolls against the dead login, via --async and --output json', () => { + const run = useBeforeAll(async () => { + const clonesBefore = asCloneDirNames({ dir: scene.dir }); + const enrolled = invokeRhachetCliBinary({ + args: ['enroll', 'claude', '--model', 'haiku', '--async', '--output', 'json', '-p', 'reply ok'], + cwd: scene.dir, + env: scene.env, + timeoutMs: 180_000, + logOnError: false, + }); + const clonesAfter = asCloneDirNames({ dir: scene.dir }); + return { enrolled, clonesBefore, clonesAfter }; + }); + + then('the caller gets the constraint exit, relayed from the detached host', () => { + expect(run.enrolled.status).toEqual(2); + }); + + then('the refusal names the dead login', () => { + const output = `${run.enrolled.stdout}\n${run.enrolled.stderr}`; + expect(output).toContain("the box's claude login is dead"); + }); + + then('no clone was spawned', () => { + expect(run.clonesAfter).toEqual(run.clonesBefore); + }); + }); + + when('[t3] a clone is enrolled against the dead login, with an env credential set', () => { + const run = useBeforeAll(async () => { + const clonesBefore = asCloneDirNames({ dir: scene.dir }); + const enrolled = invokeRhachetCliBinary({ + args: ['enroll', 'claude', '--model', 'haiku', '--await', '-p', 'reply ok'], + cwd: scene.dir, + env: { ...scene.env, CLAUDE_CODE_OAUTH_TOKEN: FAKE_ENV_TOKEN }, + timeoutMs: 180_000, + logOnError: false, + }); + const clonesAfter = asCloneDirNames({ dir: scene.dir }); + return { enrolled, clonesBefore, clonesAfter }; + }); + + then('enroll does not refuse on the dead login', () => { + // claude-code prefers the env credential, so the dead file is no reason to refuse. + // the fake token itself is rejected by the server, so only the refusal is asserted + const output = `${run.enrolled.stdout}\n${run.enrolled.stderr}`; + expect(output).not.toContain("the box's claude login is dead"); + }); + + then('a clone was spawned', () => { + expect(run.clonesAfter.length).toEqual(run.clonesBefore.length + 1); + }); + + then('the dead shared login is left as it was', () => { + expect(asLoginState({ path: scene.loginShared })).toEqual('blank'); + }); + }); + + when('[t4] the actor brain dir holds a live 1.48.0 login, and no clone of it lives', () => { + const run = useBeforeAll(async () => { + // the 1.48.0 leftover: a live login of the actor's own, beside a dead shared one + const actorsDir = join(scene.dir, '.agent', '.actors'); + const actorDirName = readdirSync(actorsDir).find((name) => + name.startsWith('actor.via.hash='), + )!; + const brainDirAuthPath = join( + actorsDir, + actorDirName, + 'brain', + '.claude', + '.credentials.json', + ); + writeFileSync(brainDirAuthPath, JSON.stringify(FAKE_LOGIN_LIVE), { mode: 0o600 }); + + const clonesBefore = asCloneDirNames({ dir: scene.dir }); + const enrolled = invokeRhachetCliBinary({ + args: ['enroll', 'claude', '--model', 'haiku', '--await', '-p', 'reply ok'], + cwd: scene.dir, + env: scene.env, + timeoutMs: 180_000, + logOnError: false, + }); + const clonesAfter = asCloneDirNames({ dir: scene.dir }); + return { enrolled, clonesBefore, clonesAfter, brainDirAuthPath }; + }); + + then('enroll adopts the live login into the shared store, and says so', () => { + expect(run.enrolled.stderr).toContain('adopted the later login from'); + }); + + then('the adoption revives the box: enroll does not refuse on the dead login', () => { + // the migration runs before the dead-login check, so a fleet heals with no /login + const output = `${run.enrolled.stdout}\n${run.enrolled.stderr}`; + expect(output).not.toContain("the box's claude login is dead"); + }); + + then('a clone was spawned', () => { + expect(run.clonesAfter.length).toEqual(run.clonesBefore.length + 1); + }); + + then('the actor brain dir holds no login of its own', () => { + expect(existsSync(run.brainDirAuthPath)).toBe(false); + }); + }); + }); +}); + +/** + * .what = the clone dirs on disk across every actor of a repo + */ +const asCloneDirNames = (input: { dir: string }): string[] => { + const actorsDir = join(input.dir, '.agent', '.actors'); + return readdirSync(actorsDir) + .filter((name) => name.startsWith('actor.via.hash=')) + .flatMap((name) => { + const clonesDir = join(actorsDir, name, 'clones'); + if (!existsSync(clonesDir)) return []; + return readdirSync(clonesDir).map((clone) => `${name}/${clone}`); + }) + .sort(); +}; diff --git a/package.json b/package.json index a7fdba14..c8f94d78 100644 --- a/package.json +++ b/package.json @@ -171,13 +171,13 @@ "openai": "5.8.2", "rhachet": "link:.", "rhachet-brains-anthropic": "0.4.3", - "rhachet-brains-fireworksai": "0.1.7", + "rhachet-brains-fireworksai": "0.2.3", "rhachet-brains-openai": "0.3.1", "rhachet-brains-xai": "0.3.3", - "rhachet-roles-bhrain": "0.37.0", + "rhachet-roles-bhrain": "0.38.0", "rhachet-roles-bhrowser": "0.1.0", "rhachet-roles-bhuild": "0.21.36", - "rhachet-roles-ehmpathy": "1.39.0", + "rhachet-roles-ehmpathy": "1.39.1", "rhachet-roles-ghlitch": "0.3.0", "rhachet-roles-rhachet": "0.1.7", "test-fns": "1.17.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7204a07..e0934af6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,8 +181,8 @@ importers: specifier: 0.4.3 version: 0.4.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@) rhachet-brains-fireworksai: - specifier: 0.1.7 - version: 0.1.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@) + specifier: 0.2.3 + version: 0.2.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@) rhachet-brains-openai: specifier: 0.3.1 version: 0.3.1(@huggingface/transformers@3.8.1)(@openai/codex-sdk@0.77.0)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@) @@ -190,17 +190,17 @@ importers: specifier: 0.3.3 version: 0.3.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@) rhachet-roles-bhrain: - specifier: 0.37.0 - version: 0.37.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-fireworksai@0.1.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet@) + specifier: 0.38.0 + version: 0.38.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-fireworksai@0.2.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet@) rhachet-roles-bhrowser: specifier: 0.1.0 version: 0.1.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@) rhachet-roles-bhuild: specifier: 0.21.36 - version: 0.21.36(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet-brains-fireworksai@0.1.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet-brains-xai@0.3.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet-roles-bhrain@0.37.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-fireworksai@0.1.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet@))(rhachet@) + version: 0.21.36(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet-brains-fireworksai@0.2.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet-brains-xai@0.3.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet-roles-bhrain@0.38.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-fireworksai@0.2.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet@))(rhachet@) rhachet-roles-ehmpathy: - specifier: 1.39.0 - version: 1.39.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)) + specifier: 1.39.1 + version: 1.39.1(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)) rhachet-roles-ghlitch: specifier: 0.3.0 version: 0.3.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)) @@ -4759,8 +4759,8 @@ packages: peerDependencies: rhachet: '>=1.21.4' - rhachet-brains-fireworksai@0.1.7: - resolution: {integrity: sha512-XGOqGASiaw2kyy6WZaKuorQN+3h/YYv+K0TbpzZmJwte/PKRaiaK1gbpJnCQfHSEjnDSKj7x+zAybOHP4rtgIQ==} + rhachet-brains-fireworksai@0.2.3: + resolution: {integrity: sha512-OPcchqbrqeDzIQz2YYMGU1AeHqcPb9OWbl3WoI9qI3VW7cOILt6TL5ZWs54dxh19e6BNpuLbFUZ8/asP+9pq0A==} engines: {node: '>=8.0.0'} peerDependencies: rhachet: '>=1.21.4' @@ -4778,8 +4778,8 @@ packages: peerDependencies: rhachet: '>=1.21.4' - rhachet-roles-bhrain@0.37.0: - resolution: {integrity: sha512-PaGWb7l6Beq13vUg5eSoQ/0MWi79ybLP1eWgGrMQjLMUQYjE+8mdXM+N0E0nsIYMMK4a67OzTqVUm1oXNF8dgA==} + rhachet-roles-bhrain@0.38.0: + resolution: {integrity: sha512-yu7mnidXaWOQT9QGaPBSfrlKs60sDRptTR3D/6sr6fotDcNIF9Kaf25Iu8QE+IY882+H0KwbGZj6z+cDoR42jQ==} engines: {node: '>=8.0.0'} peerDependencies: rhachet: '>=1.41.21' @@ -4798,8 +4798,8 @@ packages: rhachet-brains-xai: '>=0.3.3' rhachet-roles-bhrain: '>=0.30.4' - rhachet-roles-ehmpathy@1.39.0: - resolution: {integrity: sha512-wX3bVNzrvctCOSh7CVAGRGOV54YNI82UCBiJYJw97MkFi8qbGmRBIMb5BMssL6lWgcfzaQwSUhALjkshg36CoA==} + rhachet-roles-ehmpathy@1.39.1: + resolution: {integrity: sha512-PS4BLciWYidblUOf3eB23h12RexYq650hYI9/2mc/dnBVUVdqZMa2y96vBWrXsvSXmJo92pa2ewIi1Nr5lMZhQ==} engines: {node: '>=8.0.0'} rhachet-roles-ghlitch@0.3.0: @@ -10333,7 +10333,7 @@ snapshots: helpful-errors: 1.5.3 joi: 17.4.0 rhachet: 1.44.4(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(zod@4.3.4) - rhachet-roles-ehmpathy: 1.39.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)) + rhachet-roles-ehmpathy: 1.39.1(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)) type-fns: 1.21.0 uuid-fns: 1.1.3 transitivePeerDependencies: @@ -12060,7 +12060,7 @@ snapshots: - '@tensorflow/tfjs' - aws-crt - rhachet-brains-fireworksai@0.1.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@): + rhachet-brains-fireworksai@0.2.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@): dependencies: domain-objects: 0.31.9 helpful-errors: 1.5.3 @@ -12112,7 +12112,7 @@ snapshots: - aws-crt - ws - rhachet-roles-bhrain@0.37.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-fireworksai@0.1.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet@): + rhachet-roles-bhrain@0.38.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-fireworksai@0.2.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet@): dependencies: '@ehmpathy/as-command': 1.0.3 '@ehmpathy/uni-time': 1.8.1 @@ -12132,7 +12132,7 @@ snapshots: rhachet: 'link:' rhachet-artifact: 1.0.3 rhachet-artifact-git: 1.1.5 - rhachet-brains-fireworksai: 0.1.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@) + rhachet-brains-fireworksai: 0.2.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@) serde-fns: 1.2.0 simple-in-memory-cache: 0.4.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)) type-fns: 1.21.0 @@ -12166,23 +12166,23 @@ snapshots: - rhachet - ws - rhachet-roles-bhuild@0.21.36(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet-brains-fireworksai@0.1.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet-brains-xai@0.3.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet-roles-bhrain@0.37.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-fireworksai@0.1.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet@))(rhachet@): + rhachet-roles-bhuild@0.21.36(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet-brains-fireworksai@0.2.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet-brains-xai@0.3.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet-roles-bhrain@0.38.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-fireworksai@0.2.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet@))(rhachet@): dependencies: domain-objects: 0.31.9 emoji-space-shim: 0.0.0 helpful-errors: 1.7.3 iso-time: 1.11.3 rhachet: 'link:' - rhachet-brains-fireworksai: 0.1.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@) + rhachet-brains-fireworksai: 0.2.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@) rhachet-brains-xai: 0.3.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@) - rhachet-roles-bhrain: 0.37.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-fireworksai@0.1.7(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet@) + rhachet-roles-bhrain: 0.38.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(@types/node@22.15.21)(rhachet-brains-fireworksai@0.2.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(rhachet@))(rhachet@) test-fns: 1.15.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)) zod: 4.3.4 transitivePeerDependencies: - '@huggingface/transformers' - '@tensorflow/tfjs' - rhachet-roles-ehmpathy@1.39.0(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)): + rhachet-roles-ehmpathy@1.39.1(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)): dependencies: '@atjsh/llmlingua-2': 2.0.3(@huggingface/transformers@3.8.1)(@tensorflow/tfjs@4.22.0(seedrandom@3.0.5))(js-tiktoken@1.0.21) '@ehmpathy/as-command': 1.0.5 diff --git a/src/.test/assets/genSampleBrainAuth.ts b/src/.test/assets/genSampleBrainAuth.ts new file mode 100644 index 00000000..525429db --- /dev/null +++ b/src/.test/assets/genSampleBrainAuth.ts @@ -0,0 +1,21 @@ +/** + * .what = the content of a FAKE claude login file (`.credentials.json`) + * .why = the login checks read this one shape; each test that plants a login builds it + * here, so a change to the shape reaches every consumer at once + * + * .note = every token is a fake; a test compares a login by its expiry or by the + * emptiness of its refresh token, never by a token value + * .note = `refreshToken: ''` is the shape claude-code's dead-token clear leaves behind + */ +export const genSampleBrainAuth = (input: { + state: 'live' | 'dead'; + expiresAt: number; +}): string => + JSON.stringify({ + claudeAiOauth: { + accessToken: input.state === 'live' ? 'fake-access' : '', + refreshToken: input.state === 'live' ? 'fake-refresh' : '', + expiresAt: input.expiresAt, + scopes: ['user:inference', 'user:profile'], + }, + }); diff --git a/src/contract/cli/invokeEnroll.ts b/src/contract/cli/invokeEnroll.ts index 7e9edd1b..30c5d207 100644 --- a/src/contract/cli/invokeEnroll.ts +++ b/src/contract/cli/invokeEnroll.ts @@ -5,14 +5,18 @@ import type { BrainCliEnrollmentSpec } from '@src/domain.objects/BrainCliEnrollm import type { BrainSlug } from '@src/domain.objects/BrainSlug'; import { ContextCli } from '@src/domain.objects/ContextCli'; import type { RoleSlug } from '@src/domain.objects/RoleSlug'; -import { asBrainCredentialAbsentLine } from '@src/domain.operations/actor/enrolled/asBrainCredentialAbsentLine'; -import { findsertBrainCredentialSymlink } from '@src/domain.operations/actor/enrolled/findsertBrainCredentialSymlink'; +import { asBrainDirAuthMigratedLine } from '@src/domain.operations/actor/enrolled/asBrainDirAuthMigratedLine'; import { findsertBrainFirstRunState } from '@src/domain.operations/actor/enrolled/findsertBrainFirstRunState'; import { genEnrollmentHash } from '@src/domain.operations/actor/enrolled/genEnrollmentHash'; import { getActorOndiskDir } from '@src/domain.operations/actor/enrolled/getActorOndiskDir'; import { getActorsRootDir } from '@src/domain.operations/actor/enrolled/getActorsRootDir'; import { getBrainOndiskDir } from '@src/domain.operations/actor/enrolled/getBrainOndiskDir'; +import { setBrainDirAuthMigrated } from '@src/domain.operations/actor/enrolled/setBrainDirAuthMigrated'; import { setBrainDirBoot } from '@src/domain.operations/boot/setBrainDirBoot'; +import { asBrainAuthAbsentLine } from '@src/domain.operations/brain/auth/asBrainAuthAbsentLine'; +import { asBrainAuthState } from '@src/domain.operations/brain/auth/asBrainAuthState'; +import { assertBrainAuthNotDead } from '@src/domain.operations/brain/auth/assertBrainAuthNotDead'; +import { getBrainAuthPath } from '@src/domain.operations/brain/auth/getBrainAuthPath'; import { getSupportedBrainCommand } from '@src/domain.operations/brain/getSupportedBrainCommand'; import { asCloneAccrualWarnLine } from '@src/domain.operations/clone/asCloneAccrualWarnLine'; import { asCloneAddressFromHandoff } from '@src/domain.operations/clone/asCloneAddressFromHandoff'; @@ -47,6 +51,7 @@ import { asRoleRefsForEnrolledSlugs } from '@src/domain.operations/init/boots/as import { getAllLinkedRoleRefs } from '@src/domain.operations/init/roles/link/getAllLinkedRoleRefs'; import { getDecodedRoleDeltaToken } from '@src/domain.operations/roles/deltas/getDecodedRoleDeltaToken'; import { getRoleDeltaTokens } from '@src/domain.operations/roles/deltas/getRoleDeltaTokens'; +import { getFileContentOrNull } from '@src/infra/filesystem/getFileContentOrNull'; import { getHomeDir } from '@src/infra/getHomeDir'; import { getOneRepoPath } from '@src/infra/host/getOneRepoPath'; import { CLONE_ACCRUAL_THRESHOLD } from '@src/utils/cloneAccrualThreshold'; @@ -403,11 +408,44 @@ const performEnroll = async (input: { scope: { kind: 'actor', actorHash: hash }, }); - // share the human's login, or say plainly that there is none to share (D13) - const credential = findsertBrainCredentialSymlink({ - brainDir, - home: getHomeDir(), + // observe the shared login every clone reads, to say plainly when there is none (D13) + // + // .note = no per-actor link is written: each clone spawns with + // CLAUDE_SECURESTORAGE_CONFIG_DIR='' (asBrainCliSpawnEnv), so it reads and refreshes + // ~/.claude/.credentials.json under ONE lock set with every peer on the box + const brainAuthPath = getBrainAuthPath({ home: getHomeDir() }); + + // retire the per-actor login a 1.48.0 enroll left here: kept while a clone of this + // actor lives (it may still read it), else removed β€” and adopted into the shared + // store first when the shared login is dead or absent. this runs BEFORE the + // dead-login check, because an adoption can revive a shared login that was cleared + const actorsRoot = getActorsRootDir({ repoPath }); + const migrated = await setBrainDirAuthMigrated( + { brainDir, brainAuthPath }, + { + getLiveCount: () => + getOneCloneLiveCountForActor({ + actorDir, + actorsRoot, + repoPath, + actorHash: hash, + }), + }, + ); + const migratedLine = asBrainDirAuthMigratedLine({ + ...migrated, + brainAuthPath, }); + if (migratedLine) console.error(migratedLine); + + // refuse a clone that would boot into a dead shared login, before any spawn (case 2) + // .note = this sits on the path a detached host re-enters, so an `--async` caller + // wears the same refusal and exit code the host reports + // .note = the login is read once, so the refusal and the absent line judge one state + const brainAuthContent = getFileContentOrNull({ path: brainAuthPath }); + assertBrainAuthNotDead({ brainAuthPath, brainAuthContent, env: process.env }); + const isBrainAuthAbsent = + asBrainAuthState({ content: brainAuthContent }) === 'absent'; // settle first-run state before any spawn, so no clone meets a prompt (D11) findsertBrainFirstRunState({ brainDir, repoPath, home: getHomeDir() }); @@ -440,8 +478,8 @@ const performEnroll = async (input: { // .note = a refused enroll (a slug collision) and a live-slug reuse spawn no brain, so // a "run /login inside the clone" hint there names a clone that never started β€” and // on a refusal it would land ahead of the json error a machine parses off stderr - if (credential.status === 'absent' && result.spawn !== null) - console.error(asBrainCredentialAbsentLine({ brainDir })); + if (isBrainAuthAbsent && result.spawn !== null) + console.error(asBrainAuthAbsentLine({ brainAuthPath })); // a live-slug reuse spawns no child β€” report it and return (no exit to forward). // a `--output json` caller (the idempotent-cron-retry path) still gets the @@ -478,7 +516,6 @@ const performEnroll = async (input: { // a bare create-always enroll can accrue billed brains β€” count the live clones // of this actor and, past the soft threshold, make the accrual visible - const actorsRoot = getActorsRootDir({ repoPath }); const liveCount = await getOneCloneLiveCountForActor({ actorDir, actorsRoot, diff --git a/src/domain.operations/actor/enrolled/__snapshots__/asBrainDirAuthMigratedLine.test.ts.snap b/src/domain.operations/actor/enrolled/__snapshots__/asBrainDirAuthMigratedLine.test.ts.snap new file mode 100644 index 00000000..66f5fde5 --- /dev/null +++ b/src/domain.operations/actor/enrolled/__snapshots__/asBrainDirAuthMigratedLine.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`asBrainDirAuthMigratedLine given: [case1] the login was kept for live clones when: [t0] the line is rendered then: it names the kept path, the live count and the respawn cure 1`] = `"β„Ή kept the pre-cure login at /r/.agent/.actors/actor.via.hash=abc/brain/.claude/.credentials.json β€” 2 clone(s) of this actor still live, and any spawned before the cure refresh it on their own locks; respawn them to close the race"`; + +exports[`asBrainDirAuthMigratedLine given: [case2] the login was adopted when: [t0] the line is rendered then: it names both paths 1`] = `"β„Ή adopted the later login from /r/.agent/.actors/actor.via.hash=abc/brain/.claude/.credentials.json into /home/h/.claude/.credentials.json"`; diff --git a/src/domain.operations/actor/enrolled/asBrainCredentialAbsentLine.test.ts b/src/domain.operations/actor/enrolled/asBrainCredentialAbsentLine.test.ts deleted file mode 100644 index ab90bdb2..00000000 --- a/src/domain.operations/actor/enrolled/asBrainCredentialAbsentLine.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { given, then, when } from 'test-fns'; - -import { asBrainCredentialAbsentLine } from './asBrainCredentialAbsentLine'; - -describe('asBrainCredentialAbsentLine', () => { - given('[case1] a brain dir with no credential to link', () => { - when('[t0] the line is rendered', () => { - then('it names the brain dir and both fixes', () => { - expect( - asBrainCredentialAbsentLine({ brainDir: '/a/brain/.claude' }), - ).toEqual( - 'β„Ή no claude credential to link into /a/brain/.claude β€” run /login inside the clone, or set ANTHROPIC_API_KEY', - ); - }); - }); - }); -}); diff --git a/src/domain.operations/actor/enrolled/asBrainCredentialAbsentLine.ts b/src/domain.operations/actor/enrolled/asBrainCredentialAbsentLine.ts deleted file mode 100644 index d1f1fe02..00000000 --- a/src/domain.operations/actor/enrolled/asBrainCredentialAbsentLine.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * .what = the one line enroll prints when the human holds no claude credential to link - * .why = an absent credential is no failure β€” the clone can log in on its own β€” but it is - * never silent: the line names the brain dir and both fixes (D13) - */ -export const asBrainCredentialAbsentLine = (input: { - brainDir: string; -}): string => - `β„Ή no claude credential to link into ${input.brainDir} β€” run /login inside the clone, or set ANTHROPIC_API_KEY`; diff --git a/src/domain.operations/actor/enrolled/asBrainDirAuthMigratedLine.test.ts b/src/domain.operations/actor/enrolled/asBrainDirAuthMigratedLine.test.ts new file mode 100644 index 00000000..e73fff0d --- /dev/null +++ b/src/domain.operations/actor/enrolled/asBrainDirAuthMigratedLine.test.ts @@ -0,0 +1,63 @@ +import { given, then, when } from 'test-fns'; + +import { asBrainDirAuthMigratedLine } from './asBrainDirAuthMigratedLine'; + +describe('asBrainDirAuthMigratedLine', () => { + const base = { + brainDirAuthPath: + '/r/.agent/.actors/actor.via.hash=abc/brain/.claude/.credentials.json', + brainAuthPath: '/home/h/.claude/.credentials.json', + liveCount: 2, + }; + + given('[case1] the login was kept for live clones', () => { + when('[t0] the line is rendered', () => { + then( + 'it names the kept path, the live count and the respawn cure', + () => { + const line = asBrainDirAuthMigratedLine({ ...base, outcome: 'kept' }); + expect(line).toContain(base.brainDirAuthPath); + expect(line).toContain('2 clone(s) of this actor still live'); + expect(line).toContain('respawn them'); + expect(line).toMatchSnapshot(); + }, + ); + }); + }); + + given('[case2] the login was adopted', () => { + when('[t0] the line is rendered', () => { + then('it names both paths', () => { + const line = asBrainDirAuthMigratedLine({ + ...base, + outcome: 'adopted', + liveCount: 0, + }); + expect(line).toContain(base.brainDirAuthPath); + expect(line).toContain(base.brainAuthPath); + expect(line).toMatchSnapshot(); + }); + }); + }); + + given('[case3] the login was removed, or there was none', () => { + when('[t0] the line is rendered', () => { + then('it says naught', () => { + expect( + asBrainDirAuthMigratedLine({ + ...base, + outcome: 'removed', + liveCount: 0, + }), + ).toBeNull(); + expect( + asBrainDirAuthMigratedLine({ + ...base, + outcome: 'none', + liveCount: 0, + }), + ).toBeNull(); + }); + }); + }); +}); diff --git a/src/domain.operations/actor/enrolled/asBrainDirAuthMigratedLine.ts b/src/domain.operations/actor/enrolled/asBrainDirAuthMigratedLine.ts new file mode 100644 index 00000000..f1646373 --- /dev/null +++ b/src/domain.operations/actor/enrolled/asBrainDirAuthMigratedLine.ts @@ -0,0 +1,20 @@ +/** + * .what = the one line enroll prints when it keeps or adopts a 1.48.0 brain-dir login + * .why = a kept login means the box still runs clones on the old per-actor lock set, + * so the race that blanks the shared login stays open until they are respawned. the + * human must learn that, and the cure, at the moment enroll sees it + * + * .note = a removal says naught: the leftover is gone and no clone depended on it + */ +export const asBrainDirAuthMigratedLine = (input: { + outcome: 'none' | 'kept' | 'removed' | 'adopted'; + brainDirAuthPath: string; + brainAuthPath: string; + liveCount: number; +}): string | null => { + if (input.outcome === 'kept') + return `β„Ή kept the pre-cure login at ${input.brainDirAuthPath} β€” ${input.liveCount} clone(s) of this actor still live, and any spawned before the cure refresh it on their own locks; respawn them to close the race`; + if (input.outcome === 'adopted') + return `β„Ή adopted the later login from ${input.brainDirAuthPath} into ${input.brainAuthPath}`; + return null; +}; diff --git a/src/domain.operations/actor/enrolled/findsertBrainCredentialSymlink.integration.test.ts b/src/domain.operations/actor/enrolled/findsertBrainCredentialSymlink.integration.test.ts deleted file mode 100644 index 31e01063..00000000 --- a/src/domain.operations/actor/enrolled/findsertBrainCredentialSymlink.integration.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { genTempDir, given, then, useBeforeAll, when } from 'test-fns'; - -import { - existsSync, - lstatSync, - mkdirSync, - readFileSync, - readlinkSync, - symlinkSync, - writeFileSync, -} from 'node:fs'; -import { join } from 'node:path'; -import { findsertBrainCredentialSymlink } from './findsertBrainCredentialSymlink'; - -/** - * .what = a temp home and an empty brain dir, with the human credential written or not - */ -const genScene = (input: { slug: string; withCredential: boolean }) => { - const dir = genTempDir({ slug: input.slug }); - const home = join(dir, 'home'); - const brainDir = join(dir, 'actor', 'brain', '.claude'); - mkdirSync(join(home, '.claude'), { recursive: true }); - mkdirSync(brainDir, { recursive: true }); - if (input.withCredential) - writeFileSync( - join(home, '.claude', '.credentials.json'), - '{"token":"human"}\n', - ); - return { home, brainDir, linkPath: join(brainDir, '.credentials.json') }; -}; - -describe('findsertBrainCredentialSymlink', () => { - given('[case1] a human credential and an empty brain dir', () => { - const scene = useBeforeAll(async () => - genScene({ slug: 'credential-link-fresh', withCredential: true }), - ); - - when('[t0] the symlink is findserted', () => { - then('an absolute symlink to the human credential is created', () => { - const result = findsertBrainCredentialSymlink({ - brainDir: scene.brainDir, - home: scene.home, - }); - expect(result.status).toEqual('linked'); - expect(lstatSync(scene.linkPath).isSymbolicLink()).toEqual(true); - expect(readlinkSync(scene.linkPath)).toEqual( - join(scene.home, '.claude', '.credentials.json'), - ); - }); - }); - - when('[t1] the symlink is findserted again', () => { - then('it is unchanged', () => { - const result = findsertBrainCredentialSymlink({ - brainDir: scene.brainDir, - home: scene.home, - }); - expect(result.status).toEqual('unchanged'); - expect(readlinkSync(scene.linkPath)).toEqual( - join(scene.home, '.claude', '.credentials.json'), - ); - }); - }); - }); - - given('[case2] no human credential', () => { - when('[t0] the symlink is findserted', () => { - then('no symlink is created, and the status is absent', () => { - const scene = genScene({ - slug: 'credential-link-absent', - withCredential: false, - }); - const result = findsertBrainCredentialSymlink({ - brainDir: scene.brainDir, - home: scene.home, - }); - expect(result.status).toEqual('absent'); - expect( - lstatSync(scene.linkPath, { throwIfNoEntry: false }), - ).toBeUndefined(); - }); - }); - }); - - given('[case3] a real credential already in the brain dir', () => { - when('[t0] the symlink is findserted', () => { - then('the real file is kept byte-equal', () => { - const scene = genScene({ - slug: 'credential-link-kept', - withCredential: true, - }); - writeFileSync(scene.linkPath, '{"token":"actor"}\n'); - const result = findsertBrainCredentialSymlink({ - brainDir: scene.brainDir, - home: scene.home, - }); - expect(result.status).toEqual('kept'); - expect(lstatSync(scene.linkPath).isSymbolicLink()).toEqual(false); - expect(readFileSync(scene.linkPath, 'utf-8')).toEqual( - '{"token":"actor"}\n', - ); - }); - }); - }); - - given('[case4] a symlink aimed at another file', () => { - when('[t0] the symlink is findserted', () => { - then('it is repointed at the human credential', () => { - const scene = genScene({ - slug: 'credential-link-stale', - withCredential: true, - }); - symlinkSync(join(scene.home, 'elsewhere.json'), scene.linkPath); - const result = findsertBrainCredentialSymlink({ - brainDir: scene.brainDir, - home: scene.home, - }); - expect(result.status).toEqual('linked'); - expect(readlinkSync(scene.linkPath)).toEqual( - join(scene.home, '.claude', '.credentials.json'), - ); - expect(existsSync(scene.linkPath)).toEqual(true); - }); - }); - }); -}); diff --git a/src/domain.operations/actor/enrolled/findsertBrainCredentialSymlink.ts b/src/domain.operations/actor/enrolled/findsertBrainCredentialSymlink.ts deleted file mode 100644 index c2f52d15..00000000 --- a/src/domain.operations/actor/enrolled/findsertBrainCredentialSymlink.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - existsSync, - lstatSync, - readlinkSync, - rmSync, - symlinkSync, -} from 'node:fs'; -import { join } from 'node:path'; - -/** - * .what = link the brain dir's `.credentials.json` to the human's, absolute - * .why = a relocated config dir holds no login of its own, so a clone would meet - * "Not logged in"; a symlink shares the human's login and follows its refresh - * .note = a real `.credentials.json` in the brain dir (a `/login` inside a clone) is - * kept β€” per-actor auth is intended. an absent human credential links no file: - * the caller names it, since an env key or api helper may still authenticate - */ -export const findsertBrainCredentialSymlink = (input: { - brainDir: string; - home: string; -}): { status: 'linked' | 'unchanged' | 'kept' | 'absent'; target: string } => { - const target = join(input.home, '.claude', '.credentials.json'); - const linkPath = join(input.brainDir, '.credentials.json'); - - // a real file in the brain dir is this actor's own login; keep it - const found = lstatSync(linkPath, { throwIfNoEntry: false }); - if (found && !found.isSymbolicLink()) return { status: 'kept', target }; - - // no human credential to share - if (!existsSync(target)) return { status: 'absent', target }; - - // a symlink already aimed at the target needs no write - if (found && readlinkSync(linkPath) === target) - return { status: 'unchanged', target }; - - // a symlink aimed elsewhere is replaced; an absent one is created - if (found) rmSync(linkPath); - symlinkSync(target, linkPath); - return { status: 'linked', target }; -}; diff --git a/src/domain.operations/actor/enrolled/getBrainDirAuthPath.test.ts b/src/domain.operations/actor/enrolled/getBrainDirAuthPath.test.ts new file mode 100644 index 00000000..08edb9f0 --- /dev/null +++ b/src/domain.operations/actor/enrolled/getBrainDirAuthPath.test.ts @@ -0,0 +1,17 @@ +import { given, then, when } from 'test-fns'; + +import { getBrainDirAuthPath } from './getBrainDirAuthPath'; + +describe('getBrainDirAuthPath', () => { + given('[case1] an actor brain dir', () => { + when('[t0] the brain-dir login path is computed', () => { + then('it is the credentials file directly in the brain dir', () => { + expect( + getBrainDirAuthPath({ + brainDir: '/repo/.agent/.actors/a/brain/.claude', + }), + ).toEqual('/repo/.agent/.actors/a/brain/.claude/.credentials.json'); + }); + }); + }); +}); diff --git a/src/domain.operations/actor/enrolled/getBrainDirAuthPath.ts b/src/domain.operations/actor/enrolled/getBrainDirAuthPath.ts new file mode 100644 index 00000000..1fa8fe60 --- /dev/null +++ b/src/domain.operations/actor/enrolled/getBrainDirAuthPath.ts @@ -0,0 +1,9 @@ +import { join } from 'node:path'; + +/** + * .what = the path of the login a 1.48.0 enroll left in an actor's brain dir + * .why = one owner of the brain-dir login layout, beside its peer for the shared + * login (getBrainAuthPath) + */ +export const getBrainDirAuthPath = (input: { brainDir: string }): string => + join(input.brainDir, '.credentials.json'); diff --git a/src/domain.operations/actor/enrolled/isBrainDirAuthAdoptable.test.ts b/src/domain.operations/actor/enrolled/isBrainDirAuthAdoptable.test.ts new file mode 100644 index 00000000..0dcaf5b6 --- /dev/null +++ b/src/domain.operations/actor/enrolled/isBrainDirAuthAdoptable.test.ts @@ -0,0 +1,66 @@ +import { given, then, when } from 'test-fns'; + +import { genSampleBrainAuth } from '@src/.test/assets/genSampleBrainAuth'; + +import { isBrainDirAuthAdoptable } from './isBrainDirAuthAdoptable'; + +const LOGIN_LIVE = genSampleBrainAuth({ state: 'live', expiresAt: 2000 }); +const LOGIN_LIVE_OLDER = genSampleBrainAuth({ state: 'live', expiresAt: 1000 }); +const LOGIN_DEAD = genSampleBrainAuth({ state: 'dead', expiresAt: 0 }); + +const TEST_CASES = [ + { + description: 'the shared login is dead, the brain-dir login is live', + brainAuthContent: LOGIN_DEAD, + brainDirAuthContent: LOGIN_LIVE, + expected: true, + }, + { + description: 'the shared login is absent, the brain-dir login is live', + brainAuthContent: null, + brainDirAuthContent: LOGIN_LIVE, + expected: true, + }, + { + description: + 'the shared login is live and older than the brain-dir login β€” a peer may refresh it', + brainAuthContent: LOGIN_LIVE_OLDER, + brainDirAuthContent: LOGIN_LIVE, + expected: false, + }, + { + description: 'the shared login is dead, the brain-dir login is dead too', + brainAuthContent: LOGIN_DEAD, + brainDirAuthContent: LOGIN_DEAD, + expected: false, + }, + { + description: 'the shared login is absent, the brain-dir login dangles', + brainAuthContent: null, + brainDirAuthContent: null, + expected: false, + }, + { + description: 'the shared login does not parse β€” claude-code judges it', + brainAuthContent: 'not json', + brainDirAuthContent: LOGIN_LIVE, + expected: false, + }, +] as const; + +describe('isBrainDirAuthAdoptable', () => { + TEST_CASES.map((thisCase) => + given(`[case] ${thisCase.description}`, () => { + when('[t0] the two logins are compared', () => { + then(`reads as ${thisCase.expected}`, () => { + expect( + isBrainDirAuthAdoptable({ + brainAuthContent: thisCase.brainAuthContent, + brainDirAuthContent: thisCase.brainDirAuthContent, + }), + ).toEqual(thisCase.expected); + }); + }); + }), + ); +}); diff --git a/src/domain.operations/actor/enrolled/isBrainDirAuthAdoptable.ts b/src/domain.operations/actor/enrolled/isBrainDirAuthAdoptable.ts new file mode 100644 index 00000000..b7bf3bff --- /dev/null +++ b/src/domain.operations/actor/enrolled/isBrainDirAuthAdoptable.ts @@ -0,0 +1,27 @@ +import { asBrainAuthState } from '@src/domain.operations/brain/auth/asBrainAuthState'; +import { isBrainAuthLive } from '@src/domain.operations/brain/auth/isBrainAuthLive'; + +/** + * .what = may a 1.48.0 brain-dir login be adopted into the shared ~/.claude login + * .why = a brain-dir login may be the last refresh winner, and so the only live token + * on the box. but the shared login is a file every live clone refreshes, under a lock + * enroll does not hold β€” so enroll may only write it when no clone can: + * - the shared login is DEAD (claude-code will not refresh a cleared token) or ABSENT + * - and the brain-dir login is LIVE (it holds a refresh token) + * a live shared login is never overwritten, even by a newer brain-dir one; a peer + * may refresh it in the same instant, and to clobber that write would de-auth the box + * + * .note = a 1.48.0 symlink to the shared login reads the shared content, so it is never + * adopted: when the shared login is dead, the link reads dead too + */ +export const isBrainDirAuthAdoptable = (input: { + brainDirAuthContent: string | null; + brainAuthContent: string | null; +}): boolean => { + // the shared login must be one no peer can refresh + const sharedState = asBrainAuthState({ content: input.brainAuthContent }); + if (sharedState === 'present') return false; + + // the brain-dir login must be able to recover itself + return isBrainAuthLive({ content: input.brainDirAuthContent }); +}; diff --git a/src/domain.operations/actor/enrolled/setBrainDirAuthMigrated.integration.test.ts b/src/domain.operations/actor/enrolled/setBrainDirAuthMigrated.integration.test.ts new file mode 100644 index 00000000..4d2730ba --- /dev/null +++ b/src/domain.operations/actor/enrolled/setBrainDirAuthMigrated.integration.test.ts @@ -0,0 +1,228 @@ +import { MalfunctionError } from 'helpful-errors'; +import { genTempDir, given, then, useBeforeAll, when } from 'test-fns'; + +import { genSampleBrainAuth } from '@src/.test/assets/genSampleBrainAuth'; + +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { setBrainDirAuthMigrated } from './setBrainDirAuthMigrated'; + +/** + * .what = the expiry a fake login records β€” how a test tells which login landed + */ +const readExpiresAt = (input: { path: string }): number => { + const parsed: { claudeAiOauth: { expiresAt: number } } = JSON.parse( + readFileSync(input.path, 'utf-8'), + ); + return parsed.claudeAiOauth.expiresAt; +}; + +/** + * .what = lay out a fake HOME login and a fake actor brain dir + */ +const genScene = (input: { slug: string }) => { + const home = genTempDir({ slug: `${input.slug}-home` }); + mkdirSync(join(home, '.claude'), { recursive: true }); + const brainAuthPath = join(home, '.claude', '.credentials.json'); + const brainDir = genTempDir({ slug: `${input.slug}-brain` }); + const brainDirAuthPath = join(brainDir, '.credentials.json'); + return { brainAuthPath, brainDir, brainDirAuthPath }; +}; + +describe('setBrainDirAuthMigrated', () => { + given('[case1] a brain dir with no login of its own', () => { + when('[t0] the migration runs', () => { + const scene = useBeforeAll(async () => { + const layout = genScene({ slug: 'migrate-none' }); + const result = await setBrainDirAuthMigrated( + { brainDir: layout.brainDir, brainAuthPath: layout.brainAuthPath }, + { + getLiveCount: () => { + throw new MalfunctionError( + 'the live probe must not run with no leftover', + { brainDir: layout.brainDir }, + ); + }, + }, + ); + return { ...layout, result }; + }); + + then('the outcome is none, and no live probe ran', () => { + expect(scene.result.outcome).toEqual('none'); + }); + }); + }); + + given('[case2] a 1.48.0 symlink to the shared login', () => { + when('[t0] a clone of the actor still lives', () => { + const scene = useBeforeAll(async () => { + const layout = genScene({ slug: 'migrate-link-live' }); + writeFileSync( + layout.brainAuthPath, + genSampleBrainAuth({ state: 'live', expiresAt: 5 }), + ); + symlinkSync(layout.brainAuthPath, layout.brainDirAuthPath); + const result = await setBrainDirAuthMigrated( + { brainDir: layout.brainDir, brainAuthPath: layout.brainAuthPath }, + { getLiveCount: async () => 3 }, + ); + return { ...layout, result }; + }); + + then('the symlink is kept, with the live count', () => { + expect(scene.result.outcome).toEqual('kept'); + expect(scene.result.liveCount).toEqual(3); + expect(lstatSync(scene.brainDirAuthPath).isSymbolicLink()).toBe(true); + }); + }); + + when('[t1] no clone of the actor lives', () => { + const scene = useBeforeAll(async () => { + const layout = genScene({ slug: 'migrate-link-dead' }); + writeFileSync( + layout.brainAuthPath, + genSampleBrainAuth({ state: 'live', expiresAt: 5 }), + ); + symlinkSync(layout.brainAuthPath, layout.brainDirAuthPath); + const result = await setBrainDirAuthMigrated( + { brainDir: layout.brainDir, brainAuthPath: layout.brainAuthPath }, + { getLiveCount: async () => 0 }, + ); + return { ...layout, result }; + }); + + then('the symlink is removed', () => { + expect(scene.result.outcome).toEqual('removed'); + expect(existsSync(scene.brainDirAuthPath)).toBe(false); + }); + + then('the shared login it pointed at is untouched', () => { + expect(readExpiresAt({ path: scene.brainAuthPath })).toEqual(5); + }); + }); + }); + + given('[case3] a live brain-dir login, and a dead shared one', () => { + when('[t0] no clone of the actor lives', () => { + const scene = useBeforeAll(async () => { + const layout = genScene({ slug: 'migrate-adopt' }); + writeFileSync( + layout.brainAuthPath, + genSampleBrainAuth({ state: 'dead', expiresAt: 0 }), + ); + writeFileSync( + layout.brainDirAuthPath, + genSampleBrainAuth({ state: 'live', expiresAt: 9000 }), + { mode: 0o600 }, + ); + const result = await setBrainDirAuthMigrated( + { brainDir: layout.brainDir, brainAuthPath: layout.brainAuthPath }, + { getLiveCount: async () => 0 }, + ); + return { ...layout, result }; + }); + + then('the login is adopted into the shared store', () => { + expect(scene.result.outcome).toEqual('adopted'); + expect(readExpiresAt({ path: scene.brainAuthPath })).toEqual(9000); + }); + + then('the shared login is mode 0600', () => { + expect(statSync(scene.brainAuthPath).mode & 0o777).toEqual(0o600); + }); + + then('the brain dir holds no login of its own', () => { + expect(existsSync(scene.brainDirAuthPath)).toBe(false); + }); + }); + }); + + given('[case4] a live brain-dir login older than the live shared one', () => { + when('[t0] no clone of the actor lives', () => { + const scene = useBeforeAll(async () => { + const layout = genScene({ slug: 'migrate-older' }); + writeFileSync( + layout.brainAuthPath, + genSampleBrainAuth({ state: 'live', expiresAt: 9000 }), + ); + writeFileSync( + layout.brainDirAuthPath, + genSampleBrainAuth({ state: 'live', expiresAt: 5 }), + ); + const result = await setBrainDirAuthMigrated( + { brainDir: layout.brainDir, brainAuthPath: layout.brainAuthPath }, + { getLiveCount: async () => 0 }, + ); + return { ...layout, result }; + }); + + then('it is removed, never adopted', () => { + expect(scene.result.outcome).toEqual('removed'); + expect(readExpiresAt({ path: scene.brainAuthPath })).toEqual(9000); + expect(existsSync(scene.brainDirAuthPath)).toBe(false); + }); + }); + }); + + given('[case5] a live brain-dir login NEWER than the live shared one', () => { + when('[t0] no clone of the actor lives', () => { + const scene = useBeforeAll(async () => { + const layout = genScene({ slug: 'migrate-newer-vs-live' }); + writeFileSync( + layout.brainAuthPath, + genSampleBrainAuth({ state: 'live', expiresAt: 5 }), + ); + writeFileSync( + layout.brainDirAuthPath, + genSampleBrainAuth({ state: 'live', expiresAt: 9000 }), + ); + const result = await setBrainDirAuthMigrated( + { brainDir: layout.brainDir, brainAuthPath: layout.brainAuthPath }, + { getLiveCount: async () => 0 }, + ); + return { ...layout, result }; + }); + + then( + 'it is removed, never adopted β€” a peer may refresh the live shared login', + () => { + expect(scene.result.outcome).toEqual('removed'); + expect(readExpiresAt({ path: scene.brainAuthPath })).toEqual(5); + expect(existsSync(scene.brainDirAuthPath)).toBe(false); + }, + ); + }); + }); + + given('[case6] a live brain-dir login, and no shared one', () => { + when('[t0] no clone of the actor lives', () => { + const scene = useBeforeAll(async () => { + const layout = genScene({ slug: 'migrate-adopt-absent' }); + writeFileSync( + layout.brainDirAuthPath, + genSampleBrainAuth({ state: 'live', expiresAt: 9000 }), + ); + const result = await setBrainDirAuthMigrated( + { brainDir: layout.brainDir, brainAuthPath: layout.brainAuthPath }, + { getLiveCount: async () => 0 }, + ); + return { ...layout, result }; + }); + + then('the login is adopted into the shared store', () => { + expect(scene.result.outcome).toEqual('adopted'); + expect(readExpiresAt({ path: scene.brainAuthPath })).toEqual(9000); + expect(existsSync(scene.brainDirAuthPath)).toBe(false); + }); + }); + }); +}); diff --git a/src/domain.operations/actor/enrolled/setBrainDirAuthMigrated.ts b/src/domain.operations/actor/enrolled/setBrainDirAuthMigrated.ts new file mode 100644 index 00000000..216afcca --- /dev/null +++ b/src/domain.operations/actor/enrolled/setBrainDirAuthMigrated.ts @@ -0,0 +1,68 @@ +import { withBrainAuthWriteLock } from '@src/domain.operations/brain/auth/withBrainAuthWriteLock'; +import { delFileSync } from '@src/infra/filesystem/delFileSync'; +import { getFileContentOrNull } from '@src/infra/filesystem/getFileContentOrNull'; +import { getFileStatOrNull } from '@src/infra/filesystem/getFileStatOrNull'; +import { setFileAtomic } from '@src/infra/setFileAtomic'; + +import { getBrainDirAuthPath } from './getBrainDirAuthPath'; +import { isBrainDirAuthAdoptable } from './isBrainDirAuthAdoptable'; + +/** + * .what = retire the per-actor login a 1.48.0 enroll left in an actor's brain dir + * .why = clones read the shared ~/.claude login; only a 1.48.0 clone still reads a + * brain-dir `.credentials.json`. so the file is kept while a clone of the actor + * lives, then removed β€” first adopted into ~/.claude only when the shared login + * is dead or absent (isBrainDirAuthAdoptable) + * + * .note = a symlink is removed as a link; its target β€” the shared login β€” is untouched + * .note = the shared login is read, judged, and written under claude-code's own write + * lock (withBrainAuthWriteLock), so a `/login`, a refresh, or a peer enroll cannot + * interleave between the judgment and the write + * .note = the brain-dir file is only read by 1.48.0 clones of this actor, and none lives + * past the probe, so the gap between the probe and the act touches no reader + * .note = never reads into a log, nor returns, a token value + */ +export const setBrainDirAuthMigrated = async ( + input: { brainDir: string; brainAuthPath: string }, + context: { getLiveCount: () => Promise }, +): Promise<{ + outcome: 'none' | 'kept' | 'removed' | 'adopted'; + brainDirAuthPath: string; + liveCount: number; +}> => { + const brainDirAuthPath = getBrainDirAuthPath({ brainDir: input.brainDir }); + + // lstat, so a dangled 1.48.0 symlink still counts as a leftover to retire + const stat = getFileStatOrNull({ path: brainDirAuthPath }); + if (!stat) return { outcome: 'none', brainDirAuthPath, liveCount: 0 }; + + // a live clone of the actor may still read this file β€” keep it until none lives + const liveCount = await context.getLiveCount(); + if (liveCount > 0) return { outcome: 'kept', brainDirAuthPath, liveCount }; + + // a live brain-dir login may be the box's only live token; adopt it under the lock + const brainDirAuthContent = getFileContentOrNull({ path: brainDirAuthPath }); + const isAdopted = await withBrainAuthWriteLock( + { brainAuthPath: input.brainAuthPath }, + () => { + const isAdoptable = isBrainDirAuthAdoptable({ + brainDirAuthContent, + brainAuthContent: getFileContentOrNull({ path: input.brainAuthPath }), + }); + if (!isAdoptable || brainDirAuthContent === null) return false; + setFileAtomic( + { path: input.brainAuthPath, content: brainDirAuthContent }, + { mode: 0o600 }, + ); + return true; + }, + ); + + // the brain dir holds no login of its own from here on + delFileSync({ path: brainDirAuthPath }); + return { + outcome: isAdopted ? 'adopted' : 'removed', + brainDirAuthPath, + liveCount, + }; +}; diff --git a/src/domain.operations/brain/auth/__snapshots__/asBrainAuthAbsentLine.test.ts.snap b/src/domain.operations/brain/auth/__snapshots__/asBrainAuthAbsentLine.test.ts.snap new file mode 100644 index 00000000..019757d3 --- /dev/null +++ b/src/domain.operations/brain/auth/__snapshots__/asBrainAuthAbsentLine.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`asBrainAuthAbsentLine given: [case1] a shared login path with no login in it when: [t0] the line is rendered then: it names the login path and both fixes 1`] = `"β„Ή no claude login at /home/h/.claude/.credentials.json β€” run /login inside the clone, or set ANTHROPIC_API_KEY"`; diff --git a/src/domain.operations/brain/auth/__snapshots__/assertBrainAuthNotDead.test.ts.snap b/src/domain.operations/brain/auth/__snapshots__/assertBrainAuthNotDead.test.ts.snap new file mode 100644 index 00000000..45ed1ebd --- /dev/null +++ b/src/domain.operations/brain/auth/__snapshots__/assertBrainAuthNotDead.test.ts.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`assertBrainAuthNotDead given: [case1] the shared login is dead when: [t0] no env credential is set then: it refuses with a ConstraintError that names /login 1`] = ` +"βœ‹ ConstraintError: the box's claude login is dead + +{ + "brainAuthPath": "/home/fake/.claude/.credentials.json", + "hint": "run /login in any claude on this box (or \`rhx git.grove.auth\` on a grove); every live clone recovers on the refill, with no respawn. or set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY for this clone" +}" +`; diff --git a/src/domain.operations/brain/auth/__snapshots__/withBrainAuthWriteLock.integration.test.ts.snap b/src/domain.operations/brain/auth/__snapshots__/withBrainAuthWriteLock.integration.test.ts.snap new file mode 100644 index 00000000..9ca21c08 --- /dev/null +++ b/src/domain.operations/brain/auth/__snapshots__/withBrainAuthWriteLock.integration.test.ts.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`withBrainAuthWriteLock given: [case7] a live holder that never releases when: [t0] a procedure asks for the lock past the wait then: the error names the lock and the fix 1`] = ` +"πŸ’₯ MalfunctionError: the claude login write lock stayed held; the shared login was not touched + +{ + "lockPath": "$LOCK_PATH", + "hint": "a live claude-code process holds the lock past the wait; retry the enroll. a lock dir older than 15s is stale, and is cleared on the next try" +}" +`; diff --git a/src/domain.operations/brain/auth/asBrainAuthAbsentLine.test.ts b/src/domain.operations/brain/auth/asBrainAuthAbsentLine.test.ts new file mode 100644 index 00000000..806c3876 --- /dev/null +++ b/src/domain.operations/brain/auth/asBrainAuthAbsentLine.test.ts @@ -0,0 +1,19 @@ +import { given, then, when } from 'test-fns'; + +import { asBrainAuthAbsentLine } from './asBrainAuthAbsentLine'; + +describe('asBrainAuthAbsentLine', () => { + given('[case1] a shared login path with no login in it', () => { + when('[t0] the line is rendered', () => { + then('it names the login path and both fixes', () => { + const line = asBrainAuthAbsentLine({ + brainAuthPath: '/home/h/.claude/.credentials.json', + }); + expect(line).toMatchSnapshot(); + expect(line).toEqual( + 'β„Ή no claude login at /home/h/.claude/.credentials.json β€” run /login inside the clone, or set ANTHROPIC_API_KEY', + ); + }); + }); + }); +}); diff --git a/src/domain.operations/brain/auth/asBrainAuthAbsentLine.ts b/src/domain.operations/brain/auth/asBrainAuthAbsentLine.ts new file mode 100644 index 00000000..8a3ad805 --- /dev/null +++ b/src/domain.operations/brain/auth/asBrainAuthAbsentLine.ts @@ -0,0 +1,10 @@ +/** + * .what = the one line enroll prints when the shared claude login is absent + * .why = an absent login is no failure β€” the clone can log in on its own, and its + * `/login` lands in the shared store every clone reads β€” but it is never + * silent: the line names the login path and both fixes (D13) + */ +export const asBrainAuthAbsentLine = (input: { + brainAuthPath: string; +}): string => + `β„Ή no claude login at ${input.brainAuthPath} β€” run /login inside the clone, or set ANTHROPIC_API_KEY`; diff --git a/src/domain.operations/brain/auth/asBrainAuthOauth.test.ts b/src/domain.operations/brain/auth/asBrainAuthOauth.test.ts new file mode 100644 index 00000000..f2a09f1d --- /dev/null +++ b/src/domain.operations/brain/auth/asBrainAuthOauth.test.ts @@ -0,0 +1,54 @@ +import { given, then, when } from 'test-fns'; + +import { genSampleBrainAuth } from '@src/.test/assets/genSampleBrainAuth'; + +import { asBrainAuthOauth } from './asBrainAuthOauth'; + +const TEST_CASES = [ + { description: 'no file', content: null, expected: null }, + { + description: 'a live login', + content: genSampleBrainAuth({ state: 'live', expiresAt: 1 }), + expected: { refreshToken: expect.any(String) }, + }, + { + description: 'a dead login (refresh token emptied)', + content: genSampleBrainAuth({ state: 'dead', expiresAt: 1 }), + expected: { refreshToken: '' }, + }, + { + description: 'an oauth block with no refresh token', + content: JSON.stringify({ claudeAiOauth: { accessToken: 'a' } }), + expected: { refreshToken: null }, + }, + { + description: 'a file with no oauth block', + content: JSON.stringify({ other: true }), + expected: null, + }, + { + description: 'an oauth block that is not an object', + content: JSON.stringify({ claudeAiOauth: 'x' }), + expected: null, + }, + { description: 'a json scalar', content: '42', expected: null }, + { + description: 'a file that is not json', + content: 'not json', + expected: null, + }, +] as const; + +describe('asBrainAuthOauth', () => { + TEST_CASES.map((thisCase) => + given(`[case] ${thisCase.description}`, () => { + when('[t0] the content is read', () => { + then('it yields the oauth block, or null', () => { + expect(asBrainAuthOauth({ content: thisCase.content })).toEqual( + thisCase.expected, + ); + }); + }); + }), + ); +}); diff --git a/src/domain.operations/brain/auth/asBrainAuthOauth.ts b/src/domain.operations/brain/auth/asBrainAuthOauth.ts new file mode 100644 index 00000000..46bd7418 --- /dev/null +++ b/src/domain.operations/brain/auth/asBrainAuthOauth.ts @@ -0,0 +1,27 @@ +import { asJsonParsedOrNull } from '@src/infra/json/asJsonParsedOrNull'; + +/** + * .what = the oauth block of a claude login file, or null when it carries none + * .why = the login readers each need the same narrow: parse the json, reach the + * `claudeAiOauth` object, and treat any other shape as "no oauth block". one reader + * of the shape keeps the two from drift + * + * .note = a file that does not parse reads as null, not as an error; the callers read + * an unknown shape as "not a login we can judge", and claude-code judges it itself + */ +export const asBrainAuthOauth = (input: { + content: string | null; +}): { refreshToken: unknown } | null => { + if (input.content === null) return null; + + // a file we cannot read as json carries no oauth block we can trust + const parsed = asJsonParsedOrNull({ content: input.content }); + if (!parsed || typeof parsed !== 'object') return null; + + // reach the oauth object; any other shape carries no login + const oauth = 'claudeAiOauth' in parsed ? parsed.claudeAiOauth : null; + if (!oauth || typeof oauth !== 'object') return null; + return { + refreshToken: 'refreshToken' in oauth ? oauth.refreshToken : null, + }; +}; diff --git a/src/domain.operations/brain/auth/asBrainAuthState.test.ts b/src/domain.operations/brain/auth/asBrainAuthState.test.ts new file mode 100644 index 00000000..e57df346 --- /dev/null +++ b/src/domain.operations/brain/auth/asBrainAuthState.test.ts @@ -0,0 +1,51 @@ +import { given, then, when } from 'test-fns'; + +import { genSampleBrainAuth } from '@src/.test/assets/genSampleBrainAuth'; + +import { asBrainAuthState } from './asBrainAuthState'; + +const TEST_CASES = [ + { description: 'no file', content: null, expected: 'absent' }, + { + description: 'a live login', + content: genSampleBrainAuth({ state: 'live', expiresAt: 1 }), + expected: 'present', + }, + { + description: 'the dead-token clear (secrets emptied, rest kept)', + content: JSON.stringify({ + claudeAiOauth: { + accessToken: '', + refreshToken: '', + expiresAt: 0, + refreshTokenExpiresAt: 9999999999999, + scopes: ['user:inference'], + }, + }), + expected: 'dead', + }, + { + description: 'a file with no oauth block', + content: '{}', + expected: 'present', + }, + { + description: 'a file that is not json', + content: 'not json', + expected: 'present', + }, +] as const; + +describe('asBrainAuthState', () => { + TEST_CASES.map((thisCase) => + given(`[case] ${thisCase.description}`, () => { + when('[t0] the content is read', () => { + then(`reads as ${thisCase.expected}`, () => { + expect(asBrainAuthState({ content: thisCase.content })).toEqual( + thisCase.expected, + ); + }); + }); + }), + ); +}); diff --git a/src/domain.operations/brain/auth/asBrainAuthState.ts b/src/domain.operations/brain/auth/asBrainAuthState.ts new file mode 100644 index 00000000..d0fdef08 --- /dev/null +++ b/src/domain.operations/brain/auth/asBrainAuthState.ts @@ -0,0 +1,24 @@ +import { asBrainAuthOauth } from './asBrainAuthOauth'; + +/** + * .what = the state of a claude login file, read from its content alone + * .why = enroll must tell a DEAD login from a live one before it spawns a clone that + * would only meet `Login expired`. claude-code's dead-token clear leaves the file in + * place with its secrets emptied (`refreshToken: ""`), so presence alone proves naught + * + * .note = a file that does not parse, or carries no oauth block, reads as `present`: + * enroll only refuses the one shape it can name for certain, and claude-code itself + * reports any other fault in the clone + * .note = never returns, logs, or compares a token value beyond its emptiness + */ +export const asBrainAuthState = (input: { + content: string | null; +}): 'absent' | 'dead' | 'present' => { + // no file at all + if (input.content === null) return 'absent'; + + // the dead-token clear empties the refresh token and keeps the rest + const oauth = asBrainAuthOauth({ content: input.content }); + if (oauth?.refreshToken === '') return 'dead'; + return 'present'; +}; diff --git a/src/domain.operations/brain/auth/assertBrainAuthNotDead.test.ts b/src/domain.operations/brain/auth/assertBrainAuthNotDead.test.ts new file mode 100644 index 00000000..30f6436e --- /dev/null +++ b/src/domain.operations/brain/auth/assertBrainAuthNotDead.test.ts @@ -0,0 +1,95 @@ +import { ConstraintError } from 'helpful-errors'; +import { getError, given, then, when } from 'test-fns'; + +import { genSampleBrainAuth } from '@src/.test/assets/genSampleBrainAuth'; + +import { assertBrainAuthNotDead } from './assertBrainAuthNotDead'; + +const brainAuthPath = '/home/fake/.claude/.credentials.json'; + +describe('assertBrainAuthNotDead', () => { + given('[case1] the shared login is dead', () => { + const brainAuthContent = genSampleBrainAuth({ + state: 'dead', + expiresAt: 0, + }); + + when('[t0] no env credential is set', () => { + then('it refuses with a ConstraintError that names /login', async () => { + const error = await getError(() => + assertBrainAuthNotDead({ brainAuthPath, brainAuthContent, env: {} }), + ); + expect(error).toBeInstanceOf(ConstraintError); + expect(error.message).toContain("the box's claude login is dead"); + expect(error.message).toContain('/login'); + expect(error.message).toMatchSnapshot(); + }); + }); + + when('[t1] CLAUDE_CODE_OAUTH_TOKEN is set', () => { + then('it passes β€” the file is not the clone credential', () => { + expect(() => + assertBrainAuthNotDead({ + brainAuthPath, + brainAuthContent, + env: { CLAUDE_CODE_OAUTH_TOKEN: 'x' }, + }), + ).not.toThrow(); + }); + }); + + when('[t2] ANTHROPIC_API_KEY is set', () => { + then('it passes β€” the file is not the clone credential', () => { + expect(() => + assertBrainAuthNotDead({ + brainAuthPath, + brainAuthContent, + env: { ANTHROPIC_API_KEY: 'x' }, + }), + ).not.toThrow(); + }); + }); + }); + + given('[case2] the shared login is live', () => { + const brainAuthContent = genSampleBrainAuth({ + state: 'live', + expiresAt: 1, + }); + when('[t0] no env credential is set', () => { + then('it passes', () => { + expect(() => + assertBrainAuthNotDead({ brainAuthPath, brainAuthContent, env: {} }), + ).not.toThrow(); + }); + }); + }); + + given('[case3] there is no shared login', () => { + when('[t0] no env credential is set', () => { + then('it passes β€” the clone can /login on its own', () => { + expect(() => + assertBrainAuthNotDead({ + brainAuthPath, + brainAuthContent: null, + env: {}, + }), + ).not.toThrow(); + }); + }); + }); + + given('[case4] the shared login does not parse', () => { + when('[t0] no env credential is set', () => { + then('it passes β€” claude-code judges it in the clone', () => { + expect(() => + assertBrainAuthNotDead({ + brainAuthPath, + brainAuthContent: 'not json', + env: {}, + }), + ).not.toThrow(); + }); + }); + }); +}); diff --git a/src/domain.operations/brain/auth/assertBrainAuthNotDead.ts b/src/domain.operations/brain/auth/assertBrainAuthNotDead.ts new file mode 100644 index 00000000..f0096f76 --- /dev/null +++ b/src/domain.operations/brain/auth/assertBrainAuthNotDead.ts @@ -0,0 +1,35 @@ +import { ConstraintError } from 'helpful-errors'; + +import { asBrainAuthState } from './asBrainAuthState'; +import { isBrainAuthViaEnv } from './isBrainAuthViaEnv'; + +/** + * .what = refuse an enroll whose clone would boot into a dead login + * .why = every clone reads the one shared login. once claude-code clears it (a revoked + * login, or a refresh the server rejects), each new clone would boot straight into + * `Login expired` with no keyboard to answer it. a refusal with the cure is cheaper + * than a fleet of stuck panes, and one `/login` heals every live clone at once + * + * .note = an env credential (CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, + * ANTHROPIC_AUTH_TOKEN) wins over the file in claude-code, so it is never refused + * .note = only the dead-token clear is refused. an ABSENT login is not β€” the clone can + * `/login` on its own (asBrainAuthAbsentLine) β€” and a login that does not parse is + * claude-code's to judge, in the clone + * .note = pure: the caller reads the login once and passes its content in + */ +export const assertBrainAuthNotDead = (input: { + brainAuthPath: string; + brainAuthContent: string | null; + env: NodeJS.ProcessEnv; +}): void => { + // an env credential wins over the file, so the file state does not matter + if (isBrainAuthViaEnv({ env: input.env })) return; + + // refuse only the one shape we can name for certain: the dead-token clear + if (asBrainAuthState({ content: input.brainAuthContent }) !== 'dead') return; + + throw new ConstraintError("the box's claude login is dead", { + brainAuthPath: input.brainAuthPath, + hint: 'run /login in any claude on this box (or `rhx git.grove.auth` on a grove); every live clone recovers on the refill, with no respawn. or set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY for this clone', + }); +}; diff --git a/src/domain.operations/brain/auth/getBrainAuthPath.test.ts b/src/domain.operations/brain/auth/getBrainAuthPath.test.ts new file mode 100644 index 00000000..b3569473 --- /dev/null +++ b/src/domain.operations/brain/auth/getBrainAuthPath.test.ts @@ -0,0 +1,15 @@ +import { given, then, when } from 'test-fns'; + +import { getBrainAuthPath } from './getBrainAuthPath'; + +describe('getBrainAuthPath', () => { + given('[case1] a home dir', () => { + when('[t0] the shared login path is computed', () => { + then('it is the credentials file under ~/.claude', () => { + expect(getBrainAuthPath({ home: '/home/surfer' })).toEqual( + '/home/surfer/.claude/.credentials.json', + ); + }); + }); + }); +}); diff --git a/src/domain.operations/brain/auth/getBrainAuthPath.ts b/src/domain.operations/brain/auth/getBrainAuthPath.ts new file mode 100644 index 00000000..3693d72e --- /dev/null +++ b/src/domain.operations/brain/auth/getBrainAuthPath.ts @@ -0,0 +1,9 @@ +import { join } from 'node:path'; + +/** + * .what = the path of the box's one claude login, under the given home + * .why = every clone spawns with CLAUDE_SECURESTORAGE_CONFIG_DIR='', which keys the + * login by `~/.claude` β€” so one name owns where that login lives + */ +export const getBrainAuthPath = (input: { home: string }): string => + join(input.home, '.claude', '.credentials.json'); diff --git a/src/domain.operations/brain/auth/isBrainAuthLive.test.ts b/src/domain.operations/brain/auth/isBrainAuthLive.test.ts new file mode 100644 index 00000000..7aa5c770 --- /dev/null +++ b/src/domain.operations/brain/auth/isBrainAuthLive.test.ts @@ -0,0 +1,48 @@ +import { given, then, when } from 'test-fns'; + +import { genSampleBrainAuth } from '@src/.test/assets/genSampleBrainAuth'; + +import { isBrainAuthLive } from './isBrainAuthLive'; + +const TEST_CASES = [ + { description: 'no file', content: null, expected: false }, + { + description: 'a login with a refresh token', + content: genSampleBrainAuth({ state: 'live', expiresAt: 1234 }), + expected: true, + }, + { + description: 'a login with a refresh token and no expiry', + content: JSON.stringify({ claudeAiOauth: { refreshToken: 'r' } }), + expected: true, + }, + { + description: 'a dead login that still records an expiry', + content: genSampleBrainAuth({ state: 'dead', expiresAt: 1234 }), + expected: false, + }, + { + description: 'a file with no oauth block', + content: JSON.stringify({ other: true }), + expected: false, + }, + { + description: 'a file that is not json', + content: 'not json', + expected: false, + }, +] as const; + +describe('isBrainAuthLive', () => { + TEST_CASES.map((thisCase) => + given(`[case] ${thisCase.description}`, () => { + when('[t0] the content is read', () => { + then(`reads as ${thisCase.expected}`, () => { + expect(isBrainAuthLive({ content: thisCase.content })).toEqual( + thisCase.expected, + ); + }); + }); + }), + ); +}); diff --git a/src/domain.operations/brain/auth/isBrainAuthLive.ts b/src/domain.operations/brain/auth/isBrainAuthLive.ts new file mode 100644 index 00000000..13718091 --- /dev/null +++ b/src/domain.operations/brain/auth/isBrainAuthLive.ts @@ -0,0 +1,16 @@ +import { asBrainAuthOauth } from './asBrainAuthOauth'; + +/** + * .what = does a claude login file hold a refresh token, so it can recover itself + * .why = a login with a refresh token is live whatever its access expiry says β€” the + * next call refreshes it. this is how enroll tells a brain-dir login worth adoption + * from a dead or foreign one + * + * .note = an absent file, a dead login (empty refresh token), or one that does not + * parse reads as false + * .note = never returns, logs, or compares a token value beyond its emptiness + */ +export const isBrainAuthLive = (input: { content: string | null }): boolean => { + const oauth = asBrainAuthOauth({ content: input.content }); + return typeof oauth?.refreshToken === 'string' && oauth.refreshToken !== ''; +}; diff --git a/src/domain.operations/brain/auth/isBrainAuthViaEnv.test.ts b/src/domain.operations/brain/auth/isBrainAuthViaEnv.test.ts new file mode 100644 index 00000000..fc66e7ea --- /dev/null +++ b/src/domain.operations/brain/auth/isBrainAuthViaEnv.test.ts @@ -0,0 +1,41 @@ +import { given, then, when } from 'test-fns'; + +import { isBrainAuthViaEnv } from './isBrainAuthViaEnv'; + +const TEST_CASES = [ + { description: 'no credential env', env: {}, expected: false }, + { + description: 'an empty key', + env: { ANTHROPIC_API_KEY: '' }, + expected: false, + }, + { + description: 'CLAUDE_CODE_OAUTH_TOKEN', + env: { CLAUDE_CODE_OAUTH_TOKEN: 'x' }, + expected: true, + }, + { + description: 'ANTHROPIC_API_KEY', + env: { ANTHROPIC_API_KEY: 'x' }, + expected: true, + }, + { + description: 'ANTHROPIC_AUTH_TOKEN', + env: { ANTHROPIC_AUTH_TOKEN: 'x' }, + expected: true, + }, +] as const; + +describe('isBrainAuthViaEnv', () => { + TEST_CASES.map((thisCase) => + given(`[case] ${thisCase.description}`, () => { + when('[t0] the env is checked', () => { + then(`reads as ${thisCase.expected}`, () => { + expect(isBrainAuthViaEnv({ env: thisCase.env })).toEqual( + thisCase.expected, + ); + }); + }); + }), + ); +}); diff --git a/src/domain.operations/brain/auth/isBrainAuthViaEnv.ts b/src/domain.operations/brain/auth/isBrainAuthViaEnv.ts new file mode 100644 index 00000000..e0ae3089 --- /dev/null +++ b/src/domain.operations/brain/auth/isBrainAuthViaEnv.ts @@ -0,0 +1,21 @@ +/** + * .what = the env keys through which claude-code 2.1.280 takes a credential that + * outranks the login file + * .why = each one authenticates a clone on its own, so a clone spawned with any of them + * never reads `~/.claude/.credentials.json`; a key set to '' counts as absent, since + * claude-code skips an empty value + */ +const BRAIN_AUTH_ENV_KEYS = [ + 'CLAUDE_CODE_OAUTH_TOKEN', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', +] as const; + +/** + * .what = does the spawn env hand claude-code a credential of its own? + * .why = claude-code prefers an env credential over the login file, so a clone spawned + * with one authenticates whatever state `~/.claude/.credentials.json` is in β€” a dead + * login there is no reason to refuse it + */ +export const isBrainAuthViaEnv = (input: { env: NodeJS.ProcessEnv }): boolean => + BRAIN_AUTH_ENV_KEYS.some((key) => !!input.env[key]); diff --git a/src/domain.operations/brain/auth/withBrainAuthWriteLock.integration.test.ts b/src/domain.operations/brain/auth/withBrainAuthWriteLock.integration.test.ts new file mode 100644 index 00000000..0b5d3d56 --- /dev/null +++ b/src/domain.operations/brain/auth/withBrainAuthWriteLock.integration.test.ts @@ -0,0 +1,284 @@ +import { MalfunctionError } from 'helpful-errors'; +import { genTempDir, getError, given, then, useThen, when } from 'test-fns'; + +import { + existsSync, + mkdirSync, + renameSync, + rmdirSync, + statSync, + utimesSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { withBrainAuthWriteLock } from './withBrainAuthWriteLock'; + +/** + * .what = a fake ~/.claude dir, with the path of its login and of claude-code's write lock + */ +const genScene = (input: { slug: string }) => { + const claudeDir = join(genTempDir({ slug: input.slug }), '.claude'); + mkdirSync(claudeDir, { recursive: true }); + return { + brainAuthPath: join(claudeDir, '.credentials.json'), + lockPath: join(claudeDir, '.storage-write.lock'), + }; +}; + +describe('withBrainAuthWriteLock', () => { + given('[case1] no holder of the lock', () => { + const scene = genScene({ slug: 'brain-auth-lock-free' }); + + when('[t0] a procedure runs under the lock', () => { + const result = useThen('it runs', async () => { + // .note = deliberate mutation: the holder captures a read from inside the closure + const heldWithin = { value: false }; + const returned = await withBrainAuthWriteLock( + { brainAuthPath: scene.brainAuthPath }, + () => { + heldWithin.value = existsSync(scene.lockPath); + return 'done'; + }, + ); + return { returned, heldWithin: heldWithin.value }; + }); + + then("it holds claude-code's lock dir while the procedure runs", () => { + expect(result.heldWithin).toBe(true); + }); + + then("it returns the procedure's result", () => { + expect(result.returned).toEqual('done'); + }); + + then('it releases the lock after', () => { + expect(existsSync(scene.lockPath)).toBe(false); + }); + }); + + when('[t1] the procedure throws', () => { + const result = useThen('it runs', async () => { + const error = await withBrainAuthWriteLock( + { brainAuthPath: scene.brainAuthPath }, + () => { + throw new Error('procedure fault'); + }, + ).catch((thrown: unknown) => thrown); + return { error }; + }); + + then('the error surfaces', () => { + expect(result.error).toBeInstanceOf(Error); + }); + + then('the lock is still released', () => { + expect(existsSync(scene.lockPath)).toBe(false); + }); + }); + }); + + given('[case2] a live holder that releases after a short hold', () => { + const scene = genScene({ slug: 'brain-auth-lock-held' }); + + when('[t0] a procedure asks for the lock', () => { + const result = useThen('it runs', async () => { + mkdirSync(scene.lockPath); + // .note = deliberate mutation: the holder marks its release, so the order is read + // from a marker rather than from wall-clock times + const released = { value: false }; + setTimeout(() => { + released.value = true; + rmdirSync(scene.lockPath); + }, 300); + const ranAfterRelease = await withBrainAuthWriteLock( + { brainAuthPath: scene.brainAuthPath }, + () => released.value, + ); + return { ranAfterRelease }; + }); + + then('it waits for the holder, then runs', () => { + expect(result.ranAfterRelease).toBe(true); + }); + + then('it releases the lock after', () => { + expect(existsSync(scene.lockPath)).toBe(false); + }); + }); + }); + + given('[case3] a stale lock left by a holder that died', () => { + const scene = genScene({ slug: 'brain-auth-lock-stale' }); + + when('[t0] a procedure asks for the lock', () => { + const result = useThen('it runs', async () => { + mkdirSync(scene.lockPath); + const longAgo = new Date(Date.now() - 60_000); + utimesSync(scene.lockPath, longAgo, longAgo); + const returned = await withBrainAuthWriteLock( + { brainAuthPath: scene.brainAuthPath }, + () => 'ran', + ); + return { returned }; + }); + + then('it clears the stale lock and runs', () => { + expect(result.returned).toEqual('ran'); + }); + + then('it releases the lock after', () => { + expect(existsSync(scene.lockPath)).toBe(false); + }); + }); + }); + + given('[case4] a peer reclaims the lock while the procedure runs', () => { + const scene = genScene({ slug: 'brain-auth-lock-reclaimed' }); + + when('[t0] the peer swaps in its own fresh lock dir', () => { + const result = useThen('it runs', async () => { + const returned = await withBrainAuthWriteLock( + { brainAuthPath: scene.brainAuthPath }, + () => { + // the peer makes its dir first, so its inode differs from ours + const peerDir = `${scene.lockPath}.peer`; + mkdirSync(peerDir); + rmdirSync(scene.lockPath); + renameSync(peerDir, scene.lockPath); + return 'ran'; + }, + ); + const peerLockKept = existsSync(scene.lockPath); + rmdirSync(scene.lockPath); + return { returned, peerLockKept }; + }); + + then("it returns the procedure's result", () => { + expect(result.returned).toEqual('ran'); + }); + + then("the release leaves the peer's lock in place", () => { + expect(result.peerLockKept).toBe(true); + }); + }); + }); + + given('[case6] a procedure that holds the lock past a refresh', () => { + const scene = genScene({ slug: 'brain-auth-lock-slow' }); + + when('[t0] the lock ages toward stale while the procedure runs', () => { + const result = useThen('it runs', async () => { + const returned = await withBrainAuthWriteLock( + { brainAuthPath: scene.brainAuthPath }, + async () => { + // age the held lock past the stale bound, then outlast one refresh + const longAgo = new Date(Date.now() - 60_000); + utimesSync(scene.lockPath, longAgo, longAgo); + await new Promise((done) => setTimeout(done, 5_500)); + return { lockAgeMs: Date.now() - statSync(scene.lockPath).mtimeMs }; + }, + ); + return { returned }; + }); + + then('the lock was refreshed, so no peer would judge it stale', () => { + expect(result.returned.lockAgeMs).toBeLessThan(15_000); + }); + + then('it releases the lock after', () => { + expect(existsSync(scene.lockPath)).toBe(false); + }); + }); + }); + + given('[case5] the lock dir is gone before the release', () => { + const scene = genScene({ slug: 'brain-auth-lock-gone' }); + + when('[t0] the procedure ends after the lock dir was removed', () => { + const result = useThen('it runs', async () => { + const returned = await withBrainAuthWriteLock( + { brainAuthPath: scene.brainAuthPath }, + () => { + rmdirSync(scene.lockPath); + return 'ran'; + }, + ); + return { returned }; + }); + + then('the release is a no-op, not an error', () => { + expect(result.returned).toEqual('ran'); + expect(existsSync(scene.lockPath)).toBe(false); + }); + }); + + when( + '[t1] the lock dir is removed, then the procedure outlasts a refresh', + () => { + const result = useThen('it runs', async () => { + const returned = await withBrainAuthWriteLock( + { brainAuthPath: scene.brainAuthPath }, + async () => { + rmdirSync(scene.lockPath); + await new Promise((done) => setTimeout(done, 5_500)); + return 'ran'; + }, + ); + return { returned }; + }); + + then( + 'the refresh skips the absent lock, and the procedure completes', + () => { + expect(result.returned).toEqual('ran'); + expect(existsSync(scene.lockPath)).toBe(false); + }, + ); + }, + ); + }); + + given('[case7] a live holder that never releases', () => { + const scene = genScene({ slug: 'brain-auth-lock-kept' }); + + when('[t0] a procedure asks for the lock past the wait', () => { + const result = useThen('it runs', async () => { + // the holder keeps its lock fresh, so it never reads as stale + mkdirSync(scene.lockPath); + const holder = setInterval(() => { + const now = new Date(); + utimesSync(scene.lockPath, now, now); + }, 2_000); + + // .note = deliberate mutation: the procedure marks whether it ran + const ran = { value: false }; + const error = await getError( + withBrainAuthWriteLock({ brainAuthPath: scene.brainAuthPath }, () => { + ran.value = true; + }), + ); + clearInterval(holder); + const holderLockKept = existsSync(scene.lockPath); + rmdirSync(scene.lockPath); + return { error, ran: ran.value, holderLockKept }; + }); + + then('it throws a MalfunctionError', () => { + expect(result.error).toBeInstanceOf(MalfunctionError); + }); + + then('the procedure never runs', () => { + expect(result.ran).toBe(false); + }); + + then("the holder's lock is left in place", () => { + expect(result.holderLockKept).toBe(true); + }); + + then('the error names the lock and the fix', () => { + expect( + result.error.message.split(scene.lockPath).join('$LOCK_PATH'), + ).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/src/domain.operations/brain/auth/withBrainAuthWriteLock.ts b/src/domain.operations/brain/auth/withBrainAuthWriteLock.ts new file mode 100644 index 00000000..98a3ebe0 --- /dev/null +++ b/src/domain.operations/brain/auth/withBrainAuthWriteLock.ts @@ -0,0 +1,163 @@ +import { MalfunctionError } from 'helpful-errors'; + +import { delDirSync } from '@src/infra/filesystem/delDirSync'; +import { getFileStatOrNull } from '@src/infra/filesystem/getFileStatOrNull'; +import { isErrnoEexist } from '@src/infra/filesystem/isErrnoEexist'; +import { isErrnoEnoent } from '@src/infra/filesystem/isErrnoEnoent'; + +import { mkdirSync, type Stats, utimesSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +/** + * .what = run a procedure while it holds claude-code's own login write lock + * .why = claude-code serializes every write to the shared login under one lock, + * `~/.claude/.storage-write` (proper-lockfile, `realpath: false`, `stale: 15000`). + * a read-decide-write of the shared login outside that lock races a `/login` or a + * refresh; inside it, no claude-code writer can interleave + * + * .note = proper-lockfile's protocol is a directory: the lock is held while + * `.lock` exists, and a lock whose mtime is older than `stale` is abandoned. + * this speaks the same protocol, so the two exclude each other + * .note = while held, the lock's mtime is refreshed every 5s, as claude-code's + * `.oauth_refresh.lock` does (`update: 5000`, as read from claude-code 2.1.280), so + * a slow procedure never outlives the 15s stale bound and loses its lock mid-write + * .note = a lock dir is known by its inode. the release removes only the dir this + * holder made, and a stale clear removes only the dir it judged stale, so a peer's + * fresh lock is never removed by a holder or a reclaimer that acted on an old read + */ +export const withBrainAuthWriteLock = async ( + input: { brainAuthPath: string }, + procedure: () => T | Promise, +): Promise => { + const lockPath = join(dirname(input.brainAuthPath), '.storage-write.lock'); + mkdirSync(dirname(lockPath), { recursive: true }); + + // take the lock, or wait out a live holder; a stale one is cleared + const lockHeld = await getLockHeld({ lockPath, attempt: 0 }); + if (!lockHeld) + throw new MalfunctionError( + 'the claude login write lock stayed held; the shared login was not touched', + { + lockPath, + hint: 'a live claude-code process holds the lock past the wait; retry the enroll. a lock dir older than 15s is stale, and is cleared on the next try', + }, + ); + + // keep the lock fresh while the procedure runs, so no peer judges a live hold stale + const heartbeat = setInterval( + () => setLockFreshIfSame({ lockPath, lock: lockHeld }), + LOCK_UPDATE_MS, + ); + heartbeat.unref(); + + // run the procedure, and release the lock whatever it does + try { + return await procedure(); + } finally { + clearInterval(heartbeat); + delLockIfSame({ lockPath, lock: lockHeld }); + } +}; + +const LOCK_STALE_MS = 15000; +const LOCK_UPDATE_MS = 5000; +const LOCK_TRIES = 20; + +/** + * .what = take the lock within LOCK_TRIES attempts, with a linear backoff + * .why = a live holder (a claude-code write) releases in milliseconds; the backoff + * waits past it without a busy spin + */ +const getLockHeld = async (input: { + lockPath: string; + attempt: number; +}): Promise => { + if (input.attempt >= LOCK_TRIES) return null; + const lockHeld = getLockHeldOnce({ lockPath: input.lockPath }); + if (lockHeld) return lockHeld; + await new Promise((done) => setTimeout(done, 100 * (input.attempt + 1))); + return getLockHeld({ lockPath: input.lockPath, attempt: input.attempt + 1 }); +}; + +/** + * .what = one attempt to take a directory lock; the held lock's stat, or null + * .why = mkdir is atomic, so of two racers exactly one creates the dir; a lock dir + * older than the stale bound belongs to a holder that died, and is cleared + */ +const getLockHeldOnce = (input: { lockPath: string }): Stats | null => { + // the lock is ours when our mkdir made the dir + if (isDirMade({ path: input.lockPath })) + return getFileStatOrNull({ path: input.lockPath }); + + // a live holder keeps the lock; an abandoned one is cleared for the next attempt + const lockSeen = getFileStatOrNull({ path: input.lockPath }); + if (lockSeen && isLockStale({ lock: lockSeen })) + delLockIfStillStale({ lockPath: input.lockPath, lock: lockSeen }); + return null; +}; + +/** + * .what = remove the lock dir only when it is still the stale one given + * .why = between the first read and the removal, a peer may have cleared it and + * taken a fresh one (a new inode), or its holder may have refreshed it (a new + * mtime); either way the lock is live again, and stays + */ +const delLockIfStillStale = (input: { + lockPath: string; + lock: Stats; +}): void => { + const lockNow = getFileStatOrNull({ path: input.lockPath }); + if (lockNow?.ino !== input.lock.ino) return; + if (!isLockStale({ lock: lockNow })) return; + delDirSync({ path: input.lockPath }); +}; + +/** + * .what = remove the lock dir only when it is still the one given, by inode + * .why = a peer may have cleared the given lock and taken a fresh one since it was + * read; that fresh lock is the peer's, and stays + */ +const delLockIfSame = (input: { lockPath: string; lock: Stats }): void => { + const lockNow = getFileStatOrNull({ path: input.lockPath }); + if (lockNow?.ino !== input.lock.ino) return; + delDirSync({ path: input.lockPath }); +}; + +/** + * .what = touch the lock dir's mtime, only when it is still the one given, by inode + * .why = a fresh mtime keeps a live hold under the stale bound however long the + * procedure runs; a lock a peer has since taken is the peer's, and is left alone + * .note = it runs in a timer, where a throw would crash the process; a lock dir a + * peer removed between the stat and the touch is absent, which is no fault + */ +const setLockFreshIfSame = (input: { lockPath: string; lock: Stats }): void => { + const lockNow = getFileStatOrNull({ path: input.lockPath }); + if (lockNow?.ino !== input.lock.ino) return; + const now = new Date(); + try { + utimesSync(input.lockPath, now, now); + } catch (error) { + if (isErrnoEnoent(error)) return; + throw error; + } +}; + +/** + * .what = is this lock dir older than the stale bound + */ +const isLockStale = (input: { lock: Stats }): boolean => + Date.now() - input.lock.mtimeMs > LOCK_STALE_MS; + +/** + * .what = make a dir; false when a dir is already at the path + * .why = the atomic step of the lock: of two racers, exactly one gets true + */ +const isDirMade = (input: { path: string }): boolean => { + try { + mkdirSync(input.path); + return true; + } catch (error) { + if (isErrnoEexist(error)) return false; + throw error; + } +}; diff --git a/src/domain.operations/clone/pty/genBrainCliPlainClone.integration.test.ts b/src/domain.operations/clone/pty/genBrainCliPlainClone.integration.test.ts index 132895ec..28dd07f0 100644 --- a/src/domain.operations/clone/pty/genBrainCliPlainClone.integration.test.ts +++ b/src/domain.operations/clone/pty/genBrainCliPlainClone.integration.test.ts @@ -15,9 +15,9 @@ describe('genBrainCliPlainClone.integration', () => { const brainDir = genTempDir({ slug: `plainclone-brain-${serial}` }); const envOut = join(cwd, 'env.json'); - // the child records the two env vars the spawn must carry + // the child records the env vars the spawn must carry const program = [ - `const env = { configDir: process.env.CLAUDE_CONFIG_DIR ?? null, serial: process.env[${JSON.stringify(CLONE_ENV_KEYS.serial)}] ?? null };`, + `const env = { configDir: process.env.CLAUDE_CONFIG_DIR ?? null, secureStorageDir: process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR ?? null, serial: process.env[${JSON.stringify(CLONE_ENV_KEYS.serial)}] ?? null };`, `require('fs').writeFileSync(${JSON.stringify(envOut)}, JSON.stringify(env));`, ].join('\n'); @@ -32,6 +32,7 @@ describe('genBrainCliPlainClone.integration', () => { const exitCode = await clone.waitForExit; const env = JSON.parse(readFileSync(envOut, 'utf8')) as { configDir: string | null; + secureStorageDir: string | null; serial: string | null; }; return { serial, brainDir, exitCode, env }; @@ -46,6 +47,11 @@ describe('genBrainCliPlainClone.integration', () => { expect(scene.env.configDir).toEqual(scene.brainDir); }); + then('its login store is the shared ~/.claude, via an empty var', () => { + // '' is set, never unset: unset would key the login to the brain dir + expect(scene.env.secureStorageDir).toEqual(''); + }); + then('it carries its own serial', () => { expect(scene.env.serial).toEqual(scene.serial); }); diff --git a/src/domain.operations/enroll/asBrainCliSpawnEnv.test.ts b/src/domain.operations/enroll/asBrainCliSpawnEnv.test.ts index 9bc08547..b3cff59e 100644 --- a/src/domain.operations/enroll/asBrainCliSpawnEnv.test.ts +++ b/src/domain.operations/enroll/asBrainCliSpawnEnv.test.ts @@ -22,6 +22,27 @@ describe('asBrainCliSpawnEnv', () => { then("the caller's other env is kept", () => { expect(env['PATH']).toEqual('/usr/bin'); }); + + then('the login store is the shared ~/.claude, via an empty var', () => { + // '' points claude-code's credential file and its refresh locks at ~/.claude + expect(env['CLAUDE_SECURESTORAGE_CONFIG_DIR']).toEqual(''); + }); + }); + }); + + given('[case5] a caller env that sets the secure-storage dir', () => { + when('[t0] the spawn env is built', () => { + const env = asBrainCliSpawnEnv({ + env: { + PATH: '/usr/bin', + CLAUDE_SECURESTORAGE_CONFIG_DIR: '/some/other/dir', + }, + brainDir: '/repo/.agent/.actors/actor.via.hash=abc12345/brain/.claude', + }); + + then('rhachet overrides it, so no clone splits the store', () => { + expect(env['CLAUDE_SECURESTORAGE_CONFIG_DIR']).toEqual(''); + }); }); }); @@ -54,6 +75,7 @@ describe('asBrainCliSpawnEnv', () => { [ 'ANTHROPIC_API_KEY', 'CLAUDE_CONFIG_DIR', + 'CLAUDE_SECURESTORAGE_CONFIG_DIR', 'PATH', ...Object.keys(BRAIN_CLI_SPINUP_ENV_DEFAULTS), ].sort(), diff --git a/src/domain.operations/enroll/asBrainCliSpawnEnv.ts b/src/domain.operations/enroll/asBrainCliSpawnEnv.ts index 38e17b6e..a23df24d 100644 --- a/src/domain.operations/enroll/asBrainCliSpawnEnv.ts +++ b/src/domain.operations/enroll/asBrainCliSpawnEnv.ts @@ -38,11 +38,15 @@ export const BRAIN_CLI_SPINUP_ENV_DEFAULTS: Readonly> = { /** * .what = the env a brain cli clone spawns with: the spinup defaults, then the * caller's env minus the parent-session markers, then `CLAUDE_CONFIG_DIR` - * set to the actor's brain dir + * set to the actor's brain dir and `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to '' * .why = one owner for both spawn sites (pty and plain), so neither can drop the * relocation and boot a clone from the human's `~/.claude` (D4), nor leak a * parent session's markers into the clone (a clone run from inside claude * would otherwise persist no transcript), nor pay spinup work it never uses + * .note = `CLAUDE_SECURESTORAGE_CONFIG_DIR=''` (empty, not unset) points claude-code's + * login store, its write lock, and its oauth refresh lock at `~/.claude`, while + * config stays per actor β€” one login file under one lock set for every clone. + * it is set after the caller env, so a caller value never overrides it */ export const asBrainCliSpawnEnv = (input: { env: NodeJS.ProcessEnv; @@ -55,4 +59,5 @@ export const asBrainCliSpawnEnv = (input: { ), ), CLAUDE_CONFIG_DIR: input.brainDir, + CLAUDE_SECURESTORAGE_CONFIG_DIR: '', }); diff --git a/src/infra/filesystem/delDirSync.ts b/src/infra/filesystem/delDirSync.ts new file mode 100644 index 00000000..251c4844 --- /dev/null +++ b/src/infra/filesystem/delDirSync.ts @@ -0,0 +1,19 @@ +import { rmdirSync } from 'node:fs'; +import { isErrnoEnoent } from './isErrnoEnoent'; + +/** + * .what = remove an empty dir; a no-op when the dir is already gone + * .why = a lock dir may be removed by a peer between a check and the removal; + * a dir already gone is the goal met, never a fault + * + * .note = only ENOENT is allowed; a dir with content (ENOTEMPTY) or any other + * error surfaces + */ +export const delDirSync = (input: { path: string }): void => { + try { + rmdirSync(input.path); + } catch (error) { + if (isErrnoEnoent(error)) return; + throw error; + } +}; diff --git a/src/infra/filesystem/delFileSync.ts b/src/infra/filesystem/delFileSync.ts index fbe9071b..c84c32d8 100644 --- a/src/infra/filesystem/delFileSync.ts +++ b/src/infra/filesystem/delFileSync.ts @@ -1,3 +1,5 @@ +import { isErrnoEnoent } from '@src/infra/filesystem/isErrnoEnoent'; + import { unlinkSync } from 'node:fs'; /** @@ -22,6 +24,6 @@ export const delFileSync = (input: { path: string }): void => { unlinkSync(input.path); } catch (error) { // allow expected errors: ENOENT = already absent, which is the desired end state - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + if (!isErrnoEnoent(error)) throw error; } }; diff --git a/src/infra/filesystem/getFileContentOrNull.integration.test.ts b/src/infra/filesystem/getFileContentOrNull.integration.test.ts new file mode 100644 index 00000000..580cf1aa --- /dev/null +++ b/src/infra/filesystem/getFileContentOrNull.integration.test.ts @@ -0,0 +1,56 @@ +import { genTempDir, getError, given, then, when } from 'test-fns'; + +import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getFileContentOrNull } from './getFileContentOrNull'; + +describe('getFileContentOrNull', () => { + given('[case1] a file is at the path', () => { + const dir = genTempDir({ slug: 'content-present' }); + const path = join(dir, 'a.txt'); + writeFileSync(path, 'hello'); + + when('[t0] it is read', () => { + then('it returns the content', () => { + expect(getFileContentOrNull({ path })).toEqual('hello'); + }); + }); + }); + + given('[case2] no entry is at the path', () => { + const dir = genTempDir({ slug: 'content-absent' }); + + when('[t0] it is read', () => { + then('it returns null', () => { + expect(getFileContentOrNull({ path: join(dir, 'none.txt') })).toEqual( + null, + ); + }); + }); + }); + + given('[case3] a dangled symlink is at the path', () => { + const dir = genTempDir({ slug: 'content-dangled' }); + const path = join(dir, 'link.txt'); + symlinkSync(join(dir, 'gone.txt'), path); + + when('[t0] it is read', () => { + then('it returns null', () => { + expect(getFileContentOrNull({ path })).toEqual(null); + }); + }); + }); + + given('[case4] a directory is at the path', () => { + const dir = genTempDir({ slug: 'content-dir' }); + const path = join(dir, 'sub'); + mkdirSync(path); + + when('[t0] it is read', () => { + then('it surfaces the error rather than read as absent', async () => { + const error = await getError(() => getFileContentOrNull({ path })); + expect(error).toHaveProperty('code', 'EISDIR'); + }); + }); + }); +}); diff --git a/src/infra/filesystem/getFileContentOrNull.ts b/src/infra/filesystem/getFileContentOrNull.ts new file mode 100644 index 00000000..0239254d --- /dev/null +++ b/src/infra/filesystem/getFileContentOrNull.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'node:fs'; +import { isErrnoEnoent } from './isErrnoEnoent'; + +/** + * .what = read a file as utf-8, or null when no file is at the path + * .why = "read it if present" is the one shape the login checks need, and one read + * per decision leaves no gap between an existence check and the read + * + * .note = only ENOENT maps to null; every other error (EACCES, EISDIR) surfaces + * .note = a path through a dangled symlink reads as null, like an absent file + */ +export const getFileContentOrNull = (input: { + path: string; +}): string | null => { + try { + return readFileSync(input.path, 'utf-8'); + } catch (error) { + if (isErrnoEnoent(error)) return null; + throw error; + } +}; diff --git a/src/infra/filesystem/getFileStatOrNull.integration.test.ts b/src/infra/filesystem/getFileStatOrNull.integration.test.ts new file mode 100644 index 00000000..3fbc0fed --- /dev/null +++ b/src/infra/filesystem/getFileStatOrNull.integration.test.ts @@ -0,0 +1,58 @@ +import { genTempDir, getError, given, then, when } from 'test-fns'; + +import { symlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getFileStatOrNull } from './getFileStatOrNull'; + +describe('getFileStatOrNull', () => { + given('[case1] a file is at the path', () => { + const dir = genTempDir({ slug: 'stat-file' }); + const path = join(dir, 'a.txt'); + writeFileSync(path, 'hello'); + + when('[t0] it is stat-ed', () => { + then('it returns the stats of a file', () => { + expect(getFileStatOrNull({ path })?.isFile()).toEqual(true); + }); + }); + }); + + given('[case2] a dangled symlink is at the path', () => { + const dir = genTempDir({ slug: 'stat-dangled' }); + const path = join(dir, 'link.txt'); + symlinkSync(join(dir, 'gone.txt'), path); + + when('[t0] it is stat-ed', () => { + then('it returns the stats of the link itself', () => { + expect(getFileStatOrNull({ path })?.isSymbolicLink()).toEqual(true); + }); + }); + }); + + given('[case3] no entry is at the path', () => { + const dir = genTempDir({ slug: 'stat-absent' }); + + when('[t0] it is stat-ed', () => { + then('it returns null', () => { + expect(getFileStatOrNull({ path: join(dir, 'none.txt') })).toEqual( + null, + ); + }); + }); + }); + + given('[case4] a path under a file, not a dir', () => { + const dir = genTempDir({ slug: 'stat-notdir' }); + const file = join(dir, 'a.txt'); + writeFileSync(file, 'hello'); + + when('[t0] it is stat-ed', () => { + then('it surfaces the error rather than read as absent', async () => { + const error = await getError(() => + getFileStatOrNull({ path: join(file, 'b.txt') }), + ); + expect(error).toHaveProperty('code', 'ENOTDIR'); + }); + }); + }); +}); diff --git a/src/infra/filesystem/getFileStatOrNull.ts b/src/infra/filesystem/getFileStatOrNull.ts new file mode 100644 index 00000000..b43de360 --- /dev/null +++ b/src/infra/filesystem/getFileStatOrNull.ts @@ -0,0 +1,18 @@ +import { lstatSync, type Stats } from 'node:fs'; +import { isErrnoEnoent } from './isErrnoEnoent'; + +/** + * .what = lstat a path, or null when no entry is at the path + * .why = lstat, not stat: a dangled symlink is still an entry, and a caller that + * must retire a leftover link has to see it + * + * .note = only ENOENT maps to null; every other error surfaces + */ +export const getFileStatOrNull = (input: { path: string }): Stats | null => { + try { + return lstatSync(input.path); + } catch (error) { + if (isErrnoEnoent(error)) return null; + throw error; + } +}; diff --git a/src/infra/filesystem/isErrnoEexist.test.ts b/src/infra/filesystem/isErrnoEexist.test.ts new file mode 100644 index 00000000..7af97a8f --- /dev/null +++ b/src/infra/filesystem/isErrnoEexist.test.ts @@ -0,0 +1,40 @@ +import { given, then, when } from 'test-fns'; + +import { isErrnoEexist } from './isErrnoEexist'; + +const TEST_CASES = [ + { + description: 'an fs error for a path already taken', + error: Object.assign(new Error('taken'), { code: 'EEXIST' }), + expected: true, + }, + { + description: 'a plain object with code EEXIST (another vm realm)', + error: { code: 'EEXIST' }, + expected: true, + }, + { + description: 'an fs error for an absent path', + error: Object.assign(new Error('absent'), { code: 'ENOENT' }), + expected: false, + }, + { + description: 'an error with no code', + error: new Error('x'), + expected: false, + }, + { description: 'null', error: null, expected: false }, + { description: 'a string', error: 'EEXIST', expected: false }, +] as const; + +describe('isErrnoEexist', () => { + TEST_CASES.map((thisCase) => + given(`[case] ${thisCase.description}`, () => { + when('[t0] the error is checked', () => { + then(`reads as ${thisCase.expected}`, () => { + expect(isErrnoEexist(thisCase.error)).toEqual(thisCase.expected); + }); + }); + }), + ); +}); diff --git a/src/infra/filesystem/isErrnoEexist.ts b/src/infra/filesystem/isErrnoEexist.ts new file mode 100644 index 00000000..c42a60e5 --- /dev/null +++ b/src/infra/filesystem/isErrnoEexist.ts @@ -0,0 +1,11 @@ +/** + * .what = is this a node fs error for a path already taken + * .why = a structural check, since an fs error thrown across a vm realm (e.g. under + * jest) fails `instanceof Error` β€” so an instanceof guard would rethrow the + * very EEXIST it means to allow + */ +export const isErrnoEexist = (error: unknown): boolean => + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'EEXIST'; diff --git a/src/infra/filesystem/isErrnoEnoent.test.ts b/src/infra/filesystem/isErrnoEnoent.test.ts new file mode 100644 index 00000000..96515c68 --- /dev/null +++ b/src/infra/filesystem/isErrnoEnoent.test.ts @@ -0,0 +1,40 @@ +import { given, then, when } from 'test-fns'; + +import { isErrnoEnoent } from './isErrnoEnoent'; + +const TEST_CASES = [ + { + description: 'an fs error for an absent path', + error: Object.assign(new Error('absent'), { code: 'ENOENT' }), + expected: true, + }, + { + description: 'a plain object with code ENOENT (another vm realm)', + error: { code: 'ENOENT' }, + expected: true, + }, + { + description: 'an fs error for a denied path', + error: Object.assign(new Error('denied'), { code: 'EACCES' }), + expected: false, + }, + { + description: 'an error with no code', + error: new Error('x'), + expected: false, + }, + { description: 'null', error: null, expected: false }, + { description: 'a string', error: 'ENOENT', expected: false }, +] as const; + +describe('isErrnoEnoent', () => { + TEST_CASES.map((thisCase) => + given(`[case] ${thisCase.description}`, () => { + when('[t0] the error is checked', () => { + then(`reads as ${thisCase.expected}`, () => { + expect(isErrnoEnoent(thisCase.error)).toEqual(thisCase.expected); + }); + }); + }), + ); +}); diff --git a/src/infra/filesystem/isErrnoEnoent.ts b/src/infra/filesystem/isErrnoEnoent.ts new file mode 100644 index 00000000..b2328f96 --- /dev/null +++ b/src/infra/filesystem/isErrnoEnoent.ts @@ -0,0 +1,11 @@ +/** + * .what = is this a node fs error for an absent path + * .why = a structural check, since an fs error thrown across a vm realm (e.g. under + * jest) fails `instanceof Error` β€” so an instanceof guard would rethrow the + * very ENOENT it means to allow + */ +export const isErrnoEnoent = (error: unknown): boolean => + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT'; diff --git a/src/infra/json/asJsonParsedOrNull.test.ts b/src/infra/json/asJsonParsedOrNull.test.ts new file mode 100644 index 00000000..c6125807 --- /dev/null +++ b/src/infra/json/asJsonParsedOrNull.test.ts @@ -0,0 +1,34 @@ +import { asJsonParsedOrNull } from './asJsonParsedOrNull'; + +const TEST_CASES = [ + { + description: 'parses a json object', + given: { content: '{"a":1}' }, + expect: { output: { a: 1 } }, + }, + { + description: 'parses a json scalar', + given: { content: '7' }, + expect: { output: 7 }, + }, + { + description: 'reads non-json as null', + given: { content: 'not json {' }, + expect: { output: null }, + }, + { + description: 'reads an empty string as null', + given: { content: '' }, + expect: { output: null }, + }, +]; + +describe('asJsonParsedOrNull', () => { + TEST_CASES.map((thisCase) => + test(thisCase.description, () => { + expect(asJsonParsedOrNull(thisCase.given)).toEqual( + thisCase.expect.output, + ); + }), + ); +}); diff --git a/src/infra/json/asJsonParsedOrNull.ts b/src/infra/json/asJsonParsedOrNull.ts new file mode 100644 index 00000000..1d88a17e --- /dev/null +++ b/src/infra/json/asJsonParsedOrNull.ts @@ -0,0 +1,26 @@ +/** + * .what = parse a json string, or null when it is not valid json + * .why = a reader of a file it does not own (e.g. a login written by another tool) + * treats unparseable bytes as "no shape we can judge", never as a crash + * + * .note = only a SyntaxError maps to null; every other error surfaces + */ +export const asJsonParsedOrNull = (input: { content: string }): unknown => { + try { + return JSON.parse(input.content); + } catch (error) { + if (isSyntaxError(error)) return null; + throw error; + } +}; + +/** + * .what = is this error a SyntaxError + * .why = a check by name, since an error thrown across a vm realm (e.g. under jest) + * fails `instanceof SyntaxError` + */ +const isSyntaxError = (error: unknown): boolean => + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'SyntaxError'; diff --git a/src/infra/setFileAtomic.integration.test.ts b/src/infra/setFileAtomic.integration.test.ts index a72b7762..c5ae34d3 100644 --- a/src/infra/setFileAtomic.integration.test.ts +++ b/src/infra/setFileAtomic.integration.test.ts @@ -3,7 +3,7 @@ import { genTempDir, given, then, when } from 'test-fns'; import { setFileAtomic } from '@src/infra/setFileAtomic'; import { spawn } from 'node:child_process'; -import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; /** @@ -60,6 +60,21 @@ describe('setFileAtomic', () => { }); }); + given('[case2.1] a prior file with open permissions', () => { + when('[t0] a new content is set with mode 0600', () => { + then('the file lands with mode 0600', () => { + const dir = genTempDir({ slug: 'setFileAtomic-c2-1t0' }); + const path = join(dir, '.credentials.json'); + writeFileSync(path, 'prior\n', { mode: 0o644 }); + + setFileAtomic({ path, content: 'next\n' }, { mode: 0o600 }); + + expect(readFileSync(path, 'utf8')).toEqual('next\n'); + expect(statSync(path).mode & 0o777).toEqual(0o600); + }); + }); + }); + given('[case3] two writer processes at once', () => { when('[t0] each sets its own content many times', () => { then( diff --git a/src/infra/setFileAtomic.ts b/src/infra/setFileAtomic.ts index c5ea2839..c0801152 100644 --- a/src/infra/setFileAtomic.ts +++ b/src/infra/setFileAtomic.ts @@ -6,11 +6,13 @@ import { basename, dirname, join } from 'node:path'; * .what = write a file whole, via a temp file in the same dir then a rename * .why = a reader never sees a partial file, and two racers each land one whole * content β€” rename is atomic on posix within one filesystem + * .note = options.mode sets the permission bits the file lands with (e.g. 0o600 for a + * credential); the temp file is created fresh, so the mode applies from birth */ -export const setFileAtomic = (input: { - path: string; - content: string; -}): void => { +export const setFileAtomic = ( + input: { path: string; content: string }, + options?: { mode?: number }, +): void => { // the temp file sits beside the target, so the rename never crosses a filesystem const dir = dirname(input.path); mkdirSync(dir, { recursive: true }); @@ -20,7 +22,11 @@ export const setFileAtomic = (input: { ); // write the temp, then swap it in; drop the temp if the swap fails - writeFileSync(pathTemp, input.content, 'utf8'); + // an absent mode falls to node's default (0o666, less the umask) + writeFileSync(pathTemp, input.content, { + encoding: 'utf8', + mode: options?.mode, + }); try { renameSync(pathTemp, input.path); } catch (error) {