diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index b878c6f765..cd5b0c0e39 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -104,7 +104,7 @@ That no-fetch path is a purely local fast-forward of tracked files, never an ori A remote launch and the deferred bootstrap sweep ask the configured host to fast-forward its persistent home to that host's code-root commit under the same clean and ancestry guards. `/updatefirstmate` first updates the remote code root from its own origin, then runs that guarded home sync. SSH exit 255 preserves the route and reports unknown completion; it never triggers local respawn or failover. -The same placement-specific launch and deferred bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`, and the one shared captain-preference file `data/captain-shared.md`. +The same placement-specific launch and deferred bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`, `config/av-inject`, and the one shared captain-preference file `data/captain-shared.md`. Because these paths are gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. Propagation failures warn without blocking secondmate launch or session-start continuation, and the destination keeps whatever safely validated state the helper left behind. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. @@ -125,7 +125,7 @@ Keep every `data/learnings.md` fully local by captain decision; route fleet-gene No AGENTS.md reread nudge is needed at spawn or respawn because the agent reads instructions fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs that AGENTS.md re-read. Bootstrap reports successful AGENTS.md re-read sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. A separate, literal-content config reread is required whenever inherited `config/*` material changes under an already-running secondmate. -For a local home, after each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`), in deterministic allowlist order. +For a local home, after each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`, `config/av-inject`), in deterministic allowlist order. Each changed path is printed with clear begin/end delimiters and the destination file's full exact new bytes unparsed, or the explicit token `ABSENT` when propagation removed the destination copy. The instruction uses only minimal framing that these are defaults/rules and do not remove judgment; it never includes SHA values, selected profiles, parsed summaries, or any other generated interpretation. `data/captain-shared.md` is not a config file and is never inlined into this instruction file or message. diff --git a/AGENTS.md b/AGENTS.md index bd40813bf7..267b0b4cec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inhe config/startup-memory-budget primary-authoritative per-home startup-memory budget; LOCAL, gitignored, materialized as 7,500 estimated tokens by locked primary bootstrap and inherited into secondmate homes; see docs/configuration.md "Startup memory budget" config/herdr-presentation-spaces optional "off" opt-out from, or "on" opt-in to, Herdr's default-on disposable single-task visual projection, which is unconfigured-default-on only at or above a Herdr version floor; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces" config/trace-context optional presence flag enabling default-off native W3C trace-context propagation to spawned agents; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Trace context propagation" and docs/trace-context.md +config/av-inject optional "on" toggle wrapping every worker launch in `av inject +KEY... -- ` so workers receive the captain's static API keys from Automic Vault as environment variables; LOCAL, gitignored; OFF by default, inherited by secondmate homes; see docs/configuration.md "Automic Vault secret injection" config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated Relay watcher cadence; LOCAL, gitignored; source before arming watcher when present diff --git a/bin/fm-av-inject-lib.sh b/bin/fm-av-inject-lib.sh new file mode 100644 index 0000000000..1d1c084db5 --- /dev/null +++ b/bin/fm-av-inject-lib.sh @@ -0,0 +1,124 @@ +# shellcheck shell=bash +# Automic Vault secret-injection primitives. +# Usage: . bin/fm-av-inject-lib.sh +# +# This library owns the contract for wrapping a crewmate/secondmate launch in +# `av inject +KEY... -- ` so the worker receives the captain's static +# API keys from Automic Vault as environment variables, never on disk and never +# printed. bin/fm-spawn.sh assembles the launch command and calls +# fm_av_inject_prefix once per spawn to obtain the wrapper text. +# +# Automic Vault ("av") is a macOS-only local secrets manager: `av inject` copies +# the full ambient environment plus the named secrets and execs the target, so +# every env prefix firstmate already sets (FM_HOME, CLAUDE_CONFIG_DIR, +# TRACEPARENT, ...) is preserved as long as it appears BEFORE `av inject` on the +# launch line. The value reaches the worker's environment only; `av inject` +# itself prints nothing. See data/automic-vault-r1/report.md for the researched +# model and the migration prerequisites (verified launcher, per-secret Direct +# Access Rules, availability), and data/automic-vault-r1/decisions.md for the +# captain's approvals. +# +# Enablement is OFF by default because the shared template runs on homes and CI +# that have no Automic Vault app, and because the secrets must be migrated with +# an attended `av save` copy BEFORE injection can succeed (turning it on before +# the keys exist would make every `av inject` fail closed and break every +# spawn). Turn it on per home only after the migration is verified. + +# The static API keys approved for injection (data/automic-vault-r1 plan, key +# names exactly as stored by `av save`). Key NAMES are not secret (they are what +# `av list` returns); the VALUES live only in the macOS keychain and are never +# handled here. Override for tests with FM_AV_INJECT_KEYS. +FM_AV_INJECT_DEFAULT_KEYS="EXA_API_KEY PARALLEL_API_KEY TAVILY_API_KEY LINKUP_API_KEY BRAVE_SEARCH_API_KEY BRAVE_ANSWERS_API_KEY DEEPSEEK_API_KEY BUZZ_XYZ_KEY" + +FM_AV_INJECT_FILE="av-inject" +FM_AV_INJECT_ERROR="" + +# Read the enablement decision. FM_AV_INJECT (env) wins over the local, +# gitignored config/ file; absent/empty/off/false/no/0 all +# mean disabled (the default), on/true/yes/1 mean enabled. An unrecognized value +# is treated as disabled so a typo fails safe rather than breaking every spawn. +# Prints "on" or "off". +# Args: +fm_av_inject_mode() { # + local raw="" file="$1/$FM_AV_INJECT_FILE" + if [ -n "${FM_AV_INJECT:-}" ]; then + raw=$FM_AV_INJECT + elif [ -f "$file" ]; then + # `read` takes the first line with leading/trailing whitespace trimmed; a + # missing final newline still yields the value, so `|| true` guards the + # non-zero read at EOF. Builtins only, so the check works even when PATH is + # too restricted to resolve coreutils. + IFS= read -r raw < "$file" 2>/dev/null || true + raw=${raw#"${raw%%[![:space:]]*}"} + raw=${raw%"${raw##*[![:space:]]}"} + fi + # Case-insensitive bracket patterns avoid a lowercasing external command + # (portable to bash 3.2, and works under a PATH too restricted for coreutils). + case "$raw" in + [Oo][Nn]|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|1) printf 'on\n' ;; + *) printf 'off\n' ;; + esac +} + +# Resolve the `av` executable to an absolute path, mirroring resolve_pi_executable +# in bin/fm-spawn.sh so the pane runs the same signed CLI firstmate resolved. +# Prints the absolute path on success; returns non-zero when not found. +fm_av_inject_bin() { + local candidate dir + candidate=$(type -P -- av 2>/dev/null) || return 1 + [ -x "$candidate" ] || return 1 + case "$candidate" in + /*) printf '%s\n' "$candidate" ;; + *) + dir=$(cd "$(dirname "$candidate")" 2>/dev/null && pwd -P) || return 1 + printf '%s/%s\n' "$dir" "$(basename "$candidate")" + ;; + esac +} + +# Compute the launch-command prefix that wraps a worker launch in `av inject`. +# When injection is disabled, prints nothing and succeeds (the launch is +# unchanged). When enabled, resolves `av` and every key name and prints +# ` inject +KEY1 +KEY2 ... -- ` (with a trailing space) so the caller can +# splice it immediately before the agent binary. Fails closed with a message in +# FM_AV_INJECT_ERROR when enabled but `av` is missing or a key name is invalid, +# so a misconfigured home refuses to spawn rather than launching without the +# secrets it was told to inject. +# Args: +fm_av_inject_prefix() { # + local config_dir=$1 av key keys quoted + FM_AV_INJECT_ERROR="" + [ "$(fm_av_inject_mode "$config_dir")" = on ] || { printf '%s' ''; return 0; } + if ! av=$(fm_av_inject_bin); then + FM_AV_INJECT_ERROR="av-inject is enabled but the 'av' CLI (Automic Vault) was not found on PATH; install Automic Vault or set config/av-inject to off" + return 1 + fi + keys=${FM_AV_INJECT_KEYS:-$FM_AV_INJECT_DEFAULT_KEYS} + # `av` validates key names as [A-Za-z_][A-Za-z0-9_]* (src/cli/inject.rs); mirror + # that here so an invalid name is a loud refusal rather than a mangled launch. + quoted="" + for key in $keys; do + case "$key" in + [A-Za-z_]*) + case "$key" in + *[!A-Za-z0-9_]*) + FM_AV_INJECT_ERROR="av-inject key name '$key' is not a valid secret name ([A-Za-z_][A-Za-z0-9_]*)" + return 1 + ;; + esac + ;; + *) + FM_AV_INJECT_ERROR="av-inject key name '$key' is not a valid secret name ([A-Za-z_][A-Za-z0-9_]*)" + return 1 + ;; + esac + quoted="$quoted +$key" + done + if [ -z "$quoted" ]; then + FM_AV_INJECT_ERROR="av-inject is enabled but no secret key names are configured" + return 1 + fi + # inject +K1 +K2 -- . av path is single-quoted; key names passed + # this validation and need no quoting. + printf "'%s' inject%s -- " "$(printf '%s' "$av" | sed "s/'/'\\\\''/g")" "$quoted" +} diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 0b3ec94f09..399fe36c85 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -7,7 +7,9 @@ # spawn on codex too, primary config/backlog-backend=manual makes that home # hand-edit backlog files too, primary config/backend pins that home's local # runtime-backend default for future spawns, primary config/startup-memory-budget -# bounds that home's startup-memory curation, and primary +# bounds that home's startup-memory curation, primary config/av-inject makes that +# home wrap its own worker launches in the same Automic Vault secret injection, +# and primary # config/herdr-presentation-spaces carries the same Herdr presentation-projection # preference - an absent primary file and an absent destination file both mean # the same unconfigured default, so the generic absence mirror below converges @@ -63,7 +65,7 @@ FM_SHARED_CAPTAIN_MODE="444" # The declared inheritable set (space-separated, config-dir-relative item paths). # Extend here to inherit more of the primary's local config; override via the # environment only in tests. Items must not contain whitespace. -FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-presentation-spaces startup-memory-budget trace-context}" +FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-presentation-spaces startup-memory-budget trace-context av-inject}" # Items whose value is a home-SESSION enablement decision rather than durable # local configuration. They are inherited at the launch convergence point, where diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index cfb25f0058..e3088cf030 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -161,6 +161,10 @@ # __OPINPUT__ absolute path to the canonical operational-input encoder # __WORKTREE__ absolute path to the task worktree # __CURSORBIN__ resolved, cursor-verified executable for a cursor launch +# __AVINJECT__ optional ` inject +KEY... -- ` Automic Vault secret wrapper, +# placed immediately before the agent binary so every earlier env +# prefix is inherited into the injected child; empty unless the +# home opts in (bin/fm-av-inject-lib.sh, config/av-inject) # Verified per-harness turn-end hooks are installed automatically where enabled; some live outside the worktree. # Kimi uses one surgically installed Firstmate region in $HOME/.kimi-code/config.toml, # a firstmate-owned global hook and registry, and a gitignored per-task pointer. @@ -260,6 +264,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-trace-context-lib.sh" # shellcheck source=bin/fm-remote-readiness-lib.sh . "$SCRIPT_DIR/fm-remote-readiness-lib.sh" +# shellcheck source=bin/fm-av-inject-lib.sh +. "$SCRIPT_DIR/fm-av-inject-lib.sh" # Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn # a direct report (see bin/fm-gate-refuse-lib.sh). fm_refuse_if_gate_agent @@ -1111,17 +1117,17 @@ launch_template() { # does NOT suppress the interactive ghost text (verified empirically), so the env # var is the correct control. The dim-aware composer reader in fm-tmux-lib.sh is # the defense-in-depth backstop for any pane this flag cannot reach. - claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false __AVINJECT__claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; codex) if [ "$kind" = secondmate ]; then - printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + printf '%s' '__AVINJECT__codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' else - printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + printf '%s' '__AVINJECT__codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' fi ;; - opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' __AVINJECT__opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; pi|pi-signed) - printf '%s' '__PIBIN____PITUIMODE__' + printf '%s' '__AVINJECT____PIBIN____PITUIMODE__' if [ "$kind" = secondmate ]; then printf '%s' ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' else @@ -1135,7 +1141,7 @@ launch_template() { # --dangerously-skip-permissions. grok's turn-end signal does NOT ride the # launch command - it is a Stop-event hook installed below (global hook + # per-task pointer), so the template is identical for ship/scout/secondmate. - grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + grok) printf '%s' '__AVINJECT__grok --always-approve __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; # Cursor Agent CLI. --trust suppresses the workspace-trust prompt, which # --yolo does NOT cover and which would otherwise block every spawn, since # each task gets a fresh worktree path cursor has never seen. --yolo is the @@ -1148,12 +1154,12 @@ launch_template() { # inherited CLAUDECODE cannot outrank cursor's own marker in a process that # only reads the environment. Cursor exposes no effort flag, so the shared # effort axis is deliberately omitted and stays in task metadata only. - cursor) printf '%s' 'env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u FM_PI_HARNESS -u CURSOR_INVOKED_AS __CURSORBIN__ --trust --yolo __MODELFLAG__--workspace __WORKTREE__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + cursor) printf '%s' '__AVINJECT__env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u FM_PI_HARNESS -u CURSOR_INVOKED_AS __CURSORBIN__ --trust --yolo __MODELFLAG__--workspace __WORKTREE__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; # Kimi Code rejects a positional prompt, so it launches bare and receives # only an absolute brief pointer after the TUI readiness gate below. # Its turn-end signal is a globally configured Stop hook plus a guarded # per-task worktree token, so no launch placeholder belongs here. - kimi) printf '%s' '__KIMIBIN__ __MODELFLAG__--auto' ;; + kimi) printf '%s' '__AVINJECT____KIMIBIN__ __MODELFLAG__--auto' ;; # muse (Muse Code): a positional prompt starts the supervised interactive # session. --yolo is the single flag that makes a crewmate pane viable: muse # ships approval prompts AND a filesystem/network sandbox ON by default @@ -1175,7 +1181,7 @@ launch_template() { # session event log instead (bin/fm-busy-lib.sh), bound by the sidecar # written below. Nothing to place in the template for it. # codex, opencode, and kimi are also markerless and share this inherited-marker hazard; changing their verified launch boundaries belongs in follow-up work. - muse) printf '%s' 'env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u FM_PI_HARNESS XDG_CONFIG_HOME=__MUSECONFIG__ XDG_DATA_HOME=__MUSEDATA__ MUSE_EXPERIMENTAL_FOREIGN_PERSONAL_CONTEXT_KILL=on __MUSEBIN__ --yolo __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + muse) printf '%s' '__AVINJECT__env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u FM_PI_HARNESS XDG_CONFIG_HOME=__MUSECONFIG__ XDG_DATA_HOME=__MUSEDATA__ MUSE_EXPERIMENTAL_FOREIGN_PERSONAL_CONTEXT_KILL=on __MUSEBIN__ --yolo __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; *) return 1 ;; esac } @@ -1227,6 +1233,17 @@ if [ "$KIND" = secondmate ] && [ "$HARNESS" = muse ]; then exit 1 fi +# Resolve the optional Automic Vault secret wrapper once, before any spawn setup, +# so an enabled-but-misconfigured home refuses loudly here rather than partway +# through launch. Empty unless config/av-inject (or FM_AV_INJECT) opts in; the +# verified templates carry a __AVINJECT__ placeholder immediately before the +# agent binary, and the raw unverified-adapter escape hatch has none, so +# injection deliberately applies to verified launches only (bin/fm-av-inject-lib.sh). +if ! AV_INJECT_PREFIX=$(fm_av_inject_prefix "$CONFIG"); then + echo "error: $FM_AV_INJECT_ERROR" >&2 + exit 1 +fi + case "$HARNESS" in pi|pi-signed) PI_BIN=$(resolve_pi_executable "$HARNESS") || { @@ -2722,6 +2739,7 @@ LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} +LAUNCH=${LAUNCH//__AVINJECT__/$AV_INJECT_PREFIX} case "$HARNESS" in pi|pi-signed) LAUNCH=${LAUNCH//__PIBIN__/"$(shell_quote "$PI_BIN")"} ;; cursor) LAUNCH=${LAUNCH//__CURSORBIN__/"$(shell_quote "$CURSOR_BIN")"} ;; diff --git a/docs/configuration.md b/docs/configuration.md index e0466d80d3..d541b59cca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -127,6 +127,17 @@ A Secondmate on a remote route is covered the same way: the primary resolves and The presence flag is session-scoped enablement, so it transfers at launch and is left unchanged by live convergence into a running home. See [`trace-context.md`](trace-context.md) for carrier semantics, supported routes, the manual fleet-restart requirement, the session boundary, and safety limits; `bin/fm-trace-context-lib.sh`'s header owns the exact mechanics, and [`verification/trace-context.md`](verification/trace-context.md) records repeatable evidence. +## Automic Vault secret injection (config/av-inject / FM_AV_INJECT) + +The optional local, gitignored `config/av-inject` toggle wraps every worker launch in `av inject +KEY... -- ` so the crewmate or secondmate receives the captain's static API keys from Automic Vault as environment variables, never written to disk and never printed. +It is OFF by default: absent, empty, `off`, `false`, `no`, `0`, or any unrecognized value disables injection and launches are unchanged, while `on`, `true`, `yes`, or `1` enables it. +`FM_AV_INJECT` overrides the file with the same truthiness and exists for tests. +Enable it only after the attended per-key `av save` migration is verified, because turning it on before the keys exist would make every injection fail closed and break every spawn. +When enabled, a home with no resolvable `av` CLI refuses to spawn with a clear error rather than launching without the secrets it was told to inject; the injected keys default to the researched static set and can be overridden for tests with `FM_AV_INJECT_KEYS`. +Injection applies to the verified launch templates only; the raw unverified-adapter escape hatch is never wrapped. +`config/av-inject` is primary-authoritative and inherited into secondmate homes like the other local config toggles, so a secondmate's own workers inherit the same injection. +`bin/fm-av-inject-lib.sh`'s header owns the exact mode parsing, key validation, and wrapper mechanics, and points to the private research record that holds the Automic Vault security model, the migration prerequisites (verified launcher, per-secret Direct Access Rules, availability), and the key inventory. + ## Gate defaults (.no-mistakes.yaml) The tracked `.no-mistakes.yaml` keeps test evidence outside the repo and pins `commands.lint` to `bin/fm-lint.sh` so local lint matches CI. @@ -326,7 +337,7 @@ When a running home advances and its loaded instruction surface (`AGENTS.md`, `b If that send fails, bootstrap keeps an idempotent retry marker and emits `NUDGE_SECONDMATES:` with the failure reason. The same bootstrap run emits `SECONDMATE_LIVENESS:` only when a registered secondmate is skipped or its relaunch fails; already-live and successfully relaunched secondmates are handled silently. For a mid-session inherited local-material edit where tracked-file sync is not needed, run `bin/fm-config-push.sh`. -It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `backend`, `herdr-presentation-spaces`, `startup-memory-budget`, `trace-context`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. +It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `backend`, `herdr-presentation-spaces`, `startup-memory-budget`, `trace-context`, `av-inject`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. When an allowlisted config item changes for an already-running local home, it sends the literal-content reread pointer described in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md); unchanged allowlisted config sends no pointer unless a previous delivery is pending. A changed remote home instead receives one durably recorded marked re-read instruction after the allowlisted bytes have transferred because primary-local generation paths are not meaningful on another host. The locked bootstrap inheritance pass uses the same placement-specific behavior; see `secondmate-provisioning` for the single contract owner. diff --git a/tests/fm-av-inject.test.sh b/tests/fm-av-inject.test.sh new file mode 100755 index 0000000000..1ba758d730 --- /dev/null +++ b/tests/fm-av-inject.test.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# tests/fm-av-inject.test.sh - unit tests for the Automic Vault secret-injection +# library (bin/fm-av-inject-lib.sh) plus a spawn-path integration regression that +# proves bin/fm-spawn.sh wraps a real worker launch in `av inject +KEY... --` +# only when the home opts in. Uses a fake `av`, a fake tmux, and a real isolated +# git worktree - no live harness and no real Automic Vault app required. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=/dev/null +. "$ROOT/bin/fm-av-inject-lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-av-inject) + +# --- fm_av_inject_mode: default-off with truthy precedence ------------------- + +CFG_ON="$TMP_ROOT/cfg-on"; CFG_OFF="$TMP_ROOT/cfg-off"; CFG_GARBAGE="$TMP_ROOT/cfg-garbage" +mkdir -p "$CFG_ON" "$CFG_OFF" "$CFG_GARBAGE" +printf 'on\n' > "$CFG_ON/av-inject" +printf 'off\n' > "$CFG_OFF/av-inject" +printf 'maybe\n' > "$CFG_GARBAGE/av-inject" + +unset FM_AV_INJECT +[ "$(fm_av_inject_mode "$TMP_ROOT/nope")" = off ] || fail "absent config/av-inject must be off by default" +[ "$(fm_av_inject_mode "$CFG_OFF")" = off ] || fail "explicit off must be off" +[ "$(fm_av_inject_mode "$CFG_GARBAGE")" = off ] || fail "an unrecognized value must fail safe to off" +[ "$(fm_av_inject_mode "$CFG_ON")" = on ] || fail "explicit on must enable" +for truthy in on true yes 1 ON True YES; do + printf '%s\n' "$truthy" > "$CFG_ON/av-inject" + [ "$(fm_av_inject_mode "$CFG_ON")" = on ] || fail "'$truthy' must enable" +done +printf 'on\n' > "$CFG_ON/av-inject" +[ "$(FM_AV_INJECT=off fm_av_inject_mode "$CFG_ON")" = off ] || fail "FM_AV_INJECT=off must override a present on file" +[ "$(FM_AV_INJECT=on fm_av_inject_mode "$CFG_OFF")" = on ] || fail "FM_AV_INJECT=on must override an off file" +[ "$(FM_AV_INJECT='' fm_av_inject_mode "$CFG_ON")" = on ] || fail "empty FM_AV_INJECT must defer to a present on file" +pass "fm_av_inject_mode is default-off; FM_AV_INJECT overrides with truthy/other precedence, unset/empty defers to the file" + +# --- fm_av_inject_prefix: disabled is an empty, successful no-op -------------- + +out=$(fm_av_inject_prefix "$CFG_OFF"); rc=$? +expect_code 0 "$rc" "disabled prefix must succeed" +[ -z "$out" ] || fail "disabled prefix must be empty, got '$out'" +pass "fm_av_inject_prefix is a successful empty no-op when disabled" + +# --- fm_av_inject_prefix: enabled but no av on PATH refuses (fail closed) ----- + +EMPTY_BIN="$TMP_ROOT/empty-bin"; mkdir -p "$EMPTY_BIN" +out=$(PATH="$EMPTY_BIN" fm_av_inject_prefix "$CFG_ON" 2>&1); rc=$? +expect_code 1 "$rc" "enabled-but-missing-av must refuse" +pass "fm_av_inject_prefix refuses when enabled but the av CLI is missing" + +# --- fm_av_inject_prefix: enabled wraps in ` inject +KEY... -- ` ---------- + +FAKE_BIN=$(fm_fakebin "$TMP_ROOT/av") +cat > "$FAKE_BIN/av" <<'SH' +#!/usr/bin/env bash +exit 0 +SH +chmod +x "$FAKE_BIN/av" + +out=$(PATH="$FAKE_BIN:$PATH" fm_av_inject_prefix "$CFG_ON"); rc=$? +expect_code 0 "$rc" "enabled prefix with av present must succeed" +assert_contains "$out" "inject +EXA_API_KEY " "prefix must inject the first default key" +assert_contains "$out" "+BUZZ_XYZ_KEY " "prefix must inject the renamed buzz.xyz key" +assert_contains "$out" "$FAKE_BIN/av" "prefix must invoke the resolved absolute av path" +# Ends with `-- ` so the caller splices the agent binary immediately after it. +case "$out" in *' -- ') : ;; *) fail "prefix must end with '-- ', got '$out'" ;; esac +pass "fm_av_inject_prefix emits ' inject +KEY... -- ' for the default key set" + +# --- fm_av_inject_prefix: an invalid key name refuses ------------------------ + +out=$(PATH="$FAKE_BIN:$PATH" FM_AV_INJECT_KEYS='GOOD_KEY bad-key' fm_av_inject_prefix "$CFG_ON" 2>&1); rc=$? +expect_code 1 "$rc" "an invalid key name must refuse" +out=$(PATH="$FAKE_BIN:$PATH" FM_AV_INJECT_KEYS='1LEADING_DIGIT' fm_av_inject_prefix "$CFG_ON" 2>&1); rc=$? +expect_code 1 "$rc" "a key name starting with a digit must refuse" +pass "fm_av_inject_prefix rejects key names outside [A-Za-z_][A-Za-z0-9_]*" + +# --- spawn integration: opt-in wraps a real claude launch -------------------- + +SPAWN="$ROOT/bin/fm-spawn.sh" + +# Fake tmux: answers the pane-path query and logs the literal launch command. +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|kill-window) exit 0 ;; + send-keys) + if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then + shift + skip_next= + for a in "$@"; do + if [ -n "$skip_next" ]; then skip_next=; continue; fi + case "$a" in + -t) skip_next=1; continue ;; + -l) continue ;; + Enter|C-m) continue ;; + *) printf '%s\n' "$a" >> "$FM_FAKE_LAUNCH_LOG" ;; + esac + done + fi + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + # A fake `av` so the resolved absolute path exists for the wrapped launch. + cat > "$fakebin/av" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fakebin/av" + printf '%s\n' "$fakebin" +} + +make_spawn_case() { + local name=$1 case_dir home proj wt fakebin launchlog id + case_dir="$TMP_ROOT/spawn-$name" + home="$case_dir/home"; proj="$case_dir/project"; wt="$case_dir/wt" + launchlog="$case_dir/launch.log" + fakebin=$(make_spawn_fakebin "$case_dir/fake") + mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" + printf 'claude\n' > "$home/config/crew-harness" + printf '%s\n' "$$" > "$home/state/.lock" + touch "$home/state/.last-watcher-beat" + fm_git_worktree "$proj" "$wt" "wt-$name" + id="$name-z1" + mkdir -p "$home/data/$id" + printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" + printf '%s|%s|%s|%s|%s|%s\n' "$home" "$proj" "$wt" "$fakebin" "$launchlog" "$id" +} + +run_spawn() { + local home=$1 wt=$2 fakebin=$3 launchlog=$4 id=$5 proj=$6 + : > "$launchlog" + env -u FM_AV_INJECT -u FM_AV_INJECT_KEYS \ + FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + FM_FAKE_LAUNCH_LOG="$launchlog" PATH="$fakebin:$PATH" \ + "$SPAWN" "$id" "$proj" --mode no-mistakes --yolo off 2>&1 +} + +# Opt in: the launch is wrapped, env prefixes stay before `av inject`, and the +# agent binary follows `-- `. +rec=$(make_spawn_case on) +IFS='|' read -r HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR LAUNCH_LOG CASE_ID < "$HOME_DIR/config/av-inject" +out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$CASE_ID" "$PROJ_DIR") +assert_contains "$out" "spawned $CASE_ID" "opt-in spawn should report success" +launch=$(cat "$LAUNCH_LOG") +assert_contains "$launch" "inject +EXA_API_KEY" "wrapped launch must inject the secrets" +assert_contains "$launch" "-- claude --dangerously-skip-permissions" "the agent binary must follow the inject boundary" +# The claude prompt-suggestion env prefix must precede `av inject` so it is +# inherited into the injected child rather than being read as the exec target. +prefix_before_inject=${launch%%inject +*} +assert_contains "$prefix_before_inject" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false" "env prefixes must sit before av inject" +pass "an opted-in home wraps the real claude launch in av inject with env prefixes preserved before it" + +# Default (no config): the launch is unchanged, no inject wrapper. +rec=$(make_spawn_case off) +IFS='|' read -r HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR LAUNCH_LOG CASE_ID <