fix: harden openai auth and reasoning replay

This commit is contained in:
Peter Steinberger
2026-04-25 03:10:19 +01:00
parent ae5c657367
commit 713807b55d
17 changed files with 161 additions and 49 deletions

View File

@@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Providers/OpenAI: separate API-key and Codex sign-in onboarding groups, and avoid replaying stale OpenAI Responses reasoning blocks after a model route switch.
- Discord/subagents: preserve thread-bound completion delivery by keeping the requester-agent announce path primary and falling back to direct thread sends only when the announce produces no visible output. (#71064) Thanks @DolencLuka.
- Browser/tool: give Chrome MCP existing-session manage calls a longer default timeout, pass explicit tool timeouts through tab management, and recover stale selected-page MCP sessions instead of forcing a manual reset. Thanks @steipete.
- Browser/sandbox: clean up idle tracked tabs opened by primary-agent browser sessions, while preserving active tab reuse and lifecycle cleanup for subagents, cron, and ACP sessions. Fixes #71165. Thanks @dwbutler.

View File

@@ -112,7 +112,7 @@ external end-user instructions.
**OpenAI / OpenAI Codex**
- Image sanitization only.
- Drop orphaned reasoning signatures (standalone reasoning items without a following content block) for OpenAI Responses/Codex transcripts.
- Drop orphaned reasoning signatures (standalone reasoning items without a following content block) for OpenAI Responses/Codex transcripts, and drop replayable OpenAI reasoning after a model route switch.
- No tool call id sanitization.
- No tool result pairing repair.
- No turn validation or reordering.

View File

@@ -4,8 +4,14 @@ export const OPENAI_CODEX_LOGIN_HINT = "Sign in with OpenAI in your browser";
export const OPENAI_CODEX_DEVICE_PAIRING_LABEL = "OpenAI Codex Device Pairing";
export const OPENAI_CODEX_DEVICE_PAIRING_HINT = "Pair in browser with a device code";
export const OPENAI_WIZARD_GROUP = {
export const OPENAI_API_KEY_WIZARD_GROUP = {
groupId: "openai",
groupLabel: "OpenAI",
groupHint: "API key or Codex sign-in",
groupHint: "Direct API key",
} as const;
export const OPENAI_CODEX_WIZARD_GROUP = {
groupId: "openai-codex",
groupLabel: "OpenAI Codex",
groupHint: "ChatGPT/Codex sign-in",
} as const;

View File

@@ -117,11 +117,15 @@ describe("openai codex provider", () => {
expect(oauth?.wizard).toMatchObject({
choiceLabel: "OpenAI Codex Browser Login",
groupHint: "API key or Codex sign-in",
groupId: "openai-codex",
groupLabel: "OpenAI Codex",
groupHint: "ChatGPT/Codex sign-in",
});
expect(deviceCode?.wizard).toMatchObject({
choiceLabel: "OpenAI Codex Device Pairing",
groupHint: "API key or Codex sign-in",
groupId: "openai-codex",
groupLabel: "OpenAI Codex",
groupHint: "ChatGPT/Codex sign-in",
});
});

View File

@@ -25,7 +25,7 @@ import {
OPENAI_CODEX_DEVICE_PAIRING_LABEL,
OPENAI_CODEX_LOGIN_HINT,
OPENAI_CODEX_LOGIN_LABEL,
OPENAI_WIZARD_GROUP,
OPENAI_CODEX_WIZARD_GROUP,
} from "./auth-choice-copy.js";
import { isOpenAIApiBaseUrl, isOpenAICodexBaseUrl } from "./base-url.js";
import { OPENAI_CODEX_DEFAULT_MODEL } from "./default-models.js";
@@ -426,7 +426,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
choiceLabel: OPENAI_CODEX_LOGIN_LABEL,
choiceHint: OPENAI_CODEX_LOGIN_HINT,
assistantPriority: OPENAI_CODEX_LOGIN_ASSISTANT_PRIORITY,
...OPENAI_WIZARD_GROUP,
...OPENAI_CODEX_WIZARD_GROUP,
},
run: async (ctx) => await runOpenAICodexOAuth(ctx),
},
@@ -440,7 +440,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
choiceLabel: OPENAI_CODEX_DEVICE_PAIRING_LABEL,
choiceHint: OPENAI_CODEX_DEVICE_PAIRING_HINT,
assistantPriority: OPENAI_CODEX_DEVICE_PAIRING_ASSISTANT_PRIORITY,
...OPENAI_WIZARD_GROUP,
...OPENAI_CODEX_WIZARD_GROUP,
},
run: async (ctx) => {
try {

View File

@@ -55,7 +55,9 @@ describe("buildOpenAIProvider", () => {
expect(apiKey?.wizard).toMatchObject({
choiceLabel: "OpenAI API Key",
groupHint: "API key or Codex sign-in",
groupId: "openai",
groupLabel: "OpenAI",
groupHint: "Direct API key",
});
});

View File

@@ -10,7 +10,7 @@ import {
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { OPENAI_API_KEY_LABEL, OPENAI_WIZARD_GROUP } from "./auth-choice-copy.js";
import { OPENAI_API_KEY_LABEL, OPENAI_API_KEY_WIZARD_GROUP } from "./auth-choice-copy.js";
import { isOpenAIApiBaseUrl } from "./base-url.js";
import { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "./default-models.js";
import {
@@ -222,7 +222,7 @@ export function buildOpenAIProvider(): ProviderPlugin {
wizard: {
choiceId: "openai-api-key",
choiceLabel: OPENAI_API_KEY_LABEL,
...OPENAI_WIZARD_GROUP,
...OPENAI_API_KEY_WIZARD_GROUP,
},
}),
],

View File

@@ -18,9 +18,9 @@
"choiceLabel": "OpenAI Codex Browser Login",
"choiceHint": "Sign in with OpenAI in your browser",
"assistantPriority": -30,
"groupId": "openai",
"groupLabel": "OpenAI",
"groupHint": "API key or Codex sign-in"
"groupId": "openai-codex",
"groupLabel": "OpenAI Codex",
"groupHint": "ChatGPT/Codex sign-in"
},
{
"provider": "openai-codex",
@@ -29,9 +29,9 @@
"choiceLabel": "OpenAI Codex Device Pairing",
"choiceHint": "Pair in browser with a device code",
"assistantPriority": -10,
"groupId": "openai",
"groupLabel": "OpenAI",
"groupHint": "API key or Codex sign-in"
"groupId": "openai-codex",
"groupLabel": "OpenAI Codex",
"groupHint": "ChatGPT/Codex sign-in"
},
{
"provider": "openai",
@@ -41,7 +41,7 @@
"assistantPriority": -40,
"groupId": "openai",
"groupLabel": "OpenAI",
"groupHint": "API key or Codex sign-in",
"groupHint": "Direct API key",
"optionKey": "openaiApiKey",
"cliFlag": "--openai-api-key",
"cliOption": "--openai-api-key <key>",

View File

@@ -74,21 +74,28 @@ describe("OpenAI plugin manifest", () => {
expect(codexBrowserLogin).toMatchObject({
choiceLabel: "OpenAI Codex Browser Login",
choiceHint: "Sign in with OpenAI in your browser",
groupHint: "API key or Codex sign-in",
groupId: "openai-codex",
groupLabel: "OpenAI Codex",
groupHint: "ChatGPT/Codex sign-in",
});
expect(codexDeviceCode).toMatchObject({
choiceLabel: "OpenAI Codex Device Pairing",
choiceHint: "Pair in browser with a device code",
groupHint: "API key or Codex sign-in",
groupId: "openai-codex",
groupLabel: "OpenAI Codex",
groupHint: "ChatGPT/Codex sign-in",
});
expect(apiKey).toMatchObject({
choiceLabel: "OpenAI API Key",
groupHint: "API key or Codex sign-in",
groupId: "openai",
groupLabel: "OpenAI",
groupHint: "Direct API key",
});
expect(choices.map((choice) => choice.choiceLabel)).not.toContain(
"OpenAI Codex (ChatGPT OAuth)",
);
expect(choices.map((choice) => choice.groupHint)).not.toContain("Codex OAuth + API key");
expect(choices.map((choice) => choice.groupHint)).not.toContain("API key or Codex sign-in");
});
it("keeps auth choice copy aligned with provider wizard metadata", () => {

View File

@@ -5,7 +5,8 @@ import {
OPENAI_CODEX_DEVICE_PAIRING_LABEL,
OPENAI_CODEX_LOGIN_HINT,
OPENAI_CODEX_LOGIN_LABEL,
OPENAI_WIZARD_GROUP,
OPENAI_API_KEY_WIZARD_GROUP,
OPENAI_CODEX_WIZARD_GROUP,
} from "./auth-choice-copy.js";
const noopAuth = async () => ({ profiles: [] });
@@ -33,7 +34,7 @@ export function createOpenAICodexProvider(): ProviderPlugin {
choiceLabel: OPENAI_CODEX_LOGIN_LABEL,
choiceHint: OPENAI_CODEX_LOGIN_HINT,
assistantPriority: -30,
...OPENAI_WIZARD_GROUP,
...OPENAI_CODEX_WIZARD_GROUP,
},
},
{
@@ -47,7 +48,7 @@ export function createOpenAICodexProvider(): ProviderPlugin {
choiceLabel: OPENAI_CODEX_DEVICE_PAIRING_LABEL,
choiceHint: OPENAI_CODEX_DEVICE_PAIRING_HINT,
assistantPriority: -10,
...OPENAI_WIZARD_GROUP,
...OPENAI_CODEX_WIZARD_GROUP,
},
},
],
@@ -72,7 +73,7 @@ export function createOpenAIProvider(): ProviderPlugin {
choiceId: "openai-api-key",
choiceLabel: OPENAI_API_KEY_LABEL,
assistantPriority: -40,
...OPENAI_WIZARD_GROUP,
...OPENAI_API_KEY_WIZARD_GROUP,
},
},
],

View File

@@ -453,6 +453,26 @@ describe("downgradeOpenAIReasoningBlocks", () => {
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
});
it("drops replayable reasoning when requested even with following content", () => {
const input = [
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "internal reasoning",
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
},
{ type: "text", text: "answer" },
],
},
];
expect(downgradeOpenAIReasoningBlocks(input as any, { dropReplayableReasoning: true })).toEqual(
[{ role: "assistant", content: [{ type: "text", text: "answer" }] }],
);
});
it("drops orphaned reasoning blocks without following content", () => {
const input = [
{

View File

@@ -16,6 +16,10 @@ type OpenAIReasoningSignature = {
type: string;
};
type DowngradeOpenAIReasoningBlocksOptions = {
dropReplayableReasoning?: boolean;
};
function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature | null {
if (!value) {
return null;
@@ -201,12 +205,15 @@ export function downgradeOpenAIFunctionCallReasoningPairs(
/**
* OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id
* without the required following item.
* without the required following item, or stale encrypted reasoning after a model route switch.
*
* OpenClaw persists provider-specific reasoning metadata in `thinkingSignature`; if that metadata
* is incomplete, drop the block to keep history usable.
* is incomplete or no longer replay-safe, drop the block to keep history usable.
*/
export function downgradeOpenAIReasoningBlocks(messages: AgentMessage[]): AgentMessage[] {
export function downgradeOpenAIReasoningBlocks(
messages: AgentMessage[],
options: DowngradeOpenAIReasoningBlocksOptions = {},
): AgentMessage[] {
let anyChanged = false;
const out: AgentMessage[] = [];
@@ -248,6 +255,10 @@ export function downgradeOpenAIReasoningBlocks(messages: AgentMessage[]): AgentM
nextContent.push(block);
continue;
}
if (options.dropReplayableReasoning) {
changed = true;
continue;
}
if (hasFollowingNonThinkingBlock(assistantMsg.content, i)) {
nextContent.push(block);
continue;

View File

@@ -10,6 +10,7 @@ import {
sanitizeSnapshotChangedOpenAIReasoning,
sanitizeWithOpenAIResponses,
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
import { makeZeroUsageSnapshot } from "./usage.js";
vi.mock(
"./pi-embedded-helpers.js",
@@ -73,6 +74,12 @@ describe("sanitizeSessionHistory e2e smoke", () => {
sanitizeSessionHistory,
});
expect(result).toEqual([]);
expect(result).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "answer" }],
usage: makeZeroUsageSnapshot(),
},
]);
});
});

View File

@@ -113,24 +113,29 @@ export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise<Saniti
export function makeReasoningAssistantMessages(opts?: {
thinkingSignature?: "object" | "json";
includeText?: boolean;
}): AgentMessage[] {
const thinkingSignature: unknown =
opts?.thinkingSignature === "json"
? JSON.stringify({ id: "rs_test", type: "reasoning" })
: { id: "rs_test", type: "reasoning" };
const content: Array<Record<string, unknown>> = [
{
type: "thinking",
thinking: "reasoning",
thinkingSignature,
},
];
if (opts?.includeText) {
content.push({ type: "text", text: "answer" });
}
// Intentional: we want to build message payloads that can carry non-string
// signatures, but core typing currently expects a string.
const messages = [
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "reasoning",
thinkingSignature,
},
],
content,
},
];
@@ -178,7 +183,7 @@ export function makeSnapshotChangedOpenAIReasoningScenario() {
];
return {
sessionManager: makeInMemorySessionManager(sessionEntries),
messages: makeReasoningAssistantMessages({ thinkingSignature: "object" }),
messages: makeReasoningAssistantMessages({ thinkingSignature: "object", includeText: true }),
modelId: "gpt-5.4",
};
}

