From 847b7595daddaaf735bca428702819fa0327bf45 Mon Sep 17 00:00:00 2001 From: amos Date: Sun, 16 Aug 2026 12:09:36 +0300 Subject: [PATCH 1/3] [FIRE-1932] feat(logs): say which surface wrote each line There is one log file per agent family per machine, and every surface of that family appends to it: Claude Code from the CLI, from the Desktop app and a Cowork session all write the same claude.log, and nothing on the line said which one. The heartbeat knows the surface, but it describes ONE session, while a log holds lines from many sessions across many surfaces - so it has to be stamped as the line is written. 2026-08-13T07:02:05Z provider=claude surface=cli event=PreToolUse outcome=allow Directly after provider=, before event=. Lowercase closed-vocabulary slug, no space and no `=`, so a reader still finds every value by scanning to the next `key=`. OPTIONAL, and that is load-bearing. An undetermined surface omits the WHOLE token: `surface=` with nothing after it, or `surface=unknown`, would both be indistinguishable from a real value to that same reader. Lines written by earlier versions have no token and are never rewritten. In practice it is absent only there and on an antigravity event whose payload carries no transcriptPath - including outcome=unconfigured, which is logged before stdin is read. ONE resolution per plugin, from the signal its heartbeat already uses: - plugins/rogue/scripts/surface.{sh,ps1} is a new shared table. hook takes the slug, heartbeat takes the display label, from the same `case` - previously duplicated in both. Claude's dispatchers exit before their first log line when CLAUDE_CODE_ENTRYPOINT is unset, so the slug is always resolvable there. - plugins/codex/scripts/surface.{sh,ps1} reads ROGUE_CODEX_SURFACE once and VALIDATES it against the closed list, feeding the log token, the x-rogue-agent header and the heartbeat. The validation is new: the value comes from an env file, so an arbitrary string with a space or an `=` in it would otherwise have reached both a log line and a roster row. - antigravity already resolved its surface from the payload's transcriptPath and passed it to the heartbeat; that one value now also stamps the line and drives the IDE-only enrichment branch. Its PowerShell half resolved this AFTER the heartbeat, which would have left a few lines untagged where the sh half tagged them - Resolve-Surface moved up to match. - cursor, copilot and gemini have exactly one surface each, so a constant - still emitted through the same conditional as everyone else, so all six dispatchers share one emit shape. A second, independent way to decide the surface would be worse than no token: a line and the roster row for the same session could name different surfaces. No new failure modes. Every resolution is guarded on both sides; a missing surface.sh, an unreadable file or a throwing resolver yields an empty slug, which yields no token. Nothing here can block a session, change an allow/deny outcome, or write to stderr. The upload envelope is untouched. Tests: the slug matrix per plugin, the emit position, and that a placeholder is never written, in all three languages. The claude table is asserted against the heartbeat's own labels so the two cannot drift. tests/log_probe.ps1 needed a snapshot of its -Surface argument before dot-sourcing the dispatcher: PowerShell variable names are case-insensitive, so the dispatcher's own $script:surface silently overwrote the parameter - the same trap the probe already documents for $Creds. Versions: rogue 1.0.24, codex 1.0.1, cursor 1.1.1, copilot 1.2.1, antigravity 1.0.24, gemini 1.0.25. Vocabulary, per-plugin detection and the absent-token rules: docs/hook-log-format.md. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/plugins/marketplace.json | 4 +- .claude-plugin/marketplace.json | 4 +- .cursor-plugin/marketplace.json | 2 +- .github/plugin/marketplace.json | 4 +- CLAUDE.md | 4 +- docs/hook-log-format.md | 105 ++++++++++++++++++++++ docs/log-shipping-backend.md | 10 ++- plugins/antigravity/VERSION | 2 +- plugins/antigravity/scripts/hook.ps1 | 27 ++++-- plugins/antigravity/scripts/hook.sh | 20 ++++- plugins/codex/.codex-plugin/plugin.json | 2 +- plugins/codex/scripts/heartbeat.ps1 | 13 ++- plugins/codex/scripts/heartbeat.sh | 13 ++- plugins/codex/scripts/hook.ps1 | 29 +++++- plugins/codex/scripts/hook.sh | 27 ++++-- plugins/codex/scripts/surface.ps1 | 26 ++++++ plugins/codex/scripts/surface.sh | 32 +++++++ plugins/copilot/plugin.json | 2 +- plugins/copilot/scripts/hook.ps1 | 10 ++- plugins/copilot/scripts/hook.sh | 14 ++- plugins/cursor/.cursor-plugin/plugin.json | 2 +- plugins/cursor/scripts/hook.ps1 | 10 ++- plugins/cursor/scripts/hook.sh | 14 ++- plugins/gemini/gemini-extension.json | 2 +- plugins/gemini/scripts/hook.mjs | 15 +++- plugins/rogue/.claude-plugin/plugin.json | 2 +- plugins/rogue/scripts/heartbeat.ps1 | 18 +++- plugins/rogue/scripts/heartbeat.sh | 15 ++-- plugins/rogue/scripts/hook.ps1 | 24 ++++- plugins/rogue/scripts/hook.sh | 22 ++++- plugins/rogue/scripts/surface.ps1 | 44 +++++++++ plugins/rogue/scripts/surface.sh | 49 ++++++++++ tests/log_probe.ps1 | 20 ++++- tests/test_hook_logs.ps1 | 66 +++++++++++++- tests/test_hook_logs.sh | 76 +++++++++++++++- tests/test_hook_mjs.mjs | 60 +++++++++++++ tests/test_hook_sh_antigravity.sh | 16 +++- 37 files changed, 744 insertions(+), 61 deletions(-) create mode 100644 docs/hook-log-format.md create mode 100644 plugins/codex/scripts/surface.ps1 create mode 100644 plugins/codex/scripts/surface.sh create mode 100644 plugins/rogue/scripts/surface.ps1 create mode 100644 plugins/rogue/scripts/surface.sh diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index ae7987f..04fcb6b 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -1,6 +1,6 @@ { "name": "rogue-marketplace", - "version": "1.0.0", + "version": "1.0.1", "description": "Rogue Security extensions for OpenAI Codex", "owner": { "name": "Qualifire (Rogue Security)", @@ -10,7 +10,7 @@ "plugins": [ { "name": "rogue", - "version": "1.0.0", + "version": "1.0.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for OpenAI Codex", "author": { "name": "Rogue Security", diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3737d2e..059cbad 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-marketplace.json", "name": "rogue-marketplace", - "version": "1.0.23", + "version": "1.0.24", "description": "Rogue Security extensions for Claude Code", "owner": { "name": "Qualifire (Rogue Security)", @@ -11,7 +11,7 @@ "plugins": [ { "name": "rogue", - "version": "1.0.23", + "version": "1.0.24", "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for Claude Code", "author": { "name": "Rogue Security", diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 1289bea..25e2409 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "rogue-security", - "version": "1.1.0", + "version": "1.1.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "Rogue Security", diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 7d709cd..685a32f 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "rogue-copilot", - "version": "1.2.0", + "version": "1.2.1", "description": "Rogue Security extensions for GitHub Copilot CLI", "owner": { "name": "Qualifire (Rogue Security)", @@ -10,7 +10,7 @@ "plugins": [ { "name": "rogue", - "version": "1.2.0", + "version": "1.2.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for GitHub Copilot CLI", "author": { "name": "Rogue Security", diff --git a/CLAUDE.md b/CLAUDE.md index 948b6e5..d63a8ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,9 +109,11 @@ Every event registers two entries — an `sh` one and a PowerShell one — point Line format, identical across all six dispatchers: ``` -2026-08-11T11:26:16Z provider=claude event=PreToolUse outcome=unconfigured +2026-08-11T11:26:16Z provider=claude surface=cli event=PreToolUse outcome=unconfigured ``` +- **`surface=` is OPTIONAL and sits between `provider=` and `event=`.** One file per agent family means every surface of that family appends to it — CLI, Desktop and Cowork all write `claude.log` — so the line has to say which one wrote it. Closed vocabulary, per plugin: `cli`/`desktop`/`cowork`, `codex_cli`/`codex_app`, `antigravity`/`antigravity_ide`/`antigravity_cli`, `cursor`, `github_copilot`, `gemini_cli`. **Absent** when the surface cannot be determined — never `surface=` and never `surface=unknown`, either of which a reader scanning `key=` tokens cannot tell from a real value. In practice that means lines from a version older than rogue 1.0.24 / codex 1.0.1 / cursor 1.1.1 / copilot 1.2.1 / antigravity 1.0.24 / gemini 1.0.25, and antigravity events whose payload has no `transcriptPath`. **Each plugin resolves it ONCE, from the same signal its heartbeat uses** — `plugins/{rogue,codex}/scripts/surface.{sh,ps1}` are shared tables read by both hook and heartbeat, and antigravity's hook passes its single resolution to its own heartbeat. Never add a second detection path: a line and the roster row for one session naming different surfaces is worse than a line naming none. Full table: [docs/hook-log-format.md](docs/hook-log-format.md). + - **`provider=` is the agent slug, which is also the file's basename.** The two are kept equal on purpose so a merged grep and a file listing use one vocabulary. It is deliberately **NOT** the heartbeat's `agent_family`/`agent` (the server keys its roster and version lookup on those): five of six coincide, but Codex's family is `openai` while its slug is `codex`, and the roster labels are `gemini_cli` / `github_copilot` where the slugs are `gemini` / `copilot`. Don't "align" them. - **Path precedence**: `ROGUE_LOG_FILE` (exact path, back-compat) → `ROGUE_LOG_DIR/.log` → `~/.rogue/logs/.log`. Prefer `ROGUE_LOG_DIR` when relocating: `~/.rogue-env` is **shared by every plugin**, so a `ROGUE_LOG_FILE` set there re-collapses all six into one file. - **All three knobs come from the same env-file chain as the credentials** — bundled `${PLUGIN_ROOT}/env` → `/etc/rogue/env` / `C:\ProgramData\rogue\env` (MDM) → `~/.rogue-env`, then process env wins. This is load-bearing on **every** dispatcher, so keep it that way when editing: diff --git a/docs/hook-log-format.md b/docs/hook-log-format.md new file mode 100644 index 0000000..4949fbc --- /dev/null +++ b/docs/hook-log-format.md @@ -0,0 +1,105 @@ +# The hook log line + +Every Rogue plugin's hook dispatcher appends **one line per invocation** to its own +file under `~/.rogue/logs/` (`%USERPROFILE%\.rogue\logs\` on Windows) — +`claude.log`, `codex.log`, `cursor.log`, `gemini.log`, `copilot.log`, +`antigravity.log`. + +``` +2026-08-13T07:02:05Z provider=claude surface=cli event=PreToolUse outcome=allow +``` + +Fields are `key=value`, separated by single spaces, in a fixed order. A reader +finds a value by scanning from `=` to the next space, so **no value contains a +space or an `=`**. + +| position | token | always present | notes | +| --- | --- | --- | --- | +| 1 | timestamp | yes | UTC ISO-8601, second precision, no fractional part | +| 2 | `provider=` | yes | the agent slug, which is also the file's basename | +| 3 | `surface=` | **no — optional** | which surface of that agent wrote the line | +| 4 | `event=` | yes | the vendor's own event name, verbatim casing | +| 5+ | free-form | yes | `outcome=`, `raw=`, `reason=`, … per dispatcher | + +## `surface=` — which surface wrote the line + +There is **one log file per agent family per machine**, and every surface of that +family appends to it. Claude Code launched from the CLI, from the Desktop app and a +Cowork session all write into the same `claude.log`. The heartbeat knows the +surface, but it describes *one session*, while a log file holds lines from many +sessions across many surfaces — so the surface has to be stamped on each line as it +is written. + +### Vocabulary + +Lowercase, no spaces, no `=`. A **closed list**: the token is one of these strings +and nothing else — never a path, a user name, a host name, or a window title. + +| plugin (`provider=`) | `surface=` | how it is determined | +| --- | --- | --- | +| `claude` | `cli` | `CLAUDE_CODE_ENTRYPOINT` is set and matches nothing below | +| `claude` | `desktop` | `CLAUDE_CODE_ENTRYPOINT` contains `desktop` | +| `claude` | `cowork` | `CLAUDE_CODE_ENTRYPOINT` contains `cowork` | +| `codex` | `codex_cli` | `ROGUE_CODEX_SURFACE` unset, `codex_cli`, or unrecognised | +| `codex` | `codex_app` | `ROGUE_CODEX_SURFACE=codex_app` (the installer pins it) | +| `antigravity` | `antigravity` | the event's `transcriptPath` is under `…/antigravity/…` | +| `antigravity` | `antigravity_ide` | `transcriptPath` under `…/antigravity-ide/…` | +| `antigravity` | `antigravity_cli` | `transcriptPath` under `…/antigravity-cli/…` | +| `cursor` | `cursor` | single surface — a constant | +| `copilot` | `github_copilot` | single surface — a constant | +| `gemini` | `gemini_cli` | single surface — a constant | + +Each plugin resolves this **once**, from the signal its heartbeat already uses, and +the two read one shared table: + +- `plugins/rogue/scripts/surface.sh` / `.ps1` — `hook` takes the slug, `heartbeat` + takes the display label, from the same `case`. +- `plugins/codex/scripts/surface.sh` / `.ps1` — one read of `ROGUE_CODEX_SURFACE`, + validated against the closed list, feeding the log token, the `x-rogue-agent` + header and the heartbeat alike. +- Antigravity resolves it from the payload once per invocation and passes the same + value to its log line, its heartbeat and its per-surface enrichment. + +A second, independent way to decide the surface would be worse than no token at +all: a line and the roster row for the same session could then name different +surfaces. + +### The token is OPTIONAL + +It is **absent** when the surface cannot be determined. There is no +`surface=unknown` and no empty `surface=` — either would be indistinguishable from +a real value to a reader scanning `key=` tokens. + +In practice it is absent in two cases: + +1. **Lines written by a plugin version older than the ones below.** Old lines are + never rewritten; they have no token, permanently. +2. **An antigravity event whose payload carries no `transcriptPath`** — including + every line written before the payload is read, such as `outcome=unconfigured` on + a machine with no API key. `transcriptPath` is the only reliable signal (three + Antigravity products share one install, so a filesystem probe cannot tell which + is running), and guessing is worse than omitting. + +The other five plugins determine their surface on every line they write. + +### First version that ships it + +| plugin | version | +| --- | --- | +| rogue (Claude Code) | 1.0.24 | +| codex | 1.0.1 | +| cursor | 1.1.1 | +| copilot | 1.2.1 | +| antigravity | 1.0.24 | +| gemini | 1.0.25 | + +### Guarantees + +- The three dispatchers of a plugin (POSIX `sh`, PowerShell, and Node for Gemini) + emit **the same token for the same event** — asserted by + `tests/test_hook_logs.sh`, `tests/test_hook_logs.ps1` and + `tests/test_hook_mjs.mjs`. +- Resolving a surface can never block a session, change an allow/deny outcome, or + write to stderr. Every resolution is guarded; a failure yields an empty slug, + which yields no token. +- The upload envelope is unchanged. This is a per-line change only. diff --git a/docs/log-shipping-backend.md b/docs/log-shipping-backend.md index 3fff11f..d493adb 100644 --- a/docs/log-shipping-backend.md +++ b/docs/log-shipping-backend.md @@ -83,9 +83,17 @@ Four properties of the client that the server design has to respect: The format is phase 1's, identical across all six dispatchers: ```text -2026-08-11T11:26:16Z provider=claude event=PreToolUse outcome=unconfigured +2026-08-11T11:26:16Z provider=claude surface=cli event=PreToolUse outcome=unconfigured ``` +- **`surface=` is OPTIONAL, and sits between `provider=` and `event=`.** It names + which surface of that agent family wrote the line — `cli` / `desktop` / `cowork` + for `claude`, `codex_cli` / `codex_app`, `antigravity` / `antigravity_ide` / + `antigravity_cli`, and a constant for the single-surface plugins. It is absent on + every line written before the versions listed in + [hook-log-format.md](hook-log-format.md), and absent whenever the surface could + not be determined; there is no `surface=unknown` and no empty value. Treat the + token as optional and define a behaviour for lines that lack it. - **`provider=` is the per-line source of truth for attribution, not `log_file` and not `agent_family`.** A support upload (`ROGUE_SHIP_ALL=1`, which the no-argument form implies) carries several agents' files in one run, and an install that sets diff --git a/plugins/antigravity/VERSION b/plugins/antigravity/VERSION index 154b9fc..79728fe 100644 --- a/plugins/antigravity/VERSION +++ b/plugins/antigravity/VERSION @@ -1 +1 @@ -1.0.23 +1.0.24 diff --git a/plugins/antigravity/scripts/hook.ps1 b/plugins/antigravity/scripts/hook.ps1 index c89bda9..222abf5 100644 --- a/plugins/antigravity/scripts/hook.ps1 +++ b/plugins/antigravity/scripts/hook.ps1 @@ -91,6 +91,13 @@ function ConvertFrom-ShellQuoted { # reading a script variable from a function is implicit, but ASSIGNING one needs # the `$script:` prefix or the write lands in a function-local copy and silently # vanishes. Every write to shared state below is therefore `$script:`-qualified. +# Which SURFACE of Antigravity wrote each line - antigravity, antigravity_ide or +# antigravity_cli. Resolved from the payload's transcriptPath, the ONLY reliable +# signal (three products share one install, so a filesystem probe cannot tell which +# is running), and the same value the heartbeat reports. Empty for an event whose +# payload carries no transcriptPath, and for every line written before the payload +# is read: the token is then OMITTED, never `surface=` and never `surface=unknown`. +$script:surface = '' $script:logFile = '' # resolved in Initialize-Logging $script:logMaxBytes = 10485760 # ditto; the default stands until then $creds = @{} # credential files + process env, by Import-Credentials @@ -226,9 +233,12 @@ function Log { # produced by a rotation) would start with EF BB BF and fail any parser # that anchors on the timestamp. "`n" keeps the line ending identical to # what the sh dispatchers write, so one log format covers both platforms. + # Empty slug -> empty string, so the line is byte-identical to what an + # older version wrote. Optional means optional. + $surfaceToken = if ($script:surface) { " surface=$($script:surface)" } else { '' } [System.IO.File]::AppendAllText( $logFile, - "$stamp provider=antigravity event=$EventName $Msg`n", + "$stamp provider=antigravity$surfaceToken event=$EventName $Msg`n", (New-Object System.Text.UTF8Encoding $false)) } catch {} } @@ -339,10 +349,11 @@ function Invoke-Heartbeat { # Pass the surface along: only the hook can know it (three products share # one install, and the event's transcriptPath names which state dir it # lives in). Mirrors hook.sh's `heartbeat.sh "$_hb_agent"`. + # Resolve-Surface already did this, off the same transcriptPath, so reuse it + # rather than re-matching: one resolution feeds the roster agent, the log + # token and the IDE-only enrichment branch, and they cannot disagree. $hbArgs = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$hbPath) - $hbTp = [regex]::Match($payload, '"transcriptPath"\s*:\s*"([^"]*)"').Groups[1].Value - $hbAgent = Get-AntigravitySurface $hbTp - if ($hbAgent) { $hbArgs += @('-Agent', $hbAgent) } + if ($script:surface) { $hbArgs += @('-Agent', $script:surface) } Start-Process -FilePath 'powershell' ` -ArgumentList $hbArgs ` -WindowStyle Hidden -ErrorAction Stop @@ -705,6 +716,9 @@ function Add-StoreRead { function Resolve-Surface { $script:payloadTp = Get-PayloadTranscriptPath $payload $script:isIdeSurface = $script:payloadTp -like '*/antigravity-ide/*' + # Same path, same table: the log token, the heartbeat's roster agent and the + # IDE-only enrichment branch all come from this one resolution. + $script:surface = [string](Get-AntigravitySurface $script:payloadTp) } function Add-StoreReadForEvent { @@ -857,6 +871,10 @@ function Invoke-Main { Resolve-Url Resolve-Actor Read-Payload + # Immediately after the payload, and BEFORE anything that logs - the same + # position hook.sh resolves it in. Every log line written from here on carries + # the surface, so the two dispatchers emit the same token for the same event. + Resolve-Surface Invoke-Heartbeat Initialize-SubagentDirs @@ -865,7 +883,6 @@ function Invoke-Main { # appended base64 blob. Resolve-Subagent - Resolve-Surface Add-StoreReadForEvent Add-TranscriptTail Add-CapabilityFlag diff --git a/plugins/antigravity/scripts/hook.sh b/plugins/antigravity/scripts/hook.sh index 2d11b3a..1903672 100755 --- a/plugins/antigravity/scripts/hook.sh +++ b/plugins/antigravity/scripts/hook.sh @@ -44,6 +44,7 @@ EVENT="" # hook event name, from $1 PLUGIN_ROOT="" # of this plugin, derived from $0 BODY="" # the hook payload, as received then enriched +SURFACE="" # antigravity | antigravity_ide | antigravity_cli, or empty URL="" # where to POST it SUBAGENT_ID="" # set by reattribute_subagent when this event is a subagent's SUBAGENT_NAME="" @@ -135,8 +136,13 @@ log() { ( umask 077 mkdir -p "$(dirname "$ROGUE_LOG_FILE")" 2>/dev/null rotate_log - printf '%s provider=antigravity event=%s %s\n' \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) + # `${SURFACE:+ surface=$SURFACE}` expands to NOTHING when the slug is empty - + # which is the normal case for an event whose payload carries no transcriptPath, + # and for every line written before read_body. Never `surface=`, never + # `surface=unknown`. + printf '%s provider=antigravity%s event=%s %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${SURFACE:+ surface=$SURFACE}" \ + "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) } sanitize() { printf '%s' "$1" | tr -d '\000-\037\177'; } @@ -634,7 +640,7 @@ maybe_heartbeat() { [ "$EVENT" = "PreInvocation" ] || return 0 case "$BODY" in *'"invocationNum":0'*|*'"invocationNum": 0'*) - _hb_agent=$(surface_from_transcript "$(json_field transcriptPath "$BODY")") + _hb_agent="$SURFACE" ( nohup sh "${PLUGIN_ROOT}/scripts/heartbeat.sh" "$_hb_agent" >/dev/null 2>&1 & ) ;; esac } @@ -649,7 +655,7 @@ maybe_heartbeat() { # so the wait was pure latency on the event that blocks the developer. The # prompt comes from the conversation store instead. enrich_body() { - _surface=$(surface_from_transcript "$(json_field transcriptPath "$BODY")") + _surface="$SURFACE" if [ "$_surface" = "antigravity_ide" ]; then case "$EVENT" in # The pending prompt, before the model call that would consume it. @@ -736,6 +742,12 @@ main() { require_api_key # exits before stdin is read when there is no key load_actor read_body + # ONE resolution, three consumers: the log token, the heartbeat's roster agent + # and enrich_body's IDE-only branch. transcriptPath is the only reliable signal + # (three products share one install, so a filesystem probe cannot tell which is + # running), and it is absent from some events - which is exactly why the log + # token is optional. Empty here means the line carries no surface= at all. + SURFACE=$(surface_from_transcript "$(json_field transcriptPath "$BODY")") maybe_heartbeat # Re-attribute BEFORE enriching: augment_with_transcript re-closes the JSON diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index 7c8df47..3142a9e 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.0.0", + "version": "1.0.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for OpenAI Codex", "author": { "name": "Rogue Security", diff --git a/plugins/codex/scripts/heartbeat.ps1 b/plugins/codex/scripts/heartbeat.ps1 index d83c865..72df4ce 100644 --- a/plugins/codex/scripts/heartbeat.ps1 +++ b/plugins/codex/scripts/heartbeat.ps1 @@ -88,7 +88,18 @@ if (Test-Path -LiteralPath $pj) { } # Family is the fixed enum "openai"; surface rides the agent field. -$agent = $creds['ROGUE_CODEX_SURFACE']; if (-not $agent) { $agent = 'codex_cli' } +# One table, in scripts/surface.ps1, shared with hook.ps1 - which stamps the same +# slug on each log line and sends it as x-rogue-agent. The literal is a last-resort +# guard for a damaged install, not a second copy of the mapping. +$agent = '' +try { + $surfaceLib = Join-Path $pluginRoot 'scripts\surface.ps1' + if (Test-Path -LiteralPath $surfaceLib) { + . $surfaceLib + $agent = [string](Get-CodexSurfaceSlug $creds) + } +} catch { $agent = '' } +if (-not $agent) { $agent = 'codex_cli' } $host_ = $env:COMPUTERNAME; if (-not $host_) { try { $host_ = [System.Net.Dns]::GetHostName() } catch { $host_ = 'unknown' } } diff --git a/plugins/codex/scripts/heartbeat.sh b/plugins/codex/scripts/heartbeat.sh index ddc7499..1528bc4 100755 --- a/plugins/codex/scripts/heartbeat.sh +++ b/plugins/codex/scripts/heartbeat.sh @@ -30,9 +30,16 @@ if [ -r "$PJ" ]; then [ -n "$v" ] && VER="$v" fi -# Family is the fixed enum "openai"; surface (codex_app|codex_cli) rides the -# agent field. Installer pins ROGUE_CODEX_SURFACE; default codex_cli. -AGENT="${ROGUE_CODEX_SURFACE:-codex_cli}" +# Family is the fixed enum "openai"; the surface (codex_app|codex_cli) rides the +# agent field. One table, in scripts/surface.sh, shared with hook.sh - which stamps +# the same slug on each log line and sends it as x-rogue-agent. The literal is a +# last-resort guard for a damaged install, not a second copy of the mapping. +AGENT="" +if [ -r "${PLUGIN_ROOT:-}/scripts/surface.sh" ]; then + . "${PLUGIN_ROOT}/scripts/surface.sh" + AGENT=$(codex_surface_slug 2>/dev/null) +fi +[ -n "$AGENT" ] || AGENT="codex_cli" HOST=$(hostname 2>/dev/null || echo unknown) esc() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } diff --git a/plugins/codex/scripts/hook.ps1 b/plugins/codex/scripts/hook.ps1 index 77cc3a4..6a3f4f8 100644 --- a/plugins/codex/scripts/hook.ps1 +++ b/plugins/codex/scripts/hook.ps1 @@ -70,6 +70,11 @@ function ConvertFrom-ShellQuoted { # would make the log shipper and the dispatcher disagree on the path. # Declared (not resolved) at file scope so the ROGUE_PS_LIB_ONLY seam below can # dot-source the helpers, and so Log is safe to call before initialisation. +# Which SURFACE of Codex wrote each line - codex_app or codex_cli. Resolved from +# the credential map (scripts/surface.ps1), the same table heartbeat.ps1 reads, so a +# log line and the roster row for one session cannot name different surfaces. Empty +# OMITS the token; it is never written as `surface=` or `surface=unknown`. +$script:surface = '' $script:logFile = $null $script:logMaxBytes = 10485760 @@ -143,9 +148,12 @@ function Log { # produced by a rotation) would start with EF BB BF and fail any parser # that anchors on the timestamp. "`n" keeps the line ending identical to # what the sh dispatchers write, so one log format covers both platforms. + # Empty slug -> empty string, so the line is byte-identical to what an + # older version wrote. Optional means optional. + $surfaceToken = if ($script:surface) { " surface=$($script:surface)" } else { '' } [System.IO.File]::AppendAllText( $logFile, - "$stamp provider=codex event=$EventName $Msg`n", + "$stamp provider=codex$surfaceToken event=$EventName $Msg`n", (New-Object System.Text.UTF8Encoding $false)) } catch {} } @@ -190,6 +198,18 @@ foreach ($k in 'ROGUE_API_KEY','ROGUE_ACTOR_EMAIL','ROGUE_ACTOR_NAME','ROGUE_BAS # Logging is initialised HERE - after the credential files are parsed, so they can # relocate the log - but BEFORE the API-key check below, so an unconfigured # install still records `outcome=unconfigured`. +# Before the first Log call, so even an unconfigured install stamps the surface. +# Guarded on both sides: a damaged install with no surface.ps1, or a resolver that +# throws, leaves the slug empty and the token is omitted - logging must never change +# the hook's outcome. +try { + $surfaceLib = Join-Path $pluginRoot 'scripts\surface.ps1' + if (Test-Path -LiteralPath $surfaceLib) { + . $surfaceLib + $script:surface = [string](Get-CodexSurfaceSlug $creds) + } +} catch { $script:surface = '' } + Initialize-Logging $creds Dbg "logFile=$logFile cap=$logMaxBytes" @@ -202,7 +222,12 @@ if (-not $apiKey) { exit 0 } -$surface = $creds['ROGUE_CODEX_SURFACE']; if (-not $surface) { $surface = 'codex_cli' } +# Reuse the slug resolved above, so the header, the log token and the roster row are +# one value. The fallback covers a damaged install where surface.ps1 was missing: +# the header has always carried a surface and must keep carrying one, where the log +# token is optional. +$surface = $script:surface +if (-not $surface) { $surface = 'codex_cli' } # URL: explicit ROGUE_API_URL wins, else base + path. $url = $creds['ROGUE_API_URL'] diff --git a/plugins/codex/scripts/hook.sh b/plugins/codex/scripts/hook.sh index 01982a1..c43671c 100755 --- a/plugins/codex/scripts/hook.sh +++ b/plugins/codex/scripts/hook.sh @@ -68,11 +68,26 @@ log() { ( umask 077 mkdir -p "$(dirname "$ROGUE_LOG_FILE")" 2>/dev/null rotate_log - printf '%s provider=codex event=%s %s\n' \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) + # `${SURFACE:+ surface=$SURFACE}` expands to NOTHING when the slug is empty, so + # an undetermined surface leaves the line exactly as older versions wrote it - + # never `surface=` and never `surface=unknown`. + printf '%s provider=codex%s event=%s %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${SURFACE:+ surface=$SURFACE}" \ + "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) } sanitize() { printf '%s' "$1" | tr -d '\000-\037\177'; } +# Which SURFACE of Codex wrote this - codex_app or codex_cli. Resolved HERE, above +# the first log() call, so even an unconfigured install stamps it; the same value is +# reused for the x-rogue-agent header below, and heartbeat.sh reads the same table +# (scripts/surface.sh) so a log line and the roster row for one session cannot name +# different surfaces. Guarded: no surface.sh, no token - never a broken hook. +SURFACE="" +if [ -r "${PLUGIN_ROOT}/scripts/surface.sh" ]; then + . "${PLUGIN_ROOT}/scripts/surface.sh" + SURFACE=$(codex_surface_slug 2>/dev/null) +fi + if [ -z "${ROGUE_API_KEY:-}" ]; then log "outcome=unconfigured" echo '{}' @@ -81,9 +96,11 @@ fi . "${PLUGIN_ROOT}/scripts/actor.sh" -# Surface label (codex_app | codex_cli). Codex sets no app/cli entrypoint var, so -# the installer pins ROGUE_CODEX_SURFACE per surface; default to codex_cli. -SURFACE="${ROGUE_CODEX_SURFACE:-codex_cli}" +# SURFACE is already resolved above (scripts/surface.sh) and is reused verbatim for +# the header, so the header, the log token and the roster row are one value. The +# guard covers a damaged install where surface.sh was missing: the header has always +# carried a surface and must keep carrying one, where the log token is optional. +[ -n "$SURFACE" ] || SURFACE="codex_cli" URL="${ROGUE_API_URL:-${ROGUE_BASE_URL:-https://api.rogue.security}/api/v1/hooks/openai}" diff --git a/plugins/codex/scripts/surface.ps1 b/plugins/codex/scripts/surface.ps1 new file mode 100644 index 0000000..d0c585a --- /dev/null +++ b/plugins/codex/scripts/surface.ps1 @@ -0,0 +1,26 @@ +# Codex's SURFACE - the PowerShell half of surface.sh, kept in lockstep with it. +# +# hook.ps1 sends it as the x-rogue-agent header AND stamps it on each log line +# heartbeat.ps1 sends it as the roster agent +# +# Codex exposes no app/cli entrypoint variable of its own, so the installer pins +# ROGUE_CODEX_SURFACE per surface and everything reads that one value. +# +# slug | when +# -----------|------------------------------------------------------- +# codex_app | ROGUE_CODEX_SURFACE=codex_app +# codex_cli | ROGUE_CODEX_SURFACE=codex_cli, unset, or ANYTHING ELSE +# +# THE CLOSED LIST IS ENFORCED HERE. The value comes from an env file, so it is +# whatever someone wrote there: a space or an '=' would break the log line's +# key=value shape, and arbitrary text is exactly what this token must never carry. +# +# Takes the resolved credential map rather than reading $env: directly - the +# variable normally lives in ~/.rogue-env, which the process environment has not +# seen. Dot-sourced; Windows PowerShell 5.1 compatible. + +function Get-CodexSurfaceSlug { + param([hashtable]$Creds = @{}) + if ([string]$Creds['ROGUE_CODEX_SURFACE'] -eq 'codex_app') { return 'codex_app' } + return 'codex_cli' +} diff --git a/plugins/codex/scripts/surface.sh b/plugins/codex/scripts/surface.sh new file mode 100644 index 0000000..3196b58 --- /dev/null +++ b/plugins/codex/scripts/surface.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Codex's SURFACE — one table, three consumers. +# +# hook.sh sends it as the x-rogue-agent header AND stamps it on each log line +# heartbeat.sh sends it as the roster agent +# +# Codex exposes no app/cli entrypoint variable of its own, so the installer pins +# ROGUE_CODEX_SURFACE per surface and everything reads that one value. Keeping the +# read in one place is what stops a log line and the roster row for the same +# session from naming different surfaces. +# +# slug | when +# -----------|------------------------------------------------------- +# codex_app | ROGUE_CODEX_SURFACE=codex_app +# codex_cli | ROGUE_CODEX_SURFACE=codex_cli, unset, or ANYTHING ELSE +# +# THE CLOSED LIST IS ENFORCED HERE, and that is the point of the function rather +# than a bare `${ROGUE_CODEX_SURFACE:-codex_cli}`. The variable comes from an env +# file, so its value is whatever someone wrote there: a value with a space or an +# `=` would break the log line's `key=value` shape, and an arbitrary string on a +# line or in a roster row is exactly the kind of uncontrolled content this token +# must never carry. Anything unrecognised is treated as unset. +# +# Sourced, not executed. + +codex_surface_slug() { + case "${ROGUE_CODEX_SURFACE:-}" in + codex_app) printf 'codex_app' ;; + *) printf 'codex_cli' ;; + esac + return 0 +} diff --git a/plugins/copilot/plugin.json b/plugins/copilot/plugin.json index 2208561..d4a0e6b 100644 --- a/plugins/copilot/plugin.json +++ b/plugins/copilot/plugin.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.2.0", + "version": "1.2.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for GitHub Copilot CLI", "author": { "name": "Rogue Security", diff --git a/plugins/copilot/scripts/hook.ps1 b/plugins/copilot/scripts/hook.ps1 index 5ad6ed6..85b9f62 100644 --- a/plugins/copilot/scripts/hook.ps1 +++ b/plugins/copilot/scripts/hook.ps1 @@ -85,6 +85,10 @@ function ConvertFrom-ShellQuoted { # would make the log shipper and the dispatcher disagree on the path. # Declared (not resolved) at file scope so the ROGUE_PS_LIB_ONLY seam below can # dot-source the helpers, and so Log is safe to call before initialisation. +# The one surface this plugin has. A closed-vocabulary slug, lowercase, no space +# and no '=', so a reader finds the value by scanning to the next 'key=' token. It +# matches what heartbeat reports as the roster agent for this plugin. +$script:surface = 'github_copilot' $script:logFile = $null $script:logMaxBytes = 10485760 @@ -158,9 +162,13 @@ function Log { # produced by a rotation) would start with EF BB BF and fail any parser # that anchors on the timestamp. "`n" keeps the line ending identical to # what the sh dispatchers write, so one log format covers both platforms. + # A constant: this plugin has exactly one surface, so there is nothing to + # detect and nothing that can fail. Written through the same conditional as + # the multi-surface plugins so all six dispatchers share one emit shape. + $surfaceToken = if ($script:surface) { " surface=$($script:surface)" } else { '' } [System.IO.File]::AppendAllText( $logFile, - "$stamp provider=copilot event=$EventName $Msg`n", + "$stamp provider=copilot$surfaceToken event=$EventName $Msg`n", (New-Object System.Text.UTF8Encoding $false)) } catch {} } diff --git a/plugins/copilot/scripts/hook.sh b/plugins/copilot/scripts/hook.sh index 48f4ff8..60c7afb 100755 --- a/plugins/copilot/scripts/hook.sh +++ b/plugins/copilot/scripts/hook.sh @@ -83,6 +83,11 @@ rotate_log() { [ "$_lsz" -ge "$ROGUE_LOG_MAX_BYTES" ] && mv -f "$ROGUE_LOG_FILE" "$ROGUE_LOG_FILE.1" 2>/dev/null return 0 } +# The one surface this plugin has. A closed-vocabulary slug, lowercase, no space +# and no `=`, so a reader finds the value by scanning to the next `key=` token. It +# matches what heartbeat reports as the roster agent for this plugin. +SURFACE="github_copilot" + log() { # 0700 dir / 0600 file. The logged text is not only ours: it carries the # server's block reason, which quotes the content that tripped the rule - a @@ -94,8 +99,13 @@ log() { ( umask 077 mkdir -p "$(dirname "$ROGUE_LOG_FILE")" 2>/dev/null rotate_log - printf '%s provider=copilot event=%s %s\n' \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) + # SURFACE is a constant here: this plugin has exactly one surface, so there is + # nothing to detect and nothing that can fail. It is still written through the + # same `${SURFACE:+ …}` expansion as the multi-surface plugins so all six + # dispatchers share one emit shape. + printf '%s provider=copilot%s event=%s %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${SURFACE:+ surface=$SURFACE}" \ + "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) } sanitize() { printf '%s' "$1" | tr -d '\000-\037\177'; } diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json index a6ff757..a8a8d8c 100644 --- a/plugins/cursor/.cursor-plugin/plugin.json +++ b/plugins/cursor/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "Rogue Security", - "version": "1.1.0", + "version": "1.1.1", "description": "Rogue Security AIDR — real-time AI agent detection and response for Cursor", "author": { "name": "rogue-security", diff --git a/plugins/cursor/scripts/hook.ps1 b/plugins/cursor/scripts/hook.ps1 index c1950b4..3317c66 100644 --- a/plugins/cursor/scripts/hook.ps1 +++ b/plugins/cursor/scripts/hook.ps1 @@ -139,6 +139,10 @@ function Repair-DoubleEncodedUtf8 { # would make the log shipper and the dispatcher disagree on the path. # Declared (not resolved) at file scope so the ROGUE_PS_LIB_ONLY seam below can # dot-source the helpers, and so Log is safe to call before initialisation. +# The one surface this plugin has. A closed-vocabulary slug, lowercase, no space +# and no '=', so a reader finds the value by scanning to the next 'key=' token. It +# matches what heartbeat reports as the roster agent for this plugin. +$script:surface = 'cursor' $script:logFile = $null $script:logMaxBytes = 10485760 @@ -214,9 +218,13 @@ function Log { # produced by a rotation) would start with EF BB BF and fail any parser # that anchors on the timestamp. "`n" keeps the line ending identical to # what the sh dispatchers write, so one log format covers both platforms. + # A constant: this plugin has exactly one surface, so there is nothing to + # detect and nothing that can fail. Written through the same conditional as + # the multi-surface plugins so all six dispatchers share one emit shape. + $surfaceToken = if ($script:surface) { " surface=$($script:surface)" } else { '' } [System.IO.File]::AppendAllText( $logFile, - "$stamp provider=cursor event=$EventName $Msg`n", + "$stamp provider=cursor$surfaceToken event=$EventName $Msg`n", (New-Object System.Text.UTF8Encoding $false)) } catch {} } diff --git a/plugins/cursor/scripts/hook.sh b/plugins/cursor/scripts/hook.sh index 88e21f8..ad46da1 100755 --- a/plugins/cursor/scripts/hook.sh +++ b/plugins/cursor/scripts/hook.sh @@ -128,6 +128,11 @@ rotate_log() { [ "$_lsz" -ge "$ROGUE_LOG_MAX_BYTES" ] && mv -f "$ROGUE_LOG_FILE" "$ROGUE_LOG_FILE.1" 2>/dev/null return 0 } +# The one surface this plugin has. A closed-vocabulary slug, lowercase, no space +# and no `=`, so a reader finds the value by scanning to the next `key=` token. It +# matches what heartbeat reports as the roster agent for this plugin. +SURFACE="cursor" + log() { # 0700 dir / 0600 file. The logged text is not only ours: it carries the # server's block reason, which quotes the content that tripped the rule - a @@ -139,8 +144,13 @@ log() { ( umask 077 mkdir -p "$(dirname "$ROGUE_LOG_FILE")" 2>/dev/null rotate_log - printf '%s provider=cursor event=%s %s\n' \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$event" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) + # SURFACE is a constant here: this plugin has exactly one surface, so there is + # nothing to detect and nothing that can fail. It is still written through the + # same `${SURFACE:+ …}` expansion as the multi-surface plugins so all six + # dispatchers share one emit shape. + printf '%s provider=cursor%s event=%s %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${SURFACE:+ surface=$SURFACE}" \ + "$event" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) } # Strip control characters: the logged text is SERVER-CONTROLLED (a block reason # can carry anything), and a raw newline or CR would forge extra log lines. diff --git a/plugins/gemini/gemini-extension.json b/plugins/gemini/gemini-extension.json index de1b029..6f5862b 100644 --- a/plugins/gemini/gemini-extension.json +++ b/plugins/gemini/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.0.24", + "version": "1.0.25", "description": "Rogue Security AIDR — real-time AI agent detection and response for Gemini CLI", "contextFileName": "GEMINI.md" } diff --git a/plugins/gemini/scripts/hook.mjs b/plugins/gemini/scripts/hook.mjs index 0c6938f..6f2164f 100644 --- a/plugins/gemini/scripts/hook.mjs +++ b/plugins/gemini/scripts/hook.mjs @@ -30,6 +30,14 @@ const EVENT = process.argv[2] || "unknown"; // ("gemini_cli", which the server keys its version lookup on) — see // heartbeat.mjs. Keep the two independent. const PROVIDER = "gemini"; +// The one surface this extension has - Gemini CLI. A closed-vocabulary slug, +// lowercase, no space and no "=", so a reader finds the value by scanning to the +// next "key=" token, and it matches what heartbeat.mjs reports as the roster agent. +// A constant here: there is nothing to detect and nothing that can fail. It is +// still emitted through the same conditional the multi-surface plugins use, so all +// six dispatchers share one emit shape and an empty value omits the token entirely +// rather than writing "surface=" or "surface=unknown". +const SURFACE = "gemini_cli"; // ── Emit + exit ──────────────────────────────────────────────────────────── // stdout must be ONLY the final JSON object. Always exit 0 — a blocking verdict @@ -111,7 +119,12 @@ function log(msg) { fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true, mode: 0o700 }); rotateLog(); const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z"); - fs.appendFileSync(LOG_FILE, `${ts} provider=${PROVIDER} event=${EVENT} ${msg}\n`, { mode: 0o600 }); + const surfaceToken = SURFACE ? ` surface=${SURFACE}` : ""; + fs.appendFileSync( + LOG_FILE, + `${ts} provider=${PROVIDER}${surfaceToken} event=${EVENT} ${msg}\n`, + { mode: 0o600 }, + ); } catch { /* logging is best-effort */ } diff --git a/plugins/rogue/.claude-plugin/plugin.json b/plugins/rogue/.claude-plugin/plugin.json index 6d13bcb..9d944d0 100644 --- a/plugins/rogue/.claude-plugin/plugin.json +++ b/plugins/rogue/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.0.23", + "version": "1.0.24", "description": "Rogue Security AIDR \u2014 real-time AI agent detection and response for Claude Code", "author": { "name": "Rogue Security", diff --git a/plugins/rogue/scripts/heartbeat.ps1 b/plugins/rogue/scripts/heartbeat.ps1 index 8996c3e..f770464 100644 --- a/plugins/rogue/scripts/heartbeat.ps1 +++ b/plugins/rogue/scripts/heartbeat.ps1 @@ -98,10 +98,20 @@ if (Test-Path -LiteralPath $pj) { } # -- agent display label from entrypoint (family is the fixed enum "claude") - -$ep = ([string]$env:CLAUDE_CODE_ENTRYPOINT).ToLower() -if ($ep -like '*cowork*') { $agent = 'Claude Cowork' } -elseif ($ep -like '*desktop*') { $agent = 'Claude Code - Desktop' } -else { $agent = 'Claude Code - CLI' } +# One table, in scripts/surface.ps1, shared with hook.ps1 - which stamps the +# matching SLUG on each log line. Two copies of this mapping would eventually +# drift, and a log line naming a different surface than the roster row for the same +# session is worse than a line that names none. The literal below is a last-resort +# guard for a damaged install, not a second copy of the mapping. +$agent = '' +try { + $surfaceLib = Join-Path $pluginRoot 'scripts\surface.ps1' + if (Test-Path -LiteralPath $surfaceLib) { + . $surfaceLib + $agent = [string](Get-RogueSurfaceLabel) + } +} catch { $agent = '' } +if (-not $agent) { $agent = 'Claude Code - CLI' } $host_ = $env:COMPUTERNAME; if (-not $host_) { try { $host_ = [System.Net.Dns]::GetHostName() } catch { $host_ = 'unknown' } } diff --git a/plugins/rogue/scripts/heartbeat.sh b/plugins/rogue/scripts/heartbeat.sh index cb04050..27e934a 100755 --- a/plugins/rogue/scripts/heartbeat.sh +++ b/plugins/rogue/scripts/heartbeat.sh @@ -53,11 +53,16 @@ fi # x-rogue-agent header (free-form display label), derived from # CLAUDE_CODE_ENTRYPOINT (the same var hook.sh uses to tell GUI from cli). # Unknown → CLI. -case "$(printf '%s' "${CLAUDE_CODE_ENTRYPOINT:-}" | tr '[:upper:]' '[:lower:]')" in - *cowork*) AGENT="Claude Cowork" ;; - *desktop*) AGENT="Claude Code - Desktop" ;; - *) AGENT="Claude Code - CLI" ;; -esac +# One table, in scripts/surface.sh, shared with hook.sh - which stamps the matching +# SLUG on each log line. A `case` copied into both would eventually drift, and a log +# line naming a different surface than the roster row for the same session is worse +# than a line that names none. The literal below is a last-resort guard for a +# damaged install (missing file), not a second copy of the mapping. +if [ -r "${CLAUDE_PLUGIN_ROOT:-}/scripts/surface.sh" ]; then + . "${CLAUDE_PLUGIN_ROOT}/scripts/surface.sh" + AGENT=$(rogue_surface_label 2>/dev/null) +fi +[ -n "${AGENT:-}" ] || AGENT="Claude Code - CLI" # POST /api/v1/hooks/status (GET route is gone). The former x-rogue-agent-* # headers now ride the JSON body; x-rogue-api-key stays a header. Backslash- and diff --git a/plugins/rogue/scripts/hook.ps1 b/plugins/rogue/scripts/hook.ps1 index a077f0b..b71f7ed 100644 --- a/plugins/rogue/scripts/hook.ps1 +++ b/plugins/rogue/scripts/hook.ps1 @@ -132,6 +132,13 @@ function Repair-DoubleEncodedUtf8 { # would make the log shipper and the dispatcher disagree on the path. # Declared (not resolved) at file scope so the ROGUE_PS_LIB_ONLY seam below can # dot-source the helpers, and so Log is safe to call before initialisation. +# Which SURFACE of Claude wrote each line - cli, desktop or cowork. One file per +# agent family means every surface on the machine appends to the same claude.log, +# and nothing on the line said which one. The mapping is shared with heartbeat.ps1 +# (scripts/surface.ps1) so a line and the roster row for the same session can never +# name different surfaces. An empty value OMITS the token; it is never written as +# `surface=` or `surface=unknown`. +$script:surface = '' $script:logFile = $null $script:logMaxBytes = 10485760 @@ -205,9 +212,12 @@ function Log { # produced by a rotation) would start with EF BB BF and fail any parser # that anchors on the timestamp. "`n" keeps the line ending identical to # what the sh dispatchers write, so one log format covers both platforms. + # Empty slug -> empty string, so the line is byte-identical to what an + # older version wrote. Optional means optional. + $surfaceToken = if ($script:surface) { " surface=$($script:surface)" } else { '' } [System.IO.File]::AppendAllText( $logFile, - "$stamp provider=claude event=$EventName $Msg`n", + "$stamp provider=claude$surfaceToken event=$EventName $Msg`n", (New-Object System.Text.UTF8Encoding $false)) } catch {} } @@ -242,6 +252,18 @@ $pluginRoot = $env:CLAUDE_PLUGIN_ROOT if (-not $pluginRoot) { try { $pluginRoot = (Get-Location).Path } catch { $pluginRoot = '.' } } Dbg "pluginRoot=$pluginRoot" +# Resolve the surface before the first Log call. Guarded on both sides: a damaged +# install with no surface.ps1, or a resolver that throws, leaves the slug empty and +# the token is simply omitted - logging must never change the hook's outcome. +try { + $surfaceLib = Join-Path $pluginRoot 'scripts\surface.ps1' + if (Test-Path -LiteralPath $surfaceLib) { + . $surfaceLib + $script:surface = [string](Get-RogueSurfaceSlug) + } +} catch { $script:surface = '' } +Dbg "surface=$($script:surface)" + # -- credential resolution (later file wins; process env wins over all) ----- $creds = @{} $credFiles = @( diff --git a/plugins/rogue/scripts/hook.sh b/plugins/rogue/scripts/hook.sh index 48825a4..ce45d30 100644 --- a/plugins/rogue/scripts/hook.sh +++ b/plugins/rogue/scripts/hook.sh @@ -18,6 +18,20 @@ esac [ -z "${CLAUDE_CODE_ENTRYPOINT:-}" ] && echo '{}' && exit 0 +# Which SURFACE of Claude wrote this line - cli, desktop or cowork. One file per +# agent family means every surface on the machine appends to the same claude.log, +# and nothing on the line said which one. The mapping is shared with heartbeat.sh +# (see scripts/surface.sh) precisely so the line and the roster row it belongs to +# can never name different surfaces. Sourcing is guarded and the result may be +# empty; an empty SURFACE omits the token rather than writing surface=unknown. +# Unreachable here in practice: the gate above already returned when the +# entrypoint was unset, and any non-empty value maps to a slug. +SURFACE="" +if [ -r "${CLAUDE_PLUGIN_ROOT}/scripts/surface.sh" ]; then + . "${CLAUDE_PLUGIN_ROOT}/scripts/surface.sh" + SURFACE=$(rogue_surface_slug 2>/dev/null) +fi + # Log destination — ONE FILE PER AGENT. Every Rogue plugin shares ~/.rogue, so a # machine running Claude Code + Codex + Cursor + … used to interleave all of them # into a single hook.log with no way to tell whose line was whose. Precedence: @@ -67,8 +81,12 @@ log() { ( umask 077 mkdir -p "$(dirname "$ROGUE_LOG_FILE")" 2>/dev/null rotate_log - printf '%s provider=claude event=%s %s\n' \ - "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) + # `${SURFACE:+ surface=$SURFACE}` expands to NOTHING when the slug is empty, + # so an undetermined surface leaves the line exactly as older versions wrote + # it - the token is optional, never `surface=` and never `surface=unknown`. + printf '%s provider=claude%s event=%s %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${SURFACE:+ surface=$SURFACE}" \ + "$EVENT" "$*" >> "$ROGUE_LOG_FILE" 2>/dev/null ) } sanitize() { printf '%s' "$1" | tr -d '\000-\037\177'; } diff --git a/plugins/rogue/scripts/surface.ps1 b/plugins/rogue/scripts/surface.ps1 new file mode 100644 index 0000000..c1ad324 --- /dev/null +++ b/plugins/rogue/scripts/surface.ps1 @@ -0,0 +1,44 @@ +# Claude's SURFACE, derived from CLAUDE_CODE_ENTRYPOINT - the PowerShell half of +# surface.sh, and kept in lockstep with it. +# +# hook.ps1 stamps the SLUG on every log line (surface=cli) +# heartbeat.ps1 sends the LABEL as the roster agent (Claude Code - CLI) +# +# They must never disagree: a log line and the roster row it belongs to would then +# name different surfaces for one session, which is worse than the log saying +# nothing at all. Add a surface in surface.sh AND here. +# +# slug | label | when +# ---------|-----------------------|-------------------------------------- +# cowork | Claude Cowork | entrypoint contains "cowork" +# desktop | Claude Code - Desktop | entrypoint contains "desktop" +# cli | Claude Code - CLI | any other NON-EMPTY entrypoint +# (none) | Claude Code - CLI | entrypoint empty or unset +# +# Slugs are lowercase, contain no space and no '=', so a reader can find the value +# by scanning to the next 'key=' token. They are a closed set: never a path, a user +# name, a host, or anything else read off the environment. +# +# An EMPTY entrypoint yields an EMPTY slug and the caller omits the token entirely - +# the log never says surface=unknown. It cannot arise on the logging path (both +# dispatchers exit before their first log line when the variable is unset). The +# LABEL side still defaults to the CLI, because the roster field is required. +# +# Dot-sourced, so it defines functions and does nothing else. Windows PowerShell +# 5.1 compatible. + +function Get-RogueSurfaceSlug { + $entryPoint = ([string]$env:CLAUDE_CODE_ENTRYPOINT).ToLower() + if (-not $entryPoint) { return '' } + if ($entryPoint -like '*cowork*') { return 'cowork' } + if ($entryPoint -like '*desktop*') { return 'desktop' } + return 'cli' +} + +function Get-RogueSurfaceLabel { + switch (Get-RogueSurfaceSlug) { + 'cowork' { return 'Claude Cowork' } + 'desktop' { return 'Claude Code - Desktop' } + default { return 'Claude Code - CLI' } + } +} diff --git a/plugins/rogue/scripts/surface.sh b/plugins/rogue/scripts/surface.sh new file mode 100644 index 0000000..773a506 --- /dev/null +++ b/plugins/rogue/scripts/surface.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Claude's SURFACE, derived from CLAUDE_CODE_ENTRYPOINT — one table, two consumers. +# +# hook.sh stamps the SLUG on every log line (surface=cli) +# heartbeat.sh sends the LABEL as the roster agent (Claude Code - CLI) +# +# They must never disagree: a log line and the roster row it belongs to would then +# name different surfaces for one session, which is worse than the log saying +# nothing at all. That is the whole reason this mapping lives in its own file +# instead of as a `case` copy-pasted into both scripts. Add a surface HERE. +# +# slug | label | when +# ---------|-----------------------|-------------------------------------- +# cowork | Claude Cowork | entrypoint contains "cowork" +# desktop | Claude Code - Desktop | entrypoint contains "desktop" +# cli | Claude Code - CLI | any other NON-EMPTY entrypoint +# (none) | Claude Code - CLI | entrypoint empty or unset +# +# Slugs are lowercase, contain no space and no `=`, so a reader can find the value +# by scanning to the next `key=` token. They are a closed set: never a path, a user +# name, a host, or anything else read off the environment. +# +# An EMPTY entrypoint yields an EMPTY slug, and the caller then omits the token +# entirely — the log never says `surface=unknown`. It cannot actually arise on the +# logging path (hook.sh and hook.ps1 both `exit 0` before their first log line when +# the variable is unset), so the omission is a guarantee about a case that does not +# occur rather than a state to design around. The LABEL side still defaults to the +# CLI, because the roster field is required and must carry something. +# +# Sourced, not executed: it defines functions and returns. + +rogue_surface_slug() { + case "$(printf '%s' "${CLAUDE_CODE_ENTRYPOINT:-}" | tr '[:upper:]' '[:lower:]')" in + '') ;; + *cowork*) printf 'cowork' ;; + *desktop*) printf 'desktop' ;; + *) printf 'cli' ;; + esac + return 0 +} + +rogue_surface_label() { + case "$(rogue_surface_slug)" in + cowork) printf 'Claude Cowork' ;; + desktop) printf 'Claude Code - Desktop' ;; + *) printf 'Claude Code - CLI' ;; + esac + return 0 +} diff --git a/tests/log_probe.ps1 b/tests/log_probe.ps1 index c6cac20..6d5cd13 100644 --- a/tests/log_probe.ps1 +++ b/tests/log_probe.ps1 @@ -23,7 +23,8 @@ param( # ship transcript tails and subagent names base64-encoded. [string]$CredsB64 = '', # base64 of a JSON object of ROGUE_* values [int]$SeedBytes = 0, # >0: pre-fill the log - [string]$SeedPrevious = '' # non-empty: also create .1 + [string]$SeedPrevious = '', # non-empty: also create .1 + [string]$Surface = '' # override $script:surface before Log ) # Parse BEFORE dot-sourcing, and never name this parameter `$Creds`: dot-sourcing @@ -31,6 +32,14 @@ param( # file-scope `$creds = @{}`. PowerShell variable names are case-INSENSITIVE, so # that assignment would silently overwrite our own parameter with an empty # hashtable and every override in it would vanish. +# Snapshot BEFORE the dot-source, for exactly the reason spelled out above: the +# dispatcher declares a file-scope `$script:surface`, PowerShell variable names are +# case-INSENSITIVE, and dot-sourcing therefore overwrites this script's own +# `$Surface` parameter with the dispatcher's value. Read back afterwards it always +# equalled the default the dispatcher had just set, so the override silently did +# nothing - the same trap `$Creds` -> `$map` avoids one line below. +$surfaceOverride = $Surface + $map = @{} $credsJson = '{}' if ($CredsB64) { @@ -55,6 +64,15 @@ if ($SeedBytes -gt 0) { # -NoNewline so the byte count is exact; rotation compares against Length. Set-Content -LiteralPath $logFile -Value ('s' * $SeedBytes) -NoNewline } +# The surface is resolved in the MAIN BODY (below the seam), which never runs here, +# so each dispatcher's file-scope default is what a probe sees: the constant for a +# single-surface plugin, empty for one that detects per session. `-Surface` sets it +# explicitly so the EMIT shape - token present, token omitted - is testable; the +# resolution itself is covered separately, against surface.ps1 and against the +# main body's wiring. +if ($surfaceOverride -ne '') { $script:surface = $surfaceOverride } +"SURFACE=$($script:surface)" + Log 'outcome=probe' $lines = @(Get-Content -LiteralPath $logFile -ErrorAction SilentlyContinue) diff --git a/tests/test_hook_logs.ps1 b/tests/test_hook_logs.ps1 index b4d6284..03d6905 100644 --- a/tests/test_hook_logs.ps1 +++ b/tests/test_hook_logs.ps1 @@ -53,6 +53,7 @@ function Invoke-Probe { [hashtable]$Creds = @{}, [int]$SeedBytes = 0, [string]$SeedPrevious = '', + [string]$Surface = '', # override the dispatcher's $script:surface [switch]$NoUserProfile # prove the USERPROFILE -> $HOME fallback ) @@ -82,6 +83,10 @@ function Invoke-Probe { [System.Text.Encoding]::UTF8.GetBytes(($Creds | ConvertTo-Json -Compress))), '-SeedBytes', $SeedBytes) if ($SeedPrevious) { $argv += @('-SeedPrevious', $SeedPrevious) } + # Appended only when set: Windows PowerShell 5.1 DROPS an empty native-command + # argument, which would shift every later positional and is why the sibling + # arguments above are conditional too. + if ($Surface -ne '') { $argv += @('-Surface', $Surface) } $out = & (Get-Process -Id $PID).Path @argv 2>$null } finally { foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) } @@ -180,7 +185,11 @@ foreach ($c in $cases) { $f = Invoke-Probe -Case $c -CaseHome $h # A UTC ISO-8601 second-precision stamp with no fractional part: the shipper's # line splitter and the backend's parser both key off it. - $want = "^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z provider=$($c.slug) event=$($c.event) outcome=probe$" + # `surface=` is OPTIONAL, so the shape allows it and the dedicated section + # below pins which plugin emits which slug. Through the seam a single-surface + # plugin shows its constant while a per-session one is still empty, and both + # are well-formed lines. + $want = "^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z provider=$($c.slug)( surface=[a-z_]+)? event=$($c.event) outcome=probe$" # The FIRST physical line, not $f['LAST']: a dispatcher that emitted a # leading blank line (or any preamble) would still put a well-formed record # last and pass. The probe fires one event, so line 0 IS that record. @@ -192,6 +201,61 @@ foreach ($c in $cases) { else { Pass "$($c.slug) log has no UTF-8 BOM (head: $($f['HEAD']))" } } +Write-Host "" +Write-Host "== the optional surface token" +# Each dispatcher's file-scope default is what a probe sees: a single-surface plugin +# hard-codes its slug, while one that detects per session leaves it empty until the +# MAIN BODY resolves it (below the seam, so never here). Both are asserted, then the +# emit shape is forced both ways with -Surface. +$surfaceDefaults = @{ + claude = ''; codex = ''; antigravity = '' # resolved per session in the main body + cursor = 'cursor'; copilot = 'github_copilot' +} +foreach ($c in $cases) { + $h = New-CaseHome "surface-$($c.slug)"; $homes += $h + $f = Invoke-Probe -Case $c -CaseHome $h + Check "$($c.slug) file-scope surface default" $surfaceDefaults[$c.slug] $f['SURFACE'] + + # A resolved surface lands directly after provider= and before event=, so a + # reader scanning for the next `key=` gets the whole value. + $f = Invoke-Probe -Case $c -CaseHome $h -Surface 'desktop' + $want = "^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z provider=$($c.slug) surface=desktop event=$($c.event) outcome=probe$" + if (@([System.IO.File]::ReadAllLines($f['LOGFILE']))[-1] -match $want) { + Pass "$($c.slug) stamps surface= between provider= and event=" + } else { Fail "$($c.slug) surface placement [$($f['LAST'])]" } + + # An UNDETERMINED surface omits the whole token. `surface=` with nothing after + # it, or `surface=unknown`, would both be worse than saying nothing: a reader + # cannot tell either from a real value, and every line written by a version + # before this one has no token at all. + $f = Invoke-Probe -Case $c -CaseHome $h -Surface '' + $wantBare = "^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z provider=$($c.slug) event=$($c.event) outcome=probe$" + if (@([System.IO.File]::ReadAllLines($f['LOGFILE']))[-1] -match $wantBare) { + Pass "$($c.slug) omits the token when the surface is unknown" + } else { Fail "$($c.slug) emitted a placeholder surface [$($f['LAST'])]" } +} + +Write-Host "" +Write-Host "== claude's surface table is ONE table, shared with the heartbeat" +# The whole point of plugins/rogue/scripts/surface.ps1: hook.ps1 stamps the slug and +# heartbeat.ps1 sends the label, and a copy in each would eventually drift - leaving +# a log line and the roster row for one session naming different surfaces. Mirrors +# the sh half in tests/test_hook_logs.sh. +. (Join-Path $repo 'plugins/rogue/scripts/surface.ps1') +foreach ($row in @( + @{ ep = 'cli'; slug = 'cli'; label = 'Claude Code - CLI' }, + @{ ep = 'desktop'; slug = 'desktop'; label = 'Claude Code - Desktop' }, + @{ ep = 'cowork'; slug = 'cowork'; label = 'Claude Cowork' }, + @{ ep = 'CLI'; slug = 'cli'; label = 'Claude Code - CLI' }, + @{ ep = 'vscode-extension'; slug = 'cli'; label = 'Claude Code - CLI' }, + @{ ep = ''; slug = ''; label = 'Claude Code - CLI' } +)) { + $env:CLAUDE_CODE_ENTRYPOINT = $row.ep + Check "entrypoint '$($row.ep)' -> slug '$($row.slug)'" $row.slug ([string](Get-RogueSurfaceSlug)) + Check "entrypoint '$($row.ep)' -> label '$($row.label)'" $row.label ([string](Get-RogueSurfaceLabel)) +} +Remove-Item Env:CLAUDE_CODE_ENTRYPOINT -ErrorAction SilentlyContinue + Write-Host "" Write-Host "== rotation at the cap, including over an EXISTING .1" foreach ($c in $cases) { diff --git a/tests/test_hook_logs.sh b/tests/test_hook_logs.sh index 12e1da8..cf50702 100755 --- a/tests/test_hook_logs.sh +++ b/tests/test_hook_logs.sh @@ -98,27 +98,95 @@ for slug in $SLUGS; do check "$slug writes only ~/.rogue/logs/$slug.log" "/.rogue/logs/$slug.log " "$(tree_of "$home")" done +# The surface each dispatcher stamps on an UNCONFIGURED probe, i.e. with the +# harness's own environment. Empty means "no token on that line", which is a real +# expected value and not a gap: +# claude - CLAUDE_CODE_ENTRYPOINT is set by `fire` below, as the real client does +# codex - ROGUE_CODEX_SURFACE unset defaults to codex_cli, matching heartbeat.sh +# cursor / copilot / gemini - single-surface plugins, so a constant +# antigravity - resolved from the payload's transcriptPath, and the unconfigured +# path exits BEFORE stdin is read, so there is nothing to resolve from. The +# token is optional precisely so this line can simply omit it. +surface_for() { + case "$1" in + claude) echo cli ;; + codex) echo codex_cli ;; + cursor) echo cursor ;; + copilot) echo github_copilot ;; + gemini) echo gemini_cli ;; + antigravity) echo '' ;; + esac +} + echo -echo "== line format: ' provider= event= …'" +echo "== line format: ' provider= [surface=] event= …'" for slug in $SLUGS; do home="$TMPROOT/fmt-$slug"; mkdir -p "$home" ev=$(event_for "$slug") + sf=$(surface_for "$slug") fire "$slug" "$home" line=$(cat "$home/.rogue/logs/$slug.log" 2>/dev/null) # Timestamp must be a UTC ISO-8601 second-precision stamp, no fractional part: - # the shipper's line splitter and the backend's parser both key off it. + # the shipper's line splitter and the backend's parser both key off it. The + # surface token sits directly after provider= and before event=, so a reader + # scanning for the next `key=` finds the whole value. case "$line" in - [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z" provider=$slug event=$ev "*) - pass "$slug line is ' provider=$slug event=$ev …'" ;; + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z" provider=$slug${sf:+ surface=$sf} event=$ev "*) + pass "$slug line is ' provider=$slug${sf:+ surface=$sf} event=$ev …'" ;; *) fail "$slug line shape [$line]" ;; esac + # An undetermined surface omits the WHOLE token. `surface=` with nothing after + # it, or `surface=unknown`, would both be worse than saying nothing - a reader + # cannot tell either from a real value. + case "$line" in + *"surface="*) + if [ -n "$sf" ]; then pass "$slug stamped surface=$sf" + else fail "$slug emitted a surface token with no surface to report [$line]"; fi ;; + *) + if [ -n "$sf" ]; then fail "$slug lost its surface token [$line]" + else pass "$slug omits the token when the surface is unknown"; fi ;; + esac + case "$line" in + *surface=unknown*|*"surface= "*) fail "$slug wrote a placeholder surface [$line]" ;; + *) pass "$slug never writes a placeholder surface" ;; + esac case "$line" in *outcome=unconfigured*) pass "$slug logs outcome=unconfigured with no API key" ;; *) fail "$slug missing outcome=unconfigured [$line]" ;; esac done +echo +echo "== the surface token tracks the SURFACE, not the plugin" +# claude is the only plugin whose surface varies per session, and all three of its +# surfaces write to the SAME claude.log - which is the entire reason the token +# exists. The mapping is shared with heartbeat.sh (plugins/rogue/scripts/surface.sh), +# so these slugs and the roster labels cannot drift apart. +# Indexed, not named after the entrypoint: macOS is case-insensitive, so `cli` and +# `CLI` would share one sandbox and the second case would read the first's line. +_sfi=0 +for pair in 'cli:cli' 'desktop:desktop' 'cowork:cowork' 'vscode-extension:cli' 'CLI:cli'; do + ep=${pair%%:*}; want=${pair#*:} + _sfi=$((_sfi + 1)) + home="$TMPROOT/sf-$_sfi"; mkdir -p "$home" + ( cd "$REPO" || exit 1 + export HOME="$home" ROGUE_API_KEY='' ROGUE_LOG_FILE='' ROGUE_LOG_DIR='' ROGUE_LOG_MAX_BYTES='' + export CLAUDE_CODE_ENTRYPOINT="$ep" CLAUDE_PLUGIN_ROOT="$REPO/plugins/rogue" + printf '{}' | "$SH" "$REPO/plugins/rogue/scripts/hook.sh" PreToolUse >/dev/null 2>&1 ) + got=$(sed -n 's/.*provider=claude surface=\([a-z_]*\) event=.*/\1/p' "$home/.rogue/logs/claude.log" 2>/dev/null) + check "CLAUDE_CODE_ENTRYPOINT=$ep stamps surface=$want" "$want" "$got" +done + +# The heartbeat's roster label for the same entrypoint, from the same table. If +# these two ever disagree, a log line and the roster row for one session name +# different surfaces - worse than the line naming none. +for pair in 'cli:Claude Code - CLI' 'desktop:Claude Code - Desktop' 'cowork:Claude Cowork'; do + ep=${pair%%:*}; want=${pair#*:} + got=$(CLAUDE_CODE_ENTRYPOINT="$ep" "$SH" -c '. "$1"; rogue_surface_label' _ "$REPO/plugins/rogue/scripts/surface.sh" 2>/dev/null) + check "...and heartbeat reports \"$want\" for it" "$want" "$got" +done + echo echo "== ROGUE_LOG_DIR relocates, keeping the per-agent basename" for slug in $SLUGS; do diff --git a/tests/test_hook_mjs.mjs b/tests/test_hook_mjs.mjs index 9e2bb36..4078be9 100644 --- a/tests/test_hook_mjs.mjs +++ b/tests/test_hook_mjs.mjs @@ -66,6 +66,66 @@ function startServer(status, body) { }); } +// Same as runHook, but keeps the sandbox long enough to read the hook log back. +// The log is the whole subject of the surface tests below, and runHook deletes it. +function runHookReadLog(event, payload, env) { + return new Promise((resolve) => { + const home = freshHome(); + const child = spawn(process.execPath, [HOOK, event], { + env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, ...env }, + }); + let out = ""; + child.stdout.on("data", (c) => (out += c)); + child.on("close", () => { + const logFile = path.join(home, ".rogue", "logs", "gemini.log"); + const lines = fs.existsSync(logFile) + ? fs.readFileSync(logFile, "utf8").split("\n").filter(Boolean) + : []; + fs.rmSync(home, { recursive: true, force: true }); + resolve({ out, lines }); + }); + child.stdin.end(payload ?? ""); + }); +} + +// One file per agent family means every surface of that family appends to the same +// gemini.log, and until this token nothing on the line said which one wrote it. +// Gemini has exactly ONE surface, so the value is a constant - but it must still +// appear, be spelled the way the heartbeat spells it, and sit where the sh and +// PowerShell dispatchers put it, or a reader cannot use one rule for all six. +test("every line carries surface=gemini_cli, between provider= and event=", async () => { + const { lines } = await runHookReadLog("BeforeTool", "{}", {}); + assert.equal(lines.length, 1); + assert.match( + lines[0], + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z provider=gemini surface=gemini_cli event=BeforeTool outcome=unconfigured$/, + ); +}); + +test("the surface slug is what heartbeat.mjs reports as the roster agent", async () => { + // Two files, one vocabulary. If these drift, a log line and the roster row for + // the same install name different surfaces - worse than the line naming none. + const heartbeat = fs.readFileSync( + path.join(path.dirname(HOOK), "heartbeat.mjs"), + "utf8", + ); + assert.match(heartbeat, /agent:\s*"gemini_cli"/); + const hook = fs.readFileSync(HOOK, "utf8"); + assert.match(hook, /const SURFACE = "gemini_cli";/); +}); + +test("the token is emitted through the optional form, never as a placeholder", async () => { + // The other five dispatchers omit the whole token when they cannot determine a + // surface. Gemini always can, so the guard here is that the emit is written in + // that same conditional form rather than pasted into the template - a constant + // that is later made conditional must not start writing `surface=` or + // `surface=unknown`, both of which a reader cannot tell from a real value. + const hook = fs.readFileSync(HOOK, "utf8"); + assert.match(hook, /SURFACE \? ` surface=\$\{SURFACE\}` : ""/); + const { lines } = await runHookReadLog("BeforeTool", "{}", {}); + assert.doesNotMatch(lines[0], /surface=unknown|surface=(\s|$)/); +}); + test("no API key → fail-open {}", async () => { const out = await runHook("BeforeAgent", '{"prompt":"hi"}', {}); assert.equal(out, "{}"); diff --git a/tests/test_hook_sh_antigravity.sh b/tests/test_hook_sh_antigravity.sh index f43476c..176b06e 100755 --- a/tests/test_hook_sh_antigravity.sh +++ b/tests/test_hook_sh_antigravity.sh @@ -196,14 +196,20 @@ EOF chmod +x "$STAGE/scripts/hook.sh" "$STAGE/scripts/heartbeat.sh" # Run the staged hook.sh (PLUGIN_ROOT resolves to $STAGE) and echo its exit code. +# The hook log goes to a path OUTSIDE the per-run sandbox, so an assertion can read +# the line back after the run (the sandbox is deleted below). Truncated per call so +# each case reads only its own output. +LAST_LOG="$(mktemp -d)/antigravity.log" + run_staged() { local tmp_home rc tmp_home="$(mktemp -d)" cp "$ENV_FILE" "$tmp_home/.rogue-env" + : > "$LAST_LOG" set +e HOME="$tmp_home" \ ROGUE_API_KEY='' ROGUE_ACTOR_EMAIL='' ROGUE_ACTOR_NAME='' ROGUE_BASE_URL='' \ - ROGUE_LOG_FILE="$tmp_home/hook.log" \ + ROGUE_LOG_FILE="$LAST_LOG" \ "$SH" "$STAGE/scripts/hook.sh" "$1" <<< "$2" > "$OUT_FILE" rc=$? set -e @@ -243,6 +249,14 @@ for surface in antigravity_cli antigravity_ide antigravity; do assert_eq "$rc" "0" "$surface heartbeat exits 0" for _ in $(seq 1 30); do [ -s "$MARKER" ] && break; sleep 0.1; done assert_eq "$(cat "$MARKER" 2>/dev/null)" "$surface" "heartbeat is told the $surface surface" + # The SAME resolution stamps the log line. One value, two consumers: if these + # ever disagree, a line and the roster row for one session name different + # surfaces - worse than the line naming none. (The unconfigured path has no + # payload to resolve from and correctly emits no token at all; that case is + # covered in tests/test_hook_logs.sh.) + logged=$(sed -n 's/.*provider=antigravity surface=\([a-z_]*\) event=.*/\1/p' \ + "$LAST_LOG" 2>/dev/null | tail -1) + assert_eq "$logged" "$surface" "the log line is stamped surface=$surface" done rm -rf "$STAGE" From 48fa59d5705ed9f62865821a7dbd1624bd5de81d Mon Sep 17 00:00:00 2001 From: amos Date: Sun, 16 Aug 2026 12:13:44 +0300 Subject: [PATCH 2/3] [FIRE-1932] test: Windows PowerShell 5.1 drops the empty -Surface argument The new "omits the token" case passed `-Surface ''` to the probe. 5.1 drops an empty native-command argument, so the child saw a bare `-Surface` and refused to bind it: log_probe.ps1 : Missing an argument for parameter 'Surface'. Which is the trap the sibling arguments in this harness are already conditional for, and which pwsh 7 does not reproduce - so it only surfaced on the windows-latest job. A '' sentinel carries "force it empty" instead. Co-Authored-By: Claude Opus 5 (1M context) --- tests/log_probe.ps1 | 8 +++++++- tests/test_hook_logs.ps1 | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/log_probe.ps1 b/tests/log_probe.ps1 index 6d5cd13..7ad633c 100644 --- a/tests/log_probe.ps1 +++ b/tests/log_probe.ps1 @@ -24,6 +24,10 @@ param( [string]$CredsB64 = '', # base64 of a JSON object of ROGUE_* values [int]$SeedBytes = 0, # >0: pre-fill the log [string]$SeedPrevious = '', # non-empty: also create .1 + # '' = leave the dispatcher's own value alone; '' = force it + # EMPTY. A literal '' cannot be used for the second case: Windows PowerShell 5.1 + # DROPS an empty native-command argument, so `-Surface ''` reaches this script as + # a bare `-Surface` and fails to bind ("Missing an argument for parameter"). [string]$Surface = '' # override $script:surface before Log ) @@ -70,7 +74,9 @@ if ($SeedBytes -gt 0) { # explicitly so the EMIT shape - token present, token omitted - is testable; the # resolution itself is covered separately, against surface.ps1 and against the # main body's wiring. -if ($surfaceOverride -ne '') { $script:surface = $surfaceOverride } +if ($surfaceOverride -ne '') { + $script:surface = if ($surfaceOverride -eq '') { '' } else { $surfaceOverride } +} "SURFACE=$($script:surface)" Log 'outcome=probe' diff --git a/tests/test_hook_logs.ps1 b/tests/test_hook_logs.ps1 index 03d6905..1a28317 100644 --- a/tests/test_hook_logs.ps1 +++ b/tests/test_hook_logs.ps1 @@ -228,7 +228,7 @@ foreach ($c in $cases) { # it, or `surface=unknown`, would both be worse than saying nothing: a reader # cannot tell either from a real value, and every line written by a version # before this one has no token at all. - $f = Invoke-Probe -Case $c -CaseHome $h -Surface '' + $f = Invoke-Probe -Case $c -CaseHome $h -Surface '' # see log_probe.ps1: 5.1 drops an empty argument $wantBare = "^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z provider=$($c.slug) event=$($c.event) outcome=probe$" if (@([System.IO.File]::ReadAllLines($f['LOGFILE']))[-1] -match $wantBare) { Pass "$($c.slug) omits the token when the surface is unknown" From b226010c8a62cabc0cee1d928442051e032615ef Mon Sep 17 00:00:00 2001 From: amos Date: Sun, 16 Aug 2026 14:42:02 +0300 Subject: [PATCH 3/3] [FIRE-1932] test: a manual harness for running the working tree against a local API live_session.sh installs `git archive HEAD` and asserts against a fake node receiver, then tears everything down. Neither half fits the case this adds: exercising UNCOMMITTED plugin changes, interactively, against a backend the developer is running themselves. local_api.sh installs the working tree as a `rogue-localdev` marketplace and leaves it installed, so ordinary sessions drive it. Three details are the reason it is a script rather than a paragraph of instructions: * The install COPIES the tree, so editing plugins/rogue changes nothing until the copy is refreshed - verified, not assumed. `sync` refreshes the installed copy in place, which keeps the install record, version and enabled state intact and takes effect on the next hook invocation. * Two enabled rogue plugins answer every event twice. `up` disables rogue@rogue-marketplace and records that it did, so `down` re-enables only what it actually disabled. * Credentials have to go in ~/.rogue-env, not the process environment: hook.sh sources the env files after the process environment is already set, with no save/restore, so the file's ROGUE_API_KEY wins. The original is backed up, and a second `up` will not clobber that backup. `check` reads the log a real session wrote and asserts this branch's contract on it: the token present, in position, from the closed list, never `unknown`, never empty. Two of those assertions were written the weak way first and fixed after a deliberately malformed log failed to trip them - the position check passed as soon as ONE line was right, and the empty-token check anchored on a trailing space so it missed an empty value at end of line. A non-local --url is refused: this file is read by all six plugins, so a typo there sends every agent's prompts and tool calls somewhere unintended. The IPv6 arm escapes its brackets, since `[::1]` is a glob bracket expression and the unescaped form never matches the address it names. tests/manual/*.sh now joins the shell-parse job. Nothing else in CI reads those files, so a parse error surfaces only when someone reaches for the harness - exactly when they can least afford to debug it. --- .github/workflows/validate.yml | 7 +- tests/manual/local_api.sh | 363 +++++++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+), 1 deletion(-) create mode 100755 tests/manual/local_api.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b8104fc..43c38b9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -80,7 +80,12 @@ jobs: while IFS= read -r f; do if head -1 "$f" | grep -q bash; then chk="bash -n"; else chk="dash -n"; fi if ! $chk "$f"; then echo "::error file=$f::$chk parse error"; fail=1; fi - done < <(git ls-files 'plugins/**/scripts/*.sh' 'install.sh' 'scripts/*.sh' 'scripts/shared/*.sh') + # tests/manual/*.sh is here because nothing else in CI reads it: those + # scripts are run by hand against a live CLI, so a parse error sits + # undiscovered until someone reaches for the harness - which is exactly + # when they are least able to debug it. The automated suites under tests/ + # are covered by being executed. + done < <(git ls-files 'plugins/**/scripts/*.sh' 'install.sh' 'scripts/*.sh' 'scripts/shared/*.sh' 'tests/manual/*.sh') [ "$fail" = 0 ] || exit 1 - name: Command and skill snippets parse diff --git a/tests/manual/local_api.sh b/tests/manual/local_api.sh new file mode 100755 index 0000000..17d81e5 --- /dev/null +++ b/tests/manual/local_api.sh @@ -0,0 +1,363 @@ +#!/usr/bin/env bash +# Point THIS machine's Claude Code at the plugin WORKING TREE and a LOCAL Rogue API, +# so you can exercise uncommitted plugin changes against a backend you control. +# +# bash tests/manual/local_api.sh up [--url URL] [--key KEY] [--ship] +# bash tests/manual/local_api.sh sync # re-push working-tree edits, no reinstall +# bash tests/manual/local_api.sh status # what is installed, where it points +# bash tests/manual/local_api.sh check # assert the log looks like this branch +# bash tests/manual/local_api.sh probe # one headless session, no interactive restart +# bash tests/manual/local_api.sh down # undo everything `up` did +# +# This is the sibling of live_session.sh. That script is a self-contained, asserting +# run against a fake node receiver and it installs from `git archive HEAD`. This one +# installs the WORKING TREE and leaves it installed, so your normal interactive +# sessions drive it and your own API sees the traffic. +# +# WHAT `up` CHANGES ON YOUR MACHINE (all of it undone by `down`) +# +# * Adds a marketplace `rogue-localdev` sourced from a copy of your working tree, +# and installs `rogue@rogue-localdev` at user scope. +# * DISABLES `rogue@rogue-marketplace` if you have it, so exactly one plugin +# answers each event. Two enabled copies would double every POST and write two +# log lines per event, which makes the log unreadable for exactly the thing you +# are trying to observe. +# * Replaces `~/.rogue-env`, keeping the original at `~/.rogue-env.localdev-backup`. +# It has to be that file and not the process environment: `hook.sh` sources the +# env files AFTER the process environment is already set, with no save/restore, +# so `~/.rogue-env`'s ROGUE_API_KEY beats anything you export. (`hook.ps1` and +# `hook.mjs` apply process env last and do not have this behaviour - an +# sh-dispatcher divergence from the documented precedence, tracked separately.) +# +# ~/.rogue-env IS SHARED BY ALL SIX PLUGINS. While this is up, any Codex, Cursor, +# Gemini, Copilot or Antigravity install on this machine also talks to your local +# API and authenticates with the local key. That is usually what you want for a +# backend test; it is not what you want left running. +# +# * Nothing else. $HOME stays real, the log stays at its real path (~/.rogue/logs/ +# claude.log) so /rogue:status reads the same file you are watching. +set -u + +REPO="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +MARKET_NAME=rogue-localdev +MARKET_DIR="$HOME/.rogue-localdev/market" +ENV_FILE="$HOME/.rogue-env" +ENV_BACKUP="$HOME/.rogue-env.localdev-backup" +STATE="$HOME/.rogue-localdev/state" +LOG="${ROGUE_LOG_FILE:-$HOME/.rogue/logs/claude.log}" + +DEFAULT_URL=http://localhost:8000 +DEFAULT_KEY=localdev-key + +say() { printf '%s\n' "$*"; } +die() { printf '%s\n' "$*" >&2; exit 1; } +rule() { printf '\n── %s %s\n' "$1" "$(printf '─%.0s' $(seq 1 $((70 - ${#1}))))"; } + +command -v claude >/dev/null 2>&1 || die "need the \`claude\` CLI on PATH" + +# The version the marketplace will advertise, read the same way build-release.sh +# does. It becomes the cache directory name, which `sync` has to find again. +plugin_version() { + sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + "$REPO/plugins/rogue/.claude-plugin/plugin.json" | head -1 +} + +install_path() { + node -e ' + const fs = require("fs"), os = require("os"); + const f = os.homedir() + "/.claude/plugins/installed_plugins.json"; + try { + const p = JSON.parse(fs.readFileSync(f, "utf8")).plugins["rogue@" + process.env.MK]; + if (p && p.length) process.stdout.write(p[p.length - 1].installPath || ""); + } catch (e) {} + ' 2>/dev/null +} + +# ── copy the working tree into the marketplace ──────────────────────────────── +# Only the two paths a Claude marketplace install reads: the manifest and the +# plugin. Copying the whole repo would drag .git along for no benefit. +stage_tree() { + rm -rf "$MARKET_DIR" + mkdir -p "$MARKET_DIR/.claude-plugin" || die "cannot write $MARKET_DIR" + cp "$REPO/.claude-plugin/marketplace.json" "$MARKET_DIR/.claude-plugin/marketplace.json" + mkdir -p "$MARKET_DIR/plugins" + # -R, not `cp -a`: BSD cp has no -a. Follows the same portability rule as the + # dispatchers (wc -c over stat, etc). + cp -R "$REPO/plugins/rogue" "$MARKET_DIR/plugins/rogue" + # Rename the marketplace so it cannot collide with a real `rogue-marketplace` + # entry. The plugin inside is still called `rogue`, hence every claude command + # below is marketplace-qualified. + sed -i.bak "s/\"name\": *\"rogue-marketplace\"/\"name\": \"$MARKET_NAME\"/" \ + "$MARKET_DIR/.claude-plugin/marketplace.json" + rm -f "$MARKET_DIR/.claude-plugin/marketplace.json.bak" +} + +cmd_up() { + URL="$DEFAULT_URL"; KEY="$DEFAULT_KEY"; SHIP=0 + while [ $# -gt 0 ]; do + case "$1" in + --url) URL="${2:?--url needs a value}"; shift 2 ;; + --key) KEY="${2:?--key needs a value}"; shift 2 ;; + --ship) SHIP=1; shift ;; + *) die "unknown argument: $1" ;; + esac + done + + case "$URL" in + # The brackets are ESCAPED. `http://[::1]:*` is a glob bracket expression, not + # the literal IPv6 loopback - it matches one character from the set, so the arm + # never fires for the address it names and the URL falls through to `die`. + http://localhost:*|http://127.0.0.1:*|http://\[::1\]:*) ;; + *) die "refusing to point at a non-local URL ($URL). This rewrites ~/.rogue-env + for EVERY Rogue plugin on the machine; a typo here sends your sessions' + prompts and tool calls somewhere you did not intend." ;; + esac + + rule "the local API" + if curl -sS -o /dev/null --max-time 3 "$URL" 2>/dev/null; then + say " something is listening on $URL" + else + say " WARNING: nothing answered at $URL yet." + say " Continuing anyway - start it before you run a session. Every hook fails" + say " open, so an unreachable API costs you nothing but an outcome=fail line." + fi + + rule "install the working tree" + stage_tree + say " staged $(plugin_version) from $REPO -> $MARKET_DIR" + claude plugin marketplace add "$MARKET_DIR" --scope user 2>&1 | sed 's/^/ /' + claude plugin install "rogue@$MARKET_NAME" --scope user 2>&1 | sed 's/^/ /' + MK="$MARKET_NAME" ; export MK + IP="$(install_path)" + [ -n "$IP" ] || die " the install produced no record - check the output above" + say " installed to $IP" + + # Exactly one plugin per event. Recorded so `down` only re-enables what it + # actually disabled. + mkdir -p "$(dirname "$STATE")" + : > "$STATE" + if claude plugin list 2>/dev/null | grep -q 'rogue@rogue-marketplace'; then + if claude plugin disable rogue@rogue-marketplace --scope user >/dev/null 2>&1; then + say " disabled rogue@rogue-marketplace (it would double every event)" + echo "disabled_prod=1" >> "$STATE" + fi + fi + + rule "credentials" + if [ -f "$ENV_FILE" ]; then + # Never clobber a real backup: a second `up` without a `down` would otherwise + # overwrite the saved original with the localdev one and lose it for good. + if [ -f "$ENV_BACKUP" ]; then + say " $ENV_BACKUP already exists - keeping it, not re-backing-up" + else + cp "$ENV_FILE" "$ENV_BACKUP" && say " backed up $ENV_FILE -> $ENV_BACKUP" + echo "backed_up=1" >> "$STATE" + fi + fi + ( umask 077 + { + echo "# Written by tests/manual/local_api.sh - LOCAL DEVELOPMENT ONLY." + echo "# Restore the original with: bash tests/manual/local_api.sh down" + echo "export ROGUE_API_KEY=$KEY" + echo "export ROGUE_BASE_URL=$URL" + echo "export ROGUE_ACTOR_EMAIL=${ROGUE_ACTOR_EMAIL:-localdev@rogue.security}" + echo "export ROGUE_ACTOR_NAME=${ROGUE_ACTOR_NAME:-Local Dev}" + [ "$SHIP" = 1 ] && echo "export ROGUE_SHIP_LOGS=1" + [ "$SHIP" = 1 ] && echo "export ROGUE_SHIP_MIN_INTERVAL=0" + } > "$ENV_FILE" + ) + say " wrote $ENV_FILE (mode 600) -> $URL" + [ "$SHIP" = 1 ] && say " log shipping ON (your API must serve POST /api/v1/hooks/logs)" + + rule "next" + say " 1. Start your local API on $URL" + say " 2. RESTART Claude Code - a plugin is loaded at session start, so this" + say " session is still running the old copy." + say " 3. Use it normally, then: bash tests/manual/local_api.sh check" + say + say " Edited a script since? Re-push it without a reinstall:" + say " bash tests/manual/local_api.sh sync" +} + +# `sync` exists because the install COPIES the tree - editing plugins/rogue in the +# repo does nothing until the copy is refreshed. Refreshing the installed copy in +# place beats uninstall/reinstall: the install record, the version and the enabled +# state all stay put, and a running session picks up the next hook invocation +# immediately (each event spawns a fresh `sh hook.sh`, nothing is cached in-process). +cmd_sync() { + MK="$MARKET_NAME"; export MK + IP="$(install_path)" + [ -n "$IP" ] || die "rogue@$MARKET_NAME is not installed - run \`up\` first" + [ -d "$IP" ] || die "the install record points at $IP, which does not exist" + stage_tree + # Copy CONTENTS, so files deleted in the working tree since the install still + # linger - acceptable, and far safer than rm -rf on a path read out of a JSON file. + cp -R "$MARKET_DIR/plugins/rogue/." "$IP/" || die "copy into $IP failed" + say "synced $REPO/plugins/rogue -> $IP" + say "Takes effect on the next hook invocation. No restart needed for script edits." + say "hooks.json changes DO need a restart (the event registration is read once)." +} + +cmd_status() { + MK="$MARKET_NAME"; export MK + rule "plugins" + claude plugin list 2>/dev/null | grep -i rogue | sed 's/^/ /' || say " (none)" + IP="$(install_path)" + [ -n "$IP" ] && say " localdev install path: $IP" + if [ -n "$IP" ] && [ -f "$IP/scripts/surface.sh" ]; then + say " the installed copy HAS scripts/surface.sh (this branch's change)" + elif [ -n "$IP" ]; then + say " the installed copy has NO scripts/surface.sh - it predates this branch," + say " or \`sync\` has not run since you switched branches" + fi + rule "credentials" + if [ -f "$ENV_FILE" ]; then + sed 's/^export ROGUE_API_KEY=.*/export ROGUE_API_KEY=/' "$ENV_FILE" | sed 's/^/ /' + else + say " no $ENV_FILE" + fi + [ -f "$ENV_BACKUP" ] && say " original saved at $ENV_BACKUP" + rule "the log" + say " $LOG" + [ -f "$LOG" ] && tail -8 "$LOG" | sed 's/^/ /' || say " (nothing yet)" +} + +# What this branch is supposed to have changed, asserted against real lines that a +# real session wrote. Deliberately narrow: it reads the log only, so it says nothing +# about what the API did with the request - that is your backend's to check. +cmd_check() { + [ -f "$LOG" ] || die "no $LOG yet - run a session first" + rule "lines this run wrote" + tail -12 "$LOG" | sed 's/^/ /' + + rule "verdict" + fails=0 + ok() { say " ok: $1"; } + bad() { say " FAIL: $1"; fails=$((fails + 1)); } + + recent="$(tail -40 "$LOG")" + total="$(printf '%s\n' "$recent" | grep -c 'provider=claude')" + tagged="$(printf '%s\n' "$recent" | grep -c 'provider=claude surface=')" + + [ "$total" -gt 0 ] && ok "the hooks are firing ($total recent lines)" \ + || bad "no provider=claude lines at all - the plugin is not loaded" + + if [ "$tagged" = "$total" ] && [ "$total" -gt 0 ]; then + ok "every recent line carries surface= ($tagged/$total)" + else + bad "only $tagged of $total recent lines carry surface= - a stale install, or \`sync\` has not run" + fi + + # Position is part of the contract the backend parses on: provider, then surface, + # then event. A token in the wrong place is worse than a missing one. + # + # EVERY tagged line, not any: the obvious `grep -q ''` passes as + # soon as ONE line is right, so a run where most lines carry the token in the + # wrong place still reported ok. Count the tagged lines that do NOT match instead. + misplaced="$(printf '%s\n' "$recent" | grep 'surface=' \ + | grep -vc 'provider=claude surface=[a-z_]* event=')" + [ "${misplaced:-0}" = 0 ] && ok "surface= sits between provider= and event= on every tagged line" \ + || bad "$misplaced tagged line(s) do not have surface= between provider= and event=" + + # The closed list. Anything else means something leaked into the token. + stray="$(printf '%s\n' "$recent" | sed -n 's/.*surface=\([^ ]*\).*/\1/p' \ + | grep -vc '^\(cli\|desktop\|cowork\)$')" + [ "${stray:-0}" = 0 ] && ok "every slug is from claude's closed list" \ + || bad "$stray line(s) carry a slug outside cli/desktop/cowork" + + # The two things that must never appear, per the spec. Spelled as if/else rather + # than `grep -q … && bad … || ok …`: in that form the `||` arm also runs whenever + # the `&&` arm returns non-zero, so a future edit to bad() would silently report + # both a failure and an ok for the same line. + if printf '%s\n' "$recent" | grep -q 'surface=unknown'; then + bad "a line says surface=unknown" + else + ok "no surface=unknown" + fi + # `surface=` followed by a space OR end-of-line. Anchoring on the space alone + # missed an empty token at the end of a line. + if printf '%s\n' "$recent" | grep -qE 'surface=( |$)'; then + bad "a line has an empty surface=" + else + ok "no empty surface=" + fi + + # Did the API answer? An Unauthorized here is a backend-side key mismatch, not a + # plugin failure - the plugin relayed exactly what it got. + rule "what your API answered" + printf '%s\n' "$recent" | grep 'raw=' | tail -4 | sed 's/^/ /' || say " (no raw= lines)" + + say + [ "$fails" = 0 ] && say "LOCAL API CHECK PASSED" || say "$fails failure(s)" + return "$fails" +} + +# A headless probe, for when you do not want to restart your editor to see whether +# the API is wired up. Claude Code did NOT run plugin-provided hooks in a `claude -p` +# run on 2.1.223, so this registers the INSTALLED plugin's own hooks.json through +# --settings - the same trick, and the same caveat, as live_session.sh. +cmd_probe() { + MK="$MARKET_NAME"; export MK + IP="$(install_path)" + [ -n "$IP" ] || die "rogue@$MARKET_NAME is not installed - run \`up\` first" + command -v node >/dev/null 2>&1 || die "need node for this subcommand" + SETTINGS="$HOME/.rogue-localdev/hooks-settings.json" + mkdir -p "$(dirname "$SETTINGS")" + ROGUE_LOCAL_ROOT="$IP" ROGUE_LOCAL_OUT="$SETTINGS" node -e ' + const fs = require("fs"); + const root = process.env.ROGUE_LOCAL_ROOT, out = process.env.ROGUE_LOCAL_OUT; + const hooks = JSON.parse(fs.readFileSync(root + "/hooks/hooks.json", "utf8")).hooks; + // The only edit: expand ${CLAUDE_PLUGIN_ROOT}, which Claude Code substitutes for + // a plugin hook but not for a settings hook. + const expanded = JSON.parse( + JSON.stringify(hooks).replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, root)); + fs.writeFileSync(out, JSON.stringify({ hooks: expanded }, null, 2)); + console.log(" registered " + Object.keys(expanded).length + " events from " + root); + ' || die "could not build the settings file" + rule "one headless session" + ( cd "$HOME/.rogue-localdev" || exit 1 + env CLAUDE_PLUGIN_ROOT="$IP" \ + claude --settings "$SETTINGS" --output-format text \ + -p 'Run the shell command `echo rogue-localdev-probe` and then reply with just the word done.' \ + 2>&1 | sed 's/^/ claude: /' ) + cmd_check +} + +cmd_down() { + rule "undo" + claude plugin uninstall "rogue@$MARKET_NAME" --scope user >/dev/null 2>&1 \ + && say " uninstalled rogue@$MARKET_NAME" + claude plugin marketplace remove "$MARKET_NAME" >/dev/null 2>&1 \ + && say " removed the $MARKET_NAME marketplace" + # Uninstalling leaves the extracted tree in the cache, and that leftover is not + # inert: it is a NEWER copy than your real install, so anything resolving the + # plugin root by "newest under the cache" - the last-resort layer of the + # /rogue:status support snippet - would pick this one. + if [ -d "$HOME/.claude/plugins/cache/$MARKET_NAME" ]; then + rm -rf "$HOME/.claude/plugins/cache/$MARKET_NAME" && say " removed its plugin cache" + fi + if [ -f "$STATE" ] && grep -q '^disabled_prod=1' "$STATE"; then + claude plugin enable rogue@rogue-marketplace --scope user >/dev/null 2>&1 \ + && say " re-enabled rogue@rogue-marketplace" + fi + if [ -f "$ENV_BACKUP" ]; then + mv "$ENV_BACKUP" "$ENV_FILE" && say " restored $ENV_FILE from the backup" + else + say " no backup to restore - $ENV_FILE still points at the local API." + say " Run /rogue:setup to write your real credentials back." + fi + rm -rf "$HOME/.rogue-localdev" + say " removed the staging directory" + say + say " RESTART Claude Code to unload the plugin." +} + +case "${1:-}" in + up) shift; cmd_up "$@" ;; + sync) cmd_sync ;; + status) cmd_status ;; + check) cmd_check ;; + probe) cmd_probe ;; + down) cmd_down ;; + *) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' ; exit 1 ;; +esac