fix: gate codex acp route hints

This commit is contained in:
Peter Steinberger
2026-04-26 04:32:06 +01:00
parent b58223510c
commit 6336ed4166
12 changed files with 282 additions and 9 deletions

View File

@@ -119,6 +119,7 @@ Docs: https://docs.openclaw.ai
- Browser automation: keep stable tab ids and labels attached when Chromium replaces the raw target after form submissions or other action-triggered navigations, and return the replacement `targetId` from `/act` when the match is provable. Fixes #46137.
- QQ Bot: make `qqbot_remind` schedule, list, and remove Gateway cron jobs directly for owner-authorized senders instead of returning `cronParams` and relying on a follow-up generic `cron` tool call. Fixes #70865. (#70937) Thanks @GaosCode.
- Agents/ACP: hide `sessions_spawn` ACP runtime options unless an ACP backend is loaded, and make `/acp doctor` call out `plugins.allow` blocking bundled `acpx`. Thanks @vincentkoc.
- Agents/Codex: keep ACP prompt/skill routing hidden unless an ACP runtime backend is available, and warn in doctor when enabled Codex plugin configs still route `openai-codex/*` models through PI. Thanks @vincentkoc.
- Media delivery: avoid sending generated image attachments twice when the assistant reply already includes explicit `MEDIA:` lines for the same turn, and reject unsafe remote `MEDIA:` URLs before delivery. Thanks @pashpashpash.
- Codex harness: ignore retryable app-server error notifications after Codex recovers, and preserve the real nested error message for terminal app-server failures instead of replacing it with a generic failure. Thanks @pashpashpash.
- Agents/Codex: prepare native Codex sub-agent session metadata without a

View File

@@ -249,7 +249,7 @@ doctor prints platform-specific fix guidance. On macOS with a Homebrew Node, the
fix is usually `brew postinstall ca-certificates`. With `--deep`, the probe runs
even if the gateway is healthy.
### 2c) Codex OAuth provider overrides
### 2e) Codex OAuth provider overrides
If you previously added legacy OpenAI transport settings under
`models.providers.openai-codex`, they can shadow the built-in Codex OAuth
@@ -259,6 +259,16 @@ the stale transport override and get the built-in routing/fallback behavior
back. Custom proxies and header-only overrides are still supported and do not
trigger this warning.
### 2f) Codex plugin route warnings
When the bundled Codex plugin is enabled, doctor also checks whether
`openai-codex/*` primary model refs still resolve through the default PI runner.
That combination is valid when you want Codex OAuth/subscription auth through
PI, but it is easy to confuse with the native Codex app-server harness. Doctor
warns and points to the explicit app-server shape:
`openai/*` plus `embeddedHarness.runtime: "codex"` or
`OPENCLAW_AGENT_RUNTIME=codex`.
### 3) Legacy state migrations (disk layout)
Doctor can migrate older on-disk layouts into the current structure:

View File

@@ -53,6 +53,11 @@ want native app-server execution. Legacy `codex/*` model refs still auto-select
the harness for compatibility, but runtime-backed legacy provider prefixes are
not shown as normal model/provider choices.
If the `codex` plugin is enabled but the primary model is still
`openai-codex/*`, `openclaw doctor` warns instead of changing the route. That is
intentional: `openai-codex/*` remains the PI Codex OAuth/subscription path, and
native app-server execution stays an explicit runtime choice.
## Pick the right model prefix
OpenAI-family routes are prefix-specific. Use `openai-codex/*` when you want

View File

@@ -42,6 +42,8 @@ Enabling the OpenAI plugin, or selecting an `openai-codex/*` model, does not
enable the bundled Codex app-server plugin. OpenClaw enables that plugin only
when you explicitly select the native Codex harness with
`embeddedHarness.runtime: "codex"` or use a legacy `codex/*` model ref.
If the bundled `codex` plugin is enabled but `openai-codex/*` still resolves
through PI, `openclaw doctor` warns and leaves the route unchanged.
</Note>
## OpenClaw feature coverage

View File

@@ -1,6 +1,10 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
__testing as acpRuntimeTesting,
registerAcpRuntimeBackend,
} from "../../acp/runtime/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js";
import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
@@ -109,9 +113,30 @@ async function setupPluginOutsideSkills() {
return { workspaceDir, pluginRoot, outsideSkills };
}
function registerHealthyAcpBackend() {
registerAcpRuntimeBackend({
id: "acpx",
runtime: {
async ensureSession(input) {
return {
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: input.sessionKey,
};
},
async *runTurn() {
yield { type: "done" as const };
},
async cancel() {},
async close() {},
},
});
}
afterEach(async () => {
hoisted.loadPluginManifestRegistryForInstalledIndex.mockReset();
hoisted.loadPluginRegistrySnapshot.mockReset();
acpRuntimeTesting.resetAcpRuntimeBackendsForTests();
await tempDirs.cleanup();
});
@@ -132,8 +157,9 @@ describe("resolvePluginSkillDirs", () => {
it.each([
{
name: "keeps acpx plugin skills when ACP is enabled",
name: "keeps acpx plugin skills when ACP runtime is available",
acpEnabled: true,
backendAvailable: true,
expectedDirs: ({ acpxRoot, helperRoot }: { acpxRoot: string; helperRoot: string }) => [
path.resolve(acpxRoot, "skills"),
path.resolve(helperRoot, "skills"),
@@ -142,12 +168,24 @@ describe("resolvePluginSkillDirs", () => {
{
name: "skips acpx plugin skills when ACP is disabled",
acpEnabled: false,
backendAvailable: true,
expectedDirs: ({ helperRoot }: { acpxRoot: string; helperRoot: string }) => [
path.resolve(helperRoot, "skills"),
],
},
])("$name", async ({ acpEnabled, expectedDirs }) => {
{
name: "skips acpx plugin skills when no ACP runtime backend is loaded",
acpEnabled: true,
backendAvailable: false,
expectedDirs: ({ helperRoot }: { acpxRoot: string; helperRoot: string }) => [
path.resolve(helperRoot, "skills"),
],
},
])("$name", async ({ acpEnabled, backendAvailable, expectedDirs }) => {
const { workspaceDir, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry();
if (backendAvailable) {
registerHealthyAcpBackend();
}
const dirs = resolvePluginSkillDirs({
workspaceDir,

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
@@ -61,7 +62,7 @@ export function resolvePluginSkillDirs(params: {
params.config?.plugins,
createRegistryPluginIdNormalizer(registry),
);
const acpEnabled = params.config?.acp?.enabled !== false;
const acpRuntimeAvailable = isAcpRuntimeSpawnAvailable({ config: params.config });
const memorySlot = normalizedPlugins.slots.memory;
let selectedMemoryPluginId: string | null = null;
const seen = new Set<string>();
@@ -81,8 +82,8 @@ export function resolvePluginSkillDirs(params: {
if (!activationState.activated) {
continue;
}
// ACP router skills should not be attached when ACP is explicitly disabled.
if (!acpEnabled && record.id === "acpx") {
// ACP router skills should not be attached unless ACP can actually spawn.
if (!acpRuntimeAvailable && record.id === "acpx") {
continue;
}
const memoryDecision = resolveMemorySlotDecision({

View File

@@ -7,7 +7,7 @@ export function buildSubagentSystemPrompt(params: {
childSessionKey: string;
label?: string;
task?: string;
/** Whether ACP-specific routing guidance should be included. Defaults to true. */
/** Whether ACP-specific routing guidance should be included. Defaults to false. */
acpEnabled?: boolean;
/** Registered runtime slash/native command names such as `codex`. */
nativeCommandNames?: string[];
@@ -25,7 +25,7 @@ export function buildSubagentSystemPrompt(params: {
typeof params.maxSpawnDepth === "number"
? params.maxSpawnDepth
: DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
const acpEnabled = params.acpEnabled !== false;
const acpEnabled = params.acpEnabled === true;
const nativeCodexCommandAvailable = (params.nativeCommandNames ?? []).some(
(name) => name.trim().replace(/^\/+/, "").toLowerCase() === "codex",
);

View File

@@ -304,6 +304,7 @@ describe("buildAgentSystemPrompt", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["sessions_spawn"],
acpEnabled: true,
});
expect(prompt).toContain("sessions_spawn");
@@ -318,6 +319,7 @@ describe("buildAgentSystemPrompt", () => {
workspaceDir: "/tmp/openclaw",
toolNames: ["sessions_spawn", "subagents", "agents_list", "exec"],
nativeCommandNames: ["codex"],
acpEnabled: true,
});
expect(prompt).toContain("Native Codex app-server plugin is available");
@@ -358,6 +360,7 @@ describe("buildAgentSystemPrompt", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["sessions_spawn", "subagents", "agents_list", "exec"],
acpEnabled: true,
sandboxInfo: {
enabled: true,
},
@@ -935,6 +938,7 @@ describe("buildSubagentSystemPrompt", () => {
task: "research task",
childDepth: 1,
maxSpawnDepth: 2,
acpEnabled: true,
});
expect(prompt).toContain("## Sub-Agent Spawning");
@@ -981,6 +985,19 @@ describe("buildSubagentSystemPrompt", () => {
expect(prompt).toContain("You CAN spawn your own sub-agents");
});
it("omits ACP spawning guidance by default", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
task: "research task",
childDepth: 1,
maxSpawnDepth: 2,
});
expect(prompt).not.toContain('runtime: "acp"');
expect(prompt).not.toContain("For ACP harness sessions (claudecode/gemini/opencode");
expect(prompt).toContain("You CAN spawn your own sub-agents");
});
it("prefers native Codex commands over Codex ACP when available", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
@@ -988,6 +1005,7 @@ describe("buildSubagentSystemPrompt", () => {
childDepth: 1,
maxSpawnDepth: 2,
nativeCommandNames: ["codex"],
acpEnabled: true,
});
expect(prompt).toContain("Native Codex app-server plugin is available");

View File

@@ -496,7 +496,7 @@ export function buildAgentSystemPrompt(params: {
memoryCitationsMode?: MemoryCitationsMode;
promptContribution?: ProviderSystemPromptContribution;
}) {
const acpEnabled = params.acpEnabled !== false;
const acpEnabled = params.acpEnabled === true;
const sandboxedRuntime = params.sandboxInfo?.enabled === true;
const acpSpawnRuntimeEnabled = acpEnabled && !sandboxedRuntime;
const coreToolSummaries: Record<string, string> = {

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { collectCodexRouteWarnings } from "./codex-route-warnings.js";
function codexPluginConfig(): Pick<OpenClawConfig, "plugins"> {
return {
plugins: {
entries: {
codex: { enabled: true },
},
},
} as Pick<OpenClawConfig, "plugins">;
}
describe("collectCodexRouteWarnings", () => {
it("warns when the Codex plugin is enabled but openai-codex models still route through PI", () => {
const warnings = collectCodexRouteWarnings({
cfg: {
...codexPluginConfig(),
agents: {
defaults: {
model: "openai-codex/gpt-5.5",
},
},
} as OpenClawConfig,
});
expect(warnings).toEqual([expect.stringContaining("Codex plugin is enabled")]);
expect(warnings[0]).toContain("agents.defaults.model");
expect(warnings[0]).toContain('runtime "pi"');
expect(warnings[0]).toContain('embeddedHarness.runtime: "codex"');
});
it("does not warn when the native Codex runtime is selected", () => {
const warnings = collectCodexRouteWarnings({
cfg: {
...codexPluginConfig(),
agents: {
defaults: {
model: "openai-codex/gpt-5.5",
embeddedHarness: {
runtime: "codex",
},
},
},
} as OpenClawConfig,
});
expect(warnings).toEqual([]);
});
it("does not warn when OPENCLAW_AGENT_RUNTIME selects native Codex", () => {
const warnings = collectCodexRouteWarnings({
cfg: {
...codexPluginConfig(),
agents: {
defaults: {
model: "openai-codex/gpt-5.5",
},
},
} as OpenClawConfig,
env: {
OPENCLAW_AGENT_RUNTIME: "codex",
},
});
expect(warnings).toEqual([]);
});
it("does not warn unless the Codex plugin is explicitly enabled or allowed", () => {
const warnings = collectCodexRouteWarnings({
cfg: {
agents: {
defaults: {
model: "openai-codex/gpt-5.5",
},
},
} as OpenClawConfig,
});
expect(warnings).toEqual([]);
});
});

View File

@@ -0,0 +1,112 @@
import type {
AgentEmbeddedHarnessConfig,
AgentModelConfig,
} from "../../../config/types.agents-shared.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
type CodexPiRouteHit = {
path: string;
model: string;
runtime: string;
};
function normalizeString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
}
function normalizeModelRef(model: AgentModelConfig | undefined): string | undefined {
if (typeof model === "string") {
return model.trim() || undefined;
}
return typeof model?.primary === "string" && model.primary.trim()
? model.primary.trim()
: undefined;
}
function isOpenAICodexModelRef(model: string | undefined): model is string {
return normalizeString(model)?.startsWith("openai-codex/") === true;
}
function isCodexPluginEnabled(cfg: OpenClawConfig): boolean {
const plugins = cfg.plugins;
if (plugins?.enabled === false) {
return false;
}
const allow = plugins?.allow;
if (Array.isArray(allow) && !allow.map((entry) => normalizeString(entry)).includes("codex")) {
return false;
}
return (
plugins?.entries?.codex?.enabled === true ||
(Array.isArray(allow) && allow.map((entry) => normalizeString(entry)).includes("codex"))
);
}
function resolveRuntime(params: {
env?: NodeJS.ProcessEnv;
agentHarness?: AgentEmbeddedHarnessConfig;
defaultsHarness?: AgentEmbeddedHarnessConfig;
}): string {
return (
normalizeString(params.env?.OPENCLAW_AGENT_RUNTIME) ??
normalizeString(params.agentHarness?.runtime) ??
normalizeString(params.defaultsHarness?.runtime) ??
"pi"
);
}
function collectOpenAICodexPiRouteHits(
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): CodexPiRouteHit[] {
const defaults = cfg.agents?.defaults;
const defaultsHarness = defaults?.embeddedHarness;
const hits: CodexPiRouteHit[] = [];
const defaultModel = normalizeModelRef(defaults?.model);
const defaultRuntime = resolveRuntime({ env, defaultsHarness });
if (isOpenAICodexModelRef(defaultModel) && defaultRuntime !== "codex") {
hits.push({ path: "agents.defaults.model", model: defaultModel, runtime: defaultRuntime });
}
for (const agent of cfg.agents?.list ?? []) {
const model = normalizeModelRef(agent.model);
if (!isOpenAICodexModelRef(model)) {
continue;
}
const runtime = resolveRuntime({
env,
agentHarness: agent.embeddedHarness,
defaultsHarness,
});
if (runtime === "codex") {
continue;
}
const id = typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : "<unknown>";
hits.push({ path: `agents.list.${id}.model`, model, runtime });
}
return hits;
}
export function collectCodexRouteWarnings(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): string[] {
if (!isCodexPluginEnabled(params.cfg)) {
return [];
}
const hits = collectOpenAICodexPiRouteHits(params.cfg, params.env);
if (hits.length === 0) {
return [];
}
return [
[
"- Codex plugin is enabled, but `openai-codex/*` model refs still use the OpenClaw PI runner unless `embeddedHarness.runtime` is `codex`.",
...hits.map(
(hit) => `- ${hit.path}: ${hit.model} currently resolves with runtime "${hit.runtime}".`,
),
'- To use native Codex app-server, set the model to `openai/<model>` and set `agents.defaults.embeddedHarness.runtime: "codex"` (or the agent-level equivalent).',
"- Leave this unchanged if you intentionally want Codex OAuth/subscription auth through PI.",
].join("\n"),
];
}

View File

@@ -139,6 +139,9 @@ export async function collectDoctorPreviewWarnings(params: {
}).join("\n"),
);
}
const { collectCodexRouteWarnings } = await import("./codex-route-warnings.js");
warnings.push(...collectCodexRouteWarnings({ cfg: params.cfg, env }));
}
if (hasPluginLoadPaths(params.cfg)) {