From 51cf45320502d2b090d64dc5e5bd407651b28409 Mon Sep 17 00:00:00 2001 From: Xelanor Date: Sun, 20 Sep 2026 21:10:49 +0300 Subject: [PATCH 1/2] fix(bin): derive agy trust's project from the pooled worktree A Treehouse pool is keyed by a project's resolved origin and shared by every local clone of that origin, so an agy spawn can be handed a pool worktree linked to a different home's clone than the project this spawn's own home registered. fm-spawn.sh passed the registered project to fm-agy-trust.sh regardless, whose structural scope test correctly refused the false assertion, so every cross-clone agy spawn fell back to answering the folder-trust dialog after launch: readiness depended on the dialog rendering, a single non-retried Enter landing, and a busy verdict all inside one bounded window, plus a warning per spawn for a structurally normal fleet shape. The fallback is bounded and cannot wedge - every gate failure path fails the spawn with endpoint cleanup - but it is the weaker, dialog-dependent route for a normal shape. Derive the agy-trust argument from the acquired worktree's own git common dir instead, falling back to the registered project only when that cannot be resolved, the same derivation the claude pooled-worktree fix applies. The trust script's scope test is unchanged. --- bin/fm-spawn.sh | 28 ++++++++++++++++++- docs/verification/agy.md | 24 ++++++++++++++-- tests/fm-agy-harness.test.sh | 54 ++++++++++++++++++++++++++++++++++-- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index b1b8608531d..c36c600f60d 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -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 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 diff --git a/docs/verification/agy.md b/docs/verification/agy.md index c8bec59a9a7..8d70e4ea14c 100644 --- a/docs/verification/agy.md +++ b/docs/verification/agy.md @@ -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 `` 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: '/wt' is not a worktree of project '/project' +warning: could not pre-register agy workspace trust for /wt; the launch will answer the folder-trust dialog in window firstmate:fm-agy-pooled-z15-70118 instead +``` + +After `bin/fm-spawn.sh` derived the agy-trust `` 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. @@ -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 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 diff --git a/tests/fm-agy-harness.test.sh b/tests/fm-agy-harness.test.sh index 1ccf3b4ba10..6b82e5af323 100755 --- a/tests/fm-agy-harness.test.sh +++ b/tests/fm-agy-harness.test.sh @@ -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" @@ -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" @@ -756,6 +767,44 @@ 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/ 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 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" +} + test_agy_dialog_despite_registration_is_answered_once() { local id rec out rc enters id="agy-vendor-z10-$$" @@ -910,6 +959,7 @@ 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_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 From e5fa8c2403376c01740168fbf74870e3f730796f Mon Sep 17 00:00:00 2001 From: Xelanor Date: Sun, 20 Sep 2026 23:59:04 +0300 Subject: [PATCH 2/2] no-mistakes(document): align agy harness trust doc with pooled-worktree derivation --- .../references/harness/agy.md | 2 +- docs/verification/agy.md | 4 +-- tests/fm-agy-harness.test.sh | 36 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.agents/skills/harness-adapters/references/harness/agy.md b/.agents/skills/harness-adapters/references/harness/agy.md index 406dcb1b070..cccba2e8157 100644 --- a/.agents/skills/harness-adapters/references/harness/agy.md +++ b/.agents/skills/harness-adapters/references/harness/agy.md @@ -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. diff --git a/docs/verification/agy.md b/docs/verification/agy.md index 8d70e4ea14c..a12b30e5269 100644 --- a/docs/verification/agy.md +++ b/docs/verification/agy.md @@ -72,7 +72,7 @@ Before the derivation, the spawn still succeeded and the gate answered the dialo ```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: '/wt' is not a worktree of project '/project' -warning: could not pre-register agy workspace trust for /wt; the launch will answer the folder-trust dialog in window firstmate:fm-agy-pooled-z15-70118 instead +warning: could not pre-register agy workspace trust for /wt; the launch will answer the folder-trust dialog in window firstmate:fm-agy-pooled- instead ``` After `bin/fm-spawn.sh` derived the agy-trust `` 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: @@ -88,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 pooled-sibling worktree pre-trusted through its real primary 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. +`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 diff --git a/tests/fm-agy-harness.test.sh b/tests/fm-agy-harness.test.sh index 6b82e5af323..4cf68543f11 100755 --- a/tests/fm-agy-harness.test.sh +++ b/tests/fm-agy-harness.test.sh @@ -805,6 +805,41 @@ test_agy_spawn_trusts_a_worktree_pooled_against_a_sibling_clone() { 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 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-$$" @@ -960,6 +995,7 @@ 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