Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs-site/src/content/docs/guides/subagent-v1-default.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,10 @@ Four routes, in the order most people should try them:
3. **Trust a direct key-auth Responses relay.** A provider you explicitly mark with
`allowEncryptedV2AgentTasks: true` receives the opaque payload instead of the 400. Only do this
for a destination you know can consume it.
4. **Enable `agentTaskRecovery`.** Experimental and off by default. It recovers most fresh spawns
through the ChatGPT backend, at the cost of quota, latency and a dependency on undocumented
behavior, and it still loses message-type follow-ups and multipart envelopes.
4. **Enable `agentTaskRecovery`.** Experimental and off by default. It recovers unreadable encrypted
`NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and `FINAL_ANSWER` items through the ChatGPT backend, at
the cost of quota, latency and a dependency on undocumented behavior; combo recovery remains
limited to spawned-child turns, and split-token fragments stay unsupported.

See [Sub-agent Surface](/guides/sub-agent-surface/) for the full mechanics of each, and
[Agent configuration](/reference/configuration/agents/) for the settings themselves.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,13 @@ explicitly enabled and the final routed task contains an otherwise unreadable Fe
opencodex uses a raw Responses passthrough request to the fixed
`https://chatgpt.com/backend-api/codex/responses` endpoint with forward-mode authentication.
ChatGPT returns the plaintext assignment through a forced function call; opencodex then converts
only that task item to a standard user message before routed-provider dispatch.
only that task item to a standard user message before routed-provider dispatch. Direct routed
recovery, cached history replay, and the unreadable-task detector recognise all four codex-rs
agent-message types: `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and `FINAL_ANSWER`. Combo recovery
remains limited to spawned-child turns. A `FINAL_ANSWER` envelope may omit its `Task name` line.
Recovery then has no header address to compare with the item's recipient, so that single cross-check
does not run; the sender comparison and the cache scope, which still binds the structured recipient,
are unchanged.

This is not local decryption and does not fix the Codex wire protocol. It depends on undocumented
ChatGPT backend behavior and may stop working after a backend change. The recovered assignment is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1093,12 +1093,14 @@ whitespace-only strings remain unchanged, as do incomplete and mixed encrypted/u
Encrypted and unknown content is not normalized; native encrypted tasks still require the
separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery).

With task recovery enabled, replayed `NEW_TASK` and `MESSAGE` items reuse a cached assignment only
after validating the caller and matching the parent-thread scope. Replay restoration
does not make a new recovery request or extend cache expiry. Expired or unseen
ciphertext is not replaced. Fresh encrypted `NEW_TASK` and `MESSAGE` items use the same
opt-in recovery path, including native-parent `send_message` delivery. Message type,
sender, recipient, parent scope and caller credentials remain part of validation or cache identity.
With task recovery enabled, replayed `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and `FINAL_ANSWER`
items reuse a cached assignment only after validating the caller and matching the parent-thread
scope. Replay restoration does not make a new recovery request or extend cache expiry. Expired or
unseen ciphertext is not replaced. Fresh encrypted `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and
`FINAL_ANSWER` items use the same opt-in recovery path, including native-parent `send_message`
delivery. Message type, sender, recipient, parent scope and caller credentials remain part of
validation or cache identity. A `FINAL_ANSWER` without a `Task name` line has no header address to
cross-check, but its recipient still scopes the cache.

When a request contains several agent messages, cached replay restoration checks each
message independently. The cache separates message type, sender, recipient and ciphertext
Expand Down
80 changes: 53 additions & 27 deletions src/server/responses/agent-task-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,17 @@ interface AgentEnvelope {
encryptedStartIndex: number;
inputSnapshot: string;
headerText: string;
messageType: "NEW_TASK" | "MESSAGE";
taskName: string;
messageType: "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK" | "FINAL_ANSWER";
taskName: string | null;
sender: string;
ciphertexts: readonly string[];
author: string;
recipient: string;
}

const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE|FOLLOWUP_TASK)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
// FINAL_ANSWER omits the Task name line when the sender declares no recipient.
const FINAL_ANSWER_HEADER = /(?:^|\n)Message Type\s*:\s*FINAL_ANSWER\s*\n(?:Task name\s*:\s*(\S+)\s*\n)?Sender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;

function findEnvelope(input: unknown): AgentEnvelope | null {
if (!Array.isArray(input)) return null;
Expand All @@ -104,7 +106,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
if (!Array.isArray(content)) return null;

let headerText: string | null = null;
let messageType: "NEW_TASK" | "MESSAGE" | null = null;
let messageType: "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK" | "FINAL_ANSWER" | null = null;
let taskName: string | null = null;
let sender: string | null = null;
let encryptedStartIndex = -1;
Expand All @@ -119,16 +121,24 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
&& typeof part.text === "string"
) {
const match = ROUTING_HEADER.exec(part.text);
if (match) {
const finalMatch = match ? null : FINAL_ANSWER_HEADER.exec(part.text);
if (match || finalMatch) {
if (headerText !== null) return null;
const m = match ?? finalMatch!;
if (
part.text.slice(0, match.index).trim().length > 0
|| part.text.slice(match.index + match[0].length).trim().length > 0
part.text.slice(0, m.index).trim().length > 0
|| part.text.slice(m.index + m[0].length).trim().length > 0
) return null;
headerText = match[0].startsWith("\n") ? match[0].slice(1) : match[0];
messageType = match[1] as "NEW_TASK" | "MESSAGE";
taskName = match[2]!;
sender = match[3]!;
headerText = m[0].startsWith("\n") ? m[0].slice(1) : m[0];
if (match) {
messageType = match[1] as "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK";
taskName = match[2]!;
sender = match[3]!;
} else {
messageType = "FINAL_ANSWER";
taskName = finalMatch![1] ?? null;
sender = finalMatch![2]!;
}
}
}
if (part.type !== "encrypted_content") continue;
Expand All @@ -145,15 +155,20 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
if (
!headerText
|| !messageType
|| !taskName
|| !sender
|| encryptedStartIndex < 0
|| ciphertexts.length === 0
) return null;

const itemRecord = item as { author?: unknown; recipient?: unknown };
if (typeof itemRecord.author !== "string" || typeof itemRecord.recipient !== "string") return null;
if (itemRecord.author !== sender || itemRecord.recipient !== taskName) return null;
// A FINAL_ANSWER without a Task name line declares no recipient, so the structured
// recipient is only cross-checked when a task name is present; admission is the
// trust boundary either way.
if (
itemRecord.author !== sender
|| (taskName !== null && itemRecord.recipient !== taskName)
) return null;

return {
itemIndex,
Expand All @@ -170,10 +185,18 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
}

function stripMatchingEnvelope(assignment: string, envelope: AgentEnvelope): string | null {
const match = ROUTING_HEADER.exec(assignment);
if (!match) return assignment;
const header = envelope.messageType === "FINAL_ANSWER" ? FINAL_ANSWER_HEADER : ROUTING_HEADER;
const match = header.exec(assignment);
if (!match) {
// A routing header of the other family is still an echoed envelope: a bare payload does not
// carry one, and a same-family header past the start already fails closed, so reject it too.
const foreign = (header === FINAL_ANSWER_HEADER ? ROUTING_HEADER : FINAL_ANSWER_HEADER).exec(assignment);
return foreign ? null : assignment;
}
if (match.index !== 0) return null;
if (
if (envelope.messageType === "FINAL_ANSWER") {
if ((match[1] ?? null) !== envelope.taskName || match[2] !== envelope.sender) return null;
} else if (
match[1] !== envelope.messageType
|| match[2] !== envelope.taskName
|| match[3] !== envelope.sender
Expand Down Expand Up @@ -295,18 +318,21 @@ function admittedRecovery(
if (!envelope) return { admitted: false, reason: "unsupported_envelope" };
const admission = recoveryAdmission(req, config);
if (!admission) return { admitted: false, reason: "admission_denied" };
// A JSON-encoded fixed-order tuple, not a delimiter-joined string: a field that carries the
// delimiter shifts every boundary after it, so two envelopes could hash to one entry and one
// recovery would replay for the other. A FINAL_ANSWER that omits its Task name line carries no
// addressing in the header, which leaves the structured recipient as the only field separating
// two such envelopes and makes the boundary the whole difference.
const cacheKey = createHash("sha256")
.update(admission.cacheScope)
.update("\0")
.update(parentThreadId ?? "")
.update("\0")
.update(envelope.messageType)
.update("\0")
.update(envelope.taskName)
.update("\0")
.update(envelope.sender)
.update("\0")
.update(JSON.stringify(envelope.ciphertexts))
.update(JSON.stringify([
admission.cacheScope,
parentThreadId ?? "",
envelope.messageType,
envelope.taskName ?? "",
envelope.recipient,
envelope.sender,
envelope.ciphertexts,
]))
.digest("hex");
return { admitted: true, recovery: { envelope, admission, cacheKey } };
}
Expand Down
29 changes: 15 additions & 14 deletions src/server/responses/encrypted-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,21 +177,21 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[])
/**
* The routing header codex-rs writes above a delegated agent payload.
*
* `MESSAGE` is matched as well as `NEW_TASK`, and only for the unreadability CHECK --
* recovery stays NEW_TASK-only. #3021 reported a subagent `MESSAGE` arriving in the
* parent conversation as raw `gAAAA...` ciphertext after an `adapter_eof`. The detector
* decides "unreadable" by stripping the envelope and asking whether any plaintext
* survives, so an envelope shape it does not recognise counts as surviving text: a
* `MESSAGE` whose entire body is one Fernet token measured as READABLE and was forwarded
* verbatim.
* All four codex-rs message types are recognised: NEW_TASK, MESSAGE, FOLLOWUP_TASK,
* and FINAL_ANSWER, whose Task name line is optional. #3021 reported a subagent
* `MESSAGE` arriving in the parent conversation as raw `gAAAA...` ciphertext after an
* `adapter_eof`. The detector decides "unreadable" by stripping the envelope and asking
* whether any plaintext survives, so an envelope shape it does not recognise counts as
* surviving text: an unrecognised type whose entire body is one Fernet token measured as
* READABLE and would be forwarded verbatim.
*
* Widening the strip is not the same as widening recovery. Recovery decrypts, and
* decrypting a `MESSAGE` on the parent's behalf would build a plaintext oracle out of a
* payload the parent's session may have no right to read. This only lets the proxy
* NOTICE that what it is about to forward is unreadable ciphertext, which is what the
* report asks for: fail closed with a structured error rather than paste the token.
* This strip must therefore stay in step with the message types the opt-in recovery
* recognises (see agent-task-recovery.ts). The strip itself is still only detection: it
* lets the proxy notice that what it is about to forward is unreadable ciphertext and
* fail closed with a structured error rather than paste the token. Recovery admission,
* not the strip, is the trust boundary for decryption.
*/
export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE|FOLLOWUP_TASK)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)|(?:^|\n)Message Type\s*:\s*FINAL_ANSWER[^\n]*\n(?:Task name\s*:[^\n]*\n)?Sender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;

// CXC is the compatibility-hook control namespace. Strip only the tagged paragraph:
// later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are
Expand Down Expand Up @@ -241,7 +241,8 @@ function splitFernetParts(content: unknown[]): Set<object> {
export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
if (!Array.isArray(input)) return false;

// codex-rs appends one NEW_TASK agent_message at the current input tail. Historical
// codex-rs appends one agent_message (any of the four codex-rs message types) at the
// current input tail. Historical
// agent messages may be adjacent in full-history bodies; they must not poison the
// later task. compaction_trigger/additional_tools are trailing metadata rather than
// a newer user turn.
Expand Down
13 changes: 10 additions & 3 deletions structure/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,16 @@ Full derivation with per-line citations: `devlog/_plan/260816_codexrs_multiagent

