Skip to content

Commit 11804a4

Browse files
authored
Fail closed when an explicit agent harness is missing (#71265)
* Fail closed for explicit agent harness selection * Scope explicit harness fallback opt in
1 parent 5adf9d2 commit 11804a4

7 files changed

Lines changed: 145 additions & 36 deletions

File tree

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
bfae9b7760c3372f48d073da40059b0faa43c33f643b4aac3a942932a32df9eb config-baseline.json
2-
c8ff25fcdd2389d5fd88f8ba188d77c21f58b56765b555eecf3b37437f743d50 config-baseline.core.json
1+
0adf332920764704575b21d2fe9568742d977ff0169683319c168d68ea7cf143 config-baseline.json
2+
2936d2ccf0c1e6e932a0e7c617b809e4b31dbb9a7d5afefbba29b229913b9e50 config-baseline.core.json
33
22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json
4-
5bace1f246d5462dcf00ec7d4f378350bc7b6d01141609d704dc8c2e03e2230a config-baseline.plugin.json
4+
28d874a4910174c7014ef2a267269a3327d31ff657f76d38c034ef1b86eae484 config-baseline.plugin.json

docs/gateway/config-agents.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ Time format in system prompt. Default: `auto` (OS preference).
369369
- For direct OpenAI Responses models, server-side compaction is enabled automatically. Use `params.responsesServerCompaction: false` to stop injecting `context_management`, or `params.responsesCompactThreshold` to override the threshold. See [OpenAI server-side compaction](/providers/openai#server-side-compaction-responses-api).
370370
- `params`: global default provider parameters applied to all models. Set at `agents.defaults.params` (e.g. `{ cacheRetention: "long" }`).
371371
- `params` merge precedence (config): `agents.defaults.params` (global base) is overridden by `agents.defaults.models["provider/model"].params` (per-model), then `agents.list[].params` (matching agent id) overrides by key. See [Prompt Caching](/reference/prompt-caching) for details.
372-
- `embeddedHarness`: default low-level embedded agent runtime policy. Use `runtime: "auto"` to let registered plugin harnesses claim supported models, `runtime: "pi"` to force the built-in PI harness, or a registered harness id such as `runtime: "codex"`. Set `fallback: "none"` to disable automatic PI fallback. New Codex harness configs should keep model refs canonical as `openai/*` and select the harness here rather than using legacy `codex/*` model refs.
372+
- `embeddedHarness`: default low-level embedded agent runtime policy. Use `runtime: "auto"` to let registered plugin harnesses claim supported models, `runtime: "pi"` to force the built-in PI harness, or a registered harness id such as `runtime: "codex"`. Automatic PI fallback defaults to `"pi"` only in `auto` mode. Explicit plugin runtimes such as `codex` default to `"none"` unless you set `fallback: "pi"`. New Codex harness configs should keep model refs canonical as `openai/*` and select the harness here rather than using legacy `codex/*` model refs.
373373
- Config writers that mutate these fields (for example `/models set`, `/models set-image`, and fallback add/remove commands) save canonical object form and preserve existing fallback lists when possible.
374374
- `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: 4.
375375

@@ -395,9 +395,9 @@ Codex app-server harness.
395395
```
396396

397397
- `runtime`: `"auto"`, `"pi"`, or a registered plugin harness id. The bundled Codex plugin registers `codex`.
398-
- `fallback`: `"pi"` or `"none"`. `"pi"` keeps the built-in PI harness as the compatibility fallback when no plugin harness is selected. `"none"` makes missing or unsupported plugin harness selection fail instead of silently using PI. Selected plugin harness failures always surface directly.
399-
- Environment overrides: `OPENCLAW_AGENT_RUNTIME=<id|auto|pi>` overrides `runtime`; `OPENCLAW_AGENT_HARNESS_FALLBACK=none` disables PI fallback for that process.
400-
- For Codex-only deployments, set `model: "openai/gpt-5.5"`, `embeddedHarness.runtime: "codex"`, and `embeddedHarness.fallback: "none"`.
398+
- `fallback`: `"pi"` or `"none"`. In `runtime: "auto"`, omitted fallback defaults to `"pi"` so old configs can keep using PI when no plugin harness claims a run. In explicit plugin runtime mode, such as `runtime: "codex"`, omitted fallback defaults to `"none"` so a missing harness fails instead of silently using PI. Runtime overrides do not inherit fallback from a broader scope; set `fallback: "pi"` alongside the explicit runtime when you intentionally want that compatibility fallback. Selected plugin harness failures always surface directly.
399+
- Environment overrides: `OPENCLAW_AGENT_RUNTIME=<id|auto|pi>` overrides `runtime`; `OPENCLAW_AGENT_HARNESS_FALLBACK=pi|none` overrides fallback for that process.
400+
- For Codex-only deployments, set `model: "openai/gpt-5.5"` and `embeddedHarness.runtime: "codex"`. You may also set `embeddedHarness.fallback: "none"` explicitly for readability; it is the default for explicit plugin runtimes.
401401
- Harness choice is pinned per session id after the first embedded run. Config/env changes affect new or reset sessions, not an existing transcript. Legacy sessions with transcript history but no recorded pin are treated as PI-pinned. `/status` shows non-PI harness ids such as `codex` next to `Fast`.
402402
- This only controls the embedded chat harness. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings.
403403

@@ -946,7 +946,7 @@ scripts/sandbox-browser-setup.sh # optional browser image
946946
- `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive | max`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set.
947947
- `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Applies when no per-message or session reasoning override is set.
948948
- `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set.
949-
- `embeddedHarness`: optional per-agent low-level harness policy override. Use `{ runtime: "codex", fallback: "none" }` to make one agent Codex-only while other agents keep the default PI fallback.
949+
- `embeddedHarness`: optional per-agent low-level harness policy override. Use `{ runtime: "codex" }` to make one agent Codex-only while other agents keep the default PI fallback in `auto` mode.
950950
- `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions.
951951
- `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.
952952
- `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`.

docs/plugins/codex-harness.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ title: "Codex harness"
44
read_when:
55
- You want to use the bundled Codex app-server harness
66
- You need Codex harness config examples
7-
- You want to disable PI fallback for Codex-only deployments
7+
- You want Codex-only deployments to fail instead of falling back to PI
88
---
99

1010
The bundled `codex` plugin lets OpenClaw run embedded agent turns through the
@@ -190,8 +190,9 @@ With this shape:
190190

191191
## Codex-only deployments
192192

193-
Disable PI fallback when you need to prove that every embedded agent turn uses
194-
the Codex harness:
193+
Force the Codex harness when you need to prove that every embedded agent turn
194+
uses Codex. Explicit plugin runtimes default to no PI fallback, so
195+
`fallback: "none"` is optional but often useful as documentation:
195196

196197
```json5
197198
{
@@ -210,13 +211,13 @@ the Codex harness:
210211
Environment override:
211212

212213
```bash
213-
OPENCLAW_AGENT_RUNTIME=codex \
214-
OPENCLAW_AGENT_HARNESS_FALLBACK=none \
215-
openclaw gateway run
214+
OPENCLAW_AGENT_RUNTIME=codex openclaw gateway run
216215
```
217216

218-
With fallback disabled, OpenClaw fails early if the Codex plugin is disabled,
219-
the app-server is too old, or the app-server cannot start.
217+
With Codex forced, OpenClaw fails early if the Codex plugin is disabled, the
218+
app-server is too old, or the app-server cannot start. Set
219+
`OPENCLAW_AGENT_HARNESS_FALLBACK=pi` only if you intentionally want PI to handle
220+
missing harness selection.
220221

221222
## Per-agent Codex
222223

@@ -581,12 +582,12 @@ understanding continue to use the matching provider/model settings such as
581582
select an `openai/gpt-*` model with `embeddedHarness.runtime: "codex"` (or a
582583
legacy `codex/*` ref), and check whether `plugins.allow` excludes `codex`.
583584

584-
**OpenClaw uses PI instead of Codex:** if no Codex harness claims the run,
585-
OpenClaw may use PI as the compatibility backend. Set
586-
`embeddedHarness.runtime: "codex"` to force Codex selection while testing, or
587-
`embeddedHarness.fallback: "none"` to fail when no plugin harness matches. Once
588-
Codex app-server is selected, its failures surface directly without extra
589-
fallback config.
585+
**OpenClaw uses PI instead of Codex:** `runtime: "auto"` can still use PI as the
586+
compatibility backend when no Codex harness claims the run. Set
587+
`embeddedHarness.runtime: "codex"` to force Codex selection while testing. A
588+
forced Codex runtime now fails instead of falling back to PI unless you
589+
explicitly set `embeddedHarness.fallback: "pi"`. Once Codex app-server is
590+
selected, its failures surface directly without extra fallback config.
590591

591592
**The app-server is rejected:** upgrade Codex so the app-server handshake
592593
reports version `0.118.0` or newer.

src/agents/harness/selection.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,36 @@ function registerFailingCodexHarness(): void {
100100
}
101101

102102
describe("runAgentHarnessAttemptWithFallback", () => {
103-
it("falls back to the PI harness when a forced plugin harness is unavailable", async () => {
103+
it("fails when a forced plugin harness is unavailable and fallback is omitted", async () => {
104104
process.env.OPENCLAW_AGENT_RUNTIME = "codex";
105105

106+
await expect(runAgentHarnessAttemptWithFallback(createAttemptParams())).rejects.toThrow(
107+
'Requested agent harness "codex" is not registered and PI fallback is disabled.',
108+
);
109+
expect(piRunAttempt).not.toHaveBeenCalled();
110+
});
111+
112+
it("falls back to the PI harness for a forced plugin harness only when explicitly configured", async () => {
113+
process.env.OPENCLAW_AGENT_RUNTIME = "codex";
114+
process.env.OPENCLAW_AGENT_HARNESS_FALLBACK = "pi";
115+
106116
const result = await runAgentHarnessAttemptWithFallback(createAttemptParams());
107117

108118
expect(result.sessionIdUsed).toBe("pi");
109119
expect(piRunAttempt).toHaveBeenCalledTimes(1);
110120
});
111121

122+
it("does not inherit config fallback when env forces a plugin harness", async () => {
123+
process.env.OPENCLAW_AGENT_RUNTIME = "codex";
124+
125+
await expect(
126+
runAgentHarnessAttemptWithFallback(
127+
createAttemptParams({ agents: { defaults: { embeddedHarness: { fallback: "pi" } } } }),
128+
),
129+
).rejects.toThrow('Requested agent harness "codex" is not registered');
130+
expect(piRunAttempt).not.toHaveBeenCalled();
131+
});
132+
112133
it("falls back to the PI harness in auto mode when no plugin harness matches", async () => {
113134
process.env.OPENCLAW_AGENT_RUNTIME = "auto";
114135

@@ -177,6 +198,56 @@ describe("runAgentHarnessAttemptWithFallback", () => {
177198
).rejects.toThrow("PI fallback is disabled");
178199
expect(piRunAttempt).not.toHaveBeenCalled();
179200
});
201+
202+
it("fails for config-forced plugin harnesses when fallback is omitted", async () => {
203+
await expect(
204+
runAgentHarnessAttemptWithFallback(
205+
createAttemptParams({ agents: { defaults: { embeddedHarness: { runtime: "codex" } } } }),
206+
),
207+
).rejects.toThrow('Requested agent harness "codex" is not registered');
208+
expect(piRunAttempt).not.toHaveBeenCalled();
209+
});
210+
211+
it("allows config-forced plugin harnesses to opt into PI fallback", async () => {
212+
const result = await runAgentHarnessAttemptWithFallback(
213+
createAttemptParams({
214+
agents: { defaults: { embeddedHarness: { runtime: "codex", fallback: "pi" } } },
215+
}),
216+
);
217+
218+
expect(result.sessionIdUsed).toBe("pi");
219+
expect(piRunAttempt).toHaveBeenCalledTimes(1);
220+
});
221+
222+
it("does not inherit default fallback when an agent forces a plugin harness", async () => {
223+
await expect(
224+
runAgentHarnessAttemptWithFallback({
225+
...createAttemptParams({
226+
agents: {
227+
defaults: { embeddedHarness: { fallback: "pi" } },
228+
list: [{ id: "strict", embeddedHarness: { runtime: "codex" } }],
229+
},
230+
}),
231+
sessionKey: "agent:strict:session-1",
232+
}),
233+
).rejects.toThrow('Requested agent harness "codex" is not registered');
234+
expect(piRunAttempt).not.toHaveBeenCalled();
235+
});
236+
237+
it("lets an agent-forced plugin harness opt into PI fallback", async () => {
238+
const result = await runAgentHarnessAttemptWithFallback({
239+
...createAttemptParams({
240+
agents: {
241+
defaults: { embeddedHarness: { fallback: "none" } },
242+
list: [{ id: "strict", embeddedHarness: { runtime: "codex", fallback: "pi" } }],
243+
},
244+
}),
245+
sessionKey: "agent:strict:session-1",
246+
});
247+
248+
expect(result.sessionIdUsed).toBe("pi");
249+
expect(piRunAttempt).toHaveBeenCalledTimes(1);
250+
});
180251
});
181252

182253
describe("selectAgentHarness", () => {

src/agents/harness/selection.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -333,12 +333,45 @@ export function resolveAgentHarnessPolicy(params: {
333333
: normalizeEmbeddedAgentRuntime(agentPolicy?.runtime ?? defaultsPolicy?.runtime);
334334
return {
335335
runtime,
336-
fallback:
337-
resolveEmbeddedAgentHarnessFallback(env) ??
338-
normalizeAgentHarnessFallback(agentPolicy?.fallback ?? defaultsPolicy?.fallback),
336+
fallback: resolveAgentHarnessFallbackPolicy({
337+
env,
338+
runtime,
339+
agentPolicy,
340+
defaultsPolicy,
341+
}),
339342
};
340343
}
341344

345+
function resolveAgentHarnessFallbackPolicy(params: {
346+
env: NodeJS.ProcessEnv;
347+
runtime: EmbeddedAgentRuntime;
348+
agentPolicy?: AgentEmbeddedHarnessConfig;
349+
defaultsPolicy?: AgentEmbeddedHarnessConfig;
350+
}): EmbeddedAgentHarnessFallback {
351+
const envFallback = resolveEmbeddedAgentHarnessFallback(params.env);
352+
if (envFallback) {
353+
return envFallback;
354+
}
355+
356+
const envRuntime = params.env.OPENCLAW_AGENT_RUNTIME?.trim();
357+
if (envRuntime && isPluginAgentRuntime(params.runtime)) {
358+
return normalizeAgentHarnessFallback(undefined, params.runtime);
359+
}
360+
361+
if (params.agentPolicy?.runtime) {
362+
return normalizeAgentHarnessFallback(params.agentPolicy.fallback, params.runtime);
363+
}
364+
365+
return normalizeAgentHarnessFallback(
366+
params.agentPolicy?.fallback ?? params.defaultsPolicy?.fallback,
367+
params.runtime,
368+
);
369+
}
370+
371+
function isPluginAgentRuntime(runtime: EmbeddedAgentRuntime): boolean {
372+
return runtime !== "auto" && runtime !== "pi";
373+
}
374+
342375
function resolveAgentEmbeddedHarnessConfig(
343376
config: OpenClawConfig | undefined,
344377
params: { agentId?: string; sessionKey?: string },
@@ -357,8 +390,12 @@ function resolveAgentEmbeddedHarnessConfig(
357390

358391
function normalizeAgentHarnessFallback(
359392
value: AgentEmbeddedHarnessConfig["fallback"] | undefined,
393+
runtime: EmbeddedAgentRuntime,
360394
): EmbeddedAgentHarnessFallback {
361-
return value === "none" ? "none" : "pi";
395+
if (value) {
396+
return value === "none" ? "none" : "pi";
397+
}
398+
return runtime === "auto" ? "pi" : "none";
362399
}
363400

364401
function formatProviderModel(params: { provider: string; modelId?: string }): string {

src/config/schema.base.generated.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3038,7 +3038,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
30383038
enum: ["pi", "none"],
30393039
title: "Default Embedded Harness Fallback",
30403040
description:
3041-
"Embedded harness fallback when no plugin harness matches. Selected plugin harness failures surface directly. Set none to disable automatic PI fallback.",
3041+
"Embedded harness fallback when no plugin harness matches. Auto mode defaults to pi; explicit plugin runtimes default to none and do not inherit broader fallback settings. Selected plugin harness failures surface directly.",
30423042
},
30433043
},
30443044
additionalProperties: false,
@@ -5793,13 +5793,13 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
57935793
enum: ["pi", "none"],
57945794
title: "Agent Embedded Harness Fallback",
57955795
description:
5796-
"Per-agent embedded harness fallback. Set none to disable automatic PI fallback for this agent.",
5796+
"Per-agent embedded harness fallback. Auto mode defaults to pi; explicit plugin runtimes default to none and do not inherit broader fallback settings.",
57975797
},
57985798
},
57995799
additionalProperties: false,
58005800
title: "Agent Embedded Harness",
58015801
description:
5802-
"Per-agent embedded harness policy override. Use fallback=none to make missing plugin harness selection fail instead of falling back to PI.",
5802+
"Per-agent embedded harness policy override. Use runtime=codex to force Codex for one agent while defaults stay in auto mode.",
58035803
},
58045804
model: {
58055805
anyOf: [
@@ -23513,7 +23513,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
2351323513
},
2351423514
"agents.defaults.embeddedHarness.fallback": {
2351523515
label: "Default Embedded Harness Fallback",
23516-
help: "Embedded harness fallback when no plugin harness matches. Selected plugin harness failures surface directly. Set none to disable automatic PI fallback.",
23516+
help: "Embedded harness fallback when no plugin harness matches. Auto mode defaults to pi; explicit plugin runtimes default to none and do not inherit broader fallback settings. Selected plugin harness failures surface directly.",
2351723517
tags: ["reliability"],
2351823518
},
2351923519
"agents.list": {
@@ -23558,7 +23558,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
2355823558
},
2355923559
"agents.list.*.embeddedHarness": {
2356023560
label: "Agent Embedded Harness",
23561-
help: "Per-agent embedded harness policy override. Use fallback=none to make missing plugin harness selection fail instead of falling back to PI.",
23561+
help: "Per-agent embedded harness policy override. Use runtime=codex to force Codex for one agent while defaults stay in auto mode.",
2356223562
tags: ["advanced"],
2356323563
},
2356423564
"agents.list.*.embeddedHarness.runtime": {
@@ -23568,7 +23568,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
2356823568
},
2356923569
"agents.list.*.embeddedHarness.fallback": {
2357023570
label: "Agent Embedded Harness Fallback",
23571-
help: "Per-agent embedded harness fallback. Set none to disable automatic PI fallback for this agent.",
23571+
help: "Per-agent embedded harness fallback. Auto mode defaults to pi; explicit plugin runtimes default to none and do not inherit broader fallback settings.",
2357223572
tags: ["reliability"],
2357323573
},
2357423574
"gateway.port": {

src/config/schema.help.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,13 +1155,13 @@ export const FIELD_HELP: Record<string, string> = {
11551155
"agents.defaults.embeddedHarness.runtime":
11561156
"Embedded harness runtime: auto, pi, or a registered plugin harness id such as codex.",
11571157
"agents.defaults.embeddedHarness.fallback":
1158-
"Embedded harness fallback when no plugin harness matches. Selected plugin harness failures surface directly. Set none to disable automatic PI fallback.",
1158+
"Embedded harness fallback when no plugin harness matches. Auto mode defaults to pi; explicit plugin runtimes default to none and do not inherit broader fallback settings. Selected plugin harness failures surface directly.",
11591159
"agents.list.*.embeddedHarness":
1160-
"Per-agent embedded harness policy override. Use fallback=none to make missing plugin harness selection fail instead of falling back to PI.",
1160+
"Per-agent embedded harness policy override. Use runtime=codex to force Codex for one agent while defaults stay in auto mode.",
11611161
"agents.list.*.embeddedHarness.runtime":
11621162
"Per-agent embedded harness runtime: auto, pi, or a registered plugin harness id such as codex.",
11631163
"agents.list.*.embeddedHarness.fallback":
1164-
"Per-agent embedded harness fallback. Set none to disable automatic PI fallback for this agent.",
1164+
"Per-agent embedded harness fallback. Auto mode defaults to pi; explicit plugin runtimes default to none and do not inherit broader fallback settings.",
11651165
"agents.defaults.imageModel.primary":
11661166
"Optional image model (provider/model) used when the primary model lacks image input.",
11671167
"agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).",

0 commit comments

Comments
 (0)