mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
fix: harden openai auth and reasoning replay
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user