`src/server/responses/agent-task-recovery.ts` admits at most 32 consecutive, individually complete
Fernet-shaped parts with a combined 2 MiB ciphertext limit. Every encrypted slot must belong to
that run. The existing credential admission precedes cache access; the cache key includes an
unambiguous ordered sequence. One fixed-endpoint request forwards separate parts, and assignment
that run. The existing credential admission precedes cache access; the cache key is a JSON-encoded
fixed-order tuple of every addressing field (scope, parent thread, message type, task name,
recipient, sender, ciphertexts) rather than a delimiter-joined string, so no field content can shift
a boundary. One fixed-endpoint request forwards separate parts, and assignment
replacement compares the complete original item snapshot before splicing the run. Recovery output
is model-transcribed plaintext, not cryptographic fidelity proof, and no internal outage retry is added.
Recovery recognises all four codex-rs message types (NEW_TASK, MESSAGE, FOLLOWUP_TASK,
FINAL_ANSWER); a FINAL_ANSWER envelope may omit the Task name line, in which case the
structured recipient is not cross-checked because the envelope names no recipient, and
admission remains the trust boundary.

`src/server/responses/encrypted-payload.ts` uses bounded concatenation only to recognize otherwise
unreadable split-token shapes. The sanitizer preserves just those fragment objects and continues
Expand Down Expand Up @@ -223,7 +229,8 @@ target its own `structuredClone` and its own concrete route, so a sibling's repa
them and a target resolving to a routed Responses wire would otherwise send what the parent's own
dispatch no longer does.

