diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 5bbb561e98..e4f893e4ab 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -426,7 +426,8 @@ An interrupted turn fires `stop` twice for one generation (`aborted` then `error Launch-template rule: the launch command MUST let the shell exec `cursor-agent` as its sole/last command with no trailing `;` command. The launcher execs node under `exec -a "$0"`, so with a clean exec the pane's `#{pane_current_command}` reports `cursor-agent` (which `bin/backends/tmux.sh`'s alive-set matches); any trailing shell command leaves the pane leader as `bash` and breaks liveness. -Attribution (hard gate): cursor defaults `attribution.attributeCommitsToAgent` and `attributePRsToAgent` to `true` in the global `~/.cursor/cli-config.json`, producing `Co-authored-by: Cursor `, which violates `AGENTS.md`. -`fm-spawn` neutralizes this per worktree with a gitignored `.cursor/cli.json` project config (`{"attribution":{"attributeCommitsToAgent":false,"attributePRsToAgent":false}}`) that cursor reads by default and merges over the global (its `--disable-project-configs` flag documents that default read), so no destructive or racy edit of the shared global config is needed and the override is removed with the worktree. +Attribution (hard gate): cursor's server-driven attribution runs `git commit --trailer "Co-authored-by: Cursor "`, which violates `AGENTS.md`. +The only config that suppresses it is `attribution.attributeCommitsToAgent`/`attributePRsToAgent` in the GLOBAL `~/.cursor/cli-config.json` (the project `.cursor/cli.json` schema accepts only `permissions`, so an `attribution` block there is rejected with `Unrecognized key(s)` and crashes the launch); cursor also rewrites that global file at runtime and shares it across concurrent workers, so no cursor config can neutralize attribution safely. +Instead `fm-spawn` installs a per-task git `commit-msg` hook under `state/.cursor-git-hooks/` that strips any `Co-authored-by:` trailer in cursor's agent namespace (`@cursor.com`/`@cursor.sh`) and reaches it only for the cursor worker through an env-injected `core.hooksPath` (git resolves a linked worktree's hooks from the shared common dir, so a per-worktree hooks dir would not fire); the hook touches no cursor config, never races another worker, leaves human co-authors intact, and is removed at teardown. The `→` composer glyph is handled by `bin/fm-composer-lib.sh` (a `→` agent-glyph arm) and `bin/fm-tmux-lib.sh` (the empty-box geometry proof), so a dim, idle cursor composer classifies empty rather than as pending input. `docs/verification/runtime-backends.md` owns the dated live-verification evidence, and `data/cursor-verify/report.md` holds the raw probe transcript. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index d3d3bb0789..e9b54b7835 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -888,7 +888,7 @@ crew_dispatch_validate() { return 0 fi err=$(jq -r ' - def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi"] | index($h); + def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi","cursor"] | index($h); def effort_ok($h; $e): if $e == null then true elif ($e | type) != "string" then false @@ -896,7 +896,7 @@ crew_dispatch_validate() { elif $h == "codex" then (["low","medium","high","xhigh"] | index($e)) 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 == "opencode" or $h == "kimi" then false + elif $h == "opencode" or $h == "kimi" or $h == "cursor" then false else true end; def profiles($value): diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 36728093e2..860fbdf74f 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -83,7 +83,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) +# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|pi-signed|grok|kimi|cursor) # 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. pi-signed launches that exact executable name from PATH and @@ -135,8 +135,9 @@ # plus a gitignored .fm-grok-turnend worktree pointer and a state token. # cursor uses firstmate-owned entries in $HOME/.cursor/hooks.json plus a global # registry, a gitignored .fm-cursor-turnend worktree pointer and a state token, -# and a gitignored .cursor/cli.json that neutralizes commit/PR agent attribution -# for that worktree only. +# and a per-task commit-msg hook (reached via env-injected core.hooksPath) that +# strips cursor-agent's Co-authored-by attribution trailer without touching any +# cursor config; the hook dir lives under state/ and is removed at teardown. # On success prints: spawned harness= kind= [mode= yolo=] window= worktree= # A ship task records the explicit mode/yolo it was passed; a secondmate spawn records # mode=secondmate, yolo=off, home=, and projects=; a scout records neither, and both the @@ -2047,18 +2048,46 @@ EOF printf 'token=%s\n' "${auth_file##*/}" > "$WT/.fm-cursor-turnend" exclude_path '.fm-cursor-turnend' # Attribution neutralization (AGENTS.md: never add an agent name as a commit - # co-author). cursor defaults attribution.attributeCommitsToAgent and - # attributePRsToAgent to true in the GLOBAL ~/.cursor/cli-config.json, which - # cursor rewrites at runtime and which concurrent workers share. Instead of a - # destructive or racy global edit, drop a per-worktree project config - # (.cursor/cli.json) that cursor reads by default and merges over the global - # (proven by cursor's own --disable-project-configs flag: "Ignore - # .cursor/cli.json files"), setting both flags false for this worktree only. - # It is gitignored and removed with the worktree, so no restore is needed and - # concurrent cursor tasks never race. - mkdir -p "$WT/.cursor" - printf '%s\n' '{"version":1,"attribution":{"attributeCommitsToAgent":false,"attributePRsToAgent":false}}' > "$WT/.cursor/cli.json" - exclude_path '.cursor/cli.json' + # co-author). cursor's server-driven attribution runs `git commit --trailer + # "Co-authored-by: Cursor "`; the only config that + # suppresses it lives in the GLOBAL ~/.cursor/cli-config.json (the project + # .cursor/cli.json schema accepts only `permissions`, so an attribution block + # there crashes the launch), and cursor rewrites that global file at runtime + # and shares it across concurrent workers. So instead of any cursor config we + # install a per-task git commit-msg hook that strips the Cursor trailer from + # every commit this worker makes, and reach it through env-injected + # core.hooksPath below (git resolves worktree hooks from the shared common + # dir, so a per-worktree hooks dir would not fire). The hook lives under + # state/, outside the worktree, so it never shows in git status, never races + # another worker, and is removed by teardown. + CURSOR_GIT_HOOKS_DIR="$STATE_REAL/$ID.cursor-git-hooks" + mkdir -p "$CURSOR_GIT_HOOKS_DIR" + cat > "$CURSOR_GIT_HOOKS_DIR/commit-msg" <<'CURSOR_COMMIT_MSG_HOOK' +#!/usr/bin/env bash +# firstmate-owned per-task commit-msg hook (bin/fm-spawn.sh, cursor adapter). +# Strips cursor-agent's Co-authored-by attribution trailer so a firstmate-launched +# cursor crew never records an agent co-author (AGENTS.md: never add an agent name +# as a commit co-author). Reached only by the cursor worker via env-injected +# core.hooksPath; it edits the message in place, leaves every other line byte for +# byte, and never blocks a commit. +set -u +msg=${1:-} +[ -n "$msg" ] && [ -f "$msg" ] || exit 0 +# A Co-authored-by trailer whose email is in cursor's agent namespace +# (cursoragent@cursor.com and the cursor.com/cursor.sh domains), case-insensitive. +# A human co-author never uses that address, so legitimate trailers are preserved. +re='^[[:space:]]*Co-authored-by:.*<[^>]*@cursor\.(com|sh)>[[:space:]]*$' +if grep -iqE "$re" "$msg" 2>/dev/null; then + tmp="$msg.fm-cursor.$$" + if grep -ivE "$re" "$msg" > "$tmp" 2>/dev/null; then + mv -f "$tmp" "$msg" + else + rm -f "$tmp" 2>/dev/null || true + fi +fi +exit 0 +CURSOR_COMMIT_MSG_HOOK + chmod +x "$CURSOR_GIT_HOOKS_DIR/commit-msg" ;; esac fi @@ -2211,6 +2240,19 @@ if [ -n "$SPAWN_TRACEPARENT" ]; then fi fi fi +# Activate the cursor attribution-stripping commit-msg hook for the cursor worker +# by pointing git at its per-task hooks dir via the GIT_CONFIG_* environment (git +# ignores a per-worktree hooks dir but honors an env-injected core.hooksPath). +# Sent on the same GOTMPDIR channel so it lands before launch and is inherited by +# every git the cursor worker runs; the hook itself is a no-op for any commit +# without a Cursor trailer, so it never disturbs no-mistakes commits in the pane. +# Gated to non-secondmate exactly like the hook creation above, so the injected +# path always names a hook dir that was actually written. +if [ "$HARNESS" = cursor ] && [ "$KIND" != secondmate ]; then + spawn_send_text_line "$T" "export GIT_CONFIG_COUNT=1" + spawn_send_text_line "$T" "export GIT_CONFIG_KEY_0=core.hooksPath" + spawn_send_text_line "$T" "export GIT_CONFIG_VALUE_0=$(shell_quote "$STATE_REAL/$ID.cursor-git-hooks")" +fi sleep 0.3 spawn_send_literal "$T" "$LAUNCH" sleep 0.3 diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 4e1101e3a7..e4dc71be36 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -489,6 +489,9 @@ remove_kimi_turnend_auth() { remove_cursor_turnend_auth() { local state_dir=$1 id=$2 token hooks_dir + # The per-task commit-msg hook dir (attribution neutralization) is keyed only on + # the task id, so remove it unconditionally, before the turn-end token gate below. + rm -rf "$state_dir/$id.cursor-git-hooks" token=$(cat "$state_dir/$id.cursor-turnend-token" 2>/dev/null || true) case "$token" in ''|*[!A-Za-z0-9._-]*) return 0 ;; esac hooks_dir="$HOME/.cursor/fm-turn-end.d" @@ -1628,7 +1631,7 @@ cleanup_firstmate_home_children() { 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-cursor-turnend" "$child_wt/.cursor/cli.json" + "$child_wt/.fm-cursor-turnend" fi fm_backend_remove_worktree "$child_backend" "$child_orca_worktree_id" || return 1 elif [ -n "$child_wt" ] && [ -d "$child_wt" ]; then @@ -1636,7 +1639,7 @@ cleanup_firstmate_home_children() { 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-cursor-turnend" "$child_wt/.cursor/cli.json" + "$child_wt/.fm-cursor-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 : @@ -1806,7 +1809,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then 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-cursor-turnend" "$WT/.cursor/cli.json" + "$WT/.fm-cursor-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" @@ -1820,7 +1823,7 @@ elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then # 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-cursor-turnend" "$WT/.cursor/cli.json" + "$WT/.fm-cursor-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 diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 7d5d77fc9e..8ee4fab02e 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -142,20 +142,25 @@ The confirmed facts the adapter depends on: - Liveness: a worker launched so the shell execs `cursor-agent` as its sole command reports `#{pane_current_command}` = `cursor-agent`, stable at idle and after a turn, so `bin/backends/tmux.sh`'s `*cursor*` alive-set arm matches; a trailing shell command instead leaves the pane leader as `bash`, which is why the launch template ends with `cursor-agent`. - Busy/turn-end: the native `beforeSubmitPrompt` (busy) and `stop` (turn-end/idle) user hooks fire for the interactive worker, carry `workspace_roots[]`, `generation_id`, and `hook_event_name`, and an interrupted turn fires `stop` twice for one `generation_id` (`aborted` then `error`), so the installed hook dedupes `stop` by `generation_id`. -- Attribution: the global `~/.cursor/cli-config.json` defaults `attributeCommitsToAgent`/`attributePRsToAgent` to `true`; cursor reads a per-project `.cursor/cli.json` by default (its `--disable-project-configs` flag documents that default read) and merges it over the global, so a gitignored per-worktree `.cursor/cli.json` disabling both flags neutralizes attribution without a global edit. +- Attribution: cursor's server-driven attribution runs `git commit --trailer "Co-authored-by: Cursor "`; the only suppressing config is `attributeCommitsToAgent`/`attributePRsToAgent` in the global `~/.cursor/cli-config.json`, but the project `.cursor/cli.json` schema accepts only `permissions` (an `attribution` block there is rejected with `Unrecognized key(s)` and crashes the launch) and cursor rewrites the shared global at runtime, so `fm-spawn` instead installs a per-task `commit-msg` hook (reached via env-injected `core.hooksPath`) that strips the Cursor trailer with no cursor-config edit. See the live proof below. - Composer: the idle composer is a bordered box whose `→` (U+2192) glyph and `Add a follow-up` placeholder are both dim (SGR 2). CI-enforced portable regressions: ```sh -tests/fm-cursor-harness.test.sh # launch template, hook guard + busy lifecycle + stop dedupe, teardown, attribution, detection, lock +tests/fm-cursor-harness.test.sh # launch template, hook guard + busy lifecycle + stop dedupe, teardown, commit-msg attribution hook + core.hooksPath wiring, detection, lock tests/fm-busy-adapter-wiring.test.sh # cursor-hook is the only trusted cursor source tests/fm-composer-ghost.test.sh # dim → glyph + Add a follow-up idle composer reads empty tests/fm-composer-lib.test.sh # → agent-glyph classification tests/fm-tmux-agent-liveness.test.sh # a cursor-agent foreground process classifies alive ``` -The live trailer-free-commit proof (a cursor worker committing with no `Co-authored-by: Cursor` trailer) requires an interactive worker and is captured during the supervised cursor test-drive, since a crewmate cannot launch the cursor TUI in its own pane. +Live attribution proof (2026-08-05, cursor-agent 2026.07.23-e383d2b, git 2.53.0), run with an interactive worker in a separate tmux window because a crewmate cannot launch the cursor TUI in its own pane: + +- Old mechanism crashes the launch: a throwaway repo carrying the merged adapter's project `.cursor/cli.json` (`{"version":1,"attribution":{...}}`) launched with `cursor-agent --force --trust --model composer-2.5 "..."` printed `Invalid project config ... Unrecognized key(s) in object: 'version', 'attribution'` and dropped the pane leader to `bash`, so cursor never started. +- New mechanism launches and strips the trailer: with no cursor config, the per-task `commit-msg` hook installed under `state/.cursor-git-hooks/`, and `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=` exported before launch, the pane leader stayed `cursor-agent` (no crash) and the worker's own autonomous `git commit --trailer "Co-authored-by: Cursor " -m ...` produced commit body `Add READY marker.` with no `Co-authored-by` trailer (`git log -1 --format=%B`), the hook being the sole reason the trailer is absent because cursor's default attribution stayed enabled. + +Refresh this proof after a cursor-agent upgrade with the same two-repo drive (bad-config crash, then hooked trailer-free commit); a unit test cannot cover it because the trailer is server-driven and only a real worker emits the `--trailer` commit. ### Cleanup endpoint identity diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 6c72c0bbc4..58ee05581f 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -814,6 +814,8 @@ pi-signed max effort is accepted^{"rules":[{"when":"signed coding","use":{"harne 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 +cursor model profile is accepted^{"rules":[{"when":"cursor work","use":{"harness":"cursor","model":"composer-2.5"}}]}^empty^ +unsupported cursor effort is flagged^{"rules":[{"when":"cursor work","use":{"harness":"cursor","model":"composer-2.5","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: cursor:high array use with quota-balanced is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude","model":"claude-sonnet-5","effort":"high"},{"harness":"codex","model":"gpt-5.5","effort":"high"}],"select":"quota-balanced"}]}^empty^ array use without select is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude"},{"harness":"codex"}]}]}^empty^ one-element array use is accepted^{"rules":[{"when":"focused feature","use":[{"harness":"claude"}]}]}^empty^ diff --git a/tests/fm-cursor-harness.test.sh b/tests/fm-cursor-harness.test.sh index c50ab96694..297d05ca9f 100755 --- a/tests/fm-cursor-harness.test.sh +++ b/tests/fm-cursor-harness.test.sh @@ -2,8 +2,9 @@ # Behavior tests for the verified cursor-agent (Cursor Composer) crewmate adapter: # launch template, the guarded global user hook + per-task registry, the semantic # busy lifecycle its beforeSubmitPrompt/stop hooks drive, the stop-double-fire -# dedupe, the non-destructive commit/PR attribution neutralization, teardown -# cleanup, detection, and session-lock holder recognition. +# dedupe, the per-task commit-msg hook that strips cursor's agent trailer (wired +# via env-injected core.hooksPath, no cursor-config edit), teardown cleanup, +# detection, and session-lock holder recognition. set -u # shellcheck source=tests/lib.sh @@ -35,6 +36,7 @@ case "${1:-}" in list-windows) exit 0 ;; has-session|new-session|new-window|kill-window) exit 0 ;; send-keys) + [ -n "${FM_FAKE_TEXT_LOG:-}" ] && printf '%s\n' "$*" >> "$FM_FAKE_TEXT_LOG" prev= for arg in "$@"; do if [ "$prev" = -l ]; then printf '%s\n' "$arg" >> "$FM_FAKE_LAUNCH_LOG"; break; fi @@ -64,6 +66,7 @@ make_spawn_case() { # fm_git_worktree "$proj" "$wt" "wt-$name" touch "$home/state/.last-watcher-beat" : > "$case_dir/launch.log" + : > "$case_dir/text.log" printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" } @@ -80,7 +83,7 @@ run_spawn() { # [extra spawn args. FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ - FM_FAKE_LAUNCH_LOG="$case_dir/launch.log" \ + FM_FAKE_LAUNCH_LOG="$case_dir/launch.log" FM_FAKE_TEXT_LOG="$case_dir/text.log" \ PATH="$fakebin:$BASE_PATH" \ "$SPAWN" "$id" "$proj" --harness cursor --mode no-mistakes --yolo off "$@" 2>&1 } @@ -146,17 +149,49 @@ test_cursor_spawn_installs_hook_registers_token_and_neutralizes_attribution() { assert_grep "busyevent=$ROOT/bin/fm-busy-event.sh" "$HOME_DIR/.cursor/fm-turn-end.d/$token" \ "cursor registry entry did not record this home's fm-busy-event path" - # Attribution neutralization: per-worktree .cursor/cli.json, both flags false, gitignored. - assert_present "$WT_DIR/.cursor/cli.json" "cursor attribution neutralization file was not written" - "$JQ_BIN" -e '.attribution.attributeCommitsToAgent == false and .attribution.attributePRsToAgent == false' \ - "$WT_DIR/.cursor/cli.json" >/dev/null \ - || fail "cursor .cursor/cli.json did not disable both commit and PR attribution" local excl excl=$(git -C "$WT_DIR" rev-parse --git-path info/exclude) case "$excl" in /*) : ;; *) excl="$WT_DIR/$excl" ;; esac - assert_grep '.cursor/cli.json' "$excl" "attribution file was not gitignored" assert_grep '.fm-cursor-turnend' "$excl" "cursor pointer was not gitignored" - pass "fm-spawn: cursor installs its hook, registers a task token, and neutralizes commit/PR attribution" + + # Attribution neutralization: a per-task commit-msg hook under state/, NOT the old + # crash-inducing project .cursor/cli.json, wired for the worker via core.hooksPath. + assert_absent "$WT_DIR/.cursor/cli.json" \ + "cursor still writes the project .cursor/cli.json that crashes the launch" + local hookdir hook + # fm-spawn resolves the hook dir under the canonicalized state path (STATE_REAL), + # so mirror that here and match the shell-quoted value fm-spawn exports. + hookdir="$(cd "$HOME_DIR/state" && pwd -P)/$id.cursor-git-hooks" + hook="$hookdir/commit-msg" + assert_present "$hook" "cursor commit-msg attribution hook was not installed" + [ -x "$hook" ] || fail "cursor commit-msg hook is not executable" + # The worker's git is pointed at the hook dir through the GIT_CONFIG_* env, sent + # before launch on the same channel as GOTMPDIR. + assert_grep 'export GIT_CONFIG_KEY_0=core.hooksPath' "$CASE_DIR/text.log" \ + "cursor spawn did not wire core.hooksPath into the worker env" + assert_grep "GIT_CONFIG_VALUE_0='$hookdir'" "$CASE_DIR/text.log" \ + "cursor spawn did not point core.hooksPath at the per-task hook dir" + + # The hook strips only cursor's agent trailer and leaves every other line intact. + local msgfile + msgfile="$CASE_DIR/commit-msg-sample" + { + printf '%s\n\n' 'Add a feature' + printf '%s\n' 'Co-authored-by: Real Person ' + printf '%s\n' 'Co-authored-by: Cursor ' + } > "$msgfile" + "$hook" "$msgfile" || fail "cursor commit-msg hook exited nonzero" + assert_no_grep 'cursoragent@cursor.com' "$msgfile" "hook left cursor's agent trailer in the message" + assert_grep 'human@example.com' "$msgfile" "hook stripped a legitimate human co-author" + assert_grep 'Add a feature' "$msgfile" "hook mangled the commit subject" + # A message with no cursor trailer is passed through byte for byte. + local clean + clean="$CASE_DIR/commit-msg-clean" + printf '%s\n\n%s\n' 'Fix bug' 'Signed-off-by: Dev ' > "$clean" + cp "$clean" "$clean.orig" + "$hook" "$clean" || fail "cursor commit-msg hook exited nonzero on a clean message" + cmp -s "$clean" "$clean.orig" || fail "hook altered a commit message with no cursor trailer" + pass "fm-spawn: cursor installs its hook, registers a task token, and neutralizes commit attribution" } test_cursor_hook_busy_lifecycle_and_stop_dedupe() { @@ -245,6 +280,9 @@ test_cursor_teardown_removes_pointer_registry_and_attribution() { token=$(sed -n 's/^token=//p' "$WT_DIR/.fm-cursor-turnend") # Simulate an accumulated stop-dedupe directory so teardown must clear it too. mkdir -p "$HOME_DIR/.cursor/fm-turn-end.d/$token.stops/gen-A" + # spawn created the per-task attribution hook dir; teardown must remove it. + assert_present "$HOME_DIR/state/$id.cursor-git-hooks/commit-msg" \ + "cursor attribution hook was not present before teardown" HOME="$HOME_DIR" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$HOME_DIR" \ FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ @@ -253,11 +291,11 @@ test_cursor_teardown_removes_pointer_registry_and_attribution() { "$TEARDOWN" "$id" --force >/dev/null 2>&1 || fail "cursor teardown failed" assert_absent "$WT_DIR/.fm-cursor-turnend" "cursor pointer survived teardown" - assert_absent "$WT_DIR/.cursor/cli.json" "cursor attribution file survived teardown" + assert_absent "$HOME_DIR/state/$id.cursor-git-hooks" "cursor attribution hook dir survived teardown" assert_absent "$HOME_DIR/.cursor/fm-turn-end.d/$token" "cursor registry token survived teardown" assert_absent "$HOME_DIR/.cursor/fm-turn-end.d/$token.stops" "cursor stop-dedupe dir survived teardown" assert_absent "$HOME_DIR/state/$id.cursor-turnend-token" "cursor state token survived teardown" - pass "fm-teardown: cursor pointer, registry token, dedupe dir, and attribution file are removed" + pass "fm-teardown: cursor pointer, registry token, dedupe dir, and attribution hook dir are removed" } test_cursor_hook_install_is_idempotent_and_preserves_foreign_hooks() {