Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a51361f
fix: recover follow-up and final-answer agent messages
lzfxxx Sep 18, 2026
47f4e96
Merge remote-tracking branch 'origin/dev' into codex/four-agent-messa…
lzfxxx Sep 18, 2026
718e33b
fix: reject mismatched recovery envelope echoes
lzfxxx Sep 18, 2026
c9ec685
fix(codex): catch deferred WAL preflight open failures
lzfxxx Sep 18, 2026
d50fd06
Merge remote-tracking branch 'origin/dev' into codex/four-agent-messa…
lzfxxx Sep 18, 2026
19e52c2
test(remote-workspace): stop using the host runtime as the sandbox st…
lzfxxx Sep 18, 2026
3402a73
test(responses-state): fire the ACL belt on a short timer in the neve…
lzfxxx Sep 18, 2026
96ad9ec
test(layout): hold ratchet-overflow cases in sibling files
lzfxxx Sep 18, 2026
e02ab7f
test(catalog): expect routed rows to clear pins in default mode
lzfxxx Sep 18, 2026
1186695
Merge remote-tracking branch 'origin/dev' into codex/four-agent-messa…
lzfxxx Sep 18, 2026
55b9718
Merge remote-tracking branch 'origin/dev' into codex/four-agent-messa…
lzfxxx Sep 18, 2026
30c90bf
test(server): give the local-read capability case a real server budget
lzfxxx Sep 18, 2026
8f93c7d
fix: key the recovery cache on a JSON tuple, not a joined string
lzfxxx Sep 18, 2026
a881fc7
test(claude,responses,server): budget the live-server suites that fai…
lzfxxx Sep 18, 2026
f7c9f5e
test(cli): check the Aside bulk failure channel before its stdout
lzfxxx Sep 18, 2026
3c50995
fix(responses): align encrypted envelope guard with recovery
lzfxxx Sep 23, 2026
a6dca4d
Merge upstream dev into encrypted agent message recovery
lzfxxx Sep 23, 2026
0f020d0
test: remove unrelated fixture and timing changes from agent recovery PR
lzfxxx Sep 23, 2026
408d799
Merge remote-tracking branch 'upstream/dev' into codex/four-agent-mes…
lzfxxx Sep 23, 2026
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 @@ -228,7 +228,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 @@ -1236,12 +1236,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
75 changes: 49 additions & 26 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,15 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
}

function stripMatchingEnvelope(assignment: string, envelope: AgentEnvelope): string | null {
const match = ROUTING_HEADER.exec(assignment);
const header = envelope.messageType === "FINAL_ANSWER" ? FINAL_ANSWER_HEADER : ROUTING_HEADER;
const foreign = header === FINAL_ANSWER_HEADER ? ROUTING_HEADER : FINAL_ANSWER_HEADER;
if (foreign.test(assignment)) return null;
const match = header.exec(assignment);
if (!match) return 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 +315,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 @@ -198,21 +198,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]*\n\s*Task name\s*:[^\n]*\n\s*Sender\s*:[^\n]*\n\s*Payload\s*:\s*(?:\n|$)|(?:^|\n)Message Type\s*:\s*FINAL_ANSWER[^\n]*\n\s*(?:Task name\s*:[^\n]*\n\s*)?Sender\s*:[^\n]*\n\s*Payload\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 @@ -262,7 +262,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 @@ -171,10 +171,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 @@ -234,7 +240,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
13 changes: 13 additions & 0 deletions tests/helpers/agent-task-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ function routingEnvelope(

export const ROUTING_ENVELOPE = routingEnvelope();

function finalAnswerEnvelope(withTaskName: boolean, taskName = "/root/worker", sender = "/root"): string {
return [
"Message Type: FINAL_ANSWER",
...(withTaskName ? [`Task name: ${taskName}`] : []),
`Sender: ${sender}`,
"Payload:",
"",
].join("\n");
}

export const FINAL_ANSWER_ENVELOPE = finalAnswerEnvelope(false);
export const FINAL_ANSWER_TASK_ENVELOPE = finalAnswerEnvelope(true);

export function agentMessage(content: Array<Record<string, unknown>>): unknown[] {
return [{
type: "agent_message",
Expand Down
Loading
Loading