From 165f14769a89219ddedd848f4eec315026fd5113 Mon Sep 17 00:00:00 2001 From: Yuval Date: Thu, 13 Aug 2026 19:00:07 +0300 Subject: [PATCH 1/3] feat(gemini): resolve the running subagent and send it as x-rogue-agent-id A Gemini subagent's own hook events are shape-identical to the main agent's: createBaseInput emits only session_id / transcript_path / cwd / hook_event_name / timestamp, and none of them names the delegation that is running. Its tool calls therefore persisted with no attribution at all, while only the delegation report carried a name. Gemini's own transcript records do name it, and the rule is bookkeeping rather than timing: a delegation appears in the per-session subagent directory when it STARTS and in the parent transcript when it ENDS, so started-minus-finished is what is running. The remaining UUID is the subagent's session id, which is also the vendor agentId upstream stamps on the completed invoke_agent record, so it is a real per-instance id rather than a slug two concurrent runs would share. No timestamp of any kind is read or compared. An earlier design selected the live delegation by mtime, which would have made correctness depend on filesystem timestamp resolution. The POSTed body stays byte-for-byte the bytes read from stdin. The payload is parsed into a local for inspection only; everything derived travels as a header, and the tests assert body identity against the raw inbound bytes on every attribution case. Sends only the id. The agent name is already inside the relayed bytes on the one event that has one, and the backend reads it from there. Every failure path yields no header, never a wrong id: concurrent delegations, a missing directory, an unreadable or oversized parent, a delegation recorded without an agentId (matched by its prompt instead), or an id that would not be header-safe. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 3 +- plugins/gemini/scripts/hook.mjs | 215 ++++++++++++++++++++- tests/test_hook_mjs.mjs | 323 +++++++++++++++++++++++++++++++- 3 files changed, 533 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4aa8062..4d1a63c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,8 @@ A near-verbatim port of `qualifire-dev/rogue-plugin-cursor`'s `plugins/rogue/` ( A native **Gemini CLI extension** (endpoint `/hooks/gemini`, family `gemini`, surface `gemini_cli`). Deliberately breaks from the sh+PowerShell dual-dispatcher of the other three because **Gemini CLI guarantees Node 20+ on PATH** (every install method requires it; the Homebrew formula declares `node` a dependency; there is no bundled-runtime/SEA build). So: - **One cross-platform Node ESM hook** — `scripts/hook.mjs` (plus `setup.mjs`, `heartbeat.mjs`), Node built-ins only (global `fetch`, `node:fs/os/path/child_process`). **No `curl`, no `jq`, no dependencies, no build step, and NO sh/ps lockstep.** Don't "port" it back to shell — the single-script model is the point. - **Manifest is `gemini-extension.json`** (at the plugin root, version is source of truth). **NO marketplace file** — Gemini installs from a repo/archive/local dir, so the version-sync check in `validate.yml` does not cover it. **Hooks live in `hooks/hooks.json`** (a Gemini extension convention — NOT in the manifest), one `command` per event: `node "${extensionPath}${/}scripts${/}hook.mjs" `, timeout **20000ms** (units are ms; the fetch timeout is 15000ms, inside the budget). `${extensionPath}${/}` makes one command string work on Windows and macOS. -- **PURE RELAY** — the backend emits Gemini's native decision shapes (`{"decision":"deny"|"block","reason":...}`, `toolConfig.mode:"NONE"`), so `hook.mjs` relays the response verbatim and Gemini renders the block. Fail-open `{}` on missing key / network error / non-200 / bad response; **always exit 0** (a block is carried in the relayed JSON body, per Gemini's structured-output contract — not the exit code). +- **PURE RELAY** — the backend emits Gemini's native decision shapes (`{"decision":"deny"|"block","reason":...}`, `toolConfig.mode:"NONE"`), so `hook.mjs` relays the response verbatim and Gemini renders the block. Fail-open `{}` on missing key / network error / non-200 / bad response; **always exit 0** (a block is carried in the relayed JSON body, per Gemini's structured-output contract — not the exit code). The **body** is still a pure relay (byte-for-byte the bytes Gemini piped in, never re-serialized), but the dispatcher is no longer a pure relay in the sense of never *reading* the payload: it parses stdin into a local to resolve subagent attribution, and everything it derives travels as a header (see the next bullet). +- **Subagent attribution (`x-rogue-agent-id`, the one header `hook.mjs` adds)**: a Gemini subagent's own hook events are shape-identical to the main agent's and name no agent, so the dispatcher resolves the running delegation from Gemini's **own transcript records**: `dir = dirname(transcript_path) + sanitize(session_id)` is the per-session subagent directory (`ChatRecordingService.initialize`), each `.jsonl` in it **is** a delegation's session UUID, and that UUID is exactly the `agentId` upstream stamps on the parent transcript's completed `invoke_agent` record. So **started minus finished** (present in the directory = started, recorded in the parent = finished) is what is running, and a **unique** survivor is sent as `x-rogue-agent-id`. `args.prompt` from the parent's `invoke_agent` records is a second "finished" key, matched (JSON-escaped) against a bounded 64 KB head of a candidate file, covering a delegation that ended without an `agentId` (errored / cancelled / max-turns) which would otherwise look live forever and mis-tag later main-agent tool calls. **No timestamps of any kind** are read or compared (not mtimes, not record timestamps) and **no state file** exists: both inputs are append-only records upstream writes for its own reasons, so the answer does not depend on how long a hook takes or on filesystem timestamp resolution. Gated to `AfterTool` and to `BeforeTool` when `tool_name !== "invoke_agent"`: the delegation **request** is suppressed as a correctness guard, since in a parallel batch the first delegation's file already exists when the second's `BeforeTool` fires. `AfterTool invoke_agent` **is** tagged (it is the subagent's own report, and its hook fires before the parent's completion record is written, so the finishing delegation still counts as live). **Every failure path yields no header**, never a wrong id or a failed POST: 2+ open delegations, no directory, a missing/oversized (32 MB cap) parent, a bad or absent `session_id`/`transcript_path`, an id that is not a bare `[A-Za-z0-9_-]{1,64}` token (an invalid header value would make `fetch` throw), or any exception at all. The resolved value is logged as `agent=`. **Only the id is sent** (no `x-rogue-agent-name-b64`): the name is already in the relayed bytes on the one event that has one (`AfterTool invoke_agent`'s `tool_input.agent_name`) and the backend reads it from there, so subagent tool rows land with `agent_id` set and `agent_name` NULL by design. The single upstream assumption is that the parent's completion record lands **before** the main agent's next tool hook (it does structurally: `recordCompletedToolCalls` runs "before sending responses to Gemini"); if that ever reordered, a main-agent row carrying a subagent UUID is the signature. Tests: `tests/test_hook_mjs.mjs` (`node tests/test_hook_mjs.mjs`), including the load-bearing assertion that the POSTed body is byte-identical to stdin. - **Events**: **the full Gemini hook set is registered and POSTed to `/hooks/gemini`** (send-everything for audit + enforcement) — **only `AfterModel` is excluded**. Registered/monitored: `SessionStart, BeforeAgent, BeforeModel, AfterAgent, BeforeTool, AfterTool, BeforeToolSelection, SessionEnd, Notification, PreCompress`. **Block/enforce:** `BeforeAgent, AfterAgent, BeforeTool, AfterTool` (the backend's `gemini_cli.blockingEvents`). **MCP calls/responses ride `BeforeTool`/`AfterTool`** (the parser keys off `mcp_context`). **`AfterModel` is intentionally NOT registered** — per Google's hook spec it fires once per streamed response chunk (would double-count content already captured whole at `AfterAgent`). **Audit-only** (POSTed, never blocks — Gemini ignores their decision): `SessionStart` (when configured: fires the detached heartbeat, then falls through to POST like any other event; when **unconfigured** — no `ROGUE_API_KEY`: emits the `/setup` hint response and returns **without** firing the heartbeat or POSTing to `/hooks/gemini`), `BeforeToolSelection` (filter-capable via `toolConfig.mode:"NONE"`, but monitor-only for v1), `SessionEnd` (best-effort — Gemini "will not wait" for it on exit), `Notification`, `PreCompress`. Mechanically, `hook.mjs` already POSTs any event that isn't `SessionStart` through its generic path, so capturing the new four was pure `hooks.json` registration; the only script change was making `SessionStart` fall through to the POST after firing its heartbeat. - **Hook trust**: like Codex, Gemini fingerprints the hook `command` and skips it until reviewed via `/hooks`. Keep the `command` strings byte-stable forever; mutate only `scripts/*.mjs`. `/setup` documents the one-time trust step. - **Credentials**: reads the shared `~/.rogue-env` (mode 600) from disk each invocation, via the same env-file precedence as the other plugins (`${extensionPath}/env` → `/etc/rogue/env` / `C:\ProgramData\rogue\env` → `~/.rogue-env`). No manifest `settings[]` (that targets MCP env injection and would prompt a keychain entry on install). Actor cascade: env → `git config --global` → hostname/whoami. diff --git a/plugins/gemini/scripts/hook.mjs b/plugins/gemini/scripts/hook.mjs index 89a716b..dda3293 100644 --- a/plugins/gemini/scripts/hook.mjs +++ b/plugins/gemini/scripts/hook.mjs @@ -7,6 +7,11 @@ // decision shapes ({"decision":"deny"|"block", "reason":...} / toolConfig), so // this dispatcher is a PURE RELAY — Gemini renders the block itself. // +// The POSTed body is BYTE-FOR-BYTE the bytes read from stdin. The dispatcher +// does parse those bytes (into a local, for subagent attribution — see +// resolveSubagentId), but the parse result never reaches the request: everything +// it derives travels as a HEADER. Never re-serialize the payload into `body`. +// // One cross-platform script replaces the sh + PowerShell dual-dispatcher used by // the Claude/Codex/Cursor plugins: Gemini CLI guarantees Node 20+ on PATH (every // install method requires it; Homebrew declares `node` as a dependency), so we @@ -104,6 +109,194 @@ function describeOutcome(bodyText) { return "outcome=allow"; } +// ── Subagent attribution (x-rogue-agent-id) ───────────────────────────────── +// +// A Gemini subagent's OWN hook events are shape-identical to the main agent's: +// createBaseInput emits only session_id / transcript_path / cwd / +// hook_event_name / timestamp, and nothing in the payload names the delegation +// that is running. But Gemini's own transcript records do, and the rule is a +// BOOKKEEPING one rather than a timing one: +// +// a delegation appears in the subagent directory when it STARTS, +// and in the parent transcript when it ENDS, +// so (started minus finished) is what is running right now. +// +// dir = dirname(transcript_path) / sanitize(session_id) +// started = the .jsonl basenames in dir (each IS a subagent session UUID) +// finished = those recorded in the parent transcript +// agentId = the single remaining one, or nothing +// +// Both sides are append-only records upstream writes for its own reasons, so +// NO timestamp of any kind is read or compared: not file mtimes, not record +// timestamps. The answer is the same however long a hook takes and whatever the +// filesystem does with timestamp resolution. +// +// Bundle citations (Gemini CLI 0.55.1, @google/gemini-cli chunk-TBDX7VEE.js): +// :285510-285531 ChatRecordingService.initialize — a subagent's transcript is +// `//.jsonl`, +// while the main session file sits directly in `/`. +// :253978-253980 sanitizeFilenamePart = part.replace(/[^a-zA-Z0-9_-]/g, "_"). +// :331296 recordCompletedToolCalls stamps `agentId` (the subagent's +// session UUID, i.e. its filename) on the parent's completed +// invoke_agent record. That record is the "finished" marker. +// +// THE ONE ASSUMPTION: the parent's completion record must land BEFORE the main +// agent's next tool hook. It does structurally, not by timing margin — +// recordCompletedToolCalls is documented (:331283-331284) as running "before +// sending responses to Gemini" and its caller (:347827) invokes it as soon as +// scheduleAgentTools resolves, so the model roundtrip that produces the next +// tool call cannot precede it. If upstream ever reordered that, a finished +// delegation would stay "live" and a MAIN-agent tool row would be tagged with a +// subagent's UUID. That is the only route in this design to a WRONG id; every +// other failure yields no header at all. A main-agent row carrying a subagent +// UUID is the signature to look for after a Gemini version bump. +// +// The same ordering is what makes the delegation report itself work: the +// AfterTool invoke_agent hook fires before the completion record is written, so +// the finishing delegation is still "live" and its report row gets the id. + +// Cost guards. A session that never delegates pays one ENOENT and stops. +const PARENT_MAX_BYTES = 32 * 1024 * 1024; // over this, send no header +const CANDIDATE_HEAD_BYTES = 64 * 1024; // enough for a subagent's first record + +// Which events may carry the tag. Only a tool event can be fired BY a subagent. +// BeforeAgent/AfterAgent/BeforeModel/SessionStart/... are never a subagent's, +// and BeforeAgent carries the developer's prompt, where a wrong tag is worst. +// +// `BeforeTool invoke_agent` (the main agent's delegation REQUEST) is excluded as +// a CORRECTNESS GUARD, not merely for semantics: in a parallel batch of two +// invoke_agent calls the first delegation's file already exists when the +// second's BeforeTool fires, so the rule would hand the FIRST agent's id to the +// SECOND agent's request. Suppressing the event removes the case entirely. +// (Delegations are parallelizable: _isParallelizable, :346148, returns false +// only for edit tools, update_topic, and an explicit wait_for_previous: true.) +// `AfterTool invoke_agent` is included — that one IS the subagent's own report. +function isAgentTaggableEvent(parsed) { + if (EVENT === "AfterTool") return true; + if (EVENT === "BeforeTool") return parsed.tool_name !== "invoke_agent"; + return false; +} + +// Read a file as UTF-8, or null if it is missing, unreadable or over `maxBytes`. +function readCapped(file, maxBytes) { + try { + if (fs.statSync(file).size > maxBytes) return null; + return fs.readFileSync(file, "utf8"); + } catch { + return null; + } +} + +// The delegated prompts recorded in the parent, JSON-escaped for a raw-text +// substring test against a subagent file (see hasDelegatedPrompt). Only lines +// mentioning invoke_agent are parsed; the rest of the transcript is never JSON. +function delegatedPrompts(parentText) { + const out = []; + for (const line of parentText.split("\n")) { + if (!line.includes("invoke_agent")) continue; + let record; + try { + record = JSON.parse(line); + } catch { + continue; + } + for (const call of record?.toolCalls ?? []) { + const prompt = call?.args?.prompt; + if (call?.name === "invoke_agent" && typeof prompt === "string" && prompt) { + out.push(JSON.stringify(prompt).slice(1, -1)); + } + } + } + return out; +} + +// Fallback "finished" key for a delegation that ended WITHOUT an agentId: that +// value is read out of the tool RESPONSE, so an errored / cancelled / max-turns +// agent can be recorded without one, and it would otherwise stay "live" for the +// rest of the session and mis-tag every later main-agent tool call. `args` comes +// from the REQUEST and is always there, and the subagent's first `user` record +// embeds the delegated prompt verbatim inside its context preamble. +// +// Compared JSON-escaped against the raw head, so no parse of a possibly +// truncated head is needed and a multi-line prompt still matches. An unreadable +// head returns false, i.e. the candidate stays live and the usual +// unique-or-nothing guard applies. +function hasDelegatedPrompt(file, escapedPrompts) { + if (escapedPrompts.length === 0) return false; + let head = ""; + try { + const fd = fs.openSync(file, "r"); + try { + const buf = Buffer.alloc(CANDIDATE_HEAD_BYTES); + const n = fs.readSync(fd, buf, 0, CANDIDATE_HEAD_BYTES, 0); + head = buf.subarray(0, n).toString("utf8"); + } finally { + fs.closeSync(fd); + } + } catch { + return false; + } + return escapedPrompts.some((p) => head.includes(p)); +} + +// Returns the running delegation's session UUID, or undefined. NEVER throws: +// every failure path is "no header", because a wrong agent id is worse than a +// missing one and a throw here would cost the POST itself. +function resolveSubagentId(parsed) { + try { + if (!parsed || typeof parsed !== "object") return undefined; + if (!isAgentTaggableEvent(parsed)) return undefined; + + const transcript = parsed.transcript_path; + const session = parsed.session_id; + if (typeof transcript !== "string" || !transcript) return undefined; + if (typeof session !== "string" || !session) return undefined; + + // Upstream's own sanitizer (:253978-253980); it also makes the joined path + // traversal-proof, since "/" and "." both become "_". + const dir = path.join( + path.dirname(transcript), + session.replace(/[^a-zA-Z0-9_-]/g, "_"), + ); + // No delegation in this session → ENOENT → no header, the common case. + const started = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => f.slice(0, -".jsonl".length)); + if (started.length === 0) return undefined; + + // Unreadable / absent (recording disabled, conversationFile nulled after + // ENOSPC) or oversized: `finished` is uncomputable and every candidate would + // look live, so send nothing. + const parent = readCapped(transcript, PARENT_MAX_BYTES); + if (parent === null) return undefined; + + // A subagent UUID appears in the parent on exactly one line, its completion + // record, so the primary key is a plain substring test that needs no JSON + // parsing at all. Everything recorded → nothing is running. + const unresolved = started.filter((id) => !parent.includes(id)); + if (unresolved.length === 0) return undefined; + + // Only what the substring test missed pays for the prompt fallback. + const prompts = delegatedPrompts(parent); + const live = unresolved.filter( + (id) => !hasDelegatedPrompt(path.join(dir, `${id}.jsonl`), prompts), + ); + + // Zero → a main-agent event. Two or more → concurrent delegations, which the + // rule cannot separate. Both send nothing. + if (live.length !== 1) return undefined; + // The id is a filename, i.e. arbitrary bytes from disk. An invalid header + // value makes fetch THROW, which would fail-open the whole POST — far worse + // than no attribution. It must look like the vendor UUID it is, and the + // backend rejects (never truncates) an id over 64 chars. + const id = live[0]; + return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : undefined; + } catch { + return undefined; + } +} + // ── Read all of stdin ──────────────────────────────────────────────────────── function readStdin() { return new Promise((resolve) => { @@ -149,6 +342,17 @@ async function main() { ); const url = env.ROGUE_API_URL || `${base}/api/v1/hooks/gemini`; + // Inspect the relayed bytes in a LOCAL. The parse result is only ever read + // from — `body: payload` below stays the exact bytes Gemini piped in. + let parsed = null; + try { + parsed = JSON.parse(payload); + } catch { + parsed = null; + } + const agentId = resolveSubagentId(parsed); + const agentLog = `agent=${agentId || "none"}`; + let bodyText = "{}"; try { const resp = await fetch(url, { @@ -159,6 +363,11 @@ async function main() { "x-rogue-event": EVENT, "x-rogue-actor-email": actor.email, "x-rogue-actor-name": actor.name, + // Only the id. The agent NAME is already inside the relayed bytes on the + // one event that has one (AfterTool invoke_agent's tool_input.agent_name) + // and the backend reads it from there, so a name header would duplicate a + // field for zero information. + ...(agentId ? { "x-rogue-agent-id": agentId } : {}), }, body: payload, signal: AbortSignal.timeout(15000), @@ -173,13 +382,13 @@ async function main() { bodyText = "{}"; } } - log(`http=${resp.status} ${describeOutcome(bodyText)}`); + log(`http=${resp.status} ${agentLog} ${describeOutcome(bodyText)}`); } else { - log(`http=${resp.status} outcome=fail-open`); + log(`http=${resp.status} ${agentLog} outcome=fail-open`); bodyText = "{}"; } } catch (e) { - log(`error="${sanitize(e && e.message)}" outcome=fail-open`); + log(`error="${sanitize(e && e.message)}" ${agentLog} outcome=fail-open`); bodyText = "{}"; } diff --git a/tests/test_hook_mjs.mjs b/tests/test_hook_mjs.mjs index 9e2bb36..6c4c8ba 100644 --- a/tests/test_hook_mjs.mjs +++ b/tests/test_hook_mjs.mjs @@ -47,15 +47,25 @@ function runHook(event, payload, env) { } // Start a one-shot server that records the request and replies with `body`. +// `seen.raw` keeps the inbound bytes UNDECODED — the subagent-attribution tests +// assert the POSTed body is byte-identical to what was piped in on stdin. function startServer(status, body) { return new Promise((resolve) => { const seen = {}; const server = http.createServer((req, res) => { - seen.headers = req.headers; - let b = ""; - req.on("data", (c) => (b += c)); + // SessionStart also spawns the DETACHED heartbeat (hook.mjs fireHeartbeat), + // which POSTs its own body to /api/v1/hooks/status and races the event + // POST. Answer it, but never let it overwrite what the event POST recorded, + // or a SessionStart assertion reads the heartbeat's body instead. + const isHeartbeat = (req.url || "").endsWith("/hooks/status"); + if (!isHeartbeat) seen.headers = req.headers; + const chunks = []; + req.on("data", (c) => chunks.push(c)); req.on("end", () => { - seen.body = b; + if (!isHeartbeat) { + seen.raw = Buffer.concat(chunks); + seen.body = seen.raw.toString("utf8"); + } res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); }); @@ -203,3 +213,308 @@ test("SessionEnd → POSTs with x-rogue-event SessionEnd", async () => { server.close(); } }); + +// ── Subagent attribution (x-rogue-agent-id) ───────────────────────────────── +// The dispatcher resolves the running delegation as (subagent files present) +// minus (delegations the parent transcript records as finished), and sends the +// single remaining session UUID as x-rogue-agent-id. These fixtures are literal +// transcript records copied in shape from a real Gemini 0.55.1 session; NO +// timestamp, mtime or ordering is manipulated anywhere, because the rule reads +// none. + +const SESSION_ID = "d0fc529c-7537-40db-8302-ae175ef23655"; +const SUB_A = "f2401533-ab7c-4e7c-9a75-603c29d9e9c6"; +const SUB_B = "9db87efe-1f9e-4b8a-a41b-9367fb677095"; +const PROMPT_A = "How do I create a custom subagent?\nGive me the details."; +const PROMPT_B = "Write a poem about the sea."; + +// The parent's completed invoke_agent record. recordCompletedToolCalls stamps +// `agentId` with the subagent's session UUID; an abnormally terminated +// delegation is recorded WITHOUT it (agentId comes from the tool response). +function completionRecord(prompt, agentId) { + return JSON.stringify({ + id: "3cd90726-47e3-432a-ba34-bb9ccbbced71", + timestamp: "2026-08-13T09:25:14.818Z", + type: "gemini", + content: "", + toolCalls: [ + { + id: "invoke_agent__call_500699", + name: "invoke_agent", + args: { agent_name: "cli_help", prompt, wait_for_previous: true }, + status: "success", + ...(agentId ? { agentId } : {}), + }, + ], + }); +} + +// A subagent transcript: its header plus the first `user` record, which embeds +// the delegated prompt verbatim inside the context preamble. Nothing else in +// the file is ever read. +function subagentFile(uuid, prompt) { + return `${JSON.stringify({ + sessionId: uuid, + projectHash: "52b218b5", + startTime: "2026-08-13T09:27:02.187Z", + lastUpdated: "2026-08-13T09:27:02.187Z", + kind: "subagent", + directories: ["/tmp/project"], + })}\n${JSON.stringify({ + id: "e4da2b35-0de8-419f-acdb-d37187939a9f", + timestamp: "2026-08-13T09:27:05.698Z", + type: "user", + content: [{ text: `\n\n...\n\n${prompt}` }], + })}\n`; +} + +// Build chats/.jsonl + chats//.jsonl and return the +// transcript path. `subagents` is a list of [uuid, prompt] pairs. +function makeChats(parentRecords, subagents, { noSubDir = false } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rogue-gem-chats-")); + const chats = path.join(root, "chats"); + fs.mkdirSync(chats, { recursive: true }); + if (!noSubDir) { + fs.mkdirSync(path.join(chats, SESSION_ID)); + for (const [uuid, prompt] of subagents) { + fs.writeFileSync( + path.join(chats, SESSION_ID, `${uuid}.jsonl`), + subagentFile(uuid, prompt), + ); + } + } + const transcript = path.join(chats, "session-2026-08-13T09-24-d0fc529c.jsonl"); + fs.writeFileSync(transcript, parentRecords.map((r) => `${r}\n`).join("")); + return { root, transcript }; +} + +// POST one event and return the x-rogue-agent-id header (undefined = not sent). +// Asserts on every call that the body relayed is byte-identical to stdin. +async function agentIdFor(event, payload) { + const { server, seen, port } = await startServer(200, "{}"); + try { + await runHook(event, payload, { + ROGUE_API_KEY: "rsk_test", + ROGUE_BASE_URL: `http://127.0.0.1:${port}`, + }); + assert.deepEqual( + seen.raw, + Buffer.from(payload, "utf8"), + "the POSTed body must be the stdin bytes, unmodified", + ); + return seen.headers["x-rogue-agent-id"]; + } finally { + server.close(); + } +} + +// Standard tool-event payload: only the base fields Gemini actually sends. +function toolPayload(transcript, toolName) { + return JSON.stringify({ + session_id: SESSION_ID, + transcript_path: transcript, + cwd: "/tmp/project", + hook_event_name: "BeforeTool", + tool_name: toolName, + tool_input: { command: "ls" }, + }); +} + +test("one live delegation → sends its UUID as x-rogue-agent-id", async () => { + const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), + SUB_A, + ); + assert.equal( + await agentIdFor("BeforeTool", toolPayload(transcript, "run_shell_command")), + SUB_A, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("two live delegations → no header (concurrency is unattributable)", async () => { + const { root, transcript } = makeChats( + [], + [ + [SUB_A, PROMPT_A], + [SUB_B, PROMPT_B], + ], + ); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), + undefined, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("delegation recorded in the parent (agentId) → finished, no header", async () => { + const { root, transcript } = makeChats( + [completionRecord(PROMPT_A, SUB_A)], + [[SUB_A, PROMPT_A]], + ); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), + undefined, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("finished delegation + one live one → only the live UUID", async () => { + const { root, transcript } = makeChats( + [completionRecord(PROMPT_A, SUB_A)], + [ + [SUB_A, PROMPT_A], + [SUB_B, PROMPT_B], + ], + ); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), + SUB_B, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("abnormal termination (record without agentId) → prompt marks it finished", async () => { + // The delegation is recorded but carries no agentId (errored / cancelled / + // max-turns). Without the args.prompt fallback it would look live forever and + // mis-tag every later main-agent tool call. + const { root, transcript } = makeChats( + [completionRecord(PROMPT_A, null)], + [[SUB_A, PROMPT_A]], + ); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), + undefined, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("no subagent directory → no header", async () => { + const { root, transcript } = makeChats([], [], { noSubDir: true }); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), + undefined, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("parent transcript missing or oversized → no header", async () => { + const missing = makeChats([], [[SUB_A, PROMPT_A]]); + try { + fs.rmSync(missing.transcript); + assert.equal( + await agentIdFor( + "AfterTool", + toolPayload(missing.transcript, "run_shell_command"), + ), + undefined, + "unreadable parent → finished is uncomputable → no header", + ); + } finally { + fs.rmSync(missing.root, { recursive: true, force: true }); + } + + const big = makeChats([], [[SUB_A, PROMPT_A]]); + try { + // Sparse grow past the 32 MB cap; only statSync().size is consulted. + fs.truncateSync(big.transcript, 32 * 1024 * 1024 + 1); + assert.equal( + await agentIdFor("AfterTool", toolPayload(big.transcript, "run_shell_command")), + undefined, + ); + } finally { + fs.rmSync(big.root, { recursive: true, force: true }); + } +}); + +test("invoke_agent: BeforeTool sends no header, AfterTool sends the id", async () => { + // BeforeTool invoke_agent is the MAIN agent's delegation request, and in a + // parallel batch the first delegation's file already exists when the second's + // BeforeTool fires — tagging it would attribute agent 1's id to agent 2. + const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); + try { + assert.equal( + await agentIdFor("BeforeTool", toolPayload(transcript, "invoke_agent")), + undefined, + ); + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), + SUB_A, + "the delegation report IS the subagent's own output", + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("non-tool events never carry the tag", async () => { + const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); + const payload = JSON.stringify({ + session_id: SESSION_ID, + transcript_path: transcript, + cwd: "/tmp/project", + }); + try { + for (const event of ["SessionStart", "BeforeAgent", "AfterAgent", "BeforeModel"]) { + assert.equal(await agentIdFor(event, payload), undefined, event); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("malformed / minimal payloads resolve to no header, never a failed POST", async () => { + assert.equal(await agentIdFor("AfterTool", "not json at all"), undefined); + assert.equal(await agentIdFor("AfterTool", '{"tool_name":"x"}'), undefined); + assert.equal( + await agentIdFor("AfterTool", '{"session_id":123,"transcript_path":null}'), + undefined, + ); +}); + +test("body stays byte-identical even when a header is added", async () => { + const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); + // Pretty-printed, non-ASCII, trailing newline: anything re-serialized would + // come back compacted and/or re-escaped. + const payload = `${JSON.stringify( + { + session_id: SESSION_ID, + transcript_path: transcript, + tool_name: "run_shell_command", + tool_input: { command: "echo 'héllo — 世界' # \\u0041" }, + }, + null, + 2, + )}\n`; + const { server, seen, port } = await startServer(200, "{}"); + try { + await runHook("AfterTool", payload, { + ROGUE_API_KEY: "rsk_test", + ROGUE_BASE_URL: `http://127.0.0.1:${port}`, + }); + assert.equal(seen.headers["x-rogue-agent-id"], SUB_A); + assert.deepEqual(seen.raw, Buffer.from(payload, "utf8")); + } finally { + server.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); From dcc3be0efa97482956453ff3767ee734cd63855d Mon Sep 17 00:00:00 2001 From: Yuval Date: Sun, 6 Sep 2026 15:43:32 +0300 Subject: [PATCH 2/3] chore(gemini): 1.0.27 -> 1.0.28 The running subagent is resolved and sent as x-rogue-agent-id in this branch, so installs in the field need a new version to pull it. gemini-extension.json is the only version file for this plugin - there is no Gemini marketplace manifest - and scripts/plugin-versions.sh reads it for the release versions.json. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/gemini/gemini-extension.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/gemini/gemini-extension.json b/plugins/gemini/gemini-extension.json index d343cd3..91c4c64 100644 --- a/plugins/gemini/gemini-extension.json +++ b/plugins/gemini/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "rogue", - "version": "1.0.27", + "version": "1.0.28", "description": "Rogue Security AIDR — real-time AI agent detection and response for Gemini CLI", "contextFileName": "GEMINI.md" } From 551b8270f1f2aa2509ef2a5658c658e42c90d95c Mon Sep 17 00:00:00 2001 From: Yuval Date: Sun, 6 Sep 2026 16:07:19 +0300 Subject: [PATCH 3/3] fix(gemini): tag only the delegation report, never an ordinary tool event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live set — subagent files present minus delegations the parent records as finished — says which delegations are UNFINISHED. It was being read as which agent fired the event, and those are not the same thing, because the main agent keeps running tools inside that window: - invoke_agent is parallelizable, and the scheduler executes a maximal run of parallelizable calls together. A batch of [invoke_agent, shell] runs the shell alongside the delegation, and the shell's AfterTool fires while the delegation is live. - The parent's completion record, the only "finished" marker, is written once per MODEL RESPONSE after the whole scheduler run resolves, not once per batch. A response whose calls split into several batches (any edit tool forces a split) leaves the delegation live for every later batch, BeforeTool included. Either one hands a main-agent run_shell_command or replace the subagent's UUID — a false attribution in an audit trail, which is the one outcome this design said it would never produce. The header's "ONE ASSUMPTION" (that the completion record lands before the main agent's next tool hook) held for the subagent's own loop, which records per turn, and not for the main agent's. Nothing in the payload separates the two: LocalAgentExecutor hands the subagent the parent's Config and geminiClient, so createBaseInput reads session_id and transcript_path off the PARENT for a subagent's tool hook. A subagent's BeforeTool and the main agent's are byte-comparable. So the tag is now sent on `AfterTool invoke_agent` alone — a delegation event by its tool_name, which no main-agent tool call can wear. Per-tool attribution needs a signal upstream does not emit; it cannot be inferred here. Also: delegatedPrompts now collects only records that LACK an agentId. A record that has one is already resolved by the substring test on the id, so its prompt added nothing to `finished` and only widened what the prompt fallback matched — rerun a prompt and the OLD completed record marked the NEW live delegation finished, dropping the header for a delegation plainly running. What remains is a prompt whose delegation errored/cancelled, the one case with no id to match. Identical reruns there still fail open to no header, which is the safe direction and is now stated as such. Both are regression-tested, and both new tests fail against the previous hook.mjs. Citations re-verified against Gemini CLI 0.58.0. tests/test_hook_mjs.mjs was never run by CI — the file existed and nothing invoked it — so validate.yml now runs it, on the node 20 the job already pins. Its server also recorded ANY inbound request as the event POST, so the log shipper's /hooks/logs POST could overwrite the event under load and fail the byte-identity assert; it now records the event endpoint only. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/validate.yml | 8 ++ plugins/gemini/scripts/hook.mjs | 132 ++++++++++++++++++----------- tests/test_hook_mjs.mjs | 143 ++++++++++++++++++++------------ 3 files changed, 182 insertions(+), 101 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 39eb8ea..a65d1b7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -202,6 +202,14 @@ jobs: SH=dash sh tests/test_hook_logs.sh SH=bash bash tests/test_hook_logs.sh + - name: Gemini dispatcher contract (node) + # The only suite that exercises hook.mjs end to end against a real server, + # and until now the only dispatcher suite CI never ran — the file existed + # and nothing invoked it. It carries the subagent-attribution rules, whose + # failure mode is a WRONG x-rogue-agent-id on a main-agent tool row: a + # false attribution in an audit trail, which no other gate can see. + run: node --test tests/test_hook_mjs.mjs + - name: Log-shipper contract (sh) # The shipper is a byte-offset state machine over a file another process is # appending to and rotating underneath it, so its failure modes are silent by diff --git a/plugins/gemini/scripts/hook.mjs b/plugins/gemini/scripts/hook.mjs index c760ade..9c44641 100644 --- a/plugins/gemini/scripts/hook.mjs +++ b/plugins/gemini/scripts/hook.mjs @@ -200,11 +200,17 @@ function describeOutcome(bodyText) { // ── Subagent attribution (x-rogue-agent-id) ───────────────────────────────── // -// A Gemini subagent's OWN hook events are shape-identical to the main agent's: -// createBaseInput emits only session_id / transcript_path / cwd / -// hook_event_name / timestamp, and nothing in the payload names the delegation -// that is running. But Gemini's own transcript records do, and the rule is a -// BOOKKEEPING one rather than a timing one: +// A Gemini subagent's OWN hook events are shape-identical to the main agent's, +// and worse, they are shape-IDENTICAL in their identifying fields too: +// LocalAgentExecutor.executionContext (:347508-347510) hands the subagent the +// PARENT's Config and geminiClient and changes only promptId, so createBaseInput +// (:362493-362495) reads `session_id` off the parent's Config and +// `transcript_path` off the parent's recording service. A subagent's BeforeTool +// and the main agent's BeforeTool are byte-comparable. Nothing in the payload +// separates them, and nothing derived from it can. +// +// What the transcripts DO record is which delegations exist, on a bookkeeping +// rule rather than a timing one: // // a delegation appears in the subagent directory when it STARTS, // and in the parent transcript when it ENDS, @@ -220,50 +226,59 @@ function describeOutcome(bodyText) { // timestamps. The answer is the same however long a hook takes and whatever the // filesystem does with timestamp resolution. // -// Bundle citations (Gemini CLI 0.55.1, @google/gemini-cli chunk-TBDX7VEE.js): -// :285510-285531 ChatRecordingService.initialize — a subagent's transcript is -// `//.jsonl`, -// while the main session file sits directly in `/`. -// :253978-253980 sanitizeFilenamePart = part.replace(/[^a-zA-Z0-9_-]/g, "_"). -// :331296 recordCompletedToolCalls stamps `agentId` (the subagent's -// session UUID, i.e. its filename) on the parent's completed -// invoke_agent record. That record is the "finished" marker. +// THAT SET IS NOT WHO FIRED THE EVENT. It is only which delegations are +// unfinished, and the main agent keeps running tools inside that window: // -// THE ONE ASSUMPTION: the parent's completion record must land BEFORE the main -// agent's next tool hook. It does structurally, not by timing margin — -// recordCompletedToolCalls is documented (:331283-331284) as running "before -// sending responses to Gemini" and its caller (:347827) invokes it as soon as -// scheduleAgentTools resolves, so the model roundtrip that produces the next -// tool call cannot precede it. If upstream ever reordered that, a finished -// delegation would stay "live" and a MAIN-agent tool row would be tagged with a -// subagent's UUID. That is the only route in this design to a WRONG id; every -// other failure yields no header at all. A main-agent row carrying a subagent -// UUID is the signature to look for after a Gemini version bump. +// 1. invoke_agent is parallelizable (_isParallelizable, :346515-346525, +// returns false only for edit tools, update_topic and an explicit +// wait_for_previous: true), and the scheduler dequeues a maximal run of +// parallelizable calls and executes them together (:346493). A batch of +// [invoke_agent, run_shell_command] runs the shell concurrently with the +// delegation, and the shell's AfterTool fires while the delegation is live. +// 2. The parent's completion record — the ONLY "finished" marker — is written +// once per MODEL RESPONSE, after the whole scheduler run resolves +// (:387589 then :387623), not once per batch. A response whose calls split +// into several batches (any edit tool forces a split) leaves the delegation +// "live" for every later batch in that response, BeforeTool included. // -// The same ordering is what makes the delegation report itself work: the -// AfterTool invoke_agent hook fires before the completion record is written, so -// the finishing delegation is still "live" and its report row gets the id. - -// Cost guards. A session that never delegates pays one ENOENT and stops. +// So `live.length === 1` is not evidence that this event came from inside that +// subagent, and tagging an ordinary tool event on it hands a main-agent +// run_shell_command or replace the subagent's UUID. A wrong id is worse than a +// missing one — it is a false attribution in an audit trail — so ordinary tool +// events carry no tag at all. Recovering per-tool attribution needs a signal +// upstream does not currently emit; it cannot be inferred here. +// +// Bundle citations (Gemini CLI 0.58.0, @google/gemini-cli chunk-MFLFXOVQ.js): +// :285586-285603 ChatRecordingService.initialize — a subagent's transcript is +// `//.jsonl`, +// while the main session file sits directly in `/`. +// :347622 the subagent's sessionId IS its agentId (randomUUID), so the +// filename is a real per-instance id, not a slug two runs share. +// :331632 recordCompletedToolCalls stamps `agentId` on the parent's +// completed invoke_agent record. That record is "finished". +// :340820-340824 the AfterTool payload carries only llmContent/returnDisplay/ +// error — the tool response's `data.agentId` (:348714) is NOT +// relayed, which is why the id is resolved from disk at all. const PARENT_MAX_BYTES = 32 * 1024 * 1024; // over this, send no header const CANDIDATE_HEAD_BYTES = 64 * 1024; // enough for a subagent's first record -// Which events may carry the tag. Only a tool event can be fired BY a subagent. -// BeforeAgent/AfterAgent/BeforeModel/SessionStart/... are never a subagent's, -// and BeforeAgent carries the developer's prompt, where a wrong tag is worst. +// The one event the live set can legitimately name: `AfterTool invoke_agent`, +// the delegation's own completion. It is a delegation event by its tool_name, so +// no main-agent tool call can wear the tag, and the id it takes is the id of a +// delegation that is by construction unfinished at that moment — its own. +// +// The two-delegation case resolves conservatively rather than wrongly: a +// parallel batch of two invoke_agent calls leaves both live when either report +// fires, so `live.length !== 1` sends nothing rather than handing agent 1's id +// to agent 2's report. // -// `BeforeTool invoke_agent` (the main agent's delegation REQUEST) is excluded as -// a CORRECTNESS GUARD, not merely for semantics: in a parallel batch of two -// invoke_agent calls the first delegation's file already exists when the -// second's BeforeTool fires, so the rule would hand the FIRST agent's id to the -// SECOND agent's request. Suppressing the event removes the case entirely. -// (Delegations are parallelizable: _isParallelizable, :346148, returns false -// only for edit tools, update_topic, and an explicit wait_for_previous: true.) -// `AfterTool invoke_agent` is included — that one IS the subagent's own report. +// Every other event is excluded. Non-tool events (BeforeAgent, AfterAgent, +// BeforeModel, SessionStart, …) are never a subagent's, and BeforeAgent carries +// the developer's prompt, where a wrong tag is worst. Ordinary tool events are +// excluded because the live set does not identify who fired them — see the +// window analysis above. function isAgentTaggableEvent(parsed) { - if (EVENT === "AfterTool") return true; - if (EVENT === "BeforeTool") return parsed.tool_name !== "invoke_agent"; - return false; + return EVENT === "AfterTool" && parsed.tool_name === "invoke_agent"; } // Read a file as UTF-8, or null if it is missing, unreadable or over `maxBytes`. @@ -279,6 +294,14 @@ function readCapped(file, maxBytes) { // The delegated prompts recorded in the parent, JSON-escaped for a raw-text // substring test against a subagent file (see hasDelegatedPrompt). Only lines // mentioning invoke_agent are parsed; the rest of the transcript is never JSON. +// +// ONLY records that lack an agentId contribute. A record that has one is already +// matched by the substring test on the id itself, so collecting its prompt adds +// no delegation to `finished` — it only widens what the prompt test matches, and +// a prompt is not unique to a delegation. Rerun the same prompt and the OLD +// completed record would mark the NEW live delegation finished, dropping the +// attribution of a delegation that is plainly running. A prompt collected here +// is instead the only trace an errored / cancelled / max-turns delegation left. function delegatedPrompts(parentText) { const out = []; for (const line of parentText.split("\n")) { @@ -290,8 +313,10 @@ function delegatedPrompts(parentText) { continue; } for (const call of record?.toolCalls ?? []) { + if (call?.name !== "invoke_agent") continue; + if (typeof call?.agentId === "string" && call.agentId) continue; const prompt = call?.args?.prompt; - if (call?.name === "invoke_agent" && typeof prompt === "string" && prompt) { + if (typeof prompt === "string" && prompt) { out.push(JSON.stringify(prompt).slice(1, -1)); } } @@ -302,7 +327,7 @@ function delegatedPrompts(parentText) { // Fallback "finished" key for a delegation that ended WITHOUT an agentId: that // value is read out of the tool RESPONSE, so an errored / cancelled / max-turns // agent can be recorded without one, and it would otherwise stay "live" for the -// rest of the session and mis-tag every later main-agent tool call. `args` comes +// rest of the session and suppress every later delegation's report. `args` comes // from the REQUEST and is always there, and the subagent's first `user` record // embeds the delegated prompt verbatim inside its context preamble. // @@ -310,6 +335,12 @@ function delegatedPrompts(parentText) { // truncated head is needed and a multi-line prompt still matches. An unreadable // head returns false, i.e. the candidate stays live and the usual // unique-or-nothing guard applies. +// +// A prompt is not an identifier, so this cannot prove WHICH delegation it +// finished: rerun a prompt whose earlier delegation ended without an agentId and +// the live rerun matches it too. That direction is deliberate — it costs the +// rerun its header, where the opposite default would leave a dead delegation +// live and hand ITS id to the rerun's report. function hasDelegatedPrompt(file, escapedPrompts) { if (escapedPrompts.length === 0) return false; let head = ""; @@ -328,9 +359,13 @@ function hasDelegatedPrompt(file, escapedPrompts) { return escapedPrompts.some((p) => head.includes(p)); } -// Returns the running delegation's session UUID, or undefined. NEVER throws: +// Returns the reporting delegation's session UUID, or undefined. NEVER throws: // every failure path is "no header", because a wrong agent id is worse than a // missing one and a throw here would cost the POST itself. +// +// Only reached for `AfterTool invoke_agent` — the delegation's own completion, +// which fires before the parent's record of it is written, so the delegation +// that is reporting is still one of the unfinished ones. See isAgentTaggableEvent. function resolveSubagentId(parsed) { try { if (!parsed || typeof parsed !== "object") return undefined; @@ -341,7 +376,7 @@ function resolveSubagentId(parsed) { if (typeof transcript !== "string" || !transcript) return undefined; if (typeof session !== "string" || !session) return undefined; - // Upstream's own sanitizer (:253978-253980); it also makes the joined path + // Upstream's own sanitizer (:254005-254007); it also makes the joined path // traversal-proof, since "/" and "." both become "_". const dir = path.join( path.dirname(transcript), @@ -372,8 +407,9 @@ function resolveSubagentId(parsed) { (id) => !hasDelegatedPrompt(path.join(dir, `${id}.jsonl`), prompts), ); - // Zero → a main-agent event. Two or more → concurrent delegations, which the - // rule cannot separate. Both send nothing. + // Zero → the reporting delegation was already resolved by the prompt + // fallback. Two or more → a parallel batch of delegations, which the rule + // cannot separate. Both send nothing. if (live.length !== 1) return undefined; // The id is a filename, i.e. arbitrary bytes from disk. An invalid header // value makes fetch THROW, which would fail-open the whole POST — far worse diff --git a/tests/test_hook_mjs.mjs b/tests/test_hook_mjs.mjs index ce0e0b3..db125af 100644 --- a/tests/test_hook_mjs.mjs +++ b/tests/test_hook_mjs.mjs @@ -53,16 +53,21 @@ function startServer(status, body) { return new Promise((resolve) => { const seen = {}; const server = http.createServer((req, res) => { - // SessionStart also spawns the DETACHED heartbeat (hook.mjs fireHeartbeat), - // which POSTs its own body to /api/v1/hooks/status and races the event - // POST. Answer it, but never let it overwrite what the event POST recorded, - // or a SessionStart assertion reads the heartbeat's body instead. - const isHeartbeat = (req.url || "").endsWith("/hooks/status"); - if (!isHeartbeat) seen.headers = req.headers; + // SessionStart and AfterAgent also spawn the DETACHED heartbeat (hook.mjs + // fireHeartbeat), which POSTs to /api/v1/hooks/status and, riding along + // inside it, the log shipper, which POSTs to /api/v1/hooks/logs. Both race + // the event POST on this same server. Answer them, but record ONLY the + // event endpoint, or an assertion reads a side channel's body instead. + // + // A whitelist, not a list of the side channels to skip: excluding + // /hooks/status alone left the shipper recording over the event, which is + // a byte-identity failure that only shows up under load. + const isEvent = (req.url || "").endsWith("/hooks/gemini"); + if (isEvent) seen.headers = req.headers; const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { - if (!isHeartbeat) { + if (isEvent) { seen.raw = Buffer.concat(chunks); seen.body = seen.raw.toString("utf8"); } @@ -292,12 +297,16 @@ test("SessionEnd → POSTs with x-rogue-event SessionEnd", async () => { }); // ── Subagent attribution (x-rogue-agent-id) ───────────────────────────────── -// The dispatcher resolves the running delegation as (subagent files present) +// The dispatcher resolves the reporting delegation as (subagent files present) // minus (delegations the parent transcript records as finished), and sends the -// single remaining session UUID as x-rogue-agent-id. These fixtures are literal -// transcript records copied in shape from a real Gemini 0.55.1 session; NO -// timestamp, mtime or ordering is manipulated anywhere, because the rule reads -// none. +// single remaining session UUID as x-rogue-agent-id on `AfterTool invoke_agent` +// alone. These fixtures are literal transcript records copied in shape from a +// real Gemini session; NO timestamp, mtime or ordering is manipulated anywhere, +// because the rule reads none. +// +// The event restriction is the load-bearing correctness property here, so it is +// asserted from both sides: the delegation report gets the id, and every +// ordinary tool event gets nothing however live the delegation looks. const SESSION_ID = "d0fc529c-7537-40db-8302-ae175ef23655"; const SUB_A = "f2401533-ab7c-4e7c-9a75-603c29d9e9c6"; @@ -397,22 +406,48 @@ function toolPayload(transcript, toolName) { }); } -test("one live delegation → sends its UUID as x-rogue-agent-id", async () => { +test("the delegation report carries the live subagent's UUID", async () => { const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); try { assert.equal( - await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), - SUB_A, - ); - assert.equal( - await agentIdFor("BeforeTool", toolPayload(transcript, "run_shell_command")), + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), SUB_A, + "AfterTool invoke_agent IS the delegation's own completion", ); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); +test("no ordinary tool event is tagged, however live the delegation looks", async () => { + // The live set says which delegations are unfinished, NOT who fired the event. + // The main agent keeps running tools inside that window: invoke_agent is + // parallelizable, so a batch of [invoke_agent, run_shell_command] runs the + // shell alongside it, and the parent's completion record — the only "finished" + // marker — is written once per model response, after the whole scheduler run, + // so every later batch in that response sees the delegation live too. Tagging + // any of those events attributes a MAIN-agent tool call to the subagent. + const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); + try { + for (const tool of ["run_shell_command", "read_file", "replace", "invoke_agent"]) { + assert.equal( + await agentIdFor("BeforeTool", toolPayload(transcript, tool)), + undefined, + `BeforeTool ${tool}`, + ); + } + for (const tool of ["run_shell_command", "read_file", "replace"]) { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, tool)), + undefined, + `AfterTool ${tool}`, + ); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test("two live delegations → no header (concurrency is unattributable)", async () => { const { root, transcript } = makeChats( [], @@ -423,7 +458,7 @@ test("two live delegations → no header (concurrency is unattributable)", async ); try { assert.equal( - await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), undefined, ); } finally { @@ -438,7 +473,7 @@ test("delegation recorded in the parent (agentId) → finished, no header", asyn ); try { assert.equal( - await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), undefined, ); } finally { @@ -456,7 +491,29 @@ test("finished delegation + one live one → only the live UUID", async () => { ); try { assert.equal( - await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), + SUB_B, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("rerunning a completed delegation's prompt keeps the rerun's own id", async () => { + // SUB_A finished normally, so its record carries an agentId and the substring + // test alone resolves it. SUB_B is a live rerun of the SAME prompt. Collecting + // prompts from records that already have an agentId would let SUB_A's record + // mark SUB_B finished and drop the header for a delegation that is running. + const { root, transcript } = makeChats( + [completionRecord(PROMPT_A, SUB_A)], + [ + [SUB_A, PROMPT_A], + [SUB_B, PROMPT_A], + ], + ); + try { + assert.equal( + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), SUB_B, ); } finally { @@ -467,14 +524,14 @@ test("finished delegation + one live one → only the live UUID", async () => { test("abnormal termination (record without agentId) → prompt marks it finished", async () => { // The delegation is recorded but carries no agentId (errored / cancelled / // max-turns). Without the args.prompt fallback it would look live forever and - // mis-tag every later main-agent tool call. + // suppress every later delegation's report. const { root, transcript } = makeChats( [completionRecord(PROMPT_A, null)], [[SUB_A, PROMPT_A]], ); try { assert.equal( - await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), undefined, ); } finally { @@ -486,7 +543,7 @@ test("no subagent directory → no header", async () => { const { root, transcript } = makeChats([], [], { noSubDir: true }); try { assert.equal( - await agentIdFor("AfterTool", toolPayload(transcript, "run_shell_command")), + await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), undefined, ); } finally { @@ -499,10 +556,7 @@ test("parent transcript missing or oversized → no header", async () => { try { fs.rmSync(missing.transcript); assert.equal( - await agentIdFor( - "AfterTool", - toolPayload(missing.transcript, "run_shell_command"), - ), + await agentIdFor("AfterTool", toolPayload(missing.transcript, "invoke_agent")), undefined, "unreadable parent → finished is uncomputable → no header", ); @@ -515,7 +569,7 @@ test("parent transcript missing or oversized → no header", async () => { // Sparse grow past the 32 MB cap; only statSync().size is consulted. fs.truncateSync(big.transcript, 32 * 1024 * 1024 + 1); assert.equal( - await agentIdFor("AfterTool", toolPayload(big.transcript, "run_shell_command")), + await agentIdFor("AfterTool", toolPayload(big.transcript, "invoke_agent")), undefined, ); } finally { @@ -523,26 +577,6 @@ test("parent transcript missing or oversized → no header", async () => { } }); -test("invoke_agent: BeforeTool sends no header, AfterTool sends the id", async () => { - // BeforeTool invoke_agent is the MAIN agent's delegation request, and in a - // parallel batch the first delegation's file already exists when the second's - // BeforeTool fires — tagging it would attribute agent 1's id to agent 2. - const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); - try { - assert.equal( - await agentIdFor("BeforeTool", toolPayload(transcript, "invoke_agent")), - undefined, - ); - assert.equal( - await agentIdFor("AfterTool", toolPayload(transcript, "invoke_agent")), - SUB_A, - "the delegation report IS the subagent's own output", - ); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - test("non-tool events never carry the tag", async () => { const { root, transcript } = makeChats([], [[SUB_A, PROMPT_A]]); const payload = JSON.stringify({ @@ -561,9 +595,12 @@ test("non-tool events never carry the tag", async () => { test("malformed / minimal payloads resolve to no header, never a failed POST", async () => { assert.equal(await agentIdFor("AfterTool", "not json at all"), undefined); - assert.equal(await agentIdFor("AfterTool", '{"tool_name":"x"}'), undefined); + assert.equal(await agentIdFor("AfterTool", '{"tool_name":"invoke_agent"}'), undefined); assert.equal( - await agentIdFor("AfterTool", '{"session_id":123,"transcript_path":null}'), + await agentIdFor( + "AfterTool", + '{"tool_name":"invoke_agent","session_id":123,"transcript_path":null}', + ), undefined, ); }); @@ -576,8 +613,8 @@ test("body stays byte-identical even when a header is added", async () => { { session_id: SESSION_ID, transcript_path: transcript, - tool_name: "run_shell_command", - tool_input: { command: "echo 'héllo — 世界' # \\u0041" }, + tool_name: "invoke_agent", + tool_input: { agent_name: "cli_help", prompt: "héllo — 世界 # \\u0041" }, }, null, 2,