Skip to content

feat(harness): add crew-only cursor and agy adapters - #1

Merged
HelloWorldSungin merged 16 commits into
mainfrom
fm/cursor-agy-build
Jul 24, 2026
Merged

HelloWorldSungin merged 16 commits into
mainfrom
fm/cursor-agy-build

Conversation

@HelloWorldSungin

Copy link
Copy Markdown
Owner

Adds cursor (the cursor-agent CLI) and agy (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.sh enforces 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 exec time 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/done actionable 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 same state/<id>.turn-ended signal 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:

Verification

bin/fm-lint.sh is 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-agent and agy sessions:

ok - cursor launches via its fm-spawn template, herdr natively detects it (alive), and it works then settles
ok - cursor: the native debounced completion detector produces a turn-end wake from the settled pane
ok - agy launches via its fm-spawn template (trust pre-seeded), herdr natively detects it, and it works then settles
ok - agy: the native debounced completion detector produces a turn-end wake from the settled pane
ok - agy workspace trust is seeded before launch and removed afterward

That smoke is gated behind FM_CURSOR_AGY_LIVE_E2E=1 and classified live-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:

  • the native turn-end detector dropped a completion when a turn started and finished between polls, because Herdr's event wait consumed the working edge;
  • the smoke could leak an agy trust entry into the real global settings if it aborted mid-run;
  • the smoke was unclassified and only checked binary presence, so it could launch paid CLIs on a logged-out machine.

Pre-existing failures, NOT caused by this branch

tests/fm-calm-pi-extension.test.sh fails identically on a clean origin/main checkout at 6b0d21d, with the assertion:

not ok - /calm left the grep row in the transcript (unexpected: 'CALM_EXPORT_GREP')

This was verified by extracting origin/main at 6b0d21d into 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 to main. 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.sh is a known pre-existing flake that can hang; it passed in this run.

Sungin Kim 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.
@cursor

cursor Bot commented Jul 24, 2026

Copy link
Copy Markdown

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant