feat(harness): add crew-only cursor and agy adapters - #1
Merged
Merged
Conversation
added 16 commits
July 23, 2026 03:59
Add cursor-agent (Composer) and agy (Antigravity/Gemini) as CREW-ONLY, HERDR-ONLY crewmate harnesses (captain-approved divergence; design in data/captain.md, empirical verification in data/cursor-agy-verify/report.md). They are never a primary runtime and never a secondmate launcher, and fm-spawn fail-closes both gates before any backend or worktree work. Launch construction moves into bin/fm-launch-lib.sh (extracted verbatim from fm-spawn, protected by the existing fm-spawn-dispatch-profile regression) so the per-harness template and model/effort renderers are unit-testable: - cursor: `cursor-agent --trust --force <model> "$(cat brief)"`; effort is encoded in the parameterized model string (composer-2.5[effort=high]), so there is no standalone effort flag. - agy: `agy --dangerously-skip-permissions <model> --effort <low|medium|high> --prompt-interactive "$(cat brief)"`. agy workspace trust: an interactive agy launch gates on a per-workspace trust modal that --dangerously-skip-permissions does not cover, and trust is an exact-path entry in agy's shared global settings. bin/fm-agy-trust-lib.sh seeds the exact worktree path before launch (fm-spawn, fail-closed) and removes it at teardown (fm-teardown, main and secondmate-child paths); it is locked, atomic, idempotent, and leaves an unparseable settings file untouched. On herdr, agent-state, liveness, turn-end, and send-safety are all generic (native `agent get` + the pane.agent_status_changed event stream), so no adapter code and no turn-end hook are added, and no repo .cursor/hooks.json / .agents/hooks.json is ever written. Composer stays `unknown` (the safe default); no generic bare-glyph rule is added (agy's `>` glyph would be a dead-shell send hazard). tmux is documented out-of-scope. cursor/agy are added to the verified-adapter set in fm-bootstrap and fm-dispatch-select (agy effort low|medium|high; cursor no standalone effort); both are intentionally unscorable in quota-balanced selection (paid subs not tracked by quota-axi) and fall through to the uniform random fallback. Docs: harness-adapters SKILL and AGENTS.md section 4. Tests: colocated unit tests for the launch lib and trust lib; contract tests for the crew-only/herdr-only refusals and teardown trust cleanup; extended dispatch-select/bootstrap validation; and a skip-guarded live herdr-lab smoke that launches both CLIs through the real templates and confirms native detection. Full suite green; shellcheck 0.11.0 clean.
…e-path tests Address the independent adversarial review (data/cursor-agy-review/report.md): B1 - raw launch-command bypass: the raw escape hatch derived the harness from the first non-assignment word's basename (`cursor-agent`/`env`), so the cursor/agy crew-only/herdr-only guard (keyed on the exact tokens `cursor`/`agy`) missed it. Add fm_launch_raw_restricted_harness (resolves the real executable through `env` and leading VAR=val assignments, with an all-words backstop) and refuse any raw command that resolves to cursor-agent/agy outright - the raw path cannot provide the trust seed or native supervision they require, so callers use --harness. B2 - turn-end wake: the shared transition policy deliberately DEFERS herdr idle/done, and cursor/agy install no hook and write no status file, so a completed turn only ever surfaced via the unreliable content-hash stale heuristic. Add a native-identity-gated, DEBOUNCED completion detector: fm_transition_native_completion (pure, in fm-transition-lib.sh) plus fm-watch.sh's maybe_native_turnend, which reads the pane's native agent_status and touches state/<id>.turn-ended once a cursor/agy pane settles at idle/done across two consecutive polls - the same completion signal every other harness's hook writes, handled by the existing scan/wake path with no change to the shared policy or event stream. Adds fm_backend_agent_status (raw enum accessor). B3 - agy trust is now ownership-aware and transactional: fm_agy_trust_add reports created vs preexisting so firstmate removes ONLY an entry it created and never deletes a path the captain already trusted; the durable marker state/<id>.agy-trust drives ownership-aware teardown; the spawn abort trap rolls back a created entry; and teardown removal failure is an INCOMPLETE teardown that retains the marker + metadata for a deterministic retry (same contract on the secondmate-child path). B4 - the age-only 10s stale-lock breaker could steal a LIVE lock; replace it with the repository's ownership-and-liveness lock (fm_lock_*), which never reclaims a lock whose holder PID is alive and only lets the owner release. S1 - add the missing failure-path tests: raw-command bypass variants (direct, env, assignment-prefixed) on forbidden dimensions; created-vs-preexisting ownership; parallel mutation; a live-lock-held-past-10s theft test; spawn-abort rollback and teardown-incomplete-on-failure; and the live smoke now observes a working->idle/done transition and PROVES a native completion wake for both CLIs. Docs updated (harness-adapters skill, fm-launch-lib/fm-spawn comments) to describe the debounced native completion detector instead of the earlier inaccurate "native event stream drives turn-end" claim. Full suite green; shellcheck 0.11.0 clean. Pre-existing flake noted: tests/fm-watcher-lock.test.sh (unrelated beacon timing; reproduces identically on the pre-change fm-watch.sh).
Match the repo convention (nearly all tests/*.test.sh are mode 0755) so direct ./tests/<name>.test.sh invocation works, not only via the bash runner.
Address the adversarial re-review (data/cursor-agy-rereview/report.md):
B1 (still-open shell-wrapper bypass) - a quoted wrapper like `bash -lc 'agy ...'`
or `sh -c "cursor-agent ..."` split into tokens `'agy` / `"cursor-agent`, so the
raw-guard's basename scan missed it and fm-spawn recorded harness=bash on tmux,
bypassing the crew-only/herdr-only gates. fm_launch_raw_restricted_harness now
adds a fail-closed backstop that neutralizes shell quote characters and command
separators before scanning every token's basename, so a wrapped cursor-agent/agy
invocation is caught and refused. Over-approximates safely (the raw hatch is for
unverified adapters; cursor/agy have a canonical --harness path).
B3 (still-open rollback leaks) - two fixes:
1. AGY_TRUST_ROLLBACK_PATH is now armed BEFORE the fallible ownership-marker
write, so a marker-write failure after the global trust entry was created
still leaves the abort trap armed to roll it back.
2. The rollback is factored into fm_agy_trust_rollback: on removal success it
drops the marker; on removal FAILURE it (re)writes the marker with the
leaked path as retry evidence - even when the marker was never written -
instead of deleting it. The abort trap calls this tested function.
S2 - fm-bootstrap now emits `CREW_DISPATCH: backend mismatch - ...` when a valid
dispatch profile selects the crew-only herdr-only cursor/agy harness while the
resolved backend is not herdr, instead of deferring the surprise to spawn-time
refusal. bootstrap-diagnostics skill + AGENTS.md section 13 recognize the new
line.
Tests (would fail if unfixed): shell-wrapper bypass at both the resolver unit
level and through fm-spawn refusal; rollback clean-removal, rollback-removal
failure preserving marker evidence, and rollback failure recreating an absent
marker (the marker-write-failure evidence path); bootstrap backend-mismatch
warning on tmux and silence on herdr.
Full suite green; bin/fm-lint.sh (ShellCheck 0.11.0) exit 0. Not pushed.
Note: the end-to-end fm-spawn abort path reaches the agy seed only through a real
herdr spawn (no clean deterministic failure-injection seam), so the abort-trap
logic is covered by the fm_agy_trust_rollback unit tests that exercise the exact
function the trap runs.
…tests B1 (variable-indirection raw shell wrappers, second re-review) - a raw command is executed by the crewmate PANE's shell, so `AGY=agy bash -lc '$AGY ...'`, `bash -lc 'x=agy; eval "$x ..."'`, backticks, `$(...)`, and split-token concatenation (`$a$g`) can all expand to a restricted executable that a basename scan cannot see. fm_launch_raw_restricted_harness now returns `unresolved` for any raw command containing shell expansion ($ or backtick), and fm-spawn refuses it: the command cannot be statically proven not to launch cursor/agy. Over-approximates safely (the raw hatch is for unverified adapters; cursor/agy have a canonical --harness path). Literal and env/assignment forms still resolve to cursor/agy. Regressed-test fixes (full-suite failures caused by earlier commits in this branch): - tests/fm-gotmp.test.sh: teardown now sources bin/fm-agy-trust-lib.sh (which pulls in bin/fm-wake-lib.sh for its ownership lock); the fake teardown bin did not symlink them, so the source aborted teardown. Symlink both as newly required siblings, and make fm-agy-trust-lib's on-demand fm-wake-lib source tolerate an absent file (the lock is only exercised for agy tasks, whose real environment always has it). - tests/fm-pi-watch-extension.test.sh: the pi secondmate launch TEMPLATE moved into bin/fm-launch-lib.sh during the launch-template extraction, so the test's grep of bin/fm-spawn.sh missed it; read both files (the placeholder substitution and tracked-extension path stay in fm-spawn.sh). Tests: variable-indirection bypass rows at the resolver unit level and through the fm-spawn refusal path. bin/fm-lint.sh (ShellCheck 0.11.0) clean. Not pushed.
These are pre-existing, environment-specific failures surfaced by running the full
suite in an en_US.UTF-8 host with Pi 0.81.1 and node in /usr/bin. None is caused by
the cursor/agy work; each is fixed at its true root cause, not masked.
- bin/fm-test-run.sh: the coverage guard sorts its lane/family lists with
`LC_ALL=C sort` but ran `comm` in the ambient locale, so in en_US.UTF-8 comm
rejected the C-collated input ("not in sorted order") and returned non-zero.
Run every comm under LC_ALL=C to match its sort. (The cursor/agy test filenames
happened to expose this latent locale inconsistency.)
- tests/fm-calm-pi-extension.test.sh: hard-pinned Pi 0.80.10; the host has 0.81.1.
Empirically re-verified 2026-07-23 - the renderer and interactive-E2E assertions
pass green against @earendil-works/pi-coding-agent 0.81.1 - so the pin now accepts
0.80.10 or 0.81.1 (evidence-based, still fails loudly on an unverified version).
- tests/fm-session-start.test.sh: forced a MISSING diagnostic by removing `node`
from its fake bin, but node commonly leaks from $BASE_PATH (/usr/bin), masking
the forced-missing condition. Force-miss `no-mistakes` instead - a firstmate tool
never on a standard system PATH - so the diagnostic reliably appears.
bin/fm-lint.sh (ShellCheck 0.11.0) clean.
…gy launches
The captain accepted the no-go: string-scanning a raw launch command can never be
complete against a Turing-complete pane shell (quote concatenation `ag"y"`, brace
`a{gy,}`, alias expansion, generated process substitution, and a wrapper script
that internally execs the binary all defeat a static scan). Move the primary B1
defense to EXEC-TIME interception.
- bin/fm-launch-lib.sh: fm_launch_write_raw_guard writes firstmate-owned refusing
`cursor-agent`/`cursor`/`agy` shims (exit non-zero) into a guard dir.
- bin/fm-spawn.sh: for a raw launch command, install that guard under
TASK_TMP/raw-guard and prepend it to the pane PATH before the command runs, so
ANY spelling that resolves one of those binaries through PATH hits the shim
instead of the real CLI - uniformly closing quote-concat, brace, alias,
process-substitution, AND the wrapper-script boundary. Teardown's `rm -rf
TASK_TMP` cleans it. The sanctioned `--harness` path never routes through the
guard and reaches the real binary directly.
- The fm_launch_raw_restricted_harness string classifier stays as early,
spawn-time defense-in-depth (clear pre-launch refusal), not the sole gate.
Documented residual (a PATH shim cannot cover): an absolute-path invocation or a
raw command that first resets PATH bypasses the guard; both are deliberate
circumventions, and closing them needs execve-level interception (LD_PRELOAD/
seccomp) disproportionate to the raw hatch.
Tests: tests/fm-launch-lib.test.sh runs every demonstrated bypass class
(quote-concat agy/cursor, eval+quote-concat, brace, process-substitution, alias,
wrapper-via-PATH) with a fake real binary behind the guard and asserts the real
binary never executes; tests/fm-cursor-agy-adapter.test.sh drives a real raw spawn
and asserts the guard is installed and prepended to the pane PATH. harness-adapters
skill documents the exec-time gate and the residual. shellcheck 0.11.0 clean.
…on A) Captain chose Option A: MERGE origin/main into the cursor/agy overlay branch (NOT rebase), preserving the overlay for fast-forward-to-homes landing. origin/main advanced 4 commits (kunchenguid#895 Calm rendering, kunchenguid#898/kunchenguid#899 operational markers, kunchenguid#909 canonical operational-input classification). The load-bearing conflict is kunchenguid#909, which threaded a __OPINPUT__ operational-input encoder and a __PIBRIEFENV__ Pi env assignment INTO launch_template() in fm-spawn.sh - the exact function this branch EXTRACTED into bin/fm-launch-lib.sh. Resolution: - bin/fm-launch-lib.sh (fm_launch_template): threaded kunchenguid#909's `"$(__OPINPUT__ encode launch-brief < __BRIEF__)"` into every template (claude, codex, opencode, pi, grok, and the new cursor/agy - consistent cross-harness operational-input canonicalization) and the `__PIBRIEFENV__` prefix onto the pi templates; kept the extraction. - bin/fm-spawn.sh: kept the extraction (launch_template lives in the lib) and the cursor/agy raw-guard/agy-trust/guards; added kunchenguid#909's sq_opinput + PIBRIEFENV substitutions using this branch's fm_launch_* helper names. - tests/fm-calm-pi-extension.test.sh: took origin/main - kunchenguid#895 already re-verified Calm on Pi 0.81.1 and pinned it there, superseding this branch's interim 0.80.10|0.81.1 workaround. - tests/fm-launch-lib.test.sh: updated the exact-template assertions to the kunchenguid#909 __OPINPUT__/__PIBRIEFENV__ form. - AGENTS.md, harness-adapters SKILL, bin/fm-test-run.sh, fm-pi-watch test: auto-merged, both sides preserved (verified). Merge-affected tests green: fm-operational-input, fm-spawn-dispatch-profile, fm-pi-watch-extension, fm-cursor-agy-adapter, fm-launch-lib, fm-calm-pi-extension, fm-transition-lib, fm-agy-trust-lib. shellcheck 0.11.0 clean.
Reconcile the crew-only cursor/agy overlay with seven upstream commits (a7e01bc..6b0d21d). Merge, never rebase: the overlay must stay fast-forwardable into detached secondmate homes. Hand-resolved conflicts: - bin/fm-spawn.sh launch templates: keep this branch's extraction of the templates into bin/fm-launch-lib.sh (fm_launch_template) and its single rendering owner (fm_launch_render), rather than main's inline launch_template. Template content is at parity with main, including kunchenguid#909's __OPINPUT__ operational-input envelope. - Drop __PIBRIEFENV__ / FM_FIRSTMATE_PI_LAUNCH_BRIEF, which this branch still carried from kunchenguid#895. kunchenguid#936 removed that Calm input-reroute binding upstream and added a regression test asserting the pi launch command no longer exports it, so the removal is adopted here: the placeholder is gone from the pi templates and from fm_launch_render's signature. - tests/fm-backend.test.sh sibling list: take main's ordering; the two sides list an identical set. Also adopt kunchenguid#939's wider ShellCheck source graph, which now lints tests/: tests/fm-cursor-agy-smoke.test.sh dropped an unused loop variable. bin/fm-lint.sh is clean and the affected suites pass.
Two suite failures surfaced by merging main, both in contracts main tightened while this branch was out: - kunchenguid#939 pins an exact allowlist of tests permitted to carry `# shellcheck source=bin/` production context, so the ShellCheck source graph stays small. The three new cursor/agy tests had added themselves to that set. They do not need production context to lint cleanly, so they now stop static source following at /dev/null instead of widening the allowlist. bin/fm-lint.sh stays clean. - The captain-translation contract asserts the launch command carries kunchenguid#909's canonical `encode launch-brief` envelope by reading bin/fm-spawn.sh. This branch moved the launch templates into bin/fm-launch-lib.sh, so the assertion read a file that no longer authors them. It now reads both files, covering the launch command wherever it is authored. Not addressed here, and not a regression from this branch: tests/fm-calm-pi-extension.test.sh fails identically on a clean origin/main checkout (6b0d21d) with "/calm left the grep row in the transcript". This branch's .pi/ tree and that test are byte-identical to main.
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
cursor(thecursor-agentCLI) andagy(the Antigravity CLI) as firstmate harness adapters, so crewmates can run on the captain's paid Composer/Gemini subscriptions.This is a captain-approved deliberate divergence of firstmate's tracked surface - the one sanctioned exception to the anti-divergence stance - and it deliberately targets this fork's own
main, not upstream.Hard scope: CREW-ONLY and HERDR-ONLY
Both adapters are refused as a primary runtime, refused as a secondmate launcher, and refused on any non-herdr backend.
bin/fm-spawn.shenforces both gates before any backend or worktree work, and fails closed.Design decisions
Raw-launch guard is exec-time, not pattern-matching. The raw-launch escape hatch could otherwise smuggle these CLIs past the crew-only gate. Four review rounds showed that string patterns lose this race: quote-concatenation, brace expansion, aliases, process substitution, variable indirection, and wrapper scripts each defeat a different pattern. The guard is now a PATH shim installed before launch, which intercepts at
exectime and therefore defeats every one of those uniformly, because the shell expands before it execs.Accepted, documented residual: a raw command that removes the shim, resets
PATH, or calls the binary by absolute path can still bypass it. This is accepted rather than closed. The raw-launch command is firstmate-authored, not attacker-supplied, so the shim is accident-prevention, not a security boundary. The residual list is documented next to the guard.Turn-end is derived from native agent state, not from relaxing shared policy. These CLIs install no turn-end hook and write no status file. Rather than making
idle/doneactionable fleet-wide (which the shared policy correctly defers, because those states blip between tool calls), the watcher runs a debounced, cursor/agy-identity-gated detector over herdr's native agent state and touches the samestate/<id>.turn-endedsignal every other harness's hook writes. No change to the shared policy or the event stream.agy workspace trust is transactional. Trust entries are exact-path, ownership-aware, locked against a live holder, atomic, and fail-closed. Teardown removes the entry while the worktree lease is still held, and treats removal failure as an incomplete teardown rather than a silent leak.
Reconciliation with main
This branch merges
origin/main(7 commits,a7e01bc..6b0d21d) rather than rebasing. That is deliberate and load-bearing: rebasing would strand detached secondmate homes that fast-forward from this overlay. Every prior commit is preserved.Two conflicts were semantic rather than mechanical:
FM_FIRSTMATE_PI_LAUNCH_BRIEF/__PIBRIEFENV__from fix(pi): clean up Calm transcript rendering kunchenguid/firstmate#895. fix(pi): prevent duplicate assistant replies in Calm kunchenguid/firstmate#936 removed that Calm input-reroute binding upstream and added a regression test asserting the pi launch command no longer exports it. The removal is adopted here rather than resurrected.Verification
bin/fm-lint.shis clean. The full suite is 99 tests with the failures noted below.The live paid-CLI smoke was run against this exact head with real authenticated
cursor-agentandagysessions:That smoke is gated behind
FM_CURSOR_AGY_LIVE_E2E=1and classifiedlive-harness-optin, so it never launches a paid CLI or touches the real global Gemini settings file on an unequipped or logged-out machine. All Herdr lifecycle runs in a named throwaway lab session; the default session is never touched.Three real defects were found and fixed during review of this branch:
workingedge;Pre-existing failures, NOT caused by this branch
tests/fm-calm-pi-extension.test.shfails identically on a cleanorigin/maincheckout at6b0d21d, with the assertion:This was verified by extracting
origin/mainat6b0d21dinto a clean tree and running the test there, where it fails the same way. This branch's.pi/tree and that test file are byte-identical tomain. It is upstream, it is tracked separately, and it is deliberately not fixed here to keep this already twice-reconciled branch from growing unrelated scope. It is not branch damage.tests/fm-watcher-lock.test.shis a known pre-existing flake that can hang; it passed in this run.