Nothing here decrypts, and the tail NEW_TASK envelope keeps `unreadable_encrypted_agent_task` and
Nothing here decrypts, and the tail agent_message envelope (any of the four codex-rs
message types) keeps `unreadable_encrypted_agent_task` and
its opt-in recovery unchanged: an unreadable current task still fails closed rather than reaching a
child with a marker where its assignment should be. An `agent_message` carrying unknown parts but
no ciphertext still reaches the wire unchanged and still draws the destination's own 422, which is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { saveConfig } from "../../src/config";
import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p";
import { isolationBudgetMs } from "../helpers/ci-watchdog";
import { SERVER_BUDGET_MS } from "../helpers/test-budget";
import { startServer } from "../../src/server";
import type { OcxConfig } from "../../src/types";
Expand Down Expand Up @@ -62,7 +63,9 @@ function cfg(anthropicBaseUrl: string, extraClaude?: Record<string, unknown>): O
providers: {
mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true, liveModels: false, models: ["test-model"] },
},
connectTimeoutMs: 250,
// Shortened on purpose so a wedged upstream fails fast; the wrapper's full-suite
// lane needs headroom for a loopback round-trip on a busy machine.
connectTimeoutMs: isolationBudgetMs(250),
claudeCode: { anthropicBaseUrl, ...extraClaude },
} as OcxConfig;
}
Expand Down
11 changes: 7 additions & 4 deletions tests/cli/cli-headless-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,13 @@ describe("Aside CLI recovery metadata", () => {
expect(await handleClientIntegrationCommand([
"enable", "--client", "aside", ...(wantsJson ? ["--json"] : []),
], runtime.deps)).toBe(1);
// Assert the failure channel before stdout. A request that never reached the route leaves
// stdout empty for a reason stderr alone names, and checking stdout first reports the empty
// rendering instead of the transport error that caused it.
expect(error.mock.calls.map(call => String(call[0])).join("\n")).toContain(result.message);
expect(runtime.requests).toEqual([{
path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true },
}]);
const stdout = log.mock.calls.map(call => String(call[0])).join("\n");
if (wantsJson) {
expect(JSON.parse(stdout)).toEqual(result);
Expand All @@ -1261,10 +1268,6 @@ describe("Aside CLI recovery metadata", () => {
"aside:9 Profile 9 recovery failed Recovery did not finish.",
]);
}
expect(error.mock.calls.map(call => String(call[0])).join("\n")).toContain(result.message);
expect(runtime.requests).toEqual([{
path: "/api/client-integrations/aside/profiles", method: "PUT", body: { enabled: true },
}]);
} finally {
log.mockRestore();
error.mockRestore();
Expand Down
Loading
Loading