Skip to content

Commit e9396d8

Browse files
fix(v2): fail fast on unreadable routed agent tasks
Detect V2 Fernet-encrypted agent tasks that cannot be decrypted by non-ChatGPT providers and return 400 early, preventing cost storms. Fixes the mixed-slot bypass found by Sol review: control preambles like [CXC-LEAF-GUARD] are no longer treated as actionable task text. Co-authored-by: Mathias <66031173+MathiasHeinke@users.noreply.github.com> Based-on: PR #283
1 parent fa212c7 commit e9396d8

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

src/server/responses.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit } from "../lib/upstream-retry";
5959
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
6060
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
61+
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
6162
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../providers/openai-virtual-models";
6263
import { isUsageDebugEnabled } from "../usage/debug";
6364
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "./request-decompress";
@@ -312,6 +313,57 @@ function looksLikeBackendCiphertext(payload: string): boolean {
312313
*/
313314
const FERNET_TOKEN_RUN = /gAAAA[A-Za-z0-9_-]{60,}={0,2}/g;
314315

316+
const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*NEW_TASK[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
317+
const AGENT_MESSAGE_CONTROL_PREAMBLE = /(?:^|\n)\[CXC-(?:LEAF-GUARD|SKILL-AFFORDANCE)\][\s\S]*?(?=\n{2,}|$)/g;
318+
319+
/**
320+
* True when a V2 agent message contains a backend-minted Fernet task but no
321+
* provider-readable task text. The routing envelope and hook-added control
322+
* preambles are metadata, not actionable work. Inspect this before spawn-message
323+
* sanitization splits mixed encrypted slots into plaintext and ciphertext parts.
324+
*/
325+
export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
326+
if (!Array.isArray(input)) return false;
327+
328+
return input.some(item => {
329+
if (!item || typeof item !== "object" || (item as { type?: unknown }).type !== "agent_message") {
330+
return false;
331+
}
332+
333+
const content = (item as { content?: unknown }).content;
334+
if (!Array.isArray(content)) return false;
335+
336+
let hasFernetTask = false;
337+
const readableParts: string[] = [];
338+
for (const part of content) {
339+
if (!part || typeof part !== "object") continue;
340+
const record = part as { type?: unknown; text?: unknown; encrypted_content?: unknown };
341+
if (
342+
(record.type === "input_text" || record.type === "text" || record.type === "output_text")
343+
&& typeof record.text === "string"
344+
) {
345+
readableParts.push(record.text);
346+
continue;
347+
}
348+
if (record.type !== "encrypted_content" || typeof record.encrypted_content !== "string") {
349+
continue;
350+
}
351+
352+
const withoutFernet = record.encrypted_content.replace(FERNET_TOKEN_RUN, "\n\n");
353+
if (withoutFernet !== record.encrypted_content) hasFernetTask = true;
354+
readableParts.push(withoutFernet);
355+
}
356+
357+
if (!hasFernetTask) return false;
358+
const readableTask = readableParts
359+
.join("\n\n")
360+
.replace(AGENT_MESSAGE_ROUTING_ENVELOPE, "\n")
361+
.replace(AGENT_MESSAGE_CONTROL_PREAMBLE, "\n")
362+
.trim();
363+
return readableTask.length === 0;
364+
});
365+
}
366+
315367
/**
316368
* Split a non-ciphertext encrypted slot into ordered parts: prose becomes input_text,
317369
* embedded Fernet blobs stay encrypted_content so the backend can still decrypt the
@@ -809,6 +861,9 @@ export async function handleResponses(
809861
const originalBody = body;
810862
body = expandPreviousResponseInput(body);
811863
const previousResponseInputExpanded = body !== originalBody;
864+
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
865+
(body as { input?: unknown } | undefined)?.input,
866+
);
812867

813868
// Spawn-message compatibility (both directions): agent_message task payloads ride in
814869
// encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
@@ -867,6 +922,17 @@ export async function handleResponses(
867922
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
868923
}
869924

925+
// The canonical ChatGPT backend can decrypt its V2 Fernet task tokens; routed
926+
// providers cannot. Reject the raw-input classification before adapter construction
927+
// or provider dispatch so an unreadable worker task cannot trigger a cost storm.
928+
if (!isCanonicalOpenAiForwardProvider(route.provider) && unreadableEncryptedAgentTask) {
929+
return formatErrorResponse(
930+
400,
931+
"invalid_request_error",
932+
"Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.",
933+
);
934+
}
935+
870936
// Apply the routed model id upstream: routing may strip a "<provider>/" namespace
871937
// (e.g. "opencode-go/deepseek-v4-pro" → "deepseek-v4-pro"). Adapters read parsed.modelId,
872938
// and the passthrough adapter serializes _rawBody, so rewrite both.
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import {
3+
handleResponses,
4+
hasUnreadableEncryptedAgentTask,
5+
} from "../src/server/responses";
6+
import type { OcxConfig } from "../src/types";
7+
8+
const originalFetch = globalThis.fetch;
9+
const FERNET_TASK = `gAAAA${"Ab1_-".repeat(20)}==`;
10+
const ROUTING_ENVELOPE = [
11+
"Message Type: NEW_TASK",
12+
"Task name: /root/worker",
13+
"Sender: /root",
14+
"Payload:",
15+
"",
16+
].join("\n");
17+
18+
afterEach(() => {
19+
globalThis.fetch = originalFetch;
20+
});
21+
22+
function agentMessage(content: Array<Record<string, unknown>>): unknown[] {
23+
return [{
24+
type: "agent_message",
25+
author: "/root",
26+
recipient: "/root/worker",
27+
content,
28+
}];
29+
}
30+
31+
function routedConfig(): OcxConfig {
32+
return {
33+
port: 0,
34+
defaultProvider: "xai",
35+
providers: {
36+
xai: {
37+
adapter: "openai-chat",
38+
baseUrl: "https://api.x.ai/v1",
39+
authMode: "key",
40+
apiKey: "test-xai-key",
41+
},
42+
},
43+
} as OcxConfig;
44+
}
45+
46+
function nativeConfig(): OcxConfig {
47+
return {
48+
port: 0,
49+
defaultProvider: "openai",
50+
providers: {
51+
openai: {
52+
adapter: "openai-responses",
53+
baseUrl: "https://chatgpt.com/backend-api/codex",
54+
authMode: "forward",
55+
codexAccountMode: "direct",
56+
},
57+
},
58+
} as OcxConfig;
59+
}
60+
61+
async function post(
62+
config: OcxConfig,
63+
model: string,
64+
input: unknown[],
65+
headers: HeadersInit = {},
66+
): Promise<Response> {
67+
return handleResponses(new Request("http://localhost/v1/responses", {
68+
method: "POST",
69+
headers: {
70+
"content-type": "application/json",
71+
...Object.fromEntries(new Headers(headers)),
72+
},
73+
body: JSON.stringify({ model, input, stream: false }),
74+
}), config, { model: "", provider: "" });
75+
}
76+
77+
describe("V2 routed agent-message ciphertext guard", () => {
78+
test("blocks a pure Fernet-only agent task", () => {
79+
expect(hasUnreadableEncryptedAgentTask(agentMessage([
80+
{ type: "encrypted_content", encrypted_content: FERNET_TASK },
81+
]))).toBe(true);
82+
});
83+
84+
test("blocks a routing envelope followed only by a Fernet task", () => {
85+
expect(hasUnreadableEncryptedAgentTask(agentMessage([
86+
{ type: "input_text", text: ROUTING_ENVELOPE },
87+
{ type: "encrypted_content", encrypted_content: FERNET_TASK },
88+
]))).toBe(true);
89+
});
90+
91+
test("blocks a control preamble mixed into the Fernet slot before sanitization", async () => {
92+
const input = agentMessage([
93+
{ type: "input_text", text: ROUTING_ENVELOPE },
94+
{
95+
type: "encrypted_content",
96+
encrypted_content: `[CXC-LEAF-GUARD] follow the worker boundary.\n\n${FERNET_TASK}`,
97+
},
98+
]);
99+
let fetchCalls = 0;
100+
globalThis.fetch = (async () => {
101+
fetchCalls += 1;
102+
throw new Error("provider dispatch must not happen");
103+
}) as typeof fetch;
104+
105+
const response = await post(routedConfig(), "xai/grok-4.5", input);
106+
const raw = await response.text();
107+
const json = JSON.parse(raw) as {
108+
error?: { type?: string; code?: string; message?: string };
109+
};
110+
111+
expect(response.status).toBe(400);
112+
expect(json.error).toMatchObject({
113+
type: "invalid_request_error",
114+
code: "invalid_request_error",
115+
});
116+
expect(json.error?.message).toContain("encrypted");
117+
expect(fetchCalls).toBe(0);
118+
expect(raw).not.toContain(FERNET_TASK);
119+
expect(raw).not.toContain("gAAAA");
120+
});
121+
122+
test("allows genuine readable task text after the envelope", () => {
123+
expect(hasUnreadableEncryptedAgentTask(agentMessage([
124+
{
125+
type: "input_text",
126+
text: `${ROUTING_ENVELOPE}Implement the focused regression test.`,
127+
},
128+
{ type: "encrypted_content", encrypted_content: FERNET_TASK },
129+
]))).toBe(false);
130+
});
131+
132+
test("ignores encrypted reasoning and compaction items", () => {
133+
expect(hasUnreadableEncryptedAgentTask([
134+
{ type: "reasoning", encrypted_content: FERNET_TASK, summary: [] },
135+
{ type: "compaction", encrypted_content: FERNET_TASK },
136+
])).toBe(false);
137+
});
138+
139+
test("allows the canonical ChatGPT route to forward the encrypted task", async () => {
140+
let forwardedBody = "";
141+
globalThis.fetch = (async (_input, init) => {
142+
forwardedBody = typeof init?.body === "string" ? init.body : "";
143+
return Response.json({
144+
id: "resp_native",
145+
object: "response",
146+
status: "completed",
147+
model: "gpt-5.5",
148+
output: [],
149+
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
150+
});
151+
}) as typeof fetch;
152+
153+
const input = agentMessage([
154+
{ type: "input_text", text: ROUTING_ENVELOPE },
155+
{ type: "encrypted_content", encrypted_content: FERNET_TASK },
156+
]);
157+
const response = await post(nativeConfig(), "gpt-5.5", input, {
158+
authorization: "Bearer caller-codex-token",
159+
});
160+
161+
expect(response.status).toBe(200);
162+
expect(forwardedBody).toContain(FERNET_TASK);
163+
});
164+
});

0 commit comments

Comments
 (0)