View File

@@ -789,7 +789,42 @@ describe("sanitizeSessionHistory", () => {
sanitizeSessionHistory,
});
expect(result).toEqual([]);
expect(result).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "answer" }],
usage: makeZeroUsageSnapshot(),
},
]);
});
it("keeps paired openai reasoning when the model snapshot stays the same", async () => {
const sessionEntries = [
makeModelSnapshotEntry({
provider: "openai",
modelApi: "openai-responses",
modelId: "gpt-5.4",
}),
];
const sessionManager = makeInMemorySessionManager(sessionEntries);
const messages = makeReasoningAssistantMessages({
thinkingSignature: "json",
includeText: true,
});
const result = await sanitizeWithOpenAIResponses({
sanitizeSessionHistory,
messages,
modelId: "gpt-5.4",
sessionManager,
});
expect(result).toEqual([
{
...(messages[0] as Record<string, unknown>),
usage: makeZeroUsageSnapshot(),
},
]);
});
it("drops orphaned toolResult entries when switching from openai history to anthropic", async () => {

View File

@@ -461,6 +461,16 @@ export async function sanitizeSessionHistory(params: {
params.modelApi === "openai-responses" ||
params.modelApi === "openai-codex-responses" ||
params.modelApi === "azure-openai-responses";
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null;
const modelChanged = priorSnapshot
? !isSameModelSnapshot(priorSnapshot, {
timestamp: 0,
provider: params.provider,
modelApi: params.modelApi,
modelId: params.modelId,
})
: false;
const normalizedAssistantReplay = normalizeAssistantReplayContent(withInterSessionMarkers);
const sanitizedImages = await sanitizeSessionMessagesImages(
normalizedAssistantReplay,
@@ -494,7 +504,9 @@ export async function sanitizeSessionHistory(params: {
: sanitizedToolCalls;
const openAISafeToolCalls = isOpenAIResponsesApi
? downgradeOpenAIFunctionCallReasoningPairs(
downgradeOpenAIReasoningBlocks(openAIRepairedToolCalls),
downgradeOpenAIReasoningBlocks(openAIRepairedToolCalls, {
dropReplayableReasoning: modelChanged,
}),
)
: sanitizedToolCalls;
const sanitizedToolIds =
@@ -515,16 +527,6 @@ export async function sanitizeSessionHistory(params: {
const sanitizedCompactionUsage = ensureAssistantUsageSnapshots(
stripStaleAssistantUsageBeforeLatestCompaction(sanitizedToolResults),
);
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null;
const modelChanged = priorSnapshot
? !isSameModelSnapshot(priorSnapshot, {
timestamp: 0,
provider: params.provider,
modelApi: params.modelApi,
modelId: params.modelId,
})
: false;
const provider = params.provider?.trim();
let providerSanitized: AgentMessage[] | undefined;
if (provider && provider.length > 0) {

View File

@@ -87,6 +87,7 @@ vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: vi.fn(() => ({})),
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveAgentEffectiveModelPrimary: vi.fn(() => undefined),
resolveAgentModelFallbacksOverride: vi.fn(() => undefined),
resolveSessionAgentId: vi.fn(() => "main"),
}));
@@ -336,6 +337,16 @@ describe("/model chat UX", () => {
expect(reply?.text).toContain("Switch: /model <provider/model>");
});
it("treats /model list as a models browser alias, not a model id", async () => {
const reply = await resolveModelInfoReply({
directives: parseInlineDirectives("/model list"),
});
expect(reply?.text).toContain("Providers:");
expect(reply?.text).toContain("Use: /models <provider>");
expect(reply?.text).toContain("Switch: /model <provider/model>");
});
it("shows active runtime model when different from selected model", async () => {
const reply = await resolveModelInfoReply({
provider: "fireworks",