fix: harden codex sandbox execution

Harden the Codex app-server native execution bridge for OpenClaw sandboxed runs. The change keeps core sandbox policy in OpenClaw while exposing the process, filesystem, and HTTP relay behavior Codex needs inside a scoped exec server.

The large exec-server/test files were split into focused modules before landing, and the PR was rebased onto current main with focused tests, Testbox changed checks, CI, and Codex autoreview green.

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
Josh Avant
2026-05-21 15:47:32 -07:00
committed by GitHub
parent c2004fe662
commit ba06376c79
47 changed files with 5161 additions and 263 deletions

View File

@@ -131,6 +131,7 @@ Docs: https://docs.openclaw.ai
- Matrix/config: accept `messages.queue.byChannel.matrix` queue overrides and keep queue provider schema/type keys aligned for Matrix, Google Chat, and Mattermost. Thanks @bdjben.
- CLI: format `openclaw acp client` failures through the shared error formatter so object-shaped errors stay readable instead of printing `[object Object]`. Fixes #83904. (#84080)
- Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when `/api/show` omits capabilities. (#84055) Thanks @dutifulbob.
- Codex app-server: disable native Code Mode, user MCP, and app-backed plugin execution while OpenClaw sandboxing is active, routing shell access through `sandbox_exec`/`sandbox_process` instead. (#84388) Thanks @joshavant.
- Installer/Windows: launch `install.ps1` onboarding as an attached child process so fresh native Windows installs do not freeze visibly at `Starting setup...` or corrupt the wizard's terminal rendering.
- CLI/update: keep restart health checks working across one-version CLI/Gateway protocol skew and use the managed Gateway service Node for all follow-up commands even when the package root is unchanged, so `openclaw update` no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight.
- CLI/gateway: include the running Gateway version in `gateway status` JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev.

View File

@@ -21,11 +21,12 @@ Treat them differently from normal config:
## Currently documented flags
| Surface | Key | Use it when | More |
| ------------------------ | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Local model runtime | `agents.defaults.experimental.localModelLean`, `agents.list[].experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) |
| Memory search | `agents.defaults.memorySearch.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) |
| Structured planning tool | `tools.experimental.planTool` | You want the structured `update_plan` tool exposed for multi-step work tracking in compatible runtimes and UIs | [Gateway configuration reference](/gateway/config-tools#toolsexperimental) |
| Surface | Key | Use it when | More |
| ------------------------ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Local model runtime | `agents.defaults.experimental.localModelLean`, `agents.list[].experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) |
| Memory search | `agents.defaults.memorySearch.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) |
| Codex harness | `plugins.entries.codex.config.appServer.experimental.sandboxExecServer` | You want native Codex app-server 0.132.0 or newer to target an OpenClaw sandbox-backed exec-server instead of disabling Code Mode | [Codex harness reference](/plugins/codex-harness-reference#sandboxed-native-execution) |
| Structured planning tool | `tools.experimental.planTool` | You want the structured `update_plan` tool exposed for multi-step work tracking in compatible runtimes and UIs | [Gateway configuration reference](/gateway/config-tools#toolsexperimental) |
## Local model lean mode

View File

@@ -98,12 +98,13 @@ If you deploy the OpenClaw Gateway itself as a Docker container, it orchestrates
- **Config requires host paths**: The `openclaw.json` `workspace` configuration MUST contain the **Host's absolute path** (e.g. `/home/user/.openclaw/workspaces`), not the internal Gateway container path. When OpenClaw asks the Docker daemon to spawn a sandbox, the daemon evaluates paths relative to the Host OS namespace, not the Gateway namespace.
- **FS bridge parity (identical volume map)**: The OpenClaw Gateway native process also writes heartbeat and bridge files to the `workspace` directory. Because the Gateway evaluates the exact same string (the host path) from within its own containerized environment, the Gateway deployment MUST include an identical volume map linking the host namespace natively (`-v /home/user/.openclaw:/home/user/.openclaw`).
- **Codex code mode**: When an OpenClaw sandbox is active, OpenClaw constrains Codex app-server turns to Codex `workspace-write` sandboxing even if the Codex plugin default is `danger-full-access`. The Codex turn network flag follows the OpenClaw sandbox egress setting, so Docker `network: "none"` stays offline and `network: "bridge"` or a custom Docker network allows outbound access. Do not mount the host Docker socket into agent sandbox containers or custom Codex sandboxes.
- **Codex code mode**: When an OpenClaw sandbox is active, OpenClaw disables Codex app-server native Code Mode, user MCP servers, and app-backed plugin execution for that turn because those native surfaces run from the Gateway-host app-server process instead of the OpenClaw sandbox backend. Shell access is exposed through OpenClaw sandbox-backed tools such as `sandbox_exec` and `sandbox_process` when the normal exec/process tools are available. Do not mount the host Docker socket into agent sandbox containers or custom Codex sandboxes.
On Ubuntu/AppArmor hosts, Codex `workspace-write` can fail before shell startup
when the service user is not allowed to create unprivileged user namespaces.
When Docker sandbox egress is disabled (`network: "none"`, the default),
Codex also needs an unprivileged network namespace. Common symptoms are
when you intentionally run native Codex `workspace-write` without active
OpenClaw sandboxing and the service user is not allowed to create unprivileged
user namespaces. When Docker sandbox egress is disabled (`network: "none"`, the
default), Codex also needs an unprivileged network namespace. Common symptoms are
`bwrap: setting up uid map: Permission denied` and
`bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`. Run
`openclaw doctor`; if it reports a Codex bwrap namespace probe failure, prefer

View File

@@ -85,23 +85,24 @@ For an already-running app-server, use WebSocket transport:
Supported `appServer` fields:
| Field | Default | Meaning |
| ----------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `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. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
| `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`. |
| `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"`. |
| Field | Default | Meaning |
| -------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `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. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
| `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`. |
| `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 when OpenClaw sandboxing is inactive. Active OpenClaw sandboxes disable native Code Mode instead of relying on Codex host-side sandboxing. |
| `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"`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
The plugin blocks older or unversioned app-server handshakes. Codex app-server
must report stable version `0.125.0` or newer.
@@ -147,15 +148,16 @@ values are allowed. Individual policy fields override `mode`. The older
but new configs should use `auto_review`.
When an OpenClaw sandbox is active, the local Codex app-server process still
runs on the Gateway host. OpenClaw therefore keeps Codex's own filesystem
sandbox for native code-mode turns. `danger-full-access` turns are narrowed to
Codex `workspace-write`, and `workspace-write` turn `networkAccess` is derived
from the OpenClaw sandbox egress setting: Docker `network: "none"` stays
offline, while `network: "bridge"` or a custom Docker network permits outbound
access.
runs on the Gateway host. OpenClaw therefore disables Codex native Code Mode,
user MCP servers, and app-backed plugin execution for that turn instead of
treating Codex host-side sandboxing as equivalent to the OpenClaw sandbox
backend. Shell access is exposed through OpenClaw sandbox-backed dynamic tools
such as `sandbox_exec` and `sandbox_process` when the normal exec/process tools
are available.
On Ubuntu/AppArmor hosts, Codex bwrap can fail under `workspace-write` before
the shell command starts. If you see
the shell command starts when you intentionally run native Codex
`workspace-write` without active OpenClaw sandboxing. If you see
`bwrap: setting up uid map: Permission denied` or
`bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`, run
`openclaw doctor` and fix the reported host namespace policy for the OpenClaw
@@ -164,6 +166,43 @@ a scoped AppArmor profile for the service process; the
`kernel.apparmor_restrict_unprivileged_userns=0` fallback is host-wide and has
security tradeoffs.
## Sandboxed native execution
The stable default is fail-closed: active OpenClaw sandboxing disables native
Codex execution surfaces that would otherwise run from the Codex app-server
host. Use `appServer.experimental.sandboxExecServer: true` only when you want to
try Codex's remote environment support with OpenClaw's sandbox backend. This
preview path requires Codex app-server 0.132.0 or newer.
```json5
{
plugins: {
entries: {
codex: {
enabled: true,
config: {
appServer: {
experimental: {
sandboxExecServer: true,
},
},
},
},
},
},
}
```
When the flag is on and the current OpenClaw session is sandboxed, OpenClaw
starts a local loopback exec-server backed by the active sandbox, registers it
with Codex app-server, and starts the Codex thread and turn with that
OpenClaw-owned environment. If the app-server cannot register the environment,
the run fails closed instead of silently falling back to host execution.
This preview path is local-only. A remote WebSocket app-server cannot reach the
loopback exec-server unless it is running on the same host, so OpenClaw rejects
that combination.
## Auth and environment isolation
Auth is selected in this order:

View File

@@ -21,11 +21,12 @@ Do not configure `openai-codex/gpt-*` model refs. Put OpenAI agent auth order
under `auth.order.openai`; older `openai-codex:*` profiles and
`auth.order.openai-codex` entries remain supported for existing installs.
OpenClaw starts Codex app-server threads with Codex native code mode enabled
while leaving code-mode-only off by default. That keeps Codex native workspace
and code capabilities available while OpenClaw dynamic tools continue through
the app-server `item/tool/call` bridge. Restricted tool policies still disable
native code mode entirely.
When no OpenClaw sandbox is active, OpenClaw starts Codex app-server threads
with Codex native code mode enabled while leaving code-mode-only off by default.
That keeps Codex native workspace and code capabilities available while
OpenClaw dynamic tools continue through the app-server `item/tool/call` bridge.
Active OpenClaw sandboxing and restricted tool policies disable native code mode
entirely unless you opt into the experimental sandbox exec-server path.
For the broader model/provider/runtime split, start with
[Agent runtimes](/concepts/agent-runtimes). The short version is:
@@ -346,12 +347,11 @@ Local stdio app-server sessions default to the trusted local operator posture:
`approvalPolicy: "never"`, `approvalsReviewer: "user"`, and
`sandbox: "danger-full-access"`. If local Codex requirements disallow that
implicit YOLO posture, OpenClaw selects allowed guardian permissions instead.
When an OpenClaw sandbox is active for the session, OpenClaw narrows Codex
`danger-full-access` to Codex `workspace-write` so native Codex code-mode turns
stay inside the sandboxed workspace. The Codex turn network flag follows the
OpenClaw sandbox egress policy: Docker `network: "none"` stays offline, while
`network: "bridge"` or a custom Docker network allows outbound access.
Explicit Codex `workspace-write` turns use the same egress-derived network flag.
When an OpenClaw sandbox is active for the session, OpenClaw disables Codex
native Code Mode, user MCP servers, and app-backed plugin execution for that
turn instead of relying on Codex host-side sandboxing. Shell access is exposed
through OpenClaw sandbox-backed dynamic tools such as `sandbox_exec` and
`sandbox_process` when the normal exec/process tools are available.
Use guardian mode when you want Codex native auto-review before sandbox escapes
or extra permissions:
@@ -511,23 +511,24 @@ Supported top-level Codex plugin fields:
Supported `appServer` fields:
| Field | Default | Meaning |
| ----------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `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. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
| `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`. Raise this for slow post-tool or status-only synthesis phases. |
| `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"`. |
| Field | Default | Meaning |
| -------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `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. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
| `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`. Raise this for slow post-tool or status-only synthesis phases. |
| `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 when OpenClaw sandboxing is inactive. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. Active OpenClaw sandboxes disable native Code Mode instead of relying on Codex host-side sandboxing. |
| `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"`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
OpenClaw-owned dynamic tool calls are bounded independently from
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 30 second

View File

@@ -114,6 +114,7 @@ export default definePluginEntry({
);
api.on("inbound_claim", (event, ctx) =>
handleCodexConversationInboundClaim(event, ctx, {
config: api.runtime.config?.current?.() as OpenClawConfig | undefined,
pluginConfig: resolveCurrentPluginConfig(),
resumeCodexCliSessionOnNode: (params) =>
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),

View File

@@ -206,6 +206,11 @@ describe("codex media understanding provider", () => {
serviceName: "OpenClaw",
developerInstructions:
"You are OpenClaw's bounded image-understanding worker. Describe only the provided image content. Do not call tools, edit files, or ask follow-up questions.",
config: {
"features.code_mode": false,
"features.code_mode_only": false,
},
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,
@@ -363,6 +368,11 @@ describe("codex media understanding provider", () => {
serviceName: "OpenClaw",
developerInstructions:
"You are OpenClaw's bounded structured-extraction worker. Return only the requested extraction. Do not call tools, edit files, ask follow-up questions, or include secrets.",
config: {
"features.code_mode": false,
"features.code_mode_only": false,
},
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,

View File

@@ -31,6 +31,7 @@ import {
type JsonObject,
type JsonValue,
} from "./src/app-server/protocol.js";
import { buildCodexRuntimeThreadConfig } from "./src/app-server/thread-lifecycle.js";
const DEFAULT_CODEX_IMAGE_MODEL =
FALLBACK_CODEX_MODELS.find((model) => model.inputModalities.includes("image"))?.id ??
@@ -158,6 +159,8 @@ async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams):
sandbox: "read-only",
serviceName: "OpenClaw",
developerInstructions: params.developerInstructions,
config: buildCodexRuntimeThreadConfig(undefined, { nativeCodeModeEnabled: false }),
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
persistExtendedHistory: false,

View File

@@ -190,6 +190,16 @@
"serviceTier": { "type": ["string", "null"] },
"defaultWorkspaceDir": {
"type": "string"
},
"experimental": {
"type": "object",
"additionalProperties": false,
"properties": {
"sandboxExecServer": {
"type": "boolean",
"default": false
}
}
}
}
}
@@ -369,6 +379,16 @@
"label": "Default Workspace",
"help": "Workspace used by /codex bind when --cwd is omitted.",
"advanced": true
},
"appServer.experimental": {
"label": "Experimental",
"help": "Experimental Codex app-server integrations.",
"advanced": true
},
"appServer.experimental.sandboxExecServer": {
"label": "Sandbox Exec Server",
"help": "Route native Codex execution through an OpenClaw sandbox-backed exec-server when OpenClaw sandboxing is active.",
"advanced": true
}
}
}

View File

@@ -106,6 +106,7 @@ export class CodexAppServerClient {
private initialized = false;
private closed = false;
private closeError: Error | undefined;
private serverVersion: string | undefined;
private stderrTail = "";
private pendingParse:
| {
@@ -178,11 +179,15 @@ export class CodexAppServerClient {
experimentalApi: true,
},
} satisfies CodexInitializeParams);
assertSupportedCodexAppServerVersion(response);
this.serverVersion = assertSupportedCodexAppServerVersion(response);
this.notify("initialized");
this.initialized = true;
}
getServerVersion(): string | undefined {
return this.serverVersion;
}
request<M extends CodexAppServerRequestMethod>(
method: M,
params: CodexAppServerRequestParams<M>,
@@ -568,18 +573,19 @@ function timeoutServerRequestResponse(
};
}
function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse): void {
function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse): string {
const detectedVersion = readCodexVersionFromUserAgent(response.userAgent);
if (!detectedVersion) {
throw new Error(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but OpenClaw could not determine the running Codex version. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`,
);
}
if (compareVersions(detectedVersion, MIN_CODEX_APP_SERVER_VERSION) < 0) {
if (compareCodexAppServerVersions(detectedVersion, MIN_CODEX_APP_SERVER_VERSION) < 0) {
throw new Error(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required, but detected ${detectedVersion}. Update the configured Codex app-server binary, or remove custom command overrides to use the managed binary.`,
);
}
return detectedVersion;
}
export function readCodexVersionFromUserAgent(userAgent: string | undefined): string | undefined {
@@ -592,7 +598,7 @@ export function readCodexVersionFromUserAgent(userAgent: string | undefined): st
return match?.[1];
}
function compareVersions(left: string, right: string): number {
export function compareCodexAppServerVersions(left: string, right: string): number {
const leftVersion = parseVersionForComparison(left);
const rightVersion = parseVersionForComparison(right);
const leftParts = leftVersion.parts;

View File

@@ -56,6 +56,16 @@ function startCompaction(sessionFile: string, options: { currentTokenCount?: num
});
}
function startSandboxedCompaction(sessionFile: string) {
return maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
});
}
type CompactResult = NonNullable<Awaited<ReturnType<typeof maybeCompactCodexAppServerSession>>>;
function requireCompactResult(result: CompactResult | undefined): CompactResult {
@@ -112,6 +122,21 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(details.turnId).toBe("turn-1");
});
it("blocks native app-server compaction when the current OpenClaw session is sandboxed", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const result = requireCompactResult(await startSandboxedCompaction(sessionFile));
expect(result.ok).toBe(false);
expect(result.compacted).toBe(false);
expect(result.reason).toContain(
"Codex-native native compaction is unavailable because OpenClaw sandboxing is active for this session.",
);
expect(fake.request).not.toHaveBeenCalled();
});
it("accepts native context-compaction item completion as success", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);

View File

@@ -16,6 +16,7 @@ import {
import type { CodexAppServerClient, CodexServerNotificationHandler } from "./client.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import { isJsonObject, type CodexServerNotification, type JsonObject } from "./protocol.js";
import { resolveCodexNativeSandboxBlock } from "./sandbox-guard.js";
import { clearCodexAppServerBinding, readCodexAppServerBinding } from "./session-binding.js";
type CodexNativeCompactionCompletion = {
signal: "thread/compacted" | "item/completed";
@@ -322,6 +323,15 @@ async function compactCodexNativeThread(
params: CompactEmbeddedPiSessionParams,
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
): Promise<EmbeddedPiCompactResult | undefined> {
const sandboxBlock = resolveCodexNativeSandboxBlock({
config: params.config,
sessionKey: params.sandboxSessionKey ?? params.sessionKey,
sessionId: params.sessionId,
surface: "native compaction",
});
if (sandboxBlock) {
return { ok: false, compacted: false, reason: sandboxBlock };
}
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const binding = await readCodexAppServerBinding(params.sessionFile, { config: params.config });
if (!binding?.threadId) {

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import {
CODEX_APP_SERVER_CONFIG_KEYS,
CODEX_APP_SERVER_EXPERIMENTAL_CONFIG_KEYS,
CODEX_COMPUTER_USE_CONFIG_KEYS,
CODEX_PLUGIN_ENTRY_CONFIG_KEYS,
CODEX_PLUGINS_CONFIG_KEYS,
@@ -464,6 +465,18 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
});
});
it("parses app-server experimental flags", () => {
expect(
readCodexPluginConfig({
appServer: {
experimental: {
sandboxExecServer: true,
},
},
}).appServer?.experimental,
).toEqual({ sandboxExecServer: true });
});
it("rejects the retired dynamic tool profile key", () => {
expect(
readCodexPluginConfig({
@@ -833,6 +846,17 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
for (const key of CODEX_APP_SERVER_CONFIG_KEYS) {
expectUiHintLabel(manifest, `appServer.${key}`);
}
const appServerExperimentalProperties = (
manifest.configSchema.properties.appServer.properties.experimental as {
properties: Record<string, unknown>;
}
).properties;
expect(Object.keys(appServerExperimentalProperties).toSorted()).toEqual([
...CODEX_APP_SERVER_EXPERIMENTAL_CONFIG_KEYS,
]);
for (const key of CODEX_APP_SERVER_EXPERIMENTAL_CONFIG_KEYS) {
expectUiHintLabel(manifest, `appServer.experimental.${key}`);
}
const computerUseManifestKeys = Object.keys(
manifest.configSchema.properties.computerUse.properties,
).toSorted();

View File

@@ -72,6 +72,10 @@ export type CodexPluginsConfig = {
plugins?: Record<string, CodexPluginEntryConfig>;
};
export type CodexAppServerExperimentalConfig = {
sandboxExecServer?: boolean;
};
export type ResolvedCodexPluginPolicy = {
configKey: string;
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
@@ -136,6 +140,7 @@ export type CodexPluginConfig = {
approvalsReviewer?: CodexAppServerApprovalsReviewer;
serviceTier?: CodexServiceTier | null;
defaultWorkspaceDir?: string;
experimental?: CodexAppServerExperimentalConfig;
};
};
@@ -156,8 +161,11 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
"approvalsReviewer",
"serviceTier",
"defaultWorkspaceDir",
"experimental",
] as const;
export const CODEX_APP_SERVER_EXPERIMENTAL_CONFIG_KEYS = ["sandboxExecServer"] as const;
export const CODEX_COMPUTER_USE_CONFIG_KEYS = [
"enabled",
"autoInstall",
@@ -203,6 +211,11 @@ const codexAppServerServiceTierSchema = z
z.string().trim().min(1).nullable().optional(),
)
.optional();
const codexAppServerExperimentalSchema = z
.object({
sandboxExecServer: z.boolean().optional(),
})
.strict();
const codexPluginEntryConfigSchema = z
.object({
@@ -264,6 +277,7 @@ const codexPluginConfigSchema = z
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
serviceTier: codexAppServerServiceTierSchema,
defaultWorkspaceDir: z.string().optional(),
experimental: codexAppServerExperimentalSchema.optional(),
})
.strict()
.optional(),
@@ -283,6 +297,10 @@ export function readCodexPluginConfig(value: unknown): CodexPluginConfig {
return { ...config, ...(plugins.data ? { codexPlugins: plugins.data } : {}) };
}
export function isCodexSandboxExecServerEnabled(pluginConfig?: unknown): boolean {
return readCodexPluginConfig(pluginConfig).appServer?.experimental?.sandboxExecServer === true;
}
export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodexPluginsPolicy {
const config = readCodexPluginConfig(pluginConfig).codexPlugins;
const configured = config !== undefined;

View File

@@ -70,6 +70,11 @@ export type CodexDynamicToolSpec = JsonObject & {
inputSchema: JsonValue;
};
export type CodexTurnEnvironmentParams = JsonObject & {
environmentId: string;
cwd: string;
};
export type CodexThreadStartParams = JsonObject & {
input?: CodexUserInput[];
cwd?: string;
@@ -77,11 +82,12 @@ export type CodexThreadStartParams = JsonObject & {
modelProvider?: string | null;
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandbox?: CodexSandboxPolicy;
sandbox?: string;
serviceTier?: CodexServiceTier | null;
dynamicTools?: CodexDynamicToolSpec[] | null;
developerInstructions?: string;
experimentalRawEvents?: boolean;
environments?: CodexTurnEnvironmentParams[] | null;
persistExtendedHistory?: boolean;
};
@@ -89,6 +95,13 @@ export type CodexThreadResumeParams = JsonObject & {
threadId: string;
model?: string;
modelProvider?: string | null;
approvalPolicy?: string | JsonObject;
approvalsReviewer?: string | null;
sandbox?: string;
serviceTier?: CodexServiceTier | null;
config?: JsonObject;
developerInstructions?: string;
persistExtendedHistory?: boolean;
};
export type CodexThreadStartResponse = {
@@ -137,6 +150,7 @@ export type CodexTurnStartParams = JsonObject & {
sandboxPolicy?: CodexSandboxPolicy;
serviceTier?: CodexServiceTier | null;
effort?: string | null;
environments?: CodexTurnEnvironmentParams[] | null;
collaborationMode?: {
mode: string;
settings: JsonObject & {
@@ -476,6 +490,7 @@ export declare namespace v2 {
}
type CodexAppServerRequestParamsOverride = {
"environment/add": { environmentId: string; execServerUrl: string };
"thread/fork": CodexThreadForkParams;
"thread/inject_items": CodexThreadInjectItemsParams;
"thread/start": CodexThreadStartParams;
@@ -489,6 +504,7 @@ type CodexAppServerRequestResultMap = {
"account/read": CodexGetAccountResponse;
"app/list": CodexAppsListResponse;
"config/mcpServer/reload": JsonValue;
"environment/add": JsonValue;
"experimentalFeature/enablement/set": JsonValue;
"feedback/upload": JsonValue;
"hooks/list": CodexHooksListResponse;

View File

@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sharedClientMocks = vi.hoisted(() => ({
createIsolatedCodexAppServerClient: vi.fn(),
getSharedCodexAppServerClient: vi.fn(),
}));
vi.mock("./shared-client.js", () => sharedClientMocks);
const { requestCodexAppServerJson } = await import("./request.js");
describe("requestCodexAppServerJson sandbox guard", () => {
beforeEach(() => {
sharedClientMocks.createIsolatedCodexAppServerClient.mockReset();
sharedClientMocks.getSharedCodexAppServerClient.mockReset();
});
it("fails closed before raw app-server bypass methods in sandboxed sessions", async () => {
await expect(
requestCodexAppServerJson({
method: "command/exec",
requestParams: { command: ["sh", "-lc", "id"] },
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
}),
).rejects.toThrow(
"Codex-native app-server method `command/exec` is unavailable because OpenClaw sandboxing is active for this session.",
);
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
});
it("allows metadata methods in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ ok: true }));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
await expect(
requestCodexAppServerJson({
method: "thread/list",
requestParams: { limit: 10 },
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
}),
).resolves.toEqual({ ok: true });
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
});
it("allows sandbox-pinned thread starts in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ thread: { id: "thread-1" }, model: "gpt-5.5" }));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
const params = {
cwd: "/workspace",
environments: [{ environmentId: "openclaw-sandbox-abc123", cwd: "/workspace" }],
};
await expect(
requestCodexAppServerJson({
method: "thread/start",
requestParams: params,
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
}),
).resolves.toEqual({ thread: { id: "thread-1" }, model: "gpt-5.5" });
expect(request).toHaveBeenCalledWith("thread/start", params, { timeoutMs: 60_000 });
});
});

View File

@@ -6,6 +6,7 @@ import type {
CodexAppServerRequestResult,
JsonValue,
} from "./protocol.js";
import { resolveCodexAppServerDirectSandboxBypassBlock } from "./sandbox-guard.js";
import {
createIsolatedCodexAppServerClient,
getSharedCodexAppServerClient,
@@ -20,6 +21,8 @@ export async function requestCodexAppServerJson<M extends CodexAppServerRequestM
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sessionKey?: string;
sessionId?: string;
isolated?: boolean;
}): Promise<CodexAppServerRequestResult<M>>;
export async function requestCodexAppServerJson<T = JsonValue | undefined>(params: {
@@ -30,6 +33,8 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sessionKey?: string;
sessionId?: string;
isolated?: boolean;
}): Promise<T>;
export async function requestCodexAppServerJson<T = JsonValue | undefined>(params: {
@@ -40,8 +45,20 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sessionKey?: string;
sessionId?: string;
isolated?: boolean;
}): Promise<T> {
const sandboxBlock = resolveCodexAppServerDirectSandboxBypassBlock({
method: params.method,
requestParams: params.requestParams,
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
});
if (sandboxBlock) {
throw new Error(sandboxBlock);
}
const timeoutMs = params.timeoutMs ?? 60_000;
return await withTimeout(
(async () => {

View File

@@ -8,6 +8,7 @@ import {
embeddedAgentLog,
type HarnessContextEngine as ContextEngine,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexServerNotification } from "./protocol.js";
@@ -677,6 +678,121 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await run;
});
it("reprojects thread-bootstrap context for native-disabled transient Codex threads", async () => {
const restoreSandboxBackend = registerSandboxBackend(
"codex-context-test-sandbox",
async () => ({
id: "codex-context-test-sandbox",
runtimeId: "codex-context-test-runtime",
runtimeLabel: "Codex Context Test Sandbox",
workdir: "/workspace",
buildExecSpec: async () => ({
argv: ["true"],
env: {},
stdinMode: "pipe-closed" as const,
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
}),
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
try {
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
},
},
});
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ prompt }) => ({
messages: [
assistantMessage("native-disabled context", 10),
userMessage(prompt ?? "", 11),
],
estimatedTokens: 42,
systemPromptAddition: "context-engine system",
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
})),
});
const harness = createStartedThreadHarness(async (method) => {
if (method === "thread/start") {
return threadStartResult("thread-transient");
}
if (method === "thread/resume") {
throw new Error("native-disabled turns should not resume the previous Codex thread");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
params.config = {
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "codex-context-test-sandbox",
scope: "session",
workspaceAccess: "rw",
prune: { idleHours: 0, maxAgeDays: 0 },
},
},
},
} as EmbeddedRunAttemptParams["config"];
let runError: unknown;
const run = runCodexAppServerAttempt(params).catch((error: unknown) => {
runError = error;
throw error;
});
await vi.waitFor(
() => {
if (runError) {
throw runError;
}
expect(harness.requests.map((request) => request.method)).toContain("turn/start");
},
{ interval: 1 },
);
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/start",
"turn/start",
]);
expectRequestInputTextContains(harness, "OpenClaw assembled context for this turn:");
expectRequestInputTextContains(harness, "native-disabled context");
await harness.notify({
method: "turn/completed",
params: {
threadId: "thread-transient",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "completed",
items: [{ type: "agentMessage", id: "msg-1", text: "transient answer" }],
},
},
});
await run;
} finally {
restoreSandboxBackend();
}
});
it("starts a fresh Codex thread when thread-bootstrap projection falls back to per-turn projection", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -29,7 +29,9 @@ import {
} from "openclaw/plugin-sdk/hook-runtime";
import { clearPluginCommands, registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import WebSocket from "ws";
function queueActiveRunMessageForTest(
...args: Parameters<typeof queueAgentHarnessMessage>
@@ -69,6 +71,7 @@ import {
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
import { createCodexTestModel } from "./test-support.js";
import {
buildContextEngineBinding,
buildTurnCollaborationMode,
buildThreadResumeParams,
buildTurnStartParams,
@@ -285,8 +288,34 @@ function mockCall(mock: unknown, label: string, index = 0): unknown[] {
return call;
}
function openSocket(url: string): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const socket = new WebSocket(url);
const timer = setTimeout(() => {
socket.close();
reject(new Error("timed out opening WebSocket"));
}, 1_000);
const rejectBeforeOpen = (error: Error) => {
clearTimeout(timer);
reject(error);
};
socket.once("open", () => {
clearTimeout(timer);
resolve(socket);
});
socket.once("error", rejectBeforeOpen);
socket.once("close", () => {
rejectBeforeOpen(new Error("WebSocket closed before open"));
});
});
}
function createAppServerHarness(
requestImpl: (method: string, params: unknown) => Promise<unknown>,
requestImpl: (
method: string,
params: unknown,
options?: { signal?: AbortSignal },
) => Promise<unknown>,
options: {
onStart?: (authProfileId: string | undefined, agentDir: string | undefined) => void;
} = {},
@@ -295,14 +324,15 @@ function createAppServerHarness(
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
let handleServerRequest: AppServerRequestHandler | undefined;
const closeHandlers = new Set<() => void>();
const request = vi.fn(async (method: string, params?: unknown) => {
const request = vi.fn(async (method: string, params?: unknown, requestOptions?: unknown) => {
requests.push({ method, params });
return requestImpl(method, params);
return requestImpl(method, params, requestOptions as { signal?: AbortSignal } | undefined);
});
setCodexAppServerClientFactoryForTest(async (_startOptions, authProfileId, agentDir) => {
options.onStart?.(authProfileId, agentDir);
return {
getServerVersion: () => "0.132.0",
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
@@ -372,13 +402,17 @@ function createAppServerHarness(
}
function createStartedThreadHarness(
requestImpl: (method: string, params: unknown) => Promise<unknown> = async () => undefined,
requestImpl: (
method: string,
params: unknown,
options?: { signal?: AbortSignal },
) => Promise<unknown> = async () => undefined,
options: {
onStart?: (authProfileId: string | undefined, agentDir: string | undefined) => void;
} = {},
) {
return createAppServerHarness(async (method, params) => {
const override = await requestImpl(method, params);
return createAppServerHarness(async (method, params, requestOptions) => {
const override = await requestImpl(method, params, requestOptions);
if (override !== undefined) {
return override;
}
@@ -752,6 +786,7 @@ describe("runCodexAppServerAttempt", () => {
effectiveWorkspace: workspaceDir,
sandboxSessionKey,
sandbox: { enabled: true, backendId: "ssh" } as never,
nativeToolSurfaceEnabled: false,
runAbortController: new AbortController(),
sessionAgentId: "main",
pluginConfig: {},
@@ -767,7 +802,7 @@ describe("runCodexAppServerAttempt", () => {
);
});
it("keeps Docker sandbox shell tools hidden when native Code Mode can honor sandbox paths", async () => {
it("exposes Docker sandbox shell tools when OpenClaw sandboxing disables native Code Mode", async () => {
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("exec"),
createRuntimeDynamicTool("process"),
@@ -782,21 +817,31 @@ describe("runCodexAppServerAttempt", () => {
if (!sandboxSessionKey) {
throw new Error("createParams must provide a sessionKey for Codex dynamic tool tests.");
}
const sandbox = { enabled: true, backendId: "docker" } as never;
const nativeToolSurfaceEnabled = testing.shouldEnableCodexAppServerNativeToolSurface(
params,
sandbox,
);
const dockerTools = await testing.buildDynamicTools({
params,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: workspaceDir,
sandboxSessionKey,
sandbox: { enabled: true, backendId: "docker" } as never,
nativeToolSurfaceEnabled: true,
sandbox,
nativeToolSurfaceEnabled,
runAbortController: new AbortController(),
sessionAgentId: "main",
pluginConfig: {},
onYieldDetected: () => undefined,
});
expect(dockerTools.map((tool) => tool.name)).toEqual(["message"]);
expect(nativeToolSurfaceEnabled).toBe(false);
expect(dockerTools.map((tool) => tool.name)).toEqual([
"message",
"sandbox_exec",
"sandbox_process",
]);
});
it("exposes Docker sandbox shell tools when native Code Mode cannot honor sandbox paths", async () => {
@@ -838,6 +883,415 @@ describe("runCodexAppServerAttempt", () => {
);
});
it("starts active OpenClaw sandbox turns with Codex native execution disabled", async () => {
const restoreSandboxBackend = registerSandboxBackend("codex-test-sandbox", async () => ({
id: "codex-test-sandbox",
runtimeId: "codex-test-runtime",
runtimeLabel: "Codex Test Sandbox",
workdir: "/workspace",
buildExecSpec: async () => ({
argv: ["true"],
env: {},
stdinMode: "pipe-closed" as const,
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
}));
try {
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("exec"),
createRuntimeDynamicTool("process"),
createRuntimeDynamicTool("message"),
]);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.config = {
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "codex-test-sandbox",
scope: "session",
},
},
},
} as never;
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
const run = runCodexAppServerAttempt(params, {
pluginConfig: { appServer: { mode: "yolo" } },
});
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const startRequest = requests.find((request) => request.method === "thread/start");
const startParams = startRequest?.params as Record<string, unknown> | undefined;
const startConfig = startParams?.config as Record<string, unknown> | undefined;
const dynamicTools = startParams?.dynamicTools as Array<{ name: string }> | undefined;
expect(startConfig?.["features.code_mode"]).toBe(false);
expect(startConfig?.["features.code_mode_only"]).toBe(false);
expect(startParams?.environments).toEqual([]);
expect(dynamicTools?.map((tool) => tool.name)).toEqual([
"message",
"sandbox_exec",
"sandbox_process",
]);
} finally {
restoreSandboxBackend();
}
});
it("routes native Codex execution through an OpenClaw sandbox exec-server when opted in", async () => {
const restoreSandboxBackend = registerSandboxBackend("codex-test-sandbox", async () => ({
id: "codex-test-sandbox",
runtimeId: "codex-test-runtime",
runtimeLabel: "Codex Test Sandbox",
workdir: "/workspace",
buildExecSpec: async () => ({
argv: ["true"],
env: {},
stdinMode: "pipe-closed" as const,
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
}));
try {
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("exec"),
createRuntimeDynamicTool("process"),
createRuntimeDynamicTool("message"),
]);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.config = {
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "codex-test-sandbox",
scope: "session",
},
},
},
} as never;
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
const run = runCodexAppServerAttempt(params, {
pluginConfig: {
appServer: {
mode: "yolo",
experimental: { sandboxExecServer: true },
},
},
});
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const environmentAdd = requests.find((request) => request.method === "environment/add");
const environmentAddParams = environmentAdd?.params as
| { environmentId?: string; execServerUrl?: string }
| undefined;
const startRequest = requests.find((request) => request.method === "thread/start");
const startParams = startRequest?.params as
| {
cwd?: string;
dynamicTools?: Array<{ name: string }>;
environments?: Array<{ environmentId?: string; cwd?: string }>;
sandbox?: string;
config?: {
"features.code_mode"?: boolean;
"features.code_mode_only"?: boolean;
};
}
| undefined;
const turnRequest = requests.find((request) => request.method === "turn/start");
const turnParams = turnRequest?.params as
| {
cwd?: string;
environments?: Array<{ environmentId?: string; cwd?: string }>;
sandboxPolicy?: { type?: string; networkAccess?: string };
}
| undefined;
expect(environmentAddParams?.environmentId).toMatch(/^openclaw-sandbox-/);
expect(environmentAddParams?.execServerUrl).toMatch(/^ws:\/\/127\.0\.0\.1:/);
expect(startParams?.cwd).toBe("/workspace");
expect(startParams?.config?.["features.code_mode"]).toBe(true);
expect(startParams?.config?.["features.code_mode_only"]).toBe(false);
expect(startParams?.dynamicTools?.map((tool) => tool.name)).toEqual(["message"]);
expect(startParams?.environments).toEqual([
{ environmentId: environmentAddParams?.environmentId, cwd: "/workspace" },
]);
expect(startParams?.sandbox).toBe("danger-full-access");
expect(turnParams?.sandboxPolicy).toEqual({
type: "externalSandbox",
networkAccess: "enabled",
});
expect(turnParams?.cwd).toBe("/workspace");
expect(turnParams?.environments).toEqual(startParams?.environments);
} finally {
restoreSandboxBackend();
}
});
it("releases the sandbox exec-server when turn/start fails", async () => {
const restoreSandboxBackend = registerSandboxBackend("codex-test-sandbox", async () => ({
id: "codex-test-sandbox",
runtimeId: "codex-test-runtime",
runtimeLabel: "Codex Test Sandbox",
workdir: "/workspace",
buildExecSpec: async () => ({
argv: ["true"],
env: {},
stdinMode: "pipe-closed" as const,
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
}));
try {
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("exec"),
createRuntimeDynamicTool("process"),
createRuntimeDynamicTool("message"),
]);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.config = {
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "codex-test-sandbox",
scope: "session",
},
},
},
} as never;
const { requests } = createStartedThreadHarness(async (method) => {
if (method === "turn/start") {
throw new Error("turn start failed");
}
return undefined;
});
await expect(
runCodexAppServerAttempt(params, {
pluginConfig: {
appServer: {
mode: "yolo",
experimental: { sandboxExecServer: true },
},
},
}),
).rejects.toThrow("turn start failed");
const environmentAdd = requests.find((request) => request.method === "environment/add");
const environmentAddParams = environmentAdd?.params as { execServerUrl?: string } | undefined;
expect(environmentAddParams?.execServerUrl).toMatch(/^ws:\/\/127\.0\.0\.1:/);
let leakedSocket: WebSocket | undefined;
try {
leakedSocket = await openSocket(environmentAddParams!.execServerUrl!);
} catch {
leakedSocket = undefined;
} finally {
leakedSocket?.close();
}
expect(leakedSocket).toBeUndefined();
} finally {
restoreSandboxBackend();
}
});
it("releases the sandbox exec-server when context-engine retry setup fails", async () => {
const restoreSandboxBackend = registerSandboxBackend("codex-test-sandbox", async () => ({
id: "codex-test-sandbox",
runtimeId: "codex-test-runtime",
runtimeLabel: "Codex Test Sandbox",
workdir: "/workspace",
buildExecSpec: async () => ({
argv: ["true"],
env: {},
stdinMode: "pipe-closed" as const,
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
}));
try {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.contextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: false },
assemble: vi.fn(async ({ messages, prompt }) => ({
messages: [...messages, userMessage(prompt ?? "", 10)],
estimatedTokens: 42,
})),
} as never;
await writeExistingBinding(sessionFile, workspaceDir, {
contextEngine: buildContextEngineBinding(params),
});
params.config = {
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "codex-test-sandbox",
scope: "session",
},
},
},
} as never;
const { requests } = createStartedThreadHarness(async (method) => {
if (method === "thread/resume") {
return threadStartResult("thread-existing");
}
if (method === "thread/start") {
throw new Error("retry setup failed");
}
if (method === "turn/start") {
throw new Error("context window exceeded");
}
return undefined;
});
await expect(
runCodexAppServerAttempt(params, {
pluginConfig: {
appServer: {
mode: "yolo",
experimental: { sandboxExecServer: true },
},
},
}),
).rejects.toThrow("retry setup failed");
const environmentAdd = requests.find((request) => request.method === "environment/add");
const environmentAddParams = environmentAdd?.params as { execServerUrl?: string } | undefined;
expect(environmentAddParams?.execServerUrl).toMatch(/^ws:\/\/127\.0\.0\.1:/);
let leakedSocket: WebSocket | undefined;
try {
leakedSocket = await openSocket(environmentAddParams!.execServerUrl!);
} catch {
leakedSocket = undefined;
} finally {
leakedSocket?.close();
}
expect(leakedSocket).toBeUndefined();
} finally {
restoreSandboxBackend();
}
});
it("releases the sandbox exec-server when startup times out after environment registration", async () => {
const restoreSandboxBackend = registerSandboxBackend("codex-test-sandbox", async () => ({
id: "codex-test-sandbox",
runtimeId: "codex-test-runtime",
runtimeLabel: "Codex Test Sandbox",
workdir: "/workspace",
buildExecSpec: async () => ({
argv: ["true"],
env: {},
stdinMode: "pipe-closed" as const,
}),
runShellCommand: async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}),
}));
try {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.timeoutMs = 5;
params.runtimePlan = createCodexRuntimePlanFixture();
params.config = {
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "codex-test-sandbox",
scope: "session",
},
},
},
} as never;
const { requests } = createStartedThreadHarness(async (method, _params, options) => {
if (method === "thread/start") {
await new Promise<never>((_resolve, reject) => {
const signal = options?.signal;
if (signal?.aborted) {
reject(new Error("thread start aborted"));
return;
}
signal?.addEventListener("abort", () => reject(new Error("thread start aborted")), {
once: true,
});
});
}
return undefined;
});
await expect(
runCodexAppServerAttempt(params, {
pluginConfig: {
appServer: {
mode: "yolo",
experimental: { sandboxExecServer: true },
},
},
startupTimeoutFloorMs: 5,
}),
).rejects.toThrow("codex app-server startup timed out");
const environmentAdd = requests.find((request) => request.method === "environment/add");
const environmentAddParams = environmentAdd?.params as { execServerUrl?: string } | undefined;
expect(environmentAddParams?.execServerUrl).toMatch(/^ws:\/\/127\.0\.0\.1:/);
await vi.waitFor(async () => {
let leakedSocket: WebSocket | undefined;
try {
leakedSocket = await openSocket(environmentAddParams!.execServerUrl!);
} catch {
leakedSocket = undefined;
} finally {
leakedSocket?.close();
}
expect(leakedSocket).toBeUndefined();
});
} finally {
restoreSandboxBackend();
}
});
it("does not expose sandbox shell tools when sandbox routing is disabled", async () => {
testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("exec"),
@@ -946,6 +1400,7 @@ describe("runCodexAppServerAttempt", () => {
const processTool = createRuntimeDynamicTool("process");
const tools = testing.addSandboxShellDynamicToolsIfAvailable([], [execTool, processTool], {
sandbox: { enabled: true, backendId: "ssh" },
nativeToolSurfaceEnabled: false,
pluginConfig: {},
} as never);
@@ -1347,11 +1802,19 @@ describe("runCodexAppServerAttempt", () => {
expect(testing.shouldEnableCodexAppServerNativeToolSurface(params)).toBe(false);
});
it("disables Codex native tool surfaces when Docker bind targets need container paths", () => {
it("disables Codex native tool surfaces whenever an OpenClaw sandbox is active", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
expect(
testing.shouldEnableCodexAppServerNativeToolSurface(params, {
enabled: true,
backendId: "docker",
docker: { binds: [] },
} as never),
).toBe(false);
expect(
testing.shouldEnableCodexAppServerNativeToolSurface(params, {
enabled: true,
@@ -1366,7 +1829,7 @@ describe("runCodexAppServerAttempt", () => {
backendId: "docker",
docker: { binds: ["/tmp/openclaw-data:/tmp/openclaw-data:rw"] },
} as never),
).toBe(true);
).toBe(false);
expect(
testing.shouldEnableCodexAppServerNativeToolSurface(params, {
@@ -1380,6 +1843,89 @@ describe("runCodexAppServerAttempt", () => {
},
} as never),
).toBe(false);
expect(
testing.shouldEnableCodexAppServerNativeToolSurface(params, {
enabled: true,
backendId: "ssh",
} as never),
).toBe(false);
});
it("keeps sandbox exec-server native surfaces behind sandbox tool policy", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
const sandbox = {
enabled: true,
backendId: "docker",
backend: {},
tools: {
allow: ["exec", "process", "read", "write", "edit", "apply_patch"],
deny: [],
},
};
expect(
testing.shouldEnableCodexAppServerNativeToolSurface(params, sandbox as never, {
sandboxExecServerEnabled: true,
}),
).toBe(true);
expect(
testing.shouldEnableCodexAppServerNativeToolSurface(
params,
{
...sandbox,
tools: { allow: ["exec"], deny: [] },
} as never,
{ sandboxExecServerEnabled: true },
),
).toBe(false);
expect(
testing.shouldEnableCodexAppServerNativeToolSurface(
params,
{
...sandbox,
tools: { allow: [], deny: ["write"] },
} as never,
{ sandboxExecServerEnabled: true },
),
).toBe(false);
params.toolsAllow = ["message"];
expect(
testing.shouldEnableCodexAppServerNativeToolSurface(params, sandbox as never, {
sandboxExecServerEnabled: true,
}),
).toBe(false);
});
it("projects mirrored history for transient native-disabled Codex threads", () => {
expect(
testing.shouldProjectMirroredHistoryForCodexStart({
startupBinding: {
threadId: "thread-existing",
dynamicToolsFingerprint: "same-tools",
} as never,
dynamicToolsFingerprint: "same-tools",
historyMessages: [userMessage("earlier request", Date.now())],
forceProject: true,
}),
).toBe(true);
expect(
testing.shouldProjectMirroredHistoryForCodexStart({
startupBinding: {
threadId: "thread-existing",
dynamicToolsFingerprint: "same-tools",
} as never,
dynamicToolsFingerprint: "same-tools",
historyMessages: [assistantMessage("earlier response", Date.now())],
forceProject: true,
}),
).toBe(false);
});
it("forces the message dynamic tool for message-tool-only source replies", () => {
@@ -9532,109 +10078,6 @@ describe("runCodexAppServerAttempt", () => {
expect(turnRequestParams?.model).toBe("gpt-5.4-codex");
});
it("maps active OpenClaw sandbox egress into Codex workspace-write turns", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
pluginConfig: {
appServer: {
approvalPolicy: "never",
sandbox: "danger-full-access",
},
},
});
expect(
testing.resolveCodexAppServerSandboxPolicyForOpenClawSandbox(
appServer,
{
enabled: true,
backendId: "docker",
docker: { network: "none" },
} as never,
"/tmp/workspace",
),
).toEqual({
type: "workspaceWrite",
writableRoots: ["/tmp/workspace"],
networkAccess: false,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
});
expect(
testing.resolveCodexAppServerSandboxPolicyForOpenClawSandbox(
{ ...appServer, sandbox: "workspace-write" },
{
enabled: true,
backendId: "docker",
docker: { network: "bridge" },
} as never,
"/tmp/workspace",
),
).toEqual({
type: "workspaceWrite",
writableRoots: ["/tmp/workspace"],
networkAccess: true,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
});
expect(
testing.resolveCodexAppServerSandboxPolicyForOpenClawSandbox(
appServer,
{
enabled: true,
backendId: "docker",
docker: {
network: "bridge",
binds: [
"/tmp/openclaw-writable-data:/data:rw",
"/tmp/openclaw-readonly-data:/readonly:ro",
],
},
} as never,
"/tmp/workspace",
),
).toEqual({
type: "workspaceWrite",
writableRoots: ["/tmp/workspace", path.resolve("/tmp/openclaw-writable-data")],
networkAccess: true,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
});
expect(
testing.resolveCodexAppServerSandboxPolicyForOpenClawSandbox(
appServer,
{
enabled: true,
backendId: "ssh",
} as never,
"/tmp/workspace",
),
).toEqual({
type: "workspaceWrite",
writableRoots: ["/tmp/workspace"],
networkAccess: true,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
});
expect(
testing.resolveCodexAppServerSandboxPolicyForOpenClawSandbox(
appServer,
null,
"/tmp/workspace",
),
).toBeUndefined();
expect(
testing.resolveCodexAppServerSandboxPolicyForOpenClawSandbox(
{ ...appServer, sandbox: "read-only" },
{ enabled: true } as never,
"/tmp/workspace",
),
).toBeUndefined();
});
it("passes current Codex service tier request values through app-server resume and turn requests", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -36,9 +36,6 @@ import {
resolveBootstrapContextForRun,
setActiveEmbeddedRun,
supportsModelTools,
hasSandboxBindContainerPathAliases,
hasSandboxBindReadonlyHostShadows,
resolveWritableSandboxBindHostRoots,
runAgentCleanupStep,
type AgentMessage,
type EmbeddedRunAttemptParams,
@@ -55,6 +52,7 @@ import {
onInternalDiagnosticEvent,
type DiagnosticEventPayload,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
@@ -79,6 +77,7 @@ import {
import { ensureCodexComputerUse } from "./computer-use.js";
import {
isCodexAppServerApprovalPolicyAllowedByRequirements,
isCodexSandboxExecServerEnabled,
readCodexPluginConfig,
resolveCodexComputerUseConfig,
resolveCodexPluginsPolicy,
@@ -130,9 +129,10 @@ import {
readCodexDynamicToolCallParams,
} from "./protocol-validators.js";
import {
type CodexSandboxPolicy,
type CodexTurnEnvironmentParams,
type CodexUserInput,
isJsonObject,
type CodexSandboxPolicy,
type CodexServerNotification,
type CodexDynamicToolSpec,
type CodexDynamicToolCallParams,
@@ -148,6 +148,11 @@ import {
resolveCodexUsageLimitResetAtMs,
shouldRefreshCodexRateLimitsForUsageLimitMessage,
} from "./rate-limits.js";
import {
ensureCodexSandboxExecServerEnvironment,
releaseCodexSandboxExecServerEnvironment,
type CodexSandboxExecEnvironment,
} from "./sandbox-exec-server.js";
import {
clearCodexAppServerBinding,
readCodexAppServerBinding,
@@ -201,6 +206,14 @@ const CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS = 5 * 60_000;
const CODEX_NATIVE_HOOK_RELAY_RENEW_INTERVAL_MS = 60_000;
const CODEX_STEER_ALL_DEBOUNCE_MS = 500;
const LOG_FIELD_MAX_LENGTH = 160;
const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [
"exec",
"process",
"read",
"write",
"edit",
"apply_patch",
] as const;
const CODEX_NATIVE_PROJECT_DOC_BASENAMES = new Set(["agents.md"]);
const CODEX_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set([
"identity.md",
@@ -473,39 +486,6 @@ function toCodexTextInput(text: string): CodexUserInput {
type OpenClawSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
function resolveCodexAppServerSandboxPolicyForOpenClawSandbox(
appServer: CodexAppServerRuntimeOptions,
sandbox: OpenClawSandboxContext,
cwd: string,
): CodexSandboxPolicy | undefined {
if (!sandbox?.enabled || appServer.sandbox === "read-only") {
return undefined;
}
const networkAccess = codexNetworkAccessForOpenClawSandbox(sandbox);
const writableRoots = new Set([cwd]);
if (sandbox.backendId === "docker") {
for (const root of resolveWritableSandboxBindHostRoots(sandbox.docker.binds)) {
writableRoots.add(root);
}
}
// Codex app-server still runs on the Gateway host, so keep Codex's
// filesystem sandbox while mirroring the OpenClaw sandbox egress policy.
return {
type: "workspaceWrite",
writableRoots: [...writableRoots],
networkAccess,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
};
}
function codexNetworkAccessForOpenClawSandbox(sandbox: OpenClawSandboxContext): boolean {
if (!sandbox?.enabled || sandbox.backendId !== "docker") {
return true;
}
return sandbox.docker.network.trim().toLowerCase() !== "none";
}
function resolveCodexAppServerForOpenClawToolPolicy(params: {
appServer: CodexAppServerRuntimeOptions;
pluginConfig: CodexPluginConfig;
@@ -828,11 +808,6 @@ export async function runCodexAppServerAttempt(
: sandbox.workspaceDir
: resolvedWorkspace;
await fs.mkdir(effectiveWorkspace, { recursive: true });
const codexSandboxPolicy = resolveCodexAppServerSandboxPolicyForOpenClawSandbox(
configuredAppServer,
sandbox,
effectiveWorkspace,
);
const appServer = resolveCodexAppServerForOpenClawToolPolicy({
appServer: configuredAppServer,
pluginConfig,
@@ -929,7 +904,10 @@ export async function runCodexAppServerAttempt(
disableTools: params.disableTools,
toolsAllow: params.toolsAllow,
});
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params, sandbox);
const sandboxExecServerEnabled = isCodexSandboxExecServerEnabled(pluginConfig);
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params, sandbox, {
sandboxExecServerEnabled,
});
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
}
@@ -1120,7 +1098,9 @@ export async function runCodexAppServerAttempt(
};
if (activeContextEngine) {
try {
await applyActiveContextEngineProjection(startupBinding);
await applyActiveContextEngineProjection(
!nativeToolSurfaceEnabled ? undefined : startupBinding,
);
} catch (assembleErr) {
embeddedAgentLog.warn("context engine assemble failed; using Codex baseline prompt", {
error: formatErrorMessage(assembleErr),
@@ -1131,6 +1111,7 @@ export async function runCodexAppServerAttempt(
startupBinding,
dynamicToolsFingerprint: codexDynamicToolsFingerprint(toolBridge.specs),
historyMessages,
forceProject: !nativeToolSurfaceEnabled,
})
) {
const projection = projectContextEngineAssemblyForCodex({
@@ -1176,6 +1157,16 @@ export async function runCodexAppServerAttempt(
let trajectoryEndRecorded = false;
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
let startupClientForCleanup: CodexAppServerClient | undefined;
let sandboxExecEnvironmentAcquired = false;
const releaseSandboxExecEnvironment = async () => {
if (sandboxExecEnvironmentAcquired) {
sandboxExecEnvironmentAcquired = false;
await releaseCodexSandboxExecServerEnvironment(sandbox);
}
};
let codexEnvironmentSelection: CodexTurnEnvironmentParams[] | undefined;
let codexExecutionCwd = effectiveWorkspace;
let codexSandboxPolicy: CodexSandboxPolicy | undefined;
let restartContextEngineCodexThread:
| (() => Promise<CodexAppServerThreadLifecycleBinding>)
| undefined;
@@ -1267,9 +1258,14 @@ export async function runCodexAppServerAttempt(
approvalPolicy: withMcpElicitationsApprovalPolicy(appServer.approvalPolicy),
}
: appServer;
({ client, thread } = await withCodexStartupTimeout({
let releaseStartupResourcesOnTimeout: (() => Promise<void>) | undefined;
const startupResult = await withCodexStartupTimeout({
timeoutMs: startupTimeoutMs,
signal: runAbortController.signal,
onTimeout: async () => {
runAbortController.abort("codex_startup_timeout");
await releaseStartupResourcesOnTimeout?.();
},
operation: async () => {
let attemptedClient: CodexAppServerClient | undefined;
const startupAttempt = async () => {
@@ -1287,12 +1283,66 @@ export async function runCodexAppServerAttempt(
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
});
let startupSandboxEnvironment: CodexSandboxExecEnvironment | undefined;
let startupSandboxEnvironmentAcquired = false;
const releaseStartupSandboxEnvironment = async () => {
if (startupSandboxEnvironmentAcquired) {
startupSandboxEnvironmentAcquired = false;
await releaseCodexSandboxExecServerEnvironment(sandbox);
}
};
releaseStartupResourcesOnTimeout = releaseStartupSandboxEnvironment;
try {
startupSandboxEnvironment = shouldRequireCodexSandboxExecServerEnvironment({
sandbox,
nativeToolSurfaceEnabled,
sandboxExecServerEnabled,
})
? await ensureCodexSandboxExecServerEnvironment({
client: startupClient,
sandbox: sandbox ?? null,
appServerStartOptions: appServer.start,
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
})
: undefined;
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
if (runAbortController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
if (
sandbox?.enabled &&
nativeToolSurfaceEnabled &&
sandboxExecServerEnabled &&
!startupSandboxEnvironment
) {
throw new Error(
"Codex app-server did not register an OpenClaw sandbox exec-server environment.",
);
}
} catch (error) {
await releaseStartupSandboxEnvironment();
throw error;
}
const startupEnvironmentSelection = resolveCodexSandboxEnvironmentSelection(
startupSandboxEnvironment,
nativeToolSurfaceEnabled,
);
const startupExecutionCwd = resolveCodexAppServerExecutionCwd({
effectiveWorkspace,
environment: startupSandboxEnvironment,
nativeToolSurfaceEnabled,
});
const startupSandboxPolicy = startupSandboxEnvironment
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(sandbox)
: undefined;
const buildThreadLifecycleParams = () =>
({
client: startupClient,
params: buildActiveRunAttemptParams(),
agentId: sessionAgentId,
cwd: effectiveWorkspace,
cwd: startupExecutionCwd,
dynamicTools: toolBridge.specs,
appServer: pluginAppServer,
developerInstructions: promptBuild.developerInstructions,
@@ -1303,6 +1353,7 @@ export async function runCodexAppServerAttempt(
userMcpServersEnabled: nativeToolSurfaceEnabled,
mcpServersFingerprint: bundleMcpThreadConfig.fingerprint,
mcpServersFingerprintEvaluated: bundleMcpThreadConfig.evaluated,
environmentSelection: startupEnvironmentSelection,
contextEngineProjection,
pluginThreadConfig: pluginThreadConfigRequired
? {
@@ -1323,9 +1374,31 @@ export async function runCodexAppServerAttempt(
}
: undefined,
}) satisfies Parameters<typeof startOrResumeThread>[0];
restartContextEngineCodexThread = () => startOrResumeThread(buildThreadLifecycleParams());
const startupThread = await startOrResumeThread(buildThreadLifecycleParams());
return { client: startupClient, thread: startupThread };
try {
restartContextEngineCodexThread = () =>
startOrResumeThread(buildThreadLifecycleParams());
const startupThread = await startOrResumeThread(buildThreadLifecycleParams());
if (runAbortController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
startupSandboxEnvironmentAcquired = false;
return {
client: startupClient,
thread: startupThread,
sandboxEnvironment: startupSandboxEnvironment,
environmentSelection: startupEnvironmentSelection,
executionCwd: startupExecutionCwd,
sandboxPolicy: startupSandboxPolicy,
};
} catch (error) {
await releaseStartupSandboxEnvironment();
throw error;
} finally {
if (releaseStartupResourcesOnTimeout === releaseStartupSandboxEnvironment) {
releaseStartupResourcesOnTimeout = undefined;
}
}
};
for (
let attempt = 1;
@@ -1373,7 +1446,13 @@ export async function runCodexAppServerAttempt(
}
throw new Error("codex app-server startup retry loop exited unexpectedly");
},
}));
});
client = startupResult.client;
thread = startupResult.thread;
sandboxExecEnvironmentAcquired = Boolean(startupResult.sandboxEnvironment);
codexEnvironmentSelection = startupResult.environmentSelection;
codexExecutionCwd = startupResult.executionCwd;
codexSandboxPolicy = startupResult.sandboxPolicy;
startupClientForCleanup = undefined;
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
@@ -1381,6 +1460,7 @@ export async function runCodexAppServerAttempt(
});
} catch (error) {
nativeHookRelay?.unregister();
await releaseSandboxExecEnvironment();
clearSharedCodexAppServerClientIfCurrent(startupClientForCleanup);
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
throw error;
@@ -2386,10 +2466,11 @@ export async function runCodexAppServerAttempt(
"turn/start",
buildTurnStartParams(params, {
threadId: thread.threadId,
cwd: effectiveWorkspace,
cwd: codexExecutionCwd,
appServer: pluginAppServer,
promptText: codexTurnPromptText,
sandboxPolicy: codexSandboxPolicy,
environmentSelection: codexEnvironmentSelection,
heartbeatCollaborationInstructions:
workspaceBootstrapContext.heartbeatCollaborationInstructions,
}),
@@ -2423,24 +2504,29 @@ export async function runCodexAppServerAttempt(
error: formatErrorMessage(turnStartError),
},
);
const preRetrySessionFile = activeSessionFile;
const compactedForRetry = await forceContextEngineCompactionForCodexOverflow(turnStartError);
await clearCodexAppServerBinding(preRetrySessionFile);
if (activeSessionFile !== preRetrySessionFile) {
await clearCodexAppServerBinding(activeSessionFile);
}
if (compactedForRetry) {
await rebuildPromptAfterContextEngineCompaction();
}
thread = await restartContextEngineCodexThread();
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "thread_ready_retry", threadId: thread.threadId },
});
try {
turn = await startCodexTurn();
} catch (retryError) {
turnStartError = retryError;
const preRetrySessionFile = activeSessionFile;
const compactedForRetry =
await forceContextEngineCompactionForCodexOverflow(turnStartError);
await clearCodexAppServerBinding(preRetrySessionFile);
if (activeSessionFile !== preRetrySessionFile) {
await clearCodexAppServerBinding(activeSessionFile);
}
if (compactedForRetry) {
await rebuildPromptAfterContextEngineCompaction();
}
thread = await restartContextEngineCodexThread();
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "thread_ready_retry", threadId: thread.threadId },
});
try {
turn = await startCodexTurn();
} catch (retryError) {
turnStartError = retryError;
}
} catch (retrySetupError) {
turnStartError = retrySetupError;
}
}
if (turn === undefined) {
@@ -2499,6 +2585,7 @@ export async function runCodexAppServerAttempt(
notificationCleanup();
requestCleanup();
nativeHookRelay?.unregister();
await releaseSandboxExecEnvironment();
await runAgentCleanupStep({
runId: params.runId,
sessionId: params.sessionId,
@@ -2849,6 +2936,7 @@ export async function runCodexAppServerAttempt(
requestCleanup();
closeCleanup?.();
nativeHookRelay?.unregister();
await releaseSandboxExecEnvironment();
runAbortController.signal.removeEventListener("abort", abortListener);
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
steeringQueue?.cancel();
@@ -3493,32 +3581,96 @@ function includeForcedMessageToolAllow(
function shouldEnableCodexAppServerNativeToolSurface(
params: EmbeddedRunAttemptParams,
sandbox?: OpenClawSandboxContext,
options: { sandboxExecServerEnabled?: boolean } = {},
): boolean {
const toolsAllow = includeForcedMessageToolAllow(params.toolsAllow, params);
if (toolsAllow === undefined) {
return canCodexAppServerNativeToolSurfaceHonorSandbox(sandbox);
return canCodexAppServerNativeToolSurfaceHonorSandbox(sandbox, options);
}
// Codex native code mode exposes its shell/file surface as one app-server
// capability, so narrow OpenClaw allowlists must fail closed rather than
// widening `message` or `web_search` into shell access.
return (
hasWildcardCodexToolsAllow(toolsAllow) &&
canCodexAppServerNativeToolSurfaceHonorSandbox(sandbox)
canCodexAppServerNativeToolSurfaceHonorSandbox(sandbox, options)
);
}
function canCodexAppServerNativeToolSurfaceHonorSandbox(
sandbox: OpenClawSandboxContext | undefined,
options: { sandboxExecServerEnabled?: boolean } = {},
): boolean {
if (!sandbox?.enabled || sandbox.backendId !== "docker") {
if (!sandbox?.enabled) {
return true;
}
return (
!hasSandboxBindContainerPathAliases(sandbox.docker.binds) &&
!hasSandboxBindReadonlyHostShadows(sandbox.docker.binds)
if (
options.sandboxExecServerEnabled === true &&
sandbox.backend &&
canSandboxToolPolicyExposeCodexNativeToolSurface(sandbox)
) {
return true;
}
// Codex app-server native shell, filesystem, and user MCP execution are owned
// by the app-server process. Without the explicit exec-server integration,
// active OpenClaw sandboxing must disable the native surface and route shell
// access through sandbox-backed dynamic tools instead.
return false;
}
function canSandboxToolPolicyExposeCodexNativeToolSurface(sandbox: {
tools: Parameters<typeof isToolAllowed>[0];
}): boolean {
return CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS.every((toolName) =>
isToolAllowed(sandbox.tools, toolName),
);
}
function shouldRequireCodexSandboxExecServerEnvironment(params: {
sandbox?: OpenClawSandboxContext;
nativeToolSurfaceEnabled: boolean;
sandboxExecServerEnabled: boolean;
}): boolean {
return Boolean(
params.sandbox?.enabled && params.nativeToolSurfaceEnabled && params.sandboxExecServerEnabled,
);
}
function resolveCodexSandboxEnvironmentSelection(
environment: CodexSandboxExecEnvironment | undefined,
nativeToolSurfaceEnabled: boolean,
): CodexTurnEnvironmentParams[] | undefined {
return environment && nativeToolSurfaceEnabled ? [environment] : undefined;
}
function resolveCodexAppServerExecutionCwd(params: {
effectiveWorkspace: string;
environment?: CodexSandboxExecEnvironment;
nativeToolSurfaceEnabled: boolean;
}): string {
return params.environment && params.nativeToolSurfaceEnabled
? params.environment.cwd
: params.effectiveWorkspace;
}
function resolveCodexExternalSandboxPolicyForOpenClawSandbox(
sandbox: OpenClawSandboxContext | undefined,
): CodexSandboxPolicy {
return {
type: "externalSandbox",
networkAccess: codexNetworkAccessForOpenClawSandbox(sandbox) ? "enabled" : "restricted",
};
}
function codexNetworkAccessForOpenClawSandbox(
sandbox: OpenClawSandboxContext | undefined,
): boolean {
if (sandbox?.backendId !== "docker") {
return true;
}
const network = sandbox?.docker?.network?.trim().toLowerCase();
return Boolean(network && network !== "none");
}
function disableCodexPluginThreadConfig(pluginConfig?: unknown): CodexPluginConfig {
const config = readCodexPluginConfig(pluginConfig);
return {
@@ -3552,7 +3704,7 @@ function addSandboxShellDynamicToolsIfAvailable(
...execTool,
name: "sandbox_exec",
description:
"Run a shell command through OpenClaw's configured sandbox backend for this session. Use only when the command must execute in the OpenClaw sandbox backend, such as an SSH-backed sandbox or Docker container-path bind layout that Codex's native shell cannot represent. Use Codex's native shell for normal local workspace commands.",
"Run a shell command through OpenClaw's configured sandbox backend for this session. Use when OpenClaw sandboxing is active or when a command must execute in the sandbox backend, such as an SSH-backed sandbox or Docker container-path bind layout. Use Codex's native shell only when no OpenClaw sandbox is active and native Code Mode is available.",
execute: async (toolCallId, args, signal, onUpdate) => {
const result = await execTool.execute(toolCallId, args, signal, onUpdate);
return {
@@ -3574,14 +3726,14 @@ function addSandboxShellDynamicToolsIfAvailable(
...processTool,
name: "sandbox_process",
description:
"Manage sandbox_exec sessions that were started through OpenClaw's configured sandbox backend for this session: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use only for sandbox_exec follow-up; use Codex's native shell session handling for normal native shell commands.",
"Manage sandbox_exec sessions that were started through OpenClaw's configured sandbox backend for this session: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use only for sandbox_exec follow-up; use Codex's native shell session handling only when no OpenClaw sandbox is active and native Code Mode is available.",
};
return [...filteredTools, sandboxExecTool, sandboxProcessTool];
}
function shouldExposeSandboxExecDynamicTool(input: DynamicToolBuildParams): boolean {
const backendId = input.sandbox?.enabled ? input.sandbox.backendId.trim().toLowerCase() : "";
return Boolean(backendId && (backendId !== "docker" || input.nativeToolSurfaceEnabled === false));
return Boolean(backendId && input.nativeToolSurfaceEnabled === false);
}
function isSandboxShellDynamicToolExcluded(config: CodexPluginConfig): boolean {
@@ -3636,10 +3788,14 @@ function shouldProjectMirroredHistoryForCodexStart(params: {
startupBinding: CodexAppServerThreadBinding | undefined;
dynamicToolsFingerprint: string;
historyMessages: AgentMessage[];
forceProject?: boolean;
}): boolean {
if (!params.historyMessages.some((message) => message.role === "user")) {
return false;
}
if (params.forceProject) {
return true;
}
if (!params.startupBinding?.threadId) {
return true;
}
@@ -3711,6 +3867,7 @@ function resolveContextEngineBootstrapProjectionDecision(params: {
async function withCodexStartupTimeout<T>(params: {
timeoutMs: number;
signal: AbortSignal;
onTimeout?: () => void | Promise<void>;
operation: () => Promise<T>;
}): Promise<T> {
if (params.signal.aborted) {
@@ -3718,6 +3875,8 @@ async function withCodexStartupTimeout<T>(params: {
}
let timeout: NodeJS.Timeout | undefined;
let abortCleanup: (() => void) | undefined;
let timeoutError: Error | undefined;
let timeoutCleanup: Promise<void> | undefined;
try {
return await Promise.race([
params.operation(),
@@ -3730,13 +3889,26 @@ async function withCodexStartupTimeout<T>(params: {
reject(error);
};
timeout = setTimeout(() => {
rejectOnce(new Error("codex app-server startup timed out"));
timeoutError = new Error("codex app-server startup timed out");
timeoutCleanup = Promise.resolve(params.onTimeout?.()).then(
() => undefined,
() => undefined,
);
void timeoutCleanup.finally(() => {
rejectOnce(timeoutError!);
});
}, params.timeoutMs);
const abortListener = () => rejectOnce(new Error("codex app-server startup aborted"));
params.signal.addEventListener("abort", abortListener, { once: true });
abortCleanup = () => params.signal.removeEventListener("abort", abortListener);
}),
]);
} catch (error) {
if (timeoutError) {
await timeoutCleanup;
throw timeoutError;
}
throw error;
} finally {
if (timeout) {
clearTimeout(timeout);
@@ -4912,9 +5084,9 @@ export const testing = {
resolveDynamicToolCallTimeoutMs,
resolveCodexDynamicToolsLoading,
rotateOversizedCodexAppServerStartupBinding,
resolveCodexAppServerSandboxPolicyForOpenClawSandbox,
resolveCodexAppServerForOpenClawToolPolicy,
resolveOpenClawCodingToolsSessionKeys,
shouldProjectMirroredHistoryForCodexStart,
shouldEnableCodexAppServerNativeToolSurface,
shouldForceMessageTool,
buildCodexPluginThreadConfigEligibilityLogData,

View File

@@ -0,0 +1,527 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
closeCodexSandboxExecServersForTests,
ensureCodexSandboxExecServerEnvironment,
} from "./sandbox-exec-server.js";
import {
codexFsSandboxContext,
createClient,
createSandboxContext,
execServerUrlFromClient,
globPath,
openSocket,
rpc,
specialPath,
} from "./sandbox-exec-server.test-helpers.js";
afterEach(async () => {
vi.unstubAllEnvs();
await closeCodexSandboxExecServersForTests();
});
describe("OpenClaw Codex sandbox exec-server filesystem", () => {
it("routes file writes through the sandbox fs bridge", async () => {
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ writeFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "fs/writeFile", {
path: "/workspace/note.txt",
dataBase64: Buffer.from("hello").toString("base64"),
});
await rpc(socket, "fs/writeFile", {
path: "/workspace/empty.txt",
dataBase64: "",
});
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/note.txt",
data: Buffer.from("hello"),
mkdir: false,
});
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/empty.txt",
data: Buffer.alloc(0),
mkdir: false,
});
socket.close();
});
it("preserves missing-parent failures for file writes", async () => {
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
stat: async ({ filePath }) =>
filePath === "/workspace" ? { type: "directory", size: 1, mtimeMs: 1 } : null,
writeFile,
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/writeFile", {
path: "/workspace/missing/note.txt",
dataBase64: Buffer.from("hello").toString("base64"),
}),
).rejects.toThrow("parent directory not found");
expect(writeFile).not.toHaveBeenCalled();
socket.close();
});
it("enforces Codex fs sandbox policy before mutating through the fs bridge", async () => {
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ writeFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/writeFile", {
path: "/workspace/read-only.txt",
dataBase64: Buffer.from("blocked").toString("base64"),
sandbox: codexFsSandboxContext({
entries: [{ path: specialPath("root"), access: "read" }],
}),
}),
).rejects.toThrow("Codex fs sandbox denied write access");
await rpc(socket, "fs/writeFile", {
path: "/workspace/allowed.txt",
dataBase64: Buffer.from("allowed").toString("base64"),
sandbox: codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
],
}),
});
expect(writeFile).toHaveBeenCalledTimes(1);
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/allowed.txt",
data: Buffer.from("allowed"),
mkdir: false,
});
socket.close();
});
it("honors Codex fs sandbox protected metadata carveouts", async () => {
const remove = vi.fn(async () => undefined);
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ remove, writeFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const workspacePolicy = codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: specialPath("project_roots", ".git"), access: "read" },
],
});
await expect(
rpc(socket, "fs/writeFile", {
path: "/workspace/.git/config",
dataBase64: Buffer.from("blocked").toString("base64"),
sandbox: workspacePolicy,
}),
).rejects.toThrow("Codex fs sandbox denied write access");
await expect(
rpc(socket, "fs/remove", {
path: "/workspace",
recursive: true,
force: true,
sandbox: workspacePolicy,
}),
).rejects.toThrow("because /workspace/.git is not writable");
expect(writeFile).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
socket.close();
});
it("enforces Codex fs sandbox glob deny entries", async () => {
const remove = vi.fn(async () => undefined);
const readFile = vi.fn(async () => Buffer.from("ok"));
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ readFile, remove, writeFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const policy = codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: globPath("private/*.txt"), access: "deny" },
],
});
await expect(
rpc(socket, "fs/readFile", {
path: "/workspace/private/secret.txt",
sandbox: policy,
}),
).rejects.toThrow("Codex fs sandbox denied read access");
await expect(
rpc(socket, "fs/readFile", {
path: "/workspace/key.pem",
sandbox: codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: globPath("**/*.pem"), access: "deny" },
],
}),
}),
).rejects.toThrow("Codex fs sandbox denied read access");
await expect(
rpc(socket, "fs/readFile", {
path: "/workspace/KEY.PEM",
sandbox: codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: globPath("**/*.[Pp][Ee][Mm]"), access: "deny" },
],
}),
}),
).rejects.toThrow("Codex fs sandbox denied read access");
await rpc(socket, "fs/writeFile", {
path: "/workspace/private/nested/allowed.txt",
dataBase64: Buffer.from("ok").toString("base64"),
sandbox: policy,
});
await expect(
rpc(socket, "fs/remove", {
path: "/workspace/private",
recursive: true,
force: true,
sandbox: policy,
}),
).rejects.toThrow("because /workspace/private/*.txt is not writable");
expect(readFile).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
expect(writeFile).toHaveBeenCalledTimes(1);
socket.close();
});
it("ignores non-granting Codex fs sandbox special entries", async () => {
const writeFile = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ writeFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "fs/writeFile", {
path: "/workspace/allowed.txt",
dataBase64: Buffer.from("ok").toString("base64"),
sandbox: codexFsSandboxContext({
entries: [
{ path: specialPath("minimal"), access: "read" },
{ path: specialPath("unknown"), access: "read" },
{ path: specialPath("current_working_directory"), access: "write" },
],
}),
});
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/allowed.txt",
data: Buffer.from("ok"),
mkdir: false,
});
socket.close();
});
it("fails closed for unsupported Codex fs sandbox glob classes", async () => {
const readFile = vi.fn(async () => Buffer.from("ok"));
const sandbox = createSandboxContext({ readFile });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/readFile", {
path: "/workspace/key.pem",
sandbox: codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: globPath("**/*.[Pp"), access: "deny" },
],
}),
}),
).rejects.toThrow("fs sandbox glob character class must be closed");
expect(readFile).not.toHaveBeenCalled();
socket.close();
});
it("fails closed for recursive removes below protected glob prefixes", async () => {
const remove = vi.fn(async () => undefined);
const sandbox = createSandboxContext({ remove });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const policy = codexFsSandboxContext({
entries: [
{ path: specialPath("root"), access: "read" },
{ path: specialPath("project_roots"), access: "write" },
{ path: globPath("**/*.pem"), access: "deny" },
],
});
await expect(
rpc(socket, "fs/remove", {
path: "/workspace/src",
recursive: true,
force: true,
sandbox: policy,
}),
).rejects.toThrow("because /workspace/**/*.pem is not writable");
expect(remove).not.toHaveBeenCalled();
socket.close();
});
it("routes recursive copies through the sandbox filesystem bridge", async () => {
const mkdirp = vi.fn(async () => undefined);
const readFile = vi.fn(async ({ filePath }: { filePath: string }) =>
Buffer.from(`data:${filePath}`),
);
const writeFile = vi.fn(async () => undefined);
const runShellCommand = vi.fn(async (_params?: { args?: string[] }) => ({
stdout: Buffer.from("f\tfile.txt\nd\tsubdir\n"),
stderr: Buffer.alloc(0),
code: 0,
}));
runShellCommand.mockImplementation(async (params?: { args?: string[] }) => ({
stdout: Buffer.from(
params?.args?.[0] === "/workspace/source-dir/subdir"
? "f\tnested.txt\n"
: "f\tfile.txt\nd\tsubdir\n",
),
stderr: Buffer.alloc(0),
code: 0,
}));
const sandbox = createSandboxContext({
mkdirp,
readFile,
runShellCommand,
stat: async ({ filePath }) => ({
type: filePath.endsWith("source-dir") || filePath.endsWith("subdir") ? "directory" : "file",
size: 1,
mtimeMs: 1,
}),
writeFile,
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "fs/copy", {
sourcePath: "/workspace/source-dir",
destinationPath: "/workspace/destination-dir",
recursive: true,
});
expect(mkdirp).toHaveBeenCalledWith({ filePath: "/workspace/destination-dir" });
expect(mkdirp).toHaveBeenCalledWith({ filePath: "/workspace/destination-dir/subdir" });
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/destination-dir/file.txt",
data: Buffer.from("data:/workspace/source-dir/file.txt"),
mkdir: true,
});
expect(writeFile).toHaveBeenCalledWith({
filePath: "/workspace/destination-dir/subdir/nested.txt",
data: Buffer.from("data:/workspace/source-dir/subdir/nested.txt"),
mkdir: true,
});
expect(runShellCommand).toHaveBeenCalledWith(
expect.objectContaining({ args: ["/workspace/source-dir"] }),
);
expect(runShellCommand).toHaveBeenCalledWith(
expect.objectContaining({ args: ["/workspace/source-dir/subdir"] }),
);
socket.close();
});
it("rejects recursive directory copies into their own subtree", async () => {
const mkdirp = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
mkdirp,
stat: async () => ({
type: "directory",
size: 1,
mtimeMs: 1,
}),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/copy", {
sourcePath: "/workspace/source-dir",
destinationPath: "/workspace/source-dir/backup",
recursive: true,
}),
).rejects.toThrow("Cannot recursively copy a directory into itself");
expect(mkdirp).not.toHaveBeenCalled();
socket.close();
});
it("reports missing metadata as an exec-server not found error", async () => {
const sandbox = createSandboxContext({ stat: async () => null });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(rpc(socket, "fs/getMetadata", { path: "/workspace/missing" })).rejects.toThrow(
"file not found",
);
socket.close();
});
it("rejects oversized file reads before buffering through the fs bridge", async () => {
const readFile = vi.fn(async () => Buffer.from("too-large"));
const sandbox = createSandboxContext({
readFile,
stat: async () => ({
type: "file",
size: 512 * 1024 * 1024 + 1,
mtimeMs: 1,
}),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(rpc(socket, "fs/readFile", { path: "/workspace/huge.bin" })).rejects.toThrow(
"file is too large to read through Codex sandbox exec-server",
);
expect(readFile).not.toHaveBeenCalled();
socket.close();
});
it("does not create parent directories for non-recursive directory creation", async () => {
const mkdirp = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
mkdirp,
stat: async ({ filePath }) =>
filePath === "/workspace/existing" ? { type: "directory", size: 1, mtimeMs: 1 } : null,
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/createDirectory", {
path: "/workspace/missing/child",
recursive: false,
}),
).rejects.toThrow("parent directory not found");
expect(mkdirp).not.toHaveBeenCalled();
await rpc(socket, "fs/createDirectory", {
path: "/workspace/existing/child",
recursive: false,
});
expect(mkdirp).toHaveBeenCalledWith({ filePath: "/workspace/existing/child" });
socket.close();
});
it("surfaces sandbox bridge denials as exec-server errors", async () => {
const sandbox = createSandboxContext({
writeFile: async () => {
throw new Error("sandbox denied write outside workspace");
},
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "fs/writeFile", {
path: "/outside/note.txt",
dataBase64: Buffer.from("no").toString("base64"),
}),
).rejects.toThrow("sandbox denied write outside workspace");
socket.close();
});
});

View File

@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
closeCodexSandboxExecServersForTests,
ensureCodexSandboxExecServerEnvironment,
} from "./sandbox-exec-server.js";
import {
collectNotifications,
createClient,
createSandboxContext,
execServerUrlFromClient,
openSocket,
rpc,
shellQuote,
waitForHttpBodyDeltas,
} from "./sandbox-exec-server.test-helpers.js";
afterEach(async () => {
vi.unstubAllEnvs();
await closeCodexSandboxExecServersForTests();
});
describe("OpenClaw Codex sandbox exec-server HTTP", () => {
it("routes HTTP requests through the sandbox backend", async () => {
const runShellCommand = vi.fn(async () => ({
stdout: Buffer.from(
JSON.stringify({
status: 201,
headers: [{ name: "content-type", value: "text/plain" }],
bodyBase64: Buffer.from("sandbox-http").toString("base64"),
}),
),
stderr: Buffer.alloc(0),
code: 0,
}));
const sandbox = createSandboxContext({ runShellCommand });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "http/request", {
requestId: "http-1",
method: "POST",
url: "https://example.test/mcp",
headers: [{ name: "authorization", value: "Bearer test" }],
bodyBase64: Buffer.from("body").toString("base64"),
}),
).resolves.toEqual({
status: 201,
headers: [{ name: "content-type", value: "text/plain" }],
bodyBase64: Buffer.from("sandbox-http").toString("base64"),
});
expect(runShellCommand).toHaveBeenCalledWith(
expect.objectContaining({
allowFailure: true,
stdin: expect.stringContaining("https://example.test/mcp"),
}),
);
socket.close();
});
it("streams HTTP response body deltas from the sandbox backend", async () => {
const headerLine = JSON.stringify({
type: "headers",
status: 202,
headers: [{ name: "content-type", value: "text/event-stream" }],
});
const bodyLine = JSON.stringify({
type: "bodyDelta",
seq: 1,
deltaBase64: Buffer.from("event: ok\n\n").toString("base64"),
done: false,
});
const doneLine = JSON.stringify({
type: "bodyDelta",
seq: 2,
deltaBase64: "",
done: true,
});
const buildExecSpec = vi.fn(async () => ({
argv: [
"/bin/sh",
"-lc",
[headerLine, bodyLine, doneLine]
.map((line) => `printf '%s\\n' ${shellQuote(line)}`)
.join("; "),
],
env: process.env,
stdinMode: "pipe-closed" as const,
}));
const runShellCommand = vi.fn(async () => ({
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
code: 0,
}));
const sandbox = createSandboxContext({ buildExecSpec, runShellCommand });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
const notifications = collectNotifications(socket);
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "http/request", {
requestId: "http-stream",
method: "GET",
url: "https://example.test/sse",
streamResponse: true,
}),
).resolves.toEqual({
status: 202,
headers: [{ name: "content-type", value: "text/event-stream" }],
bodyBase64: "",
});
const deltas = await waitForHttpBodyDeltas(notifications, 2);
expect(buildExecSpec).toHaveBeenCalledWith(
expect.objectContaining({
command: expect.stringContaining("python3"),
usePty: false,
workdir: "/workspace",
}),
);
expect(runShellCommand).not.toHaveBeenCalled();
expect(deltas).toEqual([
expect.objectContaining({
requestId: "http-stream",
seq: 1,
deltaBase64: Buffer.from("event: ok\n\n").toString("base64"),
done: false,
}),
expect.objectContaining({
requestId: "http-stream",
seq: 2,
deltaBase64: "",
done: true,
}),
]);
socket.close();
});
it("terminates streaming HTTP subprocesses when the exec-server socket closes", async () => {
const finalizeExec = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
buildExecSpec: async () => ({
argv: [
process.execPath,
"-e",
[
"process.on('SIGTERM', () => process.exit(143));",
`console.log(${JSON.stringify(
JSON.stringify({
type: "headers",
status: 200,
headers: [],
}),
)});`,
"setInterval(() => {}, 1000);",
].join(""),
],
env: process.env,
finalizeToken: "stream-token",
stdinMode: "pipe-closed",
}),
finalizeExec,
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "http/request", {
requestId: "http-stream-close",
method: "GET",
url: "https://example.test/sse",
streamResponse: true,
}),
).resolves.toEqual({
status: 200,
headers: [],
bodyBase64: "",
});
socket.terminate();
await vi.waitFor(
() =>
expect(finalizeExec).toHaveBeenCalledWith(
expect.objectContaining({
status: "failed",
token: "stream-token",
}),
),
{ timeout: 5_000 },
);
});
});

View File

@@ -0,0 +1,236 @@
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import { vi } from "vitest";
import WebSocket from "ws";
type RpcResponse = {
id: number;
result?: unknown;
error?: { message: string };
};
export function createSandboxContext(overrides: {
buildExecSpec?: NonNullable<SandboxContext["backend"]>["buildExecSpec"];
finalizeExec?: NonNullable<SandboxContext["backend"]>["finalizeExec"];
mkdirp?: NonNullable<SandboxContext["fsBridge"]>["mkdirp"];
readFile?: NonNullable<SandboxContext["fsBridge"]>["readFile"];
remove?: NonNullable<SandboxContext["fsBridge"]>["remove"];
runShellCommand?: NonNullable<SandboxContext["backend"]>["runShellCommand"];
stat?: NonNullable<SandboxContext["fsBridge"]>["stat"];
writeFile?: NonNullable<SandboxContext["fsBridge"]>["writeFile"];
}): SandboxContext {
return {
enabled: true,
backendId: "docker",
sessionKey: "agent:codex:test",
workspaceDir: "/host/workspace",
agentWorkspaceDir: "/host/workspace",
workspaceAccess: "rw",
runtimeId: "openclaw-test-runtime",
runtimeLabel: "openclaw-test-runtime",
containerName: "openclaw-test-runtime",
containerWorkdir: "/workspace",
docker: { binds: [], image: "test", workdir: "/workspace", env: {}, network: "none" },
tools: {},
browserAllowHostControl: false,
backend: {
id: "docker",
runtimeId: "openclaw-test-runtime",
runtimeLabel: "openclaw-test-runtime",
workdir: "/workspace",
buildExecSpec:
overrides.buildExecSpec ??
(async () => ({
argv: ["/bin/sh", "-lc", "true"],
env: process.env,
stdinMode: "pipe-closed",
})),
finalizeExec: overrides.finalizeExec,
runShellCommand:
overrides.runShellCommand ??
(async () => ({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), code: 0 })),
},
fsBridge: {
resolvePath: ({
filePath,
}: Parameters<NonNullable<SandboxContext["fsBridge"]>["resolvePath"]>[0]) => ({
relativePath: filePath,
containerPath: filePath,
}),
readFile: overrides.readFile ?? (async () => Buffer.alloc(0)),
writeFile: overrides.writeFile ?? (async () => undefined),
mkdirp: overrides.mkdirp ?? (async () => undefined),
remove: overrides.remove ?? (async () => undefined),
rename: async () => undefined,
stat:
overrides.stat ??
(async ({ filePath }) => ({
type: /\.[^/]+$/u.test(filePath) ? "file" : "directory",
size: 1,
mtimeMs: 1,
})),
},
} as unknown as SandboxContext;
}
export function createClient(options: { serverVersion?: string } = {}) {
return {
getServerVersion: vi.fn(() => options.serverVersion ?? "0.132.0"),
request: vi.fn(async (_method: string, _params?: unknown) => ({})),
};
}
export function execServerUrlFromClient(
client: ReturnType<typeof createClient>,
callIndex = 0,
): string {
const params = client.request.mock.calls[callIndex]?.[1];
if (!params || typeof params !== "object" || !("execServerUrl" in params)) {
throw new Error(`missing execServerUrl for environment/add call ${callIndex}`);
}
const { execServerUrl } = params as { execServerUrl?: unknown };
if (typeof execServerUrl !== "string" || !execServerUrl) {
throw new Error(`invalid execServerUrl for environment/add call ${callIndex}`);
}
return execServerUrl;
}
export function codexFsSandboxContext(params: {
entries: Array<{ path: unknown; access: "read" | "write" | "none" | "deny" }>;
cwd?: string;
}): unknown {
return {
permissions: {
type: "managed",
file_system: {
type: "restricted",
entries: params.entries,
},
network: "restricted",
},
cwd: params.cwd ?? "/workspace",
windowsSandboxLevel: "disabled",
windowsSandboxPrivateDesktop: false,
useLegacyLandlock: false,
};
}
export function specialPath(kind: string, subpath?: string): unknown {
return {
type: "special",
value: {
kind,
...(subpath ? { subpath } : {}),
},
};
}
export function globPath(pattern: string): unknown {
return {
type: "glob_pattern",
pattern,
};
}
export function openSocket(url: string): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const socket = new WebSocket(url);
socket.once("open", () => resolve(socket));
socket.once("error", reject);
});
}
export function collectNotifications(
socket: WebSocket,
): Array<{ method: string; params?: unknown }> {
const notifications: Array<{ method: string; params?: unknown }> = [];
socket.on("message", (data) => {
const message = JSON.parse(Buffer.from(data as Buffer).toString("utf8")) as {
id?: number;
method?: string;
params?: unknown;
};
if (message.id === undefined && message.method) {
notifications.push({ method: message.method, params: message.params });
}
});
return notifications;
}
export async function readUntilClosed(
socket: WebSocket,
processId: string,
): Promise<{
chunks?: Array<{ stream: string; chunk: string }>;
exited?: boolean;
exitCode?: number;
closed?: boolean;
nextSeq?: number;
}> {
let afterSeq = 0;
const chunks: Array<{ stream: string; chunk: string }> = [];
for (let attempt = 0; attempt < 20; attempt += 1) {
const read = (await rpc(socket, "process/read", {
processId,
afterSeq,
waitMs: 1000,
})) as {
chunks?: Array<{ seq?: number; stream: string; chunk: string }>;
exited?: boolean;
exitCode?: number;
closed?: boolean;
nextSeq?: number;
};
chunks.push(...(read.chunks ?? []));
afterSeq = Math.max(afterSeq, (read.nextSeq ?? 1) - 1);
if (read.closed) {
return { ...read, chunks };
}
}
throw new Error(`process ${processId} did not close`);
}
export function waitForSocketClose(socket: WebSocket): Promise<{ code: number }> {
return new Promise((resolve) => {
socket.once("close", (code) => resolve({ code }));
});
}
export async function waitForHttpBodyDeltas(
notifications: Array<{ method: string; params?: unknown }>,
count: number,
): Promise<unknown[]> {
for (let attempt = 0; attempt < 20; attempt += 1) {
const deltas = notifications
.filter((notification) => notification.method === "http/request/bodyDelta")
.map((notification) => notification.params);
if (deltas.length >= count) {
return deltas;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`expected ${count} http body deltas`);
}
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
export function rpc(socket: WebSocket, method: string, params: unknown): Promise<unknown> {
const id = Math.floor(Math.random() * 1_000_000);
return new Promise((resolve, reject) => {
const onMessage = (data: WebSocket.RawData) => {
const response = JSON.parse(Buffer.from(data as Buffer).toString("utf8")) as RpcResponse;
if (response.id !== id) {
return;
}
socket.off("message", onMessage);
if (response.error) {
reject(new Error(response.error.message));
return;
}
resolve(response.result);
};
socket.on("message", onMessage);
socket.send(JSON.stringify({ id, method, params }));
});
}

View File

@@ -0,0 +1,460 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
closeCodexSandboxExecServersForTests,
ensureCodexSandboxExecServerEnvironment,
releaseCodexSandboxExecServerEnvironment,
} from "./sandbox-exec-server.js";
import {
collectNotifications,
createClient,
createSandboxContext,
execServerUrlFromClient,
openSocket,
readUntilClosed,
rpc,
waitForSocketClose,
} from "./sandbox-exec-server.test-helpers.js";
afterEach(async () => {
vi.unstubAllEnvs();
await closeCodexSandboxExecServersForTests();
});
describe("OpenClaw Codex sandbox exec-server", () => {
it("reports unavailable app-server remote environment support without exposing an environment", async () => {
const sandbox = createSandboxContext({});
const client = {
getServerVersion: vi.fn(() => "0.132.0"),
request: vi.fn(async () => {
throw new Error("unknown variant environment/add");
}),
};
await expect(
ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
}),
).resolves.toBeUndefined();
});
it("does not advertise a local exec-server URL to remote app-servers", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await expect(
ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
appServerStartOptions: {
transport: "websocket",
command: "codex",
commandSource: "config",
args: [],
url: "wss://codex.example.test/app-server",
headers: {},
},
}),
).rejects.toThrow("cannot be registered with a remote Codex app-server");
expect(client.request).not.toHaveBeenCalled();
});
it("does not treat 127-prefixed DNS names as local app-server hosts", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await expect(
ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
appServerStartOptions: {
transport: "websocket",
command: "codex",
commandSource: "config",
args: [],
url: "wss://127.example.test/app-server",
headers: {},
},
}),
).rejects.toThrow("cannot be registered with a remote Codex app-server");
expect(client.request).not.toHaveBeenCalled();
});
it("rejects Codex app-server versions before the sandbox exec-server environment contract", async () => {
const sandbox = createSandboxContext({});
const client = createClient({ serverVersion: "0.131.0" });
await expect(
ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
}),
).rejects.toThrow("Codex app-server 0.132.0 or newer is required");
expect(client.request).not.toHaveBeenCalled();
});
it("registers a sandbox-backed Codex environment and routes process execution through it", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", "printf 'sandbox-process-ok\\n'"],
env: process.env,
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const requests: Array<{ method: string; params: unknown }> = [];
const client = {
getServerVersion: vi.fn(() => "0.132.0"),
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
return {};
}),
};
const environment = await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const addRequest = requests[0];
expect(addRequest?.method).toBe("environment/add");
expect(environment).toEqual({
environmentId: expect.stringMatching(/^openclaw-sandbox-/),
cwd: "/workspace",
});
const execServerUrl =
typeof addRequest?.params === "object" &&
addRequest.params &&
"execServerUrl" in addRequest.params
? String(addRequest.params.execServerUrl)
: "";
expect(execServerUrl).toMatch(/^ws:\/\/127\.0\.0\.1:/);
const socket = await openSocket(execServerUrl);
const notifications = collectNotifications(socket);
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const start = (await rpc(socket, "process/start", {
processId: "proc-1",
argv: ["/bin/sh", "-lc", "printf ok"],
cwd: "/workspace",
env: { POLICY_SET: "env-wins", TEST_FLAG: "1" },
envPolicy: {
inherit: "none",
ignoreDefaultExcludes: true,
exclude: [],
set: { POLICY_SET: "policy", POLICY_ONLY: "1" },
includeOnly: [],
},
tty: false,
pipeStdin: false,
arg0: null,
})) as { processId?: string; nextSeq?: number };
expect(start).toEqual({ processId: "proc-1" });
const read = await readUntilClosed(socket, "proc-1");
expect(read.exited).toBe(true);
expect(read.exitCode).toBe(0);
expect(read.closed).toBe(true);
expect(Buffer.from(read.chunks?.[0]?.chunk ?? "", "base64").toString("utf8")).toBe(
"sandbox-process-ok\n",
);
expect(buildExecSpec).toHaveBeenCalledWith(
expect.objectContaining({
command: "'/bin/sh' '-lc' 'printf ok'",
env: { POLICY_ONLY: "1", POLICY_SET: "env-wins", TEST_FLAG: "1" },
usePty: false,
workdir: "/workspace",
}),
);
expect(notifications.map((notification) => notification.method)).toEqual(
expect.arrayContaining(["process/output", "process/exited", "process/closed"]),
);
socket.close();
});
it("rejects unsupported arg0 overrides instead of dropping them", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", "true"],
env: process.env,
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "process/start", {
processId: "proc-arg0",
argv: ["/bin/sh", "-lc", "true"],
cwd: "/workspace",
env: {},
tty: false,
pipeStdin: false,
arg0: "codex-linux-sandbox",
}),
).rejects.toThrow("does not support arg0 overrides");
expect(buildExecSpec).not.toHaveBeenCalled();
socket.close();
});
it("accepts stdin writes for pipe-backed processes", async () => {
const sandbox = createSandboxContext({
buildExecSpec: async () => ({
argv: ["/bin/sh", "-lc", 'read line; printf "echo:%s\\n" "$line"'],
env: process.env,
stdinMode: "pipe-open",
}),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-stdin",
argv: ["/bin/sh", "-lc", "cat"],
cwd: "/workspace",
env: {},
tty: false,
pipeStdin: true,
arg0: null,
});
await expect(
rpc(socket, "process/write", {
processId: "proc-stdin",
chunk: Buffer.from("hello\n").toString("base64"),
}),
).resolves.toEqual({ status: "accepted" });
const read = await readUntilClosed(socket, "proc-stdin");
expect(Buffer.from(read.chunks?.[0]?.chunk ?? "", "base64").toString("utf8")).toBe(
"echo:hello\n",
);
socket.close();
});
it("keeps tty process starts pipe-backed for sandbox backends", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", 'read line; printf "tty:%s\\n" "$line"'],
env: process.env,
stdinMode: "pipe-open" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-tty",
argv: ["/bin/sh", "-lc", "cat"],
cwd: "/workspace",
env: {},
tty: true,
pipeStdin: false,
arg0: null,
});
await expect(
rpc(socket, "process/write", {
processId: "proc-tty",
chunk: Buffer.from("hello\n").toString("base64"),
}),
).resolves.toEqual({ status: "accepted" });
const read = await readUntilClosed(socket, "proc-tty");
expect(buildExecSpec).toHaveBeenCalledWith(expect.objectContaining({ usePty: false }));
expect(read.chunks?.[0]?.stream).toBe("pty");
expect(Buffer.from(read.chunks?.[0]?.chunk ?? "", "base64").toString("utf8")).toBe(
"tty:hello\n",
);
socket.close();
});
it("does not let Codex env policy inherit host secret variables", async () => {
vi.stubEnv("HOME", "/gateway-home");
vi.stubEnv("USER", "gateway-user");
vi.stubEnv("TMPDIR", "/gateway-tmp");
vi.stubEnv("OPENCLAW_TEST_SECRET_TOKEN", "host-secret");
vi.stubEnv("OPENCLAW_TEST_DATABASE_PASSWORD", "host-password");
vi.stubEnv("OPENCLAW_TEST_PRIVATE_KEY", "host-private-key");
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", "true"],
env: {},
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-secret-env",
argv: ["/bin/sh", "-lc", "true"],
cwd: "/workspace",
env: {},
envPolicy: {
inherit: "all",
ignoreDefaultExcludes: true,
exclude: [],
set: {},
includeOnly: [],
},
tty: false,
pipeStdin: false,
arg0: null,
});
expect(buildExecSpec).toHaveBeenCalledWith(
expect.objectContaining({
env: {},
}),
);
socket.close();
});
it("keeps process/read cursors at the last returned byte-limited chunk", async () => {
const sandbox = createSandboxContext({
buildExecSpec: async () => ({
argv: [
process.execPath,
"-e",
"process.stdout.write('aaaa'); process.stderr.write('bbbb');",
],
env: process.env,
stdinMode: "pipe-closed",
}),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-cursor",
argv: [process.execPath, "-e", "ignored"],
cwd: "/workspace",
env: {},
tty: false,
pipeStdin: false,
arg0: null,
});
const complete = await readUntilClosed(socket, "proc-cursor");
expect(complete.chunks?.length ?? 0).toBeGreaterThanOrEqual(2);
const firstRead = (await rpc(socket, "process/read", {
processId: "proc-cursor",
afterSeq: 0,
maxBytes: 4,
})) as { chunks?: Array<{ seq: number }>; nextSeq?: number };
expect(firstRead.chunks).toHaveLength(1);
expect(firstRead.nextSeq).toBe((firstRead.chunks?.[0]?.seq ?? 0) + 1);
expect(firstRead.nextSeq ?? 0).toBeLessThan(complete.nextSeq ?? 0);
const secondRead = (await rpc(socket, "process/read", {
processId: "proc-cursor",
afterSeq: (firstRead.nextSeq ?? 1) - 1,
maxBytes: 4,
})) as { chunks?: Array<{ seq: number }> };
expect(secondRead.chunks?.length ?? 0).toBeGreaterThanOrEqual(1);
socket.close();
});
it("returns protocol statuses for unsupported process writes and unknown termination", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "process/write", {
processId: "missing",
chunk: Buffer.from("hello").toString("base64"),
}),
).resolves.toEqual({ status: "unknownProcess" });
await expect(
rpc(socket, "process/terminate", {
processId: "missing",
}),
).resolves.toEqual({ running: false });
socket.close();
});
it("rejects WebSocket clients that do not know the exec-server capability path", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const unauthorizedUrl = execServerUrlFromClient(client).replace(
/\/openclaw-[^/?#]+/u,
"/wrong",
);
const socket = await openSocket(unauthorizedUrl);
await expect(waitForSocketClose(socket)).resolves.toEqual({ code: 1008 });
});
it("closes the exec-server when its sandbox environment is released", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const execServerUrl = execServerUrlFromClient(client);
await releaseCodexSandboxExecServerEnvironment(sandbox);
await expect(openSocket(execServerUrl)).rejects.toThrow();
});
it("keeps a shared exec-server open when another turn reacquires during release", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const firstExecServerUrl = execServerUrlFromClient(client);
const release = releaseCodexSandboxExecServerEnvironment(sandbox);
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
await release;
const secondExecServerUrl = execServerUrlFromClient(client, 1);
expect(secondExecServerUrl).toBe(firstExecServerUrl);
const socket = await openSocket(secondExecServerUrl);
await expect(rpc(socket, "initialize", { clientName: "test" })).resolves.toEqual({
sessionId: expect.any(String),
});
socket.close();
});
});

View File

@@ -0,0 +1,355 @@
import { createHash, randomUUID } from "node:crypto";
import { once } from "node:events";
import type { IncomingMessage } from "node:http";
import { isIP, type AddressInfo } from "node:net";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import { WebSocketServer, type RawData, type WebSocket } from "ws";
import { compareCodexAppServerVersions, type CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
import type { JsonValue } from "./protocol.js";
import {
createDirectory,
copyPath,
getMetadata,
readDirectory,
readFile,
removePath,
writeFile,
} from "./sandbox-exec-server/filesystem.js";
import { httpRequest } from "./sandbox-exec-server/http.js";
import {
JsonRpcProtocolError,
parseRequest,
sendError,
sendResult,
} from "./sandbox-exec-server/json-rpc.js";
import {
readProcess,
startProcess,
terminateProcess,
writeProcess,
} from "./sandbox-exec-server/processes.js";
import type {
JsonRpcRequest,
ManagedProcess,
OpenClawExecServer,
} from "./sandbox-exec-server/types.js";
import { MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION } from "./version.js";
export type CodexSandboxExecEnvironment = {
environmentId: string;
cwd: string;
};
const SANDBOX_EXEC_SERVERS = new Map<string, Promise<OpenClawExecServer>>();
export async function closeCodexSandboxExecServersForTests(): Promise<void> {
const servers = await Promise.allSettled(SANDBOX_EXEC_SERVERS.values());
SANDBOX_EXEC_SERVERS.clear();
await Promise.all(
servers.map(async (entry) => {
if (entry.status === "fulfilled") {
entry.value.refCount = 0;
await closeOpenClawExecServer(entry.value);
}
}),
);
}
export async function ensureCodexSandboxExecServerEnvironment(params: {
client: CodexAppServerClient;
sandbox: SandboxContext | null;
appServerStartOptions?: CodexAppServerStartOptions;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<CodexSandboxExecEnvironment | undefined> {
if (!params.sandbox?.enabled || !params.sandbox.backend) {
return undefined;
}
if (!canExposeLocalExecServerToAppServer(params.appServerStartOptions)) {
throw new Error(
"OpenClaw Codex exec-server uses a local loopback URL and cannot be registered with a remote Codex app-server.",
);
}
assertCodexSandboxExecServerSupported(params.client);
const execServer = await acquireOpenClawExecServer(params.sandbox);
try {
await params.client.request(
"environment/add",
{
environmentId: execServer.environmentId,
execServerUrl: execServer.url,
},
{ timeoutMs: params.timeoutMs, signal: params.signal },
);
} catch (error) {
await releaseOpenClawExecServer(execServer);
if (isEnvironmentAddUnsupported(error)) {
embeddedAgentLog.warn("codex app-server does not support remote environments yet", {
environmentId: execServer.environmentId,
});
return undefined;
}
throw error;
}
return {
environmentId: execServer.environmentId,
cwd: params.sandbox.containerWorkdir,
};
}
export async function releaseCodexSandboxExecServerEnvironment(
sandbox: SandboxContext | null | undefined,
): Promise<void> {
if (!sandbox?.enabled) {
return;
}
const server = await SANDBOX_EXEC_SERVERS.get(sandbox.runtimeId)?.catch(() => undefined);
if (server) {
await releaseOpenClawExecServer(server);
}
}
function assertCodexSandboxExecServerSupported(client: CodexAppServerClient): void {
const detectedVersion = client.getServerVersion();
if (
!detectedVersion ||
compareCodexAppServerVersions(
detectedVersion,
MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION,
) < 0
) {
throw new Error(
`Codex app-server ${MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION} or newer is required for OpenClaw sandbox exec-server environments, but detected ${
detectedVersion ?? "an unknown version"
}. Disable appServer.experimental.sandboxExecServer or configure a newer Codex app-server binary.`,
);
}
}
function isEnvironmentAddUnsupported(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return (
error.message.includes("environment/add") &&
(error.message.includes("unknown variant") || error.message.includes("Method not found"))
);
}
function canExposeLocalExecServerToAppServer(
startOptions: CodexAppServerStartOptions | undefined,
): boolean {
if (!startOptions || startOptions.transport !== "websocket") {
return true;
}
if (typeof startOptions.url !== "string") {
return false;
}
try {
const host = new URL(startOptions.url).hostname.toLowerCase();
const ipHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
if (host === "localhost" || ipHost === "::1") {
return true;
}
return isIP(ipHost) === 4 && ipHost.split(".")[0] === "127";
} catch {
return false;
}
}
async function acquireOpenClawExecServer(sandbox: SandboxContext): Promise<OpenClawExecServer> {
const key = sandbox.runtimeId;
while (true) {
const existing = SANDBOX_EXEC_SERVERS.get(key);
const promise = existing ?? startAndRememberOpenClawExecServer(sandbox);
const server = await promise;
if (!server.closed && SANDBOX_EXEC_SERVERS.get(key) === promise) {
server.refCount += 1;
return server;
}
}
}
function startAndRememberOpenClawExecServer(sandbox: SandboxContext): Promise<OpenClawExecServer> {
const created = startOpenClawExecServer(sandbox);
const key = sandbox.runtimeId;
SANDBOX_EXEC_SERVERS.set(key, created);
void created.catch(() => {
if (SANDBOX_EXEC_SERVERS.get(key) === created) {
SANDBOX_EXEC_SERVERS.delete(key);
}
});
return created;
}
async function startOpenClawExecServer(sandbox: SandboxContext): Promise<OpenClawExecServer> {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("OpenClaw Codex exec-server did not bind to a TCP port.");
}
const environmentId = buildEnvironmentId(sandbox);
const authPath = `/openclaw-${randomUUID()}`;
const url = `ws://127.0.0.1:${(address as AddressInfo).port}${authPath}`;
const execServer: OpenClawExecServer = {
authPath,
closed: false,
environmentId,
refCount: 0,
url,
sandbox,
server,
};
server.on("connection", (socket, request) => {
if (!isAuthorizedExecServerRequest(execServer, request)) {
socket.close(1008, "unauthorized");
return;
}
handleConnection(execServer, socket);
});
embeddedAgentLog.info("codex sandbox exec-server started", {
environmentId,
runtimeId: sandbox.runtimeId,
backendId: sandbox.backendId,
});
return execServer;
}
async function releaseOpenClawExecServer(execServer: OpenClawExecServer): Promise<void> {
if (execServer.closed) {
return;
}
execServer.refCount = Math.max(0, execServer.refCount - 1);
if (execServer.refCount > 0) {
return;
}
const current = await SANDBOX_EXEC_SERVERS.get(execServer.sandbox.runtimeId)?.catch(
() => undefined,
);
if (execServer.refCount > 0 || execServer.closed) {
return;
}
if (current === execServer) {
SANDBOX_EXEC_SERVERS.delete(execServer.sandbox.runtimeId);
}
await closeOpenClawExecServer(execServer);
}
async function closeOpenClawExecServer(execServer: OpenClawExecServer): Promise<void> {
if (execServer.closed) {
return;
}
execServer.closed = true;
for (const client of execServer.server.clients) {
client.close(1001, "shutdown");
}
await new Promise<void>((resolve) => {
execServer.server.close(() => resolve());
});
}
function buildEnvironmentId(sandbox: SandboxContext): string {
const hash = createHash("sha256").update(sandbox.runtimeId).digest("hex").slice(0, 16);
return `openclaw-sandbox-${hash}`;
}
function isAuthorizedExecServerRequest(
execServer: OpenClawExecServer,
request: IncomingMessage,
): boolean {
const url = new URL(request.url ?? "", "ws://127.0.0.1");
return url.pathname === execServer.authPath;
}
function handleConnection(execServer: OpenClawExecServer, socket: WebSocket): void {
const processes = new Map<string, ManagedProcess>();
socket.on("message", (data) => {
void handleMessage(execServer, processes, socket, data).catch((error: unknown) => {
embeddedAgentLog.warn("codex sandbox exec-server message failed", { error });
});
});
socket.on("close", () => {
for (const process of processes.values()) {
process.abortController.abort();
}
});
}
async function handleMessage(
execServer: OpenClawExecServer,
processes: Map<string, ManagedProcess>,
socket: WebSocket,
data: RawData,
): Promise<void> {
const request = parseRequest(data);
if (!request.method) {
sendError(socket, request.id, -32600, "Invalid Request");
return;
}
const method = request.method;
if (request.id === undefined) {
if (method !== "initialized") {
sendError(socket, -1, -32600, `Unexpected notification: ${method}`);
}
return;
}
try {
const result = await dispatchRequest(execServer, processes, socket, { ...request, method });
sendResult(socket, request.id, result);
} catch (error) {
sendError(
socket,
request.id,
error instanceof JsonRpcProtocolError ? error.code : -32603,
error instanceof Error ? error.message : String(error),
);
}
}
async function dispatchRequest(
execServer: OpenClawExecServer,
processes: Map<string, ManagedProcess>,
socket: WebSocket,
request: Required<Pick<JsonRpcRequest, "method">> & Pick<JsonRpcRequest, "id" | "params">,
): Promise<JsonValue | undefined> {
switch (request.method) {
case "initialize":
return { sessionId: randomUUID() };
// These method names are the Codex exec-server remote-environment RPCs.
// The app-server process-control surface uses different names such as
// process/spawn, but those are not sent to registered exec-server URLs.
case "process/start":
return startProcess(execServer, processes, socket, request.params);
case "process/read":
return await readProcess(processes, request.params);
case "process/write":
return writeProcess(processes, request.params);
case "process/terminate":
return terminateProcess(processes, request.params);
case "fs/readFile":
return await readFile(execServer, request.params);
case "fs/writeFile":
await writeFile(execServer, request.params);
return {};
case "fs/createDirectory":
await createDirectory(execServer, request.params);
return {};
case "fs/getMetadata":
return await getMetadata(execServer, request.params);
case "fs/readDirectory":
return await readDirectory(execServer, request.params);
case "fs/remove":
await removePath(execServer, request.params);
return {};
case "fs/copy":
await copyPath(execServer, request.params);
return {};
case "http/request":
return await httpRequest(execServer, socket, request.params);
default:
throw new Error(`Unsupported OpenClaw sandbox exec-server method: ${request.method}`);
}
}

View File

@@ -0,0 +1,261 @@
import { posix as pathPosix } from "node:path";
import type { SandboxFsStat } from "openclaw/plugin-sdk/sandbox";
import type { JsonObject, JsonValue } from "../protocol.js";
import {
assertFsSandboxAccess,
assertNoReadOnlyDescendant,
assertResolvedFsSandboxAccess,
joinSandboxChildPath,
normalizeSandboxAbsolutePath,
pathContains,
resolveFsSandboxPolicy,
} from "./fs-policy.js";
import {
JSON_RPC_NOT_FOUND,
JsonRpcProtocolError,
requireBase64String,
requireObject,
requireString,
} from "./json-rpc.js";
import { requireBackend, requireFsBridge } from "./runtime.js";
import type { DirectoryEntry, OpenClawExecServer, ResolvedFsSandboxPolicy } from "./types.js";
const CODEX_SANDBOX_EXEC_SERVER_MAX_READ_FILE_BYTES = 512 * 1024 * 1024;
export async function readFile(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "fs/readFile params");
const filePath = requireString(record.path, "path");
assertFsSandboxAccess(execServer, record, [{ path: filePath, access: "read" }]);
const fsBridge = requireFsBridge(execServer);
const stat = await fsBridge.stat({ filePath });
if (!stat) {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, "file not found");
}
if (stat.type === "file" && stat.size > CODEX_SANDBOX_EXEC_SERVER_MAX_READ_FILE_BYTES) {
throw new Error(
`file is too large to read through Codex sandbox exec-server: ${stat.size} bytes`,
);
}
const data = await fsBridge.readFile({
filePath,
});
return { dataBase64: data.toString("base64") };
}
export async function writeFile(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<void> {
const record = requireObject(params, "fs/writeFile params");
const filePath = requireString(record.path, "path");
assertFsSandboxAccess(execServer, record, [{ path: filePath, access: "write" }]);
const fsBridge = requireFsBridge(execServer);
const parent = await fsBridge.stat({ filePath: pathPosix.dirname(filePath) });
if (parent?.type !== "directory") {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, "parent directory not found");
}
await fsBridge.writeFile({
filePath,
data: Buffer.from(requireBase64String(record.dataBase64, "dataBase64"), "base64"),
mkdir: false,
});
}
export async function createDirectory(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<void> {
const record = requireObject(params, "fs/createDirectory params");
const filePath = requireString(record.path, "path");
assertFsSandboxAccess(execServer, record, [{ path: filePath, access: "write" }]);
const fsBridge = requireFsBridge(execServer);
if (record.recursive === false) {
const parentPath = pathPosix.dirname(filePath);
const parent = await fsBridge.stat({ filePath: parentPath });
if (parent?.type !== "directory") {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, "parent directory not found");
}
}
await fsBridge.mkdirp({
filePath,
});
}
export async function getMetadata(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "fs/getMetadata params");
const filePath = requireString(record.path, "path");
assertFsSandboxAccess(execServer, record, [{ path: filePath, access: "read" }]);
const fsBridge = requireFsBridge(execServer);
const stat = await fsBridge.stat({
filePath,
});
if (!stat) {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, "file not found");
}
return metadataResponse(stat);
}
export async function readDirectory(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "fs/readDirectory params");
const filePath = requireString(record.path, "path");
const fsSandboxPolicy = resolveFsSandboxPolicy(execServer, record);
return {
entries: await listDirectoryEntries(execServer, filePath, fsSandboxPolicy),
};
}
async function listDirectoryEntries(
execServer: OpenClawExecServer,
filePath: string,
fsSandboxPolicy: ResolvedFsSandboxPolicy | undefined,
): Promise<DirectoryEntry[]> {
assertResolvedFsSandboxAccess(fsSandboxPolicy, [{ path: filePath, access: "read" }]);
const fsBridge = requireFsBridge(execServer);
const backend = requireBackend(execServer);
const resolved = fsBridge.resolvePath({
filePath,
});
if (!resolved) {
throw new Error(`Cannot resolve sandbox path: ${filePath}`);
}
const result = await backend.runShellCommand({
script:
'find "$1" -mindepth 1 -maxdepth 1 -exec sh -c \'for path do name=${path##*/}; if [ -L "$path" ]; then kind=o; elif [ -d "$path" ]; then kind=d; elif [ -f "$path" ]; then kind=f; else kind=o; fi; printf "%s\\t%s\\n" "$kind" "$name"; done\' sh {} +',
args: [resolved.containerPath],
allowFailure: true,
});
if (result.code !== 0) {
const stderr = result.stderr.toString("utf8").trim();
throw new Error(stderr || `sandbox directory listing failed with code ${result.code}`);
}
const lines = result.stdout.toString("utf8").split("\n").filter(Boolean);
return lines.map((line) => {
const [kind = "o", fileName = ""] = line.split("\t");
return {
fileName,
isDirectory: kind === "d",
isFile: kind === "f",
};
});
}
export async function removePath(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<void> {
const record = requireObject(params, "fs/remove params");
const filePath = requireString(record.path, "path");
const fsSandboxPolicy = resolveFsSandboxPolicy(execServer, record);
assertResolvedFsSandboxAccess(fsSandboxPolicy, [{ path: filePath, access: "write" }]);
if (record.recursive !== false) {
assertNoReadOnlyDescendant(fsSandboxPolicy, filePath, "remove");
}
const fsBridge = requireFsBridge(execServer);
await fsBridge.remove({
filePath,
recursive: record.recursive !== false,
force: record.force !== false,
});
}
export async function copyPath(
execServer: OpenClawExecServer,
params: JsonValue | undefined,
): Promise<void> {
const record = requireObject(params, "fs/copy params");
const sourcePath = requireString(record.sourcePath ?? record.source, "sourcePath");
const destinationPath = requireString(
record.destinationPath ?? record.destination,
"destinationPath",
);
const fsSandboxPolicy = resolveFsSandboxPolicy(execServer, record);
assertResolvedFsSandboxAccess(fsSandboxPolicy, [
{ path: sourcePath, access: "read" },
{ path: destinationPath, access: "write" },
]);
await copySandboxPath(execServer, {
sourcePath,
destinationPath,
recursive: record.recursive === true,
fsSandboxPolicy,
});
}
async function copySandboxPath(
execServer: OpenClawExecServer,
params: {
sourcePath: string;
destinationPath: string;
recursive: boolean;
fsSandboxPolicy: ResolvedFsSandboxPolicy | undefined;
},
): Promise<void> {
const fsBridge = execServer.sandbox.fsBridge;
if (!fsBridge) {
throw new Error("Sandbox filesystem bridge is unavailable.");
}
assertResolvedFsSandboxAccess(params.fsSandboxPolicy, [
{ path: params.sourcePath, access: "read" },
{ path: params.destinationPath, access: "write" },
]);
const sourceStat = await fsBridge.stat({ filePath: params.sourcePath });
if (!sourceStat) {
throw new JsonRpcProtocolError(JSON_RPC_NOT_FOUND, "file not found");
}
if (sourceStat?.type === "directory") {
if (!params.recursive) {
throw new Error(`Cannot copy directory without recursive=true: ${params.sourcePath}`);
}
if (
pathContains(
normalizeSandboxAbsolutePath(params.sourcePath, "copy source path"),
normalizeSandboxAbsolutePath(params.destinationPath, "copy destination path"),
)
) {
throw new Error("Cannot recursively copy a directory into itself.");
}
await fsBridge.mkdirp({ filePath: params.destinationPath });
for (const entry of await listDirectoryEntries(
execServer,
params.sourcePath,
params.fsSandboxPolicy,
)) {
if (!entry.isDirectory && !entry.isFile) {
throw new Error(`Cannot copy unsupported filesystem entry: ${entry.fileName}`);
}
await copySandboxPath(execServer, {
sourcePath: joinSandboxChildPath(params.sourcePath, entry.fileName),
destinationPath: joinSandboxChildPath(params.destinationPath, entry.fileName),
recursive: true,
fsSandboxPolicy: params.fsSandboxPolicy,
});
}
return;
}
const data = await fsBridge.readFile({ filePath: params.sourcePath });
await fsBridge.writeFile({
filePath: params.destinationPath,
data,
mkdir: true,
});
}
function metadataResponse(stat: SandboxFsStat | null): JsonObject {
return {
isDirectory: stat?.type === "directory",
isFile: stat?.type === "file",
isSymlink: false,
createdAtMs: 0,
modifiedAtMs: stat?.mtimeMs ?? 0,
};
}

View File

@@ -0,0 +1,346 @@
import { posix as pathPosix } from "node:path";
import type { JsonObject } from "../protocol.js";
import { requireObject, requireString } from "./json-rpc.js";
import type {
FsAccessMode,
OpenClawExecServer,
ResolvedFsSandboxEntry,
ResolvedFsSandboxPolicy,
} from "./types.js";
export function assertFsSandboxAccess(
execServer: OpenClawExecServer,
record: JsonObject,
requests: Array<{ path: string; access: "read" | "write" }>,
): void {
assertResolvedFsSandboxAccess(resolveFsSandboxPolicy(execServer, record), requests);
}
export function resolveFsSandboxPolicy(
execServer: OpenClawExecServer,
record: JsonObject,
): ResolvedFsSandboxPolicy | undefined {
if (record.sandbox === undefined || record.sandbox === null) {
return undefined;
}
const sandbox = requireObject(record.sandbox, "fs sandbox context");
const permissions = requireObject(sandbox.permissions, "fs sandbox permissions");
const permissionType = requireString(permissions.type, "fs sandbox permissions type");
if (permissionType === "disabled" || permissionType === "external") {
return { unrestricted: true, entries: [] };
}
if (permissionType !== "managed") {
throw new Error(`Unsupported Codex fs sandbox permission type: ${permissionType}`);
}
const fileSystem = requireObject(permissions.file_system, "fs sandbox file system permissions");
const fileSystemType = requireString(fileSystem.type, "fs sandbox file system permissions type");
if (fileSystemType === "unrestricted") {
return { unrestricted: true, entries: [] };
}
if (fileSystemType !== "restricted") {
throw new Error(`Unsupported Codex fs sandbox file system type: ${fileSystemType}`);
}
if (!Array.isArray(fileSystem.entries)) {
throw new Error("fs sandbox file system entries must be an array.");
}
const cwd = readFsSandboxCwd(execServer, sandbox);
return {
unrestricted: false,
entries: fileSystem.entries.flatMap((entry, index) => {
const resolved = resolveFsSandboxEntry(
requireObject(entry, `fs sandbox entry ${index}`),
cwd,
);
return resolved ? [resolved] : [];
}),
};
}
function readFsSandboxCwd(execServer: OpenClawExecServer, sandbox: JsonObject): string {
if (sandbox.cwd === undefined || sandbox.cwd === null) {
return normalizeSandboxAbsolutePath(execServer.sandbox.containerWorkdir, "sandbox cwd");
}
return normalizeSandboxAbsolutePath(requireString(sandbox.cwd, "sandbox cwd"), "sandbox cwd");
}
function resolveFsSandboxEntry(entry: JsonObject, cwd: string): ResolvedFsSandboxEntry | undefined {
const access = readFsAccessMode(entry.access);
const pathSpec = requireObject(entry.path, "fs sandbox entry path");
const pathType = requireString(pathSpec.type, "fs sandbox entry path type");
if (pathType === "path") {
return {
kind: "path",
path: normalizeSandboxAbsolutePath(
requireString(pathSpec.path, "fs sandbox path"),
"fs sandbox path",
),
access,
};
}
if (pathType === "special") {
if (isNonGrantingFsSpecialPath(requireObject(pathSpec.value, "fs sandbox special path"))) {
return undefined;
}
return {
kind: "path",
path: resolveFsSpecialPath(requireObject(pathSpec.value, "fs sandbox special path"), cwd),
access,
};
}
if (pathType === "glob_pattern") {
const pattern = requireString(pathSpec.pattern, "fs sandbox glob pattern");
const absolutePattern = normalizeSandboxGlobPattern(
pattern.startsWith("/") ? pattern : pathPosix.join(cwd, pattern),
);
return {
kind: "glob",
pattern: absolutePattern,
matcher: compileSandboxGlobPattern(absolutePattern),
literalPrefix: sandboxGlobLiteralPrefix(absolutePattern),
access,
};
}
throw new Error(`Unsupported Codex fs sandbox path type: ${pathType}`);
}
function isNonGrantingFsSpecialPath(value: JsonObject): boolean {
const kind = requireString(value.kind, "fs sandbox special path kind");
return kind === "minimal" || kind === "unknown";
}
function readFsAccessMode(value: unknown): FsAccessMode {
if (value === "read" || value === "write" || value === "none") {
return value;
}
if (value === "deny") {
return "none";
}
throw new Error("fs sandbox entry access must be read, write, none, or deny.");
}
function resolveFsSpecialPath(value: JsonObject, cwd: string): string {
const kind = requireString(value.kind, "fs sandbox special path kind");
if (kind === "root") {
return "/";
}
if (kind === "project_roots" || kind === "current_working_directory") {
const subpath =
value.subpath === undefined || value.subpath === null
? undefined
: requireString(value.subpath, "fs sandbox project roots subpath");
return normalizeSandboxAbsolutePath(
subpath ? pathPosix.join(cwd, subpath) : cwd,
"fs sandbox project roots path",
);
}
if (kind === "slash_tmp" || kind === "tmpdir") {
return "/tmp";
}
throw new Error(`Unsupported Codex fs sandbox special path: ${kind}`);
}
export function assertResolvedFsSandboxAccess(
policy: ResolvedFsSandboxPolicy | undefined,
requests: Array<{ path: string; access: "read" | "write" }>,
): void {
if (!policy?.unrestricted && policy) {
for (const request of requests) {
const access = resolveFsAccess(policy, request.path);
if (request.access === "read" && access === "none") {
throw new Error(`Codex fs sandbox denied read access to ${request.path}`);
}
if (request.access === "write" && access !== "write") {
throw new Error(`Codex fs sandbox denied write access to ${request.path}`);
}
}
}
}
function resolveFsAccess(policy: ResolvedFsSandboxPolicy, rawPath: string): FsAccessMode {
if (policy.unrestricted) {
return "write";
}
const target = normalizeSandboxAbsolutePath(rawPath, "fs path");
let selected: { specificity: number; rank: number; access: FsAccessMode } | undefined;
for (const entry of policy.entries) {
if (!fsSandboxEntryMatches(entry, target)) {
continue;
}
const candidate = {
specificity: fsSandboxEntrySpecificity(entry),
rank: fsAccessRank(entry.access),
access: entry.access,
};
if (
!selected ||
candidate.specificity > selected.specificity ||
(candidate.specificity === selected.specificity && candidate.rank > selected.rank)
) {
selected = candidate;
}
}
return selected?.access ?? "none";
}
export function assertNoReadOnlyDescendant(
policy: ResolvedFsSandboxPolicy | undefined,
rawPath: string,
operation: string,
): void {
if (!policy || policy.unrestricted) {
return;
}
const target = normalizeSandboxAbsolutePath(rawPath, "fs path");
const protectedDescendant = policy.entries.find((entry) => {
if (entry.access === "write" || !fsSandboxEntryCanAffectDescendant(entry, target)) {
return false;
}
if (entry.kind === "glob") {
return true;
}
const protectedPath = entry.path;
return protectedPath && resolveFsAccess(policy, protectedPath) !== "write";
});
if (protectedDescendant) {
const protectedPath =
protectedDescendant.kind === "path" ? protectedDescendant.path : protectedDescendant.pattern;
throw new Error(
`Codex fs sandbox denied recursive ${operation} of ${rawPath} because ${protectedPath} is not writable.`,
);
}
}
export function normalizeSandboxAbsolutePath(rawPath: string, label: string): string {
if (!rawPath || rawPath.includes("\0") || !rawPath.startsWith("/")) {
throw new Error(`${label} must be an absolute sandbox path.`);
}
const normalized = pathPosix.normalize(rawPath);
return normalized === "//" ? "/" : normalized;
}
export function pathContains(root: string, target: string): boolean {
return root === "/" || target === root || target.startsWith(`${root}/`);
}
function fsSandboxEntryMatches(entry: ResolvedFsSandboxEntry, target: string): boolean {
if (entry.kind === "path") {
return pathContains(entry.path, target);
}
return entry.matcher.test(target);
}
function fsSandboxEntryCanAffectDescendant(entry: ResolvedFsSandboxEntry, target: string): boolean {
if (entry.kind === "path") {
return pathContains(target, entry.path) && target !== entry.path;
}
return pathContains(target, entry.literalPrefix) || pathContains(entry.literalPrefix, target);
}
function fsSandboxEntrySpecificity(entry: ResolvedFsSandboxEntry): number {
return pathSpecificity(entry.kind === "path" ? entry.path : entry.literalPrefix);
}
function pathSpecificity(filePath: string): number {
return filePath === "/" ? 0 : filePath.split("/").filter(Boolean).length;
}
function fsAccessRank(access: FsAccessMode): number {
if (access === "none") {
return 2;
}
if (access === "write") {
return 1;
}
return 0;
}
function normalizeSandboxGlobPattern(pattern: string): string {
if (!pattern || pattern.includes("\0") || !pattern.startsWith("/")) {
throw new Error("fs sandbox glob pattern must be absolute.");
}
return pattern.replace(/\/{2,}/gu, "/");
}
function compileSandboxGlobPattern(pattern: string): RegExp {
let source = "^";
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index];
const next = pattern[index + 1];
if (char === "*" && next === "*" && pattern[index + 2] === "/") {
source += "(?:.*/)?";
index += 2;
} else if (char === "*" && next === "*") {
source += ".*";
index += 1;
} else if (char === "*") {
source += "[^/]*";
} else if (char === "?") {
source += "[^/]";
} else if (char === "[") {
const compiledClass = compileSandboxGlobCharacterClass(pattern, index);
source += compiledClass.source;
index = compiledClass.endIndex;
} else {
source += char?.replace(/[\\^$+?.()|[\]{}]/gu, "\\$&") ?? "";
}
}
source += "$";
return new RegExp(source, "u");
}
function compileSandboxGlobCharacterClass(
pattern: string,
startIndex: number,
): { source: string; endIndex: number } {
let index = startIndex + 1;
if (index >= pattern.length) {
throw new Error("fs sandbox glob character class must be closed.");
}
const negated = pattern[index] === "!" || pattern[index] === "^";
if (negated) {
index += 1;
}
let body = "";
for (; index < pattern.length; index += 1) {
const char = pattern[index];
if (char === "]" && body) {
return {
source: `[${negated ? "^" : ""}${body}]`,
endIndex: index,
};
}
if (!char || char === "/") {
throw new Error("fs sandbox glob character class cannot match path separators.");
}
body += escapeSandboxGlobCharacterClassChar(char, body.length === 0);
}
throw new Error("fs sandbox glob character class must be closed.");
}
function escapeSandboxGlobCharacterClassChar(char: string, first: boolean): string {
if (char === "\\" || char === "]") {
return `\\${char}`;
}
if (first && char === "^") {
return "\\^";
}
return char;
}
function sandboxGlobLiteralPrefix(pattern: string): string {
const wildcardIndex = pattern.search(/[*?[]/u);
const prefix = wildcardIndex === -1 ? pattern : pattern.slice(0, wildcardIndex);
const slash = prefix.lastIndexOf("/");
if (slash <= 0) {
return "/";
}
return normalizeSandboxAbsolutePath(prefix.slice(0, slash), "fs sandbox glob prefix");
}
export function joinSandboxChildPath(parent: string, child: string): string {
if (!child || child === "." || child === ".." || child.includes("/") || child.includes("\0")) {
throw new Error(`Invalid sandbox directory entry name: ${child}`);
}
return parent.endsWith("/") ? `${parent}${child}` : `${parent}/${child}`;
}

View File

@@ -0,0 +1,312 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import type { WebSocket } from "ws";
import type { JsonObject, JsonValue } from "../protocol.js";
import { readHttpHeaders, requireNumber, requireObject, requireString } from "./json-rpc.js";
import { requireBackend } from "./runtime.js";
import type { HttpHeader, OpenClawExecServer } from "./types.js";
export async function httpRequest(
execServer: OpenClawExecServer,
socket: WebSocket,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "http/request params");
const requestId = requireString(record.requestId, "requestId");
const request = {
method: requireString(record.method, "method"),
url: requireString(record.url, "url"),
headers: readHttpHeaders(record.headers),
bodyBase64: typeof record.bodyBase64 === "string" ? record.bodyBase64 : undefined,
timeoutMs:
typeof record.timeoutMs === "number" && record.timeoutMs > 0
? Math.floor(record.timeoutMs)
: undefined,
streamResponse: record.streamResponse === true,
};
if (request.streamResponse) {
return await runStreamingSandboxHttpRequest(execServer, socket, requestId, request);
}
const result = await runSandboxHttpRequest(execServer, {
...request,
streamResponse: false,
});
return result;
}
type SandboxHttpRequest = {
method: string;
url: string;
headers: HttpHeader[];
bodyBase64?: string;
timeoutMs?: number;
streamResponse: boolean;
};
async function runSandboxHttpRequest(
execServer: OpenClawExecServer,
params: SandboxHttpRequest,
): Promise<JsonObject & { status: number; headers: HttpHeader[]; bodyBase64: string }> {
const backend = requireBackend(execServer);
const result = await backend.runShellCommand({
script: SANDBOX_HTTP_REQUEST_SCRIPT,
stdin: JSON.stringify(params),
allowFailure: true,
});
if (result.code !== 0) {
const stderr = result.stderr.toString("utf8").trim();
throw new Error(stderr || `sandbox http/request failed with code ${result.code}`);
}
const parsed = JSON.parse(result.stdout.toString("utf8")) as {
status?: unknown;
headers?: unknown;
bodyBase64?: unknown;
};
if (typeof parsed.status !== "number" || !Array.isArray(parsed.headers)) {
throw new Error("sandbox http/request returned an invalid response envelope");
}
return {
status: parsed.status,
headers: readHttpHeaders(parsed.headers),
bodyBase64: typeof parsed.bodyBase64 === "string" ? parsed.bodyBase64 : "",
};
}
async function runStreamingSandboxHttpRequest(
execServer: OpenClawExecServer,
socket: WebSocket,
requestId: string,
params: SandboxHttpRequest,
): Promise<JsonObject> {
const backend = requireBackend(execServer);
const execSpec = await backend.buildExecSpec({
command: SANDBOX_HTTP_REQUEST_SCRIPT,
workdir: execServer.sandbox.containerWorkdir,
env: {},
usePty: false,
});
const [command, ...args] = execSpec.argv;
if (!command) {
throw new Error("OpenClaw sandbox HTTP exec spec did not provide a command.");
}
const child = spawn(command, args, {
env: execSpec.env,
stdio: ["pipe", "pipe", "pipe"],
});
const abortOnSocketClose = () => child.kill("SIGTERM");
socket.once("close", abortOnSocketClose);
child.once("close", () => {
socket.off("close", abortOnSocketClose);
});
child.stdin.end(JSON.stringify(params));
return await readStreamingSandboxHttpResponse({
child,
execSpec,
finalizeExec: backend.finalizeExec,
requestId,
socket,
});
}
function readStreamingSandboxHttpResponse(params: {
child: ChildProcessWithoutNullStreams;
execSpec: { finalizeToken?: unknown };
finalizeExec?: NonNullable<SandboxContext["backend"]>["finalizeExec"];
requestId: string;
socket: WebSocket;
}): Promise<JsonObject> {
return new Promise((resolve, reject) => {
let headerResolved = false;
let failed = false;
let lastBodySeq = 0;
let stdoutBuffer = "";
let stderr = "";
const finalize = async (status: "completed" | "failed", exitCode: number | null) => {
await params.finalizeExec?.({
status,
exitCode,
timedOut: false,
token: params.execSpec.finalizeToken,
});
};
const fail = (message: string, exitCode: number | null) => {
if (failed) {
return;
}
failed = true;
void finalize("failed", exitCode).catch((error: unknown) => {
embeddedAgentLog.warn("codex sandbox http/request finalize failed", { error });
});
if (headerResolved) {
sendHttpBodyDelta(params.socket, {
requestId: params.requestId,
seq: lastBodySeq + 1,
deltaBase64: "",
done: true,
error: message,
});
return;
}
reject(new Error(message));
};
params.child.stdout.on("data", (chunk: Buffer) => {
stdoutBuffer += chunk.toString("utf8");
let newline = stdoutBuffer.indexOf("\n");
while (newline >= 0) {
const line = stdoutBuffer.slice(0, newline).trim();
stdoutBuffer = stdoutBuffer.slice(newline + 1);
if (line) {
try {
const message = requireObject(JSON.parse(line) as JsonValue, "http stream message");
const type = requireString(message.type, "http stream message type");
if (type === "headers") {
headerResolved = true;
resolve({
status: requireNumber(message.status, "http status"),
headers: readHttpHeaders(message.headers),
bodyBase64: "",
});
} else if (type === "bodyDelta") {
const seq = requireNumber(message.seq, "http body sequence");
lastBodySeq = Math.max(lastBodySeq, seq);
sendHttpBodyDelta(params.socket, {
requestId: params.requestId,
seq,
deltaBase64: typeof message.deltaBase64 === "string" ? message.deltaBase64 : "",
done: message.done === true,
error: typeof message.error === "string" ? message.error : null,
});
}
} catch (error) {
fail(error instanceof Error ? error.message : String(error), null);
}
}
newline = stdoutBuffer.indexOf("\n");
}
});
params.child.stderr.on("data", (chunk: Buffer) => {
stderr = `${stderr}${chunk.toString("utf8")}`.slice(-4096);
});
params.child.once("error", (error) => fail(error.message, null));
params.child.once("close", (code) => {
const exitCode = code ?? 1;
if (failed) {
return;
}
if (exitCode === 0) {
void finalize("completed", exitCode).catch((error: unknown) => {
embeddedAgentLog.warn("codex sandbox http/request finalize failed", { error });
});
if (!headerResolved) {
reject(new Error("sandbox http/request exited before returning headers"));
}
return;
}
fail(stderr.trim() || `sandbox http/request failed with code ${exitCode}`, exitCode);
});
});
}
const SANDBOX_HTTP_REQUEST_SCRIPT = String.raw`
tmp=$(mktemp "$TMPDIR/openclaw-http.XXXXXX.py" 2>/dev/null || mktemp "/tmp/openclaw-http.XXXXXX.py") || exit 1
trap 'rm -f "$tmp"' EXIT
cat > "$tmp" <<'PY'
import base64
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
def emit(payload):
print(json.dumps(payload, separators=(",", ":")), flush=True)
def response_headers(response):
return [{"name": name, "value": value} for name, value in response.headers.items()]
def handle_response(input_data, response):
headers = response_headers(response)
status = int(getattr(response, "status", getattr(response, "code", 0)))
if input_data.get("streamResponse"):
emit({"type": "headers", "status": status, "headers": headers})
seq = 1
while True:
chunk = response.read(65536)
if not chunk:
break
emit({
"type": "bodyDelta",
"seq": seq,
"deltaBase64": base64.b64encode(chunk).decode("ascii"),
"done": False,
})
seq += 1
emit({"type": "bodyDelta", "seq": seq, "deltaBase64": "", "done": True})
return
body = response.read()
emit({
"status": status,
"headers": headers,
"bodyBase64": base64.b64encode(body).decode("ascii"),
})
def main():
input_data = json.load(sys.stdin)
url = str(input_data.get("url", ""))
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("http/request only supports http and https URLs")
body_base64 = input_data.get("bodyBase64")
data = base64.b64decode(body_base64) if isinstance(body_base64, str) else None
request = urllib.request.Request(
url,
data=data,
method=str(input_data.get("method", "GET")),
)
for header in input_data.get("headers") or []:
request.add_header(str(header.get("name", "")), str(header.get("value", "")))
timeout_ms = input_data.get("timeoutMs")
timeout = None
if isinstance(timeout_ms, (int, float)) and timeout_ms > 0:
timeout = timeout_ms / 1000
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
handle_response(input_data, response)
except urllib.error.HTTPError as response:
handle_response(input_data, response)
if __name__ == "__main__":
main()
PY
python3 "$tmp"
`.trim();
function sendHttpBodyDelta(
socket: WebSocket,
params: {
requestId: string;
seq: number;
deltaBase64: string;
done: boolean;
error?: string | null;
},
): void {
if (socket.readyState !== 1) {
return;
}
socket.send(
JSON.stringify({
jsonrpc: "2.0",
method: "http/request/bodyDelta",
params: {
requestId: params.requestId,
seq: params.seq,
deltaBase64: params.deltaBase64,
done: params.done,
error: params.error ?? null,
},
}),
);
}

View File

@@ -0,0 +1,93 @@
import type { RawData, WebSocket } from "ws";
import type { JsonObject, JsonValue } from "../protocol.js";
import type { HttpHeader, JsonRpcRequest } from "./types.js";
export const JSON_RPC_NOT_FOUND = -32004;
export class JsonRpcProtocolError extends Error {
constructor(
readonly code: number,
message: string,
) {
super(message);
}
}
export function parseRequest(data: RawData): JsonRpcRequest {
const buffer = Array.isArray(data)
? Buffer.concat(data)
: Buffer.isBuffer(data)
? data
: Buffer.from(data);
const text = buffer.toString("utf8");
const parsed = JSON.parse(text) as unknown;
return requireObject(parsed, "JSON-RPC request") as JsonRpcRequest;
}
export function requireObject(value: unknown, label: string): JsonObject {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object.`);
}
return value as JsonObject;
}
export function requireString(value: unknown, label: string): string {
if (typeof value !== "string" || !value) {
throw new Error(`${label} must be a non-empty string.`);
}
return value;
}
export function requireBase64String(value: unknown, label: string): string {
if (typeof value !== "string") {
throw new Error(`${label} must be a string.`);
}
return value;
}
export function requireNumber(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${label} must be a finite number.`);
}
return value;
}
export function requireStringArray(value: unknown, label: string): string[] {
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
throw new Error(`${label} must be a string array.`);
}
if (value.length === 0) {
throw new Error(`${label} must not be empty.`);
}
return value;
}
export function readHttpHeaders(value: unknown): HttpHeader[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry, index) => {
const record = requireObject(entry as JsonValue, `header ${index}`);
return {
name: requireString(record.name, "header name"),
value: requireString(record.value, "header value"),
};
});
}
export function sendResult(
socket: WebSocket,
id: string | number,
result: JsonValue | undefined,
): void {
socket.send(JSON.stringify({ jsonrpc: "2.0", id, result: result ?? {} }));
}
export function sendError(
socket: WebSocket,
id: string | number | undefined,
code: number,
message: string,
): void {
socket.send(JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error: { code, message } }));
}

View File

@@ -0,0 +1,411 @@
import { spawn } from "node:child_process";
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { WebSocket } from "ws";
import type { JsonObject, JsonValue } from "../protocol.js";
import { requireObject, requireString, requireStringArray } from "./json-rpc.js";
import type { ManagedProcess, OpenClawExecServer, ProcessChunk } from "./types.js";
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const RETAINED_PROCESS_OUTPUT_BYTES = 1024 * 1024;
const CLOSED_PROCESS_EVICTION_MS = 60_000;
export async function startProcess(
execServer: OpenClawExecServer,
processes: Map<string, ManagedProcess>,
socket: WebSocket,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "process/start params");
const processId = requireString(record.processId, "processId");
if (processes.has(processId)) {
throw new Error(`process already exists: ${processId}`);
}
const argv = requireStringArray(record.argv, "argv");
const cwd = requireString(record.cwd, "cwd");
rejectUnsupportedArg0(record.arg0);
const env = readProcessEnv(record);
const tty = record.tty === true;
const pipeStdin = record.pipeStdin === true;
const managed: ManagedProcess = {
processId,
chunks: [],
retainedOutputBytes: 0,
nextSeq: 1,
exited: false,
exitCode: null,
closed: false,
failure: null,
tty,
pipeStdin,
abortController: new AbortController(),
child: null,
finalized: false,
waiters: [],
emitNotification: (method, notificationParams) => {
if (socket.readyState === 1) {
socket.send(JSON.stringify({ jsonrpc: "2.0", method, params: notificationParams }));
}
},
evictProcess: () => {
if (managed.evictionTimer) {
return;
}
managed.evictionTimer = setTimeout(() => {
if (processes.get(processId) === managed && managed.closed) {
processes.delete(processId);
}
}, CLOSED_PROCESS_EVICTION_MS);
managed.evictionTimer.unref?.();
},
};
processes.set(processId, managed);
try {
await runProcess(execServer, managed, { argv, cwd, env });
} catch (error) {
processes.delete(processId);
managed.failure = error instanceof Error ? error.message : String(error);
managed.exitCode = null;
managed.exited = true;
managed.closed = true;
notifyProcessWaiters(managed);
throw error;
}
return { processId };
}
async function runProcess(
execServer: OpenClawExecServer,
managed: ManagedProcess,
params: { argv: string[]; cwd: string; env: Record<string, string> },
): Promise<void> {
const backend = execServer.sandbox.backend;
if (!backend) {
throw new Error("OpenClaw sandbox backend is unavailable.");
}
throwIfProcessStartCancelled(managed);
const execSpec = await backend.buildExecSpec({
command: shellCommandFromArgv(params.argv),
workdir: params.cwd,
env: params.env,
// This bridge currently owns only pipe-backed child processes. Asking the
// backend for a PTY can produce commands such as `docker exec -t`, which
// require this process itself to own a real TTY.
usePty: false,
});
managed.finalizeToken = execSpec.finalizeToken;
managed.finalizeExec = backend.finalizeExec;
if (managed.abortController.signal.aborted) {
managed.failure = "process start cancelled";
await finalizeProcess(managed);
throw new Error("process start cancelled");
}
const [command, ...args] = execSpec.argv;
if (!command) {
throw new Error("OpenClaw sandbox exec spec did not provide a command.");
}
const child = spawn(command, args, {
env: execSpec.env,
stdio: ["pipe", "pipe", "pipe"],
});
managed.child = child;
const abortListener = () => child.kill("SIGTERM");
managed.abortController.signal.addEventListener("abort", abortListener, { once: true });
child.stdout.on("data", (chunk: Buffer) =>
appendProcessChunk(managed, managed.tty ? "pty" : "stdout", chunk),
);
child.stderr.on("data", (chunk: Buffer) => appendProcessChunk(managed, "stderr", chunk));
child.once("error", (error) => {
managed.failure = error.message;
emitProcessClosed(managed, null);
});
child.once("close", (code) => {
managed.abortController.signal.removeEventListener("abort", abortListener);
emitProcessClosed(managed, code ?? 1);
});
if (!managed.tty && !managed.pipeStdin) {
child.stdin.end();
}
}
function throwIfProcessStartCancelled(managed: ManagedProcess): void {
if (managed.abortController.signal.aborted) {
throw new Error("process start cancelled");
}
}
function appendProcessChunk(
managed: ManagedProcess,
stream: ProcessChunk["stream"],
data: Buffer,
): void {
if (data.length === 0) {
return;
}
const chunk = {
seq: managed.nextSeq,
stream,
chunk: data.toString("base64"),
};
managed.chunks.push(chunk);
managed.retainedOutputBytes += data.length;
while (managed.retainedOutputBytes > RETAINED_PROCESS_OUTPUT_BYTES && managed.chunks.length > 1) {
const removed = managed.chunks.shift();
if (!removed) {
break;
}
managed.retainedOutputBytes -= Buffer.from(removed.chunk, "base64").byteLength;
}
managed.nextSeq += 1;
managed.emitNotification("process/output", {
processId: managed.processId,
seq: chunk.seq,
stream: chunk.stream,
chunk: chunk.chunk,
});
notifyProcessWaiters(managed);
}
function emitProcessClosed(managed: ManagedProcess, exitCode: number | null): void {
if (!managed.exited) {
const exitSeq = managed.nextSeq;
managed.nextSeq += 1;
managed.exitCode = exitCode;
managed.exited = true;
if (exitCode !== null) {
managed.emitNotification("process/exited", {
processId: managed.processId,
seq: exitSeq,
exitCode,
});
}
}
if (!managed.closed) {
const closeSeq = managed.nextSeq;
managed.nextSeq += 1;
managed.closed = true;
managed.emitNotification("process/closed", {
processId: managed.processId,
seq: closeSeq,
});
}
void finalizeProcess(managed).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
managed.failure ??= message;
embeddedAgentLog.warn("codex sandbox exec-server finalize failed", {
processId: managed.processId,
error: message,
});
});
managed.evictProcess();
notifyProcessWaiters(managed);
}
async function finalizeProcess(managed: ManagedProcess): Promise<void> {
if (managed.finalized) {
return;
}
managed.finalized = true;
managed.child?.stdin.destroy();
await managed.finalizeExec?.({
status: managed.failure ? "failed" : "completed",
exitCode: managed.exitCode,
timedOut: false,
token: managed.finalizeToken,
});
}
function limitProcessChunks(chunks: ProcessChunk[], maxBytes: number | undefined): ProcessChunk[] {
if (!maxBytes) {
return chunks;
}
const retained: ProcessChunk[] = [];
let retainedBytes = 0;
for (const chunk of chunks) {
const byteLength = Buffer.from(chunk.chunk, "base64").byteLength;
if (retained.length > 0 && retainedBytes + byteLength > maxBytes) {
break;
}
retained.push(chunk);
retainedBytes += byteLength;
if (retainedBytes >= maxBytes) {
break;
}
}
return retained;
}
export async function readProcess(
processes: Map<string, ManagedProcess>,
params: JsonValue | undefined,
): Promise<JsonObject> {
const record = requireObject(params, "process/read params");
const processId = requireString(record.processId, "processId");
const managed = requireProcess(processes, processId);
const afterSeq = typeof record.afterSeq === "number" ? record.afterSeq : 0;
const waitMs = typeof record.waitMs === "number" && record.waitMs > 0 ? record.waitMs : 0;
if (!managed.exited && !hasChunksAtOrAfter(managed, afterSeq) && waitMs > 0) {
await waitForProcessUpdate(managed, waitMs);
}
const chunks = limitProcessChunks(
managed.chunks.filter((chunk) => chunk.seq > afterSeq),
typeof record.maxBytes === "number" && record.maxBytes > 0 ? record.maxBytes : undefined,
);
const lastChunk = chunks.at(-1);
return {
chunks,
nextSeq: lastChunk ? lastChunk.seq + 1 : managed.nextSeq,
exited: managed.exited,
exitCode: managed.exitCode,
closed: managed.closed,
failure: managed.failure,
};
}
export function writeProcess(
processes: Map<string, ManagedProcess>,
params: JsonValue | undefined,
): JsonObject {
const record = requireObject(params, "process/write params");
const processId = requireString(record.processId, "processId");
const managed = processes.get(processId);
if (!managed) {
return { status: "unknownProcess" };
}
const chunk = Buffer.from(requireString(record.chunk, "chunk"), "base64");
if ((!managed.tty && !managed.pipeStdin) || managed.closed || !managed.child?.stdin.writable) {
return { status: "stdinClosed" };
}
managed.child.stdin.write(chunk);
return { status: "accepted" };
}
export function terminateProcess(
processes: Map<string, ManagedProcess>,
params: JsonValue | undefined,
): JsonObject {
const record = requireObject(params, "process/terminate params");
const processId = requireString(record.processId, "processId");
const managed = processes.get(processId);
if (!managed) {
return { running: false };
}
const running = !managed.exited;
managed.abortController.abort();
managed.child?.kill("SIGTERM");
if (running && !managed.child) {
emitProcessClosed(managed, null);
}
return { running };
}
function waitForProcessUpdate(managed: ManagedProcess, waitMs: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(done, Math.min(waitMs, 30_000));
function done() {
clearTimeout(timer);
managed.waiters = managed.waiters.filter((waiter) => waiter !== done);
resolve();
}
managed.waiters.push(done);
});
}
function notifyProcessWaiters(managed: ManagedProcess): void {
const waiters = managed.waiters;
managed.waiters = [];
for (const waiter of waiters) {
waiter();
}
}
function hasChunksAtOrAfter(managed: ManagedProcess, afterSeq: number): boolean {
return managed.chunks.some((chunk) => chunk.seq > afterSeq);
}
function shellCommandFromArgv(argv: string[]): string {
return argv.map(shellEscape).join(" ");
}
function shellEscape(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function requireProcess(processes: Map<string, ManagedProcess>, processId: string): ManagedProcess {
const managed = processes.get(processId);
if (!managed) {
throw new Error(`unknown process: ${processId}`);
}
return managed;
}
function rejectUnsupportedArg0(value: unknown): void {
if (value === undefined || value === null) {
return;
}
if (typeof value === "string") {
throw new Error("Codex sandbox exec-server does not support arg0 overrides.");
}
throw new Error("arg0 must be a string or null.");
}
function readEnv(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const env: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(value)) {
if (typeof rawValue === "string" && ENV_KEY_RE.test(key)) {
env[key] = rawValue;
}
}
return env;
}
function readProcessEnv(record: JsonObject): Record<string, string> {
const policyEnv = buildEnvFromPolicy(record.envPolicy);
return {
...policyEnv,
...readEnv(record.env),
};
}
function buildEnvFromPolicy(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const policy = value as Record<string, unknown>;
const inheritedEnv = readEnv(policy.set);
const includeOnly = readStringList(policy.includeOnly);
if (includeOnly.length > 0) {
filterEnvKeys(inheritedEnv, includeOnly, true);
}
return inheritedEnv;
}
function filterEnvKeys(
env: Record<string, string>,
patterns: string[],
keepMatches: boolean,
): void {
if (patterns.length === 0) {
return;
}
const regexes = patterns.map((pattern) => wildcardPatternToRegex(pattern));
for (const key of Object.keys(env)) {
const matches = regexes.some((regex) => regex.test(key));
if (matches !== keepMatches) {
delete env[key];
}
}
}
function wildcardPatternToRegex(pattern: string): RegExp {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/gu, "\\$&");
return new RegExp(`^${escaped.replaceAll("*", ".*").replaceAll("?", ".")}$`, "iu");
}
function readStringList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}

View File

@@ -0,0 +1,22 @@
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import type { OpenClawExecServer } from "./types.js";
export function requireBackend(
execServer: OpenClawExecServer,
): NonNullable<SandboxContext["backend"]> {
const backend = execServer.sandbox.backend;
if (!backend) {
throw new Error("OpenClaw sandbox backend is unavailable.");
}
return backend;
}
export function requireFsBridge(
execServer: OpenClawExecServer,
): NonNullable<SandboxContext["fsBridge"]> {
const fsBridge = execServer.sandbox.fsBridge;
if (!fsBridge) {
throw new Error("Sandbox filesystem bridge is unavailable.");
}
return fsBridge;
}

View File

@@ -0,0 +1,80 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
import type { WebSocketServer } from "ws";
import type { JsonObject, JsonValue } from "../protocol.js";
export type JsonRpcRequest = {
id?: string | number;
method?: string;
params?: JsonValue;
};
export type ProcessChunk = {
seq: number;
stream: "stdout" | "stderr" | "pty";
chunk: string;
};
export type DirectoryEntry = {
fileName: string;
isDirectory: boolean;
isFile: boolean;
};
export type FsAccessMode = "read" | "write" | "none";
export type ResolvedFsSandboxEntry =
| {
kind: "path";
path: string;
access: FsAccessMode;
}
| {
kind: "glob";
pattern: string;
matcher: RegExp;
literalPrefix: string;
access: FsAccessMode;
};
export type ResolvedFsSandboxPolicy = {
unrestricted: boolean;
entries: ResolvedFsSandboxEntry[];
};
export type HttpHeader = {
name: string;
value: string;
};
export type ManagedProcess = {
processId: string;
chunks: ProcessChunk[];
retainedOutputBytes: number;
nextSeq: number;
exited: boolean;
exitCode: number | null;
closed: boolean;
failure: string | null;
tty: boolean;
pipeStdin: boolean;
abortController: AbortController;
child: ChildProcessWithoutNullStreams | null;
finalizeToken?: unknown;
finalizeExec?: NonNullable<SandboxContext["backend"]>["finalizeExec"];
finalized: boolean;
evictionTimer?: ReturnType<typeof setTimeout>;
waiters: Array<() => void>;
emitNotification: (method: string, params: JsonObject) => void;
evictProcess: () => void;
};
export type OpenClawExecServer = {
environmentId: string;
authPath: string;
refCount: number;
closed: boolean;
url: string;
sandbox: SandboxContext;
server: WebSocketServer;
};

View File

@@ -0,0 +1,153 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveSandboxRuntimeStatus } from "openclaw/plugin-sdk/sandbox";
type DirectMethodPolicy =
| "allowed-control-plane"
| "blocked-native-bypass"
| "requires-openclaw-environment";
const DIRECT_METHOD_POLICIES = new Map<string, DirectMethodPolicy>([
["account/rateLimits/read", "allowed-control-plane"],
["account/read", "allowed-control-plane"],
["app/list", "allowed-control-plane"],
["config/mcpServer/reload", "allowed-control-plane"],
["environment/add", "allowed-control-plane"],
["experimentalFeature/enablement/set", "allowed-control-plane"],
["feedback/upload", "allowed-control-plane"],
["hooks/list", "allowed-control-plane"],
["initialize", "allowed-control-plane"],
["marketplace/add", "allowed-control-plane"],
["mcpServerStatus/list", "allowed-control-plane"],
["model/list", "allowed-control-plane"],
["plugin/install", "allowed-control-plane"],
["plugin/list", "allowed-control-plane"],
["plugin/read", "allowed-control-plane"],
["skills/list", "allowed-control-plane"],
["thread/archive", "allowed-control-plane"],
["thread/inject_items", "allowed-control-plane"],
["thread/list", "allowed-control-plane"],
["thread/metadata/update", "allowed-control-plane"],
["thread/name/update", "allowed-control-plane"],
["thread/read", "allowed-control-plane"],
["thread/rollback", "allowed-control-plane"],
["thread/start", "requires-openclaw-environment"],
["thread/unarchive", "allowed-control-plane"],
["thread/unsubscribe", "allowed-control-plane"],
["turn/interrupt", "allowed-control-plane"],
["turn/steer", "allowed-control-plane"],
["command/exec", "blocked-native-bypass"],
["command/resize", "blocked-native-bypass"],
["command/terminate", "blocked-native-bypass"],
["command/write", "blocked-native-bypass"],
["fuzzyFileSearch", "blocked-native-bypass"],
["mcpServer/resource/read", "blocked-native-bypass"],
["mcpServer/tool/call", "blocked-native-bypass"],
["process/kill", "blocked-native-bypass"],
["process/resizePty", "blocked-native-bypass"],
["process/spawn", "blocked-native-bypass"],
["process/writeStdin", "blocked-native-bypass"],
["review/start", "blocked-native-bypass"],
["thread/compact/start", "blocked-native-bypass"],
["thread/fork", "blocked-native-bypass"],
["thread/resume", "blocked-native-bypass"],
["thread/shellCommand", "blocked-native-bypass"],
["turn/start", "blocked-native-bypass"],
]);
const BLOCKED_DIRECT_METHOD_PREFIXES = ["command/", "fs/", "windowsSandbox/"] as const;
export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
method: string;
requestParams?: unknown;
config?: OpenClawConfig;
sessionKey?: string;
sessionId?: string;
}): string | undefined {
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim();
if (!sessionKey) {
return undefined;
}
const runtime = resolveSandboxRuntimeStatus({
cfg: params.config,
sessionKey,
});
if (!runtime.sandboxed) {
return undefined;
}
const policy = resolveDirectMethodPolicy(params.method);
if (policy === "allowed-control-plane") {
return undefined;
}
if (
policy === "requires-openclaw-environment" &&
hasOpenClawSandboxEnvironmentSelection(params.requestParams)
) {
return undefined;
}
return formatCodexNativeSandboxBlock({
surface: `app-server method \`${params.method}\``,
});
}
export function resolveCodexNativeSandboxBlock(params: {
config?: OpenClawConfig;
sessionKey?: string;
sessionId?: string;
surface: string;
}): string | undefined {
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim();
if (!sessionKey) {
return undefined;
}
const runtime = resolveSandboxRuntimeStatus({
cfg: params.config,
sessionKey,
});
if (!runtime.sandboxed) {
return undefined;
}
return formatCodexNativeSandboxBlock({ surface: params.surface });
}
function resolveDirectMethodPolicy(method: string): DirectMethodPolicy {
const exact = DIRECT_METHOD_POLICIES.get(method);
if (exact) {
return exact;
}
if (BLOCKED_DIRECT_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) {
return "blocked-native-bypass";
}
return "blocked-native-bypass";
}
function hasOpenClawSandboxEnvironmentSelection(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const environments = (value as { environments?: unknown }).environments;
return (
Array.isArray(environments) &&
environments.length > 0 &&
environments.every((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
const environment = entry as { environmentId?: unknown; cwd?: unknown };
return (
typeof environment.environmentId === "string" &&
environment.environmentId.startsWith("openclaw-sandbox-") &&
typeof environment.cwd === "string" &&
environment.cwd.trim().length > 0
);
})
);
}
function formatCodexNativeSandboxBlock(params: { surface: string }): string {
return [
`Codex-native ${params.surface} is unavailable because OpenClaw sandboxing is active for this session.`,
"This mode cannot route execution through the OpenClaw sandbox backend.",
"Use a normal Codex harness turn, or run an intentionally unsandboxed session.",
].join(" ");
}

View File

@@ -46,6 +46,7 @@ export type CodexAppServerThreadBinding = {
pluginAppsInputFingerprint?: string;
pluginAppPolicyContext?: PluginAppPolicyContext;
contextEngine?: CodexAppServerContextEngineBinding;
environmentSelectionFingerprint?: string;
createdAt: string;
updatedAt: string;
};
@@ -123,6 +124,10 @@ export async function readCodexAppServerBinding(
: undefined,
pluginAppPolicyContext: readPluginAppPolicyContext(parsed.pluginAppPolicyContext),
contextEngine: readContextEngineBinding(parsed.contextEngine),
environmentSelectionFingerprint:
typeof parsed.environmentSelectionFingerprint === "string"
? parsed.environmentSelectionFingerprint
: undefined,
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(),
};
@@ -165,6 +170,7 @@ export async function writeCodexAppServerBinding(
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: binding.contextEngine,
environmentSelectionFingerprint: binding.environmentSelectionFingerprint,
createdAt: binding.createdAt ?? now,
updatedAt: now,
};

View File

@@ -495,6 +495,21 @@ describe("runCodexAppServerSideQuestion", () => {
expect(result).toEqual({ text: "Nested answer." });
});
it("rejects /btw before forking when the current OpenClaw session is sandboxed", async () => {
await expect(
runCodexAppServerSideQuestion(
sideParams({
cfg: { agents: { defaults: { sandbox: { mode: "all" } } } } as never,
sessionKey: "sandboxed-session",
}),
),
).rejects.toThrow(
"Codex-native /btw side-question mode is unavailable because OpenClaw sandboxing is active for this session.",
);
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
});
it("installs native hook relay config for opted-in side threads", async () => {
const client = createFakeClient();
let relayIdDuringFork: string | undefined;

View File

@@ -59,6 +59,7 @@ import {
} from "./protocol.js";
import { rememberCodexRateLimits, readRecentCodexRateLimits } from "./rate-limit-cache.js";
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
import { resolveCodexNativeSandboxBlock } from "./sandbox-guard.js";
import { readCodexAppServerBinding } from "./session-binding.js";
import { getSharedCodexAppServerClient } from "./shared-client.js";
import {
@@ -125,6 +126,15 @@ export async function runCodexAppServerSideQuestion(
"Codex /btw needs an active Codex thread. Send a normal message first, then try /btw again.",
);
}
const sandboxBlock = resolveCodexNativeSandboxBlock({
config: params.cfg,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
surface: "/btw side-question mode",
});
if (sandboxBlock) {
throw new Error(sandboxBlock);
}
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });

View File

@@ -29,6 +29,7 @@ import {
type CodexSandboxPolicy,
type CodexThreadResumeParams,
type CodexThreadStartParams,
type CodexTurnEnvironmentParams,
type CodexTurnStartParams,
type JsonObject,
type CodexUserInput,
@@ -96,6 +97,7 @@ export async function startOrResumeThread(params: {
userMcpServersEnabled?: boolean;
mcpServersFingerprint?: string;
mcpServersFingerprintEvaluated?: boolean;
environmentSelection?: CodexTurnEnvironmentParams[];
pluginThreadConfig?: CodexPluginThreadConfigProvider;
contextEngineProjection?: CodexContextEngineThreadBootstrapProjection;
}): Promise<CodexAppServerThreadLifecycleBinding> {
@@ -111,6 +113,9 @@ export async function startOrResumeThread(params: {
agentId: params.agentId ?? params.params.agentId,
});
const userMcpServersFingerprint = fingerprintUserMcpServersConfigPatch(userMcpServersConfigPatch);
const environmentSelectionFingerprint = fingerprintEnvironmentSelection(
params.environmentSelection,
);
let binding = await readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
@@ -160,6 +165,19 @@ export async function startOrResumeThread(params: {
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
if (
binding?.threadId &&
binding.environmentSelectionFingerprint !== environmentSelectionFingerprint
) {
embeddedAgentLog.debug(
"codex app-server environment selection changed; starting a new thread",
{
threadId: binding.threadId,
},
);
await clearCodexAppServerBinding(params.params.sessionFile);
binding = undefined;
}
if (
binding?.threadId &&
params.mcpServersFingerprintEvaluated === true &&
@@ -296,6 +314,7 @@ export async function startOrResumeThread(params: {
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt: binding.createdAt,
},
{
@@ -329,6 +348,7 @@ export async function startOrResumeThread(params: {
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
lifecycle: { action: "resumed" },
};
} catch (error) {
@@ -363,6 +383,7 @@ export async function startOrResumeThread(params: {
config,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
environmentSelection: params.environmentSelection,
}),
),
);
@@ -392,6 +413,7 @@ export async function startOrResumeThread(params: {
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt,
},
{
@@ -427,6 +449,7 @@ export async function startOrResumeThread(params: {
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt,
updatedAt: createdAt,
lifecycle: {
@@ -563,6 +586,7 @@ export function buildThreadStartParams(
config?: JsonObject;
nativeCodeModeEnabled?: boolean;
nativeCodeModeOnlyEnabled?: boolean;
environmentSelection?: CodexTurnEnvironmentParams[];
},
): CodexThreadStartParams {
const modelProvider = resolveCodexAppServerModelProvider({
@@ -585,7 +609,7 @@ export function buildThreadStartParams(
nativeCodeModeEnabled: options.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
}),
...(options.nativeCodeModeEnabled === false ? { environments: [] } : {}),
...resolveCodexThreadEnvironmentSelection(options),
developerInstructions:
options.developerInstructions ??
buildDeveloperInstructions(params, { dynamicTools: options.dynamicTools }),
@@ -691,6 +715,7 @@ export function buildTurnStartParams(
appServer: CodexAppServerRuntimeOptions;
promptText?: string;
sandboxPolicy?: CodexSandboxPolicy;
environmentSelection?: CodexTurnEnvironmentParams[];
heartbeatCollaborationInstructions?: string;
},
): CodexTurnStartParams {
@@ -705,12 +730,26 @@ export function buildTurnStartParams(
model: params.modelId,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
effort: resolveReasoningEffort(params.thinkLevel, params.modelId),
...(options.environmentSelection ? { environments: options.environmentSelection } : {}),
collaborationMode: buildTurnCollaborationMode(params, {
heartbeatCollaborationInstructions: options.heartbeatCollaborationInstructions,
}),
};
}
function resolveCodexThreadEnvironmentSelection(options: {
nativeCodeModeEnabled?: boolean;
environmentSelection?: CodexTurnEnvironmentParams[];
}): Pick<CodexThreadStartParams, "environments"> {
if (options.nativeCodeModeEnabled === false) {
return { environments: [] };
}
if (options.environmentSelection) {
return { environments: options.environmentSelection };
}
return {};
}
type CodexTurnCollaborationMode = NonNullable<CodexTurnStartParams["collaborationMode"]>;
export function buildTurnCollaborationMode(
@@ -787,6 +826,12 @@ function fingerprintUserMcpServersConfigPatch(
return configPatch ? JSON.stringify(stabilizeJsonValue(configPatch)) : undefined;
}
function fingerprintEnvironmentSelection(
environments: CodexTurnEnvironmentParams[] | undefined,
): string | undefined {
return environments ? JSON.stringify(environments.map(stabilizeJsonValue)) : undefined;
}
function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue {
if (!isJsonObject(tool)) {
return stabilizeJsonValue(tool);

View File

@@ -1,4 +1,5 @@
export const MIN_CODEX_APP_SERVER_VERSION = "0.125.0";
export const MIN_CODEX_SANDBOX_EXEC_SERVER_APP_SERVER_VERSION = "0.132.0";
export const MANAGED_CODEX_APP_SERVER_PACKAGE = "@openai/codex";
// Keep this in sync with the Codex CLI live-test package pin.
export const MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION = "0.132.0";

View File

@@ -11,6 +11,7 @@ import { isCodexFastServiceTier, type CodexComputerUseConfig } from "./app-serve
import { listAllCodexAppServerModels } from "./app-server/models.js";
import { isJsonObject, type JsonValue } from "./app-server/protocol.js";
import { rememberCodexRateLimits } from "./app-server/rate-limit-cache.js";
import { resolveCodexNativeSandboxBlock } from "./app-server/sandbox-guard.js";
import {
clearCodexAppServerBinding,
readCodexAppServerBinding,
@@ -210,6 +211,16 @@ const CODEX_DIAGNOSTICS_CONFIRMATION_MAX_REQUESTS_PER_SCOPE = 100;
const CODEX_DIAGNOSTICS_CONFIRMATION_MAX_SCOPES = 100;
const CODEX_DIAGNOSTICS_SCOPE_FIELD_MAX_CHARS = 128;
const CODEX_RESUME_SAFE_THREAD_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;
const CODEX_NATIVE_EXECUTION_SUBCOMMANDS = new Set([
"bind",
"resume",
"steer",
"model",
"fast",
"permissions",
"compact",
"review",
]);
const lastCodexDiagnosticsUploadByThread = new Map<string, number>();
const lastCodexDiagnosticsUploadByScope = new Map<string, number>();
@@ -233,6 +244,10 @@ export async function handleCodexSubcommand(
if (normalized === "help") {
return { text: buildHelp() };
}
const sandboxBlock = resolveCodexNativeCommandSandboxBlock(ctx, normalized, rest);
if (sandboxBlock) {
return { text: sandboxBlock };
}
if (normalized === "plugins") {
if (!deps.codexPluginsManagementIo) {
return {
@@ -401,6 +416,62 @@ export async function handleCodexSubcommand(
return { text: `Unknown Codex command: ${formatCodexDisplayText(subcommand)}\n\n${buildHelp()}` };
}
function resolveCodexNativeCommandSandboxBlock(
ctx: PluginCommandContext,
subcommand: string,
args: readonly string[],
): string | undefined {
if (!CODEX_NATIVE_EXECUTION_SUBCOMMANDS.has(subcommand)) {
return undefined;
}
if (returnsBeforeNativeCodexExecution(subcommand, args)) {
return undefined;
}
return resolveCodexNativeSandboxBlock({
config: ctx.config,
sessionKey: ctx.sessionKey,
sessionId: ctx.sessionId,
surface: `/${["codex", subcommand].join(" ")}`,
});
}
function returnsBeforeNativeCodexExecution(subcommand: string, args: readonly string[]): boolean {
switch (subcommand) {
case "bind":
return parseBindArgs([...args]).help === true;
case "resume":
return returnsBeforeNativeCodexResume(args);
case "steer":
return args.join(" ").trim() === "";
case "model":
return args.length === 0 || args.length > 1;
case "fast":
return args.length === 0 || args.length > 1 || parseCodexFastModeArg(args[0]) === undefined;
case "permissions":
return (
args.length === 0 || args.length > 1 || parseCodexPermissionsModeArg(args[0]) === undefined
);
case "compact":
case "review":
case "stop":
return args.length > 0;
default:
return false;
}
}
function returnsBeforeNativeCodexResume(args: readonly string[]): boolean {
const parsed = parseResumeArgs([...args]);
const normalizedThreadId = parsed.threadId?.trim();
if (parsed.help) {
return true;
}
if (parsed.host) {
return !normalizedThreadId || parsed.bindHere !== true;
}
return !normalizedThreadId || args.length !== 1;
}
async function handleComputerUseCommand(
deps: CodexCommandDeps,
pluginConfig: unknown,

View File

@@ -24,6 +24,8 @@ export type CodexControlRequestOptions = {
config?: AuthProfileOrderConfig;
authProfileId?: string;
agentDir?: string;
sessionKey?: string;
sessionId?: string;
isolated?: boolean;
};
@@ -68,6 +70,8 @@ export async function codexControlRequest(
timeoutMs: runtime.requestTimeoutMs,
startOptions: runtime.start,
config: options.config,
sessionKey: options.sessionKey,
sessionId: options.sessionId,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
isolated: options.isolated,

View File

@@ -51,6 +51,18 @@ function createContext(
};
}
function createSandboxedContext(
args: string,
sessionFile?: string,
overrides: Partial<PluginCommandContext> = {},
): PluginCommandContext {
return createContext(args, sessionFile, {
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
sessionKey: "sandboxed-session",
...overrides,
} as Partial<PluginCommandContext>);
}
function createDeps(overrides: Partial<CodexCommandDeps> = {}): Partial<CodexCommandDeps> {
return {
codexControlRequest: vi.fn(),
@@ -331,6 +343,147 @@ describe("codex command", () => {
expect(writeCodexAppServerBinding).not.toHaveBeenCalled();
});
it.each([
"bind",
"resume thread-123",
"steer keep going",
"steer keep going --help",
"model --help",
"model gpt-5.5",
"fast on",
"permissions yolo",
"compact",
"review",
])("blocks /codex %s in sandboxed sessions before native Codex execution", async (args) => {
const sessionFile = path.join(tempDir, "session.jsonl");
const codexControlRequest = vi.fn();
const startCodexConversationThread = vi.fn();
const steerCodexConversationTurn = vi.fn();
const setCodexConversationModel = vi.fn();
const setCodexConversationFastMode = vi.fn();
const setCodexConversationPermissions = vi.fn();
const stopCodexConversationTurn = vi.fn();
const result = await handleCodexCommand(createSandboxedContext(args, sessionFile), {
deps: createDeps({
codexControlRequest,
startCodexConversationThread,
steerCodexConversationTurn,
setCodexConversationModel,
setCodexConversationFastMode,
setCodexConversationPermissions,
stopCodexConversationTurn,
}),
});
expect(result.text).toContain(
"Codex-native /codex " +
args.split(/\s+/u)[0] +
" is unavailable because OpenClaw sandboxing is active for this session.",
);
expect(codexControlRequest).not.toHaveBeenCalled();
expect(startCodexConversationThread).not.toHaveBeenCalled();
expect(steerCodexConversationTurn).not.toHaveBeenCalled();
expect(setCodexConversationModel).not.toHaveBeenCalled();
expect(setCodexConversationFastMode).not.toHaveBeenCalled();
expect(setCodexConversationPermissions).not.toHaveBeenCalled();
expect(stopCodexConversationTurn).not.toHaveBeenCalled();
});
it("still returns pre-native usage for malformed sandboxed native Codex commands", async () => {
const startCodexConversationThread = vi.fn();
const setCodexConversationModel = vi.fn();
await expect(
handleCodexCommand(createSandboxedContext("bind --help"), {
deps: createDeps({ startCodexConversationThread }),
}),
).resolves.toEqual({
text: "Usage: /codex bind [thread-id] [--cwd <path>] [--model <model>] [--provider <provider>]",
});
expect(startCodexConversationThread).not.toHaveBeenCalled();
await expect(
handleCodexCommand(createSandboxedContext("model gpt-5.5 --help"), {
deps: createDeps({ setCodexConversationModel }),
}),
).resolves.toEqual({
text: "Usage: /codex model <model>",
});
expect(setCodexConversationModel).not.toHaveBeenCalled();
await expect(
handleCodexCommand(createSandboxedContext("resume"), { deps: createDeps() }),
).resolves.toEqual({
text: "Usage: /codex resume <thread-id>",
});
const resolveCodexCliSessionForBindingOnNode = vi.fn();
await expect(
handleCodexCommand(createSandboxedContext("resume cli-1 --host node-1"), {
deps: createDeps({ resolveCodexCliSessionForBindingOnNode }),
}),
).resolves.toEqual({
text: "Usage: /codex resume <session-id> --host <node> --bind here",
});
expect(resolveCodexCliSessionForBindingOnNode).not.toHaveBeenCalled();
await expect(
handleCodexCommand(createSandboxedContext("resume cli-1 --host node-1 --bind here extra"), {
deps: createDeps({ resolveCodexCliSessionForBindingOnNode }),
}),
).resolves.toEqual({
text: "Usage: /codex resume <thread-id>\nUsage: /codex resume <session-id> --host <node> --bind here",
});
expect(resolveCodexCliSessionForBindingOnNode).not.toHaveBeenCalled();
await expect(
handleCodexCommand(createSandboxedContext("steer", path.join(tempDir, "session.jsonl")), {
deps: createDeps(),
}),
).resolves.toEqual({
text: "Usage: /codex steer <message>",
});
await expect(
handleCodexCommand(createSandboxedContext("stop now"), {
deps: createDeps({ stopCodexConversationTurn: vi.fn() }),
}),
).resolves.toEqual({
text: "Usage: /codex stop",
});
});
it("allows local Codex binding status forms in sandboxed sessions", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({
schemaVersion: 1,
threadId: "thread-status",
cwd: tempDir,
model: "gpt-5.5",
approvalPolicy: "never",
sandbox: "danger-full-access",
serviceTier: "priority",
}),
);
await expect(
handleCodexCommand(createSandboxedContext("model", sessionFile), { deps: createDeps() }),
).resolves.toEqual({ text: "Codex model: gpt-5.5" });
await expect(
handleCodexCommand(createSandboxedContext("fast status", sessionFile), {
deps: createDeps(),
}),
).resolves.toEqual({ text: "Codex fast mode: on." });
await expect(
handleCodexCommand(createSandboxedContext("permissions status", sessionFile), {
deps: createDeps(),
}),
).resolves.toEqual({ text: "Codex permissions: full access." });
});
it("lists Codex CLI sessions from a requested node", async () => {
const listCodexCliSessionsOnNode = vi.fn(async () => ({
node: { nodeId: "mb-m5", displayName: "mb-m5" },
@@ -3248,6 +3401,26 @@ describe("codex command", () => {
});
});
it("stops the active bound Codex turn in sandboxed sessions", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const stopCodexConversationTurn = vi.fn(async () => ({
stopped: true,
message: "Codex stop requested.",
}));
await expect(
handleCodexCommand(createSandboxedContext("stop", sessionFile), {
deps: createDeps({ stopCodexConversationTurn }),
}),
).resolves.toEqual({ text: "Codex stop requested." });
expect(stopCodexConversationTurn).toHaveBeenCalledWith({
sessionFile,
pluginConfig: undefined,
agentDir: path.join(tempDir, "agents", "main", "agent"),
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
});
});
it("rejects malformed stop commands before interrupting Codex", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const stopCodexConversationTurn = vi.fn();

View File

@@ -28,7 +28,7 @@ export function createCodexCommand(options: CodexCommandOptions): OpenClawPlugin
ownership: "reserved",
agentPromptGuidance: [
{
text: "Native Codex app-server plugin is available (`/codex ...`). For Codex bind/control/thread/resume/steer/stop requests, prefer `/codex bind`, `/codex threads`, `/codex resume`, `/codex steer`, and `/codex stop` over ACP.",
text: "Native Codex app-server plugin is available (`/codex ...`). For Codex bind/control/thread/resume/steer/stop requests, prefer `/codex bind`, `/codex threads`, `/codex resume`, `/codex steer`, and `/codex stop` over ACP. When OpenClaw sandboxing is active, native Codex execution modes are unavailable; use normal Codex harness turns.",
surfaces: ["pi_main"],
},
{

View File

@@ -307,6 +307,104 @@ describe("codex conversation binding", () => {
});
});
it("blocks bound Codex app-server turns when the current OpenClaw session is sandboxed", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
await fs.writeFile(
`${sessionFile}.codex-app-server.json`,
JSON.stringify({ schemaVersion: 1, threadId: "thread-1", cwd: tempDir }),
);
const result = await handleCodexConversationInboundClaim(
{
content: "continue the task",
channel: "discord",
isGroup: true,
commandAuthorized: true,
sessionKey: "sandboxed-session",
},
{
channelId: "discord",
sessionKey: "sandboxed-session",
pluginBinding: {
bindingId: "binding-1",
pluginId: "codex",
pluginRoot: tempDir,
channel: "discord",
accountId: "default",
conversationId: "channel-1",
boundAt: Date.now(),
data: {
kind: "codex-app-server-session",
version: 1,
sessionFile,
workspaceDir: tempDir,
},
},
},
{
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
},
);
expect(result).toEqual({
handled: true,
reply: {
text: expect.stringContaining(
"Codex-native Codex app-server conversation binding is unavailable because OpenClaw sandboxing is active for this session.",
),
},
});
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
});
it("blocks bound Codex CLI node turns when the current OpenClaw session is sandboxed", async () => {
const resumeCodexCliSessionOnNode = vi.fn();
const result = await handleCodexConversationInboundClaim(
{
content: "continue the task",
channel: "discord",
isGroup: true,
commandAuthorized: true,
sessionKey: "sandboxed-session",
},
{
channelId: "discord",
sessionKey: "sandboxed-session",
pluginBinding: {
bindingId: "binding-1",
pluginId: "codex",
pluginRoot: tempDir,
channel: "discord",
accountId: "default",
conversationId: "channel-1",
boundAt: Date.now(),
data: {
kind: "codex-cli-node-session",
version: 1,
nodeId: "mb-m5",
sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
cwd: "/repo",
},
},
},
{
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
resumeCodexCliSessionOnNode,
},
);
expect(result).toEqual({
handled: true,
reply: {
text: expect.stringContaining(
"Codex-native Codex CLI node conversation binding is unavailable because OpenClaw sandboxing is active for this session.",
),
},
});
expect(resumeCodexCliSessionOnNode).not.toHaveBeenCalled();
});
it("recreates a missing bound thread and preserves auth plus turn overrides", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({

View File

@@ -1,4 +1,5 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
PluginConversationBindingResolvedEvent,
PluginHookInboundClaimContext,
@@ -20,6 +21,7 @@ import {
type CodexTurnStartResponse,
type JsonValue,
} from "./app-server/protocol.js";
import { resolveCodexNativeSandboxBlock } from "./app-server/sandbox-guard.js";
import {
clearCodexAppServerBinding,
isCodexAppServerNativeAuthProfile,
@@ -52,6 +54,7 @@ export {
type CodexConversationRunOptions = {
pluginConfig?: unknown;
config?: OpenClawConfig;
timeoutMs?: number;
resumeCodexCliSessionOnNode?: ResumeCodexCliSessionOnNodeFn;
};
@@ -160,6 +163,17 @@ export async function handleCodexConversationInboundClaim(
if (!prompt) {
return { handled: true };
}
const sandboxBlock = resolveCodexNativeSandboxBlock({
config: options.config,
sessionKey: event.sessionKey ?? ctx.sessionKey,
surface:
data.kind === "codex-cli-node-session"
? "Codex CLI node conversation binding"
: "Codex app-server conversation binding",
});
if (sandboxBlock) {
return { handled: true, reply: { text: sandboxBlock } };
}
if (data.kind === "codex-cli-node-session") {
const resume = options.resumeCodexCliSessionOnNode;
if (!resume) {

View File

@@ -12,6 +12,7 @@ const ALLOWED_PATCHED_DEPENDENCIES = new Map([
"patches/@agentclientprotocol__claude-agent-acp@0.36.1.patch",
],
["baileys@7.0.0-rc12", "patches/baileys@7.0.0-rc12.patch"],
["baileys@7.0.0-rc13", "patches/baileys@7.0.0-rc13.patch"],
]);
const ALLOWED_PATCH_FILES = new Set(["patches/.gitkeep", ...ALLOWED_PATCHED_DEPENDENCIES.values()]);

View File

@@ -32,7 +32,7 @@ export {
resolveSandboxRuntimeStatus,
} from "./sandbox/runtime-status.js";
export { resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js";
export { isToolAllowed, resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js";
export type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./sandbox/fs-bridge.js";
export {
buildExecRemoteCommand,

View File

@@ -32,8 +32,10 @@ export {
disposeSshSandboxSession,
getSandboxBackendFactory,
getSandboxBackendManager,
isToolAllowed,
registerSandboxBackend,
requireSandboxBackendFactory,
resolveSandboxRuntimeStatus,
resolveWritableRenameTargets,
resolveWritableRenameTargetsForBridge,
runSshSandboxCommand,