diff --git a/devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md b/devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md new file mode 100644 index 0000000000..f24b36fd9b --- /dev/null +++ b/devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md @@ -0,0 +1,97 @@ +# 020 — #4190: refuse vendor scaffolding on the qoder route + +Unit: `devlog/_plan/260911_l6_streaming_tools`. Lane L6, work-phase 2. +Issue: [#4190](https://github.com/lidge-jun/opencodex/issues/4190). + +## The contract that was being broken + +The qoder route is documented as a text and reasoning surface. `buildQoderArgs` launches the +CLI with `--tools "" --strict-mcp-config --setting-sources "" --max-turns 1 +--no-session-persistence`, and both `coding-agent/protocol.ts` and `coding-agent/turn.ts` +state that Codex retains tool control and vendor tools are never invoked. + +The reporter saw two things reach the client as assistant text anyway: an MCP lazy-loading +`` block enumerating the local machine's configured MCP servers from +`~/.qoder/mcp.json` and from plugins, and vendor tool-call markup opened as +`` and closed as `` — mismatched, which is what a model emitting +remembered markup looks like rather than a serializer's output. + +The proxy-side hole is one line of trust. `mapRawStreamEvent` forwards a `text_delta` +verbatim; frame *types* are filtered, frame *contents* are not. So whatever the vendor's agent +layer puts in the text channel is relayed, and the strongest version of this leak publishes +the operator's MCP server inventory to whoever is reading the turn. + +## What was implemented + +`src/adapters/qoder/scaffold-guard.ts`, a streaming filter, and `guardQoderScaffolding` in +the adapter, which wraps the `emit` callback handed to `runCodingAgentTurn`. That wrapper is +the last qoder-specific point in the path, which is why the guard sits there rather than in +the parser every coding-agent CLI shares — CodeBuddy runs the same turn code and is not part +of this report. + +Two shapes, two answers, per the lane packet's recorded decision: + +- A complete `` block is recognizable and + self-delimiting. It is removed and the answer around it survives. +- Anything else carrying a scaffolding signature — ``, or a `` with no opener — fails the turn closed. A partial + tool-call block has no reliable end, and the prose around it may be the vendor's own agent + narration rather than the model's answer, so repairing it would be guesswork. + +It is a stream filter, not a regex over a finished string. A marker can be split across +deltas, so a tail that is still a possible marker prefix is held rather than emitted, and the +terminal event flushes both channels first. That flush is load-bearing in a way that is not +obvious: `isContentEvent` in `empty-completion-guard.ts` counts only non-empty +`text_delta`s as content, so an answer swallowed in full and followed by `done` would reach +the client as a successful but empty turn instead of as the refusal it is. + +The suppressed block is discarded as it arrives; only the trailing bytes needed to spot a +split closer are kept, so an unclosed reminder cannot grow memory. A 64 KiB ceiling bounds +how much of a turn one unterminated block may swallow before the turn is refused. + +## Decisions this issue left open + +**Qoder only, not the shared coding-agent path.** The lane owns `src/adapters/qoder/` and +not `src/adapters/coding-agent/protocol.ts`, and the leak is reported only for Qoder. If +CodeBuddy turns out to do the same thing, the filter is a pure module and lifting it is a +small change — but it should be driven by a report, not by symmetry. + +**Fail closed rather than strip, for tool-call markup.** The issue's own review lists both +options. Silently deleting markup leaves the user with a mutilated answer and no signal that +the route's contract was violated; the error names the marker class and says why. + +**Not retryable.** The leak is intermittent, so a retry would often succeed. It is still +marked `retryable: false`: an automatic retry spends the operator's vendor credits on a +contract violation the proxy cannot influence, and hiding an intermittent violation is how it +stays unfixed. + +**Vendor tool-call frames are left alone.** `mapRawStreamEvent` maps a `tool_use` block to +`tool_call_start`. That is arguably also a contract violation, but it is a typed frame rather +than leaked text, the issue reports the text channel, and `protocol.ts` documents that seam +as deliberately prepared for a future tool bridge. + +**Known false positive.** A turn that legitimately discusses `` or +`` syntax will be stripped or refused. That is the cost of failing closed on a +route whose leak publishes the operator's MCP inventory, and it is the direction the packet +recorded. + +**Not touched: making the CLI actually run with MCP disabled.** The issue's first suggested +direction is to fix the spawn so the vendor agent layer never initializes. The installed +`@qoder-ai/qodercli` bundle still contains the reminder builder and appears to initialize it +despite the flags, so that fix lives in the vendor, not here. This guard is the containment +that does not depend on the vendor agreeing. + +## Verification + +Focused regression test at `tests/providers/qoder-scaffold-guard.test.ts`, beside the +existing `qoder-adapter.test.ts`, registered in `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`. It covers block removal, a marker split across +three deltas, the held tail released on flush, fail-closed on tool-call markup and on a stray +closer, the unterminated-block case, the latch, and the wrapper's terminal handling including +the flush-before-`done` rule and forwarding a vendor error rather than replacing it. Two +cases assert that neither the leaked server list nor the leaked shell command appears in the +refusal message. + +Local suite, typecheck and build: NOT RUN, by operator instruction for this dispatch round. +Hosted CI on the pushed head is the evidence. + diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index cee1514e55..4056d8bc26 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -997,6 +997,7 @@ "proxy-liveness.test.ts": "server", "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", + "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", "quota-bars-rows.test.ts": "gui", diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts index 20c0b5581b..1bb8821b0b 100644 --- a/src/adapters/qoder/adapter.ts +++ b/src/adapters/qoder/adapter.ts @@ -4,6 +4,7 @@ import { mapReasoningEffort } from "../../reasoning-effort"; import { buildSystemPrompt } from "../coding-agent/protocol"; import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn"; import { QODER_PROFILES, type QoderProfile } from "./profiles"; +import { QoderScaffoldFilter, QODER_SCAFFOLD_ERROR_CODE, qoderScaffoldErrorMessage } from "./scaffold-guard"; export type QoderAdapterDeps = CodingAgentDeps; @@ -31,6 +32,73 @@ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderCo return args; } +/** + * Wrap the turn's outbound channel with the scaffolding guard (#4190). + * + * The vendor CLI can put its own agent layer into the text channel despite being launched + * with tools and MCP disabled, and the shared stream-json parser forwards a text delta + * without inspecting it. This is the last point that is still qoder-specific, so the guard + * sits here rather than in the parser every coding-agent CLI shares. + * + * A terminal event flushes both channels first. The held tail is text the filter could not + * yet prove was not the start of a marker; dropping it would truncate a legitimate answer, + * and swallowing an entire response before forwarding a "done" reads downstream as an empty + * completion rather than as the refusal it is. + */ +export function guardQoderScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { + const textFilter = new QoderScaffoldFilter(); + const thinkingFilter = new QoderScaffoldFilter(); + let closed = false; + + const refuse = (reason: string): void => { + if (closed) return; + closed = true; + emit({ + type: "error", + message: qoderScaffoldErrorMessage(reason), + status: 502, + errorType: "upstream_error", + code: QODER_SCAFFOLD_ERROR_CODE, + // Intermittent, but a silent retry spends the operator's vendor credits on a + // contract violation the proxy cannot influence. Surface it instead. + retryable: false, + }); + }; + + return (event: AdapterEvent): void => { + if (closed) return; + if (event.type === "text_delta") { + const cleaned = textFilter.push(event.text); + if (cleaned.text) emit({ ...event, text: cleaned.text }); + if (cleaned.fail) refuse(cleaned.fail); + return; + } + if (event.type === "thinking_delta") { + const cleaned = thinkingFilter.push(event.thinking); + if (cleaned.text) emit({ ...event, thinking: cleaned.text }); + if (cleaned.fail) refuse(cleaned.fail); + return; + } + if (event.type === "done" || event.type === "error" || event.type === "incomplete") { + const tail = textFilter.flush(); + const reasoning = thinkingFilter.flush(); + if (tail.text) emit({ type: "text_delta", text: tail.text }); + if (reasoning.text) emit({ type: "thinking_delta", thinking: reasoning.text }); + const fail = tail.fail ?? reasoning.fail; + // A vendor error already carries the better explanation for why the turn ended; + // only a terminal that claims success is replaced. + if (fail && event.type !== "error") { + refuse(fail); + return; + } + closed = true; + emit(event); + return; + } + emit(event); + }; +} + export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapterDeps = {}): ProviderAdapter { return { name: "qoder", @@ -60,7 +128,7 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt provider, parsed, incoming, - emit, + emit: guardQoderScaffolding(emit), buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov), buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey), deps, diff --git a/src/adapters/qoder/scaffold-guard.ts b/src/adapters/qoder/scaffold-guard.ts new file mode 100644 index 0000000000..8a1b6df620 --- /dev/null +++ b/src/adapters/qoder/scaffold-guard.ts @@ -0,0 +1,233 @@ +/** + * Vendor-scaffolding guard for the qoder route (#4190). + * + * The qoder route is contractually a text and reasoning surface: the CLI is spawned with + * `--tools "" --strict-mcp-config --setting-sources ""`, and Codex keeps tool ownership. + * The vendor CLI does not always honour that. It has been observed emitting its own agent + * layer into the assistant text channel — an MCP lazy-loading `` block + * listing the local machine's configured MCP servers, and framework tool-call markup with a + * mismatched closer. Both reached the client verbatim, because the shared stream-json parser + * forwards a text delta without inspecting it. + * + * Two shapes, two answers. A complete `` block is recognizable and + * self-delimiting, so it is removed and the surrounding answer survives. Anything else that + * carries a scaffolding signature is not repairable by guesswork — a partial tool-call block + * has no reliable end, and the text around it may already be the vendor's own agent + * narration rather than the model's answer — so the turn fails closed instead. + * + * The filter is a stream, not a regex over a finished string: a marker can be split across + * deltas, so a tail that is still a possible marker prefix is held back rather than emitted. + * Callers must therefore `flush()` before forwarding a terminal event, or a legitimate + * answer ending in "<" would lose its last character. + */ + +/** Error code for a turn refused because vendor scaffolding reached the text channel. */ +export const QODER_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected"; + +const REMINDER_OPEN = "` opened and `` closed — mismatched, which is what + * a model emitting remembered markup looks like, and exactly why reconstructing the intended + * text is not possible. A stray `` with no opener is in the same class: + * the block it belonged to was already partly forwarded, or never existed. + */ +const UNREPAIRABLE_MARKERS = ["", "", REMINDER_CLOSE] as const; + +/** Markers that end a block rather than start one; their prefix is never an answer. */ +const CLOSING_MARKERS = new Set(["", REMINDER_CLOSE]); + +/** Every marker the scanner must be able to recognize mid-split. */ +const ALL_MARKERS = [REMINDER_OPEN, ...UNREPAIRABLE_MARKERS] as const; + +const MAX_MARKER_LENGTH = Math.max(...ALL_MARKERS.map(marker => marker.length)); + +/** + * True when `` so a truncated or self-closed tag still suppresses, + * which means `` in an ordinary answer would otherwise open a block and + * refuse the turn. A stem running to the end of the buffer still counts: more text may be + * arriving, and reading it as prose is the one reading that could release the block body. + */ +function reminderOpensHere(lowered: string, at: number): boolean { + const after = lowered[at + REMINDER_OPEN.length]; + return after === undefined || /[\s/>]/.test(after); +} + +/** + * Ceiling on a suppressed block before it is treated as unterminated. + * + * The block itself is discarded as it arrives, so this is not a memory bound — only the + * trailing bytes needed to spot a split closer are retained. It bounds how much of a turn a + * single unclosed reminder is allowed to swallow silently before the turn is refused. + */ +const MAX_SUPPRESSED_CHARS = 64 * 1024; + +/** Result of feeding one chunk: the text safe to forward, and a refusal reason once tripped. */ +export interface ScaffoldFilterResult { + /** Text cleared for the client. Empty when everything in the chunk was held or dropped. */ + text: string; + /** Non-null exactly once, on the chunk that trips the guard. */ + fail: string | null; +} + +/** Longest suffix of `text` that could still grow into one of the markers. */ +function heldSuffixLength(text: string): number { + const limit = Math.min(MAX_MARKER_LENGTH - 1, text.length); + for (let length = limit; length > 0; length--) { + const suffix = text.slice(text.length - length).toLowerCase(); + for (const marker of ALL_MARKERS) { + if (marker.length > length && marker.startsWith(suffix)) return length; + } + } + return 0; +} + +/** + * Streaming scaffolding filter for one channel (text or reasoning). + * + * One instance per channel: the two never share suppression state, so a reminder block + * opened in reasoning cannot swallow the answer text. + */ +export class QoderScaffoldFilter { + private mode: "pass" | "suppress" = "pass"; + private pending = ""; + private suppressedTail = ""; + private suppressedChars = 0; + private failed = false; + /** True once a reminder block has been suppressed on this channel. */ + private suppressedBlock = false; + /** Open reminder blocks; only the closer that unwinds the last one ends suppression. */ + private suppressDepth = 0; + + push(chunk: string): ScaffoldFilterResult { + if (this.failed || !chunk) return { text: "", fail: null }; + let cleared = ""; + let buffer = this.mode === "pass" ? this.pending + chunk : chunk; + this.pending = ""; + + for (;;) { + if (this.mode === "suppress") { + const scan = this.suppressedTail + buffer; + const scanned = scan.toLowerCase(); + // Unwind nesting rather than ending at the first closer. A reminder containing another + // reminder would otherwise hand the outer block's remaining body — the MCP server list + // in the reported leak — to the client as the model's answer, with a successful + // terminal and nothing to signal that anything had gone wrong. + let cursor = 0; + let close = -1; + for (;;) { + const nextClose = scanned.indexOf(REMINDER_CLOSE, cursor); + if (nextClose < 0) break; + let nextOpen = scanned.indexOf(REMINDER_OPEN, cursor); + while (nextOpen >= 0 && !reminderOpensHere(scanned, nextOpen)) { + nextOpen = scanned.indexOf(REMINDER_OPEN, nextOpen + 1); + } + if (nextOpen >= 0 && nextOpen < nextClose) { + this.suppressDepth += 1; + cursor = nextOpen + REMINDER_OPEN.length; + continue; + } + this.suppressDepth -= 1; + cursor = nextClose + REMINDER_CLOSE.length; + if (this.suppressDepth === 0) { + close = nextClose; + break; + } + } + if (close < 0) { + this.suppressedChars += buffer.length; + if (this.suppressedChars > MAX_SUPPRESSED_CHARS) { + return this.fail(cleared, `an unterminated ${REMINDER_OPEN}> block`); + } + // The block is discarded as it arrives; only enough tail to spot a split closer is kept. + // The tail must cover a split opener too, now that nesting is counted. + this.suppressedTail = scan.slice(Math.max(0, scan.length - (MAX_MARKER_LENGTH - 1))); + return { text: cleared, fail: null }; + } + buffer = scan.slice(close + REMINDER_CLOSE.length); + this.mode = "pass"; + this.suppressedTail = ""; + this.suppressedChars = 0; + continue; + } + + let earliest = -1; + let found = ""; + const lowered = buffer.toLowerCase(); + for (const marker of ALL_MARKERS) { + let at = lowered.indexOf(marker); + while (at >= 0 && marker === REMINDER_OPEN && !reminderOpensHere(lowered, at)) { + at = lowered.indexOf(marker, at + 1); + } + if (at < 0) continue; + // A closer sitting exactly where an opener starts cannot happen, so ties are impossible. + if (earliest < 0 || at < earliest) { + earliest = at; + found = marker; + } + } + + if (earliest < 0) { + const held = heldSuffixLength(buffer); + cleared += held > 0 ? buffer.slice(0, buffer.length - held) : buffer; + this.pending = held > 0 ? buffer.slice(buffer.length - held) : ""; + return { text: cleared, fail: null }; + } + + // Text produced before the scaffolding is the model's own answer, and it is kept — but + // only while this channel has not already suppressed a block. Once it has, the text + // between that block and an unrepairable marker is not an answer that happens to + // precede a leak; it is the region the vendor was narrating in, and in the reported + // case it carries the MCP server list. Forwarding it on the way to a refusal would + // publish exactly what the refusal exists to contain. + // A closer with no opener never keeps its prefix either: the block it belonged to was + // already partly forwarded or never existed, so the text ahead of it is that body. + if (!CLOSING_MARKERS.has(found) && (found === REMINDER_OPEN || !this.suppressedBlock)) { + cleared += buffer.slice(0, earliest); + } + if (found !== REMINDER_OPEN) return this.fail(cleared, `vendor tool-call markup (${found})`); + this.suppressedBlock = true; + this.mode = "suppress"; + this.suppressDepth = 1; + this.suppressedTail = ""; + this.suppressedChars = 0; + buffer = buffer.slice(earliest + REMINDER_OPEN.length); + } + } + + /** Release the held tail. Call before forwarding a terminal event, never mid-stream. */ + flush(): ScaffoldFilterResult { + if (this.failed) return { text: "", fail: null }; + if (this.mode === "suppress") return this.fail("", `an unterminated ${REMINDER_OPEN}> block`); + const text = this.pending; + this.pending = ""; + return { text, fail: null }; + } + + private fail(cleared: string, reason: string): ScaffoldFilterResult { + this.failed = true; + this.pending = ""; + this.suppressedTail = ""; + return { text: cleared, fail: reason }; + } +} + +/** + * Message for a refused turn. + * + * It names the marker class and nothing else. The leaked reminder in the report enumerated + * the operator's own MCP servers, so echoing the offending text back — into an error the + * client renders, and that a user may paste into an issue — would publish the thing this + * guard exists to contain. + */ +export function qoderScaffoldErrorMessage(reason: string): string { + return `Qoder CLI emitted ${reason} in the assistant text channel. This route runs the CLI with` + + " its own tools and MCP servers disabled and Codex owns tool control, so the turn was refused" + + " rather than forwarding vendor agent scaffolding to the client."; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 486a6de544..ba4339c178 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -832,6 +832,7 @@ "proxy-liveness.test.ts": "server", "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", + "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", "quota-bars-rows.test.ts": "gui", diff --git a/tests/providers/qoder-scaffold-guard.test.ts b/tests/providers/qoder-scaffold-guard.test.ts new file mode 100644 index 0000000000..65f06d7bc1 --- /dev/null +++ b/tests/providers/qoder-scaffold-guard.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, test } from "bun:test"; +import type { AdapterEvent } from "../../src/types"; +import { guardQoderScaffolding } from "../../src/adapters/qoder/adapter"; +import { + QoderScaffoldFilter, + QODER_SCAFFOLD_ERROR_CODE, +} from "../../src/adapters/qoder/scaffold-guard"; + +/** + * #4190: the qoder route is documented as a text and reasoning surface with the vendor CLI's + * own tools and MCP servers disabled, yet an MCP lazy-loading reminder listing the operator's + * configured servers, and vendor tool-call markup with a mismatched closer, reached the + * client as assistant text. + */ + +const REMINDER = "MCP lazy-loading is active.\n## Connected MCP servers\n" + + "- internal-notes\n- deploy-keys\nUse mcp_list / mcp_get / mcp_call."; + +const TOOL_MARKUP = "\ncd /srv/private && git status\n"; + +function collect(): { events: AdapterEvent[]; emit: (event: AdapterEvent) => void } { + const events: AdapterEvent[] = []; + return { events, emit: event => { events.push(event); } }; +} + +function textOf(events: AdapterEvent[]): string { + return events.map(event => (event.type === "text_delta" ? event.text : "")).join(""); +} + +describe("QoderScaffoldFilter", () => { + test("removes a complete reminder block and keeps the answer around it", () => { + const filter = new QoderScaffoldFilter(); + const first = filter.push(`Before.${REMINDER}After.`); + expect(first.fail).toBeNull(); + expect(first.text + filter.flush().text).toBe("Before.After."); + }); + + test("catches a marker split across deltas", () => { + const filter = new QoderScaffoldFilter(); + // The opening tag arrives in three pieces; a per-delta scan would miss it entirely. + const parts = ["Answer. secret server list Done."]; + const out = parts.map(part => filter.push(part)); + expect(out.every(result => result.fail === null)).toBe(true); + expect(out.map(result => result.text).join("") + filter.flush().text).toBe("Answer. Done."); + expect(out.map(result => result.text).join("")).not.toContain("secret server list"); + }); + + test("releases a held tail that never became a marker", () => { + const filter = new QoderScaffoldFilter(); + // "<" is a live marker prefix, so it cannot be forwarded until the stream ends. + const pushed = filter.push("compare a < b and a { + const filter = new QoderScaffoldFilter(); + const result = filter.push(`Checking the repositories.\n${TOOL_MARKUP}`); + expect(result.text).toBe("Checking the repositories.\n"); + expect(result.fail).toContain(" { + // The block it belonged to was already partly forwarded, or never existed. + expect(new QoderScaffoldFilter().push("tail").fail).toContain(""); + }); + + test("does not forward the region between a suppressed block and a refusal", () => { + // The text before the FIRST marker is the model's answer and is kept. The text after a + // block this filter already swallowed is the vendor's own narration, and in the reported + // leak that region is the MCP server list itself. + const filter = new QoderScaffoldFilter(); + const result = filter.push( + `a\n## Connected MCP servers\n- deploy-keys`, + ); + expect(result.text).toBe(""); + expect(result.text).not.toContain("deploy-keys"); + expect(result.fail).toContain(""); + }); + + test("does not forward vendor narration that sits between a reminder and tool markup", () => { + const filter = new QoderScaffoldFilter(); + const result = filter.push(`Status.${REMINDER}\n- deploy-keys\n${TOOL_MARKUP}`); + expect(result.text).toBe("Status."); + expect(result.text).not.toContain("deploy-keys"); + expect(result.fail).toContain(" { + // Ending at the first closer handed the outer block's remaining body to the client as + // the model's answer, with a successful terminal and no signal that anything was wrong. + const filter = new QoderScaffoldFilter(); + const result = filter.push( + "outerinner\n## Connected MCP servers\n- deploy-keys", + ); + expect(result.text).toBe(""); + const flushed = filter.flush(); + expect(flushed.text).not.toContain("deploy-keys"); + expect(flushed.fail).toContain("unterminated"); + }); + + test("keeps the answer after a nested reminder that does close", () => { + const filter = new QoderScaffoldFilter(); + const result = filter.push( + "oi- deploy-keys Done.", + ); + expect(result.text).toBe(" Done."); + expect(result.fail).toBeNull(); + }); + + test("counts nesting even when the tags are split across deltas", () => { + const filter = new QoderScaffoldFilter(); + const parts = ["oi- deploy-keys Done."]; + const out = parts.map(part => filter.push(part)); + expect(out.map(result => result.text).join("")).toBe(" Done."); + expect(out.every(result => result.fail === null)).toBe(true); + }); + + test("a closer with no opener forwards nothing ahead of it", () => { + // The prefix of a stray closer is the lost block's body, not an answer that preceded it. + const filter = new QoderScaffoldFilter(); + const result = filter.push("## Connected MCP servers\n- deploy-keys"); + expect(result.text).toBe(""); + expect(result.fail).toContain(""); + expect(new QoderScaffoldFilter().push("cd /srv/private && git status").text).toBe(""); + }); + + test("catches an invoke block that carries no attributes", () => { + // "", so the command shipped ahead of the refusal. + const filter = new QoderScaffoldFilter(); + const result = filter.push("Checking.\n\ncd /srv/private && git status\n"); + expect(result.text).toBe("Checking.\n"); + expect(result.text).not.toContain("git status"); + expect(result.fail).toContain(""); + }); + + test("does not open a block on a word that merely starts with the tag name", () => { + // The opener is matched without its ">", so it needs a token boundary of its own. + const filter = new QoderScaffoldFilter(); + const result = filter.push("the are documented"); + expect(result.text + filter.flush().text).toBe("the are documented"); + expect(result.fail).toBeNull(); + }); + + test("fails closed when a reminder is never terminated", () => { + const filter = new QoderScaffoldFilter(); + expect(filter.push("ok listing servers").fail).toBeNull(); + expect(filter.flush().fail).toContain("unterminated"); + }); + + test("latches: nothing more escapes after the guard trips", () => { + const filter = new QoderScaffoldFilter(); + expect(filter.push(TOOL_MARKUP).fail).not.toBeNull(); + expect(filter.push("more vendor narration")).toEqual({ text: "", fail: null }); + expect(filter.flush()).toEqual({ text: "", fail: null }); + }); +}); + +describe("guardQoderScaffolding", () => { + test("strips the reminder and still completes the turn", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + guarded({ type: "text_delta", text: `Here is the status.${REMINDER}` }); + guarded({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe("Here is the status."); + expect(textOf(events)).not.toContain("mcp_call"); + expect(events[events.length - 1]!.type).toBe("done"); + }); + + test("flushes the held tail before the terminal event", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + // Without the flush this answer would arrive truncated, and an answer that is entirely + // held back would reach the empty-completion guard as a successful but empty turn. + guarded({ type: "text_delta", text: "1 < 2" }); + guarded({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe("1 < 2"); + expect(events[events.length - 1]!.type).toBe("done"); + }); + + test("refuses the turn when tool-call markup leaks, and swallows the vendor's success", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + guarded({ type: "text_delta", text: `Checking.\n${TOOL_MARKUP}` }); + guarded({ type: "done", stopReason: "stop" }); + expect(textOf(events)).toBe("Checking.\n"); + const terminal = events[events.length - 1]!; + expect(terminal.type).toBe("error"); + if (terminal.type !== "error") throw new Error("expected an error terminal"); + expect(terminal.code).toBe(QODER_SCAFFOLD_ERROR_CODE); + expect(terminal.status).toBe(502); + expect(terminal.retryable).toBe(false); + expect(terminal.message).not.toContain("git status"); + expect(terminal.message).not.toContain("mcp_call"); + expect(events.filter(event => event.type === "done")).toHaveLength(0); + }); + + test("guards the reasoning channel independently of the text channel", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + guarded({ type: "thinking_delta", thinking: `Planning.${REMINDER}Continue.` }); + guarded({ type: "text_delta", text: "Answer." }); + guarded({ type: "done", stopReason: "stop" }); + const thinking = events.filter(event => event.type === "thinking_delta") + .map(event => event.type === "thinking_delta" ? event.thinking : "").join(""); + expect(thinking).toBe("Planning.Continue."); + expect(textOf(events)).toBe("Answer."); + }); + + test("forwards the vendor's own error rather than replacing it", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + guarded({ type: "text_delta", text: "partial never closed" }); + guarded({ type: "error", message: "Qoder CLI exited with code 118", status: 429 }); + const terminal = events[events.length - 1]!; + expect(terminal.type).toBe("error"); + if (terminal.type !== "error") throw new Error("expected an error terminal"); + // The vendor said why the turn ended; the guard's job here was only to drop the block. + expect(terminal.message).toBe("Qoder CLI exited with code 118"); + expect(textOf(events)).toBe("partial "); + }); + + test("passes unrelated events through untouched", () => { + const { events, emit } = collect(); + const guarded = guardQoderScaffolding(emit); + guarded({ type: "tool_call_start", id: "call_1", name: "exec" }); + guarded({ type: "done", stopReason: "stop" }); + expect(events.map(event => event.type)).toEqual(["tool_call_start", "done"]); + }); +});