Skip to content

Commit 726755f

Browse files
author
pavelhov
authored
Merge pull request #64 from pavelhov/codex/fix-v2-plaintext-child-messages
fix(v2): mark child messages as plaintext and repair replay
2 parents 6fe8d16 + 8c43ba4 commit 726755f

9 files changed

Lines changed: 255 additions & 4 deletions

File tree

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ dist/
99
# `mktemp -d` path, and nowhere else. Do not use a repository. See the
1010
# "Security working notes" section of AGENTS.md.
1111
.tmp/
12+
.release.local.env
13+
.release-tools/
1214
.opencode/
1315

1416
# Local agent/session artifacts

‎AGENTS.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ Both branches use the same Bun-native TypeScript runtime. `development` is a
101101
staging branch, not a separate runtime port. If native code returns, introduce
102102
it incrementally through the same feature-PR and promotion workflow.
103103

104+
For a macOS in-app release, read `structure/06_docs-and-release.md` before asking
105+
for signing setup. Check the ignored project-root `.release.local.env` first;
106+
it records paths to the existing external key and project-local Sparkle tools.
107+
Validate those files before use and never print private key contents. An unset
108+
shell variable alone does not mean the release setup is missing.
109+
104110
Merge requirements are enforced by a GitHub **ruleset** on `main`, configured
105111
in repository settings (not in this repository's files). Two invariants:
106112

‎docs-site/src/content/docs/guides/sub-agent-surface.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ parent is plaintext, including messages to native ChatGPT workers. CodexCommande
224224
arguments from usage-debug body samples, but the trusted local Codex runtime and proxy necessarily
225225
handle the plaintext to deliver it.
226226

227+
Routed children can have `send_message` or `followup_task` without a `spawn_agent` tool. Their
228+
completed collaboration message calls also carry the plaintext marker. When an older task replays
229+
a delegated message that Codex stored as plain text in an encrypted-content slot, native plaintext
230+
delivery repairs that message before forwarding it, so the task can continue.
231+
227232
For a canonical ChatGPT parent, plaintext compatibility activates only with the complete recognized
228233
V2 schema. For a routed parent, CodexCommander marks only the exact `collaboration` message calls listed
229234
above; lifecycle and unrelated tools remain untouched. A partial, changed, malformed, or colliding

‎src/server/responses/collaboration.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,27 @@ export function collabSurface(parsed: CodexCommanderParsedRequest): "v1" | "v2"
186186
return namespacedSpawn ? "v1" : "v2"; // companionless shape follows spawn_agent namespacing
187187
}
188188

189+
/**
190+
* Plaintext V2 delivery applies to full V2 parents and to V2 children that only
191+
* expose message tools (send_message/followup_task) without spawn_agent. Without
192+
* this, a routed child's collaboration message is recorded as plaintext inside an
193+
* encrypted_content slot and poisons native continuation.
194+
*/
195+
export function plaintextV2DeliveryApplies(parsed: CodexCommanderParsedRequest): boolean {
196+
const surface = collabSurface(parsed);
197+
if (surface === "v2") return true;
198+
if (surface === "v1") return false;
199+
let spawn = false;
200+
let v1Only = false;
201+
let messageTool = false;
202+
for (const t of parsed.context.tools ?? []) {
203+
if (t.name === "spawn_agent") spawn = true;
204+
else if (t.name === "send_input" || t.name === "resume_agent" || t.name === "close_agent") v1Only = true;
205+
else if (t.name === "send_message" || t.name === "followup_task") messageTool = true;
206+
}
207+
return !spawn && !v1Only && messageTool;
208+
}
209+
189210

190211

191212
export interface MultiAgentGuidanceOptions {

‎src/server/responses/core.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,13 @@ import {
187187
import { composeSsePayloadRewrites, relaySseWithPayloadRewrite } from "../sse-payload-rewrite";
188188
import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
189189

190-
import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
191-
import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
190+
import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText, plaintextV2DeliveryApplies } from "./collaboration";
191+
import {
192+
hasUnreadableEncryptedAgentTask,
193+
looksLikeBackendCiphertext,
194+
sanitizeEncryptedContentInPlace,
195+
sanitizeNativePlaintextAgentMessagesInPlace,
196+
} from "./encrypted-payload";
192197
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
193198
import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
194199
import { recordUpstreamHostFailure, resetUpstreamHostHealth, upstreamHostHealthKey } from "../../codex/upstream-host-health";
@@ -1433,6 +1438,17 @@ async function handleResponsesInner(
14331438
console.warn(
14341439
`[codexcommander] rewrote ${rewritten} plaintext encrypted_content part(s) to input_text (spawn-message compatibility)`,
14351440
);
1441+
} else if (config.multiAgentV2MessageDelivery === "plaintext") {
1442+
// Plaintext V2 mode: a routed child may have delivered an unmarked message that Codex
1443+
// recorded as prose inside an agent_message encrypted_content slot. The native backend
1444+
// cannot decrypt it on replay, so repair only those slots; genuine ciphertext is untouched.
1445+
const repaired = sanitizeNativePlaintextAgentMessagesInPlace(
1446+
(body as { input?: unknown } | undefined)?.input,
1447+
);
1448+
if (repaired > 0)
1449+
console.warn(
1450+
`[codexcommander] repaired ${repaired} plaintext agent_message encrypted_content part(s) for native replay`,
1451+
);
14361452
}
14371453

14381454
let parsed;
@@ -1454,7 +1470,7 @@ async function handleResponsesInner(
14541470
return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
14551471
}
14561472
const plaintextV2DeliveryRequested = config.multiAgentV2MessageDelivery === "plaintext"
1457-
&& collabSurface(parsed) === "v2";
1473+
&& plaintextV2DeliveryApplies(parsed);
14581474
if (plaintextV2DeliveryRequested) {
14591475
// Collaboration arguments intentionally become readable to the local Codex runtime. Keep
14601476
// them out of the separate opt-in usage-debug persistence surface on every provider route.

‎src/server/responses/encrypted-payload.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,33 @@ export function encryptedSlotParts(payload: string): Array<Record<string, string
247247
return parts.length > 0 ? parts : [{ type: "input_text", text: payload }];
248248
}
249249

250+
/**
251+
* Repair plaintext V2 agent messages before native passthrough. A routed child can
252+
* emit an unmarked collaboration message, which Codex records as plaintext in an
253+
* encrypted_content slot. The native backend cannot decrypt that slot on replay.
254+
* Keep the agent_message envelope and genuine ciphertext intact.
255+
*/
256+
export function sanitizeNativePlaintextAgentMessagesInPlace(input: unknown): number {
257+
if (!Array.isArray(input)) return 0;
258+
let rewritten = 0;
259+
for (const item of input) {
260+
if (!item || typeof item !== "object" || (item as { type?: unknown }).type !== "agent_message") continue;
261+
const content = (item as { content?: unknown }).content;
262+
if (!Array.isArray(content)) continue;
263+
for (let index = 0; index < content.length; index += 1) {
264+
const part = content[index];
265+
if (!part || typeof part !== "object" || (part as { type?: unknown }).type !== "encrypted_content") continue;
266+
const payload = (part as { encrypted_content?: unknown }).encrypted_content;
267+
if (typeof payload !== "string" || looksLikeBackendCiphertext(payload)) continue;
268+
const parts = encryptedSlotParts(payload);
269+
content.splice(index, 1, ...parts);
270+
index += parts.length - 1;
271+
rewritten += 1;
272+
}
273+
}
274+
return rewritten;
275+
}
276+
250277

251278

252279
export function hasEncryptedContentPart(content: unknown): boolean {
@@ -305,4 +332,3 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
305332
visit(input);
306333
return rewritten;
307334
}
308-

‎structure/03_catalog-and-subagents.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,10 @@ canonical ChatGPT requests atomically alias the complete known collaboration nam
231231
the three message encryption markers, then restore the namespace and add Codex's
232232
`encrypted_function_args: []` plaintext sentinel on the response. Routed adapters add that sentinel
233233
only to completed `spawn_agent`, `send_message`, and `followup_task` calls at the bridge boundary.
234+
This also applies to routed children whose available collaboration tools contain only
235+
`send_message` or `followup_task` and no `spawn_agent`. Native plaintext-mode requests repair
236+
plain prose mistakenly stored in an `agent_message` encrypted-content slot on replay, while
237+
preserving genuine ciphertext and the agent-message envelope.
234238
Lifecycle and unrelated tools remain unchanged. Because the parent schema is fixed before worker
235239
selection, all V2 delegation messages in that parent session become plaintext, including native
236240
children; usage-debug body sampling is suppressed for those turns. Changes affect subsequent

‎structure/06_docs-and-release.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ not Ed25519 verification.
136136

137137
Maintainer configuration (no production update key is supplied by this repository):
138138

139+
Before asking the maintainer for paths, source the ignored project-root
140+
`.release.local.env` on the release machine. It records the existing external
141+
key paths, project-local Sparkle tool path, and current build/version inputs
142+
even when the shell has no release variables. Validate the referenced files,
143+
private-key permissions, public-key agreement, and build inventory before use.
144+
Never print key contents or commit this local file. Prior ignored
145+
`.tmp/release-*/release-env.sh` files are migration sources only.
146+
139147
- `MACOS_UPDATE_PUBLIC_KEY_FILE`: physical, single-link file containing the
140148
canonical Base64 32-byte Ed25519 public key. Store the private counterpart in
141149
maintainer secret storage with a recovery backup; only this public value is

‎tests/v2-plaintext-collaboration.test.ts‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,3 +741,166 @@ describe("routed Responses-native V2 plaintext integration", () => {
741741
}
742742
});
743743
});
744+
745+
function childMessageNamespace(extra: Record<string, unknown>[] = []): Record<string, unknown> {
746+
return {
747+
type: "namespace",
748+
name: "collaboration",
749+
description: "V2 child collaboration",
750+
tools: [messageTool("send_message"), messageTool("followup_task"), ...extra],
751+
};
752+
}
753+
754+
function routedChildBody(stream: boolean, namespace = childMessageNamespace()): Record<string, unknown> {
755+
return {
756+
model: "responses-native/v4",
757+
stream,
758+
input: [
759+
{
760+
type: "additional_tools",
761+
role: "developer",
762+
tools: [{ type: "custom", name: "exec_command" }, namespace],
763+
},
764+
{ type: "message", role: "user", content: [{ type: "input_text", text: "report back" }] },
765+
],
766+
};
767+
}
768+
769+
function childSendMessageSse(): string {
770+
return completedSse("collaboration").replaceAll('"name":"spawn_agent"', '"name":"send_message"');
771+
}
772+
773+
function sseFunctionCalls(text: string): Record<string, unknown>[] {
774+
return text
775+
.split(/\r?\n/)
776+
.filter(line => line.startsWith("data: {"))
777+
.map(line => JSON.parse(line.slice(6)) as Record<string, unknown>)
778+
.flatMap(payload => {
779+
const direct = (payload as { item?: Record<string, unknown> }).item;
780+
const output = (payload as { response?: { output?: Record<string, unknown>[] } }).response?.output;
781+
return [...(direct ? [direct] : []), ...(output ?? [])];
782+
})
783+
.filter(item => item.type === "function_call");
784+
}
785+
786+
describe("routed V2 child with message-only collaboration tools", () => {
787+
test("marks streamed send_message without spawn_agent and suppresses usage-debug samples", async () => {
788+
globalThis.fetch = (async () => new Response(childSendMessageSse(), {
789+
status: 200,
790+
headers: { "content-type": "text/event-stream" },
791+
})) as typeof fetch;
792+
const logCtx: RequestLogContext = { model: "", provider: "" };
793+
const response = await handleResponses(new Request("http://localhost/v1/responses", {
794+
method: "POST",
795+
headers: { "content-type": "application/json" },
796+
body: JSON.stringify(routedChildBody(true)),
797+
}), routedResponsesConfig("plaintext"), logCtx);
798+
799+
expect(response.status).toBe(200);
800+
const items = sseFunctionCalls(await response.text());
801+
expect(items.length).toBeGreaterThanOrEqual(3);
802+
for (const item of items) {
803+
expect(item).toMatchObject({ namespace: "collaboration", name: "send_message", encrypted_function_args: [] });
804+
}
805+
expect(logCtx.suppressUsageDebugBodySample).toBe(true);
806+
});
807+
808+
test("marks non-streamed followup_task without spawn_agent", async () => {
809+
globalThis.fetch = (async () => Response.json(completedJson("collaboration"))) as typeof fetch;
810+
const logCtx: RequestLogContext = { model: "", provider: "" };
811+
const response = await handleResponses(new Request("http://localhost/v1/responses", {
812+
method: "POST",
813+
headers: { "content-type": "application/json" },
814+
body: JSON.stringify(routedChildBody(false)),
815+
}), routedResponsesConfig("plaintext"), logCtx);
816+
817+
expect(response.status).toBe(200);
818+
const client = await response.json() as { output: Record<string, unknown>[] };
819+
expect(client.output[0]).toMatchObject({
820+
namespace: "collaboration",
821+
name: "followup_task",
822+
encrypted_function_args: [],
823+
});
824+
expect(logCtx.suppressUsageDebugBodySample).toBe(true);
825+
});
826+
827+
test("keeps V1 companion tools excluded", async () => {
828+
globalThis.fetch = (async () => Response.json(completedJson("collaboration"))) as typeof fetch;
829+
const logCtx: RequestLogContext = { model: "", provider: "" };
830+
const v1Namespace = childMessageNamespace([
831+
{ type: "function", name: "send_input", parameters: { type: "object", properties: {} } },
832+
]);
833+
const response = await handleResponses(new Request("http://localhost/v1/responses", {
834+
method: "POST",
835+
headers: { "content-type": "application/json" },
836+
body: JSON.stringify(routedChildBody(false, v1Namespace)),
837+
}), routedResponsesConfig("plaintext"), logCtx);
838+
839+
expect(response.status).toBe(200);
840+
const client = await response.json() as { output: Record<string, unknown>[] };
841+
expect(client.output[0]).not.toHaveProperty("encrypted_function_args");
842+
expect(logCtx.suppressUsageDebugBodySample).toBeUndefined();
843+
});
844+
});
845+
846+
describe("native plaintext agent_message replay repair", () => {
847+
test("rewrites prose in agent_message encrypted slots and leaves Fernet untouched", async () => {
848+
const prose = "Worker finished: see notes, ready for review.";
849+
const encryptedTask = fernetFixture();
850+
let upstreamBody = "";
851+
globalThis.fetch = (async (_input, init) => {
852+
upstreamBody = typeof init?.body === "string" ? init.body : "";
853+
return Response.json(completedJson(V2_PLAINTEXT_COLLABORATION_NAMESPACE));
854+
}) as typeof fetch;
855+
const body = nativeV2Body(false);
856+
body.input = [
857+
...(body.input as unknown[]),
858+
{
859+
type: "agent_message",
860+
id: "amsg_plain",
861+
author: "/root/media_worker",
862+
recipient: "/root",
863+
content: [
864+
{ type: "input_text", text: "Message from /root/media_worker:" },
865+
{ type: "encrypted_content", encrypted_content: prose },
866+
],
867+
},
868+
{
869+
type: "agent_message",
870+
id: "amsg_fernet",
871+
author: "/root",
872+
recipient: "/root/worker",
873+
content: [{ type: "encrypted_content", encrypted_content: encryptedTask }],
874+
},
875+
];
876+
877+
const response = await handleResponses(new Request("http://localhost/v1/responses", {
878+
method: "POST",
879+
headers: { "content-type": "application/json", authorization: "Bearer test" },
880+
body: JSON.stringify(body),
881+
}), nativeConfig("plaintext"), { model: "", provider: "" });
882+
expect(response.status).toBe(200);
883+
await response.text();
884+
885+
const upstream = JSON.parse(upstreamBody) as { input: Record<string, unknown>[] };
886+
const plain = upstream.input.find(item => item.id === "amsg_plain");
887+
expect(plain).toEqual({
888+
type: "agent_message",
889+
id: "amsg_plain",
890+
author: "/root/media_worker",
891+
recipient: "/root",
892+
content: [
893+
{ type: "input_text", text: "Message from /root/media_worker:" },
894+
{ type: "input_text", text: prose },
895+
],
896+
});
897+
const fernet = upstream.input.find(item => item.id === "amsg_fernet");
898+
expect(fernet).toEqual({
899+
type: "agent_message",
900+
id: "amsg_fernet",
901+
author: "/root",
902+
recipient: "/root/worker",
903+
content: [{ type: "encrypted_content", encrypted_content: encryptedTask }],
904+
});
905+
});
906+
});

0 commit comments

Comments
 (0)