Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/harness-adapters/references/harness/agy.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Verified as a CREWMATE and SCOUT adapter only; `../../../../../bin/fm-spawn.sh`
## Trust, and where the decision persists

Every task worktree is a path agy has never seen, so an unregistered launch stops on `Do you trust the contents of this project?` with the safe choice `Yes, I trust this folder` preselected, and an unanswered dialog sends the turn into agy's scratch directory instead of the worktree.
There is no launch flag that suppresses the dialog, but agy honours a `trustedWorkspaces` entry in the captain's own `~/.gemini/antigravity-cli/settings.json` written ahead of launch (verified live), so `../../../../../bin/fm-spawn.sh` pre-registers the worktree through `../../../../../bin/fm-agy-trust.sh` before launch, the claude shape: the helper refuses anything but a linked worktree of the spawning project, records both the logical pane path and its resolved form because agy compares the logical cwd, and preserves every other key in the store.
There is no launch flag that suppresses the dialog, but agy honours a `trustedWorkspaces` entry in the captain's own `~/.gemini/antigravity-cli/settings.json` written ahead of launch (verified live), so `../../../../../bin/fm-spawn.sh` pre-registers the worktree through `../../../../../bin/fm-agy-trust.sh` before launch, the claude shape: the helper refuses anything but a linked worktree of the project it is passed - which the spawn derives from the worktree's own git common dir, not the spawning home's own registration, so a pooled worktree linked to another clone of the same origin still pre-registers - records both the logical pane path and its resolved form because agy compares the logical cwd, and preserves every other key in the store.
The post-launch readiness gate is the backstop: it answers a dialog that renders anyway with a single Enter, then requires a busy verdict (Herdr's native `working` status or the pinned `esc to cancel` row) before the spawn reports success, and on a path that was not pre-registered it never counts a busy verdict as ready until the dialog has been answered, because Herdr's native verdict can precede the dialog.
A pane whose brief cannot be confirmed to run in the worktree fails the spawn, records the failure in the task status, and closes the endpoint.
Never steer into a pane still showing the dialog; a spawn that reported success has already cleared it.
Expand Down
28 changes: 27 additions & 1 deletion bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3942,7 +3942,33 @@ claude*)
;;
agy)
if [ "$KIND" != secondmate ]; then
if "$FM_ROOT/bin/fm-agy-trust.sh" "$WT" "$PROJ_ABS" >/dev/null; then
# $PROJ_ABS is the project registered for THIS spawn's own home, but a
# pooled worktree is not necessarily linked to it: a Treehouse pool is
# keyed by the project's resolved origin and shared by every local clone
# of that origin (bin/fm-wake-lib.sh's fm_treehouse_project_lock_path:
# "separate clones of one origin share a single lock"), so an existing
# pool can already be anchored to a DIFFERENT home's clone of the same
# origin. Passing $PROJ_ABS there fails bin/fm-agy-trust.sh's structural
# scope test - correctly, because the assertion is false - and every
# cross-clone spawn then runs the warn-and-fallback path: the folder-trust
# dialog is answered after launch instead of pre-registered, so readiness
# depends on the dialog rendering, one Enter landing, and a busy verdict
# all inside one bounded window, and a structurally normal fleet shape
# warns on every spawn. $WT's own git common dir names its REAL primary
# checkout - the common dir's parent, in git's standard non-bare layout -
# so derive the trust <project> argument from $WT directly rather than
# trusting the home's own registration. This is a no-op whenever the pool
# already is anchored to $PROJ_ABS, which stays the fallback whenever
# $WT's common dir cannot be resolved at all. The scope test itself is
# unchanged: it still refuses anything that is not a linked worktree of
# exactly the project passed.
trust_project=$PROJ_ABS
wt_pool_common=$(git -C "$WT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || wt_pool_common=
if [ -n "$wt_pool_common" ]; then
wt_pool_owner=$(cd -P -- "$(dirname -- "$wt_pool_common")" 2>/dev/null && pwd -P) || wt_pool_owner=
[ -z "$wt_pool_owner" ] || trust_project=$wt_pool_owner
fi
if "$FM_ROOT/bin/fm-agy-trust.sh" "$WT" "$trust_project" >/dev/null; then
AGY_TRUST_PREREGISTERED=1
else
echo "warning: could not pre-register agy workspace trust for $WT; the launch will answer the folder-trust dialog in window $T instead" >&2
Expand Down
24 changes: 22 additions & 2 deletions docs/verification/agy.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,28 @@ Antigravity CLI requires permission to read, edit, and execute files here.
`agy --help` (1.2.0) lists no trust flag or pre-registration command, but agy honours a `trustedWorkspaces` entry written to `~/.gemini/antigravity-cli/settings.json` ahead of launch.
Verified under a throwaway `HOME` holding a copy of `~/.gemini` (the real settings file was never written): a folder appended to that array by hand launched `--prompt-interactive` straight into its turn and rendered the reply with no dialog, while an unregistered sibling folder launched the same way parked on the dialog.
agy compares the pane's logical working directory, not its resolved path: a symlinked cwd whose real path alone was registered still parked on the dialog, so `bin/fm-agy-trust.sh` records both the logical path and its resolved form when they differ.
`bin/fm-spawn.sh` runs that helper before launch at the same point it pre-registers claude trust; the helper applies the same structural scope test (a linked worktree of the spawning project, never a primary checkout, a subdirectory, a plain directory, or the home directory), preserves every other key in the store, and writes atomically with a fingerprint check.
`bin/fm-spawn.sh` runs that helper before launch at the same point it pre-registers claude trust, and derives its `<project>` argument from the acquired worktree's own git common dir rather than the home's own registration: a Treehouse pool is keyed by the project's resolved origin and shared by every local clone of that origin, so the worktree can be linked to a different home's clone, and asserting the spawning home's registered project would fail the scope test for a structurally normal fleet shape (the registered project stays the fallback when the common dir cannot be resolved).
The helper applies the same structural scope test (a linked worktree of that derived project, never a primary checkout, a subdirectory, a plain directory, or the home directory), preserves every other key in the store, and writes atomically with a fingerprint check.
A failed registration is a stderr warning rather than a refusal, because agy's dialog preselects the safe answer and the gate below can answer it.
The pooled-worktree derivation was verified as a portable regression on 2026-09-20, with a worktree linked to a sibling clone of the spawning project's own origin standing in for the cross-home pool shape (no real Treehouse pool is needed to exercise the same structural mismatch):

```sh
bin/fm-test-run.sh tests/fm-agy-harness.test.sh
```

Before the derivation, the spawn still succeeded and the gate answered the dialog inside its bounded window - every gate failure path fails the spawn with endpoint cleanup rather than wedging - but each cross-clone spawn depended on the dialog rendering, a single non-retried Enter landing, and a busy verdict all inside that window, on the route whose unanswered-dialog behavior was never fully explained (below), and warned on every spawn:

```text
not ok - a worktree pooled against a sibling clone failed trust pre-registration (unexpected: 'could not pre-register')
error: refusing to pre-register agy trust: '<tmp>/wt' is not a worktree of project '<tmp>/project'
warning: could not pre-register agy workspace trust for <tmp>/wt; the launch will answer the folder-trust dialog in window firstmate:fm-agy-pooled-<id> instead
```

After `bin/fm-spawn.sh` derived the agy-trust `<project>` argument from the worktree's own git common dir, the same run and the full existing suite passed, including every prior dialog, race, and fail-and-close case:

```text
ok - fm-spawn: agy pre-trusts a worktree pooled against a sibling clone of its project's own origin
```
Two supervised Herdr runs in treehouse worktrees completed file-writing turns while the dialog was still unanswered at observation time (worker file and `done:` status line both verified on disk before Enter was ever sent to those panes).
Isolated runs in untrusted `/tmp` directories never reached the workspace until Enter: the turn spun through exploratory tool calls in agy's own scratch directory instead, and only the queued prompt ran after the answer.
One run left unanswered for several minutes wrote its file to agy's scratch directory instead of the workspace once finally answered.
Expand All @@ -68,7 +88,7 @@ The spawn therefore does not depend on it: after pre-registration, `bin/fm-spawn
It polls the pane capture, answers the dialog with a single Enter the first time the `Do you trust the contents of this project?` text renders, and reports success only once `fm_busy_classify` returns a busy verdict for the pane (Herdr's native `working` status or the pinned `esc to cancel` status row).
Because Herdr's native `working` verdict is known to coexist with an unanswered dialog, the gate is strict about order: a busy verdict counts as ready only when the worktree was pre-registered before launch or the dialog has already been seen and answered; on an unregistered path it keeps polling for the dialog instead of accepting the early busy verdict.
When the brief cannot be confirmed to run within the window (an answered dialog never turns busy, a pre-trusted pane never turns busy, or an unregistered pane never shows the dialog), the spawn fails, records `failed:` in the task status, and closes the endpoint so no orphan worker survives outside task control.
`tests/fm-agy-harness.test.sh` covers the helper's registration and scope refusals against a throwaway store, and drives a fake pane whose dialog decision reads the store the spawn just wrote: the pre-trusted launch with no dialog, a dialog that renders anyway answered exactly once, the premature busy verdict on an unregistered path waiting for the dialog, and both fail-and-close paths.
`tests/fm-agy-harness.test.sh` covers the helper's registration and scope refusals against a throwaway store, and drives a fake pane whose dialog decision reads the store the spawn just wrote: the pre-trusted launch with no dialog, a pooled-sibling worktree pre-trusted through its real primary checkout, a sibling clone's primary checkout that the same derivation names but the scope test still refuses (the spawn warns and falls back rather than trusting a whole checkout), a dialog that renders anyway answered exactly once, the premature busy verdict on an unregistered path waiting for the dialog, and both fail-and-close paths.

## Model and effort

Expand Down
90 changes: 88 additions & 2 deletions tests/fm-agy-harness.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -551,8 +551,12 @@ SH
printf '%s\n' "$fakebin"
}

# With a third argument, build the pooled-worktree shape instead: the worktree
# is linked to a sibling clone of the same origin, not to the spawning home's
# own project, exactly as a shared Treehouse pool handed out from another
# home's clone of that origin would be.
make_agy_spawn_case() {
local name=$1 id=$2 case_dir home proj wt fakebin
local name=$1 id=$2 pooled=${3:-} case_dir home proj wt fakebin owner
case_dir="$TMP_ROOT/$name"
home="$case_dir/home"
proj="$case_dir/project"
Expand All @@ -571,7 +575,14 @@ EOF
mkdir -p "$home/.gemini/antigravity-cli"
printf '%s\n' '{"model":"Gemini 3.8 Flash (High)","trustedWorkspaces":["/home/someone/elsewhere"]}' \
> "$home/.gemini/antigravity-cli/settings.json"
fm_git_worktree "$proj" "$wt" "wt-$name"
owner="$proj"
if [ -n "$pooled" ]; then
owner="$case_dir/other-clone"
fm_git_worktree "$owner" "$wt" "wt-$name"
git clone --quiet "$owner.origin.git" "$proj"
else
fm_git_worktree "$proj" "$wt" "wt-$name"
fi
touch "$home/state/.last-watcher-beat"
: > "$case_dir/launch.log"
: > "$case_dir/tmux-calls.log"
Expand Down Expand Up @@ -756,6 +767,79 @@ test_agy_fresh_worktree_is_pre_trusted_and_launches_without_a_dialog() {
pass "fm-spawn: agy pre-registers the worktree and launches straight into a busy turn"
}

# A Treehouse pool is keyed by the project's resolved origin, not by which
# local clone asked for it (bin/fm-wake-lib.sh's fm_treehouse_project_lock_path:
# "separate clones of one origin share a single lock"), and Treehouse hands out
# worktrees from whichever clone's pool already exists on disk. So a second
# home that clones the same origin into its OWN projects/<name> directory can
# be handed a pool worktree linked to a DIFFERENT home's clone - the one that
# happened to create the shared pool first. Reproducing that needs no real
# Treehouse pool: a worktree linked to a sibling clone of the spawning
# project's own origin is the same structural shape. The spawn must derive the
# agy-trust <project> argument from the worktree's own git common dir so the
# helper's structural scope test sees the linkage that actually exists;
# passing the home's own registered project asserts a false linkage, the scope
# test correctly refuses it, and the spawn falls back to answering the
# folder-trust dialog after launch on every cross-clone spawn.
test_agy_spawn_trusts_a_worktree_pooled_against_a_sibling_clone() {
local id rec out rc store enters
id="agy-pooled-z15-$$"
rec=$(make_agy_spawn_case pooled "$id" pooled)
read_agy_spawn_record "$rec"
store="$HOME_DIR/.gemini/antigravity-cli/settings.json"
out=$(run_agy_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" \
--model gemini-3.8-flash-low)
rc=$?
expect_code 0 "$rc" "an agy spawn into a worktree pooled against a sibling clone of its project's own origin must succeed: $out"
assert_not_contains "$out" "could not pre-register" \
"a worktree pooled against a sibling clone failed trust pre-registration"
assert_agy_trusted "$store" "$WT_DIR" \
"the agy spawn did not pre-register the pooled worktree in agy's trust store"
assert_agy_not_trusted "$store" "$PROJ_DIR" \
"the agy spawn wrongly trusted its own registered project clone instead of the worktree's real primary checkout"
[ "$(cat "$CASE_DIR/agy.state")" = busy ] \
|| fail "the spawn reported success before the pane reached a busy turn (state: $(cat "$CASE_DIR/agy.state"))"
enters=$(count_enter_sends "$CASE_DIR/tmux-calls.log")
[ "$enters" -eq 1 ] \
|| fail "a pooled worktree pre-registered against its real primary checkout must receive only the launch Enter, got $enters Enter sends"
pass "fm-spawn: agy pre-trusts a worktree pooled against a sibling clone of its project's own origin"
}

# ADVERSARIAL COUNTERPART to the pooled case above. The isolation guard that
# screens the acquired path compares the candidate's git dir against the
# SPAWNING project's common dir, so a primary checkout of a DIFFERENT clone of
# the same origin is not the spawning project and passes that screen. Deriving
# the agy-trust <project> argument from the worktree's own common dir names
# that same checkout, so the derivation must not turn into a way to assert
# trust for a primary checkout: the helper's scope test still has to refuse it,
# the store must stay clean, and the spawn must take the warn-and-answer
# fallback instead of pre-registering a whole clone.
test_agy_spawn_refuses_to_pre_trust_a_sibling_primary_checkout() {
local id rec out rc store sibling enters
id="agy-sibling-primary-z16-$$"
rec=$(make_agy_spawn_case sibling-primary "$id")
read_agy_spawn_record "$rec"
store="$HOME_DIR/.gemini/antigravity-cli/settings.json"
sibling="$CASE_DIR/sibling-clone"
git clone --quiet "$PROJ_DIR.origin.git" "$sibling"
out=$(run_agy_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$sibling" "$FAKEBIN_DIR" "$id" \
--model gemini-3.8-flash-low)
rc=$?
expect_code 0 "$rc" "the spawn should still reach its brief through the dialog fallback: $out"
assert_contains "$out" "could not pre-register agy workspace trust" \
"a refused primary checkout did not surface the registration warning"
assert_agy_not_trusted "$store" "$sibling" \
"the derivation pre-registered a sibling clone's primary checkout in agy's trust store"
assert_agy_trusted "$store" "/home/someone/elsewhere" \
"a refused registration rewrote the store it was supposed to leave alone"
[ "$(cat "$CASE_DIR/agy.state")" = busy ] \
|| fail "the fallback reported success before the pane reached a busy turn (state: $(cat "$CASE_DIR/agy.state"))"
enters=$(count_enter_sends "$CASE_DIR/tmux-calls.log")
[ "$enters" -eq 2 ] \
|| fail "the refused path must fall back to answering the dialog exactly once, got $enters Enter sends"
pass "fm-spawn: agy never pre-trusts a sibling clone's primary checkout the derivation names"
}

test_agy_dialog_despite_registration_is_answered_once() {
local id rec out rc enters
id="agy-vendor-z10-$$"
Expand Down Expand Up @@ -910,6 +994,8 @@ test_agy_trust_registers_the_logical_and_resolved_worktree_paths
test_agy_trust_creates_a_missing_store
test_agy_trust_refuses_out_of_scope_paths
test_agy_fresh_worktree_is_pre_trusted_and_launches_without_a_dialog
test_agy_spawn_trusts_a_worktree_pooled_against_a_sibling_clone
test_agy_spawn_refuses_to_pre_trust_a_sibling_primary_checkout
test_agy_dialog_despite_registration_is_answered_once
test_agy_unregistered_path_ignores_busy_until_the_dialog_is_answered
test_agy_unregistered_path_without_a_dialog_fails_the_spawn
Expand Down