fix(codex): safely deliver remote workspace files and images (#115668)

* fix(codex): safely deliver remote workspace files and images

* chore(codex): keep release notes in pull request

* style(scripts): repair current main formatting gate

* refactor(codex): remove unused remote filesystem RPC types

* docs: refresh generated documentation map

* test(codex): type remote workspace reader mock

* fix(ci): preserve plugin test ownership boundary

* refactor(codex): split remote transfer protocol and projection types
This commit is contained in:
Peter Steinberger
2026-07-29 03:37:50 -04:00
committed by GitHub
parent d37fd8f2d4
commit a7cecc85d3
20 changed files with 1859 additions and 124 deletions
+24 -24
View File
@@ -190,30 +190,30 @@ managed stdio or the local Unix control socket for production workloads.
`appServer` fields:
| Field | Default | Meaning |
| --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; explicit `"unix"` connects to the local control socket; `"websocket"` connects to `url`. |
| `homeScope` | `"agent"` | `"agent"` isolates ordinary harness state per OpenClaw agent. `"user"` is an explicit opt-in that shares the native `$CODEX_HOME` or `~/.codex`, uses native auth, and enables owner-only thread management. User scope supports local stdio or Unix transport. For the separate supervision connection, an unset value resolves to `"user"` for stdio or Unix and `"agent"` for WebSocket. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket App Server URL or `unix://` URL. An empty explicit Unix path selects the canonical user-home control socket. |
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. |
| `loopDetectionPreToolUseRelay` | `true` | Install the Codex `PreToolUse` subprocess used only for OpenClaw loop detection and its explicit no-policy marker. Set `false` to reduce per-tool process fan-out. Before-tool plugin hooks and trusted-tool policy still install their required relay. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. |
| `turnAssistantCompletionIdleTimeoutMs` | `10000` | Quiet window after a final/non-commentary assistant item or pre-tool raw assistant completion arms the assistant-output release while OpenClaw still waits for `turn/completed`. Raising it gives Codex more time to emit `turn/completed` before OpenClaw interrupts and releases the session lane. |
| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle and progress guard used after a tool handoff, native tool completion, post-tool raw assistant progress, raw reasoning completion, or reasoning progress while OpenClaw waits for `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start, resume, and turn. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start and resume. Active OpenClaw sandboxes narrow `danger-full-access` turns to Codex `workspace-write`; the turn network flag follows OpenClaw sandbox egress. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with the supported Codex app-server so native Codex execution can run inside the active OpenClaw sandbox. |
| Field | Default | Meaning |
| --------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; explicit `"unix"` connects to the local control socket; `"websocket"` connects to `url`. |
| `homeScope` | `"agent"` | `"agent"` isolates ordinary harness state per OpenClaw agent. `"user"` is an explicit opt-in that shares the native `$CODEX_HOME` or `~/.codex`, uses native auth, and enables owner-only thread management. User scope supports local stdio or Unix transport. For the separate supervision connection, an unset value resolves to `"user"` for stdio or Unix and `"agent"` for WebSocket. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket App Server URL or `unix://` URL. An empty explicit Unix path selects the canonical user-home control socket. |
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. OpenClaw maps the local cwd into this root and transfers authoritative remote attachments over an output-capped, no-shell `command/exec` reader. Paths escaping either workspace, symbolic links, oversized files, and unbounded attachment batches fail closed; uploads retain the configured channel identity and app-server request timeout. |
| `loopDetectionPreToolUseRelay` | `true` | Install the Codex `PreToolUse` subprocess used only for OpenClaw loop detection and its explicit no-policy marker. Set `false` to reduce per-tool process fan-out. Before-tool plugin hooks and trusted-tool policy still install their required relay. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. |
| `turnAssistantCompletionIdleTimeoutMs` | `10000` | Quiet window after a final/non-commentary assistant item or pre-tool raw assistant completion arms the assistant-output release while OpenClaw still waits for `turn/completed`. Raising it gives Codex more time to emit `turn/completed` before OpenClaw interrupts and releases the session lane. |
| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle and progress guard used after a tool handoff, native tool completion, post-tool raw assistant progress, raw reasoning completion, or reasoning progress while OpenClaw waits for `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start, resume, and turn. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start and resume. Active OpenClaw sandboxes narrow `danger-full-access` turns to Codex `workspace-write`; the turn network flag follows OpenClaw sandbox egress. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with the supported Codex app-server so native Codex execution can run inside the active OpenClaw sandbox. |
`appServer.networkProxy` is explicit because it changes the Codex sandbox
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` and
+35 -23
View File
@@ -14,6 +14,16 @@ native compaction, and app-server execution. OpenClaw still owns chat
channels, session files, model selection, OpenClaw dynamic tools, approvals,
media delivery, and the visible transcript mirror.
Remote Codex app-servers can run on a different machine from the Gateway. Set
`remoteWorkspaceRoot` to validate remote workspace attachment paths. OpenClaw
transfers authoritative attachment bytes over the existing app-server connection
using a fixed, no-shell `command/exec` reader. The reader rejects symlinks,
enforces file and response size limits before allocation, and stages immutable
Gateway-managed media before channel delivery without requiring a shared or
synchronized filesystem. Codex images are materialized directly from typed
app-server events; saved-path-only images use the same bounded remote reader.
Uploads always use the Gateway's configured channel identity and request timeout.
Use canonical OpenAI model refs such as `openai/gpt-5.6-sol`. Do not configure
legacy Codex GPT refs; put OpenAI agent auth order under `auth.order.openai`.
Legacy Codex auth profile ids and legacy Codex auth order entries are
@@ -59,6 +69,8 @@ channel is the communication surface.
- A stable Codex app-server from `0.143.0` through `0.145.0`. The plugin manages a compatible
binary by default, so a `codex` command on `PATH` does not affect normal
startup.
- Node.js on the remote Codex app-server host when `remoteWorkspaceRoot` is set
and cross-machine workspace attachments must be transferred.
- Codex auth through `openclaw models auth login --provider openai`, an
app-server account already present in the agent's Codex home, or an
explicit Codex API-key auth profile.
@@ -851,29 +863,29 @@ Supported top-level Codex plugin fields:
Supported `appServer` fields:
| Field | Default | Meaning |
| --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; explicit `"unix"` connects to the local control socket; `"websocket"` connects to `url`. |
| `homeScope` | `"agent"` | `"agent"` isolates ordinary harness state per OpenClaw agent. `"user"` is an explicit opt-in that shares the native `$CODEX_HOME` or `~/.codex`, uses native auth, and enables owner-only thread management. User scope supports local stdio or Unix transport. For the separate supervision connection, an unset value resolves to `"user"` for stdio or Unix and `"agent"` for WebSocket. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket App Server URL or `unix://` URL. An empty explicit Unix path selects the canonical user-home control socket. |
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps the selected `CODEX_HOME` and inherited `HOME` for local launches. |
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. Ordinary OpenClaw dynamic tools remain available through nested `tools.*` calls; `openclaw_direct` tools stay directly model-visible. |
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. |
| `turnAssistantCompletionIdleTimeoutMs` | `10000` | Quiet window after a final/non-commentary assistant item or pre-tool raw assistant completion arms the assistant-output release while OpenClaw still waits for `turn/completed`. Raising it gives Codex more time to emit `turn/completed` before OpenClaw interrupts and releases the session lane. |
| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle and progress guard used after a tool handoff, native tool completion, post-tool raw assistant progress, raw reasoning completion, or reasoning progress while OpenClaw waits for `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. Local stdio requirements that omit `danger-full-access`, `never` approval, or the `user` reviewer make the implicit default guardian. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start/resume/turn. Guardian defaults prefer `"on-request"` when allowed. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with the supported Codex app-server so native Codex execution can run inside the active OpenClaw sandbox. |
| Field | Default | Meaning |
| --------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; explicit `"unix"` connects to the local control socket; `"websocket"` connects to `url`. |
| `homeScope` | `"agent"` | `"agent"` isolates ordinary harness state per OpenClaw agent. `"user"` is an explicit opt-in that shares the native `$CODEX_HOME` or `~/.codex`, uses native auth, and enables owner-only thread management. User scope supports local stdio or Unix transport. For the separate supervision connection, an unset value resolves to `"user"` for stdio or Unix and `"agent"` for WebSocket. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket App Server URL or `unix://` URL. An empty explicit Unix path selects the canonical user-home control socket. |
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps the selected `CODEX_HOME` and inherited `HOME` for local launches. |
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. Ordinary OpenClaw dynamic tools remain available through nested `tools.*` calls; `openclaw_direct` tools stay directly model-visible. |
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. OpenClaw maps the local cwd into this root and transfers authoritative remote attachments over an output-capped, no-shell `command/exec` reader. Paths escaping either workspace, symbolic links, oversized files, and unbounded attachment batches fail closed; uploads retain the configured channel identity and app-server request timeout. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. |
| `turnAssistantCompletionIdleTimeoutMs` | `10000` | Quiet window after a final/non-commentary assistant item or pre-tool raw assistant completion arms the assistant-output release while OpenClaw still waits for `turn/completed`. Raising it gives Codex more time to emit `turn/completed` before OpenClaw interrupts and releases the session lane. |
| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle and progress guard used after a tool handoff, native tool completion, post-tool raw assistant progress, raw reasoning completion, or reasoning progress while OpenClaw waits for `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. Local stdio requirements that omit `danger-full-access`, `never` approval, or the `user` reviewer make the implicit default guardian. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start/resume/turn. Guardian defaults prefer `"on-request"` when allowed. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects it with `default_permissions` instead of sending `sandbox`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with the supported Codex app-server so native Codex execution can run inside the active OpenClaw sandbox. |
`appServer.networkProxy` is explicit because it changes the Codex sandbox
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled`
@@ -553,6 +553,62 @@ describe("CodexAppServerClient", () => {
});
});
it("interleaves a bounded remote file command with a pending dynamic tool request", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const remotePath = "/remote/codex-workspace/reports/slack-upload.txt";
const content = "authoritative remote attachment";
harness.client.addRequestHandler(async (request, signal) => {
if (request.method !== "item/tool/call") {
return undefined;
}
const response = await harness.client.request(
"command/exec",
{
command: ["node", "-e", "fixed-reader", "--", remotePath, "64", "0", "524288"],
env: { NODE_OPTIONS: null, NODE_PATH: null },
},
{ signal, timeoutMs: 10_000 },
);
return {
contentItems: [{ type: "inputText", text: response.stdout }],
success: true,
};
});
harness.send({ id: "srv-remote-file", method: "item/tool/call", params: { tool: "message" } });
await vi.waitFor(() => expect(harness.writes).toHaveLength(1));
const fileRequest = JSON.parse(harness.writes[0] ?? "{}") as {
id?: number;
method?: string;
params?: { command?: string[]; env?: Record<string, string | null> };
};
expect(fileRequest).toMatchObject({
method: "command/exec",
params: {
command: ["node", "-e", "fixed-reader", "--", remotePath, "64", "0", "524288"],
env: { NODE_OPTIONS: null, NODE_PATH: null },
},
});
harness.send({
id: fileRequest.id,
result: {
exitCode: 0,
stdout: content,
stderr: "",
},
});
await vi.waitFor(() => expect(harness.writes).toHaveLength(2));
expect(JSON.parse(harness.writes[1] ?? "{}")).toEqual({
id: "srv-remote-file",
result: {
contentItems: [{ type: "inputText", text: content }],
success: true,
},
});
});
it("returns JSON-RPC internal errors when server request handlers throw", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const harness = createClientHarness();
@@ -0,0 +1,13 @@
/** Bounded, sandboxed argv execution over the existing app-server connection. */
export type CodexCommandExecParams = {
command: string[];
env?: Record<string, string | null>;
outputBytesCap?: number;
timeoutMs?: number;
};
export type CodexCommandExecResponse = {
exitCode: number;
stdout: string;
stderr: string;
};
@@ -36,6 +36,7 @@ import {
type CodexNativeExecutionPolicy,
} from "./native-execution-policy.js";
import type { CodexSandboxPolicy, CodexTurnEnvironmentParams } from "./protocol.js";
import { mapCodexAppServerRemoteWorkspacePath } from "./remote-workspace-path.js";
import type { CodexSandboxExecEnvironment } from "./sandbox-exec-server.js";
import {
CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME,
@@ -633,41 +634,6 @@ export function resolveCodexAppServerExecutionCwd(params: {
remoteWorkspaceRoot: params.remoteWorkspaceRoot,
});
}
/** Projects a local OpenClaw workspace cwd into the remote Codex app-server workspace root. */
function mapCodexAppServerRemoteWorkspacePath(params: {
value: string;
localWorkspaceRoot: string;
remoteWorkspaceRoot?: string;
}): string {
if (!params.remoteWorkspaceRoot) {
return params.value;
}
const localRoot = normalizeRemoteWorkspaceMatchPath(params.localWorkspaceRoot);
const remoteRoot = normalizeRemoteWorkspaceMatchPath(params.remoteWorkspaceRoot);
const normalizedValue = normalizeRemoteWorkspaceMatchPath(params.value);
if (!localRoot || !remoteRoot) {
throw new Error("Codex remoteWorkspaceRoot requires non-empty workspace roots.");
}
if (normalizedValue === localRoot) {
return remoteRoot;
}
const prefix = `${localRoot}/`;
if (!normalizedValue.startsWith(prefix)) {
throw new Error(
`Codex remoteWorkspaceRoot is configured but cwd ${params.value} is outside OpenClaw workspace root ${params.localWorkspaceRoot}; refusing to send a gateway-local cwd to the remote Codex app-server.`,
);
}
return joinRemoteWorkspacePath(remoteRoot, normalizedValue.slice(prefix.length));
}
function normalizeRemoteWorkspaceMatchPath(value: string): string {
return trimTrailingPathSeparator(value.replace(/\\/gu, "/"));
}
function trimTrailingPathSeparator(value: string): string {
return value.length > 1 ? value.replace(/[\\/]+$/u, "") : value;
}
function joinRemoteWorkspacePath(remoteRoot: string, suffix: string): string {
return remoteRoot === "/" ? `/${suffix}` : `${remoteRoot}/${suffix}`;
}
/** Converts OpenClaw sandbox networking into Codex's external-sandbox policy shape. */
export function resolveCodexExternalSandboxPolicyForOpenClawSandbox(
sandbox: OpenClawSandboxContext | undefined,
@@ -1,5 +1,8 @@
// Codex tests cover dynamic tools plugin behavior.
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
import {
@@ -23,6 +26,7 @@ import {
createTestRegistry,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { createOpenClawTestState } from "openclaw/plugin-sdk/test-state";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import {
@@ -31,6 +35,7 @@ import {
type CodexDynamicToolSpec,
type JsonValue,
} from "./protocol.js";
import type { CodexRemoteWorkspaceFileReader } from "./remote-workspace-media.js";
import { settleCodexSourceReplyFinality } from "./source-reply-finality.js";
const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
@@ -1356,6 +1361,79 @@ describe("createCodexDynamicToolBridge", () => {
]);
});
it("transfers remote Slack file uploads over the Codex app-server connection", async () => {
const openClawState = await createOpenClawTestState({
layout: "state-only",
prefix: "codex-remote-slack-upload-",
});
const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "codex-remote-upload-"));
try {
const relativePath = "reports/slack-upload.txt";
const localPath = path.join(workspaceDir, relativePath);
const remoteContent = "authoritative remote Slack attachment\n";
await mkdir(path.dirname(localPath), { recursive: true });
await writeFile(localPath, remoteContent);
const toolResult = textToolResult("Uploaded.", { messageId: "message-1" });
const execute = vi.fn(async () => toolResult);
const remotePath = `/remote/codex-workspace/${relativePath}`;
const readRemoteWorkspaceFile = vi.fn<CodexRemoteWorkspaceFileReader>(async () => ({
dataBase64: Buffer.from(remoteContent).toString("base64"),
}));
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "message", execute })],
signal: new AbortController().signal,
hookContext: {
workspaceDir,
remoteWorkspaceRoot: "/remote/codex-workspace",
remoteWorkspaceRequestTimeoutMs: 90_000,
},
});
bridge.setRemoteWorkspaceFileReader?.(readRemoteWorkspaceFile);
const result = await handleMessageToolCall(bridge, {
action: "upload-file",
channel: "slack",
to: "channel:C123",
filePath: remotePath,
});
expect(result).toEqual(expectInputText("Uploaded."));
const executedArgs = requireRecord(
callArg(execute, 0, 1, "Slack upload args"),
"upload args",
);
const stagedPath = executedArgs.filePath;
expectExecuteCall(execute, {
callId: "call-1",
args: {
action: "upload-file",
channel: "slack",
to: "channel:C123",
filePath: stagedPath,
},
});
expect(stagedPath).not.toBe(localPath);
expect(stagedPath).toEqual(
expect.stringContaining(`${path.sep}media${path.sep}outbound${path.sep}`),
);
expect(readRemoteWorkspaceFile).toHaveBeenCalledWith({
path: remotePath,
maxBytes: 64 * 1024 * 1024,
workspaceRoot: "/remote/codex-workspace",
signal: expect.any(AbortSignal),
timeoutMs: expect.any(Number),
});
expect(readRemoteWorkspaceFile.mock.calls[0]?.[0].timeoutMs).toBeGreaterThan(0);
expect(readRemoteWorkspaceFile.mock.calls[0]?.[0].timeoutMs).toBeLessThanOrEqual(90_000);
await expect(readFile(String(stagedPath), "utf8")).resolves.toBe(remoteContent);
await expect(readFile(localPath, "utf8")).resolves.toBe(remoteContent);
} finally {
await rm(workspaceDir, { recursive: true, force: true });
await openClawState.cleanup();
}
});
it("records internal UI source replies separately from outbound messaging evidence", async () => {
const toolResult = textToolResult("Sent to current chat.", {
status: "ok",
@@ -66,6 +66,10 @@ import {
type CodexDynamicToolSpec,
type JsonValue,
} from "./protocol.js";
import {
prepareCodexRemoteWorkspaceMessageMedia,
type CodexRemoteWorkspaceFileReader,
} from "./remote-workspace-media.js";
import { recordCodexSourceReplyDeliveryIntent } from "./source-reply-finality.js";
import { resolveCodexToolAbortTerminalReason } from "./tool-abort-terminal-reason.js";
@@ -73,6 +77,8 @@ type CodexDynamicToolHookContext = {
agentId?: string;
config?: EmbeddedRunAttemptParams["config"];
workspaceDir?: string;
remoteWorkspaceRoot?: string;
remoteWorkspaceRequestTimeoutMs?: number;
sessionId?: string;
sessionKey?: string;
runId?: string;
@@ -356,6 +362,8 @@ export type CodexDynamicToolBridge = {
consumeToolExecutionSnapshot?: (
toolCallId: string,
) => { executedArguments: Record<string, unknown>; executionStarted: boolean } | undefined;
/** Bind the authenticated app-server client once remote thread startup completes. */
setRemoteWorkspaceFileReader?: (reader: CodexRemoteWorkspaceFileReader) => void;
telemetry: {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool: boolean;
@@ -524,6 +532,7 @@ export function createCodexDynamicToolBridge(params: {
...ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES,
...(params.directToolNames ?? []),
]);
let readRemoteWorkspaceFile: CodexRemoteWorkspaceFileReader | undefined;
return {
availableSpecs: createCodexDynamicToolSpecs({
entries: availableTools,
@@ -536,6 +545,9 @@ export function createCodexDynamicToolBridge(params: {
directToolNames,
}),
telemetry,
setRemoteWorkspaceFileReader: (reader) => {
readRemoteWorkspaceFile = reader;
},
consumeToolExecutionSnapshot: (toolCallId) => {
const state = executionSnapshotStates.get(toolCallId);
executionSnapshotStates.delete(toolCallId);
@@ -606,7 +618,18 @@ export function createCodexDynamicToolBridge(params: {
}
};
try {
const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
const toolArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
const preparedArgs =
toolName === "message" && isRecord(toolArgs)
? await prepareCodexRemoteWorkspaceMessageMedia({
args: toolArgs,
localWorkspaceRoot: params.hookContext?.workspaceDir,
remoteWorkspaceRoot: params.hookContext?.remoteWorkspaceRoot,
readRemoteFile: readRemoteWorkspaceFile,
timeoutMs: params.hookContext?.remoteWorkspaceRequestTimeoutMs,
signal,
})
: toolArgs;
const telemetryArgs = isRecord(preparedArgs) ? preparedArgs : args;
executedArgs = structuredClone(telemetryArgs);
const messagingContext = {
@@ -7,25 +7,83 @@ import { resolveGeneratedMediaMaxBytes } from "openclaw/plugin-sdk/media-generat
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import { readItemString, readString } from "./event-projector-values.js";
import type { CodexThreadItem, JsonObject } from "./protocol.js";
import type { CodexRemoteWorkspaceFileReader } from "./remote-workspace-media.js";
const GENERATED_IMAGE_MEDIA_SUBDIR = "tool-image-generation";
export class CodexGeneratedMediaProjection {
private readonly itemIds = new Set<string>();
private readonly urlsByItemId = new Map<string, string>();
private readonly gatewayMaterializedItemIds = new Set<string>();
private readonly pendingMaterializationsByItemId = new Map<string, Promise<void>>();
constructor(private readonly config: EmbeddedRunAttemptParams["config"]) {}
constructor(
private readonly config: EmbeddedRunAttemptParams["config"],
private readonly remote?: {
remoteWorkspaceRoot?: string;
readFile?: CodexRemoteWorkspaceFileReader;
requestTimeoutMs?: number;
signal?: AbortSignal;
},
) {}
hasGeneratedMedia(): boolean {
return this.itemIds.size > 0;
}
recordNative(item: CodexThreadItem | undefined): void {
async recordNative(item: CodexThreadItem | undefined): Promise<void> {
if (item?.type !== "imageGeneration") {
return;
}
// Image generation is already a billable side effect even if its remote
// artifact cannot be transferred into this gateway's media store.
this.itemIds.add(item.id);
const result = readItemString(item, "result");
if (result) {
await this.recordImage({
itemId: item.id,
result,
revisedPrompt: readItemString(item, "revisedPrompt"),
source: "native",
});
return;
}
const savedPath = readItemString(item, "savedPath")?.trim();
if (savedPath) {
if (this.remote?.remoteWorkspaceRoot) {
if (!this.remote.readFile) {
embeddedAgentLog.warn("codex remote image has no app-server file transfer", {
itemId: item.id,
});
return;
}
try {
const response = await this.remote.readFile({
path: savedPath,
maxBytes: resolveGeneratedMediaMaxBytes(this.config, "image"),
signal: this.remote.signal,
timeoutMs: this.remote.requestTimeoutMs,
});
if (!response || typeof response.dataBase64 !== "string" || !response.dataBase64) {
embeddedAgentLog.warn("codex remote image file returned no inline bytes", {
itemId: item.id,
});
return;
}
await this.recordImage({
itemId: item.id,
result: response.dataBase64,
revisedPrompt: readItemString(item, "revisedPrompt"),
source: "native",
});
} catch (error) {
embeddedAgentLog.warn("codex app-server remote image file read failed", {
itemId: item.id,
error,
});
}
return;
}
this.recordUrl({ itemId: item.id, mediaUrl: savedPath });
}
}
@@ -39,21 +97,69 @@ export class CodexGeneratedMediaProjection {
return;
}
const itemId = readString(item, "id") ?? `raw-image-${this.itemIds.size}`;
this.itemIds.add(itemId);
await this.recordImage({
itemId,
result,
revisedPrompt: readString(item, "revised_prompt") ?? readString(item, "revisedPrompt"),
source: "raw",
});
}
private async recordImage(params: {
itemId: string;
result: string;
revisedPrompt?: string;
source: "native" | "raw";
}): Promise<void> {
this.itemIds.add(params.itemId);
if (this.gatewayMaterializedItemIds.has(params.itemId)) {
return;
}
let pending = this.pendingMaterializationsByItemId.get(params.itemId);
while (pending) {
await pending;
if (this.gatewayMaterializedItemIds.has(params.itemId)) {
return;
}
// A malformed, oversized, or failed sibling event must not suppress a
// valid completion carrying the same Codex image item.
pending = this.pendingMaterializationsByItemId.get(params.itemId);
}
const materialization = this.materializeImage(params);
this.pendingMaterializationsByItemId.set(params.itemId, materialization);
try {
await materialization;
} finally {
if (this.pendingMaterializationsByItemId.get(params.itemId) === materialization) {
this.pendingMaterializationsByItemId.delete(params.itemId);
}
}
}
private async materializeImage(params: {
itemId: string;
result: string;
revisedPrompt?: string;
source: "native" | "raw";
}): Promise<void> {
const maxBytes = resolveGeneratedMediaMaxBytes(this.config, "image");
const estimatedDecodedBytes = estimateBase64DecodedBytes(result);
const estimatedDecodedBytes = estimateBase64DecodedBytes(params.result);
if (estimatedDecodedBytes !== undefined && estimatedDecodedBytes > maxBytes) {
embeddedAgentLog.warn("codex app-server raw image generation result exceeds media limit", {
itemId,
estimatedDecodedBytes,
maxBytes,
});
embeddedAgentLog.warn(
`codex app-server ${params.source} image generation result exceeds media limit`,
{
itemId: params.itemId,
estimatedDecodedBytes,
maxBytes,
},
);
return;
}
const asset = generatedImageAssetFromBase64({
base64: result,
base64: params.result,
index: this.itemIds.size,
revisedPrompt: readString(item, "revised_prompt") ?? readString(item, "revisedPrompt"),
revisedPrompt: params.revisedPrompt,
fileNamePrefix: "codex-image-generation",
sniffMimeType: true,
});
@@ -68,18 +174,22 @@ export class CodexGeneratedMediaProjection {
maxBytes,
asset.fileName,
);
this.gatewayMaterializedItemIds.add(params.itemId);
this.recordUrl({
itemId,
itemId: params.itemId,
mediaUrl: saved.path,
// The typed savedPath may belong to a remote app-server host. Always
// prefer the copy persisted into this gateway's managed media root.
// Both Codex event shapes can carry a DevBox-local savedPath; channel
// delivery must always use the copy materialized on this gateway.
replaceExisting: true,
});
} catch (error) {
embeddedAgentLog.warn("codex app-server raw image generation result save failed", {
itemId,
error,
});
embeddedAgentLog.warn(
`codex app-server ${params.source} image generation result save failed`,
{
itemId: params.itemId,
error,
},
);
}
}
@@ -0,0 +1,17 @@
import type { CodexThreadItem, JsonValue } from "./protocol.js";
import type { CodexRemoteWorkspaceFileReader } from "./remote-workspace-media.js";
import type { CodexTrajectoryRecorder } from "./trajectory.js";
export type CodexAppServerEventProjectorOptions = {
nativePostToolUseRelayEnabled?: boolean;
onNativeToolResultRecorded?: () => void | Promise<void>;
prepareNativeMcpAppResultDetails?: (item: CodexThreadItem) => Promise<unknown>;
readRecentRateLimits?: () => JsonValue | undefined;
runAbortSignal?: AbortSignal;
remoteWorkspaceRoot?: string;
readRemoteWorkspaceFile?: CodexRemoteWorkspaceFileReader;
remoteWorkspaceRequestTimeoutMs?: number;
trajectoryRecorder?: CodexTrajectoryRecorder | null;
onContextCompacted?: () => void;
upstreamUserText?: string;
};
@@ -32,9 +32,9 @@ afterEach(async () => {
});
describe("CodexAppServerEventProjector media projection", () => {
it("attaches native Codex image-generation saved paths as reply media", async () => {
it("saves native Codex image-generation snapshots into gateway-managed media", async () => {
const projector = await createProjector();
const savedPath = "/tmp/codex-home/generated_images/session-1/ig_123.png";
const savedPath = "/home/dev-user/.codex/generated_images/session-1/ig_123.png";
await projector.handleNotification(
turnCompleted([
@@ -43,17 +43,164 @@ describe("CodexAppServerEventProjector media projection", () => {
id: "ig_123",
status: "completed",
revisedPrompt: "A tiny blue square",
result: "Zm9v",
result: tinyPngBase64,
savedPath,
},
]),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
const mediaUrl = result.toolMediaUrls?.[0];
expect(result.assistantTexts).toStrictEqual([]);
expect(result.toolMediaUrls).toEqual([savedPath]);
expect(result.hostOwnedToolMediaUrls).toEqual([savedPath]);
expect(result.toolMediaUrls).toHaveLength(1);
expect(result.hostOwnedToolMediaUrls).toEqual(result.toolMediaUrls);
expect(mediaUrl).not.toBe(savedPath);
expect(mediaUrl).toContain(`${path.sep}media${path.sep}tool-image-generation${path.sep}`);
await expect(fs.readFile(mediaUrl ?? "")).resolves.toEqual(
Buffer.from(tinyPngBase64, "base64"),
);
expect(result.replayMetadata).toStrictEqual({
hadPotentialSideEffects: true,
replaySafe: false,
});
});
it("saves typed Codex image-generation completions without a raw response or saved path", async () => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "imageGeneration",
id: "ig_typed_only",
status: "completed",
revisedPrompt: "A tiny blue square",
result: tinyPngBase64,
},
}),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
const mediaUrl = result.toolMediaUrls?.[0];
expect(result.toolMediaUrls).toHaveLength(1);
expect(result.hostOwnedToolMediaUrls).toEqual(result.toolMediaUrls);
expect(mediaUrl).toContain(`${path.sep}media${path.sep}tool-image-generation${path.sep}`);
await expect(fs.readFile(mediaUrl ?? "")).resolves.toEqual(
Buffer.from(tinyPngBase64, "base64"),
);
});
it("does not expose a remote saved path when typed image bytes are invalid", async () => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "imageGeneration",
id: "ig_typed_invalid",
status: "completed",
result: "not valid base64!",
savedPath: "/home/dev-user/.codex/generated_images/session-1/ig_typed_invalid.png",
},
}),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.toolMediaUrls).toBeUndefined();
expect(result.hostOwnedToolMediaUrls).toBeUndefined();
expect(result.replayMetadata).toStrictEqual({
hadPotentialSideEffects: true,
replaySafe: false,
});
});
it("fetches saved-path-only remote images over the bounded Codex command protocol", async () => {
const readRemoteWorkspaceFile = vi.fn(async () => ({ dataBase64: tinyPngBase64 }));
const projector = await createProjector(undefined, {
remoteWorkspaceRoot: "/remote/codex-workspace",
readRemoteWorkspaceFile,
remoteWorkspaceRequestTimeoutMs: 90_000,
});
const savedPath = "/remote/codex-home/generated_images/session-1/ig_saved_only.png";
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "imageGeneration",
id: "ig_saved_only",
status: "completed",
revisedPrompt: "A tiny blue square",
savedPath,
},
}),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(readRemoteWorkspaceFile).toHaveBeenCalledWith({
path: savedPath,
maxBytes: expect.any(Number),
signal: undefined,
timeoutMs: 90_000,
});
expect(result.toolMediaUrls).toHaveLength(1);
expect(result.toolMediaUrls?.[0]).not.toBe(savedPath);
await expect(fs.readFile(result.toolMediaUrls?.[0] ?? "")).resolves.toEqual(
Buffer.from(tinyPngBase64, "base64"),
);
});
it("never exposes a remote image path when remote file transfer is unavailable", async () => {
const projector = await createProjector(undefined, {
remoteWorkspaceRoot: "/remote/codex-workspace",
});
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "imageGeneration",
id: "ig_remote_unavailable",
status: "completed",
savedPath: "/remote/codex-home/generated_images/session-1/ig_remote_unavailable.png",
},
}),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.toolMediaUrls).toBeUndefined();
expect(result.hostOwnedToolMediaUrls).toBeUndefined();
expect(result.replayMetadata).toStrictEqual({
hadPotentialSideEffects: true,
replaySafe: false,
});
});
it("preserves image side-effect state when remote file transfer fails", async () => {
const readRemoteWorkspaceFile = vi.fn(async () => {
throw new Error("remote generated image is unavailable");
});
const projector = await createProjector(undefined, {
remoteWorkspaceRoot: "/remote/codex-workspace",
readRemoteWorkspaceFile,
});
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "imageGeneration",
id: "ig_remote_transfer_failed",
status: "completed",
savedPath: "/remote/codex-home/generated_images/session-1/ig_transfer_failed.png",
},
}),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(readRemoteWorkspaceFile).toHaveBeenCalledOnce();
expect(result.toolMediaUrls).toBeUndefined();
expect(result.hostOwnedToolMediaUrls).toBeUndefined();
expect(result.replayMetadata).toStrictEqual({
hadPotentialSideEffects: true,
replaySafe: false,
@@ -200,6 +347,38 @@ describe("CodexAppServerEventProjector media projection", () => {
);
});
it("rejects oversized typed Codex images instead of using a remote saved path", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const projector = await createProjector({
...(await createParams()),
config: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
} as EmbeddedRunAttemptParams);
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "imageGeneration",
id: "ig_typed_capped",
status: "completed",
result: tinyPngBase64,
savedPath: "/home/dev-user/.codex/generated_images/session-1/ig_typed_capped.png",
},
}),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.toolMediaUrls).toBeUndefined();
expect(result.replayMetadata).toStrictEqual({
hadPotentialSideEffects: true,
replaySafe: false,
});
expect(warn).toHaveBeenCalledWith(
"codex app-server native image generation result exceeds media limit",
expect.objectContaining({ itemId: "ig_typed_capped" }),
);
});
it("dedupes raw and typed Codex image-generation media for the same item", async () => {
const projector = await createProjector();
const savedPath = "/tmp/codex-home/generated_images/session-1/ig_123.png";
@@ -233,6 +412,79 @@ describe("CodexAppServerEventProjector media projection", () => {
expect(result.toolMediaUrls?.[0]).not.toBe(savedPath);
});
it("materializes overlapping typed and raw image events only once", async () => {
const projector = await createProjector();
await Promise.all([
projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "imageGeneration",
id: "ig_concurrent",
status: "completed",
revisedPrompt: "A tiny blue square",
result: tinyPngBase64,
savedPath: "/home/dev-user/.codex/generated_images/session-1/ig_concurrent.png",
},
}),
),
projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "image_generation_call",
id: "ig_concurrent",
status: "completed",
result: tinyPngBase64,
},
}),
),
]);
const result = projector.buildResult(buildEmptyToolTelemetry());
const mediaUrl = result.toolMediaUrls?.[0];
expect(result.toolMediaUrls).toHaveLength(1);
expect(result.hostOwnedToolMediaUrls).toEqual(result.toolMediaUrls);
await expect(fs.readFile(mediaUrl ?? "")).resolves.toEqual(
Buffer.from(tinyPngBase64, "base64"),
);
await expect(fs.readdir(path.dirname(mediaUrl ?? ""))).resolves.toHaveLength(1);
});
it("retries valid typed image bytes after an overlapping invalid raw event", async () => {
const projector = await createProjector();
await Promise.all([
projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "image_generation_call",
id: "ig_retry_valid",
status: "completed",
result: "not valid base64!",
},
}),
),
projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "imageGeneration",
id: "ig_retry_valid",
status: "completed",
result: tinyPngBase64,
savedPath: "/home/dev-user/.codex/generated_images/session-1/ig_retry_valid.png",
},
}),
),
]);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.toolMediaUrls).toHaveLength(1);
await expect(fs.readFile(result.toolMediaUrls?.[0] ?? "")).resolves.toEqual(
Buffer.from(tinyPngBase64, "base64"),
);
});
it("prefers gateway-managed image media when the typed event arrives first", async () => {
const projector = await createProjector();
const savedPath = "/home/dev-user/.codex/generated_images/session-1/ig_123.png";
@@ -24,6 +24,7 @@ import {
} from "./event-projector-items.js";
import { CodexGeneratedMediaProjection } from "./event-projector-media.js";
import { CodexNativeToolLifecycleProjector } from "./event-projector-native-tool-lifecycle.js";
import type { CodexAppServerEventProjectorOptions } from "./event-projector-options.js";
import { CodexReasoningProjection } from "./event-projector-reasoning.js";
import { buildCodexMessagesSnapshot } from "./event-projector-snapshot.js";
import { CodexToolProgressProjection } from "./event-projector-tool-progress.js";
@@ -55,7 +56,6 @@ import {
type JsonValue,
} from "./protocol.js";
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
import type { CodexTrajectoryRecorder } from "./trajectory.js";
import { createCodexUsageLimitPromptError } from "./usage-limit-error.js";
export { shouldEmitTranscriptToolProgress } from "./event-projector-tool-progress.js";
@@ -75,17 +75,6 @@ type CodexAppServerToolTelemetry = {
successfulCronAdds?: number;
};
type CodexAppServerEventProjectorOptions = {
nativePostToolUseRelayEnabled?: boolean;
onNativeToolResultRecorded?: () => void | Promise<void>;
prepareNativeMcpAppResultDetails?: (item: CodexThreadItem) => Promise<unknown>;
readRecentRateLimits?: () => JsonValue | undefined;
runAbortSignal?: AbortSignal;
trajectoryRecorder?: CodexTrajectoryRecorder | null;
onContextCompacted?: () => void;
upstreamUserText?: string;
};
export class CodexAppServerEventProjector {
private readonly assistantProjection: CodexAssistantProjection;
private readonly reasoningProjection: CodexReasoningProjection;
@@ -125,7 +114,12 @@ export class CodexAppServerEventProjector {
runAbortSignal: options.runAbortSignal,
},
);
this.generatedMediaProjection = new CodexGeneratedMediaProjection(params.config);
this.generatedMediaProjection = new CodexGeneratedMediaProjection(params.config, {
remoteWorkspaceRoot: options.remoteWorkspaceRoot,
readFile: options.readRemoteWorkspaceFile,
requestTimeoutMs: options.remoteWorkspaceRequestTimeoutMs,
signal: options.runAbortSignal,
});
this.toolProgressProjection = new CodexToolProgressProjection(params);
this.toolTranscriptProjection = new CodexToolTranscriptProjection(
params,
@@ -552,7 +546,7 @@ export class CodexAppServerEventProjector {
}
this.assistantProjection.recordItemCompleted(item, itemId, this.activeItemIds);
this.reasoningProjection.recordItem(item);
this.generatedMediaProjection.recordNative(item);
await this.generatedMediaProjection.recordNative(item);
if (item?.type === "contextCompaction" && itemId) {
this.activeCompactionItemIds.delete(itemId);
this.completedCompactionCount += 1;
@@ -638,7 +632,7 @@ export class CodexAppServerEventProjector {
this.diagnostics.warnUnknownItemStatus(item);
this.assistantProjection.recordSnapshotItem(item);
this.reasoningProjection.recordItem(item);
this.generatedMediaProjection.recordNative(item);
await this.generatedMediaProjection.recordNative(item);
this.toolProgressProjection.recordToolMeta(item);
this.toolProgressProjection.rememberCommandAggregateOutputEcho(item);
await this.emitSnapshotOnlyNativeToolProgress(item);
@@ -7,6 +7,7 @@ import type {
CodexAppsReadParams,
CodexAppsReadResponse,
} from "./app-inventory-protocol.js";
import type { CodexCommandExecParams, CodexCommandExecResponse } from "./command-exec-protocol.js";
import type { JsonObject, JsonValue } from "./protocol-json.js";
import type * as CodexMcpProtocol from "./protocol-mcp.js";
@@ -716,6 +717,7 @@ type CodexAppServerRequestParamsOverride = {
"app/installed": CodexAppsInstalledParams;
"app/list": CodexAppsListParams;
"app/read": CodexAppsReadParams;
"command/exec": CodexCommandExecParams;
"environment/add": { environmentId: string; execServerUrl: string };
"thread/fork": CodexThreadForkParams;
"thread/archive": CodexThreadArchiveParams;
@@ -743,6 +745,7 @@ type CodexAppServerRequestResultMap = {
"app/installed": CodexAppsInstalledResponse;
"app/list": CodexAppsListResponse;
"app/read": CodexAppsReadResponse;
"command/exec": CodexCommandExecResponse;
"config/mcpServer/reload": JsonValue;
"config/read": CodexConfigReadResponse;
"configRequirements/read": CodexConfigRequirementsReadResponse;
@@ -0,0 +1,508 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import { createOpenClawTestState, type OpenClawTestState } from "openclaw/plugin-sdk/test-state";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexCommandExecParams, CodexCommandExecResponse } from "./command-exec-protocol.js";
import {
prepareCodexRemoteWorkspaceMessageMedia,
readBoundedCodexRemoteWorkspaceFile,
type CodexRemoteWorkspaceFileReader,
} from "./remote-workspace-media.js";
const remoteWorkspaceRoot = "/remote/codex-workspace";
const execFileAsync = promisify(execFile);
let localWorkspaceRoot: string;
let openClawState: OpenClawTestState;
beforeEach(async () => {
openClawState = await createOpenClawTestState({
layout: "state-only",
prefix: "codex-remote-workspace-media-",
});
localWorkspaceRoot = await mkdtemp(path.join(os.tmpdir(), "codex-workspace-media-"));
});
afterEach(async () => {
await rm(localWorkspaceRoot, { recursive: true, force: true });
await openClawState.cleanup();
});
function createRemoteFileReader(files: Record<string, string>) {
return vi.fn<CodexRemoteWorkspaceFileReader>(async ({ path: remotePath, maxBytes, signal }) => {
signal?.throwIfAborted();
const content = files[remotePath];
if (content === undefined) {
throw new Error(`Codex remote workspace artifact does not exist: ${remotePath}`);
}
const buffer = Buffer.from(content);
if (buffer.byteLength > maxBytes) {
throw new Error(`Codex remote workspace artifact exceeds the limit of ${maxBytes} bytes.`);
}
return { dataBase64: buffer.toString("base64") };
});
}
function createLocalCommandClient() {
return {
request: vi.fn(
async (
_method: "command/exec",
params: CodexCommandExecParams,
): Promise<CodexCommandExecResponse> => {
try {
const result = await execFileAsync(params.command[0]!, params.command.slice(1), {
maxBuffer: Math.max(1_024, params.outputBytesCap ?? 1024 * 1024),
...(params.timeoutMs === undefined ? {} : { timeout: params.timeoutMs }),
});
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
} catch (error) {
const failure = error as { code?: number; stderr?: string; message?: string };
return {
exitCode: typeof failure.code === "number" ? failure.code : 1,
stdout: "",
stderr: failure.stderr ?? failure.message ?? "remote reader failed",
};
}
},
),
};
}
describe("readBoundedCodexRemoteWorkspaceFile", () => {
it("transfers the exact remote bytes with a no-shell, default-capped command", async () => {
const filePath = path.join(localWorkspaceRoot, "report $(never-execute).txt");
await writeFile(filePath, "authoritative remote report\n");
const client = createLocalCommandClient();
const result = await readBoundedCodexRemoteWorkspaceFile({
client,
path: filePath,
maxBytes: 64,
timeoutMs: 9_000,
});
expect(Buffer.from(result.dataBase64, "base64").toString()).toBe(
"authoritative remote report\n",
);
expect(client.request).toHaveBeenCalledWith(
"command/exec",
expect.objectContaining({
command: ["node", "-e", expect.any(String), "--", filePath, "64", "0", "524288"],
env: { NODE_OPTIONS: null, NODE_PATH: null },
timeoutMs: expect.any(Number),
}),
{ signal: undefined, timeoutMs: expect.any(Number) },
);
expect(client.request.mock.calls[0]?.[1]).not.toHaveProperty("outputBytesCap");
});
it("reassembles multi-frame files beneath the Windows-safe native output cap", async () => {
const filePath = path.join(localWorkspaceRoot, "chunked-report.bin");
const expected = Buffer.alloc(512 * 1024 + 17, 0x61);
await writeFile(filePath, expected);
const client = createLocalCommandClient();
const result = await readBoundedCodexRemoteWorkspaceFile({
client,
path: filePath,
maxBytes: expected.byteLength,
});
expect(Buffer.from(result.dataBase64, "base64")).toEqual(expected);
expect(client.request).toHaveBeenCalledTimes(2);
expect(client.request.mock.calls[0]?.[1].command).toEqual([
"node",
"-e",
expect.any(String),
"--",
filePath,
String(expected.byteLength),
"0",
"524288",
]);
expect(client.request.mock.calls[1]?.[1].command).toEqual([
"node",
"-e",
expect.any(String),
"--",
filePath,
String(expected.byteLength),
"524288",
"524288",
]);
expect(client.request.mock.calls[1]?.[1]).not.toHaveProperty("outputBytesCap");
});
it("rejects oversized remote files before base64 allocation or transfer", async () => {
const filePath = path.join(localWorkspaceRoot, "oversized.txt");
await writeFile(filePath, "too many bytes");
await expect(
readBoundedCodexRemoteWorkspaceFile({
client: createLocalCommandClient(),
path: filePath,
maxBytes: 3,
}),
).rejects.toThrow("limit of 3 bytes");
});
it("rejects remote symbolic links before opening their target", async () => {
const target = path.join(localWorkspaceRoot, "private.txt");
const link = path.join(localWorkspaceRoot, "escaped.txt");
await writeFile(target, "private content");
await symlink(target, link);
await expect(
readBoundedCodexRemoteWorkspaceFile({
client: createLocalCommandClient(),
path: link,
maxBytes: 64,
}),
).rejects.toThrow("symbolic links are not allowed");
});
it("rejects parent symbolic links escaping the remote workspace", async () => {
const externalRoot = await mkdtemp(path.join(os.tmpdir(), "codex-remote-external-"));
try {
const externalFile = path.join(externalRoot, "private.txt");
await writeFile(externalFile, "private remote content");
const link = path.join(localWorkspaceRoot, "linked-directory");
await symlink(externalRoot, link);
await expect(
readBoundedCodexRemoteWorkspaceFile({
client: createLocalCommandClient(),
path: path.join(link, "private.txt"),
maxBytes: 64,
workspaceRoot: localWorkspaceRoot,
}),
).rejects.toThrow("file escapes remote workspace");
} finally {
await rm(externalRoot, { recursive: true, force: true });
}
});
it("rejects malformed, truncated, and oversized command responses", async () => {
for (const stdout of ["not valid base64!", "YQ", "YWJjZA=="]) {
const client = {
request: vi.fn(async () => ({ exitCode: 0, stdout, stderr: "" })),
};
await expect(
readBoundedCodexRemoteWorkspaceFile({ client, path: "/remote/report.txt", maxBytes: 3 }),
).rejects.toThrow(/invalid|oversized|exceeds/);
}
});
it("reports the documented remote Node.js prerequisite clearly", async () => {
const client = {
request: vi.fn(async () => {
throw new Error("failed to spawn command: No such file or directory");
}),
};
await expect(
readBoundedCodexRemoteWorkspaceFile({ client, path: "/remote/report.txt", maxBytes: 64 }),
).rejects.toThrow("requires Node.js on the remote app-server host");
});
it("honors cancellation before issuing a native command", async () => {
const controller = new AbortController();
controller.abort();
const client = createLocalCommandClient();
await expect(
readBoundedCodexRemoteWorkspaceFile({
client,
path: "/remote/report.txt",
maxBytes: 64,
signal: controller.signal,
}),
).rejects.toThrow();
expect(client.request).not.toHaveBeenCalled();
});
});
describe("prepareCodexRemoteWorkspaceMessageMedia", () => {
it("stages scalar, list, and structured attachments from authoritative remote bytes", async () => {
const reportPath = `${remoteWorkspaceRoot}/reports/slack-upload.txt`;
const imagePath = `${remoteWorkspaceRoot}/images/preview.png`;
const readRemoteFile = createRemoteFileReader({
[reportPath]: "authoritative remote report\n",
[imagePath]: "authoritative remote image\n",
});
const result = await prepareCodexRemoteWorkspaceMessageMedia({
args: {
action: "upload-file",
filePath: reportPath,
mediaUrls: [reportPath, "https://example.com/image.png"],
attachments: [{ filePath: imagePath, title: "Preview" }],
},
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile,
});
const stagedReportPath = result.filePath;
const stagedImagePath = (result.attachments as Array<{ filePath: string }>)[0]?.filePath;
expect(result).toEqual({
action: "upload-file",
filePath: stagedReportPath,
mediaUrls: [stagedReportPath, "https://example.com/image.png"],
attachments: [{ filePath: stagedImagePath, title: "Preview" }],
});
expect(readRemoteFile).toHaveBeenCalledTimes(2);
expect(stagedReportPath).toContain(`${path.sep}media${path.sep}outbound${path.sep}`);
await expect(readFile(String(stagedReportPath), "utf8")).resolves.toBe(
"authoritative remote report\n",
);
await expect(readFile(String(stagedImagePath), "utf8")).resolves.toBe(
"authoritative remote image\n",
);
});
it("uses authoritative remote bytes even when a stale local file has the same timestamp", async () => {
const remotePath = `${remoteWorkspaceRoot}/reused-upload.txt`;
await writeFile(path.join(localWorkspaceRoot, "reused-upload.txt"), "stale local content\n");
const result = await prepareCodexRemoteWorkspaceMessageMedia({
args: { filePath: remotePath },
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile: createRemoteFileReader({ [remotePath]: "authoritative remote content\n" }),
});
await expect(readFile(String(result.filePath), "utf8")).resolves.toBe(
"authoritative remote content\n",
);
});
it("transfers newly generated remote files without waiting for workspace synchronization", async () => {
const remotePath = `${remoteWorkspaceRoot}/reports/new-upload.txt`;
const result = await prepareCodexRemoteWorkspaceMessageMedia({
args: { filePath: remotePath },
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile: createRemoteFileReader({ [remotePath]: "new remote attachment\n" }),
});
await expect(readFile(String(result.filePath), "utf8")).resolves.toBe(
"new remote attachment\n",
);
});
it("preserves the original argument object for URL-backed media", async () => {
const args = {
action: "send",
mediaUrl: "https://example.com/image.png",
attachments: [{ fileUrl: "media://inbound/image.png" }],
};
await expect(
prepareCodexRemoteWorkspaceMessageMedia({ args, localWorkspaceRoot, remoteWorkspaceRoot }),
).resolves.toBe(args);
await expect(
prepareCodexRemoteWorkspaceMessageMedia({ args, localWorkspaceRoot }),
).resolves.toBe(args);
});
it("preserves securely validated Gateway-owned media in a remote run", async () => {
const saved = await saveMediaBuffer(
Buffer.from("previously downloaded Gateway media\n"),
"text/plain",
"inbound",
1_024,
"download.txt",
);
const args = { action: "send", filePath: saved.path };
const readRemoteFile = createRemoteFileReader({});
await expect(
prepareCodexRemoteWorkspaceMessageMedia({
args,
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile,
}),
).resolves.toBe(args);
expect(readRemoteFile).not.toHaveBeenCalled();
});
it("rejects symlinks disguised as Gateway-owned media", async () => {
const saved = await saveMediaBuffer(
Buffer.from("old Gateway media\n"),
"text/plain",
"inbound",
1_024,
"replaceable.txt",
);
const externalRoot = await mkdtemp(path.join(os.tmpdir(), "codex-media-external-"));
try {
const externalPath = path.join(externalRoot, "private.txt");
await writeFile(externalPath, "gateway-local private data\n");
await unlink(saved.path);
await symlink(externalPath, saved.path);
const readRemoteFile = createRemoteFileReader({});
await expect(
prepareCodexRemoteWorkspaceMessageMedia({
args: { filePath: saved.path },
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile,
}),
).rejects.toThrow();
expect(readRemoteFile).not.toHaveBeenCalled();
} finally {
await rm(externalRoot, { recursive: true, force: true });
}
});
it("fails closed when remote transfers have no active app-server client", async () => {
await expect(
prepareCodexRemoteWorkspaceMessageMedia({
args: { filePath: `${remoteWorkspaceRoot}/reports/report.txt` },
localWorkspaceRoot,
remoteWorkspaceRoot,
}),
).rejects.toThrow("requires an active app-server client");
});
it("propagates missing remote files before invoking a channel uploader", async () => {
await expect(
prepareCodexRemoteWorkspaceMessageMedia({
args: { filePath: `${remoteWorkspaceRoot}/reports/missing.txt` },
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile: createRemoteFileReader({}),
}),
).rejects.toThrow("does not exist");
});
it("rejects gateway-local paths and traversal before issuing a remote request", async () => {
for (const filePath of ["/etc/passwd", `${remoteWorkspaceRoot}/reports/../../private.txt`]) {
const readRemoteFile = createRemoteFileReader({});
await expect(
prepareCodexRemoteWorkspaceMessageMedia({
args: { filePath },
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile,
}),
).rejects.toThrow(/outside|must stay inside/);
expect(readRemoteFile).not.toHaveBeenCalled();
}
});
it("rejects oversized remote files before handing bytes to the channel uploader", async () => {
const remotePath = `${remoteWorkspaceRoot}/reports/oversized.txt`;
await expect(
prepareCodexRemoteWorkspaceMessageMedia({
args: { filePath: remotePath },
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile: createRemoteFileReader({ [remotePath]: "too many bytes" }),
maxBytes: 3,
}),
).rejects.toThrow("limit of 3 bytes");
});
it("enforces one aggregate byte limit across remote attachments", async () => {
const first = `${remoteWorkspaceRoot}/reports/first.txt`;
const second = `${remoteWorkspaceRoot}/reports/second.txt`;
await expect(
prepareCodexRemoteWorkspaceMessageMedia({
args: { mediaUrls: [first, second] },
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile: createRemoteFileReader({ [first]: "abc", [second]: "def" }),
maxBytes: 5,
}),
).rejects.toThrow("limit of 2 bytes");
});
it("shares one configured deadline across an entire attachment batch", async () => {
const first = `${remoteWorkspaceRoot}/reports/first.txt`;
const second = `${remoteWorkspaceRoot}/reports/second.txt`;
const readRemoteFile = vi.fn<CodexRemoteWorkspaceFileReader>(async ({ path: remotePath }) => {
await delay(20);
return {
dataBase64: Buffer.from(remotePath === first ? "first" : "second").toString("base64"),
};
});
await prepareCodexRemoteWorkspaceMessageMedia({
args: { mediaUrls: [first, second] },
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile,
timeoutMs: 500,
});
expect(readRemoteFile).toHaveBeenCalledTimes(2);
const firstBudget = readRemoteFile.mock.calls[0]?.[0].timeoutMs;
const secondBudget = readRemoteFile.mock.calls[1]?.[0].timeoutMs;
expect(firstBudget).toBeGreaterThan(0);
expect(firstBudget).toBeLessThanOrEqual(500);
expect(secondBudget).toBeGreaterThan(0);
expect(secondBudget).toBeLessThan(firstBudget ?? 0);
});
it("counts repeated attachment entries before issuing any remote request", async () => {
const remotePath = `${remoteWorkspaceRoot}/reports/report.txt`;
const readRemoteFile = createRemoteFileReader({ [remotePath]: "report" });
for (const args of [
{ mediaUrls: Array.from({ length: 17 }, () => remotePath) },
{ attachments: Array.from({ length: 17 }, () => ({ filePath: remotePath })) },
]) {
await expect(
prepareCodexRemoteWorkspaceMessageMedia({
args,
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile,
}),
).rejects.toThrow("16-attachment limit");
expect(readRemoteFile).not.toHaveBeenCalled();
}
});
it("keeps staged media immutable after the remote source changes", async () => {
const remotePath = `${remoteWorkspaceRoot}/reports/immutable-upload.txt`;
const remoteFiles = { [remotePath]: "immutable transferred report\n" };
const result = await prepareCodexRemoteWorkspaceMessageMedia({
args: { filePath: remotePath },
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile: createRemoteFileReader(remoteFiles),
});
remoteFiles[remotePath] = "changed remote content\n";
await expect(readFile(String(result.filePath), "utf8")).resolves.toBe(
"immutable transferred report\n",
);
});
it("honors cancellation before requesting remote bytes", async () => {
const controller = new AbortController();
controller.abort();
const readRemoteFile = createRemoteFileReader({});
await expect(
prepareCodexRemoteWorkspaceMessageMedia({
args: { filePath: `${remoteWorkspaceRoot}/reports/missing.txt` },
localWorkspaceRoot,
remoteWorkspaceRoot,
readRemoteFile,
signal: controller.signal,
}),
).rejects.toThrow();
expect(readRemoteFile).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,435 @@
import path from "node:path";
import { root } from "openclaw/plugin-sdk/file-access-runtime";
import { getMediaDir } from "openclaw/plugin-sdk/media-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import type { CodexCommandExecParams, CodexCommandExecResponse } from "./command-exec-protocol.js";
import {
isCodexPassThroughMediaSource,
mapCodexAppServerLocalWorkspacePath,
mapCodexAppServerRemoteWorkspacePath,
} from "./remote-workspace-path.js";
const REMOTE_WORKSPACE_MEDIA_TIMEOUT_MS = 60_000;
const REMOTE_WORKSPACE_MEDIA_MAX_BYTES = 64 * 1024 * 1024;
const REMOTE_WORKSPACE_MEDIA_MAX_ATTACHMENTS = 16;
const CODEX_REMOTE_MEDIA_CHUNK_BYTES = 512 * 1024;
// codex_utils_pty's native 1 MiB default is also the only output cap supported
// by restricted Windows app-server commands.
const CODEX_REMOTE_COMMAND_DEFAULT_OUTPUT_BYTES = 1024 * 1024;
// Execute a fixed argv program, never a shell or model-provided script. Resolve
// Linux paths through the opened descriptor so a swapped parent cannot escape
// the workspace; every chunk binds to the same capped filesystem identity.
const CODEX_BOUNDED_REMOTE_FILE_READER = [
"try{",
'const fs=require("node:fs");',
'const path=require("node:path");',
"const file=process.argv[1];",
"const max=Number(process.argv[2]);",
"const offset=Number(process.argv[3]);",
"const chunk=Number(process.argv[4]);",
"const workspace=process.argv[5];",
'if(!Number.isSafeInteger(max)||max<0)throw Error("invalid media byte limit");',
'if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(chunk)||chunk<=0)throw Error("invalid media chunk");',
'if(fs.lstatSync(file).isSymbolicLink())throw Error("symbolic links are not allowed");',
"const noFollow=fs.constants.O_NOFOLLOW??0;",
"const fd=fs.openSync(file,fs.constants.O_RDONLY|noFollow);",
"try{",
"const before=fs.fstatSync(fd);",
'if(!before.isFile())throw Error("not a regular file");',
"if(workspace){",
'const descriptor=process.platform==="linux"?fs.realpathSync(`/proc/self/fd/${fd}`):fs.realpathSync(file);',
"const relative=path.relative(fs.realpathSync(workspace),descriptor);",
'if(!relative||relative===".."||relative.startsWith(`..${path.sep}`)||path.isAbsolute(relative))throw Error("file escapes remote workspace");',
"const verified=fs.statSync(descriptor);",
'if(verified.dev!==before.dev||verified.ino!==before.ino)throw Error("file changed while being opened");',
"}",
"if(before.size>max)throw Error(`file exceeds limit of ${max} bytes`);",
'if(offset>before.size)throw Error("file changed while being read");',
"const expected=Math.min(chunk,before.size-offset);",
"const buffer=Buffer.allocUnsafe(expected);",
"let total=0;",
"while(total<buffer.length){",
"const count=fs.readSync(fd,buffer,total,buffer.length-total,offset+total);",
"if(count===0)break;total+=count;",
"}",
'if(total!==expected)throw Error("file changed while being read");',
"const after=fs.fstatSync(fd);",
'const revision=stat=>[stat.dev,stat.ino,stat.size,stat.mtimeMs,stat.ctimeMs].join(":");',
'if(!after.isFile()||revision(after)!==revision(before))throw Error("file changed while being read");',
'process.stdout.write(JSON.stringify({dataBase64:buffer.toString("base64"),size:before.size,revision:revision(before)}));',
"}finally{fs.closeSync(fd)}",
"}catch(error){process.stderr.write(error instanceof Error?error.message:String(error));process.exitCode=1}",
].join("");
const MESSAGE_MEDIA_KEYS = [
"media",
"mediaUrl",
"media_url",
"path",
"filePath",
"fileUrl",
"imageUrl",
"image_url",
] as const;
const MESSAGE_MEDIA_ARRAY_KEYS = ["mediaUrls", "media_urls", "imageUrls", "image_urls"] as const;
const ATTACHMENT_MEDIA_KEYS = ["media", "mediaUrl", "path", "filePath", "fileUrl", "url"] as const;
type CodexRemoteWorkspaceFileResponse = {
dataBase64: string;
};
export type CodexRemoteWorkspaceFileReader = (params: {
path: string;
maxBytes: number;
workspaceRoot?: string;
signal?: AbortSignal;
timeoutMs?: number;
}) => Promise<CodexRemoteWorkspaceFileResponse>;
type CodexBoundedRemoteCommandClient = {
request: (
method: "command/exec",
params: CodexCommandExecParams,
options: { signal?: AbortSignal; timeoutMs?: number },
) => Promise<CodexCommandExecResponse>;
};
/** Reads actual remote bytes with a cap enforced by Codex before transport. */
export async function readBoundedCodexRemoteWorkspaceFile(params: {
client: CodexBoundedRemoteCommandClient;
path: string;
maxBytes: number;
workspaceRoot?: string;
signal?: AbortSignal;
timeoutMs?: number;
}): Promise<CodexRemoteWorkspaceFileResponse> {
if (!Number.isSafeInteger(params.maxBytes) || params.maxBytes < 0) {
throw new Error("Codex remote workspace upload requires a valid media byte limit.");
}
params.signal?.throwIfAborted();
const chunks: Buffer[] = [];
let offset = 0;
let expectedSize: number | undefined;
let expectedRevision: string | undefined;
const startedAt = Date.now();
do {
params.signal?.throwIfAborted();
const timeoutMs =
params.timeoutMs === undefined ? undefined : params.timeoutMs - (Date.now() - startedAt);
if (timeoutMs !== undefined && timeoutMs <= 0) {
throw new Error("Codex remote workspace file transfer timed out.");
}
let response: CodexCommandExecResponse;
try {
response = await params.client.request(
"command/exec",
{
command: [
"node",
"-e",
CODEX_BOUNDED_REMOTE_FILE_READER,
"--",
params.path,
String(params.maxBytes),
String(offset),
String(CODEX_REMOTE_MEDIA_CHUNK_BYTES),
...(params.workspaceRoot ? [params.workspaceRoot] : []),
],
// Prevent inherited Node preload hooks from changing the fixed reader.
env: { NODE_OPTIONS: null, NODE_PATH: null },
...(timeoutMs === undefined ? {} : { timeoutMs }),
},
{ signal: params.signal, timeoutMs },
);
} catch (error) {
if (
error instanceof Error &&
/failed to spawn|executable.*not found|\bENOENT\b/iu.test(error.message)
) {
throw new Error(
"Codex remote workspace file transfer requires Node.js on the remote app-server host.",
{ cause: error },
);
}
throw error;
}
if (!response || response.exitCode !== 0) {
const detail = typeof response?.stderr === "string" ? response.stderr.trim() : "";
throw new Error(
`Codex remote workspace artifact could not be read: ${params.path}${detail ? `: ${detail}` : ""}`,
);
}
if (
typeof response.stdout !== "string" ||
response.stdout.length > CODEX_REMOTE_COMMAND_DEFAULT_OUTPUT_BYTES
) {
throw new Error("Codex remote workspace artifact exceeded the native command output cap.");
}
let payload: unknown;
try {
payload = JSON.parse(response.stdout);
} catch {
throw new Error("Codex remote workspace artifact returned invalid chunk data.");
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error("Codex remote workspace artifact returned invalid chunk data.");
}
const chunk = payload as { dataBase64?: unknown; size?: unknown; revision?: unknown };
if (
typeof chunk.dataBase64 !== "string" ||
!Number.isSafeInteger(chunk.size) ||
(chunk.size as number) < 0 ||
(chunk.size as number) > params.maxBytes ||
typeof chunk.revision !== "string" ||
!chunk.revision
) {
throw new Error("Codex remote workspace artifact returned invalid or oversized chunk data.");
}
if (expectedSize === undefined) {
expectedSize = chunk.size as number;
expectedRevision = chunk.revision;
}
if (chunk.size !== expectedSize || chunk.revision !== expectedRevision) {
throw new Error("Codex remote workspace artifact changed during chunked transfer.");
}
const remainingBytes = expectedSize - offset;
const expectedChunkBytes = Math.min(CODEX_REMOTE_MEDIA_CHUNK_BYTES, remainingBytes);
if (chunk.dataBase64.length > Math.ceil(expectedChunkBytes / 3) * 4) {
throw new Error("Codex remote workspace artifact returned oversized chunk data.");
}
const buffer = Buffer.from(chunk.dataBase64, "base64");
if (
buffer.byteLength !== expectedChunkBytes ||
buffer.toString("base64") !== chunk.dataBase64
) {
throw new Error("Codex remote workspace artifact returned invalid chunk data.");
}
chunks.push(buffer);
offset += buffer.byteLength;
} while (offset < (expectedSize ?? 0));
return { dataBase64: Buffer.concat(chunks, offset).toString("base64") };
}
/** Stages authoritative bounded remote bytes into immutable Gateway-owned media. */
export async function prepareCodexRemoteWorkspaceMessageMedia(params: {
args: Record<string, unknown>;
localWorkspaceRoot?: string;
remoteWorkspaceRoot?: string;
readRemoteFile?: CodexRemoteWorkspaceFileReader;
signal?: AbortSignal;
timeoutMs?: number;
maxBytes?: number;
}): Promise<Record<string, unknown>> {
const { localWorkspaceRoot, remoteWorkspaceRoot } = params;
if (!localWorkspaceRoot || !remoteWorkspaceRoot) {
return params.args;
}
const remotePathsByLocalPath = new Map<string, string>();
const gatewayManagedPaths = new Set<string>();
const gatewayMediaRoot = getMediaDir();
let attachmentEntries = 0;
const mapMediaPath = (value: unknown): unknown => {
if (typeof value !== "string") {
return value;
}
if (isGatewayManagedMediaPath(value, gatewayMediaRoot)) {
attachmentEntries += 1;
gatewayManagedPaths.add(value);
return value;
}
const mapped = mapCodexAppServerLocalWorkspacePath({
value,
localWorkspaceRoot,
remoteWorkspaceRoot,
});
if (value.trim() && !isCodexPassThroughMediaSource(value)) {
attachmentEntries += 1;
remotePathsByLocalPath.set(
mapped,
mapCodexAppServerRemoteWorkspacePath({
value: mapped,
localWorkspaceRoot,
remoteWorkspaceRoot,
}),
);
}
return mapped;
};
let mappedArgs = params.args;
const setMappedValue = (key: string, value: unknown) => {
if (value === params.args[key]) {
return;
}
if (mappedArgs === params.args) {
mappedArgs = { ...params.args };
}
mappedArgs[key] = value;
};
for (const key of MESSAGE_MEDIA_KEYS) {
setMappedValue(key, mapMediaPath(params.args[key]));
}
for (const key of MESSAGE_MEDIA_ARRAY_KEYS) {
const value = params.args[key];
if (Array.isArray(value)) {
const mapped = value.map(mapMediaPath);
if (mapped.some((entry, index) => entry !== value[index])) {
setMappedValue(key, mapped);
}
}
}
if (Array.isArray(params.args.attachments)) {
const attachments = params.args.attachments;
const mapped = attachments.map((attachment) => {
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
return attachment;
}
const record = attachment as Record<string, unknown>;
let mappedAttachment = record;
for (const key of ATTACHMENT_MEDIA_KEYS) {
const value = mapMediaPath(record[key]);
if (value !== record[key]) {
if (mappedAttachment === record) {
mappedAttachment = { ...record };
}
mappedAttachment[key] = value;
}
}
return mappedAttachment;
});
if (mapped.some((attachment, index) => attachment !== attachments[index])) {
setMappedValue("attachments", mapped);
}
}
if (attachmentEntries > REMOTE_WORKSPACE_MEDIA_MAX_ATTACHMENTS) {
throw new Error(
`Codex remote workspace upload exceeds the ${REMOTE_WORKSPACE_MEDIA_MAX_ATTACHMENTS}-attachment limit.`,
);
}
for (const managedPath of gatewayManagedPaths) {
await assertGatewayManagedMediaPath(managedPath, gatewayMediaRoot);
}
if (remotePathsByLocalPath.size === 0) {
return mappedArgs;
}
const readRemoteFile = params.readRemoteFile;
if (!readRemoteFile) {
throw new Error("Codex remote workspace file transfer requires an active app-server client.");
}
const maxBytes = params.maxBytes ?? REMOTE_WORKSPACE_MEDIA_MAX_BYTES;
const timeoutMs = params.timeoutMs ?? REMOTE_WORKSPACE_MEDIA_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
const stagedPaths = new Map<string, string>();
let totalBytes = 0;
// Read the authoritative remote descriptor, not an unverified synchronized
// path. The native command caps allocation and output before bytes travel.
for (const [localPath, remotePath] of remotePathsByLocalPath) {
params.signal?.throwIfAborted();
const remainingBytes = maxBytes - totalBytes;
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
throw new Error("Codex remote workspace attachment batch timed out.");
}
const response = await readRemoteFile({
path: remotePath,
maxBytes: remainingBytes,
workspaceRoot: remoteWorkspaceRoot,
signal: params.signal,
timeoutMs: remainingMs,
});
if (!response || typeof response.dataBase64 !== "string") {
throw new Error(`Codex remote workspace artifact returned no file data: ${remotePath}`);
}
if (response.dataBase64.length > Math.ceil(remainingBytes / 3) * 4) {
throw new Error(
`Codex remote workspace artifact exceeds the limit of ${remainingBytes} bytes.`,
);
}
const remoteBuffer = Buffer.from(response.dataBase64, "base64");
if (
remoteBuffer.byteLength > remainingBytes ||
remoteBuffer.toString("base64") !== response.dataBase64
) {
throw new Error(
`Codex remote workspace artifact returned invalid or oversized file data: ${remotePath}`,
);
}
totalBytes += remoteBuffer.byteLength;
const saved = await saveMediaBuffer(
remoteBuffer,
undefined,
"outbound",
maxBytes,
path.basename(remotePath),
);
stagedPaths.set(localPath, saved.path);
}
return mapMessageMediaValues(mappedArgs, (value) => stagedPaths.get(value) ?? value);
}
function isGatewayManagedMediaPath(value: string, mediaRoot: string): boolean {
if (!path.isAbsolute(value)) {
return false;
}
const relativePath = path.relative(mediaRoot, value);
return Boolean(
relativePath &&
relativePath !== ".." &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath),
);
}
async function assertGatewayManagedMediaPath(value: string, mediaRoot: string): Promise<void> {
const media = await root(mediaRoot, { symlinks: "reject" });
const opened = await media.open(path.relative(mediaRoot, value), { symlinks: "reject" });
try {
if (!(await opened.handle.stat()).isFile()) {
throw new Error(`Codex Gateway-managed media is not a regular file: ${value}`);
}
} finally {
await opened[Symbol.asyncDispose]();
}
}
function mapMessageMediaValues(
args: Record<string, unknown>,
mapValue: (value: string) => string,
): Record<string, unknown> {
const mapped = { ...args };
for (const key of MESSAGE_MEDIA_KEYS) {
const value = mapped[key];
if (typeof value === "string") {
mapped[key] = mapValue(value);
}
}
for (const key of MESSAGE_MEDIA_ARRAY_KEYS) {
const value = mapped[key];
if (Array.isArray(value)) {
mapped[key] = value.map((entry) => (typeof entry === "string" ? mapValue(entry) : entry));
}
}
if (Array.isArray(mapped.attachments)) {
mapped.attachments = mapped.attachments.map((attachment) => {
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
return attachment;
}
const record = { ...(attachment as Record<string, unknown>) };
for (const key of ATTACHMENT_MEDIA_KEYS) {
const value = record[key];
if (typeof value === "string") {
record[key] = mapValue(value);
}
}
return record;
});
}
return mapped;
}
@@ -0,0 +1,146 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
mapCodexAppServerLocalWorkspacePath,
mapCodexAppServerRemoteWorkspacePath,
} from "./remote-workspace-path.js";
const localWorkspaceRoot = path.resolve("gateway-workspace");
const remoteWorkspaceRoot = "/remote/codex-workspace";
describe("Codex remote workspace paths", () => {
it("maps a gateway workspace artifact into the remote execution workspace", () => {
expect(
mapCodexAppServerRemoteWorkspacePath({
value: path.join(localWorkspaceRoot, "reports", "slack-upload.txt"),
localWorkspaceRoot,
remoteWorkspaceRoot,
}),
).toBe(`${remoteWorkspaceRoot}/reports/slack-upload.txt`);
});
it("maps a remote workspace artifact into the gateway workspace", () => {
expect(
mapCodexAppServerLocalWorkspacePath({
value: `${remoteWorkspaceRoot}/reports/slack-upload.txt`,
localWorkspaceRoot,
remoteWorkspaceRoot,
}),
).toBe(path.join(localWorkspaceRoot, "reports", "slack-upload.txt"));
});
it("preserves URL-backed and managed media sources", () => {
for (const value of [
"https://example.com/image.png",
"mxc://example.org/image",
"buffer://generated-image",
"media://inbound/image.png",
"data:image/png;base64,aGVsbG8=",
]) {
expect(
mapCodexAppServerLocalWorkspacePath({ value, localWorkspaceRoot, remoteWorkspaceRoot }),
).toBe(value);
}
});
it("maps workspace-relative media into the gateway workspace", () => {
expect(
mapCodexAppServerLocalWorkspacePath({
value: "reports/slack-upload.txt",
localWorkspaceRoot,
remoteWorkspaceRoot,
}),
).toBe(path.join(localWorkspaceRoot, "reports", "slack-upload.txt"));
});
it("normalizes harmless relative and nested dot segments", () => {
for (const value of [
"./reports/slack-upload.txt",
"reports/./slack-upload.txt",
`${remoteWorkspaceRoot}/reports/./slack-upload.txt`,
]) {
expect(
mapCodexAppServerLocalWorkspacePath({ value, localWorkspaceRoot, remoteWorkspaceRoot }),
).toBe(path.join(localWorkspaceRoot, "reports", "slack-upload.txt"));
}
});
it("matches Windows remote workspace roots without depending on path casing", () => {
expect(
mapCodexAppServerLocalWorkspacePath({
value: "c:\\work\\repo\\Reports\\Upload.TXT",
localWorkspaceRoot,
remoteWorkspaceRoot: "C:\\Work\\Repo",
}),
).toBe(path.join(localWorkspaceRoot, "Reports", "Upload.TXT"));
});
it("preserves case-insensitive Windows drive-root workspace paths", () => {
expect(
mapCodexAppServerLocalWorkspacePath({
value: "c:\\Reports\\Upload.TXT",
localWorkspaceRoot,
remoteWorkspaceRoot: "C:\\",
}),
).toBe(path.join(localWorkspaceRoot, "Reports", "Upload.TXT"));
});
it("projects gateway artifacts into Windows drive roots without duplicate slashes", () => {
expect(
mapCodexAppServerRemoteWorkspacePath({
value: path.join(localWorkspaceRoot, "reports", "upload.txt"),
localWorkspaceRoot,
remoteWorkspaceRoot: "C:\\",
}),
).toBe("C:/reports/upload.txt");
});
it("continues rejecting case-insensitive Windows sibling paths", () => {
expect(() =>
mapCodexAppServerLocalWorkspacePath({
value: "c:\\work\\repo-other\\private.txt",
localWorkspaceRoot,
remoteWorkspaceRoot: "C:\\Work\\Repo",
}),
).toThrow("outside");
});
it("rejects remote sibling and gateway-local filesystem paths", () => {
for (const value of [
`${remoteWorkspaceRoot}-other/report.txt`,
"/etc/passwd",
path.join(localWorkspaceRoot, "private.txt"),
"file:///etc/passwd",
]) {
expect(() =>
mapCodexAppServerLocalWorkspacePath({ value, localWorkspaceRoot, remoteWorkspaceRoot }),
).toThrow("outside");
}
});
it("rejects traversal inside a claimed remote workspace artifact", () => {
expect(() =>
mapCodexAppServerLocalWorkspacePath({
value: `${remoteWorkspaceRoot}/reports/../../private.txt`,
localWorkspaceRoot,
remoteWorkspaceRoot,
}),
).toThrow("must stay inside");
});
it("preserves workspace paths when no remote root is configured", () => {
const value = path.join(localWorkspaceRoot, "report.txt");
expect(mapCodexAppServerRemoteWorkspacePath({ value, localWorkspaceRoot })).toBe(value);
expect(mapCodexAppServerLocalWorkspacePath({ value, localWorkspaceRoot })).toBe(value);
});
it("continues rejecting gateway working directories outside the workspace", () => {
expect(() =>
mapCodexAppServerRemoteWorkspacePath({
value: path.resolve("outside-workspace", "report.txt"),
localWorkspaceRoot,
remoteWorkspaceRoot,
}),
).toThrow("outside OpenClaw workspace root");
});
});
@@ -0,0 +1,95 @@
import path from "node:path";
type CodexRemoteWorkspacePathParams = {
value: string;
localWorkspaceRoot: string;
remoteWorkspaceRoot?: string;
};
/** Projects a gateway workspace path into the remote Codex execution workspace. */
export function mapCodexAppServerRemoteWorkspacePath(
params: CodexRemoteWorkspacePathParams,
): string {
if (!params.remoteWorkspaceRoot) {
return params.value;
}
const localRoot = normalizeWorkspaceMatchPath(params.localWorkspaceRoot);
const remoteRoot = normalizeWorkspaceMatchPath(params.remoteWorkspaceRoot);
const normalizedValue = normalizeWorkspaceMatchPath(params.value);
if (!localRoot || !remoteRoot) {
throw new Error("Codex remoteWorkspaceRoot requires non-empty workspace roots.");
}
if (normalizedValue === localRoot) {
return remoteRoot;
}
const prefix = `${localRoot}/`;
if (!normalizedValue.startsWith(prefix)) {
throw new Error(
`Codex remoteWorkspaceRoot is configured but cwd ${params.value} is outside OpenClaw workspace root ${params.localWorkspaceRoot}; refusing to send a gateway-local cwd to the remote Codex app-server.`,
);
}
return joinRemoteWorkspacePath(remoteRoot, normalizedValue.slice(prefix.length));
}
/** Maps a remote workspace artifact back into the corresponding gateway workspace. */
export function mapCodexAppServerLocalWorkspacePath(
params: CodexRemoteWorkspacePathParams,
): string {
if (!params.remoteWorkspaceRoot) {
return params.value;
}
const localRoot = normalizeWorkspaceMatchPath(params.localWorkspaceRoot);
const remoteRoot = normalizeWorkspaceMatchPath(params.remoteWorkspaceRoot);
if (!localRoot || !remoteRoot) {
throw new Error("Codex remoteWorkspaceRoot requires non-empty workspace roots.");
}
const normalizedValue = normalizeWorkspaceMatchPath(params.value);
if (!normalizedValue || isCodexPassThroughMediaSource(normalizedValue)) {
return params.value;
}
const usesWindowsPaths = /^[a-z]:\//iu.test(remoteRoot) || remoteRoot.startsWith("//");
const matchValue = usesWindowsPaths ? normalizedValue.toLowerCase() : normalizedValue;
const matchRoot = usesWindowsPaths ? remoteRoot.toLowerCase() : remoteRoot;
if (matchValue === matchRoot) {
return params.localWorkspaceRoot;
}
const prefix = matchRoot.endsWith("/") ? matchRoot : `${matchRoot}/`;
const isRemoteWorkspacePath = matchValue.startsWith(prefix);
if (!isRemoteWorkspacePath && isAbsoluteWorkspacePath(normalizedValue)) {
throw new Error(
`Codex remote workspace artifact ${params.value} is outside ${params.remoteWorkspaceRoot}.`,
);
}
const suffix = isRemoteWorkspacePath ? normalizedValue.slice(prefix.length) : normalizedValue;
const suffixSegments = suffix.split("/");
if (suffixSegments.some((segment) => segment === "..")) {
throw new Error(
`Codex remote workspace artifact ${params.value} must stay inside ${params.remoteWorkspaceRoot}.`,
);
}
return path.join(
params.localWorkspaceRoot,
...suffixSegments.filter((segment) => segment !== "."),
);
}
function normalizeWorkspaceMatchPath(value: string): string {
const normalized = value.replace(/\\/gu, "/");
if (/^[a-z]:\/$/iu.test(normalized)) {
return normalized;
}
return normalized.length > 1 ? normalized.replace(/[\\/]+$/u, "") : normalized;
}
/** Keeps remote URLs and opaque managed media references out of workspace path mapping. */
export function isCodexPassThroughMediaSource(value: string): boolean {
return /^(?:https?|mxc|buffer|media):\/\//iu.test(value) || /^data:/iu.test(value);
}
function isAbsoluteWorkspacePath(value: string): boolean {
return value.startsWith("/") || /^[a-z]:\//iu.test(value) || /^[a-z][a-z0-9+.-]*:/iu.test(value);
}
function joinRemoteWorkspacePath(remoteRoot: string, suffix: string): string {
return remoteRoot.endsWith("/") ? `${remoteRoot}${suffix}` : `${remoteRoot}/${suffix}`;
}
@@ -14,6 +14,7 @@ import { CodexAppServerEventProjector } from "./event-projector.js";
import { createCodexNativeMcpAppResultDetailsPreparer } from "./native-mcp-app.js";
import type { CodexTurnStartResponse, JsonObject } from "./protocol.js";
import { readRecentCodexRateLimits } from "./rate-limit-cache.js";
import { readBoundedCodexRemoteWorkspaceFile } from "./remote-workspace-media.js";
import type { CodexAttemptLifecycleController } from "./run-attempt-lifecycle-controller.js";
import type { CodexAttemptNotificationController } from "./run-attempt-notification-controller.js";
import type { CodexAttemptResources } from "./run-attempt-resources.js";
@@ -96,6 +97,16 @@ export async function activateCodexAttemptTurn(
resourceState.nativeHookRelay.shouldRelayEvent("post_tool_use"),
readRecentRateLimits: () => readRecentCodexRateLimits(resourceState.client),
runAbortSignal: runAbortController.signal,
remoteWorkspaceRoot: connection.appServer.remoteWorkspaceRoot,
remoteWorkspaceRequestTimeoutMs: connection.appServer.requestTimeoutMs,
readRemoteWorkspaceFile: ({ path, maxBytes, signal, timeoutMs }) =>
readBoundedCodexRemoteWorkspaceFile({
client: resourceState.client,
path,
maxBytes,
signal,
timeoutMs,
}),
trajectoryRecorder,
onNativeToolResultRecorded: maybeAnnounceFastModeAutoOff,
...(prepareNativeMcpAppResultDetails ? { prepareNativeMcpAppResultDetails } : {}),
@@ -2,6 +2,7 @@ import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js";
import { startCodexAttemptThread } from "./attempt-startup.js";
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
import { readBoundedCodexRemoteWorkspaceFile } from "./remote-workspace-media.js";
import {
emitCodexAppServerEvent,
withCodexAppServerFastModeServiceTier,
@@ -107,6 +108,17 @@ export async function startCodexAttemptRuntime(resources: CodexAttemptResources)
spawnedBy: params.spawnedBy,
});
state.client = startupResult.client;
toolBridge.setRemoteWorkspaceFileReader?.(
({ path, maxBytes, workspaceRoot, signal, timeoutMs }) =>
readBoundedCodexRemoteWorkspaceFile({
client: startupResult.client,
path,
maxBytes,
workspaceRoot,
signal,
timeoutMs,
}),
);
state.thread = startupResult.thread;
state.runtimeArtifact = startupResult.runtimeArtifact;
state.turnRouter = startupResult.turnRouter;
@@ -231,6 +231,8 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) {
config: params.config,
contextWindowTokens: params.contextTokenBudget ?? params.model.contextWindow,
workspaceDir: effectiveWorkspace,
remoteWorkspaceRoot: connection.appServer.remoteWorkspaceRoot,
remoteWorkspaceRequestTimeoutMs: connection.appServer.requestTimeoutMs,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
runId: params.runId,
@@ -4101,7 +4101,7 @@ describe("runCodexAppServerAttempt", () => {
expect(readAttemptTerminal(result)).toMatchObject({ aborted: false, timedOut: false });
});
it("surfaces Codex-native image generation saved paths as reply media", async () => {
it("materializes Codex-native image generation into Gateway-owned reply media", async () => {
const savedPath = "/tmp/codex-home/generated_images/session-1/ig_123.png";
const harness = createAppServerHarness(async (method) => {
if (method === "thread/start") {
@@ -4130,8 +4130,10 @@ describe("runCodexAppServerAttempt", () => {
const result = await runCodexAppServerAttempt(createRunParams());
expect(harness.requests.map((entry) => entry.method)).toContain("turn/start");
expect(result.assistantTexts).toEqual([]);
expect(result.toolMediaUrls).toEqual([savedPath]);
expect(result.hostOwnedToolMediaUrls).toEqual([savedPath]);
expect(result.toolMediaUrls).toHaveLength(1);
expect(result.toolMediaUrls?.[0]).not.toBe(savedPath);
expect(result.hostOwnedToolMediaUrls).toEqual(result.toolMediaUrls);
await expect(fs.readFile(result.toolMediaUrls?.[0] ?? "")).resolves.toEqual(Buffer.from("foo"));
});
it("does not complete on unscoped turn/completed notifications", async () => {
const harness = createStartedThreadHarness();