diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index b12858ba8f5..0594179a874 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -3,7 +3,7 @@ name: harness-adapters description: >- Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, kimi, cursor, gemini, and muse. + Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, kimi, cursor, gemini, muse, and agy. user-invocable: false metadata: internal: true @@ -52,7 +52,7 @@ 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 and Gemini follow Non-negotiable safety above. +Kimi and agy establish their unsupported primary boundary in their selected harness reference; Muse and Gemini follow 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 @@ -90,7 +90,8 @@ A new tool remains undispatchable until the `verify` plan, its harness entry, ev "kimi": "references/harness/kimi.md", "cursor": "references/harness/cursor.md", "gemini": "references/harness/gemini.md", - "muse": "references/harness/muse.md" + "muse": "references/harness/muse.md", + "agy": "references/harness/agy.md" } } ``` diff --git a/.agents/skills/harness-adapters/references/harness/agy.md b/.agents/skills/harness-adapters/references/harness/agy.md new file mode 100644 index 00000000000..9cd07352a26 --- /dev/null +++ b/.agents/skills/harness-adapters/references/harness/agy.md @@ -0,0 +1,73 @@ +# agy (Antigravity CLI) + +Verified on 2026-09-03 with Antigravity CLI 1.1.24, which self-updated to 1.1.25 mid-investigation. +Describe agy by behavior rather than by version: it updates itself without being asked, and the hooks facility this adapter depends on is absent from `--help` and has changed recently in its own changelog. + +## Operating facts + +| Fact | Value | +|---|---| +| Binary | `agy` on `PATH`. | +| Launch | `agy --dangerously-skip-permissions [--model M] [--effort E] -i ""`. `-i` starts an interactive session seeded with the prompt; `-p` runs one turn and exits. | +| Flag order | Go-style flags: `-i` and `-p` consume the NEXT argument, so the prompt must be last and every other flag must precede it. `agy -p --dangerously-skip-permissions "..."` takes the flag as its prompt and reports the real prompt ignored. | +| Models | `agy models` lists current ids; the Gemini family encodes an effort tier in the id itself (`gemini-3.8-flash-high`), and `--model` and `--effort` may both be passed. | +| Effort | `--effort low\|medium\|high`; agy names that set in its own refusal. `xhigh` and `max` are omitted rather than rejected at launch. | +| Busy state | No semantic source and deliberately unarmed; classifies `unknown missing`. Supervision rides the turn-end wake below. | +| Exit command | `/exit` (aliased `quit`). Two `Ctrl+D` presses also exit, the first showing `press ctrl+d again to exit`. | +| Interrupt | Single Escape. Renders `⎿ Interrupted · What should Antigravity CLI do instead?` and leaves the composer EMPTY, so no clear key is needed. | +| Resume | `agy --conversation=`, printed on exit. Restores real model context, NOT the workspace: the banner shows the launching cwd, so resume must run from the original worktree. | +| Environment marker | `ANTIGRAVITY_CONVERSATION_ID`, exported to every tool subprocess, whose value equals the `conversationId` in the Stop payload. | +| Composer | Bare `>` prompt inside horizontal rules; no bordered box. `>` is a shell-prompt glyph outside a bordered container, so the composer verdict is always `unknown` - typed-submit confirmation therefore works on tmux (whose submit core resolves it through the busy footer) and reports unconfirmed on EVERY other backend including herdr, whose footer rescue is gated on `pending` - a narrower scope than cursor, which reads `pending` and is rescued on herdr. The brief rides the launch command, not `fm-send`. | +| Status bar | `? for shortcuts` when it will accept a prompt; `esc to cancel` when it will not. | + +## Detection ordering + +agy does NOT clear an inherited `CLAUDECODE`. +`CLAUDECODE=1 agy -p` and having the agent print its own environment returned both `CLAUDECODE=1` and `ANTIGRAVITY_CONVERSATION_ID`, so an agy worker launched from a claude primary carries both markers and whichever is tested first wins. +`../../../bin/fm-harness.sh` therefore tests `ANTIGRAVITY_CONVERSATION_ID` BEFORE `CLAUDECODE`, exactly as it does for cursor, and `../../../bin/fm-spawn.sh` also clears the foreign markers at the launch boundary. + +## Workspace trust is the spawn-blocking hazard + +`--dangerously-skip-permissions` governs TOOL permissions only and does NOT suppress the workspace-trust dialog. +Launching with that flag into a folder agy has never seen still renders `Do you trust the contents of this project?`, so every fresh task worktree hits it. +The dialog draws NO status-bar text, so a pane parked on it is indistinguishable from idle by any rendered signal - no spinner, no `esc to cancel`. +`../../../bin/fm-agy-trust.sh` therefore registers the worktree in `trustedWorkspaces` in `$HOME/.gemini/antigravity-cli/settings.json` before launch, and refuses rather than degrades. +Teardown withdraws the same entry with `--remove`, which runs no scope test because removal can only withdraw trust, and writes nothing when the path is already absent. +Its scope test is structural: only a linked git worktree of the named project is accepted, and a primary checkout, a foreign project's worktree, a worktree subdirectory, a plain directory, the home directory, and the settings directory are each refused. +Trust is not inherited by a nested repository - `/tmp/claude-1000` trusted did not cover the git repo at `/tmp/claude-1000/agylab` - so each task worktree needs its own entry. + +## Crew turn-end hook + +agy is outside the primary turn-end guard scope; it is a crewmate/scout adapter only and `../../../bin/fm-spawn.sh` refuses a `--secondmate` launch on it, because there is no agy primary supervision protocol. + +`../../../bin/fm-agy-turnend-hook.sh` owns one `firstmate-turn-end` key in `$HOME/.gemini/config/hooks.json`, one silent always-zero hook script, and one private token registry under `$HOME/.gemini/antigravity-cli/fm-turn-end.d/`. +Every operator hook in that file is preserved. +Each agy worker worktree receives a gitignored `.fm-agy-turnend` pointer, and the global hook touches `state/.turn-ended` only when the Stop payload's `workspacePaths`, the pointer, and the registry entry all agree. +Workspace-local `/.agents/hooks.json` also loads, but only in interactive mode: print mode logs `loaded 0 named hooks from 0 hooks.json file(s)` for the same file. + +**A Stop event is not on its own a finished turn.** +agy moves a shell command that outruns its own wait into the background, yields the composer, and fires Stop with `fullyIdle` false while that command still runs; a second Stop with `fullyIdle` true follows once it finishes and the agent reports it. +The installed hook fires only on `fullyIdle` true, and any future busy-state writer must apply the same gate. + +## Where the turn-end signal is silent + +Two paths end a turn with NO Stop event, so a worker on either goes idle and quiet and the watcher's staleness check is the only backstop. +Do not read a silent pane as a healthy one. + +- **A DECLINED tool call.** Choosing `4. No` at a permission prompt returns the pane to idle with `⎿ User declined the tool call` and fires nothing. + Reproduced twice, with a same-session plain turn firing Stop normally as a positive control. + Launching with `--dangerously-skip-permissions` is what keeps a crewmate off this path. +- **An Escape interrupt.** Cancelling a turn fires nothing. + Firstmate initiates its own interrupts, so it already knows, but a captain interrupting a pane by hand leaves no wake. + +Stop correctly does NOT fire while parked at a permission prompt, which is the safe direction: there is no false "done". + +`terminationReason` values beyond `NO_TOOL_CALL` are UNVERIFIED. +The payload documents `model_stop`, `max_steps_exceeded` and `error`, but every observation here returned `NO_TOOL_CALL`; forcing an error, a step-limit, and a context-limit stop would settle whether Stop fires on those paths at all. + +## Rendered states + +`? for shortcuts` means idle. +`esc to cancel` means busy, parked at a tool-permission prompt, or holding an open slash-command menu - the status bar alone does not separate them, so look for `Requesting permission for:` / `Do you want to proceed?` in the body. +A trailing `· N task(s) · /tasks` on an otherwise idle bar means background work is still running. +`../../../bin/fm-composer-lib.sh` matches `esc to cancel` as a DELIVERY guard only; `../../../bin/fm-busy-lib.sh` owns why it is never a recorded worker state and what a semantic `PreInvocation`/`Stop` pair would take. diff --git a/AGENTS.md b/AGENTS.md index 1e66eb4fea1..ddce1599a27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,8 @@ state/ runtime records and signals; gitignored .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown + .agy-turnend-token firstmate-owned agy hook registry token for the task; removed by teardown + .agy-trust the agy workspace-trust paths this task's spawn actually added to the operator's store; teardown withdraws exactly these and nothing else .gemini-settings.json firstmate-owned per-task Gemini settings carrying the busy-state and turn-end hooks, reached through GEMINI_CLI_SYSTEM_SETTINGS_PATH so nothing is written into the project's own .gemini/; removed by teardown .muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown .cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown @@ -199,7 +201,7 @@ A silent bootstrap section needs no action; for any printed actionable diagnosti ## 4. Harness and runtime dispatch 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` and `gemini` for crewmates and scouts only; never dispatch on an unverified adapter. +The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, and `cursor`, plus `muse`, `gemini`, and `agy` for crewmates and scouts only; never dispatch on an unverified adapter. 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. diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 8728b356cc0..3980cec50f4 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1456,7 +1456,7 @@ fm_backend_herdr_server_ensure() { # [ "$running" = "true" ] && return 0 ( unset FM_HOME FM_ROOT_OVERRIDE FM_STATE_OVERRIDE FM_DATA_OVERRIDE FM_PROJECTS_OVERRIDE FM_CONFIG_OVERRIDE \ - CURSOR_AGENT CURSOR_INVOKED_AS CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT FM_SUPERVISION_MODEL + CURSOR_AGENT CURSOR_INVOKED_AS CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT ANTIGRAVITY_CONVERSATION_ID FM_SUPERVISION_MODEL fm_backend_herdr_cli "$session" server >/dev/null 2>&1 & ) || return 1 for i in $(seq 1 20); do diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index 18704846ccd..8997ef9eb4a 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -172,6 +172,12 @@ fm_backend_tmux_classify_process_name() { # [argv0] -> agent|shell|other # cannot carry it either: ~/.local/bin/muse-bin- has no `muse` path # COMPONENT, so the fm_harness_path_name fallback below never fires for it. muse|muse-bin-*) printf 'agent' ;; + # agy is anchored for the same reason muse is: `agy` is a short fragment that + # a glob would find inside ordinary names like legacy or agyneja, and its + # launch execs the bare binary through `env`, so the live process name and + # argv[0] are both exactly `agy`. bin/fm-harness.sh applies the same exact + # anchoring to it. + agy) printf 'agent' ;; *claude*|*codex*|*opencode*|*grok*|*kimi*|pi|pi-signed|pi-launcher|Pi) printf 'agent' ;; zsh|bash|sh|dash|ash|ksh|mksh|tcsh|csh|fish) printf 'shell' ;; *) diff --git a/bin/fm-agy-trust.sh b/bin/fm-agy-trust.sh new file mode 100755 index 00000000000..34cf5a76857 --- /dev/null +++ b/bin/fm-agy-trust.sh @@ -0,0 +1,365 @@ +#!/usr/bin/env bash +# Pre-register Antigravity CLI's workspace trust for the isolated task worktree a +# ship/scout spawn is about to launch an agy crewmate into, so the worker reaches +# its brief instead of wedging on the trust dialog. +# +# Usage: fm-agy-trust.sh +# fm-agy-trust.sh --remove +# the isolated task worktree this spawn launches into +# the primary checkout that worktree belongs to +# Prints one line naming what it registered; refuses loudly on anything else. +# +# Registration also prints one `added: ` line per spelling this run PUT in +# the store, and none for a spelling that was already there. That distinction is +# the caller's only way to tell its own registration from a workspace the +# operator trusted by hand, and withdrawing the latter would resurrect the very +# dialog this exists to remove. A run that adds nothing writes nothing. +# +# --remove retires a registration at teardown, so a vendor-owned settings file +# firstmate does not own cannot accumulate one dead absolute path per task (an +# orca task worktree is named for its task id, so every task is a new path). +# It takes no and runs NO scope test, because the scope test is the +# safety property of GRANTING trust and removal can only ever withdraw it. It +# withdraws EXACTLY the spelling it is given - never the other spelling of the +# same directory, which may be the operator's own entry. It writes nothing when +# the named path is already absent, so calling it for a task that never ran on +# agy leaves the store untouched rather than rewritten, and an absent store is +# nothing to retire rather than a store to create. +# +# WHY THIS EXISTS. agy gates a folder it has never seen behind an interactive +# workspace-trust dialog, and --dangerously-skip-permissions does NOT cover it: +# that flag governs TOOL permissions only. Launching with it into a fresh +# worktree still renders "Do you trust the contents of this project?" with the +# cursor on "Yes, I trust this folder" and "No, exit" one row below. Every fresh +# task worktree therefore hits it. Firstmate's steering plane carries Enter, +# Escape and C-c with no arrow navigation, so firstmate cannot answer the dialog +# safely - and the dialog draws NO status-bar text at all, so a pane parked on it +# does not even render the `esc to cancel` a busy pane shows. The worker wedges +# before it ever reads the brief, looking like an idle pane. Registering the +# trust before launch is the only control that reaches an interactive pane. +# +# THE SCOPE TEST IS THE SAFETY PROPERTY, and it is STRUCTURAL rather than a path +# policy. must be a LINKED git worktree - its own git dir, sharing +# 's common dir - whose top level is exactly the resolved argument. Git +# is the ground truth, so the argument is never trusted on its own word: a +# primary checkout (git dir == common dir), a worktree of an unrelated repo, a +# subdirectory of a worktree, a plain directory, and a home directory are each +# refused. Refusal is a non-zero exit, never a warning and never a silent skip. +# +# The test is deliberately NOT a treehouse or orca path prefix. Treehouse's root +# is configurable, so a prefix check would refuse legitimate roots, accept +# whatever a mutable env var names, and add exactly the policy surface this +# registration must not grow. One structural test covers both worktree providers +# because Orca's task worktree is a linked git worktree too. +# +# Only the launching user's own store is written: the trustedWorkspaces array in +# $HOME/.gemini/antigravity-cli/settings.json. Every unrelated key and every +# existing entry is preserved, and the replacement is atomic. That store location +# is resolved from HOME alone because agy exposes no config-directory override: +# JETSKI_APP_DATA_DIR, ANTIGRAVITY_EXECUTABLE_DATA_DIR and XDG_CONFIG_HOME were +# each set to an empty directory across an `agy models` run and none of them +# relocated a single file (agy 1.1.25). Recheck that if agy ever documents one, +# because a store the worker does not read is a registration that does nothing. +# +# Path resolution here must answer from the filesystem, never from the caller's +# environment, because the refusals below are the safety property. CDPATH would +# redirect any relative `cd` operand - notably the `.git` that +# `git rev-parse --git-common-dir` returns for a primary checkout - into an +# unrelated directory. The git overrides do the same to git's own answers: an +# inherited GIT_DIR with GIT_WORK_TREE makes a primary checkout report a linked +# worktree's git dir, so the primary-checkout refusal would pass. Git exports +# GIT_DIR into every hook environment, so an inherited value is ordinary rather +# than hostile. Clear the whole class once here so every subshell inherits it and +# a later added git call cannot silently reintroduce the hole. +set -u + +unset CDPATH \ + GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY GIT_INDEX_FILE \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CEILING_DIRECTORIES GIT_NAMESPACE \ + GIT_DISCOVERY_ACROSS_FILESYSTEM GIT_CONFIG GIT_CONFIG_GLOBAL \ + GIT_CONFIG_SYSTEM GIT_CONFIG_NOSYSTEM GIT_CONFIG_COUNT + +MODE=register +if [ "${1-}" = --remove ]; then + MODE=remove + shift + [ "$#" -eq 1 ] || { echo "usage: fm-agy-trust.sh --remove " >&2; exit 2; } + WT_ARG=$1 + PROJ_ARG= +else + [ "$#" -eq 2 ] || { echo "usage: fm-agy-trust.sh " >&2; exit 2; } + WT_ARG=$1 + PROJ_ARG=$2 +fi + +if [ "$MODE" = remove ]; then + refuse() { echo "error: refusing to retire agy workspace trust: $1" >&2; exit 1; } +else + refuse() { echo "error: refusing to pre-register agy workspace trust: $1" >&2; exit 1; } +fi + +real_dir() { (cd -P -- "$1" 2>/dev/null && pwd -P); } + +# The caller's own spelling of a directory, symlink components intact. agy runs +# in the pane's cwd, and Go's os.Getwd answers with $PWD when it names the same +# directory, so the trust lookup can present this spelling rather than the +# resolved one. +logical_dir() { (cd -- "$1" 2>/dev/null && pwd); } + +# The fully resolved path of an existing file, or empty. Resolution runs in node +# because it must follow a symlink chain to its final target, and node is already +# this script's JSON writer. +real_file() { node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' "$1" 2>/dev/null; } + +# The resolved common dir of a git worktree, or empty. --git-common-dir can be +# relative, so it is resolved from inside the worktree rather than joined here. +common_dir_of() { + local dir=$1 common + common=$(git -C "$dir" rev-parse --git-common-dir 2>/dev/null) || return 1 + (cd -P -- "$dir" && real_dir "$common") +} + +WT_REAL=$(real_dir "$WT_ARG") || true +if [ "$MODE" = register ]; then + [ -n "$WT_REAL" ] || refuse "worktree '$WT_ARG' is not an accessible directory" + PROJ_REAL=$(real_dir "$PROJ_ARG") || true + [ -n "$PROJ_REAL" ] || refuse "project '$PROJ_ARG' is not an accessible directory" +fi + +[ -n "${HOME:-}" ] || refuse "HOME is not set, so the agy settings store cannot be located" +HOME_REAL=$(real_dir "$HOME") || true +[ -n "$HOME_REAL" ] || refuse "HOME '$HOME' is not an accessible directory" +CONFIG_DIR="$HOME_REAL/.gemini/antigravity-cli" +# agy creates its own store directory on first run, so a home that has never run +# agy is ordinary rather than an error. Create the directory for the same reason, +# and refuse only when it genuinely cannot be written, since a store this cannot +# reach means the worker meets the dialog after all. +CONFIG_DIR_REAL=$(real_dir "$CONFIG_DIR") || true +if [ -z "$CONFIG_DIR_REAL" ] && [ "$MODE" = register ]; then + mkdir -p "$CONFIG_DIR" 2>/dev/null || true + CONFIG_DIR_REAL=$(real_dir "$CONFIG_DIR") || true +fi +if [ "$MODE" = remove ]; then + # A home that has never run agy holds no registration, so there is nothing to + # retire and nothing to create on its behalf. + [ -n "$CONFIG_DIR_REAL" ] || exit 0 + [ -e "$CONFIG_DIR_REAL/settings.json" ] || exit 0 +else + [ -n "$CONFIG_DIR_REAL" ] || refuse "agy settings directory '$CONFIG_DIR' does not exist and could not be created" + + # A home or config directory is never a task worktree. Checked explicitly so the + # refusal names the real reason instead of the git verdict behind it. + [ "$WT_REAL" != "$CONFIG_DIR_REAL" ] || refuse "'$WT_REAL' is the agy settings directory, not a task worktree" + [ "$WT_REAL" != "$HOME_REAL" ] || refuse "'$WT_REAL' is the home directory, not a task worktree" + + WT_TOP=$(git -C "$WT_REAL" rev-parse --show-toplevel 2>/dev/null) || true + [ -n "$WT_TOP" ] || refuse "'$WT_REAL' is not inside a git repository" + WT_TOP_REAL=$(real_dir "$WT_TOP") || true + [ "$WT_TOP_REAL" = "$WT_REAL" ] || refuse "'$WT_REAL' is not a worktree root (its root is '${WT_TOP_REAL:-unresolvable}')" + + WT_GIT_DIR=$(git -C "$WT_REAL" rev-parse --absolute-git-dir 2>/dev/null) || true + [ -n "$WT_GIT_DIR" ] || refuse "'$WT_REAL' has no resolvable git directory" + WT_GIT_DIR=$(real_dir "$WT_GIT_DIR") || true + WT_COMMON=$(common_dir_of "$WT_REAL") || true + [ -n "$WT_COMMON" ] || refuse "'$WT_REAL' has no resolvable git common directory" + [ "$WT_GIT_DIR" != "$WT_COMMON" ] || refuse "'$WT_REAL' is a primary checkout, not an isolated worktree" + + PROJ_COMMON=$(common_dir_of "$PROJ_REAL") || true + [ -n "$PROJ_COMMON" ] || refuse "project '$PROJ_REAL' is not inside a git repository" + [ "$WT_COMMON" = "$PROJ_COMMON" ] || refuse "'$WT_REAL' is not a worktree of project '$PROJ_REAL'" + +fi + +# Every check above judges the resolved path, and that stays the scope boundary. +# REGISTRATION covers both spellings of that one directory, because a lookup miss +# is silent: agy parks on a dialog that draws no status text at all, and which +# spelling it presents depends on how its process was started. +# +# REMOVAL is deliberately NOT symmetric: it withdraws exactly the spelling it was +# named and never re-derives the pair. The caller withdraws from a record of the +# spellings its own registration reported ADDING, and the other spelling of the +# same directory may be an entry the operator made by hand - taking that one too +# would resurrect the very dialog this exists to remove. Withdrawing both is the +# caller naming both, once each. +if [ "$MODE" = remove ]; then + case "$WT_ARG" in + /*) WT_REAL=$WT_ARG ;; + *) WT_REAL=$(logical_dir "$WT_ARG") || true ;; + esac + WT_LOGICAL=$WT_REAL +else + WT_LOGICAL=$(logical_dir "$WT_ARG") || true + [ -n "$WT_LOGICAL" ] && [ "$(real_dir "$WT_LOGICAL")" = "$WT_REAL" ] || WT_LOGICAL=$WT_REAL +fi +[ -n "$WT_REAL" ] || refuse "worktree '$WT_ARG' cannot be resolved to an absolute path" + +# The store write needs node, and a missing interpreter refuses like every other +# failure here. Degrading instead would launch a worker straight into the dialog +# this registration exists to remove, which is the one outcome the whole control +# is for. A node-less home never reaches a spawn anyway, since bin/fm-bootstrap.sh +# lists node in COMMON_TOOLS and reports it at setup, which is where a missing +# tool belongs rather than as a stalled pane later. +command -v node >/dev/null 2>&1 || refuse "node is required to record workspace trust and was not found on PATH" + +STORE="$CONFIG_DIR_REAL/settings.json" +# A dotfile manager or a synced folder legitimately symlinks this store, so the +# link is followed to its final target and every check below judges that target. +# Ownership is the property that matters: another user's file is refused however +# it is reached. Writing to the resolved path is what keeps the link itself in +# place, since staging beside the link and renaming would replace it with a +# regular file and break that layout. +if [ -L "$STORE" ]; then + STORE_REAL=$(real_file "$STORE") || true + [ -n "$STORE_REAL" ] || refuse "'$STORE' is a symlink whose target cannot be resolved" + STORE=$STORE_REAL +fi +if [ -e "$STORE" ]; then + [ -f "$STORE" ] || refuse "'$STORE' is not a regular file" + [ -O "$STORE" ] || refuse "'$STORE' is not owned by this user" + [ -w "$STORE" ] || refuse "'$STORE' is not writable" +fi + +# Read-modify-write, then read back and confirm. fm-spawn can run while an agy +# session of the operator's own writes this same file, so the store can move +# under us in both directions and each needs its own answer. +# +# Losing the VENDOR's write is the serious one: this renames a whole +# re-serialisation over the file, so anything agy changed since the read - a +# telemetry choice, another workspace's trust - would be gone, in a format this +# does not own. So the bytes read are fingerprinted and re-checked immediately +# before the rename, and a store that moved is not overwritten: the whole +# read-modify-write is retried once, and a second move refuses rather than +# clobbering. +# +# That narrows the window; it does not close it. Rename cannot be conditioned on +# content, so a write landing between the final check and the rename is still +# lost, and this claims no more than that. +# +# Losing OUR entry is the mild one: a vendor rewrite that drops it only +# resurrects the dialog this registration removes, which reaches firstmate as an +# ordinary stale wake and a relaunch registers again. The readback catches it +# within these attempts, and it must fail loudly rather than report a trust it +# did not leave. +# ponytail: fingerprint-and-refuse, not a lock; flock is absent on macOS and +# cannot stop a vendor session's own rewrite anyway. +if ! node - "$STORE" "$MODE" "$WT_REAL" "$WT_LOGICAL" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const [store, mode, ...requested] = process.argv.slice(2); +// One directory, both spellings; a repeat spawn must not grow the array either. +const worktrees = [...new Set(requested.filter((value) => value !== ""))]; +// The spellings this run actually PUT in the store, which is not the same as the +// spellings it was asked for: the operator may have trusted one of them by hand +// already. Only what this run added is this task's to withdraw later. +let added = []; +const readStore = () => { + try { + return fs.readFileSync(store); + } catch (err) { + if (err.code === "ENOENT") return null; + throw err; + } +}; +const fingerprint = (buf) => + buf === null ? "absent" : crypto.createHash("sha256").update(buf).digest("hex"); +const attempt = () => { + added = []; + const original = readStore(); + const before = fingerprint(original); + let root = {}; + if (original !== null) { + const raw = original.toString("utf8"); + if (raw.trim() !== "") { + root = JSON.parse(raw); + if (root === null || typeof root !== "object" || Array.isArray(root)) { + throw new Error(`${store} is not a JSON object`); + } + } + } + if (root.trustedWorkspaces === undefined) root.trustedWorkspaces = []; + const trusted = root.trustedWorkspaces; + if (!Array.isArray(trusted)) { + throw new Error(`${store} has a non-array "trustedWorkspaces" value`); + } + if (mode === "remove") { + const kept = trusted.filter((value) => !worktrees.includes(value)); + // Nothing of ours to withdraw is not a write: a task that never ran on agy + // must not reformat the operator's settings file on its way out. + if (kept.length === trusted.length) return "recorded"; + root.trustedWorkspaces = kept; + } else { + for (const worktree of worktrees) { + if (trusted.includes(worktree)) continue; + trusted.push(worktree); + added.push(worktree); + } + // Already trusted is not a write either, for the same reason: re-serialising + // the operator's settings file on every relaunch is a change they did not ask + // for, and there is nothing to verify when nothing moved. + if (added.length === 0) return "recorded"; + } + // Two-space pretty-printed with a trailing newline, because that is the format + // agy itself writes: the store measured on the box this was written on is + // exactly `{\n "enableTelemetry": ...\n}\n`. Compact would reformat the + // operator's whole settings file on every spawn and agy's next write would + // expand it again, so this must not be "simplified" to JSON.stringify(root). + const body = `${JSON.stringify(root, null, 2)}\n`; + // Unpredictable name plus an exclusive create: the config directory may be + // writable by another local account, and a predictable path could be + // pre-created there as a symlink that a plain write would follow into some + // other file this user owns. "wx" refuses an existing path outright. + const unique = `${process.pid}.${crypto.randomBytes(8).toString("hex")}`; + const tmp = path.join(path.dirname(store), `.settings.json.fm-trust.${unique}`); + fs.writeFileSync(tmp, body, { mode: 0o600, flag: "wx" }); + let renamed = false; + try { + if (fingerprint(readStore()) !== before) return "moved"; + fs.renameSync(tmp, store); + renamed = true; + } finally { + if (!renamed) fs.rmSync(tmp, { force: true }); + } + const back = JSON.parse(fs.readFileSync(store, "utf8")); + const recorded = Array.isArray(back.trustedWorkspaces) + ? (mode === "remove" + ? worktrees.every((worktree) => !back.trustedWorkspaces.includes(worktree)) + : worktrees.every((worktree) => back.trustedWorkspaces.includes(worktree))) + : false; + return recorded ? "recorded" : "dropped"; +}; +try { + for (let i = 0; i < 3; i += 1) { + const result = attempt(); + if (result === "recorded") { + for (const worktree of added) console.log(`added: ${worktree}`); + process.exit(0); + } + if (result === "moved" && i >= 1) { + console.error(`error: ${store} was modified while trust was being recorded; refusing to overwrite it`); + process.exit(1); + } + } +} catch (err) { + console.error(`error: ${err.message}`); + process.exit(1); +} +console.error( + mode === "remove" + ? `error: ${store} still trusts ${worktrees.join(", ")} after 3 attempts` + : `error: ${store} did not retain trust for ${worktrees.join(", ")} after 3 attempts`, +); +process.exit(1); +NODE +then + if [ "$MODE" = remove ]; then + refuse "could not withdraw trust for '$WT_REAL' in '$STORE'" + fi + refuse "could not record trust for '$WT_REAL' in '$STORE'" +fi + +if [ "$MODE" = remove ]; then + echo "untrusted: $WT_REAL" +else + echo "trusted: $WT_REAL" +fi diff --git a/bin/fm-agy-turnend-hook.sh b/bin/fm-agy-turnend-hook.sh new file mode 100755 index 00000000000..e9625e91f1f --- /dev/null +++ b/bin/fm-agy-turnend-hook.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Install or remove Firstmate's guarded agy crew turn-end hook. +# +# This command is the sole owner of the edit to $HOME/.gemini/config/hooks.json. +# agy's hooks file is a JSON object whose top-level keys are named hooks, so +# Firstmate owns exactly one key - "firstmate-turn-end" - and every other named +# hook, including the operator's own, is read back and rewritten untouched. +# Missing, malformed, symlinked, or otherwise surprising config is refused +# without a config write. +# +# The installed Stop hook always prints "{}" and exits 0 so it can neither block +# a turn nor keep the agent looping. It fires ONLY when the payload reports +# fullyIdle true, reads workspacePaths from the payload, checks for a +# .fm-agy-turnend pointer before registry work, and touches a task turn-end +# marker only when the pointer names a Firstmate-created token in +# $HOME/.gemini/antigravity-cli/fm-turn-end.d/. +# +# THE fullyIdle GATE IS LOAD-BEARING. agy moves a shell command that outruns its +# WaitMsBeforeAsync into the background, yields the composer, and fires Stop with +# fullyIdle false while that command is still running; a second Stop with +# fullyIdle true follows once the command finishes and the agent has reported it +# (verified on agy 1.1.25 with a 40s sleep: two Stop events, false then true). +# Waking firstmate on the first one would report a worker done while its own +# build or test run is still going. +# +# Usage: +# fm-agy-turnend-hook.sh install +# fm-agy-turnend-hook.sh remove +set -u + +case "${1:-}" in + install|remove) ACTION=$1 ;; + -h|--help) + sed -n '2,27{s/^# \{0,1\}//;p;}' "$0" + exit 0 + ;; + *) + printf 'usage: %s install|remove\n' "${0##*/}" >&2 + exit 2 + ;; +esac + +if [ -z "${HOME:-}" ]; then + printf 'fm-agy-turnend-hook: refused: HOME is unset.\n' >&2 + exit 1 +fi +if ! command -v node >/dev/null 2>&1; then + printf 'fm-agy-turnend-hook: refused: node is required to edit hooks.json and by the installed hook.\n' >&2 + exit 1 +fi + +node - "$ACTION" "$HOME" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); + +const [action, home] = process.argv.slice(2); +const CONFIG_DIR = path.join(home, ".gemini", "config"); +const CONFIG = path.join(CONFIG_DIR, "hooks.json"); +// The hook script and the token registry live in agy's own CLI state directory +// rather than in .gemini/config, because .gemini/config is a CUSTOMIZATION ROOT +// that agy scans for skills, rules and plugins; a stray script and directory +// there are discovery surface rather than inert files. +const STATE_DIR = path.join(home, ".gemini", "antigravity-cli"); +const HOOK = path.join(STATE_DIR, "fm-turn-end.sh"); +const REGISTRY = path.join(STATE_DIR, "fm-turn-end.d"); +const KEY = "firstmate-turn-end"; +const TOKEN_NAME = /^fm\.[A-Za-z0-9]{12}$/; + +// The hook body is compared byte for byte on remove, so it is a single constant +// with no interpolation. It prints its JSON answer FIRST and sends everything +// after that to /dev/null, so no later failure can corrupt the answer agy reads +// or leak output into the pane. +const HOOK_BYTES = `#!/usr/bin/env bash +# Firstmate agy turn-end hook. Managed by fm-agy-turnend-hook.sh. +# This hook is deliberately passive: every path is silent and exits zero. +set +e +payload=$(cat) +printf '{}\\n' +exec >/dev/null 2>&1 +[ -n "\${HOME:-}" ] || exit 0 +command -v node >/dev/null 2>&1 || exit 0 +FM_AGY_PAYLOAD=$payload node -e ' +const fs = require("node:fs"); +const path = require("node:path"); +let p; +try { p = JSON.parse(process.env.FM_AGY_PAYLOAD || ""); } catch { process.exit(0); } +if (!p || p.fullyIdle !== true) process.exit(0); +const spaces = Array.isArray(p.workspacePaths) ? p.workspacePaths : []; +const registry = path.join(process.env.HOME, ".gemini", "antigravity-cli", "fm-turn-end.d"); +for (const ws of spaces) { + if (typeof ws !== "string" || !ws.startsWith("/")) continue; + let first; + try { first = fs.readFileSync(path.join(ws, ".fm-agy-turnend"), "utf8").split("\\n", 1)[0]; } catch { continue; } + if (!first.startsWith("token=")) continue; + const token = first.slice(6).trim(); + if (!/^fm\\.[A-Za-z0-9]{12}$/.test(token)) continue; + let target; + try { target = fs.readFileSync(path.join(registry, token), "utf8").trim(); } catch { continue; } + if (!target.startsWith("/") || !target.endsWith(".turn-ended")) continue; + try { fs.closeSync(fs.openSync(target, "a")); fs.utimesSync(target, new Date(), new Date()); } catch { /* passive */ } +} +' 2>/dev/null +exit 0 +`; + +const refuse = (reason) => { + console.error(`fm-agy-turnend-hook: refused: ${reason}`); + process.exit(1); +}; + +const lstatOrNull = (p) => { + try { + return fs.lstatSync(p); + } catch (err) { + if (err.code === "ENOENT") return null; + throw err; + } +}; + +const regularNotSymlink = (p, label) => { + const info = lstatOrNull(p); + if (info === null) refuse(`${label} is missing at ${p}.`); + if (info.isSymbolicLink() || !info.isFile()) { + refuse(`${label} is not a regular non-symlink file at ${p}.`); + } + return info; +}; + +const atomicWrite = (target, body, mode) => { + const unique = `${process.pid}.${crypto.randomBytes(8).toString("hex")}`; + const tmp = path.join(path.dirname(target), `.${path.basename(target)}.fm.${unique}`); + fs.writeFileSync(tmp, body, { mode, flag: "wx" }); + try { + fs.renameSync(tmp, target); + } catch (err) { + fs.rmSync(tmp, { force: true }); + throw err; + } +}; + +// agy writes this file two-space pretty-printed with a trailing newline, the +// same shape as its settings.json, so a Firstmate edit leaves the operator's +// formatting alone instead of reflowing the whole file on every spawn. +const serialize = (root) => `${JSON.stringify(root, null, 2)}\n`; + +const readConfig = () => { + const info = lstatOrNull(CONFIG); + if (info === null) return { root: {}, existed: false }; + if (info.isSymbolicLink() || !info.isFile()) { + refuse(`agy hooks config is not a regular non-symlink file at ${CONFIG}.`); + } + const raw = fs.readFileSync(CONFIG, "utf8"); + if (raw.trim() === "") return { root: {}, existed: true }; + let root; + try { + root = JSON.parse(raw); + } catch (err) { + refuse(`agy hooks config is malformed JSON at ${CONFIG}: ${err.message}`); + } + if (root === null || typeof root !== "object" || Array.isArray(root)) { + refuse(`agy hooks config is not a JSON object at ${CONFIG}.`); + } + return { root, existed: true }; +}; + +// Refuse rather than clobber when something other than this command has written +// a hook that runs the Firstmate script: a second reference means an ownership +// assumption here is already wrong. +const assertNoForeignReference = (root) => { + for (const [name, value] of Object.entries(root)) { + if (name === KEY) continue; + if (JSON.stringify(value ?? null).includes("fm-turn-end.sh")) { + refuse(`a hook other than "${KEY}" references fm-turn-end.sh in ${CONFIG}.`); + } + } +}; + +const firstmateHook = () => ({ + Stop: [ + { + type: "command", + command: 'bash "$HOME/.gemini/antigravity-cli/fm-turn-end.sh"', + timeout: 5, + }, + ], +}); + +try { + const { root, existed } = readConfig(); + assertNoForeignReference(root); + + if (action === "install") { + fs.mkdirSync(STATE_DIR, { recursive: true }); + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + const registryInfo = lstatOrNull(REGISTRY); + if (registryInfo !== null && (registryInfo.isSymbolicLink() || !registryInfo.isDirectory())) { + refuse(`Firstmate registry is not a regular directory at ${REGISTRY}.`); + } + fs.mkdirSync(REGISTRY, { recursive: true, mode: 0o700 }); + fs.chmodSync(REGISTRY, 0o700); + const hookInfo = lstatOrNull(HOOK); + if (hookInfo !== null) regularNotSymlink(HOOK, "Firstmate hook script"); + if (hookInfo === null || fs.readFileSync(HOOK, "utf8") !== HOOK_BYTES) { + atomicWrite(HOOK, HOOK_BYTES, 0o700); + } + root[KEY] = firstmateHook(); + atomicWrite(CONFIG, serialize(root), 0o600); + console.log(`installed: ${KEY} in ${CONFIG}`); + process.exit(0); + } + + // remove + if (fs.existsSync(HOOK)) { + const info = regularNotSymlink(HOOK, "Firstmate hook script"); + if (fs.readFileSync(HOOK, "utf8") !== HOOK_BYTES) { + refuse(`Firstmate hook script has unexpected content at ${HOOK}.`); + } + if (info.mode & 0o077) { + refuse(`Firstmate hook script has unexpectedly broad permissions at ${HOOK}.`); + } + } + const registryInfo = lstatOrNull(REGISTRY); + if (registryInfo !== null) { + if (registryInfo.isSymbolicLink() || !registryInfo.isDirectory()) { + refuse(`Firstmate registry is not a regular directory at ${REGISTRY}.`); + } + for (const name of fs.readdirSync(REGISTRY)) { + const entry = fs.lstatSync(path.join(REGISTRY, name)); + if (!TOKEN_NAME.test(name) || entry.isSymbolicLink() || !entry.isFile()) { + refuse(`Firstmate registry contains an unexpected entry at ${path.join(REGISTRY, name)}.`); + } + } + // Live tokens mean tasks still expect a turn-end wake, so removing the hook + // would silence them. Retire the tasks first. + const live = fs.readdirSync(REGISTRY); + if (live.length > 0) { + refuse(`${live.length} task token(s) still registered in ${REGISTRY}; tear those tasks down first.`); + } + fs.rmSync(REGISTRY, { recursive: true, force: true }); + } + fs.rmSync(HOOK, { force: true }); + if (existed && Object.prototype.hasOwnProperty.call(root, KEY)) { + delete root[KEY]; + // An otherwise empty hooks.json is removed rather than left as "{}", so a + // home that never had one is returned to exactly that state. + if (Object.keys(root).length === 0) fs.rmSync(CONFIG, { force: true }); + else atomicWrite(CONFIG, serialize(root), 0o600); + } + console.log(`removed: ${KEY}`); + process.exit(0); +} catch (err) { + console.error(`fm-agy-turnend-hook: refused: ${err.message}`); + process.exit(1); +} +NODE diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 28320608e20..7a1addd5c7f 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -1104,7 +1104,7 @@ crew_dispatch_validate() { return 0 fi err=$(jq -r ' - def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi","cursor","muse"] | index($h); + def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi","cursor","muse","agy"] | index($h); def effort_ok($h; $e): if $e == null then true elif ($e | type) != "string" then false @@ -1113,6 +1113,7 @@ crew_dispatch_validate() { elif $h == "grok" then (["low","medium","high"] | index($e)) elif $h == "pi" or $h == "pi-signed" then (["low","medium","high","xhigh","max"] | index($e)) elif $h == "muse" then (["low","medium","high","xhigh","max"] | index($e)) + elif $h == "agy" then (["low","medium","high"] | index($e)) elif $h == "opencode" or $h == "kimi" or $h == "cursor" then false else true end; diff --git a/bin/fm-busy-lib.sh b/bin/fm-busy-lib.sh index 48fe49ad32a..171412d34ba 100755 --- a/bin/fm-busy-lib.sh +++ b/bin/fm-busy-lib.sh @@ -63,6 +63,22 @@ # footers for submit acknowledgement and away-mode supervisor injection only; # neither is a recorded worker state source. # +# agy is deliberately UNARMED and has no semantic source here, so it classifies +# `unknown missing` and firstmate supervises it on its turn-end wake instead +# (bin/fm-agy-turnend-hook.sh). agy's rendered status bar is unambiguous - `? +# for shortcuts` when it accepts a prompt, `esc to cancel` when it does not - +# but a second rendered-text worker-state classifier is exactly what the rule +# above forbids, so that signature stays a delivery guard in +# bin/fm-composer-lib.sh and never a recorded state. The upgrade path is +# semantic and already verified to exist: agy fires PreInvocation before each +# model call and Stop when the loop terminates, which is the same open/close +# shape claude-hook uses. Wire that pair - and only then arm agy in +# fm_busy_sources_for_harness and in bin/fm-spawn.sh - rather than promoting the +# status bar. A Stop event is NOT on its own a closed turn: agy backgrounds a +# command that outruns its own wait and fires Stop with fullyIdle false while +# that command still runs, so any close written from Stop must require +# fullyIdle true, exactly as the turn-end hook does. +# # The muse pull source is semantic, not rendered: it folds muse's own durable # session event log. It has no writer, no arm, and no gen, because # muse's default build ships no hook or plugin surface that could push events diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 07b3b02fffb..189019b36c1 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -290,7 +290,8 @@ fm_composer_strip_ghost() { # Matching a footer to confirm a keystroke landed is a different question from # asking what a worker is doing, and the two must not be conflated. # Delivery-only rendered busy footers per harness. claude/codex: "esc to -# interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: "Ctrl+c:cancel". +# interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: "Ctrl+c:cancel"; +# agy: "esc to cancel". # Claude's current spinner has a rotating glyph and word, but every active-turn # line has an ellipsis followed by a parenthesized elapsed duration. Keep this # signature separate from the shared default because that shape is not generic @@ -311,7 +312,7 @@ fm_composer_strip_ghost() { # part of that union for the same reason the others are: without it a cursor # submit could never be acknowledged, because cursor parks its terminal cursor # outside its composer and the composer verdict is therefore always `unknown`. -FM_DELIVERY_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel|ctrl\+c to stop' +FM_DELIVERY_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel|ctrl\+c to stop|esc to cancel' FM_DELIVERY_CLAUDE_BUSY_REGEX_DEFAULT='esc to interrupt|…[[:space:]]+\([0-9]+[smh]' FM_DELIVERY_CODEX_BUSY_REGEX_DEFAULT='esc to interrupt' FM_DELIVERY_OPENCODE_BUSY_REGEX_DEFAULT='esc interrupt' @@ -326,6 +327,17 @@ FM_DELIVERY_GROK_BUSY_REGEX_DEFAULT='Ctrl\+c:cancel' # bin/fm-busy-lib.sh, never from this row. FM_DELIVERY_CURSOR_BUSY_REGEX_DEFAULT='ctrl\+c to stop' FM_DELIVERY_KIMI_BUSY_REGEX_DEFAULT='^[[:space:]]*(🌑|🌒|🌓|🌔|🌕|🌖|🌗|🌘)[[:space:]]+·[[:space:]]+' +# agy's busy footer. agy renders exactly one of two status-bar strings while a +# session is alive: `? for shortcuts` when it will accept a prompt, and +# `esc to cancel` when it will not. `esc to cancel` covers all three +# not-accepting shapes - a turn in flight, a tool-permission prompt, and an open +# slash-command menu - which is precisely what a delivery guard must refuse to +# submit into (verified live, agy 1.1.25). +# It deliberately does NOT cover agy's workspace-trust dialog, which renders no +# status-bar text at all: a pane parked there is indistinguishable from idle by +# any rendered signal, which is why bin/fm-agy-trust.sh removes that dialog +# before launch rather than trying to detect it here. +FM_DELIVERY_AGY_BUSY_REGEX_DEFAULT='esc to cancel' fm_busy_lines_match() { # [harness] local harness=${1:-} lines regex @@ -341,6 +353,7 @@ fm_busy_lines_match() { # [harness] grok) regex=$FM_DELIVERY_GROK_BUSY_REGEX_DEFAULT ;; kimi) regex=$FM_DELIVERY_KIMI_BUSY_REGEX_DEFAULT ;; cursor) regex=$FM_DELIVERY_CURSOR_BUSY_REGEX_DEFAULT ;; + agy) regex=$FM_DELIVERY_AGY_BUSY_REGEX_DEFAULT ;; '') regex=$FM_DELIVERY_BUSY_REGEX_DEFAULT ;; *) # A supplied harness must never borrow another harness's signature. diff --git a/bin/fm-control-lib.sh b/bin/fm-control-lib.sh index e3eeb7e1479..31e9d89d7c5 100644 --- a/bin/fm-control-lib.sh +++ b/bin/fm-control-lib.sh @@ -63,7 +63,7 @@ fm_control_verb_allowed() { # # than guessed at, exactly as a spawn on it would be. fm_control_harness_supported() { # case "${1-}" in - claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse) return 0 ;; + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse|agy) return 0 ;; esac return 1 } @@ -88,13 +88,18 @@ fm_control_harness_family() { # cursor*) printf 'cursor' ;; gemini*) printf 'gemini' ;; muse*) printf 'muse' ;; + agy*) printf 'agy' ;; *) return 1 ;; esac } -# Which task kinds an adapter is verified to run. muse and gemini are -# crewmate/scout adapters only: neither has a primary supervision protocol, -# and bin/fm-spawn.sh refuses a --secondmate launch on either. The control plane +# Which task kinds an adapter is verified to run. muse, gemini and agy are +# crewmate/scout adapters only: none has a primary supervision protocol, and +# bin/fm-spawn.sh refuses a --secondmate launch on them. A secondmate IS a +# firstmate, so it needs the emitted primary protocol in +# bin/fm-supervision-instructions.sh and a verified way to keep a watcher cycle +# alive; agy's worker mechanics being verified says nothing about either. The +# control plane # asks this 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. @@ -102,7 +107,7 @@ fm_control_harness_supports_kind() { # local harness=${1-} kind=${2-} fm_control_harness_supported "$harness" || return 1 case "$harness" in - muse|gemini) [ "$kind" != secondmate ] || return 1 ;; + muse|gemini|agy) [ "$kind" != secondmate ] || return 1 ;; esac return 0 } @@ -113,7 +118,7 @@ fm_control_harness_supports_kind() { # # (`(esc to cancel, s)`), and a single Escape was verified to cancel it. fm_control_interrupt_key() { # case "${1-}" in - claude|codex|opencode|pi|pi-signed|kimi|cursor|gemini|muse) printf 'Escape' ;; + claude|codex|opencode|pi|pi-signed|kimi|cursor|gemini|muse|agy) printf 'Escape' ;; grok) printf 'C-c' ;; *) return 1 ;; esac @@ -124,7 +129,7 @@ fm_control_interrupt_key() { # fm_control_interrupt_repeat() { # case "${1-}" in opencode) printf '2' ;; - claude|codex|pi|pi-signed|grok|kimi|cursor|gemini|muse) printf '1' ;; + claude|codex|pi|pi-signed|grok|kimi|cursor|gemini|muse|agy) printf '1' ;; *) return 1 ;; esac } @@ -145,7 +150,7 @@ fm_control_interrupt_repeat() { # fm_control_interrupt_clear_key() { # case "${1-}" in muse) printf 'C-u' ;; - claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini) ;; + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|agy) ;; *) return 1 ;; esac } @@ -157,7 +162,7 @@ fm_control_interrupt_ack_source() { # # after an interrupt was measured as variable - sometimes seconds, sometimes # not within 20 - so a cancellation claim built on it would be unreliable. # Normal turn completion is prompt, which is what the busy fold depends on. - claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini) printf 'none' ;; + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|agy) printf 'none' ;; *) return 1 ;; esac } @@ -165,7 +170,7 @@ fm_control_interrupt_ack_source() { # # The command that exits the agent from its own composer. fm_control_exit_command() { # case "${1-}" in - claude|opencode|grok|kimi|cursor|muse) printf '/exit' ;; + claude|opencode|grok|kimi|cursor|muse|agy) printf '/exit' ;; codex|pi|pi-signed|gemini) printf '/quit' ;; *) return 1 ;; esac @@ -230,6 +235,10 @@ fm_control_harness_wiring_paths() { # printf '%s\n' "$state/$id.muse-session-current" ;; cursor) printf '%s\n' "$state/$id.cursor-session" ;; + agy) + printf '%s\n' "$wt/.fm-agy-turnend" + printf '%s\n' "$state/$id.agy-turnend-token" + ;; # gemini's busy-state and turn-end hooks live in a firstmate-owned # settings file the launch reaches through GEMINI_CLI_SYSTEM_SETTINGS_PATH, # so retiring that one file retires the whole incarnation's wiring. Nothing @@ -240,7 +249,7 @@ fm_control_harness_wiring_paths() { # } # The firstmate-owned global turn-end registry entry a harness mints per task. -# grok and kimi are the two adapters whose turn-end hook is global and gated by +# grok, kimi and agy are the adapters whose turn-end hook is global and gated by # a private token file; every other adapter's wiring is fully covered by # fm_control_harness_wiring_paths. Prints the registry path or nothing. fm_control_harness_turnend_token_path() { # @@ -249,6 +258,7 @@ fm_control_harness_turnend_token_path() { # case "$harness" in grok) printf '%s\n' "$state/$id.grok-turnend-token" ;; kimi) printf '%s\n' "$state/$id.kimi-turnend-token" ;; + agy) printf '%s\n' "$state/$id.agy-turnend-token" ;; esac } @@ -258,6 +268,7 @@ fm_control_harness_turnend_auth_path() { # case "$harness" in grok) printf '%s\n' "${GROK_HOME:-$HOME/.grok}/hooks/fm-turn-end.d/$token" ;; kimi) printf '%s\n' "$HOME/.kimi-code/fm-turn-end.d/$token" ;; + agy) printf '%s\n' "$HOME/.gemini/antigravity-cli/fm-turn-end.d/$token" ;; *) return 0 ;; esac } diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 21146188416..27104e251d3 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -179,7 +179,7 @@ shift 2 if ! fm_control_verb_allowed "$VERB"; then { if [ "$VERB" = resume ]; then - echo "error: 'resume' is not a control verb: resuming an exited agent is not deterministic across the verified adapters (codex and grok need a session id printed at exit, opencode continues the most recent session for the cwd, and claude, pi, pi-signed, and kimi have no verified pane-resume contract). Use 'relaunch', which carries the brief plus a progress note into a fresh agent on any adapter." + echo "error: 'resume' is not a control verb: resuming an exited agent is not deterministic across the verified adapters (codex, grok, and agy need a session id printed at exit - and agy's --conversation= restores only the conversation, not the workspace, so it must be relaunched from the original worktree - opencode continues the most recent session for the cwd, and claude, pi, pi-signed, and kimi have no verified pane-resume contract). Use 'relaunch', which carries the brief plus a progress note into a fresh agent on any adapter." else echo "error: '$VERB' is not a control verb" fi diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 40d2e2e40b8..afd0e1fd7d8 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Detect the agent harness this process tree runs on. -# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse|unknown +# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse|agy|unknown # fm-harness.sh crew print the effective CREWMATE harness # (config/crew-harness; "default" resolves to own) # fm-harness.sh secondmate print the harness the PRIMARY uses to launch @@ -35,7 +35,7 @@ CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" detect_own() { # Layer 1: environment markers for verified harnesses. # Keep marker detection before ancestry detection as an explicit precedence rule. - # Claude, Pi, Grok, and Cursor set verified markers of their own; codex, + # Claude, Pi, Grok, Cursor, and agy set verified markers of their own; codex, # opencode, Kimi, and Muse are markerless, so a foreign marker retained in a terminal # multiplexer's stored environment can silently misidentify one of them before # ancestry is consulted. This is a precedence hazard, not evidence that @@ -52,6 +52,27 @@ detect_own() { # CURSOR_AGENT=1 is set for the child/tool processes this script runs as. [ "${CURSOR_AGENT:-}" = "1" ] && { echo cursor; return; } [ "${CURSOR_INVOKED_AS:-}" = "cursor-agent" ] && { echo cursor; return; } + # agy is checked BEFORE claude for the same reason cursor is: agy does NOT + # clear an inherited CLAUDECODE, so an agy worker launched from a claude + # primary carries BOTH markers and whichever is tested first wins. Verified + # live on agy 1.1.25 by running `CLAUDECODE=1 agy -p` and having the agent + # print its own environment: CLAUDECODE=1 and ANTIGRAVITY_CONVERSATION_ID were + # both present. ANTIGRAVITY_CONVERSATION_ID is agy's own unambiguous marker - + # it carries the conversation id, is exported to every tool subprocess, and its + # value matches the conversationId in agy's Stop hook payload - so ordering it + # first is what makes the verdict correct. bin/fm-spawn.sh additionally clears + # the foreign markers at the launch boundary; both are kept, because the launch + # sanitization only covers sessions fm-spawn started while this ordering also + # covers an agy session a human started by hand. + # + # agy is also ordered BEFORE gemini below. agy is Google Antigravity and is + # built on the same gemini_coder tree, and the string GEMINI_CLI is present in + # the agy binary; whether an agy session also EXPORTS GEMINI_CLI=1 is + # UNVERIFIED. Testing agy's own unambiguous marker first makes the verdict + # correct under both possibilities, and it cannot change gemini's verdict + # because a real gemini session carries no ANTIGRAVITY_CONVERSATION_ID. + [ -n "${ANTIGRAVITY_CONVERSATION_ID:-}" ] && { echo agy; return; } + # Gemini is checked BEFORE claude for exactly cursor's reason above: the # Gemini CLI does NOT clear an inherited CLAUDECODE, so a gemini worker # launched from a claude primary carries BOTH markers and whichever is @@ -119,6 +140,9 @@ detect_own() { *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; kimi) echo kimi; return ;; + # agy is Antigravity CLI's installed command name. Anchored exactly rather + # than *agy*, so unrelated commands (legacy, agyneja) cannot be misread. + agy) echo agy; return ;; # muse's installed launcher ~/.local/bin/muse execs ~/.local/bin/muse-bin- # (verified in the published launcher, muse 0.1.0-R708.1), so the live process # name carries the version and CHANGES on every auto-update. Match the stable diff --git a/bin/fm-quota-choose.sh b/bin/fm-quota-choose.sh index 43ff8c7c4b9..904a743e9c7 100755 --- a/bin/fm-quota-choose.sh +++ b/bin/fm-quota-choose.sh @@ -313,6 +313,7 @@ provider_for_harness() { kimi) printf 'kimi\n' ;; cursor) printf 'cursor\n' ;; muse) printf 'meta\n' ;; + agy) printf 'agy\n' ;; *) return 1 ;; esac } diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index b0e4869dfa3..e76202b49d1 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -111,7 +111,7 @@ # profile consultation. A --secondmate spawn is exempt and resolves the SECONDMATE # harness (config/secondmate-harness -> config/crew-harness -> own), so the # secondmate-vs-crewmate split is DURABLE across every respawn (recovery, -# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse) +# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse|agy) # overrides it for this spawn (either kind). A non-flag string containing # whitespace is treated as a RAW launch command - the escape hatch for verifying # new adapters. For pi and pi-signed, fm-spawn resolves the selected executable @@ -184,6 +184,14 @@ # a firstmate-owned global hook and registry, and a gitignored per-task pointer. # grok uses a firstmate-owned global hook under ${GROK_HOME:-$HOME/.grok}/hooks # plus a gitignored .fm-grok-turnend worktree pointer and a state token. +# agy uses the same shape: a firstmate-owned global Stop hook in +# $HOME/.gemini/config/hooks.json with its own registry, a gitignored +# .fm-agy-turnend worktree pointer and a state token. It additionally needs its +# task worktree pre-registered in agy's own trustedWorkspaces before launch, +# because its workspace-trust dialog draws no status text and cannot be answered +# from the steering plane; that registration is withdrawn again by teardown, or +# here by the abort cleanup when the spawn never publishes a record. agy is +# crewmate/scout only and is refused for --secondmate. # muse installs no hook at all - its plugin engine is off in the default build - so # it writes state/.muse-session to bind the pane to muse's own session event # log; muse and gemini are crewmate/scout only and are refused for --secondmate. @@ -734,6 +742,8 @@ spawn_remote_secondmate() { } BACKEND= +AGY_TRUST_REGISTERED=0 +AGY_TRUST_ADDED= ORCA_ABORT_CLEANUP=0 ORCA_WORKTREE_ID= ORCA_TERMINAL= @@ -791,7 +801,7 @@ parse_orca_worktree_result() { } spawn_abort_cleanup() { - local status=$? + local status=$? agy_trust_path if [ "$RELAUNCH_REPLACEMENT_PENDING" = 1 ] \ && [ "$SPAWN_META_PUBLISH_STARTED" = 1 ] \ && [ -n "$SPAWN_META_TMP" ] \ @@ -882,6 +892,27 @@ spawn_abort_cleanup() { status=1 fi fi + # The agy trust entry lives in the operator's vendor settings rather than under + # this home, and teardown - the only other thing that withdraws it - refuses + # outright for an id with no task record. So a spawn that registered and then + # left no record has to take it back itself, or nothing ever can. This runs + # after the rollback above and after the orca recovery record, so the test sees + # whether a record SURVIVES this cleanup rather than an intermediate state. + if [ "$AGY_TRUST_REGISTERED" = 1 ]; then + AGY_TRUST_REGISTERED=0 + if [ ! -e "$STATE/$ID.meta" ] && [ ! -L "$STATE/$ID.meta" ]; then + # Only what the registration reported adding, and by the spelling it named: + # a workspace the operator had already trusted is not this spawn's to take + # back, and --remove could no longer re-derive the resolved spelling anyway + # once an orca abort above deleted the directory. + printf '%s\n' "$AGY_TRUST_ADDED" | while IFS= read -r agy_trust_path; do + [ -n "$agy_trust_path" ] || continue + "$FM_ROOT/bin/fm-agy-trust.sh" --remove "$agy_trust_path" >/dev/null \ + || echo "warning: could not withdraw agy workspace trust for '$agy_trust_path' after the aborted spawn of $ID" >&2 + done + rm -f -- "$STATE/$ID.agy-trust" + fi + fi if [ "$SPAWN_META_LOCK_HELD" = 1 ]; then SPAWN_META_LOCK_HELD=0 fm_lock_release "$SPAWN_META_LOCK" || true @@ -1202,7 +1233,7 @@ if [ "$RELAUNCH" -eq 1 ]; then } elif [ "$KIND" = secondmate ]; then case "${POS[1]:-}" in - ''|claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse) + ''|claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse|agy) ARG3=${POS[1]:-} ;; *' '*) @@ -1314,7 +1345,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 GEMINI_CLI -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 ANTIGRAVITY_CONVERSATION_ID -u GEMINI_CLI -u CURSOR_INVOKED_AS __CURSORBIN__ --trust --yolo __MODELFLAG__--workspace __WORKTREE__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; # gemini (Google Gemini CLI): a positional query starts the supervised # interactive session and auto-submits it, so the brief rides the launch # command exactly as it does for claude and grok (verified: a multi-line @@ -1355,6 +1386,22 @@ launch_template() { # Its turn-end signal is a globally configured Stop hook plus a guarded # per-task worktree token, so no launch placeholder belongs here. kimi) printf '%s' '__KIMIBIN__ __MODELFLAG__--auto' ;; + # agy (Antigravity CLI). -i takes the prompt as its own VALUE and starts an + # interactive session with it, which is the shape a supervised crewmate pane + # needs; -p would run one non-interactive turn and exit. Flag order matters: + # agy's flags are Go-style and -i/-p consume the NEXT argument, so the prompt + # must be the last token and every other flag must precede it, or agy takes + # the following flag as its prompt and reports that it ignored the real one. + # --dangerously-skip-permissions covers TOOL permissions only; the separate + # WORKSPACE-trust dialog is removed before launch by bin/fm-agy-trust.sh, and + # without that the pane parks on a dialog that renders no status text at all. + # agy's turn-end signal does NOT ride the launch command - it is a Stop hook + # installed alongside the trust registration (global hook + per-task pointer), + # so the template is the same for ship and scout. The foreign primary markers + # are cleared because agy does not clear an inherited CLAUDECODE, which would + # otherwise outrank agy's own marker in a process that only reads the + # environment. + agy) printf '%s' 'env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u FM_PI_HARNESS agy --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__-i "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; # muse (Muse Code): a positional prompt starts the supervised interactive # session. --yolo is the single flag that makes a crewmate pane viable: muse # ships approval prompts AND a filesystem/network sandbox ON by default @@ -1432,6 +1479,17 @@ if [ "$KIND" = secondmate ] && { [ "$HARNESS" = muse ] || [ "$HARNESS" = gemini echo "error: $HARNESS 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 exit 1 fi +# agy is verified as a CREWMATE/SCOUT adapter only, for the same structural +# reason: a secondmate is a firstmate instance and needs the emitted primary +# supervision protocol plus a verified way to keep a watcher cycle alive. +# bin/fm-supervision-instructions.sh has no agy protocol, and agy's worker +# mechanics being verified says nothing about either. Refusing here keeps that +# gap loud instead of standing up a secondmate whose supervision could never be +# armed. +if [ "$KIND" = secondmate ] && [ "$HARNESS" = agy ]; then + echo "error: agy 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 + exit 1 +fi case "$HARNESS" in pi|pi-signed) @@ -1567,7 +1625,7 @@ model_flag_for_harness() { local harness=$1 model=$2 [ -n "$model" ] && [ "$model" != default ] || return 0 case "$harness" in - claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse) + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse|agy) printf -- '--model %s ' "$(shell_quote "$model")" ;; esac @@ -1620,6 +1678,18 @@ effort_flag_for_harness() { max) printf -- '--reasoning-effort %s ' "$(shell_quote ultra)" ;; esac ;; + agy) + # agy validates --effort itself and names its accepted set in the refusal: + # `invalid --effort "bogus" (valid: low, medium, high)`. Firstmate's shared + # axis also carries xhigh and max, so those are omitted rather than passed + # as a value agy would reject at launch; the requested axis stays in task + # metadata. agy additionally encodes an effort tier in several model ids + # (gemini-3.8-flash-high and friends), so --model and --effort can both be + # present and agy resolves the pair. + case "$effort" in + low|medium|high) printf -- '--effort %s ' "$(shell_quote "$effort")" ;; + esac + ;; # opencode's interactive `opencode --prompt` launch has a verified --model # flag but no verified effort flag. Its `opencode run --variant` flag belongs # to a different, non-interactive launch mode, so fm-spawn does not pass it. @@ -2599,12 +2669,12 @@ if [ "$RELAUNCH" -eq 0 ] && [ "$KIND" != secondmate ]; then freshen_spawn_worktree_base "$WT" || exit 1 fi -# Pre-register Claude's workspace trust for the worktree, at the first point the -# worktree is known and before any per-task state is created below. The dialog -# gates the pane before the brief is ever read, and it also gates loading the -# project settings written further down, so nothing armed below takes effect -# without it. bin/fm-claude-trust.sh owns the structural scope test and refuses -# any path that is not this project's own isolated worktree; a refusal blocks the +# Pre-register workspace trust for the worktree, at the first point the worktree +# is known and before any per-task state is created below. The dialog gates the +# pane before the brief is ever read, and it also gates loading the project +# settings written further down, so nothing armed below takes effect without it. +# Each harness's own trust script owns the structural scope test and refuses any +# path that is not this project's own isolated worktree; a refusal blocks the # spawn rather than launching a worker that would wedge on a dialog firstmate # cannot answer. Refusing here rather than beside the arm keeps this in the same # class as the two worktree refusals just above: no temp root, no retired @@ -2618,6 +2688,42 @@ if [ "$KIND" != secondmate ]; then exit 1 fi ;; + # agy uses the same pre-registration contract as claude above. It needs it for + # the same reason and one more: --dangerously-skip-permissions does NOT cover + # agy's workspace-trust dialog, and that dialog draws no status-bar text, so a + # pane parked on it is indistinguishable from idle. agy additionally installs + # its turn-end hook here, because this is the point where the worktree is final + # for both a fresh spawn and a relaunch and no busy generation has been armed + # yet, so a refusal aborts the spawn without stranding a busy record nothing + # could later clear. Both refuse loudly rather than degrade, because degrading + # launches exactly the wedged worker they prevent. + agy) + AGY_TRUST_REPORT=$("$FM_ROOT/bin/fm-agy-trust.sh" "$WT" "$PROJ_ABS") || { + echo "error: refusing agy spawn because workspace trust could not be pre-registered for '$WT'" >&2 + exit 1 + } + printf '%s\n' "$AGY_TRUST_REPORT" + AGY_TRUST_REGISTERED=1 + # The spellings the registration reported ADDING - never the ones it merely + # found already present. The store is the operator's own: they may have trusted + # this very path by hand, and taking that back at teardown would park their next + # hand-run agy on the dialog this whole control exists to remove. + AGY_TRUST_ADDED=$(printf '%s\n' "$AGY_TRUST_REPORT" | sed -n 's/^added: //p') + # Appended rather than rewritten, and deliberately NOT part of the relaunch + # wiring tables: a relaunch adds nothing when the path is already trusted, and + # one onto another harness leaves the registration standing, so the note that + # firstmate made it has to outlive both. + mkdir -p "$STATE" + printf '%s\n' "$AGY_TRUST_ADDED" | while IFS= read -r agy_added_path; do + [ -n "$agy_added_path" ] || continue + grep -Fxq -- "$agy_added_path" "$STATE/$ID.agy-trust" 2>/dev/null \ + || printf '%s\n' "$agy_added_path" >> "$STATE/$ID.agy-trust" + done + "$FM_ROOT/bin/fm-agy-turnend-hook.sh" install || { + echo "error: refusing agy spawn because the global turn-end hook could not be installed safely" >&2 + exit 1 + } + ;; esac fi @@ -2972,6 +3078,23 @@ EOF printf 'token=%s\n' "${auth_file##*/}" > "$WT/.fm-kimi-turnend" exclude_path '.fm-kimi-turnend' ;; + agy*) + # agy's Stop hook is global, but it is inert unless a workspace in the + # payload contains this task's token pointer and the token resolves through + # Firstmate's private registry. The installer owns the key-preserving + # hooks.json edit and the always-zero, silent hook script; the hook itself + # fires only on fullyIdle, so a turn that merely backgrounded a command + # does not report the worker done. + AGY_AUTH_DIR="$HOME/.gemini/antigravity-cli/fm-turn-end.d" + old_umask=$(umask) + umask 077 + auth_file=$(mktemp "$AGY_AUTH_DIR/fm.XXXXXXXXXXXX") + umask "$old_umask" + printf '%s\n' "$TURNEND" > "$auth_file" + printf '%s\n' "${auth_file##*/}" > "$STATE/$ID.agy-turnend-token" + printf 'token=%s\n' "${auth_file##*/}" > "$WT/.fm-agy-turnend" + exclude_path '.fm-agy-turnend' + ;; esac fi @@ -3164,7 +3287,17 @@ case "$HARNESS" in esac LAUNCH=${LAUNCH//__WORKTREE__/$sq_worktree} case "$HARNESS" in - claude|codex|opencode|pi|pi-signed|grok|kimi|gemini|muse) + claude|codex|opencode|pi|pi-signed|grok|kimi|muse) + LAUNCH="env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u ANTIGRAVITY_CONVERSATION_ID -u GEMINI_CLI $LAUNCH" + ;; + # gemini keeps upstream's exact clear set; splitting it out of the shared arm + # is what keeps its launch byte-identical while the shared arm also drops an + # inherited agy marker. + gemini) + LAUNCH="env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u GEMINI_CLI $LAUNCH" + ;; + # agy keeps its OWN marker (fm-harness.sh reads it) and drops the foreign ones. + agy) LAUNCH="env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u GEMINI_CLI $LAUNCH" ;; esac diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 3765d932b74..84191192f6f 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -974,26 +974,66 @@ fi # Where a harness's firstmate-owned global turn-end registry entry lives is # owned by bin/fm-control-lib.sh, so teardown and the control plane's relaunch # retire the same artifact rather than each carrying its own copy of the path. -remove_grok_turnend_auth() { - local state_dir=$1 id=$2 token_path token='' path - token_path=$(fm_control_harness_turnend_token_path grok "$state_dir" "$id") || return 1 +# grok, kimi and agy each mint one registry entry per task; the harness is the +# only thing that differs, so they share this retirement rather than carrying a +# copy each. A harness that mints none resolves to an empty path and is a no-op. +remove_turnend_auth() { # + local harness=$1 state_dir=$2 id=$3 token_path token='' path + token_path=$(fm_control_harness_turnend_token_path "$harness" "$state_dir" "$id") || return 1 if [ -n "$token_path" ] && [ -f "$token_path" ]; then IFS= read -r token < "$token_path" || [ -n "$token" ] || return 1 fi - path=$(fm_control_harness_turnend_auth_path grok "$token") || return 1 + path=$(fm_control_harness_turnend_auth_path "$harness" "$token") || return 1 [ -n "$path" ] || return 0 rm -f -- "$path" } -remove_kimi_turnend_auth() { - local state_dir=$1 id=$2 token_path token='' path - token_path=$(fm_control_harness_turnend_token_path kimi "$state_dir" "$id") || return 1 - if [ -n "$token_path" ] && [ -f "$token_path" ]; then - IFS= read -r token < "$token_path" || [ -n "$token" ] || return 1 - fi - path=$(fm_control_harness_turnend_auth_path kimi "$token") || return 1 - [ -n "$path" ] || return 0 - rm -f -- "$path" +# The agy spawn's workspace-trust entry lives in the operator's own vendor +# settings file rather than under this home, so it is the one task artifact with +# no in-tree path to remove. What is withdrawn is exactly what state/.agy-trust +# records this task's spawn as having registered - never the task's worktree path +# on its own word. The same store holds workspaces the operator trusted BY HAND, +# and the removal runs no scope test by design, so a path-keyed withdrawal would +# silently revoke one of theirs whenever a task shares the path: a secondmate's +# worktree IS the firstmate home, and a pool worktree is reused across tasks and +# harnesses. No record means this task registered nothing and nothing is touched. +# +# Placement is load-bearing too: this runs BEFORE the worktree is released, +# because a returned pool worktree is handed to the next spawn at the SAME path, +# and withdrawing after the return can revoke trust that task just registered, +# wedging its worker on a dialog that draws no status text. +# +# Best effort: a store the vendor moved under it, or one this user does not own, +# refuses rather than clobbering, and that must not strand an otherwise finished +# teardown. The record is dropped either way, because nothing reads it after this +# teardown and retaining it would only be state with no reader - so a refusal +# NAMES the path it could not withdraw and the exact command that finishes the +# job, rather than leaving a dead path in the operator's settings unannounced. +retire_agy_workspace_trust() { # + local state_dir=${1:-} id=${2:-} record path + [ -n "$state_dir" ] && [ -n "$id" ] || return 0 + record="$state_dir/$id.agy-trust" + [ -f "$record" ] && [ ! -L "$record" ] || return 0 + while IFS= read -r path; do + case "$path" in + /*) + "$SCRIPT_DIR/fm-agy-trust.sh" --remove "$path" >/dev/null || { + echo "warning: agy workspace trust for '$path' could not be withdrawn; this task's record is gone, so withdraw it by hand: $SCRIPT_DIR/fm-agy-trust.sh --remove '$path'" >&2 + } + ;; + esac + done < "$record" + rm -f -- "$record" + return 0 +} + +# Every harness that mints a global turn-end registry entry, retired together so +# a task torn down after a relaunch that changed harness leaves nothing behind. +remove_all_turnend_auth() { # + local harness + for harness in grok kimi agy; do + remove_turnend_auth "$harness" "$1" "$2" || return 1 + done } retire_busy_state() { @@ -1467,7 +1507,7 @@ validate_worktree_teardown_safety() { echo "Restore the git index state, or get the captain's explicit OK to discard, then --force." >&2 return 1 fi - dirty=$(printf '%s\n' "$dirty_raw" | grep -vE '^\?\? (\.claude/|\.fm-(grok|kimi)-turnend$)' | head -1 || true) + dirty=$(printf '%s\n' "$dirty_raw" | grep -vE '^\?\? (\.claude/|\.fm-(grok|kimi|agy)-turnend$)' | head -1 || true) if ! unpushed_raw=$(git -C "$WT" log --oneline HEAD --not --remotes -- 2>/dev/null); then if worktree_safety_blocked_by_lock "commits not on a remote"; then @@ -2562,6 +2602,7 @@ cleanup_firstmate_home_children() { fm_backend_kill "$child_backend" "$child_t" "$(meta_value "$child_meta" zellij_tab_id)" "fm-$child_id" 2>/dev/null || true fi fi + retire_agy_workspace_trust "$sub_state" "$child_id" if [ "$child_kind" = secondmate ]; then child_home=$(meta_value "$child_meta" home) [ -n "$child_home" ] || child_home=$child_wt @@ -2573,14 +2614,16 @@ cleanup_firstmate_home_children() { if [ -n "$child_wt" ] && [ -d "$child_wt" ]; then validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1 rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" \ - "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" + "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" \ + "$child_wt/.fm-agy-turnend" fi fm_backend_remove_worktree "$child_backend" "$child_orca_worktree_id" || return 1 elif [ -n "$child_wt" ] && [ -d "$child_wt" ]; then validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1 rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" \ "$child_wt/.opencode/plugins/fm-busy-state.js" \ - "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" + "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" \ + "$child_wt/.fm-agy-turnend" if [ -n "$child_proj" ] && [ -d "$child_proj" ] && command -v treehouse >/dev/null 2>&1; then if teardown_treehouse_return "$child_wt" "$child_proj" "child worktree"; then : @@ -2595,8 +2638,7 @@ cleanup_firstmate_home_children() { safe_rm_rf_child_worktree "$child_wt" "$child_proj" fi fi - remove_grok_turnend_auth "$sub_state" "$child_id" || return 1 - remove_kimi_turnend_auth "$sub_state" "$child_id" || return 1 + remove_all_turnend_auth "$sub_state" "$child_id" || return 1 remove_pr_poll_artifacts "$sub_state" "$child_id" || return 1 child_busy_gen=$(meta_value "$child_meta" busy_gen) if [ -z "$child_busy_gen" ]; then @@ -2608,6 +2650,7 @@ cleanup_firstmate_home_children() { rm -f "$sub_state/$child_id.turn-ended" \ "$sub_state/$child_id.pi-ext.ts" \ "$sub_state/$child_id.grok-turnend-token" "$sub_state/$child_id.kimi-turnend-token" \ + "$sub_state/$child_id.agy-turnend-token" "$sub_state/$child_id.agy-trust" \ "$sub_state/$child_id.muse-session" "$sub_state/$child_id.muse-session-current" \ "$sub_state/$child_id.cursor-session" "$sub_state/$child_id.reconcile-nudged" \ "$sub_state/.$child_id.branch-outcome-index" @@ -2804,6 +2847,8 @@ fi # pruned code root. Best effort - a sweep failure never blocks this teardown. "$SCRIPT_DIR/fm-remote-job-reap-orphans.sh" >&2 || true +retire_agy_workspace_trust "$STATE" "$ID" + # Best-effort: drop the local task branch so the shared repo does not accumulate refs. if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then if [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then @@ -2819,7 +2864,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then fi rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \ "$WT/.opencode/plugins/fm-busy-state.js" \ - "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" + "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" "$WT/.fm-agy-turnend" fi [ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID" @@ -2832,7 +2877,7 @@ elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then fi # Remove our hook file so a reused pool worktree cannot fire signals for a dead task. rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \ - "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" + "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" "$WT/.fm-agy-turnend" # Kills remaining processes in the worktree (including the agent), resets, returns # to pool. treehouse resolves the pool from the working directory, so run it from # the project. teardown_treehouse_return tolerates transient and stale git locks @@ -2944,8 +2989,7 @@ if [ "$KIND" = secondmate ]; then || { echo "error: receiver wake cleanup failed; preserving the secondmate route for retry" >&2; exit 1; } remove_secondmate_registry_entry "$ID" fi -remove_grok_turnend_auth "$STATE" "$ID" || exit 1 -remove_kimi_turnend_auth "$STATE" "$ID" || exit 1 +remove_all_turnend_auth "$STATE" "$ID" || exit 1 fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true # Remove the per-task temp root (/tmp/fm-/, incl. its gotmp/) recorded by spawn. # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. @@ -2955,7 +2999,9 @@ retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || exit 1 status_retire_presentation_task "$STATE" "$ID" || exit 1 rm -f "$STATE/$ID.turn-ended" \ "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" \ - "$STATE/$ID.kimi-turnend-token" "$STATE/$ID.muse-session" \ + "$STATE/$ID.kimi-turnend-token" "$STATE/$ID.agy-turnend-token" \ + "$STATE/$ID.agy-trust" \ + "$STATE/$ID.muse-session" \ "$STATE/$ID.muse-session-current" "$STATE/$ID.cursor-session" \ "$STATE/$ID.control-relaunch" "$STATE/$ID.control-relaunch.meta-prior" \ "$STATE/$ID.control-relaunch.brief-prior" "$STATE/$ID.control-relaunch.note" \ diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 0e217c4f49a..53d8182d802 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -221,6 +221,7 @@ family_for_basename() { fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-crew-state.test.sh|fm-captain-hold-lifecycle.test.sh|\ fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ + fm-agy-harness.test.sh|\ fm-kimi-harness.test.sh|fm-muse-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\ fm-lint-workflows.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ @@ -280,7 +281,7 @@ family_for_basename() { fm-cursor-primary-live-e2e.test.sh|\ fm-grok-stop-live-e2e.test.sh|fm-harness-adapter-instructions-live-e2e.test.sh|\ fm-harness-liveness-drift-live-e2e.test.sh|\ - fm-muse-signals-live-e2e.test.sh|\ + fm-muse-signals-live-e2e.test.sh|fm-agy-signals-live-e2e.test.sh|\ fm-herdr-version-floor-live-e2e.test.sh|\ fm-opencode-primary-live-e2e.test.sh|fm-pi-branch-live-e2e.test.sh|\ fm-pi-primary-live-e2e.test.sh|\ @@ -541,6 +542,8 @@ list_portable_serial() { portable_serial_weight_hints() { cat <<'EOF' tests/fm-afk-inject-e2e.test.sh 35792 +tests/fm-agy-harness.test.sh 12000 +tests/fm-agy-signals-live-e2e.test.sh 23 tests/fm-afk-pi-herdr-return-e2e.test.sh 100 tests/fm-afk-return.test.sh 1837 tests/fm-ask-user-authority.test.sh 128 diff --git a/docs/agent-control.md b/docs/agent-control.md index cba0a9b7edb..6a2a4b122e7 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -48,7 +48,8 @@ The clear is refused before anything is sent when the recorded backend cannot de Removing a worktree, closing an endpoint, or discarding work stays with [`bin/fm-teardown.sh`](../bin/fm-teardown.sh), which owns the landed-work test. **`resume` is not a verb.** -It is not deterministic across the verified adapters: codex, grok, and gemini resume only from a session id printed at exit, opencode continues the most recent session for the cwd, and claude, pi, pi-signed, and kimi have no verified pane-resume contract. +It is not deterministic across the verified adapters: codex, grok, gemini, and agy resume only from a session id printed at exit, opencode continues the most recent session for the cwd, and claude, pi, pi-signed, and kimi have no verified pane-resume contract. +agy's `--conversation=` additionally restores only the conversation, not the workspace, so it must be relaunched from the original worktree. `relaunch` covers the same need on every adapter, because the brief on disk - not a harness-private session - is the durable instruction. ## Transactional relaunch diff --git a/docs/architecture.md b/docs/architecture.md index 84688f1603d..3329db16f9c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -159,6 +159,7 @@ Every classification returns a verdict of busy, idle, unknown, or dead together Each converted adapter reports its own turn lifecycle through a machine-readable contract the vendor already exposes, rather than through rendered footer text: Pi and pi-signed through the Firstmate-owned extension's `agent_start` and `agent_settled` confirmed by `ctx.isIdle()`, OpenCode through its plugin's semantic `session.status`, Claude through owned `UserPromptSubmit`, `Stop`, `StopFailure`, and `SessionEnd` hooks, Muse through its session log, and Cursor through its conversation transcript. Kimi behind Pi inherits Pi's lifecycle. Codex and standalone Kimi classify unknown behind explicit probes until a semantic source is live-verified for them, and Grok keeps one clearly isolated rendered-tail fallback that can only ever classify a Grok task. +agy classifies unknown for a different reason: its semantic pair is verified to exist but is deliberately left unarmed, so firstmate supervises an agy worker through its turn-end wake instead of promoting its unambiguous status bar into a second rendered-text state source; `bin/fm-busy-lib.sh` owns that decision and the upgrade path out of it. Missing, malformed, stale, untrusted, or unverified semantic state is unknown, never idle, and unknown is never promoted to busy either. Ordinary task-state consumers act only on an exact busy verdict, so an unreadable worker surfaces for a closer look instead of being absorbed as still-working or written off as finished. @@ -234,7 +235,7 @@ The session-start bootstrap step keeps valid dispatch configuration silent unles When the file exists, `fm-spawn.sh` refuses crewmate and scout launches without an explicit harness, so `config/crew-harness` is only automatic when no dispatch profile file is active. Secondmate launches are exempt because they resolve the secondmate harness and any optional secondmate model or effort tokens instead. Unsupported effort values are still recorded in task meta when passed to `fm-spawn.sh`, but the launch template omits any effort flag that the selected harness does not accept. -That keeps spawn launch compatible across claude, codex, opencode, pi, pi-signed, grok, kimi, cursor, gemini, and muse while preserving the requested profile for later audit. +That keeps spawn launch compatible across claude, codex, opencode, pi, pi-signed, grok, kimi, cursor, gemini, muse, and agy while preserving the requested profile for later audit. ## Optional secondmates diff --git a/docs/configuration.md b/docs/configuration.md index 7d32ef77ad8..2c1e171b6cb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -296,11 +296,12 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa ## Harness support -claude, codex, opencode, pi, pi-signed, grok, kimi, and cursor are empirically verified for crewmate and secondmate launches; gemini is verified for crewmate and scout launches only, and [README requirements](../README.md#requirements) own the set supported for the primary session. +claude, codex, opencode, pi, pi-signed, grok, kimi, and cursor are empirically verified for crewmate and secondmate launches; muse, gemini, and agy are verified for crewmate and scout launches only and `fm-spawn` refuses a secondmate on each, because none has a primary supervision protocol; [README requirements](../README.md#requirements) own the set supported for the primary session. A cursor secondmate or primary runs the tracked project-scope `.cursor/hooks.json` in its own home and must be launched with `--trust`, or no project hook loads; [`docs/supervision-protocols/cursor.md`](supervision-protocols/cursor.md) owns its supervision protocol. Cursor typed-submit confirmation is verified on tmux and Herdr only. +agy typed-submit confirmation is verified on tmux only - a narrower scope than Cursor's, Herdr included in the exclusion: agy's composer is a bare `>` between rules rather than a bordered container, so the classifier's dead-shell-prompt safety rule makes every agy composer verdict `unknown`, and Herdr's footer rescue is gated on `pending` (which is what Cursor reads and agy never can), so only tmux's submit core resolves it; the brief itself rides the launch command rather than `fm-send`, so a spawn is unaffected. [runtime backend verification](verification/runtime-backends.md#typed-submit-confirmation-is-a-tmux-only-boundary) owns the evidence. 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. +muse's specific reason for the crewmate-and-scout-only boundary above is that it ships no usable hook surface for a primary session's turn-end supervision; [`docs/verification/muse.md`](verification/muse.md) owns that evidence, and [runtime backend verification](verification/runtime-backends.md#agy-antigravity-cli) owns agy's. 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. gemini is likewise refused for secondmates because it has no primary supervision protocol; [its adapter reference](../.agents/skills/harness-adapters/references/harness/gemini.md) owns the credential precondition, canonical-launch wiring, and raw-launch limitations. New harnesses get verified through a supervised trial task before joining the set. @@ -334,6 +335,14 @@ For Kimi crews, `fm-spawn.sh` runs `fm-kimi-turnend-hook.sh install`, drops a pe Kimi continues to use the captain's normal Kimi home, including the existing config, skills, and memory; Firstmate does not create an isolated Kimi home. The Kimi installer requires an existing regular non-symlink `~/.kimi-code/config.toml`, `python3` with `tomllib`, and `jq`; it validates but never serializes the captain's TOML and refuses before writing when the config is missing, malformed, or surprising or when either tool requirement is unavailable. Its `remove` action excises only the marker-delimited Firstmate region and removes Firstmate's hook files. +For agy crews, `fm-spawn.sh` first runs `fm-agy-trust.sh` to register the task worktree in `trustedWorkspaces` in `~/.gemini/antigravity-cli/settings.json`, because `--dangerously-skip-permissions` covers tool permissions only and a worktree agy has never seen otherwise parks on a workspace-trust dialog that renders no status-bar text. +Registration reports one `added:` line per path it actually put in the store and none for a path that was already there; the spawn records only those in `state/.agy-trust`, and `fm-teardown.sh` withdraws exactly that record with `fm-agy-trust.sh --remove`, so a settings file firstmate does not own does not accumulate one dead worktree path per task. +Recording only what was added is what keeps a workspace the operator trusted by hand out of it, including one they trusted at the very path a later task runs in, and a task with no record touches nothing at all. +It then runs `fm-agy-turnend-hook.sh install`, drops a per-task `.fm-agy-turnend` pointer in the worktree, and records the matching private registry token for teardown. +Both refuse rather than degrade, and both run before any busy generation is armed, so a refusal aborts the spawn without stranding task state. +The trust registration accepts only a linked git worktree of the named project and refuses a primary checkout, a foreign project's worktree, a worktree subdirectory, a plain directory, the home directory, and the settings directory; it also refuses a store it does not own or one that changed underneath it rather than overwriting agy's own write. +agy continues to use the captain's normal agy home, including the existing credential and settings; Firstmate does not create an isolated agy home. +The agy hook installer requires `node`, owns exactly one `firstmate-turn-end` key in `~/.gemini/config/hooks.json`, preserves every other named hook, and refuses `remove` while any task token is still registered. For Pi and pi-signed secondmate launches, `fm-spawn.sh` starts the selected executable with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts` and `.pi/extensions/fm-primary-turnend-guard.ts`, both already present from the secondmate home's git worktree. ## Crew dispatch profiles (config/crew-dispatch.json) diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 449dcde08c5..2d8d5e1a829 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -176,6 +176,10 @@ "path": ".agents/skills/harness-adapters/references/common/primary-hooks.md", "audience": "agent-runtime" }, + { + "path": ".agents/skills/harness-adapters/references/harness/agy.md", + "audience": "agent-runtime" + }, { "path": ".agents/skills/harness-adapters/references/harness/claude.md", "audience": "agent-runtime" diff --git a/docs/scripts.md b/docs/scripts.md index abaa59013dd..bacc97ffbb0 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -48,6 +48,8 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) | | `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard | | `fm-kimi-turnend-hook.sh` | Surgically install or remove Kimi's guarded global crew turn-end hook | +| `fm-agy-trust.sh` | Pre-register agy's workspace trust for one isolated task worktree, or refuse; `--remove` retires it at teardown | +| `fm-agy-turnend-hook.sh` | Install or remove agy's guarded global crew turn-end hook | | `fm-arm-pretool-check.sh` | Stable PreToolUse transport for the watcher-arm command policy (docs/arm-pretool-check.md) | | `fm-arm-command-policy.mjs` | Semantic owner of the watcher-arm PreToolUse policy (docs/arm-pretool-check.md) | | `fm-subagent-pretool-check.sh` | Primary-home delegation-shape PreToolUse guard (docs/subagent-guard.md) | diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 8308d2321fa..4dcac89df63 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -48,7 +48,7 @@ Verify setup by spawning a small task and confirming its `fm-` window appear A target-existence check proves only that the pane exists. The deeper tmux agent-liveness probe first verifies exact window membership, then reads process names to distinguish a running harness from a bare idle shell. -It classifies recognized Claude, Codex, OpenCode, Pi, pi-signed, Grok, Kimi, Cursor, and Muse process identities as `alive`, common shells as `dead`, an authoritatively absent window as `missing`, unreadable state as `unreadable`, and every other process as `ambiguous`. +It classifies recognized Claude, Codex, OpenCode, Pi, pi-signed, Grok, Kimi, Cursor, Muse, and agy process identities as `alive`, common shells as `dead`, an authoritatively absent window as `missing`, unreadable state as `unreadable`, and every other process as `ambiguous`. Only `dead` and `missing` authorize recovery because a false dead result could launch a duplicate agent. For positive attribution, the probe combines two independent name sources rather than making either one load-bearing. @@ -61,9 +61,11 @@ The same scoping covers multi-process launchers without a special case, so the P Direct executable identities `pi`, `pi-signed`, and `Pi` remain accepted exactly, and similar or prefixed process names are not accepted through those exact Pi-family entries. Muse is likewise anchored to the exact `muse` launcher identity or the installed `muse-bin-` prefix, so unrelated names such as `musescore` and `amuse` remain ambiguous. Cursor is identified from its exact `cursor-agent` identity or versioned install tree in the foreground process path or structured argv[0]; a bare `node` or unrelated `agent` remains ambiguous. +agy is anchored to the exact `agy` identity: its launch execs the bare binary, so both name sources read exactly `agy`, and `agy` is short enough that a substring match would claim ordinary names such as `legacy` and `agyneja`. The CI-enforced portable regression and opt-in real-harness drift guard follow the split owned by `.agents/skills/firstmate-coding-guidelines/SKILL.md`. Run the real-harness guard after any harness upgrade and before trusting refreshed evidence. +agy is the one entry above outside that guard's installed-harness roster, so no live run refreshes its attribution and only the portable regression pins the exact-`agy` anchoring; the opt-in `tests/fm-agy-signals-live-e2e.test.sh` guard exercises agy's vendor signals rather than its process name. ### Composer, busy state, and delivery @@ -108,6 +110,7 @@ tests/fm-composer-ghost.test.sh tests/fm-kimi-harness.test.sh tests/fm-cursor-harness.test.sh tests/fm-muse-harness.test.sh +tests/fm-agy-harness.test.sh tests/fm-tmux-submit-busy.test.sh tests/fm-bootstrap.test.sh ``` diff --git a/docs/trace-context.md b/docs/trace-context.md index ba413c07f2c..c38441336a4 100644 --- a/docs/trace-context.md +++ b/docs/trace-context.md @@ -23,7 +23,7 @@ When enabled, for each spawn Firstmate resolves one W3C `traceparent` carrier fo This feature parents no SDK span by itself. Because the injected carrier and the recorded carrier are the same string, an observer that reads the metadata reconstructs exactly the identity the child received. -The injection sits at the unconditional pre-launch export site, so it covers ship and scout spawns across `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, `cursor`, `gemini`, and `muse`, plus Secondmate spawns across that same set except the deliberately crewmate-only `gemini` and `muse` adapters. +The injection sits at the unconditional pre-launch export site, so it covers ship and scout spawns across `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, `cursor`, `gemini`, `muse`, and `agy`, plus Secondmate spawns across that same set except the deliberately crewmate-only `gemini`, `muse`, and `agy` adapters. This is the same coverage `GOTMPDIR` already has and requires no trace-specific `launch_template()` behavior. Ship and scout spawns reach that site on every spawn backend (`tmux`, `herdr`, `zellij`, `orca`, `cmux`); a Secondmate reaches it on every backend that accepts a Secondmate spawn (`tmux`, `herdr`, `zellij`), because `bin/fm-spawn.sh` rejects a Secondmate on `orca` and `cmux`. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 41b97b6adf0..064c4a452a3 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -163,6 +163,15 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa - Installation refuses before writing unless `python3` with `tomllib` and `jq` are available. - If `jq` is removed after installation, the hook remains silent and exits 0, turn-end wakes stop, and Kimi crews fall back to idle detection. - Unreadable hook input remains fail-open. +- agy (Antigravity CLI) exposes no hooks subcommand at all, but does read a global `hooks.json` whose top-level keys are named hooks, including a `Stop` event with camelCase payload fields `conversationId`, `workspacePaths`, `fullyIdle`, and `terminationReason`. +- agy has no primary supervision protocol and remains outside the primary guard integrations above; it is a crewmate/scout adapter only. +- agy crew wake support uses `bin/fm-agy-turnend-hook.sh` to own exactly one `firstmate-turn-end` key in `~/.gemini/config/hooks.json` and install a silent always-zero hook, preserving every operator hook in that file. +- The hook remains inert unless a payload `workspacePaths` entry contains a per-task token pointer that resolves through Firstmate's private registry to one `state/.turn-ended` marker. +- The hook fires only when the payload reports `fullyIdle` true: agy backgrounds a command that outruns its own wait, yields the composer, and fires `Stop` with `fullyIdle` false while that command still runs, with a second `fullyIdle` true `Stop` once it finishes. +- Two paths end an agy turn with NO `Stop` event and therefore no wake: a declined tool call, and an Escape interrupt. +- A crewmate launches with `--dangerously-skip-permissions` so it never reaches the first, and firstmate initiates its own interrupts; the watcher's staleness check is the backstop for both. +- `terminationReason` values beyond `NO_TOOL_CALL` are unverified. +- Installation refuses before writing unless `node` is available, and the installed hook exits 0 with `{}` on any unreadable input. - No harness adapter uses a shell ampersand to manufacture supervision. ## Regression coverage @@ -173,6 +182,8 @@ It also covers true-reason banner wording and reason-keyed episode dedup survivi `tests/fm-cursor-primary.test.sh` covers the Cursor park end to end over real processes with no harness installed: each tracked Claude-shaped entrypoint standing down on a Cursor payload, both follow-up sources, the bounded repair nag and its reset, the nested loop bounds, supersession, away-mode and lock-ownership inertness, Pi-host stand-down without Cursor identity and continued parking when `PI_CODING_AGENT` leaks alongside `CURSOR_AGENT` or `CURSOR_INVOKED_AS`, child-worktree exclusion, and that the adapter never exits 2. `FM_CURSOR_PRIMARY_LIVE_E2E=1 tests/fm-cursor-primary-live-e2e.test.sh` is the opt-in guard that proves the same behavior against the installed cursor-agent and fails naming the harness and version. `tests/fm-kimi-harness.test.sh` covers the separate Kimi crew hook's format preservation, idempotence, refusal cases, token guard, spawn registration, and teardown cleanup. +`tests/fm-agy-harness.test.sh` covers the agy crew hook's operator-hook preservation, the `fullyIdle` gate, the unregistered-token guard, the always-zero answer, the live-token removal refusal, and the malformed-config refusal, alongside the trust registration's accepted path and every one of its refusals. +`FM_AGY_SIGNALS_LIVE_E2E=1 tests/fm-agy-signals-live-e2e.test.sh` is the opt-in guard that proves the trust dialog, both status bars, typed submission, and the `Stop` hook against the installed agy and fails naming the harness and version. `tests/fm-supervision-instructions.test.sh` covers recovery-line ownership and pi-signed's identity-preserving reuse of Pi's protocol. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` is the opt-in isolated Pi path. [`verification/supervision.md`](verification/supervision.md#turn-end-guard) records the active cross-harness empirical evidence, including the 2026-07-24 Claude `asyncRewake` revalidation. diff --git a/docs/verification/dispatch-auth.md b/docs/verification/dispatch-auth.md index 57772f113f7..c98ac3e808e 100644 --- a/docs/verification/dispatch-auth.md +++ b/docs/verification/dispatch-auth.md @@ -111,6 +111,33 @@ There is no `projectionBasis` field; its absence means `cycle_average`. Projection confidence is not present on every known runway, so selection must preserve that absence as uncertainty rather than fabricate it. The older-schema fallback contract is owned by `quota-array-dispatch`; this evidence does not reinterpret an absent runway, pace, or selection field. +## The provider family a harness maps to is the producer's own name + +Verified 2026-09-03 against quota-axi 0.1.35. + +```sh +quota-axi --help | grep -o -- '--provider <[^>]*>' +quota-axi --json --no-credential-refresh | jq -r '[.providers[]?.provider] | @csv' +``` + +```text +--provider +"claude","codex","cursor","copilot","grok","kimi","zai","agy","alibaba","opencode-go" +``` + +The producer declares its family names in both surfaces and they agree, so a harness maps to the name that appears here rather than to its vendor's brand. +`agy` is one of them: Antigravity is its own family, not a `google`/`gemini` alias, which is what `provider_for_harness` in `bin/fm-quota-choose.sh` maps `agy` onto. + +```sh +quota-axi --json --no-credential-refresh | jq -c '.providers[] | select(.provider=="agy") | {provider, status: .quotaSemantics.status}' +``` + +```text +{"provider":"agy","status":"unknown"} +``` + +A family present with `status: unknown` is missing evidence, not a wrong family name - the same shape §"Quota granularity the judgment depends on" already records for `cursor` and `copilot`. + ## Provider-family counterfactual that this producer schema supports Verified 2026-07-30 on Pi 0.82.0 and quota-axi 0.1.16. diff --git a/docs/verification/muse.md b/docs/verification/muse.md index 2a2637b3c65..1e04b5b3e40 100644 --- a/docs/verification/muse.md +++ b/docs/verification/muse.md @@ -48,7 +48,7 @@ $ grep -nE 'muse-bin|exec ' launcher.sh `ps -o comm= -p ` returns the full executable path, whose basename is `muse-bin-`. That is why both `bin/fm-harness.sh` and `bin/backends/tmux.sh` match the anchored prefix `muse-bin-*` rather than an exact name, and why neither can rely on an install-path component: `~/.local/bin/muse-bin-` contains no `muse` path component. -The Muse launch clears `CLAUDECODE`, `PI_CODING_AGENT`, `GROK_AGENT`, `FM_PI_HARNESS`, `CURSOR_AGENT`, and `CURSOR_INVOKED_AS` before the worker starts so foreign primary markers cannot override the versioned ancestry. +The Muse launch clears `CLAUDECODE`, `PI_CODING_AGENT`, `GROK_AGENT`, `FM_PI_HARNESS`, `CURSOR_AGENT`, `CURSOR_INVOKED_AS`, and `ANTIGRAVITY_CONVERSATION_ID` before the worker starts so foreign primary markers cannot override the versioned ancestry. [`runtime-backends.md`](runtime-backends.md#agent-liveness-name-sources) owns the resulting tmux liveness verdict and its relationship to the portable decoy regression. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index ad84883ef0b..9b5c32e3844 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -1256,6 +1256,124 @@ Refresh this harness-dependent proof before accepting a cursor upgrade: FM_HARNESS_LIVENESS_DRIFT=1 bin/fm-test-run.sh tests/fm-harness-liveness-drift-live-e2e.test.sh ``` +## agy (Antigravity CLI) + +The crewmate/scout-only agy adapter was verified on 2026-09-03 against tmux on Linux x86-64, starting at Antigravity CLI 1.1.24 and finishing at 1.1.25 after the binary self-updated mid-session. +Record agy behavior rather than an agy version: it updates itself without being asked, its hooks facility is absent from `--help`, and its own changelog shows recent changes to hook ordering. +Refresh this record with `FM_AGY_SIGNALS_LIVE_E2E=1 tests/fm-agy-signals-live-e2e.test.sh`, which fails naming the harness and version. + +### Workspace trust is not covered by the permission flag + +`--dangerously-skip-permissions` governs tool permissions only. +Launching with that flag in two separate brand-new git repositories still rendered the workspace-trust dialog: + +```text +Accessing workspace: + +Do you trust the contents of this project? +Antigravity CLI requires permission to read, edit, and execute files here. +> Yes, I trust this folder + No, exit + ↑/↓ Navigate · enter Confirm +``` + +The dialog draws no status-bar text, so a pane parked on it renders neither a spinner nor `esc to cancel`. +Adding the worktree path to `trustedWorkspaces` in `~/.gemini/antigravity-cli/settings.json` and relaunching reached the ordinary banner and idle prompt with no dialog. +Trust is not inherited by a nested repository: with `/tmp/claude-1000` already trusted, launching in the git repository at `/tmp/claude-1000/agylab` still prompted. +That is why `bin/fm-agy-trust.sh` registers each task worktree by its own resolved path. + +### Turn-end hook and the fullyIdle gate + +Hook loading is reported by agy itself in `~/.gemini/antigravity-cli/cli.log`: + +```text +# only /.agents/hooks.json present, print mode: +hooks_manager.go:53] loaded 0 named hooks from 0 hooks.json file(s) +# only ~/.gemini/config/hooks.json present, print mode: +hooks_manager.go:53] loaded 1 named hooks from 1 hooks.json file(s) +# both present, interactive: +hooks_manager.go:53] loaded 1 named hooks from 1 hooks.json file(s) +hooks_manager.go:53] loaded 2 named hooks from 2 hooks.json file(s) +``` + +Interactive sessions load hooks twice, global first and then again once the workspace is known; print mode never performs the second load, so a workspace-local `.agents/hooks.json` is interactive-only. + +A `Stop` payload carries `workspacePaths` in an interactive pane and an empty array in print mode: + +```json +{ + "conversationId": "32dfe8f7-51b5-4e24-aa6e-28a7648efe15", + "fullyIdle": true, + "modelName": "gemini-3.8-flash-high", + "terminationReason": "NO_TOOL_CALL", + "workspacePaths": [""] +} +``` + +A turn whose shell command outruns agy's own wait produces TWO `Stop` events, and only the second one means the work is finished. +Asking for `sleep 40; echo bg-finished` and recording every payload gave: + +```text +1: fullyIdle=False terminationReason=NO_TOOL_CALL +2: fullyIdle=True terminationReason=NO_TOOL_CALL +``` + +Between them the pane showed `? for shortcuts` with a `· 1 task(s) · /tasks` suffix while the command ran. +Any turn-end signal must therefore require `fullyIdle` true. + +### Where the turn-end signal is silent + +A declined tool call ends the turn with no `Stop` event: choosing `4. No` at a permission prompt returned the pane to idle with `⎿ User declined the tool call` and produced no payload, reproduced twice, with a plain turn in the same session producing one normally as a positive control. +An Escape interrupt also produces no `Stop` event. +`Stop` correctly does not fire while the pane is parked at a permission prompt, so there is no false completion. +`terminationReason` values beyond `NO_TOOL_CALL` are unverified; the payload documents `model_stop`, `max_steps_exceeded`, and `error`, and forcing each would settle whether `Stop` fires on those paths. + +### Rendered identity, control, and launch + +| Fact | Observed | +| --- | --- | +| Idle status bar | `? for shortcuts` | +| Busy, parked on a permission prompt, or slash menu open | `esc to cancel` | +| Background work outstanding | idle bar plus `· N task(s) · /tasks` | +| Interrupt | single Escape; renders `⎿ Interrupted · What should Antigravity CLI do instead?` and leaves the composer empty | +| Exit | `/exit`, or `Ctrl+D` twice with `press ctrl+d again to exit` after the first | +| Resume | `agy --conversation=`, printed at exit | +| Environment marker | `ANTIGRAVITY_CONVERSATION_ID`, equal to the payload `conversationId` | + +`CLAUDECODE=1 agy -p` with the agent printing its own environment returned both `CLAUDECODE=1` and `ANTIGRAVITY_CONVERSATION_ID`, so agy does not clear an inherited primary marker and detection must test agy's marker first. + +Resuming by id from a different working directory restored the conversation and real model context - a question answerable only from the previous turn's tool output was answered correctly - but the banner showed the new working directory and that directory triggered its own trust prompt, so resume must be launched from the original worktree. + +Typing a prompt and sending `Enter` as a separate step submitted it and the agent acted, writing the requested file; a three-line message delivered by bracketed paste landed intact with no premature submit and was answered from all three lines. +`-i` and `-p` consume the next argument, so `agy -p --dangerously-skip-permissions ""` reports that it took the flag as its prompt and ignored the real one; every flag must precede the prompt. + +### Typed-submit confirmation is a tmux-only boundary + +agy's composer is a bare `>` between two horizontal rules rather than a bordered container. +`>` is a shell-prompt glyph, and the composer classifier's safety rule reads a bare shell glyph outside a bordered container as a dead-shell prompt, so an agy pane's composer verdict is always `unknown` - never `empty`: + +```sh +. bin/fm-composer-lib.sh +printf '%s\n' '─────' '> ' '─────' '? for shortcuts Gemini 3.8 Flash · high' | + { read -r a; read -r b; read -r c; read -r d; + fm_composer_classify_screen "" "$(printf '%s\n' "$a" "$b" "$c" "$d")" "" agy; } +``` + +```text +unknown +``` + +On tmux that costs nothing: the rich submit core promotes `unknown` to `empty` when a pane that read idle before typing reads busy after Enter, and agy's two status bars supply exactly that transition (`? for shortcuts` idle, `esc to cancel` busy). + +**Every other backend, Herdr included, reports a typed agy send as unconfirmed.** +On Zellij, cmux, and Orca the shared submit core returns any non-pending verdict as-is. +Herdr does consult its rendered footer, but that rescue is gated on `pending` alone, and an `unknown` verdict is returned unchanged by both its native-busy and native-idle branches. +So a typed-plane agy send lands on all four and `fm-send` still reports delivery unconfirmed and exits non-zero. + +This is NOT the same boundary Cursor has, and the difference is the verdict rather than the backend: Cursor's cursorless composer reads `pending` with typed text (§"Cursor Agent CLI"), which is exactly what Herdr's footer rescue promotes, so Cursor is verified on tmux AND Herdr. +agy's bare `>` can never read `pending`, so the one branch that would rescue it never fires. +It does not bound the spawn: the brief rides the launch command's `-i ""`, not `fm-send`. + ## Pi supervision branch The supervision-branch extension (`.pi/extensions/fm-branch-supervision.ts`, [docs/pi-supervision-branch.md](../pi-supervision-branch.md)) builds its second session through the Pi SDK surface: `createAgentSession` (including its `model`, `modelRuntime`, and `thinkingLevel` options), `DefaultResourceLoader` with `extensionFactories`, `SessionManager`, `createBashToolDefinition` with a `spawnHook`, `sendCustomMessage` for routine notes, `appendEntry` and `registerEntryRenderer` for captain outcomes, the `before_provider_request` hook, the command context's model registry for picker candidates, a fresh `ModelRuntime` for isolated-branch resolution, and Pi's own `getSupportedThinkingLevels`/`clampThinkingLevel` plus its `getThinkingLevel` and `thinking_level_select` extension surface for effort. diff --git a/tests/fm-agy-harness.test.sh b/tests/fm-agy-harness.test.sh new file mode 100755 index 00000000000..4550b0c6636 --- /dev/null +++ b/tests/fm-agy-harness.test.sh @@ -0,0 +1,1175 @@ +#!/usr/bin/env bash +# Behavior tests for the verified agy (Antigravity CLI) crewmate adapter. +# +# The trust registration is the load-bearing half and BOTH sides of its contract +# are proven here: a legitimate fresh task worktree is trusted so an agy worker +# reaches its brief with no human, and every out-of-scope path is REFUSED rather +# than warned about or quietly skipped. agy's trust dialog renders no status-bar +# text, so a worker parked on it is indistinguishable from an idle one; a +# registration that silently did nothing would produce exactly that pane. +set -u + +# shellcheck source=tests/fixtures.sh +. "$(dirname "${BASH_SOURCE[0]}")/fixtures.sh" + +# bin/fm-harness.sh checks verified ENV markers before ancestry. A suite run from +# inside Cursor, Claude, Pi, Grok, or agy inherits those markers, which outrank +# the fake ancestry the detection cases set up. Drop them so the asserted verdict +# does not depend on which harness launched the suite. +unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT \ + CURSOR_INVOKED_AS ANTIGRAVITY_CONVERSATION_ID + +# shellcheck source=bin/fm-trace-context-lib.sh +. "$ROOT/bin/fm-trace-context-lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-agy-harness) +TRUST="$ROOT/bin/fm-agy-trust.sh" +HOOK="$ROOT/bin/fm-agy-turnend-hook.sh" + +# make_case : a project with one linked worktree plus an isolated HOME +# standing in for the launching user's own agy store. +# Echoes "|||". +make_case() { + local name=$1 case_dir proj wt home + case_dir="$TMP_ROOT/$name" + proj="$case_dir/project" + wt="$case_dir/wt" + home="$case_dir/home" + mkdir -p "$home" + fm_git_worktree "$proj" "$wt" "wt-$name" + printf '%s|%s|%s|%s\n' "$case_dir" "$proj" "$wt" "$home" +} + +read_case() { + IFS='|' read -r CASE_DIR PROJ WT AGY_HOME < + HOME="$1" "$TRUST" "$2" "$3" 2>&1 +} + +run_untrust() { # + HOME="$1" "$TRUST" --remove "$2" 2>&1 +} + +trusted_paths() { # + node -e 'const fs=require("node:fs");const p=process.argv[1];if(!fs.existsSync(p))process.exit(0);const j=JSON.parse(fs.readFileSync(p,"utf8"));for(const v of (j.trustedWorkspaces||[]))console.log(v);' "$1" +} + +assert_trusted() { # + trusted_paths "$1" | grep -Fqx "$2" || fail "$3" +} + +assert_not_trusted() { # + trusted_paths "$1" | grep -Fqx "$2" && fail "$3" + return 0 +} + +# The store is the vendor's own persisted JSON, so preservation is asserted +# against the parsed value at a key path rather than the serialized bytes. +store_value() { # + local store=$1 + shift + node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));let v=j;for(const k of process.argv.slice(2)){v=(v===undefined||v===null)?undefined:v[k];}console.log(JSON.stringify(v));' "$store" "$@" +} + +assert_store_value() { # + local store=$1 expected=$2 msg=$3 actual + shift 3 + actual=$(store_value "$store" "$@") + [ "$actual" = "$expected" ] || fail "$msg (expected $expected, got $actual)" +} + +# A PATH carrying the tools the scope test needs but no node, so the +# missing-interpreter path is exercised without disturbing the real PATH. +node_free_path() { # + local dir=$1/nonode-bin tool + mkdir -p "$dir" + for tool in bash env git mkdir sed; do + ln -sf "$(command -v "$tool")" "$dir/$tool" + done + printf '%s\n' "$dir" +} + +# --- trust: the accepted path ------------------------------------------------ + +test_fresh_worktree_is_trusted() { + local rec out store + rec=$(make_case fresh) + read_case "$rec" + out=$(run_trust "$AGY_HOME" "$WT" "$PROJ") + expect_code 0 $? "a fresh linked worktree must be trusted: $out" + assert_contains "$out" "trusted:" "registration did not report what it trusted" + store=$(store_path "$AGY_HOME") + assert_trusted "$store" "$WT" "the worktree was not recorded as trusted" + # The staged write is renamed into place, so no temporary store may survive it. + [ -z "$(find "$(dirname "$store")" -maxdepth 1 -name '.settings.json.fm-trust.*' -print -quit)" ] \ + || fail "a temporary store file was left behind in the settings directory" + pass "fm-agy-trust.sh: a fresh task worktree is trusted" +} + +# agy runs in the pane's cwd, which fm-spawn hands over unresolved, so a +# worktree reached through a symlinked parent can be presented to the trust +# lookup under either spelling. A miss is silent - the pane parks on a dialog +# that draws no status text - so both spellings of the one directory are recorded. +test_symlinked_worktree_spelling_is_trusted_too() { + local rec out store link wt_link + rec=$(make_case symlink-spelling) + read_case "$rec" + link="$TMP_ROOT/symlink-spelling-link" + ln -sfn "$CASE_DIR" "$link" + wt_link="$link/wt" + out=$(run_trust "$AGY_HOME" "$wt_link" "$PROJ") + expect_code 0 $? "a worktree named through a symlinked parent must be trusted: $out" + store=$(store_path "$AGY_HOME") + assert_trusted "$store" "$wt_link" "the launch spelling of the worktree was not recorded as trusted" + assert_trusted "$store" "$WT" "the resolved spelling of the worktree was not recorded as trusted" + pass "fm-agy-trust.sh: both spellings of a symlinked worktree path are trusted" +} + +test_registration_is_idempotent() { + local rec out count + rec=$(make_case idempotent) + read_case "$rec" + run_trust "$AGY_HOME" "$WT" "$PROJ" >/dev/null + out=$(run_trust "$AGY_HOME" "$WT" "$PROJ") + expect_code 0 $? "a repeat registration must succeed: $out" + count=$(trusted_paths "$(store_path "$AGY_HOME")" | grep -Fxc "$WT") + [ "$count" = 1 ] || fail "a repeat registration duplicated the entry ($count)" + pass "fm-agy-trust.sh: repeat registration is idempotent" +} + +# The registration is the one task artifact written outside this home, into the +# operator's own vendor settings, so teardown needs a withdrawal that takes back +# exactly what the spawn added and nothing else. +# Registration covers both spellings of the directory, and each is withdrawn by +# naming it: the caller withdraws from a record of what its own registration +# reported adding, so withdrawing both is two calls, not one that re-derives. +test_removal_withdraws_the_spelling_it_is_named_and_keeps_the_rest() { + local rec out store link wt_link + rec=$(make_case removal) + read_case "$rec" + store=$(store_path "$AGY_HOME") + mkdir -p "$(dirname "$store")" + printf '%s\n' '{"enableTelemetry":false,"trustedWorkspaces":["/already/trusted"]}' > "$store" + link="$TMP_ROOT/removal-link" + ln -sfn "$CASE_DIR" "$link" + wt_link="$link/wt" + run_trust "$AGY_HOME" "$wt_link" "$PROJ" >/dev/null || fail "the fixture registration failed" + assert_trusted "$store" "$wt_link" "the fixture did not register the launch spelling" + assert_trusted "$store" "$WT" "the fixture did not register the resolved spelling" + out=$(run_untrust "$AGY_HOME" "$wt_link") + expect_code 0 $? "a registered worktree must be withdrawable: $out" + assert_not_trusted "$store" "$wt_link" "the named spelling survived its own withdrawal" + assert_trusted "$store" "$WT" "withdrawing one spelling took the other spelling with it" + out=$(run_untrust "$AGY_HOME" "$WT") + expect_code 0 $? "the second spelling must be withdrawable too: $out" + assert_not_trusted "$store" "$WT" "the resolved spelling survived being named" + assert_trusted "$store" "/already/trusted" "the withdrawal dropped an unrelated operator entry" + assert_store_value "$store" 'false' "the withdrawal disturbed an unrelated key" enableTelemetry + pass "fm-agy-trust.sh: removal withdraws the spelling it is named and leaves the rest alone" +} + +# The two spellings of one directory can have two different owners: the operator +# trusted the resolved path by hand, and only the launch spelling is the task's. +# Re-deriving the pair on removal would take theirs, and the dialog it resurrects +# draws no status text, so the pane it wedges looks idle. +test_removal_leaves_the_operator_spelling_of_the_same_directory() { + local rec out store link wt_link + rec=$(make_case removal-operator-spelling) + read_case "$rec" + store=$(store_path "$AGY_HOME") + mkdir -p "$(dirname "$store")" + # The operator's own entry, on the RESOLVED spelling, made before any task ran. + printf '{"trustedWorkspaces":["%s"]}\n' "$WT" > "$store" + link="$TMP_ROOT/removal-operator-link" + ln -sfn "$CASE_DIR" "$link" + wt_link="$link/wt" + out=$(run_trust "$AGY_HOME" "$wt_link" "$PROJ") + expect_code 0 $? "the spawn's registration must succeed: $out" + assert_contains "$out" "added: $wt_link" "the registration did not report adding the launch spelling" + case "$out" in + *"added: $WT"*) fail "the registration claimed to add the spelling the operator already trusted" ;; + esac + out=$(run_untrust "$AGY_HOME" "$wt_link") + expect_code 0 $? "the task's own spelling must be withdrawable: $out" + assert_not_trusted "$store" "$wt_link" "the task's own spelling survived the withdrawal" + assert_trusted "$store" "$WT" "the withdrawal revoked the operator's own entry for the same directory" + pass "fm-agy-trust.sh: removal leaves the operator's spelling of the same directory alone" +} + +# Teardown calls the withdrawal for every task, so a task that never ran on agy +# must not cause a write at all - reformatting a vendor file firstmate does not +# own is exactly what the registration promises not to do. +test_removal_of_an_unregistered_path_writes_nothing() { + local rec out store before + rec=$(make_case removal-noop) + read_case "$rec" + store=$(store_path "$AGY_HOME") + mkdir -p "$(dirname "$store")" + printf '%s\n' '{"enableTelemetry":false,"trustedWorkspaces":["/already/trusted"]}' > "$store" + before=$(cat "$store") + out=$(run_untrust "$AGY_HOME" "$WT") + expect_code 0 $? "withdrawing a path that was never registered must succeed: $out" + [ "$(cat "$store")" = "$before" ] \ + || fail "withdrawing an unregistered path rewrote the operator's store" + pass "fm-agy-trust.sh: withdrawing an unregistered path leaves the store byte-identical" +} + +# A home that never ran agy has no store, and a teardown must not mint one. +test_removal_without_a_store_creates_nothing() { + local rec out + rec=$(make_case removal-no-store) + read_case "$rec" + out=$(run_untrust "$AGY_HOME" "$WT") + expect_code 0 $? "withdrawing against a home with no agy store must succeed: $out" + [ ! -e "$AGY_HOME/.gemini" ] || fail "the withdrawal created an agy store in a home that had none" + pass "fm-agy-trust.sh: withdrawing against a home with no store creates nothing" +} + +# Teardown withdraws while the worktree still resolves, but the entry is an +# absolute path and must stay withdrawable once the directory is gone. +test_removal_works_after_the_worktree_is_gone() { + local rec out store wt_path + rec=$(make_case removal-after-removal) + read_case "$rec" + store=$(store_path "$AGY_HOME") + wt_path=$WT + run_trust "$AGY_HOME" "$WT" "$PROJ" >/dev/null || fail "the fixture registration failed" + git -C "$PROJ" worktree remove --force "$WT" >/dev/null 2>&1 || rm -rf "$WT" + [ ! -d "$wt_path" ] || fail "the fixture did not remove the worktree" + out=$(run_untrust "$AGY_HOME" "$wt_path") + expect_code 0 $? "a removed worktree's registration must still be withdrawable: $out" + assert_not_trusted "$store" "$wt_path" "the registration survived after the worktree was removed" + pass "fm-agy-trust.sh: a registration is withdrawable after its worktree is gone" +} + +# The registration is idempotent, so it cannot be asked "did you add this?" after +# the fact - it has to say so at the time. Teardown withdraws exactly what it +# reported adding, and a workspace the operator trusted by hand is not that. +test_registration_reports_only_what_it_added() { + local rec out + rec=$(make_case reports-added) + read_case "$rec" + out=$(run_trust "$AGY_HOME" "$WT" "$PROJ") + expect_code 0 $? "a fresh registration must succeed: $out" + assert_contains "$out" "added: $WT" "a fresh registration did not report the spelling it added" + out=$(run_trust "$AGY_HOME" "$WT" "$PROJ") + expect_code 0 $? "a repeat registration must succeed: $out" + case "$out" in + *"added: "*) fail "a repeat registration claimed to add a spelling that was already trusted: $out" ;; + esac + assert_contains "$out" "trusted:" "a repeat registration did not report what it trusted" + assert_trusted "$(store_path "$AGY_HOME")" "$WT" "the repeat registration dropped the entry" + pass "fm-agy-trust.sh: registration reports only the spellings it actually added" +} + +test_unrelated_store_content_is_preserved() { + local rec store + rec=$(make_case preserve) + read_case "$rec" + store=$(store_path "$AGY_HOME") + mkdir -p "$(dirname "$store")" + cat > "$store" <<'JSON' +{"enableTelemetry":false,"remoteControlHostname":"box-1","trustedWorkspaces":["/already/trusted"]} +JSON + run_trust "$AGY_HOME" "$WT" "$PROJ" >/dev/null || fail "registration failed against an existing store" + assert_trusted "$store" "$WT" "the worktree was not recorded in an existing store" + assert_trusted "$store" "/already/trusted" "an existing trusted workspace was dropped" + assert_store_value "$store" false "an unrelated top-level key was lost" enableTelemetry + assert_store_value "$store" '"box-1"' "an unrelated top-level value was changed" remoteControlHostname + pass "fm-agy-trust.sh: preserves unrelated store content" +} + +# --- trust: the refusals ----------------------------------------------------- + +test_primary_checkout_is_refused() { + local rec out + rec=$(make_case primary) + read_case "$rec" + out=$(run_trust "$AGY_HOME" "$PROJ" "$PROJ") + expect_code 1 $? "the primary checkout must be refused: $out" + assert_contains "$out" "primary checkout" "the refusal did not name the primary checkout" + assert_not_trusted "$(store_path "$AGY_HOME")" "$PROJ" "the primary checkout was trusted" + pass "fm-agy-trust.sh: refuses the primary checkout" +} + +# CDPATH redirects a relative `cd` operand, and `git rev-parse --git-common-dir` +# answers `.git` for a primary checkout. With a decoy on CDPATH that also holds a +# `.git`, the common dir resolved for both arguments can land in the decoy +# instead, so the git-dir-vs-common-dir comparison would disagree and the primary +# checkout would be trusted. +test_cdpath_cannot_defeat_the_primary_checkout_refusal() { + local rec out + rec=$(make_case cdpath) + read_case "$rec" + mkdir -p "$CASE_DIR/decoy/.git" + export CDPATH="$CASE_DIR/decoy" + out=$(run_trust "$AGY_HOME" "$PROJ" "$PROJ") + set -- $? + unset CDPATH + expect_code 1 "$1" "an exported CDPATH must not let the primary checkout through: $out" + assert_contains "$out" "primary checkout" "the refusal did not name the primary checkout" + assert_not_trusted "$(store_path "$AGY_HOME")" "$PROJ" "an exported CDPATH let the primary checkout be trusted" + pass "fm-agy-trust.sh: an exported CDPATH cannot defeat the scope refusal" +} + +# Git exports GIT_DIR into every hook environment, so an inherited pair is +# ordinary. With GIT_DIR naming a linked worktree's git dir and GIT_WORK_TREE +# naming the primary checkout, git reports a toplevel that matches the argument +# and a git dir that differs from the common dir, so the primary checkout would +# satisfy the refusal on the caller's environment rather than on disk. +test_git_env_overrides_cannot_defeat_the_primary_checkout_refusal() { + local rec out + rec=$(make_case gitenv) + read_case "$rec" + GIT_DIR=$(git -C "$WT" rev-parse --absolute-git-dir) + GIT_WORK_TREE=$PROJ + export GIT_DIR GIT_WORK_TREE + out=$(run_trust "$AGY_HOME" "$PROJ" "$PROJ") + set -- $? + unset GIT_DIR GIT_WORK_TREE + expect_code 1 "$1" "inherited git environment overrides must not let the primary checkout through: $out" + assert_contains "$out" "primary checkout" "the refusal did not name the primary checkout" + assert_not_trusted "$(store_path "$AGY_HOME")" "$PROJ" "inherited git environment overrides let the primary checkout be trusted" + pass "fm-agy-trust.sh: inherited git environment overrides cannot defeat the scope refusal" +} + +test_home_directory_is_refused_even_when_it_is_a_worktree() { + local rec out home + rec=$(make_case home-worktree) + read_case "$rec" + # Make HOME itself a linked worktree of the project, so every git check PASSES + # and only the home guard can refuse it. Without this the home case would pass + # vacuously through the "not inside a git repository" branch. + home="$CASE_DIR/home-wt" + git -C "$PROJ" worktree add --quiet -b wt-home "$home" + out=$(run_trust "$home" "$home" "$PROJ") + expect_code 1 $? "a home directory must be refused even as a valid worktree: $out" + assert_contains "$out" "home directory" "the refusal did not name the home directory" + assert_not_trusted "$(store_path "$home")" "$home" "the home directory was trusted" + # Prove the git checks really would have accepted it, so the guard above is + # what refused rather than an unrelated failure. + out=$(run_trust "$AGY_HOME" "$home" "$PROJ") + expect_code 0 $? "the same path must be acceptable once it is not HOME: $out" + pass "fm-agy-trust.sh: refuses a home directory the git checks would accept" +} + +test_settings_directory_is_refused() { + local rec out settings_dir + rec=$(make_case settings-dir) + read_case "$rec" + settings_dir="$AGY_HOME/.gemini/antigravity-cli" + mkdir -p "$settings_dir" + out=$(run_trust "$AGY_HOME" "$settings_dir" "$PROJ") + expect_code 1 $? "the agy settings directory must be refused: $out" + assert_contains "$out" "settings directory" "the refusal did not name the settings directory" + pass "fm-agy-trust.sh: refuses the agy settings directory" +} + +test_non_git_directory_is_refused() { + local rec out plain + rec=$(make_case plain) + read_case "$rec" + plain="$CASE_DIR/plain" + mkdir -p "$plain" + out=$(run_trust "$AGY_HOME" "$plain" "$PROJ") + expect_code 1 $? "a plain directory must be refused: $out" + assert_contains "$out" "not inside a git repository" "the refusal did not name the missing repository" + assert_not_trusted "$(store_path "$AGY_HOME")" "$plain" "a plain directory was trusted" + pass "fm-agy-trust.sh: refuses a directory that is not a git worktree" +} + +test_missing_directory_is_refused() { + local rec out + rec=$(make_case missing) + read_case "$rec" + out=$(run_trust "$AGY_HOME" "$CASE_DIR/nope" "$PROJ") + expect_code 1 $? "a nonexistent path must be refused: $out" + assert_contains "$out" "not an accessible directory" "the refusal did not name the inaccessible path" + pass "fm-agy-trust.sh: refuses a path that does not exist" +} + +test_foreign_project_worktree_is_refused() { + local rec out other other_wt + rec=$(make_case foreign) + read_case "$rec" + other="$CASE_DIR/other-project" + other_wt="$CASE_DIR/other-wt" + fm_git_worktree "$other" "$other_wt" wt-other + out=$(run_trust "$AGY_HOME" "$other_wt" "$PROJ") + expect_code 1 $? "another project's worktree must be refused: $out" + assert_contains "$out" "is not a worktree of project" "the refusal did not name the project mismatch" + assert_not_trusted "$(store_path "$AGY_HOME")" "$other_wt" "a foreign project's worktree was trusted" + pass "fm-agy-trust.sh: refuses a worktree belonging to another project" +} + +test_worktree_subdirectory_is_refused() { + local rec out sub + rec=$(make_case subdir) + read_case "$rec" + sub="$WT/sub" + mkdir -p "$sub" + out=$(run_trust "$AGY_HOME" "$sub" "$PROJ") + expect_code 1 $? "a subdirectory of the worktree must be refused: $out" + assert_contains "$out" "is not a worktree root" "the refusal did not name the non-root path" + assert_not_trusted "$(store_path "$AGY_HOME")" "$sub" "a worktree subdirectory was trusted" + pass "fm-agy-trust.sh: refuses a subdirectory of the worktree" +} + +test_symlinked_store_to_a_foreign_owned_target_is_refused() { + local rec out store + rec=$(make_case symlink-foreign) + read_case "$rec" + # Root owns /etc/passwd as a regular file on both Linux and macOS, so it stands + # in for a store resolving outside this user's ownership. Running as root would + # own it and make the refusal vacuous. + if [ "$(id -u)" = 0 ]; then + pass "fm-agy-trust.sh: refuses a store symlinked to another user's file (skipped as root)" + return 0 + fi + store=$(store_path "$AGY_HOME") + mkdir -p "$(dirname "$store")" + ln -s /etc/passwd "$store" + out=$(run_trust "$AGY_HOME" "$WT" "$PROJ") + expect_code 1 $? "a store resolving to another user's file must be refused: $out" + assert_contains "$out" "not owned by this user" "the refusal did not name the ownership failure" + assert_contains "$out" "/etc/passwd" "the refusal named the link rather than the resolved target it judged" + pass "fm-agy-trust.sh: refuses a store symlinked to another user's file" +} + +test_symlinked_store_to_an_owned_target_is_accepted() { + local rec out target store + rec=$(make_case symlink-owned) + read_case "$rec" + # The dotfile-manager and synced-folder layout: the store is a symlink whose + # target this user owns, so it must be followed rather than refused, and the + # link must survive so the layout keeps working. + target="$CASE_DIR/dotfiles/settings.json" + mkdir -p "$CASE_DIR/dotfiles" + printf '%s\n' '{"enableTelemetry":true,"trustedWorkspaces":[]}' > "$target" + store=$(store_path "$AGY_HOME") + mkdir -p "$(dirname "$store")" + ln -s "$target" "$store" + out=$(run_trust "$AGY_HOME" "$WT" "$PROJ") + expect_code 0 $? "a store symlinked to this user's own file must be accepted: $out" + assert_trusted "$target" "$WT" "the trust did not land in the symlink's target" + [ -L "$store" ] || fail "the store symlink was replaced by a regular file instead of followed" + assert_store_value "$target" true "an unrelated key in the target was lost" enableTelemetry + [ -z "$(find "$CASE_DIR/dotfiles" -maxdepth 1 -name '.settings.json.fm-trust.*' -print -quit)" ] \ + || fail "a temporary store file was left beside the resolved target" + pass "fm-agy-trust.sh: follows a store symlink to this user's own file and leaves the link intact" +} + +test_corrupt_store_fails_closed() { + local rec out store + rec=$(make_case corrupt) + read_case "$rec" + store=$(store_path "$AGY_HOME") + mkdir -p "$(dirname "$store")" + printf '%s\n' 'not json' > "$store" + out=$(run_trust "$AGY_HOME" "$WT" "$PROJ") + expect_code 1 $? "an unparseable store must be refused: $out" + assert_grep 'not json' "$store" "the unparseable store was overwritten instead of left alone" + pass "fm-agy-trust.sh: refuses an unparseable store and leaves it untouched" +} + +# A non-array trustedWorkspaces is a store shape this does not own, so it is +# refused rather than replaced: overwriting would discard whatever agy meant by +# it, in a format firstmate has no claim over. +test_non_array_trusted_workspaces_fails_closed() { + local rec out store + rec=$(make_case non-array) + read_case "$rec" + store=$(store_path "$AGY_HOME") + mkdir -p "$(dirname "$store")" + printf '%s\n' '{"trustedWorkspaces":"/one/path"}' > "$store" + out=$(run_trust "$AGY_HOME" "$WT" "$PROJ") + expect_code 1 $? "a non-array trustedWorkspaces must be refused: $out" + assert_grep '"/one/path"' "$store" "the unexpected store shape was overwritten instead of left alone" + pass "fm-agy-trust.sh: refuses a non-array trustedWorkspaces and leaves it untouched" +} + +# Registering trust is what keeps a worker off the dialog, so a missing node +# refuses rather than degrades: proceeding would launch the worker straight into +# the dialog this control exists to remove. +test_missing_node_is_refused() { + local rec out bindir + rec=$(make_case no-node) + read_case "$rec" + bindir=$(node_free_path "$CASE_DIR") + out=$(PATH="$bindir" run_trust "$AGY_HOME" "$WT" "$PROJ") + expect_code 1 $? "a missing node must refuse rather than let the spawn proceed: $out" + assert_contains "$out" "node" "the refusal did not name the missing interpreter" + assert_not_trusted "$(store_path "$AGY_HOME")" "$WT" "a worktree was trusted without an interpreter to write the store" + case "$out" in + *"trusted:"*) fail "a registration was claimed although none could be written: $out" ;; + esac + pass "fm-agy-trust.sh: a missing node is refused rather than degraded" +} + +# A missing interpreter must not soften the scope boundary, which git and the +# filesystem decide on their own. +test_scope_refusal_stays_fail_closed_without_node() { + local rec out bindir + rec=$(make_case no-node-refusal) + read_case "$rec" + bindir=$(node_free_path "$CASE_DIR") + out=$(PATH="$bindir" run_trust "$AGY_HOME" "$PROJ" "$PROJ") + expect_code 1 $? "the primary checkout must still be refused without node: $out" + assert_contains "$out" "primary checkout" "the refusal did not name the primary checkout" + pass "fm-agy-trust.sh: a scope refusal stays fail-closed without node" +} + +# fm-spawn runs from a live firstmate session that may itself be driving agy, so +# the store can be rewritten mid-registration. Losing the vendor's write would +# discard keys this does not own, so a store that moved is refused rather than +# clobbered. +# +# The race is driven deterministically rather than by a sleeping background +# writer: a preloaded module wraps fs.writeFileSync so that the moment the +# registration stages its temporary store - which is after it has read the +# original and before it re-checks the fingerprint - the real store is rewritten +# underneath it. That is the exact window the guard exists to catch, and it lands +# on every attempt, so the assertion cannot go flaky or vacuous. +test_concurrent_store_rewrite_is_refused_rather_than_clobbered() { + local rec out store shim real_node inject + rec=$(make_case concurrent) + read_case "$rec" + store=$(store_path "$AGY_HOME") + mkdir -p "$(dirname "$store")" + printf '%s\n' '{"enableTelemetry":false,"trustedWorkspaces":[]}' > "$store" + real_node=$(command -v node) || fail "test needs node" + shim="$CASE_DIR/shim" + inject="$CASE_DIR/inject.js" + mkdir -p "$shim" + cat > "$inject" < { + const result = original(target, ...rest); + if (typeof target === "string" && target.includes(".settings.json.fm-trust.")) { + n += 1; + original(store, JSON.stringify({ enableTelemetry: false, vendorKey: n, trustedWorkspaces: [] }, null, 2) + "\n"); + } + return result; +}; +JS + cat > "$shim/node" <&1; } +run_hook_remove() { HOME="$1" "$HOOK" remove 2>&1; } +hooks_config() { printf '%s/.gemini/config/hooks.json\n' "$1"; } +hook_script() { printf '%s/.gemini/antigravity-cli/fm-turn-end.sh\n' "$1"; } +hook_registry() { printf '%s/.gemini/antigravity-cli/fm-turn-end.d\n' "$1"; } + +hook_payload() { # + printf '{"fullyIdle":%s,"workspacePaths":["%s"],"conversationId":"c1","terminationReason":"NO_TOOL_CALL"}' "$1" "$2" +} + +test_hook_install_preserves_operator_hooks_and_remove_restores_them() { + local rec out config + rec=$(make_case hook-install) + read_case "$rec" + config=$(hooks_config "$AGY_HOME") + mkdir -p "$(dirname "$config")" + printf '%s\n' '{"operator-lint":{"PostToolUse":[]}}' > "$config" + out=$(run_hook_install "$AGY_HOME") + expect_code 0 $? "installing the turn-end hook must succeed: $out" + assert_store_value "$config" '{"PostToolUse":[]}' "the operator's own hook was lost on install" operator-lint + assert_store_value "$config" '"command"' "the firstmate hook was not installed as a command hook" \ + firstmate-turn-end Stop 0 type + [ -x "$(hook_script "$AGY_HOME")" ] || fail "the hook script was not installed executable" + out=$(run_hook_remove "$AGY_HOME") + expect_code 0 $? "removing the turn-end hook must succeed: $out" + assert_store_value "$config" '{"PostToolUse":[]}' "the operator's own hook was lost on remove" operator-lint + assert_store_value "$config" undefined "the firstmate hook survived removal" firstmate-turn-end + [ ! -e "$(hook_script "$AGY_HOME")" ] || fail "the hook script survived removal" + [ ! -e "$(hook_registry "$AGY_HOME")" ] || fail "the token registry survived removal" + pass "fm-agy-turnend-hook.sh: install and remove leave operator hooks untouched" +} + +# THE fullyIdle GATE. agy backgrounds a command that outruns its own wait, yields +# the composer, and fires Stop with fullyIdle false while that command still +# runs. Touching the marker there would report a worker done while its own build +# is still going, so only fullyIdle true may signal a finished turn. +test_hook_signals_only_a_fully_idle_turn() { + local rec marker token registry + rec=$(make_case hook-fullyidle) + read_case "$rec" + run_hook_install "$AGY_HOME" >/dev/null || fail "install failed" + registry=$(hook_registry "$AGY_HOME") + marker="$CASE_DIR/task.turn-ended" + token=fm.aaaaaaaaaaaa + printf '%s\n' "$marker" > "$registry/$token" + printf 'token=%s\n' "$token" > "$WT/.fm-agy-turnend" + + hook_payload false "$WT" | HOME="$AGY_HOME" bash "$(hook_script "$AGY_HOME")" >/dev/null + [ ! -e "$marker" ] || fail "a Stop with fullyIdle false reported the turn finished" + + hook_payload true "$WT" | HOME="$AGY_HOME" bash "$(hook_script "$AGY_HOME")" >/dev/null + [ -e "$marker" ] || fail "a Stop with fullyIdle true did not signal the finished turn" + pass "fm-agy-turnend-hook.sh: signals only a fullyIdle turn end" +} + +# The hook is global, so it must be inert for a workspace that is not a +# firstmate task: an unregistered token can never touch a marker. +test_hook_ignores_an_unregistered_token() { + local rec marker + rec=$(make_case hook-token) + read_case "$rec" + run_hook_install "$AGY_HOME" >/dev/null || fail "install failed" + marker="$CASE_DIR/task.turn-ended" + printf 'token=fm.zzzzzzzzzzzz\n' > "$WT/.fm-agy-turnend" + hook_payload true "$WT" | HOME="$AGY_HOME" bash "$(hook_script "$AGY_HOME")" >/dev/null + [ ! -e "$marker" ] || fail "an unregistered token signalled a turn end" + pass "fm-agy-turnend-hook.sh: ignores a workspace whose token is not registered" +} + +# The hook blocks agy's own loop, so it must always answer with valid JSON and +# exit zero even when it does nothing at all. +test_hook_always_answers_and_exits_zero() { + local rec out + rec=$(make_case hook-answer) + read_case "$rec" + run_hook_install "$AGY_HOME" >/dev/null || fail "install failed" + out=$(printf 'not json at all' | HOME="$AGY_HOME" bash "$(hook_script "$AGY_HOME")") + expect_code 0 $? "the hook must exit zero on an unparseable payload" + [ "$out" = '{}' ] || fail "the hook answered '$out' rather than an empty JSON object" + pass "fm-agy-turnend-hook.sh: always answers with {} and exits zero" +} + +test_hook_remove_refuses_while_a_task_token_is_live() { + local rec out + rec=$(make_case hook-live-token) + read_case "$rec" + run_hook_install "$AGY_HOME" >/dev/null || fail "install failed" + printf '%s\n' "$CASE_DIR/task.turn-ended" > "$(hook_registry "$AGY_HOME")/fm.bbbbbbbbbbbb" + out=$(run_hook_remove "$AGY_HOME") + expect_code 1 $? "removing the hook under a live task token must be refused: $out" + assert_contains "$out" "still registered" "the refusal did not name the live token" + [ -e "$(hook_script "$AGY_HOME")" ] || fail "the hook script was removed despite the refusal" + pass "fm-agy-turnend-hook.sh: refuses removal while a task still expects a wake" +} + +test_hook_refuses_a_malformed_config() { + local rec out config + rec=$(make_case hook-malformed) + read_case "$rec" + config=$(hooks_config "$AGY_HOME") + mkdir -p "$(dirname "$config")" + printf '%s\n' 'not json' > "$config" + out=$(run_hook_install "$AGY_HOME") + expect_code 1 $? "a malformed hooks config must be refused: $out" + assert_grep 'not json' "$config" "the malformed config was overwritten instead of left alone" + pass "fm-agy-turnend-hook.sh: refuses a malformed hooks config and leaves it untouched" +} + +# The registry is created on the first install a box ever runs, and a captain can +# dispatch several agy crewmates at once. Losing that race must not refuse a +# spawn whose directory now exists and is correct. +test_concurrent_first_installs_all_succeed() { + local round home failures + for round in 1 2 3 4 5; do + home="$TMP_ROOT/concurrent-install-$round" + mkdir -p "$home" + for _ in 1 2 3 4 5 6 7 8; do + ( run_hook_install "$home" >/dev/null 2>&1 || printf 'x' >> "$home/failures" ) & + done + wait + if [ -s "$home/failures" ]; then + failures=$(wc -c < "$home/failures" | tr -d ' ') + fail "$failures of 8 concurrent first installs were refused in round $round" + fi + assert_store_value "$(hooks_config "$home")" '"command"' \ + "a concurrent install round left no usable hook" firstmate-turn-end Stop 0 type + done + pass "fm-agy-turnend-hook.sh: concurrent first installs all succeed" +} + +# The registry holds every live task's wake token, so its mode is a property of +# the install rather than of whoever happened to create the directory first. +test_install_normalizes_a_loose_registry_mode() { + local rec mode + rec=$(make_case loose-registry) + read_case "$rec" + mkdir -p "$AGY_HOME/.gemini/antigravity-cli/fm-turn-end.d" + chmod 0755 "$AGY_HOME/.gemini/antigravity-cli/fm-turn-end.d" + run_hook_install "$AGY_HOME" >/dev/null || fail "install failed against an existing registry" + mode=$(stat -c %a "$AGY_HOME/.gemini/antigravity-cli/fm-turn-end.d" 2>/dev/null \ + || stat -f %Lp "$AGY_HOME/.gemini/antigravity-cli/fm-turn-end.d") + [ "$mode" = 700 ] \ + || fail "the install left the registry world-readable (mode $mode)" + pass "fm-agy-turnend-hook.sh: install narrows a loose registry directory mode" +} + +# --- adapter tables ---------------------------------------------------------- + +# agy does NOT clear an inherited CLAUDECODE, so both markers can be present at +# once and whichever is tested first decides. Drive them apart deliberately: with +# both set the verdict must be agy, and removing agy's marker must flip it back +# to claude, so the case cannot pass vacuously. +test_detection_prefers_the_agy_marker_over_an_inherited_claudecode() { + local verdict + verdict=$(ANTIGRAVITY_CONVERSATION_ID=abc123 CLAUDECODE=1 "$ROOT/bin/fm-harness.sh") + [ "$verdict" = agy ] || fail "an agy session carrying an inherited CLAUDECODE detected as '$verdict'" + verdict=$(CLAUDECODE=1 "$ROOT/bin/fm-harness.sh") + [ "$verdict" = claude ] || fail "removing agy's marker did not restore the claude verdict (got '$verdict')" + pass "fm-harness.sh: agy's marker outranks an inherited CLAUDECODE" +} + +test_control_tables_carry_agys_verified_mechanics() { + # shellcheck source=bin/fm-control-lib.sh + . "$ROOT/bin/fm-control-lib.sh" + fm_control_harness_supported agy || fail "agy is not a supported control-plane harness" + [ "$(fm_control_harness_family agy)" = agy ] || fail "agy does not resolve to its own adapter family" + [ "$(fm_control_interrupt_key agy)" = Escape ] || fail "agy's interrupt key is not Escape" + [ "$(fm_control_interrupt_repeat agy)" = 1 ] || fail "agy's interrupt is not a single press" + [ -z "$(fm_control_interrupt_clear_key agy)" ] || fail "agy was given a composer clear key it does not need" + [ "$(fm_control_exit_command agy)" = /exit ] || fail "agy's exit command is not /exit" + fm_control_harness_supports_kind agy ship || fail "agy must be usable for a ship task" + fm_control_harness_supports_kind agy scout || fail "agy must be usable for a scout task" + ! fm_control_harness_supports_kind agy secondmate \ + || fail "agy was accepted for a secondmate despite having no primary supervision protocol" + pass "fm-control-lib.sh: agy's verified control mechanics are registered" +} + +test_delivery_guard_reads_agys_status_bar() { + # shellcheck source=bin/fm-composer-lib.sh + . "$ROOT/bin/fm-composer-lib.sh" + printf '%s\n' '⣾ Editing files...' 'esc to cancel Gemini 3.8 Flash · high' \ + | fm_busy_lines_match agy || fail "agy's busy status bar was not read as busy" + printf '%s\n' '>' '? for shortcuts Gemini 3.8 Flash · high' \ + | fm_busy_lines_match agy && fail "agy's idle status bar was read as busy" + # A tool-permission prompt is NOT a pane that will accept a steer, and it + # carries the same footer, so the guard must refuse it too. + printf '%s\n' 'Do you want to proceed?' 'esc to cancel Gemini 3.8 Flash · high' \ + | fm_busy_lines_match agy || fail "a pane parked on a permission prompt was read as free" + pass "fm-composer-lib.sh: agy's delivery guard separates its two status bars" +} + +# agy's composer is a bare `>` between rules, and the classifier's safety rule +# reads a bare shell glyph outside a bordered container as a dead shell prompt. +# The verdict is therefore `unknown`, never `empty`, which is the whole reason +# typed-submit confirmation is a tmux-only boundary for agy - the docs claim that +# scope, so the verdict it rests on is pinned here rather than assumed. +test_agy_composer_verdict_is_unknown_not_empty() { + # shellcheck source=bin/fm-composer-lib.sh + . "$ROOT/bin/fm-composer-lib.sh" + local screen verdict + screen=$(printf '%s\n' \ + '─────────────────────────────────────────' \ + '> ' \ + '─────────────────────────────────────────' \ + '? for shortcuts Gemini 3.8 Flash · high') + verdict=$(fm_composer_classify_screen "" "$screen" "" agy) + [ "$verdict" = unknown ] \ + || fail "an empty agy composer classified '$verdict'; the documented tmux-only submit boundary rests on 'unknown'" + pass "fm-composer-lib.sh: an empty agy composer classifies unknown, not empty" +} + +test_spawn_refuses_a_secondmate_on_agy() { + local case_dir home out + case_dir="$TMP_ROOT/secondmate-refusal" + home="$case_dir/home" + fm_test_spawn_home "$home" agy + out=$(fm_test_run_spawn "$home" "$case_dir/pane" "$(fm_fakebin "$case_dir/fake")" \ + --secondmate smtest "$home" agy 2>&1 || true) + assert_contains "$out" "cannot run a secondmate" "a secondmate spawn on agy was not refused" + # The documented two-positional form omits the firstmate home, so agy has to be + # recognised as a harness name there too; unrecognised it binds as a home path + # and the run dies pointing at a directory that was never named. The home here + # is pinned to another harness so the refusal can only come from the positional + # agy, not from a configured default that happens to be agy already. + local other_home + other_home="$case_dir/other-home" + fm_test_spawn_home "$other_home" claude + out=$(fm_test_run_spawn "$other_home" "$case_dir/pane" "$(fm_fakebin "$case_dir/fake")" \ + --secondmate smtest agy 2>&1 || true) + assert_contains "$out" "cannot run a secondmate" \ + "the home-less secondmate form did not reach the agy adapter refusal" + pass "fm-spawn.sh: refuses a secondmate launch on agy" +} + +# agy's marker is tested BEFORE claude's, so an ANTIGRAVITY_CONVERSATION_ID that +# survives in the environment a non-agy worker is launched from would make that +# worker report itself as agy and misroute every crew and secondmate decision +# derived from it. The launch boundary is where the foreign marker is dropped. +test_a_non_agy_launch_clears_the_inherited_agy_marker() { + local case_dir home proj wt fakebin launch_log out launch prefix verdict + case_dir="$TMP_ROOT/marker-clear" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + launch_log="$case_dir/launch.log" + fakebin=$(make_spawn_fakebin "$case_dir/fake" claude) + fm_test_spawn_home "$home" claude + fm_git_worktree "$proj" "$wt" wt-marker-clear + fm_test_spawn_brief "$home" markerclear + out=$(FM_FAKE_LAUNCH_LOG="$launch_log" \ + fm_test_run_spawn "$home" "$wt" "$fakebin" markerclear "$proj" claude \ + --mode no-mistakes --yolo off) + expect_code 0 $? "the claude spawn must succeed: $out" + assert_present "$launch_log" "the claude spawn sent no launch command" + launch=$(head -1 "$launch_log") + prefix=${launch%%claude *} + [ "$prefix" != "$launch" ] || fail "the launch command did not invoke claude: $launch" + # Run the launch command's own environment prefix over the detector: the + # launched worker must self-identify as claude even when the pane it is + # created from still carries an agy conversation id. + verdict=$(ANTIGRAVITY_CONVERSATION_ID=abc123 CLAUDECODE=1 \ + eval "$prefix \"$ROOT/bin/fm-harness.sh\"") + [ "$verdict" = claude ] \ + || fail "a claude worker launched under an inherited agy marker detected as '$verdict'" + pass "fm-spawn.sh: a non-agy launch drops an inherited agy marker" +} + +# The spawn half: a real fm-spawn of an agy worker must pre-register the +# worktree, install the turn-end hook, mint its task token, AND deliver the +# launch command carrying the brief, with no dialog to answer and no human. +test_agy_spawn_pretrusts_its_worktree_and_reaches_the_brief() { + local case_dir home proj wt agyhome fakebin launch_log out + case_dir="$TMP_ROOT/spawn" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + # The spawn fixture pins HOME to $home/user-home so a trust pre-registration + # cannot reach the developer's real store (tests/fixtures.sh), and that pin + # beats an outer HOME= on the call. agy registers into the same sandboxed + # HOME as claude, so the store this asserts against must be that one. + agyhome="$home/user-home" + launch_log="$case_dir/launch.log" + mkdir -p "$agyhome" + fakebin=$(make_spawn_fakebin "$case_dir/fake" agy) + fm_test_spawn_home "$home" agy + fm_git_worktree "$proj" "$wt" wt-spawn + fm_test_spawn_brief "$home" agyspawn + out=$(HOME="$agyhome" FM_FAKE_LAUNCH_LOG="$launch_log" \ + fm_test_run_spawn "$home" "$wt" "$fakebin" agyspawn "$proj" agy \ + --mode no-mistakes --yolo off) + expect_code 0 $? "the agy spawn must succeed: $out" + assert_trusted "$(store_path "$agyhome")" "$wt" \ + "the agy spawn did not pre-register workspace trust for its worktree" + assert_present "$launch_log" "the agy spawn sent no launch command" + # -i starts an interactive session on the prompt; -p would run one turn and + # exit, and every flag must precede the prompt or agy consumes the wrong one. + assert_grep 'agy --dangerously-skip-permissions -i "' "$launch_log" \ + "the launch command was not the interactive agy worker launch" + assert_grep "$home/data/agyspawn/launch-brief.md" "$launch_log" \ + "the launch command did not carry the brief the worker must read" + # agy does not clear an inherited primary marker, so the launch must. + assert_grep 'env -u CLAUDECODE' "$launch_log" \ + "the launch command did not clear the foreign primary markers" + assert_store_value "$(hooks_config "$agyhome")" '"command"' \ + "the agy spawn did not install the turn-end hook" firstmate-turn-end Stop 0 type + assert_grep 'token=fm.' "$wt/.fm-agy-turnend" \ + "the agy spawn did not leave a task token pointer in the worktree" + # Teardown withdraws exactly what this record names, so a spawn that registers + # without writing it would leave the entry with nothing to key the removal off. + [ "$(head -1 "$home/state/agyspawn.agy-trust" 2>/dev/null)" = "$wt" ] \ + || fail "the agy spawn did not record the workspace trust it registered" + pass "fm-spawn.sh: an agy spawn pre-trusts its worktree, arms its wake, and launches with the brief" +} + +# The trust entry is written into the operator's vendor settings before the rest +# of the spawn can fail, and teardown - the only other withdrawal - refuses for an +# id that never published a task record. So an abort after a SUCCESSFUL +# registration has to take the entry back, or it is stranded with no supported +# command that can remove it. A malformed operator hooks config is the abort: +# the hook install refuses on it, one step after the registration. +test_aborted_spawn_withdraws_the_trust_it_registered() { + local case_dir home proj wt agyhome fakebin store config out + case_dir="$TMP_ROOT/aborted-spawn" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + # The spawn fixture pins HOME to $home/user-home so a trust pre-registration + # cannot reach the developer's real store (tests/fixtures.sh), and that pin + # beats an outer HOME= on the call. agy registers into the same sandboxed + # HOME as claude, so the store this asserts against must be that one. + agyhome="$home/user-home" + store=$(store_path "$agyhome") + config=$(hooks_config "$agyhome") + mkdir -p "$(dirname "$store")" "$(dirname "$config")" + printf '%s\n' '{"enableTelemetry":false,"trustedWorkspaces":["/already/trusted"]}' > "$store" + printf '%s\n' 'not json' > "$config" + fakebin=$(make_spawn_fakebin "$case_dir/fake" agy) + fm_test_spawn_home "$home" agy + fm_git_worktree "$proj" "$wt" wt-aborted + fm_test_spawn_brief "$home" abortedspawn + out=$(HOME="$agyhome" fm_test_run_spawn "$home" "$wt" "$fakebin" abortedspawn "$proj" agy \ + --mode no-mistakes --yolo off) + expect_code 1 $? "a spawn whose hook install is refused must fail: $out" + [ ! -e "$home/state/abortedspawn.meta" ] \ + || fail "the abort published a task record, so the leak this covers cannot happen" + assert_not_trusted "$store" "$wt" "the aborted spawn stranded its workspace-trust entry" + assert_trusted "$store" "/already/trusted" "the withdrawal dropped an unrelated operator entry" + pass "fm-spawn.sh: a spawn that aborts after registering trust withdraws it again" +} + +# The withdrawal is guarded on whether a task record survives, because a record +# is what lets teardown withdraw it later. An abort AFTER the provisional record +# is published still ends with no record - the fresh-commit rollback removes it - +# so the guard has to observe the state the cleanup LEAVES, not an intermediate +# one. An unsafe trace-context send is that abort: it fires after publication. +test_post_publish_abort_withdraws_the_trust_it_registered() { + local case_dir home proj wt agyhome fakebin store out + case_dir="$TMP_ROOT/post-publish-abort" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + # The spawn fixture pins HOME to $home/user-home so a trust pre-registration + # cannot reach the developer's real store (tests/fixtures.sh), and that pin + # beats an outer HOME= on the call. agy registers into the same sandboxed + # HOME as claude, so the store this asserts against must be that one. + agyhome="$home/user-home" + store=$(store_path "$agyhome") + mkdir -p "$(dirname "$store")" + printf '%s\n' '{"enableTelemetry":false,"trustedWorkspaces":["/already/trusted"]}' > "$store" + fakebin=$(make_spawn_fakebin "$case_dir/fake" agy) + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|kill-window|set-window-option) exit 0 ;; + send-keys) + for a in "$@"; do + case "$a" in + "export TRACEPARENT="*) exit 2 ;; + esac + done + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_test_spawn_home "$home" agy + fm_git_worktree "$proj" "$wt" wt-post-publish + fm_test_spawn_brief "$home" postpublish + # The home's own frozen trace-context decision is what makes the spawn export a + # carrier at all; the stub above then refuses that send unsafely. + : > "$home/config/trace-context" + printf '%s\n' "$$" > "$home/state/.lock" + fm_trace_context_session_start "$home/config" "$home/state/.trace-context-effective" + out=$(HOME="$agyhome" fm_test_run_spawn "$home" "$wt" "$fakebin" postpublish "$proj" agy \ + --mode no-mistakes --yolo off) + expect_code 1 $? "an unsafe trace-context send must abort the spawn: $out" + [ ! -e "$home/state/postpublish.meta" ] \ + || fail "the abort left a task record, so this case no longer covers the recordless leak" + assert_not_trusted "$store" "$wt" "the post-publish abort stranded its workspace-trust entry" + assert_trusted "$store" "/already/trusted" "the withdrawal dropped an unrelated operator entry" + pass "fm-spawn.sh: an abort after the record is published still withdraws the trust it registered" +} + +# The registration records both spellings of the worktree, and --remove can only +# re-derive that pair while the directory still resolves. An orca abort deletes +# the worktree before the cleanup runs, so a re-derived pair would collapse to +# the literal argument and leave the resolved spelling behind forever. +test_abort_withdraws_both_spellings_after_the_worktree_is_deleted() { + local case_dir home proj wt agyhome fakebin store link wt_link wt_real out + case_dir="$TMP_ROOT/abort-deleted-worktree" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + # The spawn fixture pins HOME to $home/user-home so a trust pre-registration + # cannot reach the developer's real store (tests/fixtures.sh), and that pin + # beats an outer HOME= on the call. agy registers into the same sandboxed + # HOME as claude, so the store this asserts against must be that one. + agyhome="$home/user-home" + store=$(store_path "$agyhome") + mkdir -p "$case_dir" "$(dirname "$store")" + printf '%s\n' '{"enableTelemetry":false,"trustedWorkspaces":["/already/trusted"]}' > "$store" + fm_test_spawn_home "$home" agy + fm_git_worktree "$proj" "$wt" wt-abort-deleted + fm_test_spawn_brief "$home" abortdeleted + link="$TMP_ROOT/abort-deleted-link" + ln -sfn "$case_dir" "$link" + wt_link="$link/wt" + wt_real=$(cd -P -- "$wt" && pwd -P) + [ "$wt_link" != "$wt_real" ] || fail "the fixture did not produce two distinct spellings" + fakebin=$(make_spawn_fakebin "$case_dir/fake" agy) + # The abort fires on the trace-context send, and the stub removes the worktree + # first - the state an orca abort leaves behind when it reclaims the worktree. + cat > "$fakebin/tmux" < "$home/config/trace-context" + printf '%s\n' "$$" > "$home/state/.lock" + fm_trace_context_session_start "$home/config" "$home/state/.trace-context-effective" + out=$(HOME="$agyhome" fm_test_run_spawn "$home" "$wt_link" "$fakebin" abortdeleted "$proj" agy \ + --mode no-mistakes --yolo off) + expect_code 1 $? "an unsafe trace-context send must abort the spawn: $out" + [ ! -d "$wt_real" ] || fail "the fixture did not remove the worktree before the cleanup ran" + [ ! -e "$home/state/abortdeleted.meta" ] \ + || fail "the abort left a task record, so this case no longer covers the recordless leak" + assert_not_trusted "$store" "$wt_link" "the launch spelling survived the aborted spawn" + assert_not_trusted "$store" "$wt_real" "the resolved spelling survived the aborted spawn" + assert_trusted "$store" "/already/trusted" "the withdrawal dropped an unrelated operator entry" + pass "fm-spawn.sh: an abort withdraws both trust spellings after the worktree is deleted" +} + +# The store is the operator's own. If they trusted this worktree by hand, the +# spawn finds it already there and adds nothing, so it must not claim it: the +# record is what teardown withdraws, and taking their entry back would park their +# next hand-run agy on the dialog the whole control exists to remove. +test_spawn_does_not_claim_a_workspace_the_operator_already_trusted() { + local case_dir home proj wt agyhome fakebin store out + case_dir="$TMP_ROOT/operator-trusted" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + # The spawn fixture pins HOME to $home/user-home so a trust pre-registration + # cannot reach the developer's real store (tests/fixtures.sh), and that pin + # beats an outer HOME= on the call. agy registers into the same sandboxed + # HOME as claude, so the store this asserts against must be that one. + agyhome="$home/user-home" + store=$(store_path "$agyhome") + mkdir -p "$agyhome" + fakebin=$(make_spawn_fakebin "$case_dir/fake" agy) + fm_test_spawn_home "$home" agy + fm_git_worktree "$proj" "$wt" wt-operator-trusted + fm_test_spawn_brief "$home" operatortrusted + # The operator's own trust for this exact path, made before any task ran here. + HOME="$agyhome" "$TRUST" "$wt" "$proj" >/dev/null \ + || fail "the fixture could not record the operator's own trust" + out=$(HOME="$agyhome" fm_test_run_spawn "$home" "$wt" "$fakebin" operatortrusted "$proj" agy \ + --mode no-mistakes --yolo off) + expect_code 0 $? "the agy spawn must succeed against an already-trusted worktree: $out" + assert_trusted "$store" "$wt" "the spawn disturbed the operator's own trust entry" + [ ! -e "$home/state/operatortrusted.agy-trust" ] \ + || fail "the spawn claimed a workspace the operator had already trusted: $(cat "$home/state/operatortrusted.agy-trust")" + pass "fm-spawn.sh: a spawn into an already-trusted worktree claims no registration of its own" +} + +# A refused registration must abort the spawn before any task state exists: the +# busy-state generation is armed later in the same run and nothing between would +# clear it, so a record stranded here would read as a task busy forever for an id +# that has no metadata at all. +test_refused_spawn_leaves_no_task_state() { + local case_dir home proj wt agyhome fakebin out + case_dir="$TMP_ROOT/refused-spawn" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + # The spawn fixture pins HOME to $home/user-home so a trust pre-registration + # cannot reach the developer's real store (tests/fixtures.sh), and that pin + # beats an outer HOME= on the call. agy registers into the same sandboxed + # HOME as claude, so the store this asserts against must be that one. + agyhome="$home/user-home" + # Root owns /etc/passwd, so a store resolving to it is refused as another + # user's file. Running as root would own it and make the refusal vacuous. + if [ "$(id -u)" = 0 ]; then + pass "fm-spawn.sh: a trust-refused agy spawn leaves no task state (skipped as root)" + return 0 + fi + mkdir -p "$agyhome/.gemini/antigravity-cli" + ln -s /etc/passwd "$(store_path "$agyhome")" + fakebin=$(make_spawn_fakebin "$case_dir/fake" agy) + fm_test_spawn_home "$home" agy + fm_git_worktree "$proj" "$wt" wt-refused + fm_test_spawn_brief "$home" refusedspawn + out=$(HOME="$agyhome" fm_test_run_spawn "$home" "$wt" "$fakebin" refusedspawn "$proj" agy \ + --mode no-mistakes --yolo off) + expect_code 1 $? "a spawn whose trust registration is refused must fail: $out" + assert_contains "$out" "workspace trust" "the spawn did not report the trust refusal" + [ ! -e "$home/state/refusedspawn.busy-state" ] \ + || fail "a refused spawn stranded a busy record nothing can clear" + [ ! -e "$home/state/refusedspawn.busy-gen" ] \ + || fail "a refused spawn stranded a busy generation nothing can clear" + [ ! -e "$wt/.fm-agy-turnend" ] \ + || fail "a refused spawn left a task token pointer in the worktree" + pass "fm-spawn.sh: a trust-refused agy spawn leaves no task state behind" +} + +test_fresh_worktree_is_trusted +test_symlinked_worktree_spelling_is_trusted_too +test_removal_withdraws_the_spelling_it_is_named_and_keeps_the_rest +test_removal_leaves_the_operator_spelling_of_the_same_directory +test_removal_of_an_unregistered_path_writes_nothing +test_removal_without_a_store_creates_nothing +test_removal_works_after_the_worktree_is_gone +test_registration_is_idempotent +test_registration_reports_only_what_it_added +test_unrelated_store_content_is_preserved +test_primary_checkout_is_refused +test_cdpath_cannot_defeat_the_primary_checkout_refusal +test_git_env_overrides_cannot_defeat_the_primary_checkout_refusal +test_home_directory_is_refused_even_when_it_is_a_worktree +test_settings_directory_is_refused +test_non_git_directory_is_refused +test_missing_directory_is_refused +test_foreign_project_worktree_is_refused +test_worktree_subdirectory_is_refused +test_symlinked_store_to_a_foreign_owned_target_is_refused +test_symlinked_store_to_an_owned_target_is_accepted +test_corrupt_store_fails_closed +test_non_array_trusted_workspaces_fails_closed +test_missing_node_is_refused +test_scope_refusal_stays_fail_closed_without_node +test_concurrent_store_rewrite_is_refused_rather_than_clobbered +test_hook_install_preserves_operator_hooks_and_remove_restores_them +test_hook_signals_only_a_fully_idle_turn +test_hook_ignores_an_unregistered_token +test_hook_always_answers_and_exits_zero +test_hook_remove_refuses_while_a_task_token_is_live +test_hook_refuses_a_malformed_config +test_concurrent_first_installs_all_succeed +test_install_normalizes_a_loose_registry_mode +test_detection_prefers_the_agy_marker_over_an_inherited_claudecode +test_control_tables_carry_agys_verified_mechanics +test_delivery_guard_reads_agys_status_bar +test_agy_composer_verdict_is_unknown_not_empty +test_spawn_refuses_a_secondmate_on_agy +test_a_non_agy_launch_clears_the_inherited_agy_marker +test_agy_spawn_pretrusts_its_worktree_and_reaches_the_brief +test_refused_spawn_leaves_no_task_state +test_spawn_does_not_claim_a_workspace_the_operator_already_trusted +test_aborted_spawn_withdraws_the_trust_it_registered +test_post_publish_abort_withdraws_the_trust_it_registered +test_abort_withdraws_both_spellings_after_the_worktree_is_deleted diff --git a/tests/fm-agy-signals-live-e2e.test.sh b/tests/fm-agy-signals-live-e2e.test.sh new file mode 100755 index 00000000000..b21de837f44 --- /dev/null +++ b/tests/fm-agy-signals-live-e2e.test.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Opt-in real-process guard for every agy signal firstmate's adapter reads from +# the vendor rather than from its own code. +# +# The portable suite (tests/fm-agy-harness.test.sh) pins the LOGIC with no +# harness. This guard pins the ASSUMPTIONS that logic rests on, because a stub +# can only confirm the assumption already written into the stub: +# - the workspace-trust dialog appears in a folder agy has never seen, and +# --dangerously-skip-permissions does NOT suppress it +# - registering the worktree suppresses it +# - the two status bars are still `? for shortcuts` and `esc to cancel` +# - the launch command shape still starts an interactive session on the brief +# - the Stop hook still fires, and still reports fullyIdle +# Every failure names the harness and its version, because agy self-updates. +# +# Run after an agy upgrade and before trusting refreshed per-harness evidence: +# FM_AGY_SIGNALS_LIVE_E2E=1 bin/fm-test-run.sh --family live-harness-optin +set -u + +if [ "${FM_AGY_SIGNALS_LIVE_E2E:-0}" != 1 ]; then + echo "skip: set FM_AGY_SIGNALS_LIVE_E2E=1 to run the live agy signal guard" + exit 0 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +AGY_BIN=$(command -v agy 2>/dev/null || true) +REAL_TMUX=$(command -v tmux 2>/dev/null || true) +LAB= +TRUST_ADDED= +SOCKET="fm-agy-signals-$$" +SESSION=agy-signals +TARGET="$SESSION:agy" +VERSION= +STORE="$HOME/.gemini/antigravity-cli/settings.json" +REGISTRY="$HOME/.gemini/antigravity-cli/fm-turn-end.d" +TOKEN= +HOOK_INSTALLED= + +# Every mutation of the operator's REAL agy home is unwound here rather than on +# the success path, because a failed assertion exits through this trap: an +# orphan task token left in the registry makes every later `remove` refuse, and +# a deleted worktree left in trustedWorkspaces is invisible until it misroutes. +# +# Each mutation is unwound by REVERSING it, never by restoring a snapshot over +# the store. This guard runs for minutes against the operator's live agy home, +# and a `cp` of bytes read at the start would silently discard whatever their own +# agy session wrote meanwhile - exactly the loss bin/fm-agy-trust.sh fingerprints +# its reads to refuse. Withdrawing the spellings this run added leaves every +# other entry, and every other key, as the vendor last wrote them. +cleanup() { + [ -n "$REAL_TMUX" ] && "$REAL_TMUX" -L "$SOCKET" kill-server >/dev/null 2>&1 || true + [ -z "$TOKEN" ] || rm -f -- "$REGISTRY/$TOKEN" + [ -z "$HOOK_INSTALLED" ] || "$ROOT/bin/fm-agy-turnend-hook.sh" remove >/dev/null 2>&1 || true + while IFS= read -r cleanup_path; do + [ -n "$cleanup_path" ] || continue + "$ROOT/bin/fm-agy-trust.sh" --remove "$cleanup_path" >/dev/null 2>&1 || true + done <&2; exit 1; } +pass() { printf 'ok - agy %s: %s\n' "$VERSION" "$1"; } + +# An absent harness is reported explicitly. This guard is opt-in, so reaching it +# with no binary means the operator asked for a check that cannot run, and +# passing over it silently would report evidence that was never gathered. +[ -n "$AGY_BIN" ] || fail "agy was requested for the live guard but is not on PATH" +[ -n "$REAL_TMUX" ] || fail "tmux not found" +VERSION=$("$AGY_BIN" --version 2>&1 | head -1) + +LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-agy-signals.XXXXXX") +HOME_LAB="$LAB/home" +PROJ="$LAB/project" +WT="$LAB/wt" +mkdir -p "$HOME_LAB" +git init -q "$PROJ" +git -C "$PROJ" -c user.email=a@b -c user.name=t commit -q --allow-empty -m init +git -C "$PROJ" worktree add -q -b wt-agy-signals "$WT" + +# The real agy store, so trust and hooks are read from the operator's own +# credentialed home; only the worktree under test is added and it is removed +# again below. A separate HOME would land on an unauthenticated agy and every +# assertion would degrade into a login prompt. +[ -f "$STORE" ] || fail "no agy settings store at $STORE; sign in to agy before running this guard" + +capture() { "$REAL_TMUX" -L "$SOCKET" capture-pane -t "$TARGET" -p; } +start_pane() { # + "$REAL_TMUX" -L "$SOCKET" kill-session -t "$SESSION" >/dev/null 2>&1 || true + "$REAL_TMUX" -L "$SOCKET" new-session -d -s "$SESSION" -n agy -x 200 -y 50 -c "$WT" + "$REAL_TMUX" -L "$SOCKET" send-keys -t "$TARGET" "$1" Enter +} +wait_for() { # + local deadline=$((SECONDS + $2)) + while [ "$SECONDS" -lt "$deadline" ]; do + capture | grep -qE "$1" && return 0 + sleep 2 + done + printf '%s\n' "--- pane ---" >&2 + capture >&2 + fail "$3 (waited $2s for /$1/)" +} + +# 1. The dialog appears with the permission flag set. This is the whole reason +# bin/fm-agy-trust.sh exists; if agy ever starts honouring the flag for +# workspace trust, this failing is the signal to simplify, not a regression. +# The worktree is a fresh mktemp path agy has never seen, so nothing has to be +# reset to reach the unregistered starting state this asserts. +start_pane "$AGY_BIN --dangerously-skip-permissions" +wait_for 'Do you trust the contents of this project' 60 \ + "the workspace-trust dialog did not appear in a fresh worktree with --dangerously-skip-permissions; re-check whether the trust pre-seed is still needed" +pass "--dangerously-skip-permissions does not suppress the workspace-trust dialog" + +# 2. Registering the worktree suppresses it, and the pane reaches the idle bar. +"$REAL_TMUX" -L "$SOCKET" kill-session -t "$SESSION" >/dev/null 2>&1 || true +# A worktree named through a symlinked TMPDIR - macOS's /var -> /private/var - +# registers under BOTH spellings, and removal withdraws exactly the spelling it +# is named, so the guard unwinds by the same record its callers keep: one +# withdrawal per reported `added:` line. +TRUST_REPORT=$("$ROOT/bin/fm-agy-trust.sh" "$WT" "$PROJ") \ + || fail "the trust registration refused a legitimate task worktree" +TRUST_ADDED=$(printf '%s\n' "$TRUST_REPORT" | sed -n 's/^added: //p') +start_pane "$AGY_BIN --dangerously-skip-permissions" +wait_for '\? for shortcuts' 60 "a registered worktree did not reach the idle status bar" +capture | grep -q 'Do you trust the contents' \ + && fail "the trust dialog still appeared after registration" +pass "registering the worktree suppresses the dialog and reaches the idle status bar" + +# 3. The two status bars still separate accepting from not-accepting, and the +# turn-end hook still fires with fullyIdle. Both are checked on one real turn. +"$ROOT/bin/fm-agy-turnend-hook.sh" install >/dev/null || fail "the turn-end hook could not be installed" +HOOK_INSTALLED=1 +MARKER="$LAB/task.turn-ended" +TOKEN=$(basename "$(mktemp "$REGISTRY/fm.XXXXXXXXXXXX")") +printf '%s\n' "$MARKER" > "$REGISTRY/$TOKEN" +printf 'token=%s\n' "$TOKEN" > "$WT/.fm-agy-turnend" +retire_token() { rm -f "$REGISTRY/$TOKEN"; TOKEN=; } + +# Restart so the pane loads the hook that was just installed. +"$REAL_TMUX" -L "$SOCKET" kill-session -t "$SESSION" >/dev/null 2>&1 || true +start_pane "$AGY_BIN --dangerously-skip-permissions" +wait_for '\? for shortcuts' 60 "the pane did not reach the idle status bar before the turn" + +"$REAL_TMUX" -L "$SOCKET" send-keys -t "$TARGET" "Reply with exactly: LIVEOK" +sleep 1 +"$REAL_TMUX" -L "$SOCKET" send-keys -t "$TARGET" Enter +wait_for 'esc to cancel' 30 "a submitted turn did not render the busy status bar" +pass "a running turn renders the busy status bar" + +wait_for 'LIVEOK' 120 "the submitted prompt was accepted but never answered" +wait_for '\? for shortcuts' 60 "the finished turn did not return to the idle status bar" +pass "typing plus a separate Enter submits and the agent acts on it" + +deadline=$((SECONDS + 30)) +while [ "$SECONDS" -lt "$deadline" ] && [ ! -e "$MARKER" ]; do sleep 2; done +[ -e "$MARKER" ] || fail "the Stop hook did not signal the finished turn; agy's hooks facility is undocumented in --help and has changed before" +pass "the Stop hook signals a finished turn through the task token" + +retire_token +"$ROOT/bin/fm-agy-turnend-hook.sh" remove >/dev/null || fail "the turn-end hook could not be removed" +HOOK_INSTALLED= +# The same withdrawal teardown performs, against the operator's real store. +while IFS= read -r trust_path; do + [ -n "$trust_path" ] || continue + "$ROOT/bin/fm-agy-trust.sh" --remove "$trust_path" >/dev/null \ + || fail "the trust registration could not be withdrawn" +done <}' printf '%s=%s\n' "$name" "$value" done @@ -563,12 +563,12 @@ test_server_ensure_scrubs_home_and_harness_identity() { PATH="$fb:$PATH" FM_HERDR_SERVER_ENV_LOG="$log" FM_HERDR_SERVER_MARKER="$marker" FM_HERDR_SENTINEL=kept \ FM_HOME=/tmp/wrong-home FM_ROOT_OVERRIDE=/tmp/wrong-root FM_STATE_OVERRIDE=/tmp/wrong-state \ FM_DATA_OVERRIDE=/tmp/wrong-data FM_PROJECTS_OVERRIDE=/tmp/wrong-projects FM_CONFIG_OVERRIDE=/tmp/wrong-config \ - CURSOR_AGENT=1 CURSOR_INVOKED_AS=cursor-agent CLAUDECODE=1 PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed GROK_AGENT=1 FM_SUPERVISION_MODEL=autoarm \ + CURSOR_AGENT=1 CURSOR_INVOKED_AS=cursor-agent CLAUDECODE=1 PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed GROK_AGENT=1 ANTIGRAVITY_CONVERSATION_ID=conv-abc123 FM_SUPERVISION_MODEL=autoarm \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_server_ensure fmtest' "$ROOT" expect_code 0 $? "server_ensure should start under a polluted launcher environment" output=$(cat "$log") for name in FM_HOME FM_ROOT_OVERRIDE FM_STATE_OVERRIDE FM_DATA_OVERRIDE FM_PROJECTS_OVERRIDE FM_CONFIG_OVERRIDE \ - CURSOR_AGENT CURSOR_INVOKED_AS CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT FM_SUPERVISION_MODEL; do + CURSOR_AGENT CURSOR_INVOKED_AS CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT ANTIGRAVITY_CONVERSATION_ID FM_SUPERVISION_MODEL; do assert_contains "$output" "$name=" "server_ensure leaked $name into the long-lived Herdr server" done assert_contains "$output" "FM_HERDR_SENTINEL=kept" "server_ensure removed an unrelated environment variable" @@ -3523,6 +3523,27 @@ test_send_text_submit_detects_swallowed_enter() { pass "fm_backend_herdr_send_text_submit: reports 'pending' when agent_status stays idle and the composer still holds unsent text after retried Enters (swallowed)" } +# Herdr's footer rescue promotes `pending` only. A composer whose verdict can +# never BE pending - a bare shell-prompt glyph outside a bordered container, which +# is agy's shape - therefore falls straight through as `unknown`, and fm-send +# reports the send unconfirmed. This is what makes agy's typed-submit boundary +# narrower than Cursor's (which reads `pending` and IS rescued here), and the +# docs claim that scope, so the verdict Herdr actually returns is pinned. +test_send_text_submit_does_not_rescue_an_unknown_composer() { + local dir log resp fb out + dir="$TMP_ROOT/submit-unknown"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/2.out" + printf '{"result":{"agent":{"agent_status":"idle"}}}\n' > "$resp/4.out" + printf '%s\n' '─────────────────────────' '> ' '─────────────────────────' \ + '? for shortcuts Gemini 3.8 Flash · high' > "$resp/5.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 3 0.01 0.01' "$ROOT" ) + [ "$out" = unknown ] \ + || fail "send_text_submit must return an unrescuable composer verdict as-is, got '$out'" + pass "fm_backend_herdr_send_text_submit: an 'unknown' composer is returned unrescued, not promoted to 'empty'" +} + # Regression coverage for the 2026-07-03 incident using the NEW mechanism: a # slash command's first Enter can close a completion popup and fill an # argument-hint placeholder WITHOUT submitting. In the idle-baseline path, @@ -4620,6 +4641,7 @@ test_wait_for_working_returns_unknown_when_never_readable test_wait_for_working_treats_blocked_as_submit_active test_send_text_submit_detects_landed_send test_send_text_submit_detects_swallowed_enter +test_send_text_submit_does_not_rescue_an_unknown_composer test_send_text_submit_popup_autocomplete_requires_second_enter test_send_text_submit_confirms_blocked_after_enter test_send_text_submit_preexisting_working_pending_is_queued_enter diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 3423cc4acfc..2249d0d9996 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -1125,6 +1125,9 @@ pi max effort is accepted^{"rules":[{"when":"deep coding","use":{"harness":"pi", pi-signed max effort is accepted^{"rules":[{"when":"signed coding","use":{"harness":"pi-signed","model":"openai-codex/gpt-5.6-sol","effort":"max"}}]}^empty^ muse shared efforts are accepted^{"rules":[{"when":"muse low","use":{"harness":"muse","effort":"low"}},{"when":"muse medium","use":{"harness":"muse","effort":"medium"}},{"when":"muse high","use":{"harness":"muse","effort":"high"}},{"when":"muse xhigh","use":{"harness":"muse","effort":"xhigh"}},{"when":"muse max","use":{"harness":"muse","effort":"max"}}]}^empty^ unsupported muse ultra effort is flagged^{"rules":[{"when":"muse ultra","use":{"harness":"muse","effort":"ultra"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: muse:ultra +agy dispatch profile is accepted^{"rules":[{"when":"agy work","use":{"harness":"agy","model":"gemini-3.8-flash","effort":"high"}}],"default":{"harness":"agy"}}^empty^ +unsupported agy xhigh effort is flagged^{"rules":[{"when":"agy work","use":{"harness":"agy","effort":"xhigh"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: agy:xhigh +unsupported agy max effort is flagged^{"rules":[{"when":"agy work","use":{"harness":"agy","effort":"max"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: agy:max 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 diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 85578775f22..bd921ed0b89 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -9,7 +9,8 @@ set -u # from inside Cursor, Claude, Pi, or Grok inherits those markers, which outrank # the fake ancestry the detection cases set up. Drop the ambient markers so the # asserted verdict does not depend on which harness launched the suite. -unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS +unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS \ + ANTIGRAVITY_CONVERSATION_ID SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" @@ -205,7 +206,7 @@ test_kimi_launch_then_send_is_verified() { assert_contains "$out" "spawned $id harness=kimi" "kimi spawn did not report success" launch=$(cat "$CASE_DIR/launch.log") - [ "$launch" = "env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u GEMINI_CLI '$FAKEBIN_DIR/kimi' --model 'kimi-code/k3' --auto" ] \ + [ "$launch" = "env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u ANTIGRAVITY_CONVERSATION_ID -u GEMINI_CLI '$FAKEBIN_DIR/kimi' --model 'kimi-code/k3' --auto" ] \ || fail "kimi launch did not use the absolute binary, model, and --auto only: $launch" assert_not_contains "$launch" "--effort" "kimi launch emitted a nonexistent effort flag" assert_not_contains "$launch" "turn-ended" "kimi launch embedded a turn-end path" @@ -462,7 +463,7 @@ test_kimi_falls_back_to_expanded_home_binary() { rc=$? expect_code 0 "$rc" "Kimi HOME fallback spawn should succeed" launch=$(cat "$CASE_DIR/launch.log") - [ "$launch" = "env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u GEMINI_CLI '$fallback' --auto" ] \ + [ "$launch" = "env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u ANTIGRAVITY_CONVERSATION_ID -u GEMINI_CLI '$fallback' --auto" ] \ || fail "Kimi fallback did not expand HOME into an absolute executable: $launch" pass "fm-spawn: Kimi fallback expands the active HOME" } diff --git a/tests/fm-muse-harness.test.sh b/tests/fm-muse-harness.test.sh index a5436ac8b3e..305f0a89fdd 100755 --- a/tests/fm-muse-harness.test.sh +++ b/tests/fm-muse-harness.test.sh @@ -18,7 +18,8 @@ set -u # versioned muse-bin ancestor these detection cases launch. Drop the ambient # markers so the asserted verdict does not depend on which harness launched # the suite. -unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS +unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS \ + ANTIGRAVITY_CONVERSATION_ID SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" diff --git a/tests/fm-quota-choose.test.sh b/tests/fm-quota-choose.test.sh index 50dee72a117..f868fef542f 100755 --- a/tests/fm-quota-choose.test.sh +++ b/tests/fm-quota-choose.test.sh @@ -27,6 +27,8 @@ NO_APPLICABLE="$LAB/no-applicable.json" APPLICABLE_VETO="$LAB/applicable-veto.json" MUSE_EXHAUSTED="$LAB/muse-exhausted.json" MUSE_POSITIVE="$LAB/muse-positive.json" +AGY_EXHAUSTED="$LAB/agy-exhausted.json" +AGY_POSITIVE="$LAB/agy-positive.json" TOON="$LAB/quota.toon" RENDERER_TOON="$LAB/renderer-quota.toon" EMPTY_TOON="$LAB/empty-quota.toon" @@ -229,10 +231,10 @@ fi [ "$err" = "error: unknown harness: bogus" ] || fail "unknown harness returned: $err" ok "unknown harness fails closed" -if err=$(call_choose --snapshot "$LAB/captured.json" --candidate claude:default --candidate agy:default 2>&1); then +if err=$(call_choose --snapshot "$LAB/captured.json" --candidate claude:default --candidate copilot:default 2>&1); then fail "trailing unsupported harness was hidden by an earlier selection" fi -[ "$err" = "error: unknown harness: agy" ] || fail "trailing unsupported harness returned: $err" +[ "$err" = "error: unknown harness: copilot" ] || fail "trailing unsupported harness returned: $err" if err=$(call_choose --snapshot "$LAB/captured.json" --candidate claude:default --candidate 'claude:' 2>&1); then fail "trailing empty model was hidden by an earlier selection" @@ -533,12 +535,29 @@ fi [ "$out" = "none" ] || fail "exhausted Meta quota returned: $out" ok "Muse uses Meta quota" -if err=$(call_choose --snapshot "$LAB/captured.json" --candidate agy:default 2>&1); then +if err=$(call_choose --snapshot "$LAB/captured.json" --candidate copilot:default 2>&1); then fail "unsupported harness unexpectedly dispatched" fi -[ "$err" = "error: unknown harness: agy" ] || fail "unsupported harness returned: $err" +[ "$err" = "error: unknown harness: copilot" ] || fail "unsupported harness returned: $err" ok "unsupported harness is rejected" +# agy is a verified crewmate adapter and quota-axi reports it as its own +# provider family, so a candidate on it must route on that family's quota +# rather than abort the whole ordered list. +jq '.providers += [{"provider":"agy","windows":[],"quotaSemantics":{"status":"known","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":25,"runway":{"status":"through_reset"}}]}}]' \ + "$LAB/captured.json" > "$AGY_POSITIVE" +out=$(call_choose --snapshot "$AGY_POSITIVE" --candidate agy:default) +[ "$out" = "agy default" ] || fail "supported agy candidate returned: $out" +ok "agy candidate is accepted" + +jq '.providers += [{"provider":"agy","windows":[],"quotaSemantics":{"status":"known","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":0,"runway":{"status":"exhausted_now"}}]}}]' \ + "$LAB/captured.json" > "$AGY_EXHAUSTED" +if out=$(call_choose --snapshot "$AGY_EXHAUSTED" --candidate agy:default 2>/dev/null); then + fail "agy candidate dispatched with exhausted agy quota" +fi +[ "$out" = "none" ] || fail "exhausted agy quota returned: $out" +ok "agy uses its own provider quota" + jq '.providers += [.providers[] | select(.provider == "claude")]' "$LAB/captured.json" > "$DUPLICATE" if err=$(call_choose --snapshot "$DUPLICATE" --candidate claude:default 2>&1); then fail "duplicate provider snapshot unexpectedly dispatched" diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 57de75b9afd..ac7c5d468ea 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -57,7 +57,8 @@ set -u # ambient CLAUDECODE=1, the pi-signed ancestry case resolves "claude". Drop the # ambient markers so what this suite asserts does not depend on which harness it # was launched from; every case states the marker it means to test. -unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS +unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS \ + ANTIGRAVITY_CONVERSATION_ID BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} fm_git_identity fmtest fmtest@example.com diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 2c91aa309bf..1cc1de4c382 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -131,7 +131,7 @@ test_no_profile_keeps_claude_profile_defaults() { assert_meta_profile "$HOME_DIR/state/$id.meta" claude default default launch=$(cat "$LAUNCH_LOG") - expected="env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u GEMINI_CLI CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_SEND_FEEDBACK=0 claude --dangerously-skip-permissions --settings '{\"feedbackDrafts\":\"off\"}' \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$HOME_DIR/data/$id/launch-brief.md')\"" + expected="env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u ANTIGRAVITY_CONVERSATION_ID -u GEMINI_CLI CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_SEND_FEEDBACK=0 claude --dangerously-skip-permissions --settings '{\"feedbackDrafts\":\"off\"}' \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$HOME_DIR/data/$id/launch-brief.md')\"" [ "$launch" = "$expected" ] || fail "no-profile claude launch did not use the canonical launch kind"$'\n'"expected: $expected"$'\n'"actual: $launch" pass "no --model/--effort records defaults and types the claude launch instructions" } @@ -147,7 +147,7 @@ test_non_cursor_launch_clears_inherited_cursor_markers() { status=$? expect_code 0 "$status" "claude spawn under Cursor markers should succeed" launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u GEMINI_CLI" \ + assert_contains "$launch" "env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u ANTIGRAVITY_CONVERSATION_ID -u GEMINI_CLI" \ "non-cursor launch must clear both inherited Cursor identity markers" pass "non-cursor launches clear inherited Cursor identity markers" } @@ -739,7 +739,7 @@ test_claude_forwards_firstmate_config_dir_when_set() { status=$? expect_code 0 "$status" "claude spawn with CLAUDE_CONFIG_DIR set should succeed" launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "CLAUDE_CONFIG_DIR='$CASE_DIR/claude-work' env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u GEMINI_CLI CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_SEND_FEEDBACK=0 claude --dangerously-skip-permissions --settings '{\"feedbackDrafts\":\"off\"}'" \ + assert_contains "$launch" "CLAUDE_CONFIG_DIR='$CASE_DIR/claude-work' env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u ANTIGRAVITY_CONVERSATION_ID -u GEMINI_CLI CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CODE_SEND_FEEDBACK=0 claude --dangerously-skip-permissions --settings '{\"feedbackDrafts\":\"off\"}'" \ "claude launch did not forward firstmate's CLAUDE_CONFIG_DIR to the crewmate pane" pass "claude forwards firstmate's CLAUDE_CONFIG_DIR so the crewmate uses the same credential store" } diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index c15eb1126b8..ce9a7e8cf8d 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -1330,6 +1330,148 @@ test_fractional_legacy_retry_wait_refuses_without_arithmetic_error() { pass "fractional legacy retry wait remains supported without arithmetic" } +# The agy spawn's workspace-trust entry is the one task artifact written outside +# this home, into the operator's own vendor settings file, so teardown has to +# take it back or every task leaves a dead absolute path there forever. +test_teardown_retires_the_agy_workspace_trust_entry() { + local case_dir agyhome store rc + case_dir=$(make_case agy-trust-retire) + write_meta "$case_dir" local-only ship + agyhome="$case_dir/agyhome" + store="$agyhome/.gemini/antigravity-cli/settings.json" + mkdir -p "$(dirname "$store")" + printf '%s\n' '{"enableTelemetry":false,"trustedWorkspaces":["/already/trusted"]}' > "$store" + HOME="$agyhome" "$ROOT/bin/fm-agy-trust.sh" "$case_dir/wt" "$case_dir/project" >/dev/null \ + || fail "agy-trust-retire: the fixture could not register workspace trust" + printf '%s\n' "$case_dir/wt" > "$case_dir/state/task-x1.agy-trust" + grep -Fq "$case_dir/wt" "$store" \ + || fail "agy-trust-retire: the fixture registration did not reach the store" + rc=0 + HOME="$agyhome" run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + expect_code 0 "$rc" "agy-trust-retire: teardown should succeed: $(cat "$case_dir/stderr")" + ! grep -Fq "$case_dir/wt" "$store" \ + || fail "agy-trust-retire: teardown left the workspace-trust entry behind" + grep -Fq "/already/trusted" "$store" \ + || fail "agy-trust-retire: teardown dropped an unrelated operator entry" + pass "teardown retires the agy workspace-trust entry the spawn registered" +} + +# The entry is keyed by the recorded absolute path, not by the directory, so a +# worktree already removed out of band - a reclaimed orca worktree, a pruned pool +# worktree, a captain's rm -rf - is the case that would otherwise strand it with +# no supported command left to withdraw it. +test_teardown_retires_the_agy_trust_entry_when_the_worktree_is_gone() { + local case_dir agyhome store rc + case_dir=$(make_case agy-trust-retire-gone) + write_meta "$case_dir" local-only ship + agyhome="$case_dir/agyhome" + store="$agyhome/.gemini/antigravity-cli/settings.json" + mkdir -p "$(dirname "$store")" + printf '%s\n' '{"enableTelemetry":false,"trustedWorkspaces":["/already/trusted"]}' > "$store" + HOME="$agyhome" "$ROOT/bin/fm-agy-trust.sh" "$case_dir/wt" "$case_dir/project" >/dev/null \ + || fail "agy-trust-retire-gone: the fixture could not register workspace trust" + printf '%s\n' "$case_dir/wt" > "$case_dir/state/task-x1.agy-trust" + git -C "$case_dir/project" worktree remove --force "$case_dir/wt" >/dev/null 2>&1 \ + || rm -rf "$case_dir/wt" + [ ! -d "$case_dir/wt" ] || fail "agy-trust-retire-gone: the fixture did not remove the worktree" + rc=0 + HOME="$agyhome" run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + expect_code 0 "$rc" "agy-trust-retire-gone: teardown should succeed: $(cat "$case_dir/stderr")" + ! grep -Fq "$case_dir/wt" "$store" \ + || fail "agy-trust-retire-gone: teardown left the workspace-trust entry behind" + grep -Fq "/already/trusted" "$store" \ + || fail "agy-trust-retire-gone: teardown dropped an unrelated operator entry" + pass "teardown retires the agy workspace-trust entry even when the worktree is already gone" +} + +# A pool worktree is handed to the NEXT task at the SAME path, so the store as it +# stands when the worktree is returned is the store that task inherits. Withdraw +# after the return and this teardown can revoke trust the next task just +# registered, wedging its worker on a dialog that draws no status text. +test_teardown_retires_the_agy_trust_before_releasing_the_worktree() { + local case_dir agyhome store observed rc + case_dir=$(make_case agy-trust-before-release) + write_meta "$case_dir" local-only ship + agyhome="$case_dir/agyhome" + store="$agyhome/.gemini/antigravity-cli/settings.json" + observed="$case_dir/store-at-return.json" + mkdir -p "$(dirname "$store")" + printf '%s\n' '{"enableTelemetry":false,"trustedWorkspaces":["/already/trusted"]}' > "$store" + HOME="$agyhome" "$ROOT/bin/fm-agy-trust.sh" "$case_dir/wt" "$case_dir/project" >/dev/null \ + || fail "agy-trust-before-release: the fixture could not register workspace trust" + printf '%s\n' "$case_dir/wt" > "$case_dir/state/task-x1.agy-trust" + cat > "$case_dir/fakebin/treehouse" < "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + expect_code 0 "$rc" "agy-trust-before-release: teardown should succeed: $(cat "$case_dir/stderr")" + [ -f "$observed" ] || fail "agy-trust-before-release: the fixture never observed a worktree return" + ! grep -Fq "$case_dir/wt" "$observed" \ + || fail "agy-trust-before-release: the worktree was released to the pool while this task still held its trust entry" + grep -Fq "/already/trusted" "$observed" \ + || fail "agy-trust-before-release: the withdrawal dropped an unrelated operator entry" + pass "teardown withdraws the agy trust entry before releasing the worktree to the pool" +} + +# The store also holds workspaces the operator trusted BY HAND, and the removal +# runs no scope test, so a withdrawal keyed on the task's worktree path alone +# would take one of theirs whenever a task shares that path - a pool worktree is +# reused across tasks and harnesses, and a secondmate's worktree IS the firstmate +# home, which agy can never even run in. +test_teardown_leaves_an_operator_trust_entry_this_task_never_registered() { + local case_dir agyhome store rc + case_dir=$(make_case agy-trust-operator-entry) + write_meta "$case_dir" local-only ship + agyhome="$case_dir/agyhome" + store="$agyhome/.gemini/antigravity-cli/settings.json" + mkdir -p "$(dirname "$store")" + # The operator trusted this very path themselves; no firstmate spawn registered + # it, so there is no state/.agy-trust record beside the task. + HOME="$agyhome" "$ROOT/bin/fm-agy-trust.sh" "$case_dir/wt" "$case_dir/project" >/dev/null \ + || fail "agy-trust-operator-entry: the fixture could not register workspace trust" + grep -Fq "$case_dir/wt" "$store" \ + || fail "agy-trust-operator-entry: the fixture registration did not reach the store" + [ ! -e "$case_dir/state/task-x1.agy-trust" ] \ + || fail "agy-trust-operator-entry: the fixture must not claim the task registered it" + rc=0 + HOME="$agyhome" run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + expect_code 0 "$rc" "agy-trust-operator-entry: teardown should succeed: $(cat "$case_dir/stderr")" + grep -Fq "$case_dir/wt" "$store" \ + || fail "agy-trust-operator-entry: teardown revoked a trust entry the operator made and no task registered" + pass "teardown leaves an operator's own trust entry alone when the task never registered one" +} + +# The withdrawal is best effort and the record is dropped either way, so a refusal +# is the one path where an entry outlives everything that could name it. It has to +# say which path was stranded and how to finish the job, or the operator learns +# about it the next time agy asks them to trust a directory they already trusted. +test_teardown_names_a_trust_entry_it_could_not_withdraw() { + local case_dir agyhome store rc + case_dir=$(make_case agy-trust-refused) + write_meta "$case_dir" local-only ship + agyhome="$case_dir/agyhome" + store="$agyhome/.gemini/antigravity-cli/settings.json" + mkdir -p "$(dirname "$store")" + # A non-array trustedWorkspaces is one of the store shapes the trust script + # refuses outright rather than rewriting. + printf '%s\n' '{"enableTelemetry":false,"trustedWorkspaces":"not-an-array"}' > "$store" + printf '%s\n' "$case_dir/wt" > "$case_dir/state/task-x1.agy-trust" + rc=0 + HOME="$agyhome" run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + expect_code 0 "$rc" "agy-trust-refused: a refused withdrawal must not strand the teardown: $(cat "$case_dir/stderr")" + assert_grep "$case_dir/wt" "$case_dir/stderr" \ + "agy-trust-refused: the refusal did not name the stranded workspace path" + assert_grep "fm-agy-trust.sh --remove" "$case_dir/stderr" \ + "agy-trust-refused: the refusal did not name the command that finishes the job" + [ "$(node -e 'process.stdout.write(JSON.stringify(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).trustedWorkspaces))' "$store")" = '"not-an-array"' ] \ + || fail "agy-trust-refused: the refused withdrawal rewrote the store anyway" + pass "teardown names the agy trust entry it could not withdraw, and the command to finish it" +} + test_local_only_force_overrides_unpushed() { local case_dir rc case_dir=$(make_case force-override) @@ -3207,6 +3349,11 @@ test_local_only_merged_to_local_main_allows test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed +test_teardown_retires_the_agy_workspace_trust_entry +test_teardown_retires_the_agy_trust_entry_when_the_worktree_is_gone +test_teardown_retires_the_agy_trust_before_releasing_the_worktree +test_teardown_leaves_an_operator_trust_entry_this_task_never_registered +test_teardown_names_a_trust_entry_it_could_not_withdraw test_secondmate_pr_registration_publishes_ready_line test_secondmate_home_teardown_delivers_final_line_or_refuses test_teardown_missing_busy_sidecar_completes diff --git a/tests/fm-tmux-agent-liveness.test.sh b/tests/fm-tmux-agent-liveness.test.sh index 5c2824a44db..99690ee2e22 100755 --- a/tests/fm-tmux-agent-liveness.test.sh +++ b/tests/fm-tmux-agent-liveness.test.sh @@ -60,6 +60,10 @@ ln -s "$SLEEP_BIN" "$LAB/bin/notaharness" # back on (~/.local/bin/muse-bin-), so the executable name is the ONLY # signal, and `muse` alone is a common English fragment that must not widen into # a substring match. The last two names are the decoys that would be misread. +ln -s "$SLEEP_BIN" "$LAB/bin/agy" +ln -s "$SLEEP_BIN" "$LAB/bin/legacy" +ln -s "$SLEEP_BIN" "$LAB/bin/agyneja" +ln -s "$SLEEP_BIN" "$LAB/bin/magy" ln -s "$SLEEP_BIN" "$LAB/bin/muse-bin-0.1.0-R708.1" ln -s "$SLEEP_BIN" "$LAB/bin/musescore" ln -s "$SLEEP_BIN" "$LAB/bin/amuse" @@ -172,6 +176,24 @@ for decoy in musescore amuse muse-binary muse-bind; do done pass "tmux liveness: unrelated muse-containing command names stay ambiguous" +# --- agy's bare binary name -------------------------------------------------- +# agy launches through `env`, which execs, so the pane's process name is exactly +# `agy`. Misclassified, a healthy agy crewmate reads ambiguous and every control +# verb refuses to touch it. `agy` is short enough to appear inside ordinary +# words, so the decoys keep the fix from widening into a substring match. + +new_window agy "$LAB/bin/agy" 900 +wait_for_state "$SESSION:agy" alive \ + || fail "agy's bare binary name must classify alive" +pass "tmux liveness: agy's bare binary name classifies alive" + +for decoy in legacy agyneja magy; do + new_window "decoy-$decoy" "$LAB/bin/$decoy" 900 + wait_for_state "$SESSION:decoy-$decoy" ambiguous \ + || fail "'$decoy' merely contains 'agy' and must not classify as a live agent pane" +done +pass "tmux liveness: unrelated agy-containing command names stay ambiguous" + # --- a version name blinds one source --------------------------------------- # Giving a genuine harness-named executable the version-string argv[0] that # Claude Code 2.1.220 reports drives the two sources apart on both supported