mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-15 17:30:45 +00:00
fix: respect Codex requirements for app-server defaults (#79151)
* fix(codex): honor requirements for app-server defaults * test(codex): harden requirements policy coverage * fix(codex): match requirements sandbox constraints * fix(codex): honor approval requirements in defaults * fix(codex): honor reviewer requirements in defaults * fix(codex): honor remote sandbox requirements
This commit is contained in:
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Runtime/install: raise the supported Node 22 floor to `22.16+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921)
|
||||
- Discord/voice: include a bounded one-line STT transcript preview in verbose voice logs so live voice debugging shows what speakers said before the agent reply.
|
||||
- Codex app-server: pin the managed Codex harness and Codex CLI smoke package to `@openai/codex@0.129.0`, defer OpenClaw integration dynamic tools behind Codex tool search by default, and accept current Codex service-tier values so legacy `fast` settings survive the stable harness upgrade as `priority`.
|
||||
- Codex app-server: default implicit local stdio app-server permissions to guardian when Codex system requirements disallow the YOLO approval, reviewer, or sandbox value, including hostname-scoped remote sandbox entries, avoiding turn-start failures on managed hosts that permit only reviewed approval or narrower sandboxes.
|
||||
- Discord/voice: stream ElevenLabs TTS directly into Discord playback and send ElevenLabs latency optimization as the documented query parameter so spoken replies can start sooner.
|
||||
- Discord/voice: keep TTS playback running when another user starts speaking, ignore new capture during playback to avoid feedback loops, and downgrade expected receive-stream aborts to verbose diagnostics.
|
||||
- Telegram: treat successful same-chat `message` tool outbound sends during an inbound telegram turn as delivered when deciding whether to emit the rewritten silent reply fallback (#78685). Thanks @neeravmakwana.
|
||||
|
||||
@@ -484,7 +484,13 @@ By default, OpenClaw starts local Codex harness sessions in YOLO mode:
|
||||
`approvalPolicy: "never"`, `approvalsReviewer: "user"`, and
|
||||
`sandbox: "danger-full-access"`. This is the trusted local operator posture used
|
||||
for autonomous heartbeats: Codex can use shell and network tools without
|
||||
stopping on native approval prompts that nobody is around to answer.
|
||||
stopping on native approval prompts that nobody is around to answer. On local
|
||||
stdio Codex app-server installs where Codex's system requirements file
|
||||
disallows the implicit YOLO approval, reviewer, or sandbox value, OpenClaw
|
||||
treats the implicit default as guardian instead and selects allowed guardian
|
||||
permissions so it does not send an override that Codex app-server will reject.
|
||||
Hostname-matching `[[remote_sandbox_config]]` entries in the same requirements
|
||||
file are honored for the sandbox default decision.
|
||||
|
||||
To opt in to Codex guardian-reviewed approvals, set `appServer.mode:
|
||||
"guardian"`:
|
||||
@@ -635,22 +641,22 @@ 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. `CODEX_HOME` and `HOME` are reserved for OpenClaw's per-agent Codex isolation on local launches. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after a turn-scoped Codex app-server request while OpenClaw waits for `turn/completed`. Raise this for slow post-tool or status-only synthesis phases. |
|
||||
| `mode` | `"yolo"` | Preset for YOLO or guardian-reviewed execution. |
|
||||
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
|
||||
| `sandbox` | `"danger-full-access"` | Native Codex sandbox mode sent to thread start/resume. |
|
||||
| `approvalsReviewer` | `"user"` | Use `"auto_review"` to let Codex review native approval prompts. `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. `CODEX_HOME` and `HOME` are reserved for OpenClaw's per-agent Codex isolation on local launches. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after a turn-scoped Codex 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"`. |
|
||||
| `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"`. |
|
||||
|
||||
OpenClaw-owned dynamic tool calls are bounded independently from
|
||||
`appServer.requestTimeoutMs`: each Codex `item/tool/call` request must receive
|
||||
|
||||
@@ -12,9 +12,15 @@ import {
|
||||
resolveCodexPluginsPolicy,
|
||||
} from "./config.js";
|
||||
|
||||
type RuntimeOptionsParams = NonNullable<Parameters<typeof resolveCodexAppServerRuntimeOptions>[0]>;
|
||||
|
||||
function resolveRuntimeForTest(params: RuntimeOptionsParams = {}) {
|
||||
return resolveCodexAppServerRuntimeOptions({ env: {}, requirementsToml: null, ...params });
|
||||
}
|
||||
|
||||
describe("Codex app-server config", () => {
|
||||
it("parses typed plugin config before falling back to environment knobs", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
@@ -51,7 +57,7 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
|
||||
it("ignores app-server environment clearing for websocket transports", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
@@ -66,7 +72,7 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
|
||||
it("normalizes app-server environment variables to clear", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
clearEnv: [" OPENAI_API_KEY ", "", " "],
|
||||
@@ -83,7 +89,7 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
|
||||
it("normalizes legacy service tiers without discarding the rest of the config", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
@@ -130,7 +136,7 @@ describe("Codex app-server config", () => {
|
||||
|
||||
it("requires a websocket url when websocket transport is configured", () => {
|
||||
expect(() =>
|
||||
resolveCodexAppServerRuntimeOptions({
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: { appServer: { transport: "websocket" } },
|
||||
env: {},
|
||||
}),
|
||||
@@ -138,9 +144,8 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
|
||||
it("defaults native Codex approvals to unchained local execution", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
@@ -156,6 +161,298 @@ describe("Codex app-server config", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults native Codex approvals to guardian when requirements disallow full access", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
requirementsToml: 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n',
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses read-only sandbox for guardian defaults when requirements only allow read-only", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
requirementsToml: 'allowed_sandbox_modes = ["read-only"]\n',
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "read-only",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults native Codex approvals to guardian when requirements disallow never approval", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
requirementsToml: 'allowed_approval_policies = ["on-request"]\n',
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("selects an allowed guardian approval policy when on-request is unavailable", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
requirementsToml: 'allowed_approval_policies = ["on-failure"]\n',
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-failure",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps native Codex approvals unchained when requirements allow never approval", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
requirementsToml: 'allowed_approval_policies = ["never"]\n',
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults native Codex approvals to guardian when requirements disallow user reviewer", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
requirementsToml: 'allowed_approvals_reviewers = ["auto_review"]\n',
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("selects an allowed reviewer when sandbox requirements force guardian defaults", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
requirementsToml:
|
||||
'allowed_sandbox_modes = ["read-only", "workspace-write"]\nallowed_approvals_reviewers = ["user"]\n',
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores quoted sandbox modes inside requirements comments", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
requirementsToml: `allowed_sandbox_modes = [
|
||||
"read-only",
|
||||
# "danger-full-access",
|
||||
"workspace-write",
|
||||
]
|
||||
`,
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the first matching remote sandbox requirements before resolving local stdio defaults", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
hostName: "BUILD-01.EXAMPLE.COM.",
|
||||
requirementsToml: `[[remote_sandbox_config]]
|
||||
hostname_patterns = ["build-*.example.com"]
|
||||
allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
|
||||
[[remote_sandbox_config]]
|
||||
hostname_patterns = ["build-01.example.com"]
|
||||
allowed_sandbox_modes = ["read-only", "danger-full-access"]
|
||||
`,
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores non-matching remote-only sandbox requirements when resolving local stdio defaults", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
hostName: "laptop.example.com",
|
||||
requirementsToml: `[[remote_sandbox_config]]
|
||||
hostname_patterns = ["build-*.example.com"]
|
||||
allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
`,
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads local requirements policy from the configured requirements path", () => {
|
||||
const readPaths: string[] = [];
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: {},
|
||||
env: {},
|
||||
requirementsPath: "/custom/codex/requirements.toml",
|
||||
readRequirementsFile: (path) => {
|
||||
readPaths.push(path);
|
||||
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
|
||||
},
|
||||
});
|
||||
|
||||
expect(readPaths).toEqual(["/custom/codex/requirements.toml"]);
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads local requirements policy from the Codex Windows requirements path", () => {
|
||||
const readPaths: string[] = [];
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: {},
|
||||
env: { ProgramData: "D:\\ManagedData" },
|
||||
platform: "win32",
|
||||
readRequirementsFile: (path) => {
|
||||
readPaths.push(path);
|
||||
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
|
||||
},
|
||||
});
|
||||
|
||||
expect(readPaths).toEqual(["D:\\ManagedData\\OpenAI\\Codex\\requirements.toml"]);
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
approvalsReviewer: "auto_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps native Codex approvals unchained when requirements allow full access", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
requirementsToml:
|
||||
'allowed_sandbox_modes = ["ReadOnly", "WorkspaceWrite", "DangerFullAccess"]\n',
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps native Codex approvals unchained when requirements are malformed", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
requirementsToml: "allowed_sandbox_modes = [read-only]\n",
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply local requirements policy to websocket app-server transports", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "ws://127.0.0.1:39175",
|
||||
},
|
||||
},
|
||||
requirementsToml: 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n',
|
||||
});
|
||||
|
||||
expect(runtime).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit yolo mode when requirements disallow full access", () => {
|
||||
const requirementsToml = 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
|
||||
expect(
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: { appServer: { mode: "yolo" } },
|
||||
requirementsToml,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
env: { OPENCLAW_CODEX_APP_SERVER_MODE: "yolo" },
|
||||
requirementsToml,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
approvalsReviewer: "user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses dynamic tool profile controls", () => {
|
||||
expect(
|
||||
readCodexPluginConfig({
|
||||
@@ -237,7 +534,7 @@ describe("Codex app-server config", () => {
|
||||
|
||||
it("treats configured and environment commands as explicit overrides", () => {
|
||||
expect(
|
||||
resolveCodexAppServerRuntimeOptions({
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: { appServer: { command: "/opt/codex/bin/codex" } },
|
||||
env: { OPENCLAW_CODEX_APP_SERVER_BIN: "/usr/local/bin/codex" },
|
||||
}).start,
|
||||
@@ -249,7 +546,7 @@ describe("Codex app-server config", () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
resolveCodexAppServerRuntimeOptions({
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
env: { OPENCLAW_CODEX_APP_SERVER_BIN: "/usr/local/bin/codex" },
|
||||
}).start,
|
||||
@@ -304,7 +601,7 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
|
||||
it("allows plugin config to opt in to guardian-reviewed local execution", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
@@ -323,7 +620,7 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
|
||||
it("allows environment mode fallback to opt in to guardian-reviewed local execution", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
env: { OPENCLAW_CODEX_APP_SERVER_MODE: "guardian" },
|
||||
});
|
||||
@@ -339,13 +636,13 @@ describe("Codex app-server config", () => {
|
||||
|
||||
it("accepts the latest auto_review reviewer and legacy guardian_subagent alias", () => {
|
||||
expect(
|
||||
resolveCodexAppServerRuntimeOptions({
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: { appServer: { approvalsReviewer: "auto_review" } },
|
||||
env: {},
|
||||
}).approvalsReviewer,
|
||||
).toBe("auto_review");
|
||||
expect(
|
||||
resolveCodexAppServerRuntimeOptions({
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: { appServer: { approvalsReviewer: "guardian_subagent" } },
|
||||
env: {},
|
||||
}).approvalsReviewer,
|
||||
@@ -353,7 +650,7 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
|
||||
it("ignores removed OPENCLAW_CODEX_APP_SERVER_GUARDIAN fallback", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {},
|
||||
env: { OPENCLAW_CODEX_APP_SERVER_GUARDIAN: "1" },
|
||||
});
|
||||
@@ -368,7 +665,7 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
|
||||
it("lets explicit policy fields override guardian mode", () => {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { hostname as readHostName } from "node:os";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
|
||||
|
||||
const START_OPTIONS_KEY_SECRET = randomBytes(32);
|
||||
const UNIX_CODEX_REQUIREMENTS_PATH = "/etc/codex/requirements.toml";
|
||||
const WINDOWS_CODEX_REQUIREMENTS_SUFFIX = "\\OpenAI\\Codex\\requirements.toml";
|
||||
|
||||
type CodexAppServerTransportMode = "stdio" | "websocket";
|
||||
type CodexAppServerPolicyMode = "yolo" | "guardian";
|
||||
type CodexAppServerDefaultPolicy = {
|
||||
mode: CodexAppServerPolicyMode;
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
approvalsReviewer?: CodexAppServerApprovalsReviewer;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
};
|
||||
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
|
||||
export type CodexAppServerEffectiveApprovalPolicy =
|
||||
| CodexAppServerApprovalPolicy
|
||||
@@ -305,6 +315,11 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
params: {
|
||||
pluginConfig?: unknown;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requirementsToml?: string | null;
|
||||
requirementsPath?: string;
|
||||
readRequirementsFile?: (path: string) => string | undefined;
|
||||
platform?: NodeJS.Platform;
|
||||
hostName?: string;
|
||||
} = {},
|
||||
): CodexAppServerRuntimeOptions {
|
||||
const env = params.env ?? process.env;
|
||||
@@ -323,10 +338,20 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
const clearEnv = normalizeStringList(config.clearEnv);
|
||||
const authToken = readNonEmptyString(config.authToken);
|
||||
const url = readNonEmptyString(config.url);
|
||||
const policyMode =
|
||||
resolvePolicyMode(config.mode) ??
|
||||
resolvePolicyMode(env.OPENCLAW_CODEX_APP_SERVER_MODE) ??
|
||||
"yolo";
|
||||
const explicitPolicyMode =
|
||||
resolvePolicyMode(config.mode) ?? resolvePolicyMode(env.OPENCLAW_CODEX_APP_SERVER_MODE);
|
||||
const defaultPolicy = explicitPolicyMode
|
||||
? undefined
|
||||
: resolveDefaultCodexAppServerPolicy({
|
||||
transport,
|
||||
env,
|
||||
requirementsToml: params.requirementsToml,
|
||||
requirementsPath: params.requirementsPath,
|
||||
readRequirementsFile: params.readRequirementsFile,
|
||||
platform: params.platform,
|
||||
hostName: params.hostName,
|
||||
});
|
||||
const policyMode = explicitPolicyMode ?? defaultPolicy?.mode ?? "yolo";
|
||||
const serviceTier = normalizeCodexServiceTier(config.serviceTier);
|
||||
if (transport === "websocket" && !url) {
|
||||
throw new Error(
|
||||
@@ -353,13 +378,16 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
approvalPolicy:
|
||||
resolveApprovalPolicy(config.approvalPolicy) ??
|
||||
resolveApprovalPolicy(env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY) ??
|
||||
defaultPolicy?.approvalPolicy ??
|
||||
(policyMode === "guardian" ? "on-request" : "never"),
|
||||
sandbox:
|
||||
resolveSandbox(config.sandbox) ??
|
||||
resolveSandbox(env.OPENCLAW_CODEX_APP_SERVER_SANDBOX) ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
|
||||
approvalsReviewer:
|
||||
resolveApprovalsReviewer(config.approvalsReviewer) ??
|
||||
defaultPolicy?.approvalsReviewer ??
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
};
|
||||
@@ -502,6 +530,333 @@ function resolvePolicyMode(value: unknown): CodexAppServerPolicyMode | undefined
|
||||
return value === "guardian" || value === "yolo" ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveDefaultCodexAppServerPolicy(params: {
|
||||
transport: CodexAppServerTransportMode;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requirementsToml?: string | null;
|
||||
requirementsPath?: string;
|
||||
readRequirementsFile?: (path: string) => string | undefined;
|
||||
platform?: NodeJS.Platform;
|
||||
hostName?: string;
|
||||
}): CodexAppServerDefaultPolicy {
|
||||
if (params.transport !== "stdio") {
|
||||
return { mode: "yolo" };
|
||||
}
|
||||
const content = readCodexRequirementsToml(params);
|
||||
if (content === undefined) {
|
||||
return { mode: "yolo" };
|
||||
}
|
||||
const allowedSandboxModes = parseAllowedSandboxModesFromCodexRequirements(
|
||||
content,
|
||||
readNonEmptyString(params.hostName) ?? readHostName(),
|
||||
);
|
||||
const allowedApprovalPolicies = parseAllowedApprovalPoliciesFromCodexRequirements(content);
|
||||
const allowedApprovalsReviewers = parseAllowedApprovalsReviewersFromCodexRequirements(content);
|
||||
const yoloSandboxAllowed =
|
||||
allowedSandboxModes === undefined || allowedSandboxModes.has("danger-full-access");
|
||||
const yoloApprovalAllowed =
|
||||
allowedApprovalPolicies === undefined || allowedApprovalPolicies.has("never");
|
||||
const yoloReviewerAllowed =
|
||||
allowedApprovalsReviewers === undefined || allowedApprovalsReviewers.has("user");
|
||||
if (yoloSandboxAllowed && yoloApprovalAllowed && yoloReviewerAllowed) {
|
||||
return { mode: "yolo" };
|
||||
}
|
||||
return {
|
||||
mode: "guardian",
|
||||
approvalPolicy: selectGuardianApprovalPolicy(allowedApprovalPolicies),
|
||||
approvalsReviewer: selectGuardianApprovalsReviewer(allowedApprovalsReviewers),
|
||||
sandbox: selectGuardianSandbox(allowedSandboxModes),
|
||||
};
|
||||
}
|
||||
|
||||
function readCodexRequirementsToml(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requirementsToml?: string | null;
|
||||
requirementsPath?: string;
|
||||
readRequirementsFile?: (path: string) => string | undefined;
|
||||
platform?: NodeJS.Platform;
|
||||
}): string | undefined {
|
||||
if (params.requirementsToml !== undefined) {
|
||||
return params.requirementsToml ?? undefined;
|
||||
}
|
||||
const path =
|
||||
readNonEmptyString(params.requirementsPath) ??
|
||||
resolveCodexRequirementsPath(params.env ?? process.env, params.platform ?? process.platform);
|
||||
try {
|
||||
if (params.readRequirementsFile) {
|
||||
return params.readRequirementsFile(path);
|
||||
}
|
||||
return readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexRequirementsPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string {
|
||||
if (platform === "win32") {
|
||||
const programData = readNonEmptyString(env.ProgramData) ?? "C:\\ProgramData";
|
||||
return `${programData.replace(/[\\/]+$/, "")}${WINDOWS_CODEX_REQUIREMENTS_SUFFIX}`;
|
||||
}
|
||||
return UNIX_CODEX_REQUIREMENTS_PATH;
|
||||
}
|
||||
|
||||
function parseAllowedSandboxModesFromCodexRequirements(
|
||||
content: string,
|
||||
hostName: string,
|
||||
): Set<CodexAppServerSandboxMode> | undefined {
|
||||
const remoteSandboxModes = parseMatchingRemoteSandboxModesFromCodexRequirements(
|
||||
content,
|
||||
hostName,
|
||||
);
|
||||
if (remoteSandboxModes !== undefined) {
|
||||
return remoteSandboxModes;
|
||||
}
|
||||
const values = parseTopLevelRequirementsStringArray(content, "allowed_sandbox_modes");
|
||||
return parseRequirementsSandboxModes(values);
|
||||
}
|
||||
|
||||
function parseAllowedApprovalPoliciesFromCodexRequirements(
|
||||
content: string,
|
||||
): Set<CodexAppServerApprovalPolicy> | undefined {
|
||||
const values = parseTopLevelRequirementsStringArray(content, "allowed_approval_policies");
|
||||
if (values === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedPolicies = values
|
||||
.map((entry) => normalizeRequirementsApprovalPolicy(entry))
|
||||
.filter((entry): entry is CodexAppServerApprovalPolicy => entry !== undefined);
|
||||
return normalizedPolicies.length > 0 ? new Set(normalizedPolicies) : undefined;
|
||||
}
|
||||
|
||||
function parseAllowedApprovalsReviewersFromCodexRequirements(
|
||||
content: string,
|
||||
): Set<CodexAppServerApprovalsReviewer> | undefined {
|
||||
const values = parseTopLevelRequirementsStringArray(content, "allowed_approvals_reviewers");
|
||||
if (values === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedReviewers = values
|
||||
.map((entry) => normalizeRequirementsApprovalsReviewer(entry))
|
||||
.filter((entry): entry is CodexAppServerApprovalsReviewer => entry !== undefined);
|
||||
return normalizedReviewers.length > 0 ? new Set(normalizedReviewers) : undefined;
|
||||
}
|
||||
|
||||
function parseMatchingRemoteSandboxModesFromCodexRequirements(
|
||||
content: string,
|
||||
hostName: string,
|
||||
): Set<CodexAppServerSandboxMode> | undefined {
|
||||
const normalizedHostName = normalizeRequirementsHostName(hostName);
|
||||
if (normalizedHostName === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
for (const section of parseTomlArrayTableSections(content, "remote_sandbox_config")) {
|
||||
const patterns = parseRequirementsStringArray(section, "hostname_patterns");
|
||||
if (!patterns || !requirementsHostNameMatchesAnyPattern(normalizedHostName, patterns)) {
|
||||
continue;
|
||||
}
|
||||
return parseRequirementsSandboxModes(
|
||||
parseRequirementsStringArray(section, "allowed_sandbox_modes"),
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseRequirementsSandboxModes(
|
||||
values: string[] | undefined,
|
||||
): Set<CodexAppServerSandboxMode> | undefined {
|
||||
if (values === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedModes = values
|
||||
.map((entry) => normalizeRequirementsSandboxMode(entry))
|
||||
.filter((entry): entry is CodexAppServerSandboxMode => entry !== undefined);
|
||||
return normalizedModes.length > 0 ? new Set(normalizedModes) : undefined;
|
||||
}
|
||||
|
||||
function parseTopLevelRequirementsStringArray(content: string, key: string): string[] | undefined {
|
||||
const topLevelContent = stripTomlLineComments(content).slice(0, firstTomlTableOffset(content));
|
||||
return parseRequirementsStringArray(topLevelContent, key);
|
||||
}
|
||||
|
||||
function parseRequirementsStringArray(content: string, key: string): string[] | undefined {
|
||||
const match = content.match(new RegExp(`(?:^|\\n)\\s*${key}\\s*=\\s*\\[([\\s\\S]*?)\\]`));
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const arrayBody = match[1] ?? "";
|
||||
const stringMatches = [...arrayBody.matchAll(/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'/g)];
|
||||
if (stringMatches.length === 0 && arrayBody.trim().length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
return stringMatches.map((entry) => entry[1] ?? entry[2] ?? "");
|
||||
}
|
||||
|
||||
function parseTomlArrayTableSections(content: string, table: string): string[] {
|
||||
const strippedContent = stripTomlLineComments(content);
|
||||
const escapedTable = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const headerPattern = new RegExp(`^\\s*\\[\\[\\s*${escapedTable}\\s*\\]\\]\\s*$`, "gm");
|
||||
const sections: string[] = [];
|
||||
for (
|
||||
let match = headerPattern.exec(strippedContent);
|
||||
match;
|
||||
match = headerPattern.exec(strippedContent)
|
||||
) {
|
||||
const sectionStart = headerPattern.lastIndex;
|
||||
const rest = strippedContent.slice(sectionStart);
|
||||
const nextTableOffset = rest.search(/^\s*\[/m);
|
||||
sections.push(nextTableOffset === -1 ? rest : rest.slice(0, nextTableOffset));
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function firstTomlTableOffset(content: string): number {
|
||||
const match = content.match(/^\s*\[[^\]\n]/m);
|
||||
return match?.index ?? content.length;
|
||||
}
|
||||
|
||||
function stripTomlLineComments(value: string): string {
|
||||
let output = "";
|
||||
let quote: '"' | "'" | undefined;
|
||||
let escaped = false;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const char = value[index] ?? "";
|
||||
if (quote) {
|
||||
output += char;
|
||||
if (quote === '"' && escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (quote === '"' && char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
quote = undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
output += char;
|
||||
continue;
|
||||
}
|
||||
if (char === "#") {
|
||||
while (index < value.length && value[index] !== "\n") {
|
||||
index += 1;
|
||||
}
|
||||
if (value[index] === "\n") {
|
||||
output += "\n";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
output += char;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeRequirementsSandboxMode(value: string): CodexAppServerSandboxMode | undefined {
|
||||
const compact = value.replace(/[\s_-]/g, "").toLowerCase();
|
||||
if (compact === "readonly") {
|
||||
return "read-only";
|
||||
}
|
||||
if (compact === "workspacewrite") {
|
||||
return "workspace-write";
|
||||
}
|
||||
if (compact === "dangerfullaccess") {
|
||||
return "danger-full-access";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeRequirementsHostName(value: string): string | undefined {
|
||||
const normalized = value.trim().replace(/\.+$/g, "").toLowerCase();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function requirementsHostNameMatchesAnyPattern(hostName: string, patterns: string[]): boolean {
|
||||
return patterns.some((pattern) => {
|
||||
const normalizedPattern = normalizeRequirementsHostName(pattern);
|
||||
return normalizedPattern !== undefined && globPatternMatches(hostName, normalizedPattern);
|
||||
});
|
||||
}
|
||||
|
||||
function globPatternMatches(value: string, pattern: string): boolean {
|
||||
let regex = "^";
|
||||
for (const char of pattern) {
|
||||
if (char === "*") {
|
||||
regex += ".*";
|
||||
} else if (char === "?") {
|
||||
regex += ".";
|
||||
} else {
|
||||
regex += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
}
|
||||
regex += "$";
|
||||
return new RegExp(regex).test(value);
|
||||
}
|
||||
|
||||
function normalizeRequirementsApprovalPolicy(
|
||||
value: string,
|
||||
): CodexAppServerApprovalPolicy | undefined {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return resolveApprovalPolicy(normalized);
|
||||
}
|
||||
|
||||
function normalizeRequirementsApprovalsReviewer(
|
||||
value: string,
|
||||
): CodexAppServerApprovalsReviewer | undefined {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return resolveApprovalsReviewer(normalized);
|
||||
}
|
||||
|
||||
function selectGuardianApprovalPolicy(
|
||||
allowedApprovalPolicies: Set<CodexAppServerApprovalPolicy> | undefined,
|
||||
): CodexAppServerApprovalPolicy {
|
||||
if (allowedApprovalPolicies === undefined || allowedApprovalPolicies.has("on-request")) {
|
||||
return "on-request";
|
||||
}
|
||||
if (allowedApprovalPolicies.has("on-failure")) {
|
||||
return "on-failure";
|
||||
}
|
||||
if (allowedApprovalPolicies.has("untrusted")) {
|
||||
return "untrusted";
|
||||
}
|
||||
if (allowedApprovalPolicies.has("never")) {
|
||||
return "never";
|
||||
}
|
||||
return "on-request";
|
||||
}
|
||||
|
||||
function selectGuardianApprovalsReviewer(
|
||||
allowedApprovalsReviewers: Set<CodexAppServerApprovalsReviewer> | undefined,
|
||||
): CodexAppServerApprovalsReviewer {
|
||||
if (allowedApprovalsReviewers === undefined || allowedApprovalsReviewers.has("auto_review")) {
|
||||
return "auto_review";
|
||||
}
|
||||
if (allowedApprovalsReviewers.has("guardian_subagent")) {
|
||||
return "guardian_subagent";
|
||||
}
|
||||
if (allowedApprovalsReviewers.has("user")) {
|
||||
return "user";
|
||||
}
|
||||
return "auto_review";
|
||||
}
|
||||
|
||||
function selectGuardianSandbox(
|
||||
allowedSandboxModes: Set<CodexAppServerSandboxMode> | undefined,
|
||||
): CodexAppServerSandboxMode {
|
||||
if (allowedSandboxModes === undefined || allowedSandboxModes.has("workspace-write")) {
|
||||
return "workspace-write";
|
||||
}
|
||||
if (allowedSandboxModes.has("read-only")) {
|
||||
return "read-only";
|
||||
}
|
||||
if (allowedSandboxModes.has("danger-full-access")) {
|
||||
return "danger-full-access";
|
||||
}
|
||||
return "workspace-write";
|
||||
}
|
||||
|
||||
function resolveApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined {
|
||||
return value === "on-request" ||
|
||||
value === "on-failure" ||
|
||||
|
||||
@@ -674,8 +674,10 @@ describe("runCodexAppServerAttempt", () => {
|
||||
params.sourceReplyDeliveryMode = "message_tool_only";
|
||||
params.toolsAllow = ["message", "web_search", "heartbeat_respond"];
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start", 60_000);
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
pluginConfig: { appServer: { mode: "yolo" } },
|
||||
});
|
||||
await harness.waitForMethod("turn/start", 120_000);
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -1953,6 +1955,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
const { waitForMethod } = createStartedThreadHarness();
|
||||
const run = runCodexAppServerAttempt(
|
||||
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
|
||||
{ pluginConfig: { appServer: { mode: "yolo" } } },
|
||||
);
|
||||
|
||||
await waitForMethod("turn/start");
|
||||
@@ -1974,6 +1977,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(
|
||||
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
|
||||
{ pluginConfig: { appServer: { mode: "yolo" } } },
|
||||
);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
@@ -3107,7 +3111,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
const { requests, waitForMethod, completeTurn } = createResumeHarness();
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
pluginConfig: { appServer: { mode: "yolo" } },
|
||||
});
|
||||
await waitForMethod("turn/start");
|
||||
await completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -30,6 +30,7 @@ export function resolveCodexPromptSnapshotAppServerOptions(
|
||||
return resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig,
|
||||
env: {},
|
||||
requirementsToml: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user