diff --git a/SECURITY.md b/SECURITY.md index 2baba3ae90db..f60ff1982701 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -158,7 +158,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway. ### Code Mode Executors -OpenClaw Code Mode is opt-in. When enabled, its default `node` executor runs JavaScript with Node.js `node:vm` in a worker thread. **`node:vm` is not a security boundary.** The worker keeps guest computation off the Gateway event loop, but it runs with the Gateway process's OS privileges. Enabling Node Code Mode is a trusted-host execution choice. +When global `tools.codeMode` is absent, OpenClaw uses automatic per-model activation. Explicit `false` disables it, and an authored object without `enabled` remains off. When engaged, its default `node` executor runs JavaScript with Node.js `node:vm` in a worker thread. **`node:vm` is not a security boundary.** The worker keeps guest computation off the Gateway event loop, but it runs with the Gateway process's OS privileges. Node Code Mode is a trusted-host execution choice. The intended guest API omits filesystem, network, subprocess, environment, and module-loading APIs. Its limited globals and module guards are programming constraints, not containment against hostile JavaScript. Tool policy, approvals, hooks, and session ownership still apply to calls made through the shared tool bridge; they cannot contain code that escapes the Node VM context. Agent sandbox settings for nested tools do not turn this Gateway worker into an OS sandbox. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 98982a904557..baaead8b3fc8 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -b500a49aa06e79522f384d2e797d4f76dfc4ec5546e6bd4fa9ef27a76bb911fc config-baseline.json -4d6cbb86d5d4a42f277d9712a87ece10c2f4b26f20c2c40b9d4e75a2cd9e1ea6 config-baseline.core.json +9ea48fefe3336c2f8aa95df72a98cfcda04209cd79caf41ecee8f1e5bb701f50 config-baseline.json +4e5cf87e3f7130abaf72e44ff76e55e28a926262ec8a3c86a4a0b670d6008db2 config-baseline.core.json 93b1f2e7e7121bf82d2d571bfa0c01ce710ae96185f363da1ffbc241afdf4549 config-baseline.channel.json bfbe362937ed7c534a7a593ac5205b767f60e0b675ec05782da48fa6cee6f762 config-baseline.plugin.json diff --git a/docs/concepts/agent-runtimes.md b/docs/concepts/agent-runtimes.md index 5f3011832fc8..1c4289316a6e 100644 --- a/docs/concepts/agent-runtimes.md +++ b/docs/concepts/agent-runtimes.md @@ -313,5 +313,5 @@ reject the turn. The completed result records the runtime that actually ran. - [Agent loop](/concepts/agent-loop) - [Models](/concepts/models) - [Status](/cli/status) -- [Code Mode](/tools/code-mode) — an experimental, opt-in agent-runtime feature +- [Code Mode](/tools/code-mode) — an experimental agent-runtime feature with automatic per-model activation - [Agent runtime architecture](/agent-runtime-architecture) — code layout, module boundaries, and how the built-in runtime is selected diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 369b931dc2c3..41922d4eab20 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -406,8 +406,9 @@ Store environment values never enter the Codex app-server process, native shell, sandbox exec-server, ACP children, sandbox exec, or node exec. This Codex-native feature is separate from -[OpenClaw Code Mode](/tools/code-mode), an opt-in JavaScript runtime -for generic OpenClaw runs with a different `exec` input shape. For the +[OpenClaw Code Mode](/tools/code-mode), a separate JavaScript runtime with its +own automatic per-model activation and explicit overrides. It has a different +`exec` input shape. For the broader model/provider/runtime split, start with [Agent runtimes](/concepts/agent-runtimes): `openai/gpt-6-astra` is the model ref, `codex` is the runtime, and Telegram, Discord, Slack, or another diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index f7f353ca5728..6074b302cf7c 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -31,7 +31,7 @@ Referral link for MiniMax Coding Plan (10% off): [MiniMax Coding Plan](https://p Model refs follow the auth path: `minimax/` for API-key setups, `minimax-portal/` for OAuth setups. -MiniMax M3 is a preferred [Code Mode](/tools/code-mode) model on both API-key and OAuth routes. Set `tools.codeMode.enabled` to `"auto"` to use it; Code Mode is off by default. +MiniMax M3 is a preferred [Code Mode](/tools/code-mode) model on both API-key and OAuth routes. With no global Code Mode setting, the automatic tier can engage it; explicit agent or model settings still take precedence. ## Getting started diff --git a/docs/tools/code-mode.md b/docs/tools/code-mode.md index 935ad29858b2..42bb2d848096 100644 --- a/docs/tools/code-mode.md +++ b/docs/tools/code-mode.md @@ -9,7 +9,7 @@ read_when: - You are looking for the Code Mode page that matches your task --- -Code mode is an experimental, opt-in OpenClaw agent-runtime feature. When +Code mode is an experimental OpenClaw agent-runtime feature. When enabled, the model no longer sees every enabled tool schema. Instead, it sees `exec`, `wait`, and any direct-only tool whose structured result cannot cross the JSON-only guest bridge. The model writes a small JavaScript @@ -18,10 +18,11 @@ TypeScript-style signatures describe the available tools; executable cells use plain JavaScript without type annotations. -OpenClaw Code Mode is off by default. To try it, open **Settings → Agents & -Tools → Labs** and turn on **Code Mode**. The Labs switch writes the `"auto"` -tier, which engages only for models marked as preferred Code Mode performers. -This is the global default. Agent and model overrides take precedence. +When `tools.codeMode` is absent, OpenClaw uses the `"auto"` tier and engages +Code Mode only for models marked as preferred Code Mode performers. An authored +object without `enabled` remains off, as do `false` and `{ enabled: false }`. +Agent and model overrides take precedence. Use **Settings → Agents & Tools → +Labs → Code Mode** to choose the global setting. This page documents OpenClaw Code Mode, not Codex Code Mode. The two features diff --git a/docs/tools/code-mode/configuration.md b/docs/tools/code-mode/configuration.md index 9e110b73efa7..952e1bea1211 100644 --- a/docs/tools/code-mode/configuration.md +++ b/docs/tools/code-mode/configuration.md @@ -10,13 +10,15 @@ read_when: ## Configuration -`tools.codeMode.enabled` sets the global activation default. It defaults to -`false`, including when the Code Mode object configures other fields. Set -`true` or `"auto"` explicitly, or use an [agent or model override](/tools/code-mode/quickstart#override-one-model). +An absent global `tools.codeMode` setting defaults to `"auto"`. If you author a +Code Mode object, its activation stays off unless that object explicitly sets +`enabled`; this lets you stage executor or limit settings without enabling the +feature. Shorthand `false` also disables it. Agent and model overrides retain +their existing precedence. | Field | Default | Clamp | | --------------------- | ---------- | ----------------------------------------------- | -| `enabled` | `false` | `false`, `true`, or `"auto"` (per-model) | +| `enabled` | See above | `false`, `true`, or `"auto"` (per-model) | | `executor` | `"node"` | `"node"` or `"quickjs"` | | `mode` | `"only"` | exposes control/direct tools, catalogs the rest | | `timeoutMs` | `10000` | `100`-`60000` | @@ -60,14 +62,16 @@ tool exposure. `tools.codeMode.enabled` accepts three values: -- `false` (default): code mode is off unless an agent or model override enables it. +- `false`: code mode is off unless an agent or model override enables it. - `true`: code mode engages for tool-capable runs unless an override disables it. - `"auto"`: code mode engages only when the run's model is flagged as a preferred code-mode performer in its provider catalog. -These values supply the default when no agent or model override takes -precedence. `"auto"` uses catalog capability; an explicit per-model boolean -bypasses that capability preference. +When the global setting is completely absent, OpenClaw behaves as if it were +`"auto"`. An authored object without `enabled` behaves as `false`. These values +supply the default when no agent or model override takes precedence. `"auto"` +uses catalog capability; an explicit per-model boolean bypasses that capability +preference. ### The `compat.codeMode` catalog flag @@ -89,7 +93,7 @@ Bundled provider catalogs currently flag these models as `"preferred"`: | Provider | Models | | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| anthropic | `claude-fable-5`, `claude-opus-5`, `claude-sonnet-5`, `claude-mythos-5`, `claude-opus-4-8`, `claude-haiku-4-5` | +| anthropic | `claude-fable-5`, `claude-opus-5`, `claude-sonnet-5`, `claude-mythos-5`, `claude-opus-4-8` | | deepseek | `deepseek-v4-pro`, `deepseek-v4-flash` | | google | `gemini-3-flash-preview`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.6-flash`, `gemini-3.7-flash` | | kimi | `k3`, `k3-256k` | @@ -99,6 +103,9 @@ Bundled provider catalogs currently flag these models as `"preferred"`: | xiaomi | `mimo-v2.6-pro`, `mimo-v2.6-flash` | | zai | `glm-5.3`, `glm-5.2`, `glm-5.1` | +`claude-haiku-4-5` is marked `"capable"`: it remains available through an +explicit `true` agent or model setting, but `"auto"` does not engage it. + Everything else, including all Ollama-served local models, stays unflagged and keeps normal tool exposure under `"auto"`. @@ -133,14 +140,13 @@ changes that routing decision. ### Choosing when to enable -In A/B evaluations on the preferred models above, code mode reduced total -token usage by roughly 30-50% at equal-or-better task pass rates, mostly by -replacing many full tool schemas and per-tool round trips with one compact -program surface. Models below the preferred tier showed no consistent win and -sometimes regressed, which is why `"auto"` leaves them on direct tools. +Code Mode can reduce token use by replacing repeated full tool schemas and +intermediate model turns with a compact catalog and one program. Results depend +on the model and workload. Compare correctness and full root-plus-descendant +token use on representative tasks before forcing it broadly. -Use `"auto"` when agents switch between models: strong models get the compact -surface, weaker or local ones keep the exposure they handle best. Use `true` +Use `"auto"` when agents switch between models: preferred models get the compact +surface, while other models keep normal tool exposure. Use `true` on an exact model entry when you have verified an unflagged model performs well with code mode. For open-weight or uncached serving where every prompt token is billed or recomputed, prefer enabling per model (via `"auto"` or an explicit model override) diff --git a/docs/tools/code-mode/executors.md b/docs/tools/code-mode/executors.md index 438de8a0f3bb..0279e67164a8 100644 --- a/docs/tools/code-mode/executors.md +++ b/docs/tools/code-mode/executors.md @@ -11,7 +11,9 @@ read_when: Code Mode uses **Node** by default when enabled. Select **QuickJS** when you need a hardened guest runtime. Both executors run the same plain JavaScript cells, expose the same typed tool discovery, and use the same `exec` and `wait` -tools. Code Mode itself remains off by default. +tools. With no global Code Mode setting, automatic per-model activation applies. +Choosing an executor in Labs preserves activation. When writing an object in +config, include `enabled: "auto"` to retain automatic activation. ## Choose an executor diff --git a/docs/tools/code-mode/quickstart.md b/docs/tools/code-mode/quickstart.md index f34e15dda18f..ad1f5388f420 100644 --- a/docs/tools/code-mode/quickstart.md +++ b/docs/tools/code-mode/quickstart.md @@ -9,9 +9,12 @@ read_when: ## Enable code mode -The recommended path is **Settings → Agents & Tools → Labs → Code Mode**. The -switch takes effect for future agent runs without restarting the Gateway and -selects the `"auto"` tier. +When `tools.codeMode` is absent, OpenClaw uses the `"auto"` tier for future +agent runs. It engages Code Mode only for models marked preferred in the +provider catalog. No configuration is required for this default. + +Use **Settings → Agents & Tools → Labs → Code Mode** to change the global +setting. Changes apply to future agent runs without restarting the Gateway. To enable the same tier without the Control UI, set it in config: @@ -34,10 +37,9 @@ To default code mode on for every tool-capable run, regardless of model: ``` Object form works too: `tools.codeMode.enabled` accepts the same `false`, -`true`, and `"auto"` values. Code mode stays off when `tools.codeMode` is -omitted, `false`, or an object without an explicit `enabled` value, unless an -agent or model override enables it. Configuring limits or other Code Mode -options does not enable it. +`true`, and `"auto"` values. Code Mode stays off for `false` or an authored +object without an explicit `enabled` value, unless an agent or model override +enables it. Configuring limits or other Code Mode options does not enable it. When enabled, Code Mode defaults to Node's `node:vm` executor for trusted execution. Select QuickJS in the same settings panel or set @@ -112,7 +114,9 @@ Activation resolves from the first explicit setting in this order: 1. `agents.entries..models["provider/model"].codeMode`. 2. `agents.entries..tools.codeMode.enabled` (or its boolean/`"auto"` shorthand). 3. `agents.defaults.models["provider/model"].codeMode`. -4. `tools.codeMode.enabled` (or its shorthand), defaulting to `false`. +4. `tools.codeMode.enabled` (or its shorthand); a completely absent global + setting defaults to `"auto"`, while an authored object without `enabled` + defaults to `false`. In the Control UI, open **Settings → Agents → Agent defaults**, show **Advanced** settings, and find **Models** under **Agent Defaults**. Each model has a @@ -281,7 +285,7 @@ With code mode active, the logged model-facing tool names should be `exec` and [Swarm](/tools/swarm) adds `agents.run()`, `phase()`, and `log()` guest globals for orchestrating concurrent sub-agents from Code Mode scripts. Swarm is enabled -by default; Code Mode remains separately opt-in through `"auto"` or `true`. +by default; Code Mode activation remains separate. Use normal JavaScript control flow for fan-out, decision gates, and structured collection. diff --git a/docs/tools/code-mode/troubleshooting.md b/docs/tools/code-mode/troubleshooting.md index 8723a84cdd6b..dacfd0f39212 100644 --- a/docs/tools/code-mode/troubleshooting.md +++ b/docs/tools/code-mode/troubleshooting.md @@ -39,11 +39,17 @@ remain successful. JavaScript syntax errors are rejected during source preparation, before any nested tool dispatch. The bounded diagnostic includes a one-based source line -and column. Correct the source and submit a new `exec`; OpenClaw does not repair +and column. Malformed JavaScript reports its syntax error before module-access +checks. Correct the source and submit a new `exec`; OpenClaw does not repair or replay it automatically. This no-dispatch outcome does not enable `restartSafe` or change the result's `replaySafe` flag. Exceptions thrown by valid guest code, including `SyntaxError`, remain runtime failures. +The current parser has a known limitation with division immediately after an +optional keyword-named property, such as `value?.return / 2 / 3`. This is valid +JavaScript, but source preparation rejects it before tool dispatch. Parenthesize +the property access: `(value?.return) / 2 / 3`. + Errors returned to the guest are plain data; host `Error` instances, stack objects and prototypes are not passed through the JSON result bridge. This bridge contract does not make the Node executor a security boundary; see diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 38269f9e6850..a8d80d2726c0 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -335,6 +335,6 @@ Notes: - [Sandboxing](/gateway/sandboxing) — running commands in sandboxed environments - [Background Process](/gateway/background-process) — long-running exec and process tool - [Security](/gateway/security) — tool policy and elevated access -- [Code Mode](/tools/code-mode) — an opt-in runtime where the model writes a program that calls the hidden tool catalog +- [Code Mode](/tools/code-mode) — a runtime where the model writes a program that calls the hidden tool catalog - [`apply_patch`](/tools/apply-patch) — apply a structured edit instead of shelling out - [Tokenjuice](/tools/tokenjuice) — compacting large command output diff --git a/extensions/anthropic/forward-compat-generation.test.ts b/extensions/anthropic/forward-compat-generation.test.ts index 0d543a1b60d7..f11a80584a28 100644 --- a/extensions/anthropic/forward-compat-generation.test.ts +++ b/extensions/anthropic/forward-compat-generation.test.ts @@ -65,7 +65,7 @@ describe("unreleased Claude generations", () => { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200_000, maxTokens: 64_000, - compat: { codeMode: "preferred" }, + compat: { codeMode: "capable" }, }); expect(resolveModel("claude-haiku-4-9-20251001")).toBeUndefined(); }); diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 51da0710f951..1fd509428253 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -279,7 +279,7 @@ }, "contextWindow": 200000, "maxTokens": 64000, - "compat": { "codeMode": "preferred" } + "compat": { "codeMode": "capable" } } ] } diff --git a/extensions/anthropic/openclaw.plugin.test.ts b/extensions/anthropic/openclaw.plugin.test.ts index 3716410cee6f..9775ab8e94ad 100644 --- a/extensions/anthropic/openclaw.plugin.test.ts +++ b/extensions/anthropic/openclaw.plugin.test.ts @@ -52,11 +52,13 @@ const selectableContextWindowMetadata = { }; describe("Anthropic plugin manifest", () => { - it("flags every static Anthropic API model as code-mode preferred", () => { + it("keeps Haiku opt-in while preferring Code Mode for the other API models", () => { const models = manifest.modelCatalog?.providers?.anthropic?.models ?? []; expect(models.length).toBeGreaterThan(0); for (const model of models) { - expect(model.compat?.codeMode, model.id).toBe("preferred"); + expect(model.compat?.codeMode, model.id).toBe( + model.id === "claude-haiku-4-5" ? "capable" : "preferred", + ); } }); @@ -156,7 +158,7 @@ describe("Anthropic plugin manifest", () => { }, contextWindow: 200000, maxTokens: 64000, - compat: { codeMode: "preferred" }, + compat: { codeMode: "capable" }, }); expect(models.find((model) => model.id === "claude-haiku-4-5-20251001")).toBeUndefined(); }); diff --git a/src/agents/code-mode-headless.test.ts b/src/agents/code-mode-headless.test.ts index 394467ce35c4..7d1497ebf0b1 100644 --- a/src/agents/code-mode-headless.test.ts +++ b/src/agents/code-mode-headless.test.ts @@ -555,29 +555,42 @@ describe("headless Code Mode", () => { ); it.each([ - String.raw`return r\u0065quire('node:fs');`, - "return require?.('node:fs');", - "return (require)('node:fs');", - "return (0, require)('node:fs');", - "const load = require; return load('node:fs');", - "return module.require('node:fs');", - "return process.getBuiltinModule('node:fs');", - "return `${import('node:fs')}`;", - "return `${require('node:fs')}`;", - "return `${`nested ${import('node:fs')}`}`;", - "return `${`nested ${require('node:fs')}`}`;", - "const message = `import('node:fs')`; return require('node:fs');", - "let value = 1; return value++ / import('node:fs');", - "let value = 1; return value-- / import('node:fs');", - "const value = { of: 1 }; return value.of / import('node:fs');", - "const value = { return: 1 }; return value.return / import('node:fs');", - "const value = { if() { return 1; } }; return value.if() / import('node:fs');", - "const value = { return: 1 }; return value?.return / import('node:fs') / 1;", - "const value = { return: 1 }; return value?.return / require('node:fs') / 1;", - "const value = { if() { return 1; } }; return value?.if() / import('node:fs');", - "function run() { const await = 1; return await / (globalThis.pending = import('node:fs')); } run(); return globalThis.pending;", - "class Guest { #return = 1; run() { return this.#return / (globalThis.pending = import('node:fs')); } } new Guest().run(); return globalThis.pending;", - ])("rejects executable module access in a headless guest: %s", async (code) => { + ...[ + String.raw`return r\u0065quire('node:fs');`, + "return require?.('node:fs');", + "return (require)('node:fs');", + "return (0, require)('node:fs');", + "const load = require; return load('node:fs');", + "return module.require('node:fs');", + "return process.getBuiltinModule('node:fs');", + "return `${import('node:fs')}`;", + "return `${require('node:fs')}`;", + "return `${`nested ${import('node:fs')}`}`;", + "return `${`nested ${require('node:fs')}`}`;", + "const message = `import('node:fs')`; return require('node:fs');", + "let value = 1; return value++ / import('node:fs');", + "let value = 1; return value-- / import('node:fs');", + "const value = { of: 1 }; return value.of / import('node:fs');", + "const value = { return: 1 }; return value.return / import('node:fs');", + "const value = { if() { return 1; } }; return value.if() / import('node:fs');", + "const value = { if() { return 1; } }; return value?.if() / import('node:fs');", + "function run() { const await = 1; return await / (globalThis.pending = import('node:fs')); } run(); return globalThis.pending;", + "class Guest { #return = 1; run() { return this.#return / (globalThis.pending = import('node:fs')); } } new Guest().run(); return globalThis.pending;", + ].map((code) => ({ + code, + reason: "executable module access", + expectedError: "module access is disabled", + })), + ...[ + "const value = { return: 1 }; return value?.return / import('node:fs') / 1;", + "const value = { return: 1 }; return value?.return / require('node:fs') / 1;", + ].map((code) => ({ + code, + reason: "existing parser limitation: optional keyword property before division", + expectedError: + "SyntaxError at openclaw-code-mode:user.js:1:51: Unexpected token. No tools were dispatched; correct the JavaScript source and submit it again.", + })), + ])("rejects $reason in a headless guest: $code", async ({ code, expectedError }) => { const result = expectFailed( await runCodeModeScriptHeadless({ ctx: createHeadlessCodeModeHarness(), @@ -586,7 +599,7 @@ describe("headless Code Mode", () => { ); expect(result.code).toBe("invalid_input"); - expect(result.error).toContain("module access is disabled"); + expect(result.error).toContain(expectedError); expect(result.toolCallCount).toBe(0); }); diff --git a/src/agents/code-mode-runtime.test.ts b/src/agents/code-mode-runtime.test.ts index 2087607c7b3c..7e8041b4148a 100644 --- a/src/agents/code-mode-runtime.test.ts +++ b/src/agents/code-mode-runtime.test.ts @@ -155,7 +155,7 @@ describe("Code Mode master switch resolution", () => { { name: "object enabled auto", codeMode: { enabled: "auto" }, enabled: "auto" }, { name: "object with options", codeMode: { timeoutMs: 5000 }, enabled: false }, { name: "empty object", codeMode: {}, enabled: false }, - { name: "omitted", codeMode: undefined, enabled: false }, + { name: "omitted", codeMode: undefined, enabled: "auto" }, ])("resolves enabled for $name", ({ codeMode, enabled }) => { expect(resolveCodeModeConfig({ tools: { codeMode } } as never).enabled).toBe(enabled); }); @@ -206,6 +206,17 @@ describe("Code Mode guest source validation", () => { it.each([ { code: "const answer = ;", location: "1:16" }, { code: "const first = 1;\nconst answer = ;", location: "2:16" }, + { code: "const answer = ; return import('node:fs');", location: "1:16" }, + { + code: `const label = "${"😀".repeat(96)}";\nconst answer = ; return import('node:fs');`, + location: "2:16", + }, + { + code: `const label = "${"😀".repeat(96)}";\nconst answer = ; return require('node:fs');`, + location: "2:16", + }, + { code: "import fs from 'node:fs';", location: "1:1" }, + { code: "return import.meta.url;", location: "1:8" }, ])("rejects malformed JavaScript at $location", ({ code, location }) => { expect(() => prepareSource(code)).toThrow( "SyntaxError at openclaw-code-mode:user.js:" + location, @@ -334,7 +345,6 @@ describe("Code Mode guest source validation", () => { it.each([ ["direct require", "return require('node:fs');"], ["direct dynamic import", "return import('node:fs');"], - ["direct import.meta", "return import.meta.url;"], ["comment-separated require", "return require /* hidden */ ('node:fs');"], ["Unicode-escaped direct require", String.raw`return r\u0065quire('node:fs');`], ["optional direct require", "return require?.('node:fs');"], @@ -386,12 +396,14 @@ describe("Code Mode guest source validation", () => { "const value = { if() { return 1; } }; return value.if() / import('node:fs');", ], [ - "dynamic import after an optional keyword-shaped return property", + "existing parser limitation: dynamic import after an optional keyword property", "const value = { return: 1 }; return value?.return / import('node:fs') / 1;", + "SyntaxError at openclaw-code-mode:user.js:1:51: Unexpected token. No tools were dispatched; correct the JavaScript source and submit it again.", ], [ - "require after an optional keyword-shaped return property", + "existing parser limitation: require after an optional keyword property", "const value = { return: 1 }; return value?.return / require('node:fs') / 1;", + "SyntaxError at openclaw-code-mode:user.js:1:51: Unexpected token. No tools were dispatched; correct the JavaScript source and submit it again.", ], [ "dynamic import after an optional keyword-shaped control method", @@ -417,10 +429,6 @@ describe("Code Mode guest source validation", () => { "require after a nested contextual await identifier", "function run() { const await = 1; return await / require('node:fs'); } return run();", ], - [ - "malformed input containing an executable module loader", - "const answer = ; return import('node:fs');", - ], [ "dynamic import after an astral-filled JavaScript string", `const label = "${"😀".repeat(96)}"; return import('node:fs');`, @@ -429,23 +437,14 @@ describe("Code Mode guest source validation", () => { "require after an astral-filled JavaScript string", `const label = "${"😀".repeat(96)}"; return require('node:fs');`, ], - [ - "dynamic import after astral Unicode in malformed JavaScript", - `const label = "${"😀".repeat(96)}"; const answer = ; return import('node:fs');`, - ], - [ - "require after astral Unicode in malformed JavaScript", - `const label = "${"😀".repeat(96)}"; const answer = ; return require('node:fs');`, - ], - ])("rejects %s", (_name, code) => { - expect(() => prepareSource(code)).toThrow("code mode module access is disabled"); + ])("rejects %s", (_name, code, expectedError = "code mode module access is disabled") => { + expect(() => prepareSource(code)).toThrow(expectedError); }); it("separates every deterministic literal and executable module-shaped input", () => { const moduleExpressions = [ "require('node:fs')", "import('node:fs')", - "import.meta.url", 'require /* comment */ ("node:fs")', 'import /* comment */ ("node:fs")', ]; diff --git a/src/agents/code-mode-runtime.ts b/src/agents/code-mode-runtime.ts index ac2ef8dec6ba..13bf352108b7 100644 --- a/src/agents/code-mode-runtime.ts +++ b/src/agents/code-mode-runtime.ts @@ -86,7 +86,7 @@ function readCodeModeRawConfig( model?: { provider: string; modelId: string }, ): Record { const tools = isRecord(config?.tools) ? config.tools : undefined; - const globalRaw = normalizeCodeModeRawConfig(tools?.codeMode) ?? {}; + const globalRaw = normalizeCodeModeRawConfig(tools?.codeMode) ?? { enabled: "auto" }; const agent = config && agentId ? resolveAgentConfig(config, agentId) : undefined; const agentRaw = normalizeCodeModeRawConfig(agent?.tools?.codeMode); const key = model @@ -106,8 +106,7 @@ function readCodeModeRawConfig( } function readEnabled(value: unknown): boolean | "auto" { - // Stable option-bearing objects made `enabled` optional and defaulted it off. - // Automatic activation therefore requires an explicit `"auto"` selection. + // Authored option-bearing objects keep their historical opt-in behavior. return typeof value === "boolean" || value === "auto" ? value : false; } diff --git a/src/agents/code-mode-source.ts b/src/agents/code-mode-source.ts index 89c3f1a486b4..6c55b4566987 100644 --- a/src/agents/code-mode-source.ts +++ b/src/agents/code-mode-source.ts @@ -1,5 +1,4 @@ /** Validate guest JavaScript before execution. */ -import { tokenizer } from "acorn"; import { parseCodeModeScriptSyntax } from "./code-mode-script-syntax.js"; import { CODE_MODE_SHELL_SOURCE_ERROR, @@ -7,34 +6,6 @@ import { } from "./code-mode-shell-source.js"; import { ToolInputError } from "./tool-input-error.js"; -function maskCodeLiteralsAndComments(code: string): string { - // Parser and tokenizer offsets are UTF-16 code units, not Unicode points. - const masked = code.split(""); - const maskRange = (start: number, end: number) => { - for (let index = start; index < Math.min(end, masked.length); index += 1) { - if (masked[index] !== "\n" && masked[index] !== "\r") { - masked[index] = " "; - } - } - }; - // Malformed JavaScript needs a conservative lexical pass: never trust a - // context-free regexp token to hide executable module access. - try { - for (const token of tokenizer(code, { - ecmaVersion: "latest", - onComment: (_isBlock, _text, start, end) => maskRange(start, end), - })) { - if (token.type.label === "string" || token.type.label === "template") { - maskRange(token.start, token.end); - } - } - return masked.join(""); - } catch { - // Never inspect partially masked input after a tokenizer failure. - return code; - } -} - function isModuleLoaderCallee(callee: import("acorn").Expression | import("acorn").Super): boolean { if (callee.type === "ParenthesizedExpression") { return isModuleLoaderCallee(callee.expression); @@ -51,9 +22,7 @@ function isModuleLoaderCallee(callee: import("acorn").Expression | import("acorn function containsModuleAccess(node: import("acorn").AnyNode): boolean { if ( - node.type === "ImportDeclaration" || node.type === "ImportExpression" || - (node.type === "MetaProperty" && node.meta.name === "import") || (node.type === "CallExpression" && isModuleLoaderCallee(node.callee)) ) { return true; @@ -89,28 +58,8 @@ function containsModuleAccess(node: import("acorn").AnyNode): boolean { return false; } -function rejectsModuleAccess( - code: string, - parsed: ReturnType, -): boolean { - // Unicode escapes can spell a loader identifier without its literal name. - if (!code.includes("import") && !code.includes("require") && !code.includes("\\u")) { - return false; - } - if (parsed.ok) { - // The WASI guest has no host module loader. Only executable module syntax - // belongs in this early check; ordinary guest methods are not capabilities. - return containsModuleAccess(parsed.program); - } - const source = maskCodeLiteralsAndComments(code); - return /\bimport\b\s*(?:\.|\(|["'`{*]|\w)|\brequire\b\s*\(/u.test(source); -} - export function prepareSource(code: string): string { const parsed = parseCodeModeScriptSyntax(code); - if (rejectsModuleAccess(code, parsed)) { - throw new ToolInputError("code mode module access is disabled."); - } if (isShellLikeCodeModeSource(code)) { throw new ToolInputError(CODE_MODE_SHELL_SOURCE_ERROR); } @@ -121,5 +70,12 @@ export function prepareSource(code: string): string { `SyntaxError at openclaw-code-mode:user.js:${parsed.line}:${parsed.column + 1}: ${message}. No tools were dispatched; correct the JavaScript source and submit it again.`, ); } + // Unicode escapes can spell a loader identifier without its literal name. + if ( + (code.includes("import") || code.includes("require") || code.includes("\\u")) && + containsModuleAccess(parsed.program) + ) { + throw new ToolInputError("code mode module access is disabled."); + } return code; } diff --git a/src/agents/code-mode.config.test.ts b/src/agents/code-mode.config.test.ts index fdeb399491c8..0ee54ad73cd4 100644 --- a/src/agents/code-mode.config.test.ts +++ b/src/agents/code-mode.config.test.ts @@ -81,7 +81,7 @@ describe("Code Mode configuration", () => { }); it("resolves object config defaults", () => { - expect(resolveCodeModeConfig().executor).toBe("node"); + expect(resolveCodeModeConfig()).toMatchObject({ enabled: "auto", executor: "node" }); expect(resolveCodeModeConfig({ tools: { codeMode: true } })).toMatchObject({ enabled: true, executor: "node", @@ -190,7 +190,7 @@ describe("Code Mode configuration", () => { } as never, "ops", ); - expect(configuredAgent.enabled).toBe(false); + expect(configuredAgent.enabled).toBe("auto"); expect(configuredAgent.timeoutMs).toBe(2345); }); }); diff --git a/src/agents/code-mode.guest-source.test.ts b/src/agents/code-mode.guest-source.test.ts index c39cfbc5a41e..37102c69b214 100644 --- a/src/agents/code-mode.guest-source.test.ts +++ b/src/agents/code-mode.guest-source.test.ts @@ -294,34 +294,46 @@ describe("Code Mode guest source validation", () => { }); it.each([ - "const fs = require('node:fs'); return fs;", - String.raw`return r\u0065quire('node:fs');`, - "return require?.('node:fs');", - "return (require)('node:fs');", - "return (0, require)('node:fs');", - "const load = require; return load('node:fs');", - "return module.require('node:fs');", - "return process.getBuiltinModule('node:fs');", - "return import('node:fs');", - "return import.meta.url;", - "return `${import('node:fs')}`;", - "return `${require('node:fs')}`;", - "return `${`nested ${import('node:fs')}`}`;", - "return `${`nested ${require('node:fs')}`}`;", - "return `${({ value: import('node:fs') }).value}`;", - "const message = `import('node:fs')`; return require('node:fs');", - "const pattern = /import.meta/; return import('node:fs');", - "let value = 1; return value++ / import('node:fs');", - "let value = 1; return value-- / import('node:fs');", - "const value = { of: 1 }; return value.of / import('node:fs');", - "const value = { return: 1 }; return value.return / import('node:fs');", - "const value = { if() { return 1; } }; return value.if() / import('node:fs');", - "const value = { return: 1 }; return value?.return / import('node:fs') / 1;", - "const value = { return: 1 }; return value?.return / require('node:fs') / 1;", - "const value = { if() { return 1; } }; return value?.if() / import('node:fs');", - "function run() { const await = 1; return await / (globalThis.pending = import('node:fs')); } run(); return globalThis.pending;", - "class Guest { #return = 1; run() { return this.#return / (globalThis.pending = import('node:fs')); } } new Guest().run(); return globalThis.pending;", - ])("rejects module access: %s", async (code) => { + ...[ + "const fs = require('node:fs'); return fs;", + String.raw`return r\u0065quire('node:fs');`, + "return require?.('node:fs');", + "return (require)('node:fs');", + "return (0, require)('node:fs');", + "const load = require; return load('node:fs');", + "return module.require('node:fs');", + "return process.getBuiltinModule('node:fs');", + "return import('node:fs');", + "return `${import('node:fs')}`;", + "return `${require('node:fs')}`;", + "return `${`nested ${import('node:fs')}`}`;", + "return `${`nested ${require('node:fs')}`}`;", + "return `${({ value: import('node:fs') }).value}`;", + "const message = `import('node:fs')`; return require('node:fs');", + "const pattern = /import.meta/; return import('node:fs');", + "let value = 1; return value++ / import('node:fs');", + "let value = 1; return value-- / import('node:fs');", + "const value = { of: 1 }; return value.of / import('node:fs');", + "const value = { return: 1 }; return value.return / import('node:fs');", + "const value = { if() { return 1; } }; return value.if() / import('node:fs');", + "const value = { if() { return 1; } }; return value?.if() / import('node:fs');", + "function run() { const await = 1; return await / (globalThis.pending = import('node:fs')); } run(); return globalThis.pending;", + "class Guest { #return = 1; run() { return this.#return / (globalThis.pending = import('node:fs')); } } new Guest().run(); return globalThis.pending;", + ].map((code) => ({ + code, + reason: "module access", + expectedError: "module access is disabled", + })), + ...[ + "const value = { return: 1 }; return value?.return / import('node:fs') / 1;", + "const value = { return: 1 }; return value?.return / require('node:fs') / 1;", + ].map((code) => ({ + code, + reason: "existing parser limitation: optional keyword property before division", + expectedError: + "SyntaxError at openclaw-code-mode:user.js:1:51: Unexpected token. No tools were dispatched; correct the JavaScript source and submit it again.", + })), + ])("rejects $reason: $code", async ({ code, expectedError }) => { const tools = createSourceValidationTools(); const details = resultDetails( await expectDefined(tools[0], "tools[0] test invariant").execute("code-call-import", { @@ -329,7 +341,14 @@ describe("Code Mode guest source validation", () => { }), ); - expect(details.status).toBe("failed"); - expect(String(details.error)).toContain("module access is disabled"); + expect(details).toMatchObject({ + status: "failed", + code: "invalid_input", + failurePhase: "input", + bridgeDispatchStarted: false, + telemetry: { callCount: 0 }, + }); + expect(String(details.error)).toContain(expectedError); + expect(testing.activeRuns.size).toBe(0); }); }); diff --git a/src/agents/code-mode.guest.test.ts b/src/agents/code-mode.guest.test.ts index c2bebda194dd..f9d646130231 100644 --- a/src/agents/code-mode.guest.test.ts +++ b/src/agents/code-mode.guest.test.ts @@ -751,7 +751,8 @@ describe("Code Mode guest execution", () => { const execTool = expectDefined(codeModeTools[0], "Code Mode exec"); const malformed = [ 'await fake_command({ value: "first" });', - 'return await fake_command({ value: "jq .["2"]" });', + String.raw`const patch = { 'newText:' const value = 1;\n };`, + "return await fake_command({ value: \"import test from 'node:test';\" });", ].join("\n"); const details = resultDetails( await execTool.execute("code-call-syntax", { code: malformed }), @@ -772,12 +773,15 @@ describe("Code Mode guest execution", () => { const corrected = await runUntilCompleted({ execTool, waitTool: expectDefined(codeModeTools[1], "Code Mode wait"), - code: "return await fake_command({ value: " + JSON.stringify('jq .["2"]') + " });", + code: + "return await fake_command({ value: " + + JSON.stringify("import test from 'node:test';") + + " });", }); expect(corrected).toMatchObject({ status: "completed", replaySafe: false, - value: { name: "fake_command", input: { value: 'jq .["2"]' } }, + value: { name: "fake_command", input: { value: "import test from 'node:test';" } }, }); expect(command.execute).toHaveBeenCalledTimes(1); expect(testing.activeRuns.size).toBe(0); diff --git a/src/agents/tool-surface-plan.test.ts b/src/agents/tool-surface-plan.test.ts index 3d5d8671c4bd..7a5ff5f293af 100644 --- a/src/agents/tool-surface-plan.test.ts +++ b/src/agents/tool-surface-plan.test.ts @@ -122,9 +122,8 @@ describe("resolveAgentToolSurfacePlan", () => { }, ); - it("uses the selected model policy before transport aliases and reevaluates fallbacks", () => { + it("uses automatic activation by default while honoring model policy and fallback capability", () => { const config: OpenClawConfig = { - tools: { codeMode: "auto" }, agents: { defaults: { models: { "test/family": { codeMode: false } } } }, }; const model = { id: "family-current", provider: "test", compat: { codeMode: "preferred" } }; @@ -135,6 +134,16 @@ describe("resolveAgentToolSurfacePlan", () => { expect( resolveAgentToolSurfacePlan({ ...params, modelId: "fallback" }).codeModeControlsEnabled, ).toBe(true); + for (const compat of [{ codeMode: "capable" }, {}]) { + const plan = resolveAgentToolSurfacePlan({ + ...params, + modelId: "fallback", + model: { ...model, compat }, + }); + expect(plan.codeModeControlsEnabled).toBe(false); + expect(plan.toolSearchControlsEnabled).toBe(true); + } + expect(config.tools).toBeUndefined(); }); it.each([ diff --git a/src/config/schema.help.runtime.ts b/src/config/schema.help.runtime.ts index b8901eb399b6..ffd81821dcfb 100644 --- a/src/config/schema.help.runtime.ts +++ b/src/config/schema.help.runtime.ts @@ -134,9 +134,9 @@ export const RUNTIME_FIELD_HELP: Record = { "tools.toolSearch.maxSearchLimit": "Maximum number of Tool Search results a model can request. Runtime clamps values to the supported 1..50 range.", "tools.codeMode": - "Generic OpenClaw Code Mode. When enabled, agent runs expose only `exec` and `wait` to the model and access normal tools through the catalog bridge.", + 'Generic OpenClaw Code Mode. When omitted globally, defaults to `"auto"`; an authored object without `enabled` remains off. Engaged agent runs expose only `exec` and `wait` to the model and access normal tools through the catalog bridge.', "tools.codeMode.enabled": - 'Global OpenClaw Code Mode default. Off when omitted, including objects without `enabled`. `"auto"` engages catalog-preferred models; `true` engages tool-capable runs. Agent and model activation overrides take precedence. An engaged run fails closed if the runtime is unavailable instead of exposing the full tool list.', + 'Global OpenClaw Code Mode activation. A completely absent global setting defaults to `"auto"`; an authored object without `enabled` remains off. `"auto"` engages catalog-preferred models, while `true` engages tool-capable runs. Agent and model activation overrides take precedence. An engaged run fails closed if the runtime is unavailable instead of exposing the full tool list.', "tools.codeMode.executor": 'JavaScript executor: "node" (default) uses Node vm for trusted code and is not a security sandbox; "quickjs" uses the bundled QuickJS WASM plugin for hardened guest execution. Tool permissions apply to both. A missing selected executor fails closed.', "tools.codeMode.mode": diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 89dadb206d92..9440df384ef3 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -521,7 +521,7 @@ const CodeModeSchema = z z.literal("auto"), z .object({ - /** OpenClaw Code Mode default, overridden by per-model codeMode. Default: false; "auto" engages catalog-preferred models. */ + /** Explicit object-form activation. Omitted stays off; "auto" engages catalog-preferred models. A completely absent global codeMode setting defaults separately to auto. */ enabled: z.union([z.boolean(), z.literal("auto")]).optional(), /** Executor. Node is the default; QuickJS provides a separate WASM guest. */ executor: z.enum(["node", "quickjs"]).optional(), diff --git a/ui/src/e2e/config-safe-write.e2e.test.ts b/ui/src/e2e/config-safe-write.e2e.test.ts index 9119672d71da..b8aa09bb6876 100644 --- a/ui/src/e2e/config-safe-write.e2e.test.ts +++ b/ui/src/e2e/config-safe-write.e2e.test.ts @@ -139,78 +139,94 @@ async function capture(page: Page, name: string, content: Locator): Promise { - it("saves and reloads the Code Mode executor without changing enablement or limits", async () => { - await suite.withPage( - { - colorScheme: "dark", - locale: "en-US", - serviceWorkers: "block", - viewport: { height: 1000, width: 1440 }, - }, - async ({ page }) => { - const initialConfig = { - tools: { codeMode: { enabled: "auto", timeoutMs: 5000 } }, - }; - const quickjsConfig = { - tools: { codeMode: { ...initialConfig.tools.codeMode, executor: "quickjs" } }, - }; - const gateway = await installMockGateway(page, { - methodResponses: { - "config.get": configResponse(initialConfig, "executor-node"), - }, - }); + it.each([ + { + name: "authored activation and limits", + initialMode: { enabled: "auto", timeoutMs: 5000 }, + expectedPatch: { executor: "quickjs" }, + }, + { + name: "inherited automatic activation", + initialMode: undefined, + expectedPatch: { enabled: "auto", executor: "quickjs" }, + }, + ])( + "saves and reloads the Code Mode executor with $name", + async ({ initialMode, expectedPatch }) => { + await suite.withPage( + { + colorScheme: "dark", + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 1000, width: 1440 }, + }, + async ({ page }) => { + const initialConfig = initialMode ? { tools: { codeMode: initialMode } } : {}; + const retainedMode = { enabled: "auto", ...initialMode }; + const quickjsConfig = { + tools: { codeMode: { ...retainedMode, executor: "quickjs" } }, + }; + const gateway = await installMockGateway(page, { + methodResponses: { + "config.get": configResponse(initialConfig, "executor-node"), + }, + }); - expect((await page.goto(`${suite.server.baseUrl}settings/labs`))?.status()).toBe(200); - const executorRow = settingsRow(page, "Code Mode executor"); - const executor = executorRow.getByRole("combobox", { name: "Code Mode executor" }); - const enabled = settingsRow(page, "Code Mode").getByRole("switch", { - name: "Code Mode", - exact: true, - }); - await expect.poll(() => executor.inputValue()).toBe("node"); - expect(await executorRow.textContent()).toContain("not a security sandbox"); - expect(await enabled.getAttribute("aria-checked")).toBe("true"); - await capture(page, "code-mode-node-default.png", executor); + expect((await page.goto(`${suite.server.baseUrl}settings/labs`))?.status()).toBe(200); + const executorRow = settingsRow(page, "Code Mode executor"); + const executor = executorRow.getByRole("combobox", { name: "Code Mode executor" }); + const enabled = settingsRow(page, "Code Mode").getByRole("switch", { + name: "Code Mode", + exact: true, + }); + await expect.poll(() => executor.inputValue()).toBe("node"); + expect(await executorRow.textContent()).toContain("not a security sandbox"); + expect(await enabled.getAttribute("aria-checked")).toBe("true"); + await capture(page, "code-mode-node-default.png", executor); - await gateway.deferNext("config.patch"); - await executor.selectOption("quickjs"); - const quickjsPatch = mutationParams(await gateway.waitForRequest("config.patch")); - expect(quickjsPatch.baseHash).toBe("executor-node"); - expect(JSON.parse(String(quickjsPatch.raw))).toEqual({ - tools: { codeMode: { executor: "quickjs" } }, - }); - expect(await executor.isDisabled()).toBe(true); + await gateway.deferNext("config.patch"); + await executor.selectOption("quickjs"); + const quickjsPatch = mutationParams(await gateway.waitForRequest("config.patch")); + expect(quickjsPatch.baseHash).toBe("executor-node"); + expect(JSON.parse(String(quickjsPatch.raw))).toEqual({ + tools: { codeMode: expectedPatch }, + }); + expect(await executor.isDisabled()).toBe(true); - const quickjsResponse = configResponse(quickjsConfig, "executor-quickjs"); - await gateway.setMethodResponse("config.get", quickjsResponse); - await gateway.resolveDeferred("config.patch", { ok: true, ...quickjsResponse }); - await expect.poll(() => executor.isDisabled()).toBe(false); - expect((await page.reload())?.status()).toBe(200); - await expect.poll(() => executor.inputValue()).toBe("quickjs"); - expect(await enabled.getAttribute("aria-checked")).toBe("true"); - await capture(page, "code-mode-quickjs-reloaded.png", executor); + const quickjsResponse = configResponse(quickjsConfig, "executor-quickjs"); + await gateway.setMethodResponse("config.get", quickjsResponse); + await gateway.resolveDeferred("config.patch", { ok: true, ...quickjsResponse }); + await expect.poll(() => executor.isDisabled()).toBe(false); + expect((await page.reload())?.status()).toBe(200); + await expect.poll(() => executor.inputValue()).toBe("quickjs"); + expect(await enabled.getAttribute("aria-checked")).toBe("true"); + await capture(page, "code-mode-quickjs-reloaded.png", executor); - const priorPatches = (await gateway.getRequests("config.patch")).length; - await gateway.deferNext("config.patch"); - await executor.selectOption("node"); - const nodePatch = mutationParams( - await gateway.waitForRequest("config.patch", { after: priorPatches }), - ); - expect(nodePatch.baseHash).toBe("executor-quickjs"); - expect(JSON.parse(String(nodePatch.raw))).toEqual({ - tools: { codeMode: { executor: null } }, - }); + const priorPatches = (await gateway.getRequests("config.patch")).length; + await gateway.deferNext("config.patch"); + await executor.selectOption("node"); + const nodePatch = mutationParams( + await gateway.waitForRequest("config.patch", { after: priorPatches }), + ); + expect(nodePatch.baseHash).toBe("executor-quickjs"); + expect(JSON.parse(String(nodePatch.raw))).toEqual({ + tools: { codeMode: { executor: null } }, + }); - const nodeResponse = configResponse(initialConfig, "executor-node-restored"); - await gateway.setMethodResponse("config.get", nodeResponse); - await gateway.resolveDeferred("config.patch", { ok: true, ...nodeResponse }); - await expect.poll(() => executor.isDisabled()).toBe(false); - expect((await page.reload())?.status()).toBe(200); - await expect.poll(() => executor.inputValue()).toBe("node"); - expect(await enabled.getAttribute("aria-checked")).toBe("true"); - }, - ); - }); + const nodeResponse = configResponse( + { tools: { codeMode: retainedMode } }, + "executor-node-restored", + ); + await gateway.setMethodResponse("config.get", nodeResponse); + await gateway.resolveDeferred("config.patch", { ok: true, ...nodeResponse }); + await expect.poll(() => executor.isDisabled()).toBe(false); + expect((await page.reload())?.status()).toBe(200); + await expect.poll(() => executor.inputValue()).toBe("node"); + expect(await enabled.getAttribute("aria-checked")).toBe("true"); + }, + ); + }, + ); it("retains a Raw revert when an autosave commits after its connection closes", async () => { await suite.withPage( diff --git a/ui/src/e2e/curated-settings-defaults.e2e.test.ts b/ui/src/e2e/curated-settings-defaults.e2e.test.ts index e6b8639b0669..4fba36b3f441 100644 --- a/ui/src/e2e/curated-settings-defaults.e2e.test.ts +++ b/ui/src/e2e/curated-settings-defaults.e2e.test.ts @@ -114,7 +114,7 @@ suite.define(() => { const afterLabsReset = { agents: initialConfig.agents, browser: initialConfig.browser, - tools: { profile: "minimal" }, + tools: { codeMode: {}, profile: "minimal" }, }; const afterThinkingReset = { agents: { @@ -357,7 +357,7 @@ suite.define(() => { await reloadedCodeModeRow .getByRole("switch", { name: "Code Mode", exact: true }) .getAttribute("aria-checked"), - ).toBe("false"); + ).toBe("true"); if (captureUiProofEnabled) { await page.screenshot({ diff --git a/ui/src/pages/labs/labs-page.test.ts b/ui/src/pages/labs/labs-page.test.ts index d8eb4e6de048..8f339fb4df12 100644 --- a/ui/src/pages/labs/labs-page.test.ts +++ b/ui/src/pages/labs/labs-page.test.ts @@ -172,24 +172,44 @@ describe("LabsPage", () => { expect(codeModeToggle(page).checked).toBe(true); }); - it.each([true, false, "auto"])( - "preserves the %s Code Mode shorthand when choosing an executor", - async (enabled) => { - const { page, runtimeConfig } = await mountPage({ tools: { codeMode: enabled } }); - const select = page.querySelector( - 'select[aria-label="Code Mode executor"]', - ); - expect(select).not.toBeNull(); - select!.value = "quickjs"; - select!.dispatchEvent(new Event("change", { bubbles: true })); - - await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); - expect(runtimeConfig.patch).toHaveBeenCalledWith({ - raw: { tools: { codeMode: { enabled, executor: "quickjs" } } }, - note: "labs: update codeModeExecutor", - }); + it.each([ + ...[true, false, "auto"].map((enabled) => ({ + name: `${enabled} shorthand`, + config: enabled, + executor: "quickjs", + expectedPatch: { enabled, executor: "quickjs" }, + })), + { + name: "inherited auto", + config: undefined, + executor: "quickjs", + expectedPatch: { enabled: "auto", executor: "quickjs" }, }, - ); + { + name: "authored limits without activation", + config: { timeoutMs: 5000 }, + executor: "quickjs", + expectedPatch: { executor: "quickjs" }, + }, + { + name: "explicit auto and limits when restoring Node", + config: { enabled: "auto", executor: "quickjs", timeoutMs: 5000 }, + executor: "node", + expectedPatch: { executor: null }, + }, + ])("preserves $name when choosing an executor", async ({ config, executor, expectedPatch }) => { + const { page, runtimeConfig } = await mountPage({ tools: { codeMode: config } }); + const select = page.querySelector('select[aria-label="Code Mode executor"]'); + expect(select).not.toBeNull(); + select!.value = executor; + select!.dispatchEvent(new Event("change", { bubbles: true })); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { codeMode: expectedPatch } }, + note: "labs: update codeModeExecutor", + }); + }); it.each([ { @@ -264,6 +284,12 @@ describe("LabsPage", () => { expectedPatch: { tools: { codeMode: { enabled: "auto" } } }, note: "labs: update codeMode", }, + { + label: "Code Mode", + sourceConfig: { tools: { codeMode: false } }, + expectedPatch: { tools: { codeMode: null } }, + note: "labs: update codeMode", + }, { label: "Custom plugin UI", sourceConfig: {}, @@ -313,10 +339,9 @@ describe("LabsPage", () => { }); describe("LabsPage code mode enablement", () => { - // Mirrors resolveCodeModeConfig: omitted `enabled` is off for every object - // shape, while explicit `true` and `"auto"` remain opt-ins. + // An absent global node inherits auto; authored objects remain opt-in. it.each([ - ["unset", false, {}], + ["unset", true, {}], ["empty object", false, { tools: { codeMode: {} } }], ["object with options", false, { tools: { codeMode: { timeoutMs: 5000 } } }], ["explicit true", true, { tools: { codeMode: { enabled: true } } }], @@ -330,16 +355,17 @@ describe("LabsPage code mode enablement", () => { provider.remove(); }); - it("writes the auto tier when enabling the shipped default", async () => { + it("writes explicit false when disabling the automatic default", async () => { const { page, runtimeConfig } = await mountPage({}); const toggle = codeModeToggle(page); - toggle.checked = true; + expect(toggle.checked).toBe(true); + toggle.checked = false; toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); expect(runtimeConfig.patch).toHaveBeenCalledWith({ - raw: { tools: { codeMode: { enabled: "auto" } } }, + raw: { tools: { codeMode: { enabled: false } } }, note: "labs: update codeMode", }); }); diff --git a/ui/src/pages/labs/labs-page.ts b/ui/src/pages/labs/labs-page.ts index 6374d9dd2c52..b928e313bd65 100644 --- a/ui/src/pages/labs/labs-page.ts +++ b/ui/src/pages/labs/labs-page.ts @@ -150,7 +150,11 @@ class LabsPage extends OpenClawLightDomElement { void this.updateSetting("codeModeExecutor", executor, { tools: { codeMode: { - ...(typeof config === "boolean" || config === "auto" ? { enabled: config } : {}), + ...(config === undefined + ? { enabled: "auto" } + : typeof config === "boolean" || config === "auto" + ? { enabled: config } + : {}), executor: executor === "node" ? null : executor, }, }, diff --git a/ui/src/pages/labs/labs-registry.ts b/ui/src/pages/labs/labs-registry.ts index e3e320052e0b..fbd1dc206860 100644 --- a/ui/src/pages/labs/labs-registry.ts +++ b/ui/src/pages/labs/labs-registry.ts @@ -82,7 +82,12 @@ export const LAB_FEATURES = [ onValue: "auto", offValue: false, activeValues: [true, "auto"], - readEnabled: null, + // Mirrors resolveCodeModeConfig: absence inherits auto; authored objects opt in. + readEnabled: (raw) => + raw === undefined || + raw === true || + raw === "auto" || + (isRecord(raw) && (raw.enabled === true || raw.enabled === "auto")), enableAlso: null, resetScope: "gate", },