fix(agents): safe codex tool-terminal continuation, auth-true simple-completion routes, doctor opt-out (#108966)

* fix(agents): safe codex continuation, auth-true simple-completion routes, doctor opt-out

Three verified codex-cluster fixes:

- Tool-call-terminal turns (stopReason=toolUse, no final assistant message)
  now get one bounded continuation instead of dying with an incomplete-turn
  error. Completion is proven per tool-call id (every terminal toolCall needs
  a non-error toolResult in the snapshot) so undispatched or partially
  dispatched batches keep failing closed; the continuation appends a fresh
  native-thread turn and never replays completed tools. (#108517)
- Simple-completion (narration/titles/labels) resolves auth before finalizing
  official dual-route OpenAI models and re-materializes the auth-compatible
  physical route through the runtime-plan seam; api-key credentials are no
  longer legal for the ChatGPT-backend codex transport (fail closed), ending
  silent 401s. Custom base URLs and explicit refs stay honored. (#104779)
- openclaw doctor --fix no longer flips an explicit plugins.entries.codex
  enabled:false; explicit user opt-out now warns with a fix hint like the
  deny-list case, while missing-entry and allowlist repairs stay automatic. (#97180)

Fixes #108517
Fixes #104779
Fixes #97180

* fix(agents): accept ChatGPT token profiles on codex transport, thread messagesSnapshot, update sibling hints

The symmetric codex-transport auth gate now accepts both subscription
credential classes (oauth and ChatGPT token) instead of oauth only, so
prepared-candidate fallback keeps skipping to a valid subscription profile.
attempt-result threads messagesSnapshot into the incomplete-turn attempt
shape, and the doctor-lint sibling test expects the manual hint now that an
explicit plugins.entries.codex.enabled=false blocks auto-repair.

* chore(agents): route test mock casts through unknown, un-export unused doctor helper
This commit is contained in:
Peter Steinberger
2026-07-16 05:35:57 -07:00
committed by GitHub
parent 175181f42f
commit 2848acbbaa
16 changed files with 725 additions and 186 deletions

View File

@@ -33,6 +33,7 @@ import {
resolveReplayInvalidFlag,
resolveRunLivenessState,
resolveSilentToolResultReplyPayload,
resolveToolUseTerminalContinuationInstruction,
shouldRetryMissingAssistantTurn,
shouldRetrySilentErrorAssistantTurn,
shouldTreatEmptyAssistantReplyAsSilent,
@@ -43,6 +44,8 @@ const REASONING_ONLY_RETRY_INSTRUCTION =
"The previous assistant turn recorded reasoning but did not produce a user-visible answer. Continue from that partial turn and produce the visible answer now. Do not restate the reasoning or restart from scratch.";
const EMPTY_RESPONSE_RETRY_INSTRUCTION =
"The previous attempt did not produce a user-visible answer. Continue from the current state and produce the visible answer now. Do not restart from scratch.";
const TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION =
"The previous assistant turn completed its tool calls but did not produce a user-visible answer. Continue from the current transcript and produce the final user-visible answer now. Do not repeat completed tool calls or restart from scratch.";
let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
@@ -1007,6 +1010,159 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
expectWarnMessageWith("reasoning-only assistant turn detected");
});
it("continues once after settled side-effecting tools finish without a final answer", async () => {
const toolUseAssistant = {
role: "assistant",
stopReason: "toolUse",
provider: "openai",
model: "gpt-5.5",
content: [{ type: "toolCall", id: "tool_1", name: "write", arguments: { path: "note.txt" } }],
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
const settledToolResults = [
{ role: "toolResult", toolCallId: "tool_1", toolName: "write", isError: false },
] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"];
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams) => {
markUserMessagePersisted(attemptParams);
return makeAttemptResult({
assistantTexts: [],
toolMetas: [{ toolName: "write", meta: "path=note.txt" }],
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
messagesSnapshot: settledToolResults,
lastAssistant: toolUseAssistant,
currentAttemptAssistant: toolUseAssistant,
});
});
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({ assistantTexts: ["Write completed. Here is the final answer."] }),
);
mockedBuildEmbeddedRunPayloads
.mockReturnValueOnce([])
.mockReturnValueOnce([{ text: "Write completed. Here is the final answer." }]);
const result = await runEmbeddedAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.5",
runId: "run-tool-use-terminal-continuation",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(result.payloads?.[0]?.text).toBe("Write completed. Here is the final answer.");
const secondCall = runAttemptCall(1);
expect(secondCall.prompt).toBe(TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION);
expect(secondCall.suppressNextUserMessagePersistence).toBe(false);
expect(secondCall.skipPreparedUserTurnMessage).toBe(true);
expectWarnMessageWith("tool-use terminal turn lacked a final answer");
});
it("surfaces the existing incomplete-turn error after one tool-use continuation", async () => {
const toolUseAssistant = {
role: "assistant",
stopReason: "toolUse",
provider: "openai",
model: "gpt-5.5",
content: [{ type: "toolCall", id: "tool_1", name: "write", arguments: { path: "note.txt" } }],
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValue(
makeAttemptResult({
assistantTexts: [],
toolMetas: [{ toolName: "write", meta: "path=note.txt" }],
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
messagesSnapshot: [
{ role: "toolResult", toolCallId: "tool_1", toolName: "write", isError: false },
] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"],
lastAssistant: toolUseAssistant,
currentAttemptAssistant: toolUseAssistant,
}),
);
const result = await runEmbeddedAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.5",
runId: "run-tool-use-terminal-continuation-exhausted",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(result.payloads?.[0]?.isError).toBe(true);
expect(result.payloads?.[0]?.text).toContain(
"some tool actions may have already been executed",
);
expectWarnMessageWith("toolUseContinuations=1/1");
});
it("does not claim completion for a toolUse terminal whose tools never started", async () => {
const toolUseAssistant = {
role: "assistant",
stopReason: "toolUse",
provider: "openai",
model: "gpt-5.5",
content: [{ type: "toolCall", id: "tool_1", name: "write", arguments: { path: "note.txt" } }],
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValue(
makeAttemptResult({
assistantTexts: [],
toolMetas: [],
itemLifecycle: { startedCount: 0, completedCount: 0, activeCount: 0 },
lastAssistant: toolUseAssistant,
currentAttemptAssistant: toolUseAssistant,
}),
);
await runEmbeddedAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.5",
runId: "run-tool-use-terminal-never-started",
});
for (let call = 0; call < mockedRunEmbeddedAttempt.mock.calls.length; call += 1) {
expect(runAttemptCall(call).prompt).not.toContain(TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION);
}
expectNoWarnMessageWith("tool-use terminal turn lacked a final answer");
});
it("does not claim completion when only part of a multi-tool request dispatched", async () => {
const toolUseAssistant = {
role: "assistant",
stopReason: "toolUse",
provider: "openai",
model: "gpt-5.5",
content: [
{ type: "toolCall", id: "tool_1", name: "write", arguments: { path: "a.txt" } },
{ type: "toolCall", id: "tool_2", name: "write", arguments: { path: "b.txt" } },
],
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValue(
makeAttemptResult({
assistantTexts: [],
toolMetas: [{ toolName: "write", meta: "path=a.txt" }],
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
messagesSnapshot: [
{ role: "toolResult", toolCallId: "tool_1", toolName: "write", isError: false },
] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"],
lastAssistant: toolUseAssistant,
currentAttemptAssistant: toolUseAssistant,
}),
);
await runEmbeddedAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.5",
runId: "run-tool-use-terminal-partial-dispatch",
});
for (let call = 0; call < mockedRunEmbeddedAttempt.mock.calls.length; call += 1) {
expect(runAttemptCall(call).prompt).not.toContain(TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION);
}
expectNoWarnMessageWith("tool-use terminal turn lacked a final answer");
});
it("returns NO_REPLY without retrying reasoning-only assistant turns when silence is allowed", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
@@ -1903,6 +2059,38 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
).toBe(true);
});
it.each([
{ label: "aborted", aborted: true, timedOut: false, promptError: null },
{ label: "timed out", aborted: false, timedOut: true, promptError: null },
{ label: "prompt error", aborted: false, timedOut: false, promptError: new Error("closed") },
])("does not continue a $label tool-use terminal turn", ({ aborted, timedOut, promptError }) => {
const toolUseAssistant = {
role: "assistant",
stopReason: "toolUse",
provider: "openai",
model: "gpt-5.5",
content: [{ type: "tool_use", id: "tool_1", name: "bash", input: {} }],
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
const instruction = resolveToolUseTerminalContinuationInstruction({
provider: "openai",
modelId: "gpt-5.5",
modelApi: "openai-chatgpt-responses",
payloadCount: 0,
aborted,
timedOut,
promptError,
attempt: makeAttemptResult({
assistantTexts: [],
toolMetas: [{ toolName: "bash" }],
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
lastAssistant: toolUseAssistant,
currentAttemptAssistant: toolUseAssistant,
}),
});
expect(instruction).toBeNull();
});
it("does not flag stale lastAssistant=toolUse when currentAttemptAssistant=stop exists (#80918)", () => {
const incompleteTurnText = resolveIncompleteTurnPayloadText({
payloadCount: 1,

View File

@@ -367,6 +367,7 @@ export function completeEmbeddedAttemptResult(
lastToolError,
lastAssistant: state.lastAssistant,
itemLifecycle: getItemLifecycle(),
messagesSnapshot: state.messagesSnapshot,
toolMetas: toolMetasNormalized,
replayMetadata,
promptErrorSource: state.promptErrorSource,

View File

@@ -56,6 +56,7 @@ type IncompleteTurnAttempt = Pick<
| "lastToolError"
| "lastAssistant"
| "itemLifecycle"
| "messagesSnapshot"
| "replayMetadata"
| "promptErrorSource"
| "timedOutDuringCompaction"
@@ -136,6 +137,8 @@ const REASONING_ONLY_RETRY_INSTRUCTION =
"The previous assistant turn recorded reasoning but did not produce a user-visible answer. Continue from that partial turn and produce the visible answer now. Do not restate the reasoning or restart from scratch.";
const EMPTY_RESPONSE_RETRY_INSTRUCTION =
"The previous attempt did not produce a user-visible answer. Continue from the current state and produce the visible answer now. Do not restart from scratch.";
const TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION =
"The previous assistant turn completed its tool calls but did not produce a user-visible answer. Continue from the current transcript and produce the final user-visible answer now. Do not repeat completed tool calls or restart from scratch.";
/**
* Marks whether retrying the attempt can safely replay the prompt. Concrete
@@ -722,6 +725,76 @@ export function resolveReasoningOnlyRetryInstruction(params: {
return REASONING_ONLY_RETRY_INSTRUCTION;
}
/** Builds a fresh continuation for a clean tool-use terminal turn with settled tool activity. */
export function resolveToolUseTerminalContinuationInstruction(params: {
provider?: string;
modelId?: string;
modelApi?: string;
executionContract?: string;
payloadCount: number;
hasTerminalToolPresentation?: boolean;
aborted: boolean;
promptError?: unknown;
timedOut: boolean;
attempt: IncompleteTurnAttempt;
}): string | null {
const assistant = params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant;
// Idle is not proof of completion: a toolUse terminal whose requested tools never
// (or only partially) dispatched must keep the incomplete-turn error, or the model
// could claim skipped side effects succeeded. Lifecycle counts are attempt-cumulative
// and alias across batches, so completion is proven per tool-call id: every toolCall
// in the terminal assistant needs a non-error toolResult in the message snapshot.
const requestedToolCallIds = Array.isArray(assistant?.content)
? assistant.content.flatMap((item) => {
const block = item as { type?: unknown; id?: unknown } | null;
return block?.type === "toolCall" ? [typeof block.id === "string" ? block.id : null] : [];
})
: [];
const completedToolCallIds = new Set(
(params.attempt.messagesSnapshot ?? []).flatMap((message) => {
const result = message as { role?: unknown; toolCallId?: unknown; isError?: unknown };
return result.role === "toolResult" &&
result.isError !== true &&
typeof result.toolCallId === "string"
? [result.toolCallId]
: [];
}),
);
const allToolsProvenComplete =
params.attempt.itemLifecycle?.activeCount === 0 &&
requestedToolCallIds.length > 0 &&
requestedToolCallIds.every((id) => id !== null && completedToolCallIds.has(id));
if (
params.payloadCount !== 0 ||
params.hasTerminalToolPresentation ||
params.aborted ||
params.promptError != null ||
params.timedOut ||
assistant?.stopReason !== "toolUse" ||
!allToolsProvenComplete ||
params.attempt.lastToolError ||
params.attempt.clientToolCalls ||
params.attempt.yieldDetected ||
params.attempt.didSendDeterministicApprovalPrompt
) {
return null;
}
if (hasMessagingToolDeliveryEvidence(params.attempt)) {
return null;
}
if (
!shouldApplyNonVisibleTurnRetryGuard({
provider: params.provider,
modelId: params.modelId,
modelApi: params.modelApi,
executionContract: params.executionContract,
})
) {
return null;
}
return TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION;
}
/**
* Builds the retry instruction for empty assistant turns when the provider/model
* is eligible for non-visible turn recovery.

View File

@@ -26,6 +26,7 @@ import {
resolveReasoningOnlyRetryInstruction,
resolveRunLivenessState,
resolveSilentToolResultReplyPayload,
resolveToolUseTerminalContinuationInstruction,
shouldRetryMissingAssistantTurn,
shouldTreatEmptyAssistantReplyAsSilent,
} from "./incomplete-turn.js";
@@ -37,6 +38,7 @@ import {
import type { EmbeddedRunAttemptResult } from "./types.js";
const MAX_MISSING_ASSISTANT_RETRIES = 1;
const MAX_TOOL_USE_TERMINAL_CONTINUATIONS = 1;
const COMPACTION_CONTINUATION_RETRY_INSTRUCTION =
"The previous attempt compacted the conversation context before producing a final user-visible answer. Continue from the compacted transcript and produce the final answer now. Do not restart from scratch, do not repeat completed work, and do not rerun tools unless the transcript clearly lacks required evidence.";
const BEFORE_AGENT_FINALIZE_RETRY_PROMPT_PREFIX =
@@ -202,6 +204,36 @@ export async function resolveEmbeddedRunTerminal(input: {
);
return { action: "retry" };
}
const availableTerminalToolPresentation = input.readTerminalToolPresentation();
const nextToolUseTerminalContinuationInstruction = emptyAssistantReplyIsSilent
? null
: resolveToolUseTerminalContinuationInstruction({
provider: input.activeErrorContext.provider,
modelId: input.activeErrorContext.model,
modelApi: input.modelApi,
executionContract: input.executionContract,
payloadCount,
hasTerminalToolPresentation: Boolean(availableTerminalToolPresentation),
aborted: input.terminalAborted,
promptError: input.promptError,
timedOut: input.terminalTimedOut,
attempt,
});
if (
nextToolUseTerminalContinuationInstruction &&
retryState.toolUseContinuationAttempts < MAX_TOOL_USE_TERMINAL_CONTINUATIONS
) {
retryState.toolUseContinuationAttempts += 1;
// This starts a new persisted native-thread turn after settled tool results; it does not
// replay the failed prompt or completed tools. Therefore replaySafe does not apply.
input.activateInternalPrompt(nextToolUseTerminalContinuationInstruction, false);
log.warn(
`tool-use terminal turn lacked a final answer: runId=${runParams.runId} sessionId=${runParams.sessionId} ` +
`provider=${input.activeErrorContext.provider}/${input.activeErrorContext.model} — continuing ${retryState.toolUseContinuationAttempts}/${MAX_TOOL_USE_TERMINAL_CONTINUATIONS} ` +
`from settled tool results`,
);
return { action: "retry" };
}
const incompleteTurnText = emptyAssistantReplyIsSilent
? null
: resolveIncompleteTurnPayloadText({
@@ -220,7 +252,7 @@ export async function resolveEmbeddedRunTerminal(input: {
!input.replayState.hadPotentialSideEffects,
);
const terminalToolPresentation = incompleteTurnFallbackSafe
? input.readTerminalToolPresentation()
? availableTerminalToolPresentation
: undefined;
if (
!emptyAssistantReplyIsSilent &&
@@ -282,7 +314,8 @@ export async function resolveEmbeddedRunTerminal(input: {
`tools=${attempt.toolMetas?.length ?? 0} replaySafe=${replayMetadata.replaySafe ? "yes" : "no"} ` +
`compactions=${input.attemptCompactionCount} reasoningRetries=${retryState.reasoningOnlyAttempts}/${input.maxReasoningOnlyRetryAttempts} ` +
`emptyRetries=${retryState.emptyResponseAttempts}/${input.maxEmptyResponseRetryAttempts} ` +
`missingAssistantRetries=${retryState.missingAssistantAttempts}/${MAX_MISSING_ASSISTANT_RETRIES} ` +
`missingAssistantRetries=${retryState.missingAssistantAttempts}/${MAX_MISSING_ASSISTANT_RETRIES} ` +
`toolUseContinuations=${retryState.toolUseContinuationAttempts}/${MAX_TOOL_USE_TERMINAL_CONTINUATIONS}` +
(terminalToolPresentation
? "surfacing tool-authored terminal presentation"
: "surfacing error to user"),

View File

@@ -4,6 +4,7 @@ export type EmbeddedRunTerminalRetryState = {
reasoningOnlyAttempts: number;
emptyResponseAttempts: number;
missingAssistantAttempts: number;
toolUseContinuationAttempts: number;
compactionContinuationAttempts: number;
compactionContinuationInstruction: string | null;
beforeFinalizeRevisionAttempts: number;
@@ -14,6 +15,7 @@ export function createEmbeddedRunTerminalRetryState(): EmbeddedRunTerminalRetryS
reasoningOnlyAttempts: 0,
emptyResponseAttempts: 0,
missingAssistantAttempts: 0,
toolUseContinuationAttempts: 0,
compactionContinuationAttempts: 0,
compactionContinuationInstruction: null,
beforeFinalizeRevisionAttempts: 0,

View File

@@ -511,6 +511,32 @@ describe("getApiKeyForModel", () => {
).rejects.toThrow(/requires an OpenAI API key profile/);
});
it("rejects an explicit OpenAI API-key profile for the Codex transport", async () => {
const store = {
version: 1 as const,
profiles: {
"openai:api-key": {
type: "api_key" as const,
provider: "openai",
key: "direct-openai-key",
},
},
};
await expect(
getApiKeyForModel({
model: {
id: "gpt-5.5",
provider: "openai",
api: "openai-chatgpt-responses",
} as Model,
profileId: "openai:api-key",
lockedProfile: true,
store,
}),
).rejects.toThrow(/requires a ChatGPT subscription \(OAuth or token\) profile/);
});
it("uses the config default agent dir when resolving provider profiles", async () => {
await withOpenClawTestState(
{

View File

@@ -133,11 +133,26 @@ function directOpenAIPlatformModelRequiresApiKey(params: {
);
}
function openAICodexTransportRequiresOAuth(params: {
provider: string;
modelApi?: string;
}): boolean {
return (
normalizeProviderId(params.provider) === OPENAI_PROVIDER_ID &&
normalizeLowercaseStringOrEmpty(params.modelApi ?? "") === OPENAI_CODEX_RESPONSES_API
);
}
function isAuthModeAllowedForModel(params: {
provider: string;
modelApi?: string;
mode: ResolvedProviderAuth["mode"];
}): boolean {
if (openAICodexTransportRequiresOAuth(params)) {
// Subscription-class credentials are oauth profiles and ChatGPT tokens;
// api-key must fail closed here or the codex backend 401s at request time.
return params.mode === "oauth" || params.mode === "token";
}
return !directOpenAIPlatformModelRequiresApiKey(params) || params.mode === "api-key";
}
@@ -150,6 +165,11 @@ function assertAuthModeAllowedForModel(params: {
if (isAuthModeAllowedForModel(params)) {
return;
}
if (openAICodexTransportRequiresOAuth(params)) {
throw new Error(
`Auth profile "${params.profileId}" uses ${params.mode} auth, but ${params.provider}/${params.modelApi} requires a ChatGPT subscription (OAuth or token) profile.`,
);
}
throw new Error(
`Auth profile "${params.profileId}" uses ${params.mode} auth, but ${params.provider}/${params.modelApi} requires an OpenAI API key profile.`,
);

View File

@@ -24,6 +24,7 @@ const hoisted = vi.hoisted(() => ({
prepareModelForSimpleCompletionMock: vi.fn((params: { model: unknown }) => params.model),
completeMock: vi.fn(),
ensureAuthProfileStoreMock: vi.fn(),
getCurrentPluginMetadataSnapshotMock: vi.fn(),
}));
vi.mock("../llm/stream.js", () => ({
@@ -39,6 +40,11 @@ vi.mock("./auth-profiles/store.js", () => ({
ensureAuthProfileStore: hoisted.ensureAuthProfileStoreMock,
}));
vi.mock("../plugins/current-plugin-metadata-snapshot.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../plugins/current-plugin-metadata-snapshot.js")>()),
getCurrentPluginMetadataSnapshot: hoisted.getCurrentPluginMetadataSnapshotMock,
}));
vi.mock("./simple-completion-transport.js", () => ({
prepareModelForSimpleCompletion: hoisted.prepareModelForSimpleCompletionMock,
}));
@@ -50,6 +56,7 @@ vi.mock("./model-auth.js", () => ({
`No API key resolved for provider "${provider}" (auth mode: ${auth.mode}, checked: ${auth.source}).`,
),
getApiKeyForModel: hoisted.getApiKeyForModelMock,
resolveApiKeyForProvider: hoisted.getApiKeyForModelMock,
applyLocalNoAuthHeaderOverride: hoisted.applyLocalNoAuthHeaderOverrideMock,
}));
@@ -73,6 +80,7 @@ beforeEach(() => {
hoisted.prepareModelForSimpleCompletionMock.mockReset();
hoisted.completeMock.mockReset();
hoisted.ensureAuthProfileStoreMock.mockReset();
hoisted.getCurrentPluginMetadataSnapshotMock.mockReset();
hoisted.applyLocalNoAuthHeaderOverrideMock.mockImplementation((model: unknown) => model);
hoisted.prepareModelForSimpleCompletionMock.mockImplementation(
@@ -109,6 +117,21 @@ beforeEach(() => {
},
);
hoisted.ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: {} });
hoisted.getCurrentPluginMetadataSnapshotMock.mockReturnValue({
plugins: [
{
id: "openai",
modelCatalog: {
providers: {
openai: {
defaultUtilityModel: "gpt-5.5",
models: [{ id: "gpt-5.5" }],
},
},
},
},
],
});
});
function expectPreparedModelResult(
@@ -128,6 +151,28 @@ function callArg(mock: { mock: { calls: unknown[][] } }, index = 0): unknown {
return call[0];
}
function createOpenAIRouteModelResolver(params: {
api: "openai-responses" | "openai-chatgpt-responses";
baseUrl: string;
}) {
return vi.fn(async (...args: Parameters<typeof resolveModelAsync>) => {
const [provider, modelId, , cfg] = args;
const configured = cfg?.models?.providers?.openai;
return {
model: {
provider,
id: modelId,
api: configured?.api ?? params.api,
baseUrl: configured?.baseUrl ?? params.baseUrl,
} as Model,
authStorage: {
setRuntimeApiKey: hoisted.setRuntimeApiKeyMock,
},
modelRegistry: {},
};
});
}
describe("prepareSimpleCompletionModel", () => {
it("resolves model auth and sets runtime api key", async () => {
hoisted.getApiKeyForModelMock.mockResolvedValueOnce({
@@ -655,51 +700,147 @@ describe("prepareSimpleCompletionModel", () => {
});
describe("prepareSimpleCompletionModelForAgent", () => {
it("uses Codex auth provider for OpenAI model refs with Codex runtime policy", async () => {
it("materializes a derived utility model on the Platform route for API-key auth", async () => {
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
model: "openai/gpt-5.5",
models: {
"openai/gpt-5.4-mini": { agentRuntime: { id: "codex" } },
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
},
},
},
} as OpenClawConfig;
hoisted.resolveModelAsyncMock.mockResolvedValueOnce({
model: {
provider: "openai",
id: "gpt-5.4-mini",
} as unknown as OpenClawConfig;
const modelResolver = createOpenAIRouteModelResolver({
api: "openai-chatgpt-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
});
hoisted.getApiKeyForModelMock.mockResolvedValue({
apiKey: "placeholder",
profileId: "openai:platform",
source: "profile:openai:platform",
mode: "api-key",
});
const result = await prepareSimpleCompletionModelForAgent({
cfg,
agentId: "main",
useUtilityModel: true,
skipAgentDiscovery: true,
modelResolver: modelResolver as unknown as typeof resolveModelAsync,
});
expectPreparedModelResult(result);
expect(result.selection.provider).toBe("openai");
expect(result.selection.modelId).toBe("gpt-5.5");
expect(result.selection.runtimeProvider).toBe("openai");
expect(result.model).toMatchObject({
id: "gpt-5.5",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
});
expect(modelResolver).toHaveBeenCalledTimes(2);
expect(
(callArg(hoisted.getApiKeyForModelMock, 1) as { model?: { api?: string } }).model?.api,
).toBe("openai-responses");
});
it("keeps the Codex route for OAuth auth", async () => {
const cfg = {
agents: { defaults: { model: "openai/gpt-5.5" } },
} as unknown as OpenClawConfig;
const modelResolver = createOpenAIRouteModelResolver({
api: "openai-chatgpt-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
});
hoisted.getApiKeyForModelMock.mockResolvedValue({
apiKey: "placeholder",
profileId: "openai:chatgpt",
source: "profile:openai:chatgpt",
mode: "oauth",
});
const result = await prepareSimpleCompletionModelForAgent({
cfg,
agentId: "main",
modelRef: "openai/gpt-5.5",
skipAgentDiscovery: true,
modelResolver: modelResolver as unknown as typeof resolveModelAsync,
});
expectPreparedModelResult(result);
expect(result.selection.modelId).toBe("gpt-5.5");
expect(result.model).toMatchObject({
api: "openai-chatgpt-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
});
expect(modelResolver).toHaveBeenCalledTimes(1);
expect(hoisted.getApiKeyForModelMock).toHaveBeenCalledTimes(2);
});
it("keeps an authored custom OpenAI route untouched", async () => {
const cfg = {
models: {
providers: {
openai: {
api: "openai-responses",
baseUrl: "https://relay.example/v1",
models: [{ id: "gpt-5.5" }],
},
},
},
authStorage: {
setRuntimeApiKey: hoisted.setRuntimeApiKeyMock,
},
modelRegistry: {},
agents: { defaults: { model: "openai/gpt-5.5" } },
} as unknown as OpenClawConfig;
const modelResolver = createOpenAIRouteModelResolver({
api: "openai-responses",
baseUrl: "https://relay.example/v1",
});
hoisted.getApiKeyForModelMock.mockResolvedValue({
apiKey: "placeholder",
source: "models.providers.openai",
mode: "api-key",
});
const result = await prepareSimpleCompletionModelForAgent({
cfg,
agentId: "main",
skipAgentDiscovery: true,
modelResolver: hoisted.resolveModelAsyncMock,
modelResolver: modelResolver as unknown as typeof resolveModelAsync,
});
expectPreparedModelResult(result);
expect(result.selection.provider).toBe("openai");
expect(result.selection.modelId).toBe("gpt-5.4-mini");
expect(result.selection.runtimeProvider).toBe("openai");
expect(hoisted.resolveModelAsyncMock).toHaveBeenCalledWith(
"openai",
"gpt-5.4-mini",
expect.any(String),
expect(result.model).toMatchObject({
api: "openai-responses",
baseUrl: "https://relay.example/v1",
});
expect(modelResolver).toHaveBeenCalledTimes(1);
});
it("honors an explicit model ref while selecting its auth-compatible route", async () => {
const cfg = {
agents: { defaults: { model: "anthropic/claude-opus-4-6" } },
} as unknown as OpenClawConfig;
const modelResolver = createOpenAIRouteModelResolver({
api: "openai-chatgpt-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
});
hoisted.getApiKeyForModelMock.mockResolvedValue({
apiKey: "placeholder",
source: "env:OPENAI_API_KEY",
mode: "api-key",
});
const result = await prepareSimpleCompletionModelForAgent({
cfg,
{
skipAgentDiscovery: true,
},
);
expect(
(callArg(hoisted.getApiKeyForModelMock) as { model?: { provider?: string } }).model?.provider,
).toBe("openai");
agentId: "main",
modelRef: "openai/gpt-5.5",
skipAgentDiscovery: true,
modelResolver: modelResolver as unknown as typeof resolveModelAsync,
});
expectPreparedModelResult(result);
expect(result.selection).toMatchObject({ provider: "openai", modelId: "gpt-5.5" });
expect(result.model).toMatchObject({ id: "gpt-5.5", api: "openai-responses" });
});
});

View File

@@ -30,6 +30,7 @@ import {
applyLocalNoAuthHeaderOverride,
formatMissingAuthError,
getApiKeyForModel,
resolveApiKeyForProvider,
type ResolvedProviderAuth,
} from "./model-auth.js";
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
@@ -38,9 +39,16 @@ import {
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "./model-selection.js";
import { resolveOpenAIModelRoutes, selectOpenAIModelRouteAuth } from "./openai-model-routes.js";
import { OPENAI_PROVIDER_ID, isOpenAIProvider } from "./openai-routing.js";
import {
buildProviderModelAuthDirectSource,
buildProviderModelAuthSourcePlan,
} from "./provider-model-auth-source-plan.js";
import { applyPreparedRuntimeAuthToModel } from "./provider-request-config.js";
import { protectPreparedProviderRuntimeAuth } from "./provider-secret-egress.js";
import { buildAgentRuntimeAuthPlan } from "./runtime-plan/auth.js";
import { materializePreparedRuntimeModel } from "./runtime-plan/materialize-model.js";
import { resolveSimpleCompletionModelResolverWorkspace } from "./simple-completion-scope.js";
import { prepareModelForSimpleCompletion } from "./simple-completion-transport.js";
import { resolveUtilityModelRefForAgent } from "./utility-model.js";
@@ -255,6 +263,19 @@ export async function prepareSimpleCompletionModel(params: {
error: resolved.error ?? `Unknown model: ${params.provider}/${params.modelId}`,
};
}
const initialModel = resolved.model;
let resolvedModel = initialModel;
const routeResolution = resolveOpenAIModelRoutes({
provider: initialModel.provider,
modelId: initialModel.id,
api: initialModel.api,
baseUrl: initialModel.baseUrl,
config: params.cfg,
env: process.env,
});
const resolvesAuthBeforePhysicalRoute =
routeResolution?.kind === "routes" && routeResolution.routes.length > 1;
let auth: ResolvedProviderAuth;
const authStore = params.bindAuthOwner
@@ -265,20 +286,121 @@ export async function prepareSimpleCompletionModel(params: {
})
: undefined;
try {
auth = await getApiKeyForModel({
model: resolved.model,
cfg: params.cfg,
agentDir: params.agentDir,
workspaceDir,
profileId: params.profileId,
preferredProfile: params.preferredProfile,
...(authStore ? { store: authStore } : {}),
...(params.bindAuthOwner && params.profileId ? { lockedProfile: true } : {}),
secretSentinels: true,
});
auth = resolvesAuthBeforePhysicalRoute
? await resolveApiKeyForProvider({
provider: initialModel.provider,
cfg: params.cfg,
agentDir: params.agentDir,
workspaceDir,
profileId: params.profileId,
preferredProfile: params.preferredProfile,
...(authStore ? { store: authStore } : {}),
...(params.bindAuthOwner && params.profileId ? { lockedProfile: true } : {}),
modelId: initialModel.id,
secretSentinels: true,
})
: await getApiKeyForModel({
model: initialModel,
cfg: params.cfg,
agentDir: params.agentDir,
workspaceDir,
profileId: params.profileId,
preferredProfile: params.preferredProfile,
...(authStore ? { store: authStore } : {}),
...(params.bindAuthOwner && params.profileId ? { lockedProfile: true } : {}),
secretSentinels: true,
});
if (routeResolution?.kind === "routes") {
const source = auth.profileId
? {
kind: "profile" as const,
profileId: auth.profileId,
provider: initialModel.provider,
mode: auth.mode,
readiness: "ready" as const,
cooldown: "clear" as const,
}
: buildProviderModelAuthDirectSource({
mode: auth.mode,
availability: true,
evidence: "runtime",
});
const routeAuthDecision = selectOpenAIModelRouteAuth({
resolution: routeResolution,
sourcePlan: buildProviderModelAuthSourcePlan({
ownership: { reason: "provider-binding", source },
profiles: [],
}),
});
if (routeAuthDecision.kind !== "selected") {
throw new Error(
routeAuthDecision.kind === "rejected"
? routeAuthDecision.message
: "OpenAI route selection unexpectedly deferred after auth was resolved.",
);
}
const route = routeAuthDecision.selection.route;
const plan = buildAgentRuntimeAuthPlan({
provider: initialModel.provider,
modelId: initialModel.id,
authProfileProvider: initialModel.provider,
authProfileMode: auth.mode,
sessionAuthProfileId: auth.profileId,
sessionAuthProfileSource: params.profileId ? "user" : "auto",
modelRoute: {
provider: initialModel.provider,
modelId: initialModel.id,
api: route.api,
baseUrl: route.baseUrl,
authRequirement: route.authRequirement,
requestTransportOverrides: route.requestTransportOverrides,
runtimePolicy: route.runtimePolicy,
},
config: params.cfg,
workspaceDir,
});
resolvedModel =
(await materializePreparedRuntimeModel({
plan,
provider: initialModel.provider,
modelId: initialModel.id,
config: params.cfg,
model: initialModel,
resolveModel: ({ config, authProfileId, authProfileMode }) =>
(params.modelResolver ?? resolveModelAsync)(
initialModel.provider,
initialModel.id,
params.agentDir,
config,
{
authStorage: resolved.authStorage,
modelRegistry: resolved.modelRegistry,
skipAgentDiscovery: true,
allowBundledStaticCatalogFallback: true,
preferBundledStaticCatalogTransport: true,
workspaceDir,
authProfileId,
authProfileMode,
},
),
})) ?? initialModel;
if (resolvesAuthBeforePhysicalRoute) {
auth = await getApiKeyForModel({
model: resolvedModel,
cfg: params.cfg,
agentDir: params.agentDir,
workspaceDir,
profileId: auth.profileId,
preferredProfile: params.preferredProfile,
...(authStore ? { store: authStore } : {}),
...(params.bindAuthOwner && params.profileId ? { lockedProfile: true } : {}),
secretSentinels: true,
});
}
}
} catch (err) {
return {
error: `Auth lookup failed for provider "${resolved.model.provider}": ${formatErrorMessage(err)}`,
error: `Auth lookup failed for provider "${initialModel.provider}": ${formatErrorMessage(err)}`,
};
}
const rawApiKey = auth.apiKey?.trim();
@@ -290,17 +412,16 @@ export async function prepareSimpleCompletionModel(params: {
})
) {
return {
error: formatMissingAuthError(auth, resolved.model.provider),
error: formatMissingAuthError(auth, resolvedModel.provider),
auth,
};
}
let authValue = rawApiKey;
let resolvedModel = resolved.model;
if (rawApiKey) {
const runtimeCredential = await setRuntimeApiKeyForCompletion({
authStorage: resolved.authStorage,
model: resolved.model,
model: resolvedModel,
apiKey: rawApiKey,
authMode: auth.mode,
cfg: params.cfg,

View File

@@ -219,7 +219,9 @@ describe("runDoctorLintCli", () => {
],
});
expect(payload.findings[0].message).toContain("Codex plugin is disabled by config");
expect(payload.findings[0].fixHint).toContain("openclaw doctor --fix");
// Explicit plugins.entries.codex.enabled=false blocks auto-repair, so the
// hint names the manual action instead of promising doctor --fix.
expect(payload.findings[0].fixHint).toContain("Enable plugins.entries.codex");
} finally {
stdout.mockRestore();
}

View File

@@ -302,12 +302,12 @@ export function collectDisabledCodexPluginRouteIssues(
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): DisabledCodexPluginRouteIssue[] {
const blockedOutsideEntry = codexPluginIsBlockedOutsideEntry(cfg);
const repairBlocked = codexPluginRepairIsBlocked(cfg);
return collectDisabledCodexPluginRouteHits(cfg, env).map((hit) => ({
path: hit.path,
modelRef: hit.modelRef,
canonicalModel: hit.canonicalModel,
blockedOutsideEntry,
repairBlocked,
}));
}
@@ -315,7 +315,8 @@ export function enableCodexPluginForRequiredRoutes(params: {
cfg: OpenClawConfig;
routeHits: DisabledCodexPluginRouteHit[];
}): { cfg: OpenClawConfig; changes: string[] } {
if (params.routeHits.length === 0 || codexPluginIsBlockedOutsideEntry(params.cfg)) {
// Explicit user opt-out wins over managed-harness repair; doctor warns instead.
if (params.routeHits.length === 0 || codexPluginRepairIsBlocked(params.cfg)) {
return { cfg: params.cfg, changes: [] };
}
const cfg = structuredClone(params.cfg);
@@ -348,15 +349,19 @@ export function enableCodexPluginForRequiredRoutes(params: {
return { cfg, changes };
}
export function codexPluginIsBlockedOutsideEntry(cfg: OpenClawConfig): boolean {
function codexPluginIsBlockedOutsideEntry(cfg: OpenClawConfig): boolean {
return cfg.plugins?.enabled === false || pluginIdListIncludes(cfg.plugins?.deny, "codex");
}
export function codexPluginRepairIsBlocked(cfg: OpenClawConfig): boolean {
return (
codexPluginIsBlockedOutsideEntry(cfg) ||
asMutableRecord(asMutableRecord(cfg.plugins?.entries)?.codex)?.enabled === false
);
}
function isCodexPluginUnavailableByConfig(cfg: OpenClawConfig): boolean {
if (codexPluginIsBlockedOutsideEntry(cfg)) {
return true;
}
if (asMutableRecord(asMutableRecord(cfg.plugins?.entries)?.codex)?.enabled === false) {
if (codexPluginRepairIsBlocked(cfg)) {
return true;
}
const allow = cfg.plugins?.allow;

View File

@@ -39,8 +39,8 @@ export type DisabledCodexPluginRouteIssue = {
modelRef: string;
/** Canonical OpenAI model reference that should remain after migration. */
canonicalModel: string;
/** True when global/plugin allow policy blocks auto-enabling the Codex plugin. */
blockedOutsideEntry: boolean;
/** True when explicit plugin policy blocks auto-enabling the Codex plugin. */
repairBlocked: boolean;
};
export type SharedDefaultCompactionOverrideConsumers = Record<CompactionOverrideKey, boolean>;

View File

@@ -38,6 +38,12 @@ import { repairCodexSessionStoreRoutes } from "./codex-route-session-repair.test
import { collectCodexRouteWarnings, maybeRepairCodexRoutes } from "./codex-route-warnings.js";
import { collectBlockedLegacyOpenAICodexProviderPlan } from "./legacy-config-migrations.runtime.models.js";
const REPAIRABLE_CODEX_PLUGIN_CONFIG = { allow: ["openai"] };
const CODEX_PLUGIN_REPAIR_CHANGES = [
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
"Added codex to plugins.allow because configured agent routes use Codex runtime.",
];
describe("collectCodexRouteWarnings", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -304,7 +310,7 @@ describe("collectCodexRouteWarnings", () => {
[
"- Codex runtime is selected, but the Codex plugin is disabled.",
"- agents.defaults.model.primary: gpt-5.5 resolves to openai/gpt-5.5 with Codex runtime while the Codex plugin is disabled by config.",
"- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to an OpenClaw runtime policy.",
"- Enable plugins.entries.codex and plugin loading, and remove `codex` from plugins.deny; or set the affected OpenAI models to an OpenClaw runtime policy.",
].join("\n"),
]);
});
@@ -321,7 +327,7 @@ describe("collectCodexRouteWarnings", () => {
[
"- Codex runtime is selected, but the Codex plugin is disabled.",
"- agents.defaults.model.primary: openai/gpt-5.6 resolves to openai/gpt-5.6 with Codex runtime while the Codex plugin is disabled by config.",
"- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to an OpenClaw runtime policy.",
"- Enable plugins.entries.codex and plugin loading, and remove `codex` from plugins.deny; or set the affected OpenAI models to an OpenClaw runtime policy.",
].join("\n"),
]);
});
@@ -340,7 +346,7 @@ describe("collectCodexRouteWarnings", () => {
[
"- Codex runtime is selected, but the Codex plugin is disabled.",
"- agents.defaults.model.primary: openai/gpt-5.3-codex-spark resolves to openai/gpt-5.3-codex-spark with Codex runtime while the Codex plugin is disabled by config.",
"- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to an OpenClaw runtime policy.",
"- Enable plugins.entries.codex and plugin loading, and remove `codex` from plugins.deny; or set the affected OpenAI models to an OpenClaw runtime policy.",
].join("\n"),
]);
});
@@ -366,7 +372,7 @@ describe("collectCodexRouteWarnings", () => {
[
"- Codex runtime is selected, but the Codex plugin is disabled.",
"- agents.defaults.model.primary: openai/gpt-5.4-nano resolves to openai/gpt-5.4-nano with Codex runtime while the Codex plugin is disabled by config.",
"- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to an OpenClaw runtime policy.",
"- Enable plugins.entries.codex and plugin loading, and remove `codex` from plugins.deny; or set the affected OpenAI models to an OpenClaw runtime policy.",
].join("\n"),
]);
});
@@ -2246,7 +2252,7 @@ describe("collectCodexRouteWarnings", () => {
expect(result.changes.join("\n")).toContain("agentRuntime.id");
});
it("re-enables the Codex plugin when default OpenAI routes use Codex runtime", () => {
it("warns without overriding an explicit Codex plugin opt-out", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
@@ -2267,13 +2273,16 @@ describe("collectCodexRouteWarnings", () => {
shouldRepair: true,
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
"Added codex to plugins.allow because configured agent routes use Codex runtime.",
expect(result.warnings).toStrictEqual([
[
"- Codex runtime is selected, but the Codex plugin is disabled.",
"- agents.defaults.model.primary: gpt-5.5 resolves to openai/gpt-5.5 with Codex runtime while the Codex plugin is disabled by config.",
"- Enable plugins.entries.codex and plugin loading, and remove `codex` from plugins.deny; or set the affected OpenAI models to an OpenClaw runtime policy.",
].join("\n"),
]);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
expect(result.cfg.plugins?.allow).toEqual(["openai", "codex"]);
expect(result.changes).toStrictEqual([]);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(false);
expect(result.cfg.plugins?.allow).toEqual(["openai"]);
expect(
resolveAgentHarnessPolicy({
provider: "openai",
@@ -2311,11 +2320,7 @@ describe("collectCodexRouteWarnings", () => {
it("re-enables the Codex plugin when a qualified default heartbeat uses Codex runtime", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
defaults: {
model: "anthropic/claude-sonnet-4-6",
@@ -2329,20 +2334,14 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
it("re-enables the Codex plugin when a default subagent model uses Codex runtime", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
defaults: {
model: "anthropic/claude-sonnet-4-6",
@@ -2358,20 +2357,14 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
it("re-enables the Codex plugin when an agent inherits a default heartbeat model that uses Codex runtime", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
defaults: {
heartbeat: {
@@ -2390,20 +2383,14 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
it("re-enables the Codex plugin when an agent model alias resolves to OpenAI", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
defaults: {
model: "xiaomi/mimo-v2-pro-mit",
@@ -2419,9 +2406,7 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
@@ -2518,11 +2503,7 @@ describe("collectCodexRouteWarnings", () => {
it("re-enables the Codex plugin when a per-agent-only bare alias falls back to OpenAI", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
list: [
{
@@ -2541,20 +2522,14 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
it("re-enables the Codex plugin when a listed-agent bare primary ignores per-agent provider metadata", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
list: [
{
@@ -2571,20 +2546,14 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
it("re-enables the Codex plugin when defaults inherit the implicit OpenAI model", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
defaults: {
models: {
@@ -2599,9 +2568,7 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
@@ -2628,11 +2595,7 @@ describe("collectCodexRouteWarnings", () => {
it("re-enables the Codex plugin when defaults configure only non-Codex fallbacks", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
defaults: {
model: {
@@ -2645,9 +2608,7 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
@@ -2708,11 +2669,7 @@ describe("collectCodexRouteWarnings", () => {
it("re-enables Codex for model-map runtime policies even when the primary is non-Codex", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
defaults: {
model: "anthropic/claude-sonnet-4-6",
@@ -2728,20 +2685,14 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
it("re-enables Codex for default model-map runtime policies inherited by listed agents", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
defaults: {
models: {
@@ -2762,9 +2713,7 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
@@ -2879,11 +2828,7 @@ describe("collectCodexRouteWarnings", () => {
it("re-enables the Codex plugin when a qualified channel model uses Codex runtime", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
defaults: {
model: "anthropic/claude-sonnet-4-6",
@@ -2901,20 +2846,14 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
it("checks channel model runtime policy for every configured agent", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
agents: {
defaults: {
model: "anthropic/claude-sonnet-4-6",
@@ -2944,9 +2883,7 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});
@@ -2980,7 +2917,7 @@ describe("collectCodexRouteWarnings", () => {
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(false);
});
it("does not make an empty plugin allowlist restrictive when re-enabling Codex", () => {
it("keeps an empty allowlist unchanged when explicit opt-out blocks repair", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
@@ -2998,10 +2935,9 @@ describe("collectCodexRouteWarnings", () => {
shouldRepair: true,
});
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
expect(result.warnings).toHaveLength(1);
expect(result.changes).toStrictEqual([]);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(false);
expect(result.cfg.plugins?.allow).toEqual([]);
});
@@ -3064,9 +3000,6 @@ describe("collectCodexRouteWarnings", () => {
cfg: {
plugins: {
allow: ["openai"],
entries: {
codex: { enabled: false },
},
},
agents: {
defaults: {
@@ -3211,11 +3144,7 @@ describe("collectCodexRouteWarnings", () => {
it("re-enables the Codex plugin when a provider-prefixed catalog model does not claim a bare model", () => {
const result = maybeRepairCodexRoutes({
cfg: {
plugins: {
entries: {
codex: { enabled: false },
},
},
plugins: REPAIRABLE_CODEX_PLUGIN_CONFIG,
models: {
providers: {
"qwen-dashscope": {
@@ -3233,9 +3162,7 @@ describe("collectCodexRouteWarnings", () => {
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toStrictEqual([
"Enabled plugins.entries.codex because configured agent routes use Codex runtime.",
]);
expect(result.changes).toStrictEqual(CODEX_PLUGIN_REPAIR_CHANGES);
expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true);
});

View File

@@ -17,7 +17,7 @@ import {
rewriteConfigModelRefs,
} from "./codex-route-config-repair.js";
import {
codexPluginIsBlockedOutsideEntry,
codexPluginRepairIsBlocked,
collectConfigModelRefs,
collectDisabledCodexPluginRouteHits,
collectDisabledCodexPluginRouteIssues,
@@ -82,10 +82,10 @@ function formatLegacyLosslessCompactionWarning(params: {
function formatDisabledCodexPluginWarning(params: {
hits: DisabledCodexPluginRouteHit[];
blockedOutsideEntry: boolean;
repairBlocked: boolean;
}): string {
const fixHint = params.blockedOutsideEntry
? "- Enable plugin loading and remove `codex` from plugins.deny, or set the affected OpenAI models to an OpenClaw runtime policy."
const fixHint = params.repairBlocked
? "- Enable plugins.entries.codex and plugin loading, and remove `codex` from plugins.deny; or set the affected OpenAI models to an OpenClaw runtime policy."
: "- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to an OpenClaw runtime policy.";
return [
"- Codex runtime is selected, but the Codex plugin is disabled.",
@@ -245,7 +245,7 @@ export function collectCodexRouteWarnings(params: {
warnings.push(
formatDisabledCodexPluginWarning({
hits: disabledCodexPluginHits,
blockedOutsideEntry: codexPluginIsBlockedOutsideEntry(params.cfg),
repairBlocked: codexPluginRepairIsBlocked(params.cfg),
}),
);
}

View File

@@ -521,7 +521,7 @@ describe("CORE_HEALTH_CHECKS", () => {
target: "openai/gpt-5.5",
requirement: "Codex plugin enabled for routes that use the Codex runtime.",
fixHint:
"Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to an OpenClaw runtime policy.",
"Enable plugins.entries.codex and plugin loading, and remove codex from plugins.deny; or set the affected OpenAI models to an OpenClaw runtime policy.",
}),
]);
expect(findings[0]?.message).toContain("Codex plugin is disabled by config");

View File

@@ -757,9 +757,9 @@ const codexSessionRoutesCheck: HealthCheck = {
path: issue.path,
target: issue.canonicalModel,
requirement: "Codex plugin enabled for routes that use the Codex runtime.",
fixHint: issue.blockedOutsideEntry
fixHint: issue.repairBlocked
? [
"Enable plugin loading and remove codex from plugins.deny,",
"Enable plugins.entries.codex and plugin loading, and remove codex from plugins.deny;",
"or set the affected OpenAI models to an OpenClaw runtime policy.",
].join(" ")
: [