diff --git a/CHANGELOG.md b/CHANGELOG.md index 44fa863f7f6..80890066f3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 38da5f67f84..a000773b38a 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -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 diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 4b36fcdd4da..c1a70519c88 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -12,9 +12,15 @@ import { resolveCodexPluginsPolicy, } from "./config.js"; +type RuntimeOptionsParams = NonNullable[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", diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index e3134637147..4eab3ad3d39 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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" || diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index dbf0693d27c..e2c6d8f8ee4 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -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; diff --git a/extensions/codex/test-api.ts b/extensions/codex/test-api.ts index 9dbee4ed55a..8b0ad5c8994 100644 --- a/extensions/codex/test-api.ts +++ b/extensions/codex/test-api.ts @@ -30,6 +30,7 @@ export function resolveCodexPromptSnapshotAppServerOptions( return resolveCodexAppServerRuntimeOptions({ pluginConfig, env: {}, + requirementsToml: null, }); }