diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index fd6926b2183..203b8bbb5fe 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, NETWORK_CHECKS, HOME_SUMMARY, BACKLOG_RECONCILE, SECONDMATE_SYNC, SECONDMATE_LIVENESS, SECONDMATE_HANDOFF, NUDGE_SECONDMATES, or FMX - or reports that an interrupted backlog cleanup may have left an endpoint or local copy, or when a standalone bin/fm-bootstrap.sh or bin/fm-startup-network.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH, FLEET_SYNC, NETWORK_CHECKS, HOME_SUMMARY, BACKLOG_RECONCILE, SECONDMATE_SYNC, SECONDMATE_LIVENESS, SECONDMATE_HANDOFF, NUDGE_SECONDMATES, or FMX - or reports that an interrupted backlog cleanup may have left an endpoint or local copy, or when a standalone bin/fm-bootstrap.sh or bin/fm-startup-network.sh run prints one of those lines. A silent bootstrap section, or any other BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -35,6 +35,8 @@ When any diagnostic needs captain attention, report the plain consequence and re - `STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - ` - the visible startup-memory budget is not a safe one-line positive decimal file; do not infer the default or propagate it. Correct the local primary file, then rerun session start so the normal convergence path can deliver the validated value to secondmate homes. - `CREW_DISPATCH: invalid config/crew-dispatch.json - ` - the optional dispatch profile file exists but failed low-cost bootstrap validation; stop profile-based dispatch, report the actionable error, and require correction of the malformed schema, unverified harness name, or invalid harness/effort pair rather than falling back around it or selecting a bad profile. +- `CREW_DISPATCH: config/crew-dispatch.json - cursor is ineligible for ordinary dispatch here: ` - the file is schema-valid and profile-based dispatch continues, but a rule naming `cursor` resolves a crewmate or scout spawn onto an adapter that is refused for unattended work. + Report it and have the rule name a harness verified for unattended work, unless every spawn that rule routes will pass `--cursor-exemption`; `docs/configuration.md` owns that harness rule and this advisory. - `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. - `FLEET_SYNC: : recovered: ` - the clone had drifted onto a clean detached HEAD holding no unique commits and the sync self-healed it (re-attached the default branch and fast-forwarded); no action needed, it is reported only so the self-heal is visible. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 1d170ed10ff..789ae9da051 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -35,7 +35,7 @@ For recovery and control, use the exact `harness=` in `state/.meta`; never i Deliver lifecycle actions only through `../../../bin/fm-control.sh interrupt|exit|relaunch`. Never type an interrupt key or exit command through `fm-send`, where routing-marked lifecycle text becomes chat. Trust handling is complete only when inspection proves the target started processing its instructions; delivery success alone is not proof. -Muse is verified only for crewmate and scout work, never a secondmate or primary. +A verified adapter is not automatically admitted to every task kind; its harness reference owns that boundary. ## Detection @@ -52,7 +52,6 @@ A new adapter's verified marker and command name must land in `../../../bin/fm-h Every emitted plan appends the selected or recorded harness reference after the named common references. The `harness-adapter-routing-v1` object is the machine-readable and human-visible selection contract: choose the operation, choose the scenario within it, then append the selected harness reference. `default` is the normal scenario when no narrower scenario applies. -Kimi establishes its unsupported primary boundary in its selected harness reference; Muse follows Non-negotiable safety above. A new tool remains undispatchable until the `verify` plan, its harness entry, every named owner, and the live checks land. ```json harness-adapter-routing-v1 diff --git a/.agents/skills/harness-adapters/references/common/dispatch.md b/.agents/skills/harness-adapters/references/common/dispatch.md index 96db331b557..0b2c08909be 100644 --- a/.agents/skills/harness-adapters/references/common/dispatch.md +++ b/.agents/skills/harness-adapters/references/common/dispatch.md @@ -16,7 +16,7 @@ The inherited dispatch file applies the same best-fit profiles there. ## Owners -`../../../bin/fm-spawn.sh` owns launch, autonomy, concrete flags, task-kind compatibility, and worker turn-end wiring. +`../../../bin/fm-spawn.sh` owns launch, autonomy, concrete flags, and worker turn-end wiring, and asks `fm_control_harness_supports_kind` in `../../../bin/fm-control-lib.sh`, the one owner of task-kind compatibility. Natural-language rules stay with firstmate, while scripts receive concrete axes. `../../../bin/fm-busy-lib.sh` owns semantic busy trust. diff --git a/.agents/skills/harness-adapters/references/harness/claude.md b/.agents/skills/harness-adapters/references/harness/claude.md index 44324e467a8..3f9a1a7498f 100644 --- a/.agents/skills/harness-adapters/references/harness/claude.md +++ b/.agents/skills/harness-adapters/references/harness/claude.md @@ -12,9 +12,25 @@ Busy hooks verified 2026-07-28 on Claude Code 2.1.220. | Skill | `/`, for example `/no-mistakes`. | | Model | `--model `; discover through the interactive `/model` picker, with alias or full-name shape documented by `claude --help`. | | Effort | `--effort `, verified on 2.1.196. | +| Autonomy | `--permission-mode auto`, so the worker runs under Claude's own classifier rather than `--dangerously-skip-permissions`, which bypassed every check. The spawn also sets `CLAUDE_CODE_DISABLE_FAST_MODE=1`; see "Auto mode" below. | -Fresh-worktree or first-machine launch may show trust or bypass-permissions confirmation. -Inspect within about 20 seconds, accept the required choice with `FM_HOME= ../../../bin/fm-send.sh --key Enter` unless already bound, and verify instructions started. +Fresh-worktree or first-machine launch may show the workspace-trust confirmation. +That is the only launch surface that still requires a keypress: inspect within about 20 seconds, accept the required choice with `FM_HOME= ../../../bin/fm-send.sh --key Enter` unless already bound, and verify instructions started. +The bypass-permissions confirmation can no longer appear, because Firstmate no longer passes `--dangerously-skip-permissions`. +Claude's auto-mode entry warning and its auto-mode-unavailable notice are transcript NOTIFICATIONS, not choices, and they need no acknowledgement. +Never send Enter for either one: the pane is already running, so that keystroke lands in the worker's composer. + +## Auto mode + +Auto mode is not unconditional, and the residual risk below is knowingly accepted rather than mitigated in code. +On 2.1.251 the session falls back to the prompting `default` mode when auto mode is unavailable for the account's plan, unavailable for the session model, disabled by settings, blocked because fast mode is on, or when the classifier transcript grows too long. +The fallback target is hardcoded to the prompting mode, so no setting redirects it to a non-prompting one, and a forced-unavailable run printed no warning at all. +An unattended crewmate has nobody to answer the permission prompt it falls back to, and the `claude-hook` busy fold keeps that pane reading as busy rather than surfacing a hold. +Even while auto mode IS in force the classifier can require confirmation for an individual call or block it outright, so a `git push` or `gh pr create` is not guaranteed to complete unattended. +The spawn sets `CLAUDE_CODE_DISABLE_FAST_MODE=1` to remove the one trigger a launch command controls, so a captain's own `/fast on` cannot degrade a running crewmate. +The plan, model, settings, and classifier-transcript triggers all remain server-controlled and are not launch-controllable, so a server-side degradation can still strand an unattended Claude worker. +It looks like a pane that stops progressing with no status write and no turn-end while still classified busy, so peek any such pane for a permission prompt before assuming it is working. +Firstmate threads an arbitrary `--model` from the dispatch profile into this same launch, so a model without auto-mode support is the most likely local cause. ## Composer ghost diff --git a/.agents/skills/harness-adapters/references/harness/codex.md b/.agents/skills/harness-adapters/references/harness/codex.md index 5fb95b8e494..90c799cf504 100644 --- a/.agents/skills/harness-adapters/references/harness/codex.md +++ b/.agents/skills/harness-adapters/references/harness/codex.md @@ -14,6 +14,8 @@ Verified on 2026-06-11 with codex-cli 0.139.0 unless a fact gives a newer versio | Model flag | `--model `. | | Effort flag | `-c 'model_reasoning_effort=""'`, verified on codex-cli 0.142.1 whose installed schema contains `model_reasoning_effort`, active config uses it, and bundled catalog advertises only these four values while omitting `max`. | | Model discovery | Open the current interactive session's `/model` picker. | +| Autonomy | `-s workspace-write -a never` with `-c sandbox_workspace_write.network_access=true`, so the worker runs under codex's own sandbox and never prompts, rather than with both switched off as `--dangerously-bypass-approvals-and-sandbox` did. Verified on codex-cli 0.150.1. | +| Sandbox | `workspace-write` confines the worker's writes to the task worktree plus `/tmp` and `$TMPDIR`. The explicit `network_access` grant is a separate axis, because that sandbox otherwise denies network egress by default and a crewmate could not push, use `gh`, or install dependencies. | A directory trust dialog appears on the first run for a repository root: "Do you trust the contents of this directory?" Accept it with Enter and verify the instructions begin processing. diff --git a/.agents/skills/harness-adapters/references/harness/cursor.md b/.agents/skills/harness-adapters/references/harness/cursor.md index 3048a0a8347..084e8741b98 100644 --- a/.agents/skills/harness-adapters/references/harness/cursor.md +++ b/.agents/skills/harness-adapters/references/harness/cursor.md @@ -8,15 +8,16 @@ Cross-harness provider and credential identity is owned by `references/common/mo | Fact | Value | |---|---| | Binary | `fm_cursor_resolve_binary` in `../../../bin/fm-cursor-lib.sh` resolves stable launcher `cursor-agent` or legacy `agent`, never `cursor`; both symlink into `~/.local/share/cursor-agent/versions//cursor-agent`, whose target auto-update replaces. | -| Launch | Positional instructions with `--trust`, `--yolo`, optional `--model `, and `--workspace `, after clearing foreign primary markers. | +| Launch | Positional instructions with `--trust`, `--auto-review`, `--sandbox enabled`, optional `--model `, and `--workspace `, after clearing foreign primary markers. | | Models | Use current-account `cursor-agent --list-models` or legacy `agent --list-models`; the drifting observed list had only `cursor-grok-4.5-high` and `cursor-grok-4.5-high-fast` for Grok plus several `xhigh` ids, so choose a returned reasoning id and never assume low or medium Grok. | | Busy state | `../../../bin/fm-busy-lib.sh` folds the per-conversation transcript as `cursor-transcript`: `role:user` opens and typed `turn_ended` closes success or abort, covering manual interrupt; nothing is armed or seeded, and this backend-agnostic source was identical on tmux and Herdr. | | Exit command | `/exit`. | | Interrupt | Single Escape returns the placeholder with no clear key; control makes no cancellation claim because an aborted transcript close appeared within seconds in some runs and not within twenty in others. | | Skill invocation | `/`, for example `/no-mistakes`; Cursor discovers Firstmate's user skills. | | Resume | No verified native pane resume; use deterministic relaunch. | -| Autonomy | `--yolo`, documented alias for `--force`; footer `Run Everything`. | -| Trust | `--trust` suppresses the dialog; `--yolo` does not, and every task has a fresh path. | +| Autonomy | `--auto-review --sandbox enabled`, so the worker runs under Cursor's own review and sandbox controls. This replaced `--yolo` (the documented `--force` alias, footer `Run Everything`), which switched both off. Measured on 2026.08.25: `--sandbox enabled` genuinely confines writes under `--auto-review`, but `--force` overrides that confinement at any flag order, so Firstmate never passes `--force` or `--yolo`. | +| Trust | `--trust` suppresses the dialog; the autonomy flags do not, and every task has a fresh path. | +| Unattended work | Cursor is REFUSED for EVERY ordinary unattended kind: ship, scout, AND secondmate. `--auto-review` prompts for whatever its classifier does not deem safe, an unattended pane has no approver, and the `cursor-transcript` fold keeps a parked pane reading as working, so the stall never surfaces as a hold; a secondmate is the worst case because a whole Firstmate instance stalls invisibly. `fm_control_harness_supports_kind` in `../../../bin/fm-control-lib.sh` owns the rule for both the launch owner and the control plane, so a relaunch is refused before the running agent is stopped. The only opt-in is the per-spawn flag `--cursor-exemption attended` (a person is in the pane) or `--cursor-exemption envelope:` (the named outer isolation envelope that governs the worker), which is recorded as `cursor_exemption=` in the task meta; use Codex or Claude for ordinary unattended work. | | Marker | `CURSOR_INVOKED_AS=cursor-agent` on agent and children, plus `CURSOR_AGENT=1` on child or tool processes; other `CURSOR_*` variables are not identity markers. | | Effort | No verified flag; `references/common/model-and-effort.md` owns unsupported-value handling. | | Composer | Bare borderless row with `→` (U+2192); de-emphasized placeholders `Plan, search, build anything` when fresh and `Add a follow-up` later. | @@ -57,12 +58,23 @@ Reverse-video placeholder remnants and Herdr half-block edges belong to `../../. `../../../docs/verification/runtime-backends.md` owns captures. Refresh with `FM_HARNESS_LIVENESS_DRIFT=1 ../../../bin/fm-test-run.sh ../../../tests/fm-harness-liveness-drift-live-e2e.test.sh`. -## Worktree boundary +## Worktree boundary and exemption grants Firstmate enters its acquired worktree and passes the same absolute path through `--workspace`. Never pass Cursor `-w` or `--worktree`, which allocates a second copy under `~/.cursor/worktrees` and breaks isolation. The CLI supports repeatable `--add-dir`, but the adapter adds none; positional instructions need no grant to their private directory. -Example: `../../../bin/fm-spawn.sh --scout --harness cursor --model cursor-grok-4.5-high`. +Example of the ordinary case, which is REFUSED because nobody is watching a scout pane: `../../../bin/fm-spawn.sh --scout --harness cursor --model cursor-grok-4.5-high`. +Pass a grant only when it is true of this launch, because `attended` is an attestation that a person is sitting in the pane and `envelope:` names the outer isolation envelope an audit can go check. +The grant is per invocation and never ambient, so neither an exported variable nor an earlier attended launch can silently exempt a later unattended spawn. +A captain who will watch the pane themself adds `--cursor-exemption attended`; a worker running inside the approved routing benchmark adds `--cursor-exemption envelope:routing-benchmark` instead. +An envelope name is bounded to letters, digits, `.`, `_`, and `-` starting on a letter or digit, so a grant cannot be unauditable or carry a line break into the task record. +An explicitly passed grant is refused on a non-cursor harness rather than recorded, on the local and remote spawn routes alike, while one inherited from a task's own record is dropped when that task restarts onto another harness. +Across a relaunch or a `--secondmate` respawn, the two paths that restart a task from its own record, an `envelope:` grant is inherited while an `attended` one is not, because the person who attested may be gone by the time stuck-worker recovery relaunches; a fresh spawn always needs the flag. +A remote `--secondmate` route accepts `envelope:` only and refuses `attended`, because that attestation describes a person at this pane and says nothing about a worker started on another host. +A remote host that finds its endpoint already alive returns that route without applying a newly passed grant, so the parent records the grant the endpoint reports back rather than the one it requested and says on stderr that the request did not take effect; recovery then inherits the envelope that actually governs the running worker. +Automatic recovery of an enveloped cursor secondmate works on the remote route only, since the local liveness sweep does not act on a cursor endpoint. +A relaunch that needs a fresh attestation takes the grant on the verb itself: `../../../bin/fm-control.sh relaunch --cursor-exemption attended`. +Without that flag the spawn is refused, so route ordinary unattended scouting to Codex or Claude instead. ## Primary integration diff --git a/.agents/skills/harness-adapters/references/harness/muse.md b/.agents/skills/harness-adapters/references/harness/muse.md index b3642390cb7..05ca71663a7 100644 --- a/.agents/skills/harness-adapters/references/harness/muse.md +++ b/.agents/skills/harness-adapters/references/harness/muse.md @@ -1,12 +1,12 @@ # Muse Code Verified 2026-08-05 on Muse Code 0.1.0-R708.1, build sha 427a430436. -The router owns Muse's task-kind boundary. ## Operating facts | Fact | Value | |---|---| +| Task kinds | Crewmate and scout ONLY, never a secondmate or primary. `../../../bin/fm-spawn.sh` refuses a `--secondmate` launch, and `fm_control_harness_supports_kind` in `../../../bin/fm-control-lib.sh` owns the rule for the launch owner and the control plane, so an incompatible relaunch is refused before the running agent is stopped. Muse has no primary integration at all: the plugin engine (its only hook surface) is disabled in the default build, the Claude-compatible hook dialect names `asyncRewake` and model reawakening as explicitly unsupported - exactly what a primary's turn-end supervision needs - and no supervision protocol exists under `../../../docs/supervision-protocols/`, so a primary detected as muse falls back to the `unknown` protocol. | | Binary | Absolute `muse` from `PATH`, refused if absent; launcher `~/.local/bin/muse` execs versioned `muse-bin-`, so live process name changes on update. | | Launch | Positional instructions, like Grok or Pi. | | Models | `--model `; only provider `meta`. | @@ -62,9 +62,8 @@ Native children use per-child worktrees only with opt-in `--subagent-worktree-is It excludes `.claude/settings.local.json` because Firstmate writes it, but Muse scratch is worker output and must refuse cleanup when uncommitted. Inspect, never force past, that refusal. -## Maturity and primary limit +## Maturity Muse 0.1.0 is day-zero beta; its hourly channel poll can replace the binary and process name. The captain accepted this, so Firstmate does not set `MUSE_NO_AUTO_UPDATE=1`; a fleet may set it without adapter change. Plugins report unavailable unless `MUSE_EXPERIMENTAL_PLUGINS=on`, so busy state uses logs. -The compatibility dialect explicitly lacks `asyncRewake` and model reawakening; the router owns the resulting primary boundary. diff --git a/AGENTS.md b/AGENTS.md index 78e3b34cdf5..0939de5447d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,6 +199,9 @@ A silent bootstrap section needs no action; for any printed actionable diagnosti Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, and `cursor`, plus `muse` for crewmates and scouts only; never dispatch on an unverified adapter. +`cursor` is refused for every ordinary unattended kind (ship, scout, and secondmate), and a fresh launch stays available only through the per-spawn `--cursor-exemption attended` or `--cursor-exemption envelope:` grant, so route ordinary unattended work to `codex` or `claude`. +Each harness's complete task-kind boundary, including cursor's grant mechanics, restart inheritance, and audit rules, is owned by its harness reference in `harness-adapters`, with `fm_control_harness_supports_kind` in `bin/fm-control-lib.sh` answering kind and grant admissibility on every spawn and before a relaunch stops the running agent. +The harness-support section of `docs/configuration.md` owns local cursor secondmate recovery, and `docs/agent-control.md` owns what the pre-stop relaunch check is keyed on. If static `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. `docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. @@ -540,7 +543,7 @@ It performs guarded fast-forward updates of firstmate and registered secondmate These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `NETWORK_CHECKS:`, `HOME_SUMMARY:`, `BACKLOG_RECONCILE:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`), or when `BOOTSTRAP_INFO:` says an interrupted backlog cleanup may have left an endpoint or local copy; silence and other `BOOTSTRAP_INFO:` facts need no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH:`, `FLEET_SYNC:`, `NETWORK_CHECKS:`, `HOME_SUMMARY:`, `BACKLOG_RECONCILE:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`), or when `BOOTSTRAP_INFO:` says an interrupted backlog cleanup may have left an endpoint or local copy; silence and other `BOOTSTRAP_INFO:` facts need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `ask-user-authority` - load before deciding any ask-user finding. - `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi default TOON. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 28320608e20..ce0c8973ea0 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -1160,10 +1160,21 @@ crew_dispatch_validate() { | unique) as $bad_harnesses | if ($bad_harnesses | length) > 0 then "unverified harness: " + ($bad_harnesses | join(", ")) elif (bad_efforts | length) > 0 then "invalid effort: " + (bad_efforts | join(", ")) + elif (configured_profiles | map(.harness) | index("cursor")) then + "cursor-advisory:cursor is ineligible for ordinary dispatch here: these rules resolve the harness for crewmate and scout spawns, both of which are unattended, and cursor is refused for every ordinary unattended kind. A rule naming cursor is satisfied only when the spawn itself also passes --cursor-exemption attended or --cursor-exemption envelope:; without one it validates clean here and is refused at spawn time. Name a harness verified for unattended work, such as codex or claude, unless every spawn routed by this rule will carry a grant." else empty end end ' "$file" 2>/dev/null || true) + # A cursor rule is USABLE under a stated condition, unlike the schema failures + # above it, so it is reported as its own advisory rather than folded into the + # invalid-file channel that says the config cannot work at all. + case "$err" in + cursor-advisory:*) + echo "CREW_DISPATCH: config/crew-dispatch.json - ${err#cursor-advisory:}" + return 0 + ;; + esac if [ -n "$err" ]; then echo "CREW_DISPATCH: invalid config/crew-dispatch.json - $err" return 0 diff --git a/bin/fm-control-lib.sh b/bin/fm-control-lib.sh index 820444f58d5..725d6aa95d2 100644 --- a/bin/fm-control-lib.sh +++ b/bin/fm-control-lib.sh @@ -93,16 +93,217 @@ fm_control_harness_family() { # # Which task kinds an adapter is verified to run. muse is a crewmate/scout # adapter only: it has no primary supervision protocol, and bin/fm-spawn.sh -# refuses a --secondmate launch on it. The control plane -# asks this BEFORE it stops anything, so an incompatible relaunch target is +# refuses a --secondmate launch on it. +# +# cursor is refused for EVERY ordinary unattended kind - ship, scout, AND +# secondmate. It launches under --auto-review --sandbox enabled, which keeps a +# real filesystem sandbox but accepts that cursor's server classifier prompts +# for any call it does not deem safe. An unattended pane has no approver, and +# the cursor-transcript busy fold keeps a parked pane reading as working, so the +# stall never surfaces as a hold. A cursor secondmate is the worst case, because +# a whole firstmate instance stalls invisibly. +# +# The optional third argument is the caller's EXEMPTION grant, which is the only +# opt-in past the cursor refusal: +# attended - a person is in the pane and can answer the prompt. +# envelope: - the named outer isolation envelope governs the worker. +# Any other value, including an empty one, is no exemption, so an ordinary +# unattended spawn stays refused. The grant is PER TASK, never ambient: it +# arrives as bin/fm-spawn.sh's --cursor-exemption flag and is recorded in that +# task's own meta, so it can neither leak from one spawn to the next in a shell +# nor be inherited by an unrelated spawn. +# +# The harness is canonicalized first, so a raw launch command whose basename is +# `cursor-agent` is held to the same rule as the `cursor` adapter name rather +# than slipping past the table through the unverified-adapter escape hatch. +# +# This function is the ONE owner of which kinds an adapter may run: both muse's +# secondmate rule and cursor's unattended rule live here only, and +# bin/fm-spawn.sh asks it for every verified harness rather than repeating any +# of it, so the launch owner and the control plane cannot drift. The control +# plane asks it BEFORE it stops anything, so an incompatible relaunch target is # refused while the current agent is still running rather than after it has # been stopped. -fm_control_harness_supports_kind() { # - local harness=${1-} kind=${2-} - fm_control_harness_supported "$harness" || return 1 - case "$harness" in +fm_control_harness_supports_kind() { # [exemption] + local harness=${1-} kind=${2-} exemption=${3-} canonical + canonical=$(fm_control_harness_family "$harness") || return 1 + fm_control_harness_supported "$canonical" || return 1 + case "$canonical" in muse) [ "$kind" != secondmate ] || return 1 ;; + cursor) fm_control_cursor_exemption_valid "$exemption" || return 1 ;; + esac + return 0 +} + +# The ONE owner of what a cursor exemption grant may say. `attended` is a fixed +# token; an envelope grant must NAME its envelope in a bounded single-line +# charset, exactly the way every other recorded posture field is whitelisted. +# Two properties depend on that bound. An unnamed or free-form grant could not +# be audited back to a real envelope later, and the grant is written verbatim +# into the task record as `cursor_exemption=`, where a value carrying a +# newline would append a second `key=` line that fm_meta_get's last-match +# resolution would then prefer over the real one - a grant that could silently +# rewrite the task's recorded merge authority. Both refusal sites ask here. +fm_control_cursor_exemption_valid() { # + local grant=${1-} name + # The bracket ranges below are COLLATION-ordered, so without this the bound + # this function advertises would differ per machine: under en_US.UTF-8 + # `envelope:unicode-with-accents` matches [A-Za-z0-9] and is accepted, while + # under C it is refused. fm_task_id_path_safe in bin/fm-pr-lib.sh pins the + # locale for exactly this reason, and the one owner of the grant charset must + # answer the same question everywhere or the recorded grant is unauditable. + local LC_ALL=C + case "$grant" in + attended) return 0 ;; + envelope:*) + name=${grant#envelope:} + case "$name" in + ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 + ;; + esac + return 1 +} + +# The grant that is still in force for an UNATTENDED relaunch of a task whose +# record holds onto . Prints that grant, or +# nothing when none survives. Two independent conditions must both hold. +# +# envelope: describes a mechanically proven outer isolation envelope that +# still governs the replacement agent, so it carries over and automatic recovery +# keeps working. `attended` asserts that a PERSON IS IN THE PANE RIGHT NOW, +# which a later relaunch cannot inherit: the captain who attested may have +# walked away hours before firstmate's own stuck-worker recovery relaunches with +# nobody there. +# +# The grant also describes an exemption from CURSOR's unattended bar and nothing +# else, so it survives only onto cursor. A relaunch that resolves to another +# adapter drops it rather than carrying it into that task's record, where a +# later relaunch back onto cursor would read it as authority nobody granted for +# cursor. Dropping is right here and refusing is not: the harness switch itself +# is legitimate, and refusing it after the control plane had already stopped the +# agent is the stranding this helper exists to prevent. +# +# This is the ONE owner of that inheritance rule. bin/fm-spawn.sh's --relaunch +# path asks it for the grant it will actually launch under, and the control +# plane's PRE-STOP capability check in bin/fm-control.sh asks it for the grant +# it must evaluate the relaunch against. Restating the rule at either site +# would let the two answers disagree, and a disagreement here stops a running +# agent for a launch the owner then refuses. +fm_control_cursor_exemption_inherited() { # + local grant=${1-} target=${2-} + fm_control_cursor_exemption_valid "$grant" || return 0 + [ "$grant" != attended ] || return 0 + fm_control_cursor_exemption_applies "$target" || return 0 + printf '%s' "$grant" +} + +# Whether a cursor exemption is meaningful for a launch on . A grant +# accepted and RECORDED on another adapter would leave a stale cursor grant in +# that task's meta, which a later relaunch onto cursor would read back as +# authority nobody granted for cursor. Every spawn route - the local launch +# owner and the remote secondmate route alike - asks this one predicate, so a +# route cannot be the one that forgets the rule. +fm_control_cursor_exemption_applies() { # + local canonical + canonical=$(fm_control_harness_family "${1-}" 2>/dev/null) || return 1 + [ "$canonical" = cursor ] +} + +fm_control_cursor_exemption_harness_refusal() { # + printf -- "--cursor-exemption applies only to a cursor launch, but this spawn resolved harness=%s; drop the flag rather than recording a cursor grant that would outlive it" "${1-}" +} + +# The operator-facing reason a harness cannot run a kind. Both refusal sites - +# the launch owner in bin/fm-spawn.sh and the pre-stop relaunch check in +# bin/fm-control.sh - print this, so the diagnostic cannot drift from the table +# above the way a hand-written message at each site would. +fm_control_harness_kind_refusal() { # + local harness=${1-} kind=${2-} canonical cursor_remedy + canonical=$(fm_control_harness_family "$harness") || { + printf "'%s' is not a verified adapter, so it is not verified to run a %s task" "$harness" "$kind" + return 0 + } + case "$canonical" in + muse) + printf 'muse is a verified crewmate/scout adapter only and cannot run a secondmate; it has no primary supervision protocol. Select a harness verified for secondmates.' + ;; + cursor) + # The remedy is per KIND because the resolution chains differ, and naming a + # knob that cannot clear the refusal sends an operator to edit the wrong + # file and hit the identical message again. A secondmate resolves through + # config/secondmate-harness FIRST and falls back to config/crew-harness + # (bin/fm-harness.sh's resolve_secondmate), so BOTH knobs are named with the + # condition under which each works: naming only the first would be true but + # incomplete, and denying the second outright would be false in exactly the + # inherited-by-detection case this message opens with, where both are unset. + # crew-dispatch profiles are never consulted for a secondmate at all. It + # stays here rather than at the call sites so the one owner of the refusal + # owns its remedy too. + case "$kind" in + secondmate) cursor_remedy='set config/secondmate-harness to a verified adapter such as codex or claude, which always governs a secondmate, or set config/crew-harness while config/secondmate-harness is unset or "default", since a secondmate resolves the former first and falls back to the latter; crew-dispatch profiles are not consulted for a secondmate' ;; + *) cursor_remedy='set config/crew-harness to a verified adapter such as codex or claude, or add a crew-dispatch profile eligible for this kind' ;; + esac + printf "cursor is a verified adapter but is refused for an unattended %s launch: its --auto-review classifier prompts for calls it does not deem safe, the pane has no approver, and the parked pane keeps reading as busy. The bar applies however cursor was selected, INCLUDING when firstmate inherited it by detecting its own runtime, because silently substituting another tool would change which adapter runs the captain's work without saying so. If this home is running inside cursor and resolved it that way, %s; firstmate will not choose one for you. If a person is in the pane or a proven outer isolation envelope governs this worker, pass it on the invocation itself with --cursor-exemption attended or --cursor-exemption envelope:, which both bin/fm-spawn.sh and bin/fm-control.sh's relaunch verb accept." "$kind" "$cursor_remedy" + ;; + *) + printf "'%s' is not verified to run a %s task" "$canonical" "$kind" + ;; esac +} + +# The ONE composite question "would a launch with this profile be refused, and +# why". Prints the operator-facing reason and returns 1 when it would be refused; +# prints nothing and returns 0 when it would proceed. +# +# This exists because the control plane and the launch owner must ask the SAME +# question. bin/fm-spawn.sh asks it immediately before launching, and +# bin/fm-control.sh's relaunch asks it BEFORE it stops anything. Twice on this +# branch the pre-stop side asked a narrower question than the launch side - once +# missing the inheritance rule, once missing the non-cursor grant rule - and both +# times the result was identical: the pre-stop check passed, the running agent +# was stopped, and the replacement was then refused, stranding the task with no +# agent. Composing the POLICY rules here means a policy rule added for the launch +# path is automatically enforced pre-stop and the two cannot drift. +# +# The bound is deliberate and a contributor adding a launch-time refusal has to +# know it. What belongs here is every rule answerable from the recorded profile +# ALONE - harness, kind, and effective grant - because that is what the control +# plane can evaluate while the old agent is still running. What does not belong +# here is the ENVIRONMENTAL class, defined by its rule rather than by a roster +# that would go stale as adapters are added: any refusal whose answer depends on +# the state of THIS MACHINE rather than on the recorded profile. bin/fm-spawn.sh +# still refuses after this point for several of them - resolving an adapter's +# executable on PATH and probing a live model catalog are two examples, not the +# whole set. Those +# cannot be answered without probing this machine and running the harness binary, +# which this file must never do - it is sourced as a pure contract with no side +# effects, no backend command, and no state reads - and their answer can change +# between the check and the launch anyway, so asking them early would narrow the +# window without closing it and would buy a false sense of an absolute guarantee. +# The consequence is real and stays: `relaunch` onto a harness whose executable +# is missing stops the agent and is then refused. Add a new rule here when it is +# answerable from the profile; otherwise leave it at the launch and know that the +# relaunch transaction can strand on it. +# +# The order below is the order the refusals are reported in, so the reason the +# control plane prints pre-stop is the reason the launch would have printed. The +# harness-family check gates only the KIND rule, preserving the launch owner's +# escape hatch: an unverified raw launch command has no kind table to consult, +# while the grant rule applies to it regardless because the family canonicalizer +# still resolves a raw `cursor-agent` command to cursor. +fm_control_launch_refusal() { # + local harness=${1-} kind=${2-} grant=${3-} + if [ -n "$grant" ] && ! fm_control_cursor_exemption_applies "$harness"; then + fm_control_cursor_exemption_harness_refusal "$harness" + return 1 + fi + if fm_control_harness_family "$harness" >/dev/null 2>&1 && + ! fm_control_harness_supports_kind "$harness" "$kind" "$grant"; then + fm_control_harness_kind_refusal "$harness" "$kind" + return 1 + fi return 0 } diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 12387b0602d..70615abdd51 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -6,7 +6,10 @@ # fm-control.sh exit # fm-control.sh relaunch [--harness ] [--model ] # [--effort ] +# [--cursor-exemption >] # (--note | --note-file ) +# --cursor-exemption is a fresh per-invocation grant for THIS relaunch, the way +# to relaunch a cursor task whose recorded grant cannot be inherited. # # Why this exists, and how it differs from fm-send.sh. bin/fm-send.sh is the # DATA plane: conversational text for the agent to read, always routing-marked @@ -195,6 +198,8 @@ MODEL_SET=0 EFFORT_SET=0 NOTE= NOTE_SET=0 +NEW_CURSOR_EXEMPTION= +CURSOR_EXEMPTION_SET=0 control_want_value= for control_arg in "$@"; do if [ -n "$control_want_value" ]; then @@ -211,6 +216,7 @@ for control_arg in "$@"; do NOTE=$(cat "$control_arg") NOTE_SET=1 ;; + cursor_exemption) NEW_CURSOR_EXEMPTION=$control_arg; CURSOR_EXEMPTION_SET=1 ;; esac control_want_value= continue @@ -224,6 +230,8 @@ for control_arg in "$@"; do --effort=*) NEW_EFFORT=${control_arg#--effort=}; EFFORT_SET=1 ;; --note) control_want_value=note ;; --note=*) NOTE=${control_arg#--note=}; NOTE_SET=1 ;; + --cursor-exemption) control_want_value=cursor_exemption ;; + --cursor-exemption=*) NEW_CURSOR_EXEMPTION=${control_arg#--cursor-exemption=}; CURSOR_EXEMPTION_SET=1 ;; --note-file) control_want_value=note_file ;; --note-file=*) [ -f "${control_arg#--note-file=}" ] || die "--note-file '${control_arg#--note-file=}' is not a readable file" @@ -235,12 +243,22 @@ for control_arg in "$@"; do done if [ -n "$control_want_value" ]; then [ "$control_want_value" = note_file ] && die "--note-file requires a value" + [ "$control_want_value" = cursor_exemption ] && die "--cursor-exemption requires a value" die "--$control_want_value requires a value" fi +# The grant a caller passes HERE is a fresh per-invocation attestation for this +# relaunch, which is why `attended` is accepted on the verb even though it is +# never inherited from the record. bin/fm-control-lib.sh stays the one owner of +# what a grant may say, so an unusable value is refused before anything stops. +if [ "$CURSOR_EXEMPTION_SET" -eq 1 ]; then + fm_control_cursor_exemption_valid "$NEW_CURSOR_EXEMPTION" \ + || die "--cursor-exemption must be 'attended' (a person is in the pane) or 'envelope:' where starts with a letter or digit and continues with letters, digits, '.', '_', or '-'; '$NEW_CURSOR_EXEMPTION' names neither" +fi if [ "$VERB" != relaunch ]; then [ "$HARNESS_SET" = 0 ] && [ "$MODEL_SET" = 0 ] && [ "$EFFORT_SET" = 0 ] && [ "$NOTE_SET" = 0 ] \ - || die "--harness, --model, --effort, and --note apply to 'relaunch' only" + && [ "$CURSOR_EXEMPTION_SET" = 0 ] \ + || die "--harness, --model, --effort, --note, and --cursor-exemption apply to 'relaunch' only" fi [ "$HARNESS_SET" = 0 ] || [ -n "$NEW_HARNESS" ] || die "--harness requires a non-empty value" [ "$MODEL_SET" = 0 ] || [ -n "$NEW_MODEL" ] || die "--model requires a non-empty value" @@ -653,12 +671,36 @@ resolve_relaunch_profile() { else TARGET_HARNESS=$PRIOR_HARNESS fi - # The launch owner refuses an adapter that cannot run this task's kind, but it - # is only reached after the old agent has been stopped. Asking the same - # capability table here keeps that refusal on the pre-stop side of the - # transaction, where nothing has changed yet. - fm_control_harness_supports_kind "$TARGET_HARNESS" "$KIND" \ - || die "'$TARGET_HARNESS' is not verified to run a $KIND task, so relaunching $ID onto it would stop the running agent for a launch that must be refused; choose an adapter verified for this kind" + # The launch owner refuses a profile it cannot run, but it is only reached + # after the old agent has been stopped, so the SAME question is asked here + # while nothing has changed yet. Two properties make that equivalence hold. + # + # First the grant: an explicit --cursor-exemption on this verb is a fresh + # per-invocation attestation and is forwarded verbatim to the launch, while + # without one the launch inherits only what survives an unattended relaunch, + # which is what the shared inheritance owner returns. Either way this resolves + # the grant that will really be in force rather than the raw recorded value. + # + # Second the question itself: fm_control_launch_refusal composes every POLICY + # admissibility rule, so this cannot ask a narrower policy question than the + # launch will. Asking a subset is exactly how a running agent gets stopped for + # a launch that is then refused, and adding a policy rule to the launch path + # alone cannot reopen that gap while both sides call this one function. + # + # It is not an absolute guarantee, and the bound is stated where the owner is + # defined: any refusal whose answer depends on the state of this MACHINE rather + # than on the recorded profile is only discoverable by probing at launch, so it + # still lands after do_exit. A relaunch onto an adapter that is not installed + # here does strand the task, whichever adapter that is. + if [ "$CURSOR_EXEMPTION_SET" -eq 1 ]; then + RELAUNCH_EXEMPTION=$NEW_CURSOR_EXEMPTION + else + RELAUNCH_EXEMPTION=$(fm_control_cursor_exemption_inherited \ + "$(fm_meta_get "$META" cursor_exemption)" "$TARGET_HARNESS") + fi + if ! RELAUNCH_REFUSAL=$(fm_control_launch_refusal "$TARGET_HARNESS" "$KIND" "$RELAUNCH_EXEMPTION"); then + die "$RELAUNCH_REFUSAL Relaunching $ID onto it would stop the running agent for a launch that must be refused." + fi # A model or effort chosen for the previous harness does not transfer to a # different one, so an explicit harness change resets both axes unless the # caller names them too. @@ -830,6 +872,7 @@ do_relaunch() { spawn_args=("$ID" --relaunch --harness "$TARGET_HARNESS") [ "$TARGET_MODEL" = default ] || spawn_args+=(--model "$TARGET_MODEL") [ "$TARGET_EFFORT" = default ] || spawn_args+=(--effort "$TARGET_EFFORT") + [ "$CURSOR_EXEMPTION_SET" -eq 0 ] || spawn_args+=(--cursor-exemption "$NEW_CURSOR_EXEMPTION") if FM_CONTROL_RELAUNCH_TX="$RELAUNCH_TX" \ "$SCRIPT_DIR/fm-spawn.sh" "${spawn_args[@]}" >/dev/null; then RELAUNCH_META_PUBLISHED=1 diff --git a/bin/fm-remote-home-seed.sh b/bin/fm-remote-home-seed.sh index 7deafc40dcf..84a2c34db03 100755 --- a/bin/fm-remote-home-seed.sh +++ b/bin/fm-remote-home-seed.sh @@ -157,7 +157,12 @@ done < "$BRIEF" > "$TMP/charter.remote" PROJECTS_CSV= : > "$TMP/project.records" PROJECT_INDEX=0 -for project in "${PROJECT_NAMES[@]}"; do +# Under `set -u`, bash 3.2 (the stock macOS shell) treats "${arr[@]}" on an +# empty array as an unbound-variable error, so a --no-projects seed died here +# before doing any work. The `:-` form yields one empty element instead, which +# the guard below skips. +for project in "${PROJECT_NAMES[@]:-}"; do + [ -n "$project" ] || continue ORIGIN=${PROJECT_ORIGINS[$PROJECT_INDEX]} PROJECT_INDEX=$((PROJECT_INDEX + 1)) MODE_LINE=$(FM_HOME="$FM_HOME" FM_DATA_OVERRIDE="$DATA" "$SCRIPT_DIR/fm-project-mode.sh" "$project") diff --git a/bin/fm-remote-secondmate-control.sh b/bin/fm-remote-secondmate-control.sh index da0c5c9b161..20cfd27185a 100755 --- a/bin/fm-remote-secondmate-control.sh +++ b/bin/fm-remote-secondmate-control.sh @@ -2,7 +2,7 @@ # Host-local lifecycle control for the remote secondmate home selected by fm-on. # # Usage: -# fm-remote-secondmate-control.sh launch herdr [traceparent] +# fm-remote-secondmate-control.sh launch herdr [traceparent:] [exemption:] # fm-remote-secondmate-control.sh state # fm-remote-secondmate-control.sh route # fm-remote-secondmate-control.sh send [fire-and-forget] @@ -43,6 +43,10 @@ # the default-off path. print_route echoes the carrier the endpoint actually # holds, including for an already-alive endpoint that was not relaunched, so the # parent records the identity the agent really received rather than an intent. +# The optional launch cursor exemption is echoed back on the same terms and for +# the same reason: an already-alive endpoint is reused WITHOUT applying a newly +# supplied grant, so the parent must record the envelope that actually governs +# the running worker rather than the one this launch asked for. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -56,6 +60,10 @@ REMOTE_HERDR_SESSION=fm-remote . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-ff-lib.sh . "$SCRIPT_DIR/fm-ff-lib.sh" +# shellcheck source=bin/fm-trace-context-lib.sh +. "$SCRIPT_DIR/fm-trace-context-lib.sh" +# shellcheck source=bin/fm-control-lib.sh +. "$SCRIPT_DIR/fm-control-lib.sh" # shellcheck source=bin/fm-pending-reply-lib.sh . "$SCRIPT_DIR/fm-pending-reply-lib.sh" # shellcheck source=bin/fm-task-inbox-lib.sh @@ -123,16 +131,18 @@ state_value() { # ; prints recovery-grade state } print_route() { # - local id=$1 harness traceparent + local id=$1 harness traceparent exemption remote_endpoint_require "$id" harness=$(fm_meta_get "$REMOTE_ENDPOINT_META" harness) traceparent=$(fm_meta_get "$REMOTE_ENDPOINT_META" traceparent) + exemption=$(fm_meta_get "$REMOTE_ENDPOINT_META" cursor_exemption) printf 'schema=fm-remote-secondmate-control.v1\n' printf 'backend=%s\n' "$REMOTE_ENDPOINT_BACKEND" printf 'target=%s\n' "$REMOTE_ENDPOINT_TARGET" printf 'herdr_session=%s\n' "$REMOTE_HERDR_SESSION" printf 'harness=%s\n' "$harness" [ -z "$traceparent" ] || printf 'traceparent=%s\n' "$traceparent" + [ -z "$exemption" ] || printf 'cursor_exemption=%s\n' "$exemption" } cmd_route() { @@ -146,9 +156,74 @@ cmd_route() { print_route "$id" } +# Trailing arguments after the fixed five are SELF-DESCRIBING rather than +# positional, so a new field cannot be mistaken for an older one. The parent +# sends `traceparent:` and `exemption:`; anything else is rejected. +# +# Both directions of the mixed-version contract are stated here, because a fleet +# upgrades one host at a time and either side can be the older one. +# +# NEW parent, OLD remote: the older host takes its carrier POSITIONALLY as $6 +# and caps the verb at six arguments, so what breaks first is `traceparent:`, +# not `exemption:`. Once the parent's home enables config/trace-context the +# parent appends `traceparent:` as $6 on EVERY remote secondmate launch, +# the older host binds that whole token - prefix included - into its traceparent +# slot and forwards it as `--traceparent traceparent:00-...`, and its own +# fm-spawn refuses the non-W3C value. The blast radius is therefore every +# carrier-bearing launch to a not-yet-upgraded host, including ordinary codex and +# claude launches that have nothing to do with cursor, not just an exempted one. +# Adding a grant on top sends a seventh argument, which trips the older +# dispatcher's six-argument cap and exits on usage before it binds anything. +# Only the trace-off-plus-grant case mis-binds `exemption:` itself. Every one of +# those is loud and fails closed: none launches unexempted. +# +# OLD parent, NEW remote: the older parent sends its carrier as a BARE positional +# sixth argument, which matches neither prefix. Rejecting it would break every +# carrier-bearing remote spawn from a not-yet-upgraded parent, including ordinary +# codex and claude launches that have nothing to do with cursor, so a bare +# argument that is a well-formed W3C carrier is accepted as the traceparent it +# has always been. It can never be read as a grant: only the explicit +# `exemption:` prefix sets one, so an old parent still launches unexempted and +# the unattended bar still applies. A bare argument that is NOT a carrier is +# refused, so a typo or a future token cannot ride in through this branch. cmd_launch() { - local id=$1 harness=$2 model=$3 effort=$4 selected_backend=$5 traceparent=${6:-} - local current meta out herdr_session + local id=$1 harness=$2 model=$3 effort=$4 selected_backend=$5 + local current meta out herdr_session traceparent='' exemption='' extra + shift 5 + for extra in "$@"; do + case "$extra" in + traceparent:*) traceparent=${extra#traceparent:} ;; + exemption:*) exemption=${extra#exemption:} ;; + "") ;; + *) + if [ -z "$traceparent" ] && fm_trace_context_valid "$extra"; then + traceparent=$extra + else + die "unrecognized remote launch argument '$extra'; expected traceparent: or exemption:" + fi + ;; + esac + done + # Only an envelope grant crosses the wire, and this is the boundary that + # CONSUMES the grant, so it validates what it accepts rather than trusting the + # sender to have validated what it sent. The parent already refuses to compose + # `exemption:attended`, so nothing shipped reaches this - which is exactly why + # it belongs here: an `attended` grant asserts a person at the PARENT's pane + # and would be honored on this host as an attestation for a worker nobody is + # watching. + # + # The SHAPE of a grant is asked of fm_control_cursor_exemption_valid rather + # than matched here, so this boundary enforces exactly the rule its declared + # owner enforces - including the bounded envelope name - instead of a looser + # local copy that would let a value the owner rejects cross the wire. + case "$exemption" in + '') ;; + attended) die "a remote secondmate launch cannot carry an 'attended' cursor exemption: it asserts a person at the sending pane and says nothing about a worker on this host; send exemption:envelope: instead" ;; + *) + fm_control_cursor_exemption_valid "$exemption" \ + || die "unrecognized remote cursor exemption '$exemption'; a remote secondmate launch forwards only exemption:envelope:, where starts with a letter or digit and continues with letters, digits, '.', '_', or '-'" + ;; + esac validate_id "$id" validate_home "$id" @@ -187,6 +262,7 @@ cmd_launch() { [ "$model" = - ] || ARGS+=(--model "$model") [ "$effort" = - ] || ARGS+=(--effort "$effort") [ -z "$traceparent" ] || ARGS+=(--traceparent "$traceparent") + [ -z "$exemption" ] || ARGS+=(--cursor-exemption "$exemption") if ! out=$(HERDR_SESSION="$REMOTE_HERDR_SESSION" FM_HOME="$FM_ROOT" FM_ROOT_OVERRIDE="$FM_ROOT" \ FM_STATE_OVERRIDE="$CONTROL_STATE" FM_DATA_OVERRIDE="$CONTROL_DATA" \ FM_CONFIG_OVERRIDE="$TARGET_HOME/config" FM_SKIP_SECONDMATE_INHERIT=1 \ @@ -363,7 +439,13 @@ cmd_retire() { } case "${1:-}" in - launch) shift; [ "$#" -ge 5 ] && [ "$#" -le 6 ] || usage; cmd_launch "$@" ;; + # Only the FIXED five are counted here. The trailing arguments are + # self-describing, and cmd_launch's own loop is their single owner: it dies + # on anything that is not `traceparent:` or `exemption:`. Counting them here + # too would mean a second place to update for every new token, which is + # exactly how the exemption token came to be rejected by an upper bound of 6 + # while the parent already sent 7. + launch) shift; [ "$#" -ge 5 ] || usage; cmd_launch "$@" ;; state) shift; [ "$#" -eq 1 ] || usage; validate_id "$1"; validate_home "$1"; state_value "$1" ;; route) shift; [ "$#" -eq 1 ] || usage; cmd_route "$1" ;; send) shift; [ "$#" -ge 2 ] && [ "$#" -le 3 ] || usage; cmd_send "$@" ;; diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 5d5a6a2d8be..c2648e892e3 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1,9 +1,14 @@ #!/usr/bin/env bash # Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a # secondmate in its isolated firstmate home. -# Usage: fm-spawn.sh --mode --yolo [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] -# fm-spawn.sh --scout [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] -# fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] --secondmate +# Usage: fm-spawn.sh --mode --yolo [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--cursor-exemption >] +# fm-spawn.sh --scout [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--cursor-exemption >] +# fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] --secondmate [--cursor-exemption >] +# --cursor-exemption is the per-invocation grant that lets a cursor spawn past the +# unattended bar, either `attended` when a person is in the pane or +# `envelope:` when the named outer isolation envelope governs the worker. +# It is never inherited from the environment, and the grant is recorded in the +# task's meta and on the success line so an audit can tell the two apart. # --mode and --yolo are this task's delivery contract, REQUIRED for every ship # spawn and refused on --scout and --secondmate spawns. Firstmate resolves both # per task at intake (AGENTS.md section 7); data/projects.md holds the captain's @@ -151,7 +156,7 @@ # Batch dispatch: pass one or more `id=repo` pairs instead of a single , e.g. # fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout] # Each pair re-execs this script in single-task mode, so the single path stays the only -# source of truth; shared --scout/--harness/--model/--effort/--backend/--mode/--yolo +# source of truth; shared --scout/--harness/--model/--effort/--backend/--mode/--yolo/--cursor-exemption # applies to every pair. A ship batch therefore carries one delivery contract, and each # pair still checks it against its own brief; a batch spanning modes is two invocations. # If config/crew-dispatch.json exists, shared --harness is required for crewmate @@ -200,7 +205,12 @@ # items), on a config/backlog-backend=manual home, and in a home that keeps no # data/backlog.md. An automatic-backend home with a backlog but no compatible # tasks-axi refuses before creating any lifecycle state. -# On success prints: spawned harness= kind= [mode= yolo=] window= worktree= +# On success prints: spawned harness= kind= [mode= yolo=] [cursor_exemption=] window= worktree= +# cursor_exemption= appears only when a grant governs the worker this line reports, +# and carries the same value recorded in state/.meta so the grant is auditable. +# On the remote secondmate route that is the grant the remote endpoint actually +# holds, which differs from the requested one when an already-live endpoint was +# reused; every other route reports the grant this launch was made under. # A ship task records the explicit mode/yolo it was passed; a secondmate spawn records # mode=secondmate, yolo=off, home=, and projects=; a scout records neither, and both the # success line and state/.meta omit them. @@ -311,6 +321,11 @@ fm_refuse_if_gate_agent # set by the batch loop below), so the guard runs once for the batch, not once per pair. [ -n "${FM_SPAWN_NO_GUARD:-}" ] || "$FM_ROOT/bin/fm-guard.sh" || true KIND=ship +# The cursor unattended exemption is per invocation and never ambient: it is a +# flag on THIS spawn, recorded in THIS task's meta, so one deliberate attended +# launch cannot silently exempt a later unattended spawn in the same shell. +CURSOR_EXEMPTION= +CURSOR_EXEMPTION_SET=0 KIND_SET=0 HARNESS_ARG= MODEL= @@ -342,6 +357,7 @@ for a in "$@"; do mode) MODE=$a; MODE_SET=1 ;; yolo) YOLO=$a; YOLO_SET=1 ;; traceparent) TRACEPARENT_ARG=$a; TRACEPARENT_SET=1 ;; + cursor-exemption) CURSOR_EXEMPTION=$a; CURSOR_EXEMPTION_SET=1 ;; *) echo "error: internal parser state for --$want_value" >&2; exit 1 ;; esac want_value= @@ -365,6 +381,8 @@ for a in "$@"; do --yolo=*) YOLO=${a#--yolo=}; YOLO_SET=1 ;; --traceparent) want_value=traceparent ;; --traceparent=*) TRACEPARENT_ARG=${a#--traceparent=}; TRACEPARENT_SET=1 ;; + --cursor-exemption) want_value=cursor-exemption ;; + --cursor-exemption=*) CURSOR_EXEMPTION=${a#--cursor-exemption=}; CURSOR_EXEMPTION_SET=1 ;; *) POS+=("$a") ;; esac done @@ -376,6 +394,11 @@ done [ "$MODE_SET" -eq 0 ] || [ -n "$MODE" ] || { echo "error: --mode requires a non-empty value" >&2; exit 1; } [ "$YOLO_SET" -eq 0 ] || [ -n "$YOLO" ] || { echo "error: --yolo requires a non-empty value" >&2; exit 1; } [ "$TRACEPARENT_SET" -eq 0 ] || [ -n "$TRACEPARENT_ARG" ] || { echo "error: --traceparent requires a non-empty value" >&2; exit 1; } +[ "$CURSOR_EXEMPTION_SET" -eq 0 ] || [ -n "$CURSOR_EXEMPTION" ] || { echo "error: --cursor-exemption requires a non-empty value" >&2; exit 1; } +if [ "$CURSOR_EXEMPTION_SET" -eq 1 ] && ! fm_control_cursor_exemption_valid "$CURSOR_EXEMPTION"; then + echo "error: --cursor-exemption must be 'attended' (a person is in the pane) or 'envelope:' where starts with a letter or digit and continues with letters, digits, '.', '_', or '-' (the named outer isolation envelope that governs this worker); '$CURSOR_EXEMPTION' names neither, and a grant that is unnamed, free-form, or carrying a line break could neither be audited later nor written into the task record without displacing another recorded field" >&2 + exit 1 +fi # A parent-delivered carrier replaces this home's own resolution, so it is # refused unless it is a secondmate spawn carrying a strictly valid W3C value. # Nothing else may reach the pane's TRACEPARENT export. @@ -440,10 +463,25 @@ else fi fi +# The grant recorded on an EXISTING task's own durable record, or nothing when +# the task has no record yet. This is not ambient inheritance: it reads THIS +# task's own meta and nothing else, which is the same durable-record source +# --relaunch reads. Every path that restarts an existing task asks it, then +# passes the answer through fm_control_cursor_exemption_inherited so one rule - +# envelope survives, attended never does, and only onto cursor - governs the +# relaunch verb and firstmate's own secondmate liveness recovery alike. +spawn_recorded_cursor_exemption() { # + local meta="$1/$2.meta" + [ -f "$meta" ] && [ ! -L "$meta" ] || return 0 + fm_meta_get "$meta" cursor_exemption +} + spawn_remote_secondmate() { local id=$1 remote host root home harness positional model effort backend out rc meta tmp local remote_backend remote_target remote_harness remote_herdr_session registry_lock remote_lock remote_generation local remote_traceparent remote_recorded_traceparent sm_primary_head sync_out sync_rc + local remote_recorded_exemption remote_exemption_note + local remote_launch_refusal remote_launch_qualifier local -a launch_args id=${POS[0]:-} fm_task_id_creation_valid "$id" || { echo "error: invalid task id" >&2; return 2; } @@ -482,15 +520,60 @@ spawn_remote_secondmate() { else harness=$("$FM_ROOT/bin/fm-harness.sh" secondmate) fi - case "$harness" in - claude|codex|opencode|pi|pi-signed|grok|kimi|cursor) ;; - *) - fm_lock_release "$registry_lock" || true - fm_lock_release "$SPAWN_TASK_LOCK" || true - echo "error: remote secondmate spawn requires a verified harness adapter, not a raw launch command: $harness" >&2 - return 1 - ;; - esac + if ! fm_control_harness_supported "$harness"; then + fm_lock_release "$registry_lock" || true + fm_lock_release "$SPAWN_TASK_LOCK" || true + echo "error: remote secondmate spawn requires a verified harness adapter, not a raw launch command: $harness" >&2 + return 1 + fi + # firstmate's own secondmate liveness recovery re-runs `fm-spawn.sh + # --secondmate` with no flag, so without this an enveloped cursor secondmate + # could never be brought back after its endpoint died - the durable envelope + # that justified the grant still governs the replacement, which is the whole + # reason an envelope grant is inheritable and an attended one is not. + if [ "$CURSOR_EXEMPTION_SET" -eq 0 ]; then + CURSOR_EXEMPTION=$(fm_control_cursor_exemption_inherited \ + "$(spawn_recorded_cursor_exemption "$STATE" "$id")" "$harness") + fi + # This path launches and returns long before the shared guards below, so it + # asks the same owners rather than carrying its own copy of either rule: a + # remote cursor secondmate is the least observable parked pane there is, and it + # must not be the one spawn that walks around the bar. The grant is refused on + # a non-cursor harness HERE, before the round trip, for the same reason the + # local path refuses it: forwarding it would both record a stale cursor grant + # in this parent's task meta and hand the remote host a flag its own fm-spawn.sh + # refuses anyway. This route asks the shared composite rather than its + # constituent predicates, so a policy rule added to the owner reaches the remote + # route too - the local-versus-remote asymmetry has produced two real defects on + # this branch already, and a route standing outside the owner is how a third + # would arrive. + # The shared refusal offers both grant forms because both are valid on an + # ordinary launch, but only an envelope grant can describe a worker on another + # host, so this route qualifies the shared text rather than forking a second + # copy of it. The qualifier is attached ONLY to the cursor refusal it explains: + # the composite also refuses this route for reasons that have nothing to do + # with a grant, and stapling cursor guidance onto those would answer a question + # the operator did not ask - or, on a non-cursor harness carrying a grant, tell + # them to drop the flag and to pass one in the same breath. + remote_launch_qualifier= + if fm_control_cursor_exemption_applies "$harness"; then + remote_launch_qualifier=" On this REMOTE secondmate route only --cursor-exemption envelope: is accepted, because 'attended' asserts a person at this pane and cannot describe a worker on another host." + fi + if ! remote_launch_refusal=$(fm_control_launch_refusal "$harness" secondmate "$CURSOR_EXEMPTION"); then + fm_lock_release "$registry_lock" || true + fm_lock_release "$SPAWN_TASK_LOCK" || true + echo "error: ${remote_launch_refusal}${remote_launch_qualifier}" >&2 + return 1 + fi + # `attended` asserts that a person is at THIS pane, which says nothing about a + # worker started on another host, so it is refused here rather than forwarded. + # Only a named isolation envelope describes a property that still holds there. + if [ "$CURSOR_EXEMPTION" = attended ]; then + fm_lock_release "$registry_lock" || true + fm_lock_release "$SPAWN_TASK_LOCK" || true + echo "error: an 'attended' cursor exemption asserts a person at this pane and cannot authorize a worker on a remote host; pass --cursor-exemption envelope: naming the proven isolation envelope that governs the remote worker" >&2 + return 1 + fi model=${MODEL:--} effort=${EFFORT:--} if [ -z "$HARNESS_ARG" ] && [ -z "$positional" ]; then @@ -614,8 +697,15 @@ spawn_remote_secondmate() { if [ "$(fm_trace_context_session_effective "$STATE/.trace-context-effective")" = on ]; then remote_traceparent=$(FM_TRACE_CONTEXT=on fm_trace_context_resolve "$CONFIG" "$meta" || true) fi + # Self-describing trailing arguments; bin/fm-remote-secondmate-control.sh's + # cmd_launch owns the wire contract and its compatibility degradation. Only an + # envelope grant is forwardable: `attended` asserts a person at THIS pane and + # cannot describe a worker on another host, so it never crosses the wire. launch_args=("$id" "$harness" "$model" "$effort" "$backend") - [ -z "$remote_traceparent" ] || launch_args+=("$remote_traceparent") + [ -z "$remote_traceparent" ] || launch_args+=("traceparent:$remote_traceparent") + case "$CURSOR_EXEMPTION" in + envelope:?*) launch_args+=("exemption:$CURSOR_EXEMPTION") ;; + esac if out=$("$SCRIPT_DIR/fm-on.sh" "$id" fm-remote-secondmate-control.sh launch \ "${launch_args[@]}" < /dev/null 2>&1); then rc=0 @@ -665,6 +755,29 @@ spawn_remote_secondmate() { # reports it here so the parent does not deny the agent's actual identity. remote_recorded_traceparent=$(printf '%s\n' "$out" | sed -n 's/^traceparent=//p' | tail -1) fm_trace_context_valid "$remote_recorded_traceparent" || remote_recorded_traceparent= + # The cursor grant is read back from the same launch, on the same terms and for + # the same reason. When the remote host finds its endpoint already alive it + # returns that route WITHOUT applying this invocation's grant, so recording what + # this side requested would claim an isolation envelope that does not govern the + # live worker - and firstmate's own liveness recovery would later inherit that + # false record as authority for an envelope nobody proved. Only the grant the + # remote endpoint actually carries is recorded and reported. It is validated on + # arrival, like the carrier, and only in the envelope form: `attended` describes + # a person at a pane on the other host and is never authority here, and a value + # the one owner rejects is dropped rather than written into the task record. + remote_recorded_exemption=$(printf '%s\n' "$out" | sed -n 's/^cursor_exemption=//p' | tail -1) + case "$remote_recorded_exemption" in + envelope:*) fm_control_cursor_exemption_valid "$remote_recorded_exemption" || remote_recorded_exemption= ;; + *) remote_recorded_exemption= ;; + esac + # A remote host old enough not to echo the grant back cannot have applied one + # either - it refuses the forwarded argument outright - so an empty read-back is + # the truth there too, and the divergence below is reachable only by endpoint + # reuse. Say so rather than silently substituting a different grant, because the + # operator attested to an envelope that did not take effect. + if [ "$remote_recorded_exemption" != "$CURSOR_EXEMPTION" ]; then + echo "notice: remote secondmate $id kept its already-live endpoint, so this launch's cursor grant was not applied; it runs under ${remote_recorded_exemption:-no cursor grant} rather than the requested ${CURSOR_EXEMPTION:-none}, and the task record carries what actually governs it" >&2 + fi tmp="$meta.tmp.$$" { echo "window=remote:$id" @@ -675,6 +788,7 @@ spawn_remote_secondmate() { echo "kind=secondmate" echo "mode=secondmate" echo "yolo=off" + [ -z "$remote_recorded_exemption" ] || echo "cursor_exemption=$remote_recorded_exemption" echo "tasktmp=" echo "model=${model#-}" echo "effort=${effort#-}" @@ -710,7 +824,9 @@ spawn_remote_secondmate() { echo "error: remote secondmate $id launched, but its reply source could not be armed; endpoint metadata is preserved" >&2 return 1 fi - echo "spawned $id harness=$harness kind=secondmate mode=secondmate yolo=off window=remote:$id worktree=$home remote=$host backend=$remote_backend" + remote_exemption_note= + [ -z "$remote_recorded_exemption" ] || remote_exemption_note=" cursor_exemption=$remote_recorded_exemption" + echo "spawned $id harness=$harness kind=secondmate mode=secondmate yolo=off$remote_exemption_note window=remote:$id worktree=$home remote=$host backend=$remote_backend" return 0 } @@ -965,6 +1081,7 @@ if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in * # spanning several modes is two invocations rather than a silent mixed dispatch. [ "$MODE_SET" -eq 0 ] || shared_args+=(--mode "$MODE") [ "$YOLO_SET" -eq 0 ] || shared_args+=(--yolo "$YOLO") + [ "$CURSOR_EXEMPTION_SET" -eq 0 ] || shared_args+=(--cursor-exemption "$CURSOR_EXEMPTION") for pair in "${POS[@]}"; do case "$pair" in *=*) : ;; @@ -1247,12 +1364,42 @@ 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__)"' ;; + # + # --permission-mode auto runs the worker under claude's OWN classifier + # ("approve/deny permission prompts") instead of --dangerously-skip-permissions, + # which bypassed every check. Auto mode is NOT unconditional: claude 2.1.251 + # falls back to the prompting `default` mode when auto is unavailable for the + # plan, for the session model, while fast mode is on, or when the classifier + # transcript grows too long, and an unattended pane has nobody to answer the + # prompt it falls back to. CLAUDE_CODE_DISABLE_FAST_MODE=1 removes the one + # fallback trigger a launch can control (verified: the session then reports + # fast_mode_disabled_reason=disabled_by_env), so a captain's own /fast on + # cannot silently drop a crewmate back to interactive prompting. The + # remaining triggers are plan/model/server-side; the harness-adapters skill + # documents the resulting dialog so a degraded worker is recognizable. + claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_DISABLE_FAST_MODE=1 claude --permission-mode auto __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + # -s workspace-write -a never replaces --dangerously-bypass-approvals-and-sandbox: + # the worker runs under codex's OWN filesystem sandbox and never prompts, + # instead of running with both switched off. workspace-write confines writes + # to the task worktree plus /tmp and $TMPDIR, which is the intended blast + # radius for a crewmate. It also DENIES the supervision paths the crewmate + # contract needs outside that worktree; granting those back narrowly is a + # separate change and is deliberately not bundled here. + # + # sandbox_workspace_write.network_access=true is NOT part of that deferred + # write grant, it is a distinct axis: workspace-write confines NETWORK egress + # as well as writes, and codex defaults that key to false. Verified on codex + # 0.150.1 against a config-free CODEX_HOME - the worker's shell cannot resolve + # a host at all under the default, which would break `git push`, `gh`, and + # every dependency install the delivery contract needs. Setting it here rather + # than relying on the operator's own ~/.codex/config.toml makes the grant + # explicit and machine-independent; the write confinement above is unaffected + # (verified: a write outside the worktree is still denied with it on). codex) if [ "$kind" = secondmate ]; then - printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + printf '%s' 'codex __MODELFLAG____EFFORTFLAG__-s workspace-write -a never -c sandbox_workspace_write.network_access=true "$(__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' 'codex __MODELFLAG____EFFORTFLAG__-s workspace-write -a never -c sandbox_workspace_write.network_access=true -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__)"' ;; @@ -1267,15 +1414,30 @@ launch_template() { # grok (Grok Build TUI): a positional prompt starts the supervised interactive # session. --always-approve auto-approves every tool execution (verified: the # crewmate runs fully autonomously, no permission gate), which an unattended - # crewmate needs; it is the targeted equivalent of claude's - # --dangerously-skip-permissions. grok's turn-end signal does NOT ride the + # crewmate needs. grok's launch was deliberately left unchanged by the + # hardening that moved claude, codex, and cursor onto their own approval and + # sandbox controls. That is a scope decision, not a claim about grok: grok is + # not installed on the machine this change was verified on, so its available + # approval and sandbox controls were NOT re-examined here and no conclusion + # about them is asserted. 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__)"' ;; - # 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 - # --force alias whose TUI label is "Run Everything". --workspace pins the + # Cursor Agent CLI. --trust suppresses the workspace-trust prompt, which the + # autonomy flags do NOT cover and which would otherwise block every spawn, + # since each task gets a fresh worktree path cursor has never seen. + # --auto-review --sandbox enabled replaces the former --yolo (the --force + # alias whose TUI label is "Run Everything"): the worker runs under cursor's + # own review and sandbox controls rather than with both switched off. These + # were measured on cursor-agent 2026.08.25, not assumed. --sandbox enabled + # DOES confine writes under --auto-review (a write outside the worktree is + # denied), but --force defeats that confinement at any flag order, so --force + # and --yolo are deliberately NOT passed: either would claim a confinement + # the launch does not actually have. The accepted cost is that --auto-review + # runs a server classifier that "prompts for the rest", so a cursor pane CAN + # park on a dialog nobody is watching; the ship refusal below is what keeps + # that cost off the unattended implementation path. + # --workspace pins the # exact worktree. -w/--worktree is deliberately never passed: it allocates a # SECOND worktree under ~/.cursor/worktrees and would break firstmate's # isolation contract. The binary is resolved rather than named because @@ -1284,7 +1446,7 @@ 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' 'env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u FM_PI_HARNESS -u CURSOR_INVOKED_AS __CURSORBIN__ --trust --auto-review --sandbox enabled __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 @@ -1352,14 +1514,61 @@ case "$ARG3" in ;; esac -# muse is verified as a CREWMATE/SCOUT adapter only. A secondmate is a firstmate -# instance, so it needs a primary supervision protocol; muse has none, and its -# Claude-compatible hook dialect explicitly rejects the model-reawakening and -# asyncRewake handlers that firstmate's primary turn-end supervision is built on -# (muse 0.1.0-R708.1). Refusing here keeps that gap loud instead of standing up a -# secondmate whose supervision cycle could never be armed. -if [ "$KIND" = secondmate ] && [ "$HARNESS" = muse ]; then - echo "error: muse is a verified crewmate/scout adapter only and cannot run a secondmate; it has no primary supervision protocol. Select a harness verified for secondmates." >&2 +# Which kinds a verified adapter may run is owned by +# fm_control_harness_supports_kind in fm-control-lib.sh, and this asks it for +# EVERY verified harness rather than repeating any rule here, so a future entry +# in that table is enforced by the launch owner and the control plane alike. The +# control plane asks the same question BEFORE it stops a running agent, which is +# what keeps a refused relaunch from stranding a task with no agent at all. +# +# Two rules live in that table today. muse has no primary supervision protocol, +# so it cannot run a secondmate. cursor launches under --auto-review --sandbox +# enabled, which keeps a REAL filesystem sandbox but accepts that cursor's +# server classifier prompts for any call it does not deem safe; an unattended +# pane has no approver and the cursor-transcript busy fold keeps a parked pane +# reading as working, so cursor is refused for ship, scout, and secondmate +# alike, the last being the worst case because a whole firstmate instance stalls +# invisibly. The non-prompting alternative (--force, and its documented --yolo +# alias) defeats --sandbox enabled, so it is refused as false hardening rather +# than shipped. +# +# An unverified harness is skipped, not refused: a raw launch command is the +# documented escape hatch for an adapter with no template. The canonicalization +# inside the table still holds a raw `cursor-agent` command to the cursor rule. +# The harness this spawn resolved to is finally known here, so a RESTART can now +# ask the inheritance owner what the grant on this task's own record is still +# worth. A grant that does not survive onto this harness is dropped rather than +# carried into the record below. +# +# Restricted to the two paths that restart an EXISTING task from its own record: +# `--relaunch`, and a `--secondmate` spawn, which is the shape firstmate's own +# liveness recovery uses. An ordinary fresh ship or scout spawn is deliberately +# excluded even when its id already has a stale record, because inheriting there +# would hand a launch nobody granted an exemption for a grant from a previous +# one - the ambient, implicit authority the per-invocation flag exists to +# prevent. A fresh spawn keeps requiring the flag. +if [ "$CURSOR_EXEMPTION_SET" -eq 0 ] && + { [ "$RELAUNCH" -eq 1 ] || [ "$KIND" = secondmate ]; }; then + CURSOR_EXEMPTION=$(fm_control_cursor_exemption_inherited \ + "$(spawn_recorded_cursor_exemption "$STATE" "$ID")" "$HARNESS") +fi + +# Every POLICY admissibility rule is composed by fm_control_launch_refusal in +# bin/fm-control-lib.sh, asked here with the EFFECTIVE grant rather than with how +# the grant arrived, so flag, inheritance, and any later source go through one +# check. bin/fm-control.sh's relaunch asks the SAME function before it stops +# anything, which is what keeps a policy refusal on the pre-stop side of that +# transaction instead of stranding a task whose replacement is then refused. +# +# The environmental refusals BELOW this point are deliberately outside it: each +# one's answer depends on the state of this MACHINE - resolving an adapter's +# executable on PATH, probing a live model catalog, checking for a +# worker-reachable credential are examples rather than the whole set - which the +# control plane cannot ask without running the harness binary. A new rule that is +# answerable from harness, kind, and grant alone belongs in the composite; one +# that needs this machine belongs here and can strand a relaunch. +if ! SPAWN_LAUNCH_REFUSAL=$(fm_control_launch_refusal "$HARNESS" "$KIND" "$CURSOR_EXEMPTION"); then + echo "error: $SPAWN_LAUNCH_REFUSAL" >&2 exit 1 fi @@ -2868,7 +3077,7 @@ SPAWN_META_PATH=$SPAWN_META_TMP preserve_relaunch_meta() { awk -F= ' BEGIN { - split("window endpoint_task_id worktree project harness kind mode yolo tasktmp model effort busy_gen spawn_gen traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id home projects control_relaunch_tx", keys, " ") + split("window endpoint_task_id worktree project harness kind mode yolo tasktmp model effort cursor_exemption busy_gen spawn_gen traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id home projects control_relaunch_tx", keys, " ") for (i in keys) owned[keys[i]] = 1 } !($1 in owned) @@ -2883,6 +3092,7 @@ preserve_relaunch_meta() { echo "kind=$KIND" [ -z "$MODE" ] || echo "mode=$MODE" [ -z "$YOLO" ] || echo "yolo=$YOLO" + [ -z "$CURSOR_EXEMPTION" ] || echo "cursor_exemption=$CURSOR_EXEMPTION" echo "tasktmp=$TASK_TMP" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" @@ -3172,4 +3382,6 @@ fi SPAWN_DELIVERY= [ -z "$MODE" ] || SPAWN_DELIVERY=" mode=$MODE yolo=$YOLO" -echo "spawned $ID harness=$HARNESS kind=$KIND$SPAWN_DELIVERY window=$META_WINDOW worktree=$WT" +SPAWN_EXEMPTION_NOTE= +[ -z "$CURSOR_EXEMPTION" ] || SPAWN_EXEMPTION_NOTE=" cursor_exemption=$CURSOR_EXEMPTION" +echo "spawned $ID harness=$HARNESS kind=$KIND$SPAWN_DELIVERY$SPAWN_EXEMPTION_NOTE window=$META_WINDOW worktree=$WT" diff --git a/docs/agent-control.md b/docs/agent-control.md index 8d4aaf36fc4..677dbee0e25 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -91,7 +91,14 @@ Switching harness is therefore one ordinary relaunch rather than a separate mech - An unverified harness is refused rather than guessed at. - An implicit relaunch from a prefixed raw-command basename is refused before the agent or durable state is touched because its original launch command cannot be reconstructed. - An adapter that is not verified for this task's kind is refused **before** the running agent is stopped, not after. + That pre-stop guarantee covers the policy rules the control plane can evaluate from the recorded profile: harness, task kind, and the cursor grant that will be in force. + It does not cover environmental refusals, which only the launch can discover: relaunching onto a harness whose executable is missing from this machine, or onto a cursor model absent from its live catalog, stops the agent and is then refused, leaving the task with no agent and its work preserved. Muse is a crewmate and scout adapter only, so relaunching a secondmate onto it refuses while its agent is still up rather than leaving that secondmate with no agent when the launch owner refuses. + Cursor is the broader case: it is refused for ship, scout, and secondmate alike, because its `--auto-review` classifier can park an unattended pane on a prompt nobody answers while the pane still reads as busy. + That pre-stop answer is keyed on the grant the relaunch will actually run under, not on the raw `cursor_exemption=` value recorded in the task's own meta. + With no grant on the command line it asks about exactly what the launch owner will inherit: an `envelope:` grant survives the relaunch and the task relaunches, while an `attended` grant does not survive it and the relaunch is refused before its agent is stopped rather than after. + `relaunch` takes its own `--cursor-exemption attended` or `--cursor-exemption envelope:`, which is the supported way to relaunch a task whose recorded grant cannot be inherited: the flag is a fresh per-invocation attestation for this relaunch, it overrides the recorded value, and the pre-stop check evaluates it rather than the record. + A grant also survives only onto cursor, so a relaunch that switches to another adapter succeeds and drops `cursor_exemption=` from the record rather than refusing a legitimate harness switch. - A backend that cannot deliver the harness's interrupt key, or the composer clear that key needs, is refused rather than sent a different key. Orca's terminal API exposes only an interrupt and an Enter, so it can deliver neither Escape nor Ctrl+U. - `exit` and `relaunch` require a backend with a recovery-grade agent-state classifier - tmux and herdr - because without one the "the agent stopped" postcondition cannot be proven. diff --git a/docs/configuration.md b/docs/configuration.md index 07d04ea54a8..c3d7307eff9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -297,6 +297,22 @@ A cursor secondmate or primary runs the tracked project-scope `.cursor/hooks.jso Cursor typed-submit confirmation is verified on tmux and Herdr only. On Zellij, cmux, and Orca a typed-plane Cursor send (a harness-native invocation or an explicit backend target; ordinary text steers ride the durable inbox and exit 0 at enqueue) lands, but `fm-send` reports delivery unconfirmed and exits non-zero because their shared submit core does not consult the busy footer; [runtime backend verification](verification/runtime-backends.md#cursor-agent-cli) owns the evidence and transcript-state boundary. muse is verified for crewmate and scout launches ONLY, and `fm-spawn.sh` refuses it for a secondmate, because muse ships no usable hook surface for a primary session's turn-end supervision; [`docs/verification/muse.md`](verification/muse.md) owns that evidence. +cursor is refused for every ordinary UNATTENDED launch, ship, scout, and secondmate alike, because it runs under `--auto-review --sandbox enabled` and that classifier prompts for any call it does not deem safe, leaving a pane parked on a dialog no one answers while the `cursor-transcript` fold still reads it as working. +Set `config/crew-harness` or `config/secondmate-harness` to codex or claude for unattended work, since a cursor value there now fails at spawn time. +The bar holds however cursor was selected, so a home running inside Cursor that leaves both files unset resolves cursor by detection and is refused on the same terms as an explicit value. +The refusal names the exact knob for that kind, the harness file to set or, for a crewmate or scout, an eligible crew-dispatch profile to add, because firstmate never silently substitutes another adapter for the one this home resolved. +cursor remains permitted when a person is in the pane or a separately proven outer isolation envelope governs the worker, and each case requires passing `--cursor-exemption attended` or `--cursor-exemption envelope:` on the spawn itself. +That grant is per invocation and is recorded as `cursor_exemption=` in the task's meta, so it cannot leak to a later spawn in the same shell and an audit can tell the two grounds apart. +An envelope name is whitelisted to letters, digits, `.`, `_`, and `-` starting on a letter or digit, so a grant can neither be unauditable nor carry a line break into the record that would displace another recorded field. +An explicitly passed `--cursor-exemption` is refused outright on a non-cursor harness, on the local and remote spawn routes alike, rather than recorded where a later relaunch onto cursor could read it back as authority nobody granted. +A grant merely INHERITED from a task's own record is dropped instead of refused when that task is restarted onto a non-cursor harness, because refusing there would block a legitimate harness switch on a task that happens to carry a grant, and would do it after the control plane had already stopped the agent. +The practical consequence is that `bin/fm-control.sh relaunch --harness codex` on a cursor task succeeds and silently discards `cursor_exemption=`, so coming back to cursor later needs the grant passed again. +An `envelope:` grant is inherited across a restart because it describes a durable property that still governs the replacement agent, while an `attended` grant is never inherited because the person who attested may be gone. +That inheritance covers the two paths that restart an existing task from its own record, the `relaunch` verb and a `--secondmate` spawn. +Only the REMOTE secondmate route actually self-heals from it: the session-start liveness sweep respawns a dead remote endpoint with that shape, and the recorded envelope grant is what lets the replacement launch. +A LOCAL cursor secondmate is not auto-recovered at all, because the sweep's local branch acts only on the harnesses it has a verified state classifier for and reports any other as `unverified-harness` rather than calling `fm-spawn.sh`, so bringing one back is an explicit `bin/fm-control.sh relaunch`. +An ordinary fresh ship or scout spawn never inherits, even when its id already carries a stale record, because a launch nobody granted an exemption for must not pick one up from a previous launch. +`fm_control_harness_supports_kind` in [`bin/fm-control-lib.sh`](../bin/fm-control-lib.sh) owns that rule for both the launch owner and the control plane, so a relaunch onto cursor is refused before the running agent is stopped. muse also needs a worker-reachable credential before spawning, and the portable fleet path is the `/muse/auth.json` credential stored by `muse login`, because a caller-only `META_API_KEY` does not cross a long-lived backend daemon. New harnesses get verified through a supervised trial task before joining the set. The verified adapter evidence - each harness's busy-state source, interrupt and exit behavior, skill-invocation syntax, and per-harness quirks - lives in the skill tree rooted at [`.agents/skills/harness-adapters/SKILL.md`](../.agents/skills/harness-adapters/SKILL.md). @@ -370,6 +386,9 @@ See [`docs/examples/crew-dispatch.json`](examples/crew-dispatch.json) for a star When the file exists, bootstrap validates it with `jq`. Valid files stay silent by default; with `FM_BOOTSTRAP_VERBOSE_FACTS=1`, bootstrap emits `BOOTSTRAP_INFO: crew dispatch active config/crew-dispatch.json`, one `BOOTSTRAP_INFO:` fact per rule, and one fact for the optional default profile set. Malformed JSON, an empty or malformed rule/default array, an unverified harness, or an effort value unsupported by that harness is reported as `CREW_DISPATCH: invalid config/crew-dispatch.json - ...`; missing `jq` is reported through the normal `MISSING: jq` install-consent flow. +A schema-valid file whose rules or default name `cursor` is reported as the separate advisory `CREW_DISPATCH: config/crew-dispatch.json - cursor is ineligible for ordinary dispatch here: ...` rather than as invalid, because these rules only ever resolve unattended crewmate and scout spawns and the harness section above refuses cursor for them. +Such a rule is usable only for a spawn that also passes `--cursor-exemption`, so bootstrap says so here instead of validating clean and letting the spawn fail later. +That advisory takes the place of the verbose per-rule facts for the run that prints it. While the file remains present, no crewmate or scout spawn may proceed without an explicit resolved harness; malformed configuration must be reported and corrected rather than selected around. Secondmate homes inherit this file from the primary, so a secondmate's own crewmates apply the same dispatch profile behavior. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index c00af4256c2..48babf1bb11 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -816,6 +816,9 @@ App-server partial methods and raw socket experiments do not satisfy that bridge ## Cursor Agent CLI Cursor runs crewmate, scout, secondmate, and primary work; [`supervision.md`](supervision.md#cursor-primary-park-2026-08-13) owns the primary evidence. +The evidence below predates the unattended bar: `fm-spawn.sh` now refuses a cursor crewmate, scout, or secondmate unless the launch passes `--cursor-exemption attended` or `--cursor-exemption envelope:`, so reproducing these runs needs that flag. +It also predates the current launch posture, which is `--trust --auto-review --sandbox enabled`. +The `--yolo` and `--force` flags in the Autonomy and workspace-trust rows below were measured and then deliberately REFUSED, because `--force` was observed to defeat `--sandbox enabled` while being a documented alias of the `--yolo` it replaced, so those rows record what was tested rather than what firstmate launches. The evidence below was produced on 2026-08-11 against the installed signed CLI on macOS 26.5.2 arm64 with tmux 3.6a, running as `kunchenguid`, and extended on 2026-08-13 with the tmux composer verdict below. - Binary: `~/.local/bin/cursor-agent`, canonicalizing into `~/.local/share/cursor-agent/versions/2026.08.11-e8db854/cursor-agent`. diff --git a/docs/verification/trace-context.md b/docs/verification/trace-context.md index c6af19f8d41..e9937f3845f 100644 --- a/docs/verification/trace-context.md +++ b/docs/verification/trace-context.md @@ -16,7 +16,9 @@ A final assertion drives the file-decided path (`FM_TRACE_CONTEXT` unset) and pr The suite touches no real harness or live fleet. `tests/fm-session-start.test.sh` additionally proves only a lock-owning session start writes the effective state and a lock-refused read-only start leaves it unchanged. -The remote-route suite `tests/fm-remote-secondmate-trace-context.test.sh` (6 assertions) covers the Secondmate path that never reaches the local export site, driving the real chain - the parent's `bin/fm-spawn.sh`, `bin/fm-on.sh`, the real remote entrypoint, `bin/fm-remote-secondmate-control.sh`, and the remote host's own `bin/fm-spawn.sh` - over the deterministic SSH boundary with a stateful fake Herdr CLI, the backend a remote second mate always runs on, so the carrier the remote pane receives is read back from that pane's own log: disabled, the parent records no `traceparent=`, the remote pane receives no export, the remote home inherits no enablement flag, and the delivered snapshot is `FM_TRACE_CONTEXT=off` while `GOTMPDIR` still ships; enabled, the parent's recorded carrier, the remote endpoint's own record, and the exported pane value are one identical valid carrier sent after `GOTMPDIR` and before the launch command, with `FM_TRACE_CONTEXT=on` and the inherited flag delivered; a relaunch keeps that carrier verbatim in both the parent record and the pane export; a second remote route resolved from an environment holding a fixed ambient `TRACEPARENT` roots a trace id distinct from both that ambient carrier and the first route; the remote receiver accepts `config/trace-context` as ordinary declared inherited material while refusing `config/secondmate-harness`, which the primary deliberately does not propagate; and the delivery argument that carries a parent's carrier to a remote host is refused on a ship spawn, on a shell-metacharacter value, on an all-zero trace id, and on an empty value, so nothing but a strict W3C carrier on a Secondmate launch can reach a pane export. +The remote-route suite `tests/fm-remote-secondmate-trace-context.test.sh` (15 assertions, of which the 6 described here are the trace-context ones) covers the Secondmate path that never reaches the local export site, driving the real chain - the parent's `bin/fm-spawn.sh`, `bin/fm-on.sh`, the real remote entrypoint, `bin/fm-remote-secondmate-control.sh`, and the remote host's own `bin/fm-spawn.sh` - over the deterministic SSH boundary with a stateful fake Herdr CLI, the backend a remote second mate always runs on, so the carrier the remote pane receives is read back from that pane's own log: disabled, the parent records no `traceparent=`, the remote pane receives no export, the remote home inherits no enablement flag, and the delivered snapshot is `FM_TRACE_CONTEXT=off` while `GOTMPDIR` still ships; enabled, the parent's recorded carrier, the remote endpoint's own record, and the exported pane value are one identical valid carrier sent after `GOTMPDIR` and before the launch command, with `FM_TRACE_CONTEXT=on` and the inherited flag delivered; a relaunch keeps that carrier verbatim in both the parent record and the pane export; a second remote route resolved from an environment holding a fixed ambient `TRACEPARENT` roots a trace id distinct from both that ambient carrier and the first route; the remote receiver accepts `config/trace-context` as ordinary declared inherited material while refusing `config/secondmate-harness`, which the primary deliberately does not propagate; and the delivery argument that carries a parent's carrier to a remote host is refused on a ship spawn, on a shell-metacharacter value, on an all-zero trace id, and on an empty value, so nothing but a strict W3C carrier on a Secondmate launch can reach a pane export. +Its other 9 assertions reuse this same remote fixture to cover the cursor unattended bar on the remote route - refusal before the host is reached, a grant refused rather than recorded on a non-cursor harness, an envelope grant and a carrier crossing the wire together, a reused already-live endpoint recording the grant that governs it rather than the one the repeat launch requested, envelope-versus-attended inheritance on an unflagged respawn, and the receiver's handling of an older parent's bare positional carrier - because that route sends both self-describing trailing arguments and only this fixture drives it end to end. +[`../configuration.md`](../configuration.md) ("Harness support") owns the rule those assertions check. ```console $ bash tests/fm-trace-context-lib.test.sh | tail -1 diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 4d10fd164a7..bb4230cd308 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -520,7 +520,7 @@ test_spawn_writes_orca_metadata_and_launches_harness() { "spawn should reuse the implicit terminal returned by Orca worktree creation" assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f''export GOTMPDIR=/tmp/fm-orcaspawnz1/gotmp'$'\x1f''--enter'$'\x1f''--json' \ "spawn did not export GOTMPDIR through the Orca terminal" - assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions" \ + assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_DISABLE_FAST_MODE=1 claude --permission-mode auto" \ "spawn did not send the selected harness launch command through Orca" rm -rf "/tmp/fm-$id" pass "fm-spawn.sh --backend orca: reuses implicit terminal, records metadata, launches harness" diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 3423cc4acfc..f29aff4641a 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -1114,6 +1114,17 @@ test_crew_dispatch_validation() { [ "$out" = "$expect" ] || fail "$label: expected '$expect', got: $out" ;; grep) printf '%s\n' "$out" | grep -Fx "$expect" >/dev/null || fail "$label: missing '$expect' (got: $out)" ;; + contains) + case "$out" in + *"$expect"*) ;; + *) fail "$label: expected output containing '$expect', got: $out" ;; + esac ;; + absent) + case "$out" in + *"$expect"*) fail "$label: expected output NOT to contain '$expect', got: $out" ;; + esac ;; + *) + fail "$label: unknown row mode '$mode'" ;; esac done <<'ROWS' malformed dispatch config is flagged^{"rules":[^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - malformed JSON @@ -1128,7 +1139,11 @@ unsupported muse ultra effort is flagged^{"rules":[{"when":"muse ultra","use":{" unsupported opencode effort is flagged^{"rules":[{"when":"opencode work","use":{"harness":"opencode","model":"anthropic/claude-sonnet-4-5","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: opencode:high kimi model profile is accepted^{"rules":[{"when":"kimi work","use":{"harness":"kimi","model":"kimi-code/k3"}}]}^empty^ unsupported kimi effort is flagged^{"rules":[{"when":"kimi work","use":{"harness":"kimi","model":"kimi-code/k3","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: kimi:high -cursor model profile is accepted^{"rules":[{"when":"cursor work","use":{"harness":"cursor","model":"cursor-grok-4.5-high"}}]}^empty^ +cursor dispatch rule is flagged as ineligible^{"rules":[{"when":"cursor work","use":{"harness":"cursor","model":"cursor-grok-4.5-high"}}]}^contains^cursor is ineligible for ordinary dispatch here +cursor in an array use is flagged as ineligible^{"rules":[{"when":"big feature","use":[{"harness":"claude"},{"harness":"cursor"}]}]}^contains^cursor is ineligible for ordinary dispatch here +cursor as the default profile is flagged as ineligible^{"default":{"harness":"cursor"}}^contains^cursor is ineligible for ordinary dispatch here +cursor rule names the grant that satisfies it^{"rules":[{"when":"cursor work","use":{"harness":"cursor"}}]}^contains^--cursor-exemption attended +cursor rule is not reported as an invalid file^{"rules":[{"when":"cursor work","use":{"harness":"cursor"}}]}^absent^invalid config/crew-dispatch.json unsupported cursor effort is flagged^{"rules":[{"when":"cursor work","use":{"harness":"cursor","model":"cursor-grok-4.5-high","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: cursor:high array use with quota-balanced is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude","model":"claude-sonnet-5","effort":"high"},{"harness":"codex","model":"gpt-5.5","effort":"high"}],"select":"quota-balanced"}]}^empty^ array use without select is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude"},{"harness":"codex"}]}]}^empty^ diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index c3ab0415812..621b2dd07f9 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -121,6 +121,20 @@ SH exit 0 SH chmod +x "$fb/sleep" + # The two cases that exercise a SUCCESSFUL cursor relaunch reach + # fm_cursor_resolve_binary, an environmental check that lives past the shared + # composite, so without this stub they would pass or fail on whether the + # developer happens to have the vendor CLI installed. Cursor's resolver accepts + # a `cursor-agent` on PATH by name, which is the same stub + # tests/fm-spawn-dispatch-profile.test.sh uses for the same reason. + cat > "$fb/cursor-agent" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --list-models ]; then + printf '%b\n' "Available models\ncursor-grok-4.5-high - Grok 4.5 High" +fi +exit 0 +SH + chmod +x "$fb/cursor-agent" } # new_case [id] -> echoes a case dir with a live claude ship task. @@ -758,8 +772,10 @@ test_secondmate_relaunch_onto_a_crewmate_only_adapter_refuses_before_stop() { printf '%s' "$dir/smhome" > "$dir/fake/cwd" out=$(run_control "$dir" sm7 relaunch --harness muse); rc=$? expect_code 1 "$rc" "a crewmate-only adapter should refuse a secondmate relaunch" - assert_contains "$out" "not verified to run a secondmate task" \ + assert_contains "$out" "cannot run a secondmate" \ "the refusal should name the kind the adapter cannot run" + assert_contains "$out" "no primary supervision protocol" \ + "the refusal should give the adapter-specific reason, not a generic unverified line" [ "$(cat "$dir/fake/command")" = claude ] \ || fail "the refusal must land before the running agent is stopped" [ "$(meta_field "$dir" sm7 harness)" = claude ] \ @@ -818,6 +834,184 @@ test_ship_relaunch_ignores_the_crew_harness_config() { pass "fm-control relaunch: a ship task keeps its recorded harness instead of re-reading crew config" } +# The two cursor exemption grants differ in KIND, not degree, so relaunch treats +# them differently. envelope: describes a mechanically proven outer +# isolation envelope that still governs the replacement agent, so it survives. +# `attended` asserts a person is in the pane RIGHT NOW; the captain who attested +# may have left hours before firstmate's stuck-worker recovery relaunches, so +# inheriting it would let one attestation authorize unlimited unattended +# launches. Both directions are pinned: a silent revert of either would let an +# unattended cursor pane run on a stale human attestation. +test_relaunch_never_inherits_an_attended_cursor_grant() { + local dir out status + dir=$(new_case cursorattended rl40) + add_ship_task "$dir" rl40 cursor + printf 'cursor_exemption=attended\n' >> "$dir/home/state/rl40.meta" + printf 'zsh' > "$dir/fake/command" + out=$(run_spawn "$dir" rl40 --relaunch 2>&1) + status=$? + [ "$status" -ne 0 ] \ + || fail "a relaunch must not inherit an attended cursor grant, but the spawn succeeded: $out" + case "$out" in + *"refused for an unattended ship launch"*) ;; + *) fail "the relaunch should be refused on the cursor unattended rule, got: $out" ;; + esac + pass "fm-spawn --relaunch: an attended cursor grant is never inherited and the relaunch is refused" +} + +# The control plane's PRE-STOP capability check must ask about the grant the +# relaunch will actually run under, not the raw recorded one. This invocation +# passes no --cursor-exemption, so an `attended` record leaves the replacement +# launch with NO grant; if the pre-stop check accepted the record verbatim it +# would pass, +# stop the running agent, and only then hit the launch owner's refusal - leaving +# the task with no agent at all, which is precisely what the pre-stop check +# exists to prevent. +test_control_relaunch_refuses_an_attended_cursor_grant_before_stopping() { + local dir out rc + dir=$(new_case cursorattendedctl rl42) + add_ship_task "$dir" rl42 cursor + printf 'cursor_exemption=attended\n' >> "$dir/home/state/rl42.meta" + out=$(run_control "$dir" rl42 relaunch --note "stalled overnight"); rc=$? + expect_code 1 "$rc" "a relaunch that would launch unexempted onto cursor must be refused" + assert_contains "$out" "refused for an unattended ship launch" \ + "the refusal should come from the shared capability table" + [ "$(cat "$dir/fake/command")" = claude ] \ + || fail "the running agent must not be stopped for a relaunch that must be refused" + [ -z "$(cat "$dir/fake/literal")" ] \ + || fail "a refused relaunch must send nothing, but the pane received: $(cat "$dir/fake/literal")" + [ "$(meta_field "$dir" rl42 cursor_exemption)" = attended ] \ + || fail "a refused relaunch must leave the durable record untouched" + pass "fm-control relaunch: an attended cursor grant is refused before the agent is stopped" +} + +test_relaunch_inherits_a_named_isolation_envelope_grant() { + local dir out + dir=$(new_case cursorenvelope rl41) + add_ship_task "$dir" rl41 cursor + printf 'cursor_exemption=envelope:routing-benchmark\n' >> "$dir/home/state/rl41.meta" + printf 'zsh' > "$dir/fake/command" + out=$(run_spawn "$dir" rl41 --relaunch) + [ "$(meta_field "$dir" rl41 cursor_exemption)" = envelope:routing-benchmark ] \ + || fail "a named isolation envelope must survive relaunch, got '$(meta_field "$dir" rl41 cursor_exemption)'" + assert_contains "$out" "spawned rl41 harness=cursor" \ + "an enveloped cursor relaunch should proceed" + pass "fm-spawn --relaunch: a named isolation-envelope grant is inherited so automatic recovery still works" +} + +# THE pre-stop invariant for POLICY refusals, stated once and driven for every +# way it has been breached: if a relaunch would be refused on harness, kind, or +# grant, the running agent must never be stopped. Both known doors are covered +# here because each was found only after the other was closed - an `attended` +# record that the launch drops, and an explicit grant on a harness the launch +# will not record it on - and each time the cause was the same: the pre-stop +# check asked a NARROWER question than the launch would. Driving both through the +# control verb means a policy rule added to the launch path alone shows up here +# as a stranded agent. +# +# Environmental refusals are deliberately out of scope, matching the bound stated +# on fm_control_launch_refusal: any refusal that depends on the state of this +# machine, such as a missing harness executable, is only discoverable at launch, +# so those still strand a relaunch and no assertion here claims otherwise. +assert_relaunch_refused_without_stopping() { #