diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d07805c..3333c71 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -92,9 +92,10 @@ jobs: exit $bad' - name: PowerShell unit tests - # Both load their dispatcher through the ROGUE_PS_LIB_ONLY seam, so the + # Each loads its dispatcher through the ROGUE_PS_LIB_ONLY seam, so the # functions run on Linux even though the main body stands down there. run: | set -euo pipefail pwsh -NoProfile -File tests/test_hook_ps1.ps1 pwsh -NoProfile -File tests/test_hook_ps1_copilot.ps1 + pwsh -NoProfile -File tests/test_hook_ps1_cursor.ps1 diff --git a/CLAUDE.md b/CLAUDE.md index 4aa8062..454c45b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ Mirrors the Claude plugin with deliberate differences: A near-verbatim port of `qualifire-dev/rogue-plugin-cursor`'s `plugins/rogue/` (keep it in sync — re-pull on upstream changes). Mirrors the Claude/Codex dual-dispatcher with Cursor-native wiring: - **Dual dispatcher (sh + PowerShell), relay + ONE enrichment.** Each of the 18 Cursor events registers two `hooks.json` entries — `sh ./scripts/hook.sh ` (cwd-relative; Cursor runs hooks from the plugin root) and a PowerShell entry that loads `scripts/hook.ps1` via `$env:CURSOR_PLUGIN_ROOT`. Exactly one runs per machine (same arbitration as Claude). Endpoint `/api/v1/hooks/cursor`, header `x-rogue-source: cursor`, env var `CURSOR_PLUGIN_ROOT`. Reuses the shared `~/.rogue-env`. `setup.sh` / `setup.ps1` write it. - **File pre-image (`preToolUse` only).** The one thing the dispatchers add to the vendor payload: on `preToolUse` with `tool_name` Write/Edit and an absolute `file_path`, they read that file (still PRE-edit at that point) and append `"rogueFilePreImageB64"`, using the same jq-or-string-concat duality and fail-open rules as Copilot's `augment_with_agent_tag`. **Every file qualifies except recognized binary extensions** (`_is_binary_path` / `Test-RogueBinaryPath`: images, fonts, archives, media, compiled artifacts, office documents, databases), whose base64 is pure payload with no text to compare; an unknown extension counts as text. Budget for it: on a file write this roughly doubles the request body, since the payload already carries the post-edit content. Cursor's `preToolUse` carries the full post-edit content and no pre-edit state, so the payload alone cannot say what the edit changed. **A missing file yields an EMPTY pre-image, and that is the create signal** — no Cursor payload field distinguishes a create from an overwrite (`old_string` is `""` for any pure insertion). **Over ~256 KB it sends NO pre-image**, never a truncated one: a partial pre-image misrepresents the file's pre-edit state instead of admitting we don't know it. Multi-hunk edits need no special handling: Cursor emits one full cycle per hunk, so the file on disk is already the correct per-hunk baseline. Field extraction prefers `jq` (it understands nesting and unescaping; `file_path` sits under `tool_input`) and falls back to a text scan only when jq is absent. +- **Subagent attribution rides in HEADERS, never in the body.** A Cursor subagent's own `preToolUse`/`postToolUse`/`afterFileEdit`/`beforeShellExecution` arrive with `conversation_id` == `session_id` == **the child's own id** and no field naming the parent, so persisted verbatim each subagent orphans into its own `aidr_event`. The dispatchers resolve the parent and send `x-rogue-parent-session-id` (new, Cursor-only) plus `x-rogue-agent-id` (the child's own conversation id, the existing cross-vendor header): **always as a pair, never on a main-agent event, and never as a body field**, because the byte-for-byte relay is what let us prove the empty-`conversation_id` bug was Cursor's and not ours. `rogueFilePreImageB64` stays the ONLY body exception; `tests/test_hook_sh_cursor.sh` asserts the POSTed body is byte-identical to stdin on every case and that the pre-image is the single permitted addition. `hook.sh` appends the two headers by rebuilding the curl argument list with `set --`, since `-H "k: "` means "send it empty" and `-H "k:"` means "suppress it", and neither spells "omit"; `hook.ps1` adds two keys to its `$headers` hashtable. **Binding is deterministic, not a guess:** the child's own id is looked up as a FILENAME under `~/.cursor/projects//agent-transcripts//subagents/.jsonl`, and the parent is that grandparent directory's name, so two concurrent subagents each find their own file. Never rank by mtime or "pick the newest file", which is the one change that could attribute a child to the wrong parent. The workspace slug (`workspace_roots[0]` with the leading `/` stripped and `/`+`.` mapped to `-`) only SCOPES the scan; a miss falls back to a global glob that returns the same answer. `transcript_path` is deliberately never read: it is JSON-null on ordinary parent events too. **Two state directories under `~/.rogue/`:** `cursor-parent/` caches the resolved parent (mirrors Copilot's `copilot-submap`, so only a subagent's FIRST hook can ever miss, and Cursor reuses a child id across re-spawns), and `cursor-spawn//` is an empty marker whose mtime `subagentStart` touches (`subagentStop` clears it best-effort; a 30s TTL is what actually retires it). **The marker gates only WHETHER TO WAIT, never the answer**: on a cache miss with no file, the dispatcher polls the lookup 30x0.1s (`ROGUE_CURSOR_PARENT_ITERS`) **only while some marker under this workspace is live**, because a brand-new top-level conversation has no directory of its own for ~9s and would otherwise pay the full budget on every session start. The wait is safe (the child's file is born ~1.1s after its first hook, INDEPENDENT of hook returns) and sits inside the 120s `hooks.json` timeout. Fail-open everywhere: unresolved, unparseable, `$HOME` unset or an unwritable state dir all send no headers and POST exactly as today. `sessionStart`/`sessionEnd`/`subagentStart`/`subagentStop` are parent-side and never resolve. - **Manifest is `.cursor-plugin/plugin.json`** (version is source of truth); the Cursor marketplace file is the repo-root `.cursor-plugin/marketplace.json` (source `./plugins/cursor`, plugin version must match plugin.json — enforced by `.github/workflows/validate.yml`), kept separate from `.claude-plugin/` and `.agents/plugins/`. - **No `auto-update.sh`.** The Cursor **Team Marketplace** (admin imports the repo via Dashboard) IS Cursor's native managed/auto-update path — we don't ship a script. Per-developer one-liner installs upgrade by re-running the installer. - **`commands/{setup,status}.md`**, not `skills/` — Cursor's slash-command format. diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index 5540f0f..ed5a285 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -21,6 +21,11 @@ # Fail-open everywhere: missing API key, network error, non-200, empty body, or # non-JSON response all yield `{}` on stdout, exit 0. # +# The relayed body is byte-for-byte what Cursor sent, with the single +# `rogueFilePreImageB64` exception (see Add-FilePreImage). Subagent identity +# therefore rides in HEADERS: `x-rogue-parent-session-id` / `x-rogue-agent-id`, +# resolved from Cursor's own transcript tree (see Resolve-RogueParentSession). +# # Set ROGUE_DEBUG=1 (process/user env var) to emit diagnostics to stderr; # Cursor shows stderr in its hook log without treating it as the response. # @@ -285,6 +290,214 @@ function Add-FilePreImage { } } +# ── Subagent -> parent session attribution — lockstep with hook.sh ───────── +# A Cursor subagent's preToolUse / postToolUse / afterFileEdit / +# beforeShellExecution all arrive with conversation_id == session_id == THE +# CHILD'S OWN id, and no payload field names the parent. The one place the link +# exists is Cursor's transcript tree, where THE CHILD'S ID IS THE FILENAME: +# +# %USERPROFILE%\.cursor\projects\\agent-transcripts\\subagents\.jsonl +# +# so the parent is the grandparent directory's name. That makes this a KEY +# LOOKUP, not a search: two concurrent subagents each carry their own id and each +# find their own file. Never rank by mtime, never "pick the newest file". +# +# `transcript_path` is deliberately never read: it is JSON-null on ordinary +# parent events too, so branching on it would re-attribute main-agent traffic. +# +# Nothing here touches the payload. The resolved ids leave as headers only, so +# the relayed body stays byte-for-byte what Cursor sent. +$RogueSpawnMarkerTtlSeconds = 30 # observed subagentStart lead is 3.96-6.45s + +function Get-RogueUserHome { + if ($env:USERPROFILE) { return $env:USERPROFILE } + return $env:HOME +} +function Get-RogueCursorProjectsDir { + return [System.IO.Path]::Combine((Get-RogueUserHome), '.cursor', 'projects') +} +function Get-RogueParentCacheDir { + return [System.IO.Path]::Combine((Get-RogueUserHome), '.rogue', 'cursor-parent') +} +function Get-RogueSpawnMarkerDir { + return [System.IO.Path]::Combine((Get-RogueUserHome), '.rogue', 'cursor-spawn') +} + +# A conversation id is a uuid. Anything outside that charset is not one, and it +# would also become a path component — so reject it rather than look it up. +function Test-RogueConversationId { + param([string]$Id) + if (-not $Id) { return $false } + return ($Id -match '^[A-Za-z0-9-]+$') +} + +# `workspace_roots` is an ARRAY, so it needs its own reader rather than +# Get-RogueJsonStringField. Lockstep with hook.sh's _workspace_root. +function Get-RogueWorkspaceRoot { + param([string]$Body) + $viaJq = Invoke-RogueJq $Body @('-r', '.workspace_roots[0] // empty') + if ($viaJq) { return $viaJq.Trim() } + $m = [regex]::Match($Body, '"workspace_roots"\s*:\s*\[\s*"([^"]*)"') + if (-not $m.Success) { return '' } + return $m.Groups[1].Value.Replace('\\', '\').Replace('\/', '/') +} + +# Cursor's project-directory slug: the workspace path with the leading separator +# stripped and every "/" and "." turned into "-". +function Get-RogueWorkspaceSlug { + param([string]$Body) + $root = Get-RogueWorkspaceRoot $Body + if (-not $root) { return '' } + return ($root.TrimStart('/').Replace('/', '-').Replace('.', '-')) +} + +# $ChildId's transcript file, if Cursor has written it. Slug-scoping is an +# OPTIMIZATION, not the mechanism: slug derivation has real exceptions on disk +# (numeric slugs, `empty-window`, `.code-workspace`-derived names), so a miss +# falls back to a scan of every project dir, which returns the SAME answer +# because the filename is the key. +function Get-RogueCursorParent { + param([string]$ChildId, [string]$Slug) + try { + $projects = Get-RogueCursorProjectsDir + $roots = @() + if ($Slug) { $roots += [System.IO.Path]::Combine($projects, $Slug) } + if (Test-Path -LiteralPath $projects) { + foreach ($p in (Get-ChildItem -LiteralPath $projects -Directory -ErrorAction SilentlyContinue)) { + if ($Slug -and $p.Name -eq $Slug) { continue } # already first in line + $roots += $p.FullName + } + } + foreach ($r in $roots) { + $transcripts = [System.IO.Path]::Combine($r, 'agent-transcripts') + if (-not (Test-Path -LiteralPath $transcripts)) { continue } + foreach ($d in (Get-ChildItem -LiteralPath $transcripts -Directory -ErrorAction SilentlyContinue)) { + $f = [System.IO.Path]::Combine($d.FullName, 'subagents', ($ChildId + '.jsonl')) + if (Test-Path -LiteralPath $f -PathType Leaf) { return $d.Name } + } + } + } catch { Dbg "parent lookup failed: $($_.Exception.Message)" } + return $null +} + +# Any live marker under this workspace. The check is "is SOMETHING spawning", +# never "is MY parent spawning" — a child cannot know its parent before the +# lookup succeeds. Scoped per workspace because both sides derive the slug the +# same way from workspace_roots; unscoped only when this payload has no root. +function Test-RogueSpawnMarkerLive { + param([string]$Slug) + try { + $root = Get-RogueSpawnMarkerDir + if (-not (Test-Path -LiteralPath $root)) { return $false } + $dirs = @() + if ($Slug) { + $dirs += [System.IO.Path]::Combine($root, $Slug) + } else { + foreach ($d in (Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue)) { + $dirs += $d.FullName + } + } + $cutoff = (Get-Date).AddSeconds(-$RogueSpawnMarkerTtlSeconds) + foreach ($d in $dirs) { + if (-not (Test-Path -LiteralPath $d)) { continue } + foreach ($f in (Get-ChildItem -LiteralPath $d -File -ErrorAction SilentlyContinue)) { + if ($f.LastWriteTime -ge $cutoff) { return $true } + } + } + } catch { Dbg "marker check failed: $($_.Exception.Message)" } + return $false +} + +# subagentStart fires ON THE PARENT (conversation_id == the parent's id, verified +# on all 9 real payloads) and 3.96-6.45s BEFORE the child's subagents file +# exists. That window is exactly what the marker covers. Every step is +# best-effort: a lost marker costs a wait that would not have happened, never a +# wrong answer. +function Write-RogueSpawnMarker { + param([string]$Body) + try { + $id = Get-RogueJsonStringField $Body '.conversation_id' 'conversation_id' + if (-not (Test-RogueConversationId $id)) { return } + $slug = Get-RogueWorkspaceSlug $Body + if (-not $slug) { $slug = '_' } + $dir = [System.IO.Path]::Combine((Get-RogueSpawnMarkerDir), $slug) + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force -ErrorAction SilentlyContinue | Out-Null + } + $file = [System.IO.Path]::Combine($dir, $id) + [System.IO.File]::WriteAllText($file, '') + Dbg "spawn marker $slug/$id" + } catch { Dbg "spawn marker failed: $($_.Exception.Message)" } +} + +# Best-effort only. subagentStop carries no subagent_id and a killed subagent +# never emits one, so nothing may depend on this running; the TTL is what +# actually retires a marker. +function Remove-RogueSpawnMarker { + param([string]$Body) + try { + $id = Get-RogueJsonStringField $Body '.conversation_id' 'conversation_id' + if (-not (Test-RogueConversationId $id)) { return } + $slug = Get-RogueWorkspaceSlug $Body + if (-not $slug) { $slug = '_' } + $file = [System.IO.Path]::Combine((Get-RogueSpawnMarkerDir), $slug, $id) + if (Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue } + } catch { Dbg "spawn marker cleanup failed: $($_.Exception.Message)" } +} + +# Returns @{ Parent = ; Child = } or $null. Fail-open in +# every branch: unresolved means no headers and today's POST exactly. +function Resolve-RogueParentSession { + param([string]$Body) + try { + $id = Get-RogueJsonStringField $Body '.conversation_id' 'conversation_id' + if (-not (Test-RogueConversationId $id)) { return $null } + + # Cache, mirroring the Copilot dispatcher's submap: a subagent fires + # 18-223 hooks per spawn and Cursor REUSES a child id across re-spawns, + # so the scan runs once per subagent, ever. Only a subagent's first hook + # can miss. + $cacheDir = Get-RogueParentCacheDir + $cacheFile = [System.IO.Path]::Combine($cacheDir, $id) + if (Test-Path -LiteralPath $cacheFile -PathType Leaf) { + $cached = ([System.IO.File]::ReadAllText($cacheFile)).Trim() + if ($cached) { Dbg 'parent cache hit'; return @{ Parent = $cached; Child = $id } } + } + + $slug = Get-RogueWorkspaceSlug $Body + $parent = Get-RogueCursorParent $id $slug + if (-not $parent) { + # The child's file is born 0.811-1.627s after its first hook, and its + # creation is INDEPENDENT of hook returns (one spawn's file appeared + # 2.40s before any blocking hook fired), so this wait cannot + # self-deadlock. hooks.json allows 120s per hook, so ~3s is 2.5% of + # the budget. + # + # NEVER spin without a live marker: a brand-new TOP-LEVEL + # conversation has no directory of its own for ~9s and so looks + # exactly like an unresolved child. + $max = 30 # ~3s at 100ms/iter + if ($env:ROGUE_CURSOR_PARENT_ITERS) { $max = [int]$env:ROGUE_CURSOR_PARENT_ITERS } + if (-not (Test-RogueSpawnMarkerLive $slug)) { $max = 0 } + for ($n = 0; $n -lt $max; $n++) { + Start-Sleep -Milliseconds 100 + $parent = Get-RogueCursorParent $id $slug + if ($parent) { break } + } + } + if (-not $parent) { return $null } + + if (-not (Test-Path -LiteralPath $cacheDir)) { + New-Item -ItemType Directory -Path $cacheDir -Force -ErrorAction SilentlyContinue | Out-Null + } + try { [System.IO.File]::WriteAllText($cacheFile, $parent) } catch {} + return @{ Parent = $parent; Child = $id } + } catch { + Dbg "parent resolution failed: $($_.Exception.Message)" + return $null + } +} + # Test seam: dot-sourcing with ROGUE_PS_LIB_ONLY=1 loads the functions above # (e.g. ConvertFrom-ShellQuoted) without running the dispatcher. Production # never sets this, so the hook always runs its main body. @@ -395,6 +608,19 @@ $payload = Repair-DoubleEncodedUtf8 $payload # byte-identical. if ($EventName -eq 'preToolUse') { $payload = Add-FilePreImage $payload } +# Only the events a subagent actually fires resolve. sessionStart / sessionEnd / +# subagentStart / subagentStop are parent-side: they already carry the parent's +# own conversation id, so resolving would be pointless and waiting would tax +# every session start. +$attribution = $null +switch ($EventName) { + 'subagentStart' { Write-RogueSpawnMarker $payload } + 'subagentStop' { Remove-RogueSpawnMarker $payload } + 'sessionStart' { } + 'sessionEnd' { } + default { $attribution = Resolve-RogueParentSession $payload } +} + # ── POST (fail-open) ─────────────────────────────────────────────────────── $headers = @{ 'x-rogue-api-key' = $apiKey @@ -403,9 +629,18 @@ $headers = @{ 'x-rogue-actor-name' = $actorName 'x-rogue-source' = 'cursor' } +# Added CONDITIONALLY, and always as a pair: a subagent's events carry the +# parent's session id plus the child's own conversation id, a main agent's carry +# neither. Never add a key with an empty value — the backend prefers this header +# over the body's conversation_id, so an empty one would resolve to nothing. +if ($attribution) { + $headers['x-rogue-parent-session-id'] = $attribution.Parent + $headers['x-rogue-agent-id'] = $attribution.Child +} $url = "$baseUrl/api/v1/hooks/cursor" -Dbg "POST $url actor=$actorEmail" +$parentDbg = if ($attribution) { $attribution.Parent } else { 'none' } +Dbg "POST $url actor=$actorEmail parent=$parentDbg" # Send an explicit UTF-8 byte array: Windows PowerShell 5.1's Invoke-WebRequest # re-encodes a string body (commonly to Latin-1), which corrupts non-ASCII # prompt content and can reintroduce a BOM. GetBytes() never emits a BOM. diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index 9a602d5..ffd8e73 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -24,6 +24,14 @@ # The ONE exception is the file pre-image (see `augment_with_pre_image`), which # adds a field the payload cannot express and never removes or rewrites one. # +# Subagent identity rides in HEADERS for exactly that reason. A Cursor subagent's +# own events name only themselves, so `resolve_parent_session` reads the payload +# LOCALLY, looks the child's conversation id up as a FILENAME under Cursor's own +# transcript tree, and sends the parent id plus the child id as +# `x-rogue-parent-session-id` / `x-rogue-agent-id`. The parse result never +# reaches the POST, so the relayed body stays byte-for-byte what Cursor sent and +# `rogueFilePreImageB64` remains the single body exception. +# # Fail-open everywhere: missing API key, missing curl, network error, non-200, # empty body all yield `{}` on stdout, exit 0. Cursor # must never block because Rogue infrastructure is unavailable. @@ -261,20 +269,230 @@ if [ "$event" = "preToolUse" ]; then PAYLOAD="$(augment_with_pre_image "$PAYLOAD")" fi +# ── Subagent -> parent session attribution (headers only) ────────────────── +# A Cursor subagent's preToolUse / postToolUse / afterFileEdit / +# beforeShellExecution all fire hooks and all arrive with +# conversation_id == session_id == THE CHILD'S OWN id. No payload field names the +# parent, so persisted verbatim each subagent becomes its own orphaned session. +# +# The one place the link exists is Cursor's transcript tree, where THE CHILD'S ID +# IS THE FILENAME: +# +# ~/.cursor/projects//agent-transcripts//subagents/.jsonl +# +# so the parent is basename(dirname(dirname(hit))). That makes this a KEY LOOKUP, +# not a search: two concurrent subagents each carry their own id and each find +# their own file. Never rank by mtime, never "pick the newest file" — that is the +# one change that could attribute a child to the wrong parent. +# +# `transcript_path` is deliberately never read: it is JSON-null on ordinary +# parent events too (1,238 raw entries across 25+ real sessions), so branching on +# it would re-attribute main-agent traffic. +CURSOR_PROJECTS_DIR="$HOME/.cursor/projects" +PARENT_CACHE_DIR="$HOME/.rogue/cursor-parent" +SPAWN_MARKER_DIR="$HOME/.rogue/cursor-spawn" +SPAWN_MARKER_TTL=30 # seconds; the observed subagentStart lead is 3.96-6.45s +PARENT_ID="" +CHILD_ID="" + +# `workspace_roots` is an ARRAY, so it needs its own reader rather than +# _json_string_field. jq when available; otherwise match the first string inside +# the array literal. +_workspace_root() { + if command -v jq >/dev/null 2>&1; then + if _wr=$(printf '%s' "$1" | jq -r '.workspace_roots[0] // empty' 2>/dev/null); then + [ -n "$_wr" ] && { printf '%s' "$_wr"; return; } + fi + fi + printf '%s' "$1" \ + | grep -o '"workspace_roots"[[:space:]]*:[[:space:]]*\[[[:space:]]*"[^"]*"' 2>/dev/null \ + | head -1 \ + | sed -e 's/.*"\([^"]*\)"$/\1/' +} + +# Cursor's project-directory slug: the workspace path with the leading "/" +# stripped and every "/" and "." turned into "-". +_slugify_root() { printf '%s' "${1#/}" | tr '/.' '--'; } + +# A conversation id is a uuid. Anything outside that charset is not one, and it +# would also be interpolated into a path — so reject it rather than glob with it. +_is_conversation_id() { + case "$1" in + ''|*[!A-Za-z0-9-]*) return 1 ;; + esac + return 0 +} + +# $1 = child conversation id, $2 = workspace slug (may be empty). Echoes the +# parent conversation id. Slug-scoping is an OPTIMIZATION, not the mechanism: +# slug derivation has real exceptions on disk (numeric slugs, `empty-window`, +# `.code-workspace`-derived names), so a miss falls back to a global glob that +# returns the SAME answer because the filename is the key. +_lookup_parent() { + _lp_id="$1" + if [ -n "$2" ]; then + for _lp in "$CURSOR_PROJECTS_DIR/$2"/agent-transcripts/*/subagents/"$_lp_id.jsonl"; do + [ -e "$_lp" ] || continue + basename "$(dirname "$(dirname "$_lp")")" + return 0 + done + fi + for _lp in "$CURSOR_PROJECTS_DIR"/*/agent-transcripts/*/subagents/"$_lp_id.jsonl"; do + [ -e "$_lp" ] || continue + basename "$(dirname "$(dirname "$_lp")")" + return 0 + done + return 1 +} + +# Seconds since epoch for a file's mtime. GNU stat first (it rejects -f cleanly +# on BSD, whereas BSD's -f would print a bogus value under GNU). +_mtime() { + _mt=$(stat -c %Y "$1" 2>/dev/null) || _mt=$(stat -f %m "$1" 2>/dev/null) || return 1 + case "$_mt" in ''|*[!0-9]*) return 1 ;; esac + printf '%s' "$_mt" +} + +_marker_live_in() { + [ -d "$1" ] || return 1 + _now=$(date +%s 2>/dev/null) + case "$_now" in ''|*[!0-9]*) return 1 ;; esac + for _mk in "$1"/*; do + [ -f "$_mk" ] || continue + _mkt=$(_mtime "$_mk") || continue + _age=$((_now - _mkt)) + [ "$_age" -ge 0 ] && [ "$_age" -le "$SPAWN_MARKER_TTL" ] && return 0 + done + return 1 +} + +# Any live marker under this workspace. The check is "is SOMETHING spawning", +# never "is MY parent spawning" — a child cannot know its parent before the +# lookup succeeds. Scoped per workspace because both sides derive the slug the +# same way from workspace_roots; unscoped only when this payload has no root. +_marker_live() { + if [ -n "$1" ]; then _marker_live_in "$SPAWN_MARKER_DIR/$1"; return $?; fi + for _md in "$SPAWN_MARKER_DIR"/*; do + [ -d "$_md" ] || continue + _marker_live_in "$_md" && return 0 + done + return 1 +} + +# subagentStart fires ON THE PARENT (conversation_id == the parent's id, verified +# on all 9 real payloads) and 3.96-6.45s BEFORE the child's subagents/ file +# exists. That window is exactly what the marker covers: it tells a later, +# unresolved event that waiting is worth it. Every step is best-effort — a lost +# marker costs a wait that would not have happened, never a wrong answer. +mark_spawn() { + [ -n "${HOME:-}" ] || return 0 + _ms_id=$(_json_string_field "$PAYLOAD" '.conversation_id' conversation_id) + _is_conversation_id "$_ms_id" || return 0 + _ms_slug=$(_slugify_root "$(_workspace_root "$PAYLOAD")") + [ -n "$_ms_slug" ] || _ms_slug="_" + mkdir -p "$SPAWN_MARKER_DIR/$_ms_slug" 2>/dev/null || return 0 + : > "$SPAWN_MARKER_DIR/$_ms_slug/$_ms_id" 2>/dev/null || return 0 + dbg "spawn marker $_ms_slug/$_ms_id" +} + +# Best-effort only. subagentStop carries no subagent_id and a killed subagent +# never emits one, so nothing may depend on this running; the TTL is what +# actually retires a marker. +clear_spawn() { + [ -n "${HOME:-}" ] || return 0 + _cs_id=$(_json_string_field "$PAYLOAD" '.conversation_id' conversation_id) + _is_conversation_id "$_cs_id" || return 0 + _cs_slug=$(_slugify_root "$(_workspace_root "$PAYLOAD")") + [ -n "$_cs_slug" ] || _cs_slug="_" + rm -f "$SPAWN_MARKER_DIR/$_cs_slug/$_cs_id" 2>/dev/null + return 0 +} + +# Sets PARENT_ID/CHILD_ID when this event belongs to a subagent. Fail-open in +# every branch: unresolved leaves both empty and the POST is exactly today's. +resolve_parent_session() { + [ -n "${HOME:-}" ] || return 0 + _rp_id=$(_json_string_field "$PAYLOAD" '.conversation_id' conversation_id) + _is_conversation_id "$_rp_id" || return 0 + + # Cache, mirroring the Copilot dispatcher's submap: a subagent fires 18-223 + # hooks per spawn and Cursor REUSES a child id across re-spawns, so the scan + # runs once per subagent, ever. Only a subagent's first hook can miss. + _rp_cache="$PARENT_CACHE_DIR/$_rp_id" + if [ -r "$_rp_cache" ]; then + PARENT_ID=$(cat "$_rp_cache" 2>/dev/null) + if [ -n "$PARENT_ID" ]; then + CHILD_ID="$_rp_id" + dbg "parent cache hit" + return 0 + fi + fi + + _rp_slug=$(_slugify_root "$(_workspace_root "$PAYLOAD")") + PARENT_ID=$(_lookup_parent "$_rp_id" "$_rp_slug") || PARENT_ID="" + if [ -z "$PARENT_ID" ]; then + # The child's file is born 0.811-1.627s after its first hook, and its + # creation is INDEPENDENT of hook returns (one spawn's file appeared 2.40s + # before any blocking hook fired), so this wait cannot self-deadlock. + # hooks.json allows 120s per hook, so ~3s is 2.5% of the budget. + # + # NEVER spin without a live marker: a brand-new TOP-LEVEL conversation has no + # directory of its own for ~9s and so looks exactly like an unresolved child. + # Setting the ceiling to 0 rather than branching mirrors Copilot's + # `[ -d "$COPILOT_STATE_DIR" ] || _max=0`. + _rp_n=0 + _rp_max=${ROGUE_CURSOR_PARENT_ITERS:-30} # ~3s at 0.1s/iter + _marker_live "$_rp_slug" || _rp_max=0 + while [ "$_rp_n" -lt "$_rp_max" ]; do + sleep 0.1 + PARENT_ID=$(_lookup_parent "$_rp_id" "$_rp_slug") && [ -n "$PARENT_ID" ] && break + PARENT_ID="" + _rp_n=$((_rp_n + 1)) + done + fi + + [ -n "$PARENT_ID" ] || return 0 + CHILD_ID="$_rp_id" + mkdir -p "$PARENT_CACHE_DIR" 2>/dev/null + printf '%s' "$PARENT_ID" > "$_rp_cache" 2>/dev/null + return 0 +} + +# Only the events a subagent actually fires resolve. sessionStart / sessionEnd / +# subagentStart / subagentStop are parent-side: they already carry the parent's +# own conversation id, so resolving would be pointless and waiting would tax +# every session start. +case "$event" in + subagentStart) mark_spawn ;; + subagentStop) clear_spawn ;; + sessionStart|sessionEnd) : ;; + *) resolve_parent_session ;; +esac + # ── POST (fail-open) ─────────────────────────────────────────────────────── command -v curl >/dev/null 2>&1 || { dbg "curl not found -> {}"; printf '{}'; exit 0; } URL="$BASE_URL/api/v1/hooks/cursor" -dbg "POST $URL actor=$actor_email" -# -f makes curl emit nothing and exit non-zero on HTTP >= 400, giving us -# fail-open on non-200 for free. -RESP="$(printf '%s' "$PAYLOAD" | curl -fsS --max-time 10 -X POST \ - -H 'Content-Type: application/json' \ +dbg "POST $URL actor=$actor_email parent=${PARENT_ID:-none}" +# The two identity headers are appended as ARGUMENTS, never as conditional +# VALUES: to curl `-H "x-rogue-parent-session-id: "` means "send it empty" and +# `-H "x-rogue-parent-session-id:"` means "suppress this header entirely", so +# neither spelling can express "do not send it". Rebuilding the argument list +# with `set --` is the only shape that omits the header. They are always sent +# together or not at all, and never on a main-agent event. +set -- -H 'Content-Type: application/json' \ -H "x-rogue-api-key: $API_KEY" \ -H "x-rogue-event: $event" \ -H "x-rogue-actor-email: $actor_email" \ -H "x-rogue-actor-name: $actor_name" \ - -H 'x-rogue-source: cursor' \ + -H 'x-rogue-source: cursor' +if [ -n "$PARENT_ID" ] && [ -n "$CHILD_ID" ]; then + set -- "$@" -H "x-rogue-parent-session-id: $PARENT_ID" -H "x-rogue-agent-id: $CHILD_ID" +fi +# -f makes curl emit nothing and exit non-zero on HTTP >= 400, giving us +# fail-open on non-200 for free. +RESP="$(printf '%s' "$PAYLOAD" | curl -fsS --max-time 10 -X POST \ + "$@" \ --data-binary @- "$URL" 2>/dev/null)"; _rc=$? dbg "curl rc=$_rc resp_len=${#RESP}" [ "$_rc" -eq 0 ] || RESP="" diff --git a/tests/test_hook_ps1_cursor.ps1 b/tests/test_hook_ps1_cursor.ps1 new file mode 100644 index 0000000..04964ac --- /dev/null +++ b/tests/test_hook_ps1_cursor.ps1 @@ -0,0 +1,259 @@ +#!/usr/bin/env pwsh +# tests/test_hook_ps1_cursor.ps1 — unit tests for the Cursor PowerShell +# dispatcher's subagent -> parent session attribution +# (plugins/cursor/scripts/hook.ps1). +# +# Lockstep partner of tests/test_hook_sh_cursor.sh: every case there has a case +# here, because the repo's rule is that hook.sh and hook.ps1 move together. What +# it cannot mirror is the POST itself — hook.ps1's main body stands down on +# non-Windows, so this file loads only its FUNCTIONS through the +# ROGUE_PS_LIB_ONLY seam. The header-emit call site is covered by the sh suite +# plus the parse gate in .github/workflows/validate.yml; the resolution logic, +# which is where a wrong answer would come from, is covered here. +# +# These are the ONLY automated checks that ever execute this code: hooks.json +# loads hook.ps1 via `[scriptblock]::Create(...)` inside a catch that swallows +# failures into `{}`, so a logic error here is a silent no-op for every Windows +# Cursor user rather than an error anyone sees. +# +# Run on any platform with PowerShell: pwsh tests/test_hook_ps1_cursor.ps1 + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +# [IO.Path]::Combine takes many segments on Windows PowerShell 5.1; multi-segment +# Join-Path is PowerShell 7+ only. +$hook = [System.IO.Path]::Combine($here, '..', 'plugins', 'cursor', 'scripts', 'hook.ps1') + +# Load hook.ps1's functions without executing the dispatcher body. +$env:ROGUE_PS_LIB_ONLY = '1' +. $hook +$env:ROGUE_PS_LIB_ONLY = $null +# hook.ps1 sets SilentlyContinue for its own fail-open behaviour; the test itself +# wants failures to be loud. Every function under test guards with try/catch, so +# this does not change what they do. +$ErrorActionPreference = 'Stop' + +$fails = 0 +$count = 0 +function Assert-Eq { + param($Got, $Expected, [string]$Label) + $script:count++ + if ([string]$Got -ceq [string]$Expected) { Write-Host " ok: $Label" } + else { Write-Host "FAIL [$Label]: got <$Got>, expected <$Expected>"; $script:fails++ } +} +function Assert-True { + param($Cond, [string]$Label) + $script:count++ + if ($Cond) { Write-Host " ok: $Label" } + else { Write-Host "FAIL [$Label]: expected true, got <$Cond>"; $script:fails++ } +} +function Assert-Null { + param($Got, [string]$Label) + $script:count++ + if ($null -eq $Got) { Write-Host " ok: $Label" } + else { Write-Host "FAIL [$Label]: expected null, got <$Got>"; $script:fails++ } +} + +# ── harness ──────────────────────────────────────────────────────────────── +# The functions read %USERPROFILE% (falling back to $HOME), so pointing it at a +# throwaway directory is what isolates a case. +$homes = @() +function New-TestHome { + $d = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), + 'rogue-cursor-ps-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $d -Force | Out-Null + $script:homes += $d + $env:USERPROFILE = $d + return $d +} +function New-ChildFile { + param([string]$Root, [string]$Slug, [string]$Parent, [string]$Child) + $dir = [System.IO.Path]::Combine($Root, '.cursor', 'projects', $Slug, 'agent-transcripts', $Parent, 'subagents') + New-Item -ItemType Directory -Path $dir -Force | Out-Null + [System.IO.File]::WriteAllText([System.IO.Path]::Combine($dir, ($Child + '.jsonl')), '') +} +function New-Marker { + param([string]$Root, [string]$Slug, [string]$Id, [int]$AgeSeconds = 0) + $dir = [System.IO.Path]::Combine($Root, '.rogue', 'cursor-spawn', $Slug) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $f = [System.IO.Path]::Combine($dir, $Id) + [System.IO.File]::WriteAllText($f, '') + if ($AgeSeconds -gt 0) { + (Get-Item -LiteralPath $f).LastWriteTime = (Get-Date).AddSeconds(-$AgeSeconds) + } +} +function Set-CachedParent { + param([string]$Root, [string]$Child, [string]$Parent) + $dir = [System.IO.Path]::Combine($Root, '.rogue', 'cursor-parent') + New-Item -ItemType Directory -Path $dir -Force | Out-Null + [System.IO.File]::WriteAllText([System.IO.Path]::Combine($dir, $Child), $Parent) +} + +$WS = '/Users/test/work/proj' +$SLUG = 'Users-test-work-proj' +function New-Payload { + param([string]$Id) + # transcript_path is null here exactly as it is on real events, INCLUDING + # ordinary parent ones; nothing in the dispatcher may branch on it. + return ('{"conversation_id":"' + $Id + '","session_id":"' + $Id + + '","workspace_roots":["' + $WS + '"],"transcript_path":null}') +} + +# ── Slug derivation ──────────────────────────────────────────────────────── +Assert-Eq (Get-RogueWorkspaceSlug (New-Payload 'x')) $SLUG 'slug strips the leading / and maps / and . to -' +Assert-Eq (Get-RogueWorkspaceSlug '{"workspace_roots":["/a/b.c"]}') 'a-b-c' 'slug maps a dotted path segment' +Assert-Eq (Get-RogueWorkspaceSlug '{"conversation_id":"x"}') '' 'no workspace_roots yields an empty slug' +Assert-Eq (Get-RogueWorkspaceSlug 'not json') '' 'unparseable payload yields an empty slug' + +# ── Conversation id validation ───────────────────────────────────────────── +# The id becomes a path component, so anything outside the uuid charset is +# rejected rather than looked up. +Assert-True (Test-RogueConversationId 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa') 'a uuid is a conversation id' +Assert-True (-not (Test-RogueConversationId '../../etc/passwd')) 'a traversal-shaped id is rejected' +Assert-True (-not (Test-RogueConversationId '')) 'an empty id is rejected' +Assert-True (-not (Test-RogueConversationId 'a b')) 'an id with a space is rejected' + +# ── Lookup: the filename IS the key ──────────────────────────────────────── +$h = New-TestHome +$childA = 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa' +$parentA = '99999999-9999-4999-8999-999999999999' +New-ChildFile $h $SLUG $parentA $childA +Assert-Eq (Get-RogueCursorParent $childA $SLUG) $parentA 'parent is the agent-transcripts directory name' +Assert-Null (Get-RogueCursorParent 'ffffffff-0000-4000-8000-ffffffffffff' $SLUG) 'a child with no file resolves to nothing' + +# Slug-scoping is an OPTIMIZATION: slug derivation has real exceptions on disk +# (numeric slugs, empty-window, .code-workspace-derived names), so a wrong slug +# still resolves through the global scan, which returns the SAME answer. +$h = New-TestHome +$childS = 'dddddddd-1111-4111-8111-dddddddddddd' +$parentS = '77777777-7777-4777-8777-777777777777' +New-ChildFile $h '1784115802260' $parentS $childS +Assert-Eq (Get-RogueCursorParent $childS $SLUG) $parentS 'resolved under a non-derivable slug via the global scan' +Assert-Eq (Get-RogueCursorParent $childS '') $parentS 'resolved with no slug at all' + +# Two concurrent subagents under ONE parent: each carries its own id, so each +# finds its own file. Any "newest file wins" rule would hand one of them the +# other's id, which is the single outcome that would make this feature wrong. +$h = New-TestHome +$childX = 'bbbbbbbb-1111-4111-8111-bbbbbbbbbbbb' +$childY = 'cccccccc-1111-4111-8111-cccccccccccc' +$parentXY = '88888888-8888-4888-8888-888888888888' +New-ChildFile $h $SLUG $parentXY $childX +New-ChildFile $h $SLUG $parentXY $childY +(Get-Item -LiteralPath ([System.IO.Path]::Combine($h, '.cursor', 'projects', $SLUG, 'agent-transcripts', $parentXY, 'subagents', ($childX + '.jsonl')))).LastWriteTime = (Get-Date).AddSeconds(-60) +Assert-Eq (Get-RogueCursorParent $childX $SLUG) $parentXY 'concurrent subagent X resolves the shared parent (older file)' +Assert-Eq (Get-RogueCursorParent $childY $SLUG) $parentXY 'concurrent subagent Y resolves the shared parent' + +# ── Marker gate: decides WHETHER to wait, never the answer ───────────────── +$h = New-TestHome +Assert-True (-not (Test-RogueSpawnMarkerLive $SLUG)) 'no marker directory means no live marker' +New-Marker $h $SLUG '33333333-3333-4333-8333-333333333333' +Assert-True (Test-RogueSpawnMarkerLive $SLUG) 'a fresh marker is live' +Assert-True (Test-RogueSpawnMarkerLive '') 'a fresh marker is found with no slug (unscoped scan)' +Assert-True (-not (Test-RogueSpawnMarkerLive 'some-other-workspace')) 'markers are scoped per workspace' + +$h = New-TestHome +New-Marker $h $SLUG '44444444-4444-4444-8444-444444444444' -AgeSeconds 600 +Assert-True (-not (Test-RogueSpawnMarkerLive $SLUG)) 'a marker past the TTL is treated as absent' + +# ── subagentStart writes the marker, subagentStop clears it ──────────────── +# subagentStart fires ON THE PARENT, so its conversation_id IS the parent's id. +$h = New-TestHome +$parentM = '33333333-3333-4333-8333-333333333333' +Write-RogueSpawnMarker (New-Payload $parentM) +$markerPath = [System.IO.Path]::Combine($h, '.rogue', 'cursor-spawn', $SLUG, $parentM) +Assert-True (Test-Path -LiteralPath $markerPath) 'subagentStart writes ~/.rogue/cursor-spawn//' +Remove-RogueSpawnMarker (New-Payload $parentM) +Assert-True (-not (Test-Path -LiteralPath $markerPath)) 'subagentStop clears the marker' + +# A non-uuid conversation id never becomes a path component. +$h = New-TestHome +Write-RogueSpawnMarker '{"conversation_id":"../../evil","workspace_roots":["/a"]}' +Assert-True (-not (Test-Path -LiteralPath ([System.IO.Path]::Combine($h, '.rogue', 'cursor-spawn')))) 'a traversal-shaped id writes no marker' + +# ── Resolution: cache-cold hit, and the cache is written ─────────────────── +$h = New-TestHome +New-ChildFile $h $SLUG $parentA $childA +$r = Resolve-RogueParentSession (New-Payload $childA) +Assert-Eq $r.Parent $parentA 'cache-cold resolution returns the parent' +Assert-Eq $r.Child $childA 'cache-cold resolution returns the child id' +$cacheFile = [System.IO.Path]::Combine($h, '.rogue', 'cursor-parent', $childA) +Assert-Eq ([System.IO.File]::ReadAllText($cacheFile)) $parentA 'resolution is cached at ~/.rogue/cursor-parent/' + +# A second event reuses the cache. Proven by DELETING the transcript tree first: +# only a cache read can still answer. A subagent fires 18-223 hooks per spawn and +# Cursor reuses a child id across re-spawns, so this is the common path. +Remove-Item -LiteralPath ([System.IO.Path]::Combine($h, '.cursor')) -Recurse -Force +$r = Resolve-RogueParentSession (New-Payload $childA) +Assert-Eq $r.Parent $parentA 'second event resolves from the cache, not the filesystem' + +# The cache is read BEFORE any scan: seed a parent that exists nowhere on disk. +$h = New-TestHome +$childC = '2c2c2c2c-1111-4111-8111-2c2c2c2c2c2c' +Set-CachedParent $h $childC 'cached-parent-id' +$r = Resolve-RogueParentSession (New-Payload $childC) +Assert-Eq $r.Parent 'cached-parent-id' 'cache is consulted before the filesystem' + +# ── Resolution: fail-open paths ──────────────────────────────────────────── +# No marker means NO WAIT AT ALL. A brand-new top-level conversation has no +# directory of its own for ~9s and so looks exactly like an unresolved child; +# without this gate every session start would pay the full budget. +$h = New-TestHome +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$r = Resolve-RogueParentSession (New-Payload '22222222-2222-2222-2222-222222222222') +$sw.Stop() +Assert-Null $r 'a main-agent conversation resolves to nothing' +Assert-True ($sw.Elapsed.TotalSeconds -lt 1) "no marker means no wait (took $([math]::Round($sw.Elapsed.TotalSeconds,2))s, budget is ~3s)" + +# A stale marker is treated as absent, so it does not arm the wait either. +$h = New-TestHome +New-Marker $h $SLUG '44444444-4444-4444-8444-444444444444' -AgeSeconds 600 +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$r = Resolve-RogueParentSession (New-Payload '0a0a0a0a-1111-4111-8111-0a0a0a0a0a0a') +$sw.Stop() +Assert-Null $r 'a stale marker resolves to nothing' +Assert-True ($sw.Elapsed.TotalSeconds -lt 1) 'a stale marker does not arm the wait' + +# Live marker, file never created: the budget expires and we fail open. +$h = New-TestHome +New-Marker $h $SLUG '55555555-5555-4555-8555-555555555555' +$env:ROGUE_CURSOR_PARENT_ITERS = '3' +$r = Resolve-RogueParentSession (New-Payload 'ffffffff-1111-4111-8111-ffffffffffff') +$env:ROGUE_CURSOR_PARENT_ITERS = $null +Assert-Null $r 'budget expiry resolves to nothing (fail open)' + +# Live marker, file appears mid-wait: the wait pays off. File creation is +# INDEPENDENT of hook returns (one spawn's file appeared 2.40s before any +# blocking hook fired), so this wait cannot self-deadlock. +$h = New-TestHome +$childW = 'eeeeeeee-1111-4111-8111-eeeeeeeeeeee' +$parentW = '66666666-6666-4666-8666-666666666666' +New-Marker $h $SLUG $parentW +$job = Start-Job -ScriptBlock { + param($root, $slug, $parent, $child) + Start-Sleep -Milliseconds 700 + $dir = [System.IO.Path]::Combine($root, '.cursor', 'projects', $slug, 'agent-transcripts', $parent, 'subagents') + New-Item -ItemType Directory -Path $dir -Force | Out-Null + [System.IO.File]::WriteAllText([System.IO.Path]::Combine($dir, ($child + '.jsonl')), '') +} -ArgumentList $h, $SLUG, $parentW, $childW +$r = Resolve-RogueParentSession (New-Payload $childW) +Receive-Job $job -Wait -AutoRemoveJob | Out-Null +Assert-Eq $r.Parent $parentW 'live marker: waited and resolved once the file appeared' +Assert-Eq $r.Child $childW 'mid-wait resolution carries the child id' + +# Malformed input never throws and never resolves. +$h = New-TestHome +Assert-Null (Resolve-RogueParentSession '{"conversation_id":"../../etc/passwd"}') 'a traversal-shaped id resolves to nothing' +Assert-Null (Resolve-RogueParentSession 'not json at all') 'an unparseable payload resolves to nothing' +Assert-Null (Resolve-RogueParentSession '{}') 'a payload with no conversation_id resolves to nothing' + +# ── teardown ─────────────────────────────────────────────────────────────── +foreach ($d in $homes) { Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue } +$env:USERPROFILE = $null + +Write-Host '' +if ($fails -gt 0) { + Write-Host "$fails of $count Cursor hook.ps1 assertions FAILED" + exit 1 +} +Write-Host "All $count Cursor hook.ps1 assertions passed." +exit 0 diff --git a/tests/test_hook_sh_cursor.sh b/tests/test_hook_sh_cursor.sh new file mode 100755 index 0000000..4376287 --- /dev/null +++ b/tests/test_hook_sh_cursor.sh @@ -0,0 +1,441 @@ +#!/usr/bin/env bash +# tests/test_hook_sh_cursor.sh — end-to-end for the Cursor sh dispatcher +# (plugins/cursor/scripts/hook.sh): env file → hook.sh → mock server → stdout. +# +# The suite exists for the subagent → parent session attribution, and its +# LOAD-BEARING assertion is on every case: THE POSTED BODY IS BYTE-IDENTICAL TO +# THE PIPED STDIN. That property is what let us prove the empty-conversation_id +# bug belonged to Cursor and not to us, so all new information rides in HEADERS +# (`x-rogue-parent-session-id`, `x-rogue-agent-id`). The ONE permitted body +# exception is `rogueFilePreImageB64` on preToolUse, and case 12 asserts that +# exception explicitly so a second one cannot be added silently. +# +# The other invariant under test is that binding is DETERMINISTIC: the child's +# own conversation_id is looked up as a FILENAME under +# ~/.cursor/projects/*/agent-transcripts/*/subagents/, so two concurrent +# subagents each find their own file (case 4). Nothing here may ever become +# "pick the newest file", and the subagentStart marker decides only WHETHER TO +# WAIT, never the answer. +# +# Cursor runs `sh ./scripts/hook.sh `; override with TEST_SH=dash to +# exercise strict POSIX and catch bashisms. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +HOOK="$REPO/plugins/cursor/scripts/hook.sh" +SH="${TEST_SH:-sh}" + +PORT=$((RANDOM % 10000 + 30000)) +HEADERS_FILE="$(mktemp)" +ENV_FILE="$(mktemp)" +OUT_FILE="$(mktemp)" +HOMES=() + +cleanup() { + if [ -n "${MOCK_PID:-}" ]; then + kill "$MOCK_PID" 2>/dev/null || true + wait "$MOCK_PID" 2>/dev/null || true # absorb the job-control "Terminated" notice + fi + rm -f "$ENV_FILE" "$HEADERS_FILE" "$OUT_FILE" + for h in "${HOMES[@]:-}"; do [ -n "$h" ] && rm -rf "$h"; done + [ -n "${NOJQ_DIR:-}" ] && rm -rf "$NOJQ_DIR" + return 0 +} +trap cleanup EXIT + +cat > "$ENV_FILE" < "$1/.cursor/projects/$2/agent-transcripts/$3/subagents/$4.jsonl" +} + +# $1 home, $2 slug, $3 parent id — the subagentStart marker (mtime is the clock). +make_marker() { + mkdir -p "$1/.rogue/cursor-spawn/$2" + : > "$1/.rogue/cursor-spawn/$2/$3" +} + +# $1 home, $2 child id, $3 parent id — pre-seed the resolution cache. +seed_cache() { + mkdir -p "$1/.rogue/cursor-parent" + printf '%s' "$3" > "$1/.rogue/cursor-parent/$2" +} + +# $1 conversation id — a minimal Cursor tool payload. `transcript_path` is null +# here exactly as it is on real events, INCLUDING ordinary parent ones; nothing +# in the dispatcher may branch on it. +payload_for() { + printf '{"conversation_id":"%s","session_id":"%s","workspace_roots":["%s"],"transcript_path":null}' \ + "$1" "$1" "$WS" +} + +# $1 home, $2 event, $3 payload. Pipes the payload as raw bytes (no heredoc +# newline) so the body-identity assertion is exact. Clears ROGUE_* from the +# process env so only the env file drives credential resolution. TEST_PATH, when +# set, REPLACES the dispatcher's PATH (see make_nojq_path). +LAST_PAYLOAD="" +TEST_PATH="" +run_hook() { + local rc + LAST_PAYLOAD="$3" + set +e + printf '%s' "$3" | env \ + HOME="$1" \ + ROGUE_API_KEY='' ROGUE_ACTOR_EMAIL='' ROGUE_ACTOR_NAME='' ROGUE_BASE_URL='' \ + ROGUE_CURSOR_PARENT_ITERS="${ROGUE_CURSOR_PARENT_ITERS:-}" \ + PATH="${TEST_PATH:-$PATH}" \ + "$SH" "$HOOK" "$2" > "$OUT_FILE" + rc=$? + set -e + return $rc +} + +# A PATH holding everything the dispatcher needs EXCEPT jq, so its anchored +# text-scan fallbacks run instead. jq (on macOS 26: /usr/bin/jq) sits in the same +# directory as the rest of the toolchain, so hiding it means rebuilding PATH as a +# symlink farm rather than dropping a directory. +make_nojq_path() { + local d b src + d="$(mktemp -d)" + for b in "$SH" sh dirname basename date mkdir cat sed grep tr head base64 sleep curl stat rm; do + src="$(command -v "$b" 2>/dev/null || true)" + if [ -z "$src" ]; then echo "FAIL [nojq farm]: '$b' is not on PATH" >&2; exit 1; fi + ln -s "$src" "$d/$(basename "$src")" 2>/dev/null || true + done + if PATH="$d" command -v jq >/dev/null 2>&1; then + echo "FAIL [nojq farm]: jq is still reachable" >&2; exit 1 + fi + printf '%s' "$d" +} + +start_mock() { + MOCK_RESPONSE="${1:-{\}}" MOCK_STATUS="${2:-200}" \ + python3 "$REPO/tests/mock_server.py" "$PORT" "$HEADERS_FILE" & + MOCK_PID=$! + for _ in $(seq 1 50); do + nc -z 127.0.0.1 "$PORT" 2>/dev/null && return 0 + sleep 0.1 + done + echo "mock server failed to start" >&2; exit 1 +} + +# mock_server.py OVERWRITES the record file per request, so every case that +# asserts on a POST must restart (or at least re-clear) between assertions. +restart_mock() { + [ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true + wait "$MOCK_PID" 2>/dev/null || true + rm -f "$HEADERS_FILE" + start_mock "$@" +} + +assert_eq() { + if [ "$1" != "$2" ]; then echo "FAIL [$3]: expected <$2> but got <$1>" >&2; exit 1; fi + echo " ok: $3" +} + +posted_body() { + python3 -c 'import json,sys; sys.stdout.write(json.load(open(sys.argv[1]))["body"])' "$HEADERS_FILE" +} + +assert_header() { + local actual + actual=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["headers"].get(sys.argv[2], ""))' "$HEADERS_FILE" "$1") + assert_eq "$actual" "$2" "$3" +} + +assert_no_header() { + local actual + actual=$(python3 -c 'import json,sys; print(sys.argv[2] in json.load(open(sys.argv[1]))["headers"])' "$HEADERS_FILE" "$1") + assert_eq "$actual" "False" "$2" +} + +# THE load-bearing assertion. Compares the raw bytes the mock recorded against +# the exact bytes we piped in. +assert_body_identical() { + local got + got="$(posted_body)" + assert_eq "$got" "$LAST_PAYLOAD" "$1" +} + +# Neither identity header, in either direction — a main-agent event must look +# exactly like today's. +assert_no_identity_headers() { + assert_no_header "x-rogue-parent-session-id" "$1 (no parent header)" + assert_no_header "x-rogue-agent-id" "$1 (no agent header)" +} + +now_s() { date +%s; } + +# ── Case 1: baseline relay — verbatim body, existing headers, no identity ── +start_mock '{}' +HOME1="$(new_home)" +run_hook "$HOME1" postToolUse "$(payload_for 11111111-1111-1111-1111-111111111111)" +assert_eq "$(cat "$OUT_FILE")" '{}' "allow response relayed verbatim" +assert_header "x-rogue-event" "postToolUse" "x-rogue-event is the verbatim Cursor event name" +assert_header "x-rogue-api-key" "test-key" "x-rogue-api-key forwarded" +assert_header "x-rogue-actor-email" "test@example.com" "x-rogue-actor-email forwarded" +assert_header "x-rogue-actor-name" "Test User" "x-rogue-actor-name forwarded (with space)" +assert_header "x-rogue-source" "cursor" "x-rogue-source: cursor" +path=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["path"])' "$HEADERS_FILE") +assert_eq "$path" "/api/v1/hooks/cursor" "POST path is /api/v1/hooks/cursor" +assert_body_identical "unresolved body posted byte-identical to stdin" + +# ── Case 2: a main-agent conversation resolves nothing and never waits ───── +# A brand-new top-level conversation looks exactly like an unresolved child (its +# own agent-transcripts dir does not exist for ~9s), so the ONLY thing keeping it +# from paying the full budget is the marker gate. +restart_mock '{}' +HOME2="$(new_home)" +t0=$(now_s) +run_hook "$HOME2" preToolUse "$(payload_for 22222222-2222-2222-2222-222222222222)" +t1=$(now_s) +assert_no_identity_headers "main-agent event" +assert_body_identical "main-agent body posted byte-identical to stdin" +if [ $((t1 - t0)) -ge 2 ]; then + echo "FAIL [main-agent event returns promptly]: took $((t1 - t0))s, budget is ~3s" >&2; exit 1 +fi +echo " ok: main-agent event returns promptly (no marker -> no wait)" + +# ── Case 3: cache-cold resolution — both headers, parent is the DIR name ─── +restart_mock '{}' +HOME3="$(new_home)" +CHILD_A=aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa +PARENT_A=99999999-9999-4999-8999-999999999999 +make_child_file "$HOME3" "$SLUG" "$PARENT_A" "$CHILD_A" +run_hook "$HOME3" postToolUse "$(payload_for "$CHILD_A")" +assert_header "x-rogue-parent-session-id" "$PARENT_A" "parent header is the agent-transcripts directory name" +assert_header "x-rogue-agent-id" "$CHILD_A" "agent header is the child's OWN conversation id" +assert_body_identical "resolved child body posted byte-identical to stdin" +assert_eq "$(cat "$HOME3/.rogue/cursor-parent/$CHILD_A")" "$PARENT_A" "resolution cached at ~/.rogue/cursor-parent/" + +# ── Case 3b: a second event for the same child reuses the cache ─────────── +# Proven by DELETING the transcript file first: only a cache read can still +# answer. A subagent fires 18-223 hooks per spawn, so this is the common path. +restart_mock '{}' +rm -rf "$HOME3/.cursor/projects" +run_hook "$HOME3" beforeShellExecution "$(payload_for "$CHILD_A")" +assert_header "x-rogue-parent-session-id" "$PARENT_A" "second event resolves from the cache, not the filesystem" +assert_header "x-rogue-agent-id" "$CHILD_A" "second event still carries the child id" +assert_body_identical "cached-resolution body posted byte-identical to stdin" + +# ── Case 4: two subagents under ONE parent — each carries its OWN id ─────── +# This is the case that proves the lookup is a KEY LOOKUP and not "newest file +# wins": both files live in the same subagents/ directory, so any ranking rule +# would hand at least one event the other child's id. +restart_mock '{}' +HOME4="$(new_home)" +CHILD_X=bbbbbbbb-1111-4111-8111-bbbbbbbbbbbb +CHILD_Y=cccccccc-1111-4111-8111-cccccccccccc +PARENT_XY=88888888-8888-4888-8888-888888888888 +make_child_file "$HOME4" "$SLUG" "$PARENT_XY" "$CHILD_X" +sleep 0.05 # distinct mtimes, so a "newest wins" bug would be deterministic +make_child_file "$HOME4" "$SLUG" "$PARENT_XY" "$CHILD_Y" + +run_hook "$HOME4" postToolUse "$(payload_for "$CHILD_X")" +assert_header "x-rogue-agent-id" "$CHILD_X" "concurrent subagent X carries X's id (older file)" +assert_header "x-rogue-parent-session-id" "$PARENT_XY" "concurrent subagent X resolves the shared parent" +restart_mock '{}' +run_hook "$HOME4" postToolUse "$(payload_for "$CHILD_Y")" +assert_header "x-rogue-agent-id" "$CHILD_Y" "concurrent subagent Y carries Y's id" +restart_mock '{}' +run_hook "$HOME4" afterFileEdit "$(payload_for "$CHILD_X")" +assert_header "x-rogue-agent-id" "$CHILD_X" "X still carries X's id after Y ran (no cross-talk)" + +# ── Case 5: a child under an UNDERIVABLE slug — global glob fallback ─────── +# Slug derivation has real exceptions on disk (numeric slugs, `empty-window`, +# `.code-workspace`-derived names). The filename is the key either way. +restart_mock '{}' +HOME5="$(new_home)" +CHILD_S=dddddddd-1111-4111-8111-dddddddddddd +PARENT_S=77777777-7777-4777-8777-777777777777 +make_child_file "$HOME5" "1784115802260" "$PARENT_S" "$CHILD_S" +run_hook "$HOME5" postToolUse "$(payload_for "$CHILD_S")" +assert_header "x-rogue-parent-session-id" "$PARENT_S" "resolved under a non-derivable slug via the global glob" +assert_header "x-rogue-agent-id" "$CHILD_S" "child id unchanged by the fallback path" + +# ── Case 6: live marker + file created mid-wait -> headers sent ──────────── +# The child's file is born 0.811-1.627s after its first hook, and its creation is +# INDEPENDENT of hook returns, so this wait cannot self-deadlock. +restart_mock '{}' +HOME6="$(new_home)" +CHILD_W=eeeeeeee-1111-4111-8111-eeeeeeeeeeee +PARENT_W=66666666-6666-4666-8666-666666666666 +make_marker "$HOME6" "$SLUG" "$PARENT_W" +( sleep 0.7; make_child_file "$HOME6" "$SLUG" "$PARENT_W" "$CHILD_W" ) & +SPAWNER=$! +run_hook "$HOME6" postToolUse "$(payload_for "$CHILD_W")" +wait "$SPAWNER" 2>/dev/null || true +assert_header "x-rogue-parent-session-id" "$PARENT_W" "live marker: waited and resolved once the file appeared" +assert_header "x-rogue-agent-id" "$CHILD_W" "mid-wait resolution carries the child id" +assert_body_identical "mid-wait body posted byte-identical to stdin" + +# ── Case 7: live marker, file NEVER created -> fail open, POST still happens ── +restart_mock '{}' +HOME7="$(new_home)" +CHILD_N=ffffffff-1111-4111-8111-ffffffffffff +make_marker "$HOME7" "$SLUG" 55555555-5555-4555-8555-555555555555 +ROGUE_CURSOR_PARENT_ITERS=5 run_hook "$HOME7" postToolUse "$(payload_for "$CHILD_N")" +assert_eq "$(cat "$OUT_FILE")" '{}' "budget expiry still relays the response (fail open)" +assert_no_identity_headers "budget expired" +assert_body_identical "fail-open body posted byte-identical to stdin" + +# ── Case 8: a marker older than the TTL is treated as absent (no wait) ───── +restart_mock '{}' +HOME8="$(new_home)" +CHILD_T=0a0a0a0a-1111-4111-8111-0a0a0a0a0a0a +make_marker "$HOME8" "$SLUG" 44444444-4444-4444-8444-444444444444 +touch -t 200001010000 "$HOME8/.rogue/cursor-spawn/$SLUG/44444444-4444-4444-8444-444444444444" +t0=$(now_s) +run_hook "$HOME8" postToolUse "$(payload_for "$CHILD_T")" +t1=$(now_s) +assert_no_identity_headers "stale marker" +if [ $((t1 - t0)) -ge 2 ]; then + echo "FAIL [stale marker does not arm the wait]: took $((t1 - t0))s" >&2; exit 1 +fi +echo " ok: stale marker does not arm the wait" + +# ── Case 9: subagentStart writes the marker named by its OWN conversation_id ── +# subagentStart fires ON THE PARENT, so its conversation_id IS the parent's, and +# it must send no identity headers of its own. +restart_mock '{}' +HOME9="$(new_home)" +PARENT_M=33333333-3333-4333-8333-333333333333 +run_hook "$HOME9" subagentStart "$(payload_for "$PARENT_M")" +assert_no_identity_headers "subagentStart" +assert_body_identical "subagentStart body posted byte-identical to stdin" +if [ ! -f "$HOME9/.rogue/cursor-spawn/$SLUG/$PARENT_M" ]; then + echo "FAIL [subagentStart writes the marker]: no marker at ~/.rogue/cursor-spawn/$SLUG/$PARENT_M" >&2; exit 1 +fi +echo " ok: subagentStart writes ~/.rogue/cursor-spawn//" + +# ── Case 9b: subagentStop clears it (best effort; the TTL is the real retire) ── +restart_mock '{}' +run_hook "$HOME9" subagentStop "$(payload_for "$PARENT_M")" +if [ -f "$HOME9/.rogue/cursor-spawn/$SLUG/$PARENT_M" ]; then + echo "FAIL [subagentStop clears the marker]: marker still present" >&2; exit 1 +fi +echo " ok: subagentStop clears the marker" +assert_no_identity_headers "subagentStop" + +# ── Case 10: parent-side events never resolve, even with a file on disk ──── +# sessionEnd carries the PARENT's own conversation id; attributing it to itself +# would be meaningless, and waiting on it would tax every session. +restart_mock '{}' +HOME10="$(new_home)" +CHILD_P=1b1b1b1b-1111-4111-8111-1b1b1b1b1b1b +make_child_file "$HOME10" "$SLUG" 22222222-2222-4222-8222-222222222222 "$CHILD_P" +run_hook "$HOME10" sessionEnd "$(payload_for "$CHILD_P")" +assert_no_identity_headers "sessionEnd (parent-side event)" + +# ── Case 11: the cache is read BEFORE any filesystem scan ───────────────── +# Seeded with a parent that has no file anywhere on disk, so only a cache read +# can produce this header. +restart_mock '{}' +HOME11="$(new_home)" +CHILD_C=2c2c2c2c-1111-4111-8111-2c2c2c2c2c2c +seed_cache "$HOME11" "$CHILD_C" cached-parent-id +run_hook "$HOME11" postToolUse "$(payload_for "$CHILD_C")" +assert_header "x-rogue-parent-session-id" "cached-parent-id" "cache is consulted before the filesystem" +assert_header "x-rogue-agent-id" "$CHILD_C" "cache hit still carries the child id" + +# ── Case 12: preToolUse is the ONE permitted body mutation ──────────────── +# A resolved child's preToolUse may add `rogueFilePreImageB64` and NOTHING else. +# Asserting the exception by name is what stops a second one being added quietly. +restart_mock '{}' +HOME12="$(new_home)" +CHILD_E=3d3d3d3d-1111-4111-8111-3d3d3d3d3d3d +PARENT_E=11111111-1111-4111-8111-111111111111 +make_child_file "$HOME12" "$SLUG" "$PARENT_E" "$CHILD_E" +TARGET="$HOME12/target.txt" +printf 'pre-edit content' > "$TARGET" +PRE_PAYLOAD=$(python3 -c ' +import json,sys +print(json.dumps({ + "conversation_id": sys.argv[1], + "session_id": sys.argv[1], + "workspace_roots": [sys.argv[3]], + "transcript_path": None, + "tool_name": "Write", + "tool_input": {"file_path": sys.argv[2], "content": "new"}, +}, separators=(",", ":")))' "$CHILD_E" "$TARGET" "$WS") +run_hook "$HOME12" preToolUse "$PRE_PAYLOAD" +assert_header "x-rogue-parent-session-id" "$PARENT_E" "preToolUse still resolves the parent" +assert_header "x-rogue-agent-id" "$CHILD_E" "preToolUse still carries the child id" +diff_keys=$(posted_body | python3 -c ' +import base64,json,sys +posted = json.load(sys.stdin) +sent = json.loads(sys.argv[1]) +added = sorted(set(posted) - set(sent)) +removed = sorted(set(sent) - set(posted)) +changed = sorted(k for k in sent if posted.get(k) != sent[k]) +b64 = posted.get("rogueFilePreImageB64", "") +ok_pre = base64.b64decode(b64).decode() == "pre-edit content" +print("added=%s removed=%s changed=%s preimage_ok=%s" % (added, removed, changed, ok_pre))' "$PRE_PAYLOAD") +assert_eq "$diff_keys" \ + "added=['rogueFilePreImageB64'] removed=[] changed=[] preimage_ok=True" \ + "preToolUse adds rogueFilePreImageB64 and nothing else (the only body exception)" + +# ── Case 13: a non-preToolUse event NEVER carries a pre-image ───────────── +restart_mock '{}' +run_hook "$HOME12" afterFileEdit "$PRE_PAYLOAD" +assert_body_identical "afterFileEdit body posted byte-identical to stdin (no pre-image)" + +# ── Case 14: a non-uuid conversation_id is never used as a path component ── +restart_mock '{}' +HOME14="$(new_home)" +run_hook "$HOME14" postToolUse '{"conversation_id":"../../etc/passwd","session_id":"x","workspace_roots":["'"$WS"'"]}' +assert_no_identity_headers "non-uuid conversation_id" +assert_body_identical "traversal-shaped id body posted byte-identical to stdin" + +# ── Case 15: an unparseable payload fails open and still relays ─────────── +restart_mock '{"permission":"deny"}' +HOME15="$(new_home)" +run_hook "$HOME15" postToolUse 'not json at all' +assert_eq "$(cat "$OUT_FILE")" '{"permission":"deny"}' "unparseable payload still relays the response" +assert_no_identity_headers "unparseable payload" +assert_body_identical "unparseable payload posted byte-identical to stdin" + +# ── Case 16: resolution works with jq absent (the text-scan fallback) ───── +# jq is missing from older macOS and minimal Linux images, so `conversation_id` +# and `workspace_roots[0]` both have anchored-scan fallbacks. Only ONE path runs +# on a given machine, so the fallback needs its own coverage. +restart_mock '{}' +HOME16="$(new_home)" +CHILD_J=4e4e4e4e-1111-4111-8111-4e4e4e4e4e4e +PARENT_J=5f5f5f5f-5555-4555-8555-5f5f5f5f5f5f +make_child_file "$HOME16" "$SLUG" "$PARENT_J" "$CHILD_J" +NOJQ_DIR="$(make_nojq_path)" +TEST_PATH="$NOJQ_DIR" run_hook "$HOME16" postToolUse "$(payload_for "$CHILD_J")" +assert_header "x-rogue-parent-session-id" "$PARENT_J" "resolved without jq (anchored text scan)" +assert_header "x-rogue-agent-id" "$CHILD_J" "child id read without jq" +assert_body_identical "no-jq body posted byte-identical to stdin" +rm -rf "$NOJQ_DIR"; NOJQ_DIR="" + +echo +echo "All Cursor hook.sh tests passed (SH=$SH)." diff --git a/tests/test_hooks_json_cursor.sh b/tests/test_hooks_json_cursor.sh new file mode 100755 index 0000000..1703716 --- /dev/null +++ b/tests/test_hooks_json_cursor.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# tests/test_hooks_json_cursor.sh — static lint of the Cursor plugin's +# hooks.json. Asserts the dual-dispatcher registration (one `sh` entry + one +# PowerShell entry per event, so exactly one does real work per machine) and the +# per-hook timeout. +# +# The 120s timeout is LOAD-BEARING, not decoration: a subagent's first event may +# wait up to ~3s for Cursor to write the child's transcript file (see +# resolve_parent_session in scripts/hook.sh). 3s is 2.5% of this budget; cutting +# the timeout towards the wait would turn a resolvable subagent into a killed +# hook. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +HOOKS="$REPO/plugins/cursor/hooks/hooks.json" + +python3 - "$HOOKS" <<'PY' +import json, sys + +path = sys.argv[1] +with open(path) as f: + doc = json.load(f) # raises on invalid JSON + +errors = [] + +if doc.get("version") != 1: + errors.append(f"top-level version must be 1, got {doc.get('version')!r}") + +hooks = doc.get("hooks", {}) +expected_events = { + "sessionStart", "sessionEnd", "beforeSubmitPrompt", "preToolUse", + "postToolUse", "postToolUseFailure", "beforeShellExecution", + "afterShellExecution", "beforeMCPExecution", "afterMCPExecution", + "beforeReadFile", "afterFileEdit", "afterAgentResponse", "afterAgentThought", + "subagentStart", "subagentStop", "stop", "preCompact", +} +got_events = set(hooks.keys()) +if got_events != expected_events: + missing = sorted(expected_events - got_events) + extra = sorted(got_events - expected_events) + errors.append(f"events differ: missing={missing} extra={extra}") + +# subagentStart is what arms the marker gate, and subagentStop is what clears it. +# Losing either registration silently costs every subagent its attribution, so +# call them out by name rather than leaving them inside the set comparison. +for required in ("subagentStart", "subagentStop"): + if required not in got_events: + errors.append(f"'{required}' must stay registered (it drives the spawn marker)") + +for event, entries in sorted(hooks.items()): + if not isinstance(entries, list) or len(entries) != 2: + errors.append(f"{event}: expected exactly 2 entries (sh + PowerShell)") + continue + sh_entries = [e for e in entries if e.get("command", "").startswith("sh ./scripts/hook.sh ")] + ps_entries = [e for e in entries if e.get("command", "").startswith("powershell ")] + if len(sh_entries) != 1: + errors.append(f"{event}: expected exactly 1 sh entry") + if len(ps_entries) != 1: + errors.append(f"{event}: expected exactly 1 PowerShell entry") + for i, entry in enumerate(entries): + tag = f"{event}[{i}]" + cmd = entry.get("command") + if not isinstance(cmd, str) or not cmd: + errors.append(f"{tag}: missing 'command'") + continue + # The event name is passed as the dispatcher's argument and echoed back + # as x-rogue-event; the server routes on it. The PowerShell entry closes + # its -Command string after the argument, hence the trailing quote. + if not cmd.rstrip().rstrip('"').endswith(event): + errors.append(f"{tag}: command must end with the event name {event!r}") + if entry.get("timeout") != 120: + errors.append(f"{tag}: timeout must be 120 (the ~3s subagent wait lives inside it)") + +if errors: + for e in errors: + print(f"FAIL: {e}") + sys.exit(1) + +print(f" ok: {len(hooks)} events, each with an sh + PowerShell entry at timeout 120") +PY + +echo +echo "Cursor hooks.json lint passed."