Compare commits

..

5 Commits

Author SHA1 Message Date
Albertzzzhu d2523affe7
Merge a19f3890b8 into c4265a5f16 2026-03-15 14:43:44 +00:00
ShengtongZhu a19f3890b8 fix(guardian): remove unused import, align pi-ai version with root
- Remove unused PluginRuntime import, consolidate import lines
- Bump @mariozechner/pi-ai from 0.55.3 to 0.58.0 to match root

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:43:38 +08:00
Ayaan Zaidi c4265a5f16
fix: preserve Telegram word boundaries when rechunking HTML (#47274)
* fix: preserve Telegram chunk word boundaries

* fix: address Telegram chunking review feedback

* fix: preserve Telegram retry separators

* fix: preserve Telegram chunking boundaries (#47274)
2026-03-15 18:10:49 +05:30
Andrew Demczuk 26e0a3ee9a
fix(gateway): skip Control UI pairing when auth.mode=none (closes #42931) (#47148)
When auth is completely disabled (mode=none), requiring device pairing
for Control UI operator sessions adds friction without security value
since any client can already connect without credentials.

Add authMode parameter to shouldSkipControlUiPairing so the bypass
fires only for Control UI + operator role + auth.mode=none. This avoids
the #43478 regression where a top-level OR disabled pairing for ALL
websocket clients.
2026-03-15 13:03:39 +01:00
助爪 5c5c64b612
Deduplicate repeated tool call IDs for OpenAI-compatible APIs (#40996)
Merged via squash.

Prepared head SHA: 38d8048359
Co-authored-by: xaeon2026 <264572156+xaeon2026@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-15 19:46:07 +08:00
15 changed files with 506 additions and 99 deletions

View File

@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
### Fixes
@ -43,6 +44,7 @@ Docs: https://docs.openclaw.ai
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
## 2026.3.13

View File

@ -1,6 +1,5 @@
import { getModels as piGetModels } from "@mariozechner/pi-ai";
import type { OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { callGuardian } from "./guardian-client.js";
import {
getAllTurns,

View File

@ -5,7 +5,7 @@
"description": "OpenClaw guardian plugin — LLM-based intent-alignment review for tool calls",
"type": "module",
"dependencies": {
"@mariozechner/pi-ai": "0.55.3"
"@mariozechner/pi-ai": "0.58.0"
},
"devDependencies": {
"openclaw": "workspace:*"

View File

@ -512,6 +512,146 @@ function sliceLinkSpans(
});
}
function sliceMarkdownIR(ir: MarkdownIR, start: number, end: number): MarkdownIR {
return {
text: ir.text.slice(start, end),
styles: sliceStyleSpans(ir.styles, start, end),
links: sliceLinkSpans(ir.links, start, end),
};
}
function mergeAdjacentStyleSpans(styles: MarkdownIR["styles"]): MarkdownIR["styles"] {
const merged: MarkdownIR["styles"] = [];
for (const span of styles) {
const last = merged.at(-1);
if (last && last.style === span.style && span.start <= last.end) {
last.end = Math.max(last.end, span.end);
continue;
}
merged.push({ ...span });
}
return merged;
}
function mergeAdjacentLinkSpans(links: MarkdownIR["links"]): MarkdownIR["links"] {
const merged: MarkdownIR["links"] = [];
for (const link of links) {
const last = merged.at(-1);
if (last && last.href === link.href && link.start <= last.end) {
last.end = Math.max(last.end, link.end);
continue;
}
merged.push({ ...link });
}
return merged;
}
function mergeMarkdownIRChunks(left: MarkdownIR, right: MarkdownIR): MarkdownIR {
const offset = left.text.length;
return {
text: left.text + right.text,
styles: mergeAdjacentStyleSpans([
...left.styles,
...right.styles.map((span) => ({
...span,
start: span.start + offset,
end: span.end + offset,
})),
]),
links: mergeAdjacentLinkSpans([
...left.links,
...right.links.map((link) => ({
...link,
start: link.start + offset,
end: link.end + offset,
})),
]),
};
}
function renderTelegramChunkHtml(ir: MarkdownIR): string {
return wrapFileReferencesInHtml(renderTelegramHtml(ir));
}
function findMarkdownIRPreservedSplitIndex(text: string, start: number, limit: number): number {
const maxEnd = Math.min(text.length, start + limit);
if (maxEnd >= text.length) {
return text.length;
}
let lastOutsideParenNewlineBreak = -1;
let lastOutsideParenWhitespaceBreak = -1;
let lastOutsideParenWhitespaceRunStart = -1;
let lastAnyNewlineBreak = -1;
let lastAnyWhitespaceBreak = -1;
let lastAnyWhitespaceRunStart = -1;
let parenDepth = 0;
let sawNonWhitespace = false;
for (let index = start; index < maxEnd; index += 1) {
const char = text[index];
if (char === "(") {
sawNonWhitespace = true;
parenDepth += 1;
continue;
}
if (char === ")" && parenDepth > 0) {
sawNonWhitespace = true;
parenDepth -= 1;
continue;
}
if (!/\s/.test(char)) {
sawNonWhitespace = true;
continue;
}
if (!sawNonWhitespace) {
continue;
}
if (char === "\n") {
lastAnyNewlineBreak = index + 1;
if (parenDepth === 0) {
lastOutsideParenNewlineBreak = index + 1;
}
continue;
}
const whitespaceRunStart =
index === start || !/\s/.test(text[index - 1] ?? "") ? index : lastAnyWhitespaceRunStart;
lastAnyWhitespaceBreak = index + 1;
lastAnyWhitespaceRunStart = whitespaceRunStart;
if (parenDepth === 0) {
lastOutsideParenWhitespaceBreak = index + 1;
lastOutsideParenWhitespaceRunStart = whitespaceRunStart;
}
}
const resolveWhitespaceBreak = (breakIndex: number, runStart: number): number => {
if (breakIndex <= start) {
return breakIndex;
}
if (runStart <= start) {
return breakIndex;
}
return /\s/.test(text[breakIndex] ?? "") ? runStart : breakIndex;
};
if (lastOutsideParenNewlineBreak > start) {
return lastOutsideParenNewlineBreak;
}
if (lastOutsideParenWhitespaceBreak > start) {
return resolveWhitespaceBreak(
lastOutsideParenWhitespaceBreak,
lastOutsideParenWhitespaceRunStart,
);
}
if (lastAnyNewlineBreak > start) {
return lastAnyNewlineBreak;
}
if (lastAnyWhitespaceBreak > start) {
return resolveWhitespaceBreak(lastAnyWhitespaceBreak, lastAnyWhitespaceRunStart);
}
return maxEnd;
}
function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): MarkdownIR[] {
if (!ir.text) {
return [];
@ -523,7 +663,7 @@ function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): Markd
const chunks: MarkdownIR[] = [];
let cursor = 0;
while (cursor < ir.text.length) {
const end = Math.min(ir.text.length, cursor + normalizedLimit);
const end = findMarkdownIRPreservedSplitIndex(ir.text, cursor, normalizedLimit);
chunks.push({
text: ir.text.slice(cursor, end),
styles: sliceStyleSpans(ir.styles, cursor, end),
@ -534,32 +674,98 @@ function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): Markd
return chunks;
}
function coalesceWhitespaceOnlyMarkdownIRChunks(chunks: MarkdownIR[], limit: number): MarkdownIR[] {
const coalesced: MarkdownIR[] = [];
let index = 0;
while (index < chunks.length) {
const chunk = chunks[index];
if (!chunk) {
index += 1;
continue;
}
if (chunk.text.trim().length > 0) {
coalesced.push(chunk);
index += 1;
continue;
}
const prev = coalesced.at(-1);
const next = chunks[index + 1];
const chunkLength = chunk.text.length;
const canMergePrev = (candidate: MarkdownIR) =>
renderTelegramChunkHtml(candidate).length <= limit;
const canMergeNext = (candidate: MarkdownIR) =>
renderTelegramChunkHtml(candidate).length <= limit;
if (prev) {
const mergedPrev = mergeMarkdownIRChunks(prev, chunk);
if (canMergePrev(mergedPrev)) {
coalesced[coalesced.length - 1] = mergedPrev;
index += 1;
continue;
}
}
if (next) {
const mergedNext = mergeMarkdownIRChunks(chunk, next);
if (canMergeNext(mergedNext)) {
chunks[index + 1] = mergedNext;
index += 1;
continue;
}
}
if (prev && next) {
for (let prefixLength = chunkLength - 1; prefixLength >= 1; prefixLength -= 1) {
const prefix = sliceMarkdownIR(chunk, 0, prefixLength);
const suffix = sliceMarkdownIR(chunk, prefixLength, chunkLength);
const mergedPrev = mergeMarkdownIRChunks(prev, prefix);
const mergedNext = mergeMarkdownIRChunks(suffix, next);
if (canMergePrev(mergedPrev) && canMergeNext(mergedNext)) {
coalesced[coalesced.length - 1] = mergedPrev;
chunks[index + 1] = mergedNext;
break;
}
}
}
index += 1;
}
return coalesced;
}
function renderTelegramChunksWithinHtmlLimit(
ir: MarkdownIR,
limit: number,
): TelegramFormattedChunk[] {
const normalizedLimit = Math.max(1, Math.floor(limit));
const pending = chunkMarkdownIR(ir, normalizedLimit);
const rendered: TelegramFormattedChunk[] = [];
const finalized: MarkdownIR[] = [];
while (pending.length > 0) {
const chunk = pending.shift();
if (!chunk) {
continue;
}
const html = wrapFileReferencesInHtml(renderTelegramHtml(chunk));
const html = renderTelegramChunkHtml(chunk);
if (html.length <= normalizedLimit || chunk.text.length <= 1) {
rendered.push({ html, text: chunk.text });
finalized.push(chunk);
continue;
}
const split = splitTelegramChunkByHtmlLimit(chunk, normalizedLimit, html.length);
if (split.length <= 1) {
// Worst-case safety: avoid retry loops, deliver the chunk as-is.
rendered.push({ html, text: chunk.text });
finalized.push(chunk);
continue;
}
pending.unshift(...split);
}
return rendered;
return coalesceWhitespaceOnlyMarkdownIRChunks(finalized, normalizedLimit).map((chunk) => ({
html: renderTelegramChunkHtml(chunk),
text: chunk.text,
}));
}
export function markdownToTelegramChunks(

View File

@ -174,6 +174,35 @@ describe("markdownToTelegramChunks - file reference wrapping", () => {
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
expect(chunks.every((chunk) => chunk.html.length <= 5)).toBe(true);
});
it("prefers word boundaries when html-limit retry splits formatted prose", () => {
const input = "**Which of these**";
const chunks = markdownToTelegramChunks(input, 16);
expect(chunks.map((chunk) => chunk.text)).toEqual(["Which of ", "these"]);
expect(chunks.every((chunk) => chunk.html.length <= 16)).toBe(true);
});
it("falls back to in-paren word boundaries when the parenthesis is unbalanced", () => {
const input = "**foo (bar baz qux quux**";
const chunks = markdownToTelegramChunks(input, 20);
expect(chunks.map((chunk) => chunk.text)).toEqual(["foo", "(bar baz qux ", "quux"]);
expect(chunks.every((chunk) => chunk.html.length <= 20)).toBe(true);
});
it("does not emit whitespace-only chunks during html-limit retry splitting", () => {
const input = "**ab <<**";
const chunks = markdownToTelegramChunks(input, 11);
expect(chunks.map((chunk) => chunk.text).join("")).toBe("ab <<");
expect(chunks.every((chunk) => chunk.text.trim().length > 0)).toBe(true);
expect(chunks.every((chunk) => chunk.html.length <= 11)).toBe(true);
});
it("preserves paragraph separators when retry chunking produces whitespace-only spans", () => {
const input = "ab\n\n<<";
const chunks = markdownToTelegramChunks(input, 6);
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
expect(chunks.every((chunk) => chunk.html.length <= 6)).toBe(true);
});
});
describe("edge cases", () => {

View File

@ -355,8 +355,8 @@ importers:
extensions/guardian:
dependencies:
'@mariozechner/pi-ai':
specifier: 0.55.3
version: 0.55.3(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)
specifier: 0.58.0
version: 0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)
devDependencies:
openclaw:
specifier: workspace:*
@ -1719,11 +1719,6 @@ packages:
resolution: {integrity: sha512-zhkwx3Wdo27snVfnJWi7l+wyU4XlazkeunTtz4e500GC+ufGOp4C3aIf0XiO5ZOtTE/0lvUiG2bWULR/i4lgUQ==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.55.3':
resolution: {integrity: sha512-f9jWoDzJR9Wy/H8JPMbjoM4WvVUeFZ65QdYA9UHIfoOopDfwWE8F8JHQOj5mmmILMacXuzsqA3J7MYqNWZRvvQ==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-ai@0.58.0':
resolution: {integrity: sha512-3TrkJ9QcBYFPo4NxYluhd+JQ4M+98RaEkNPMrLFU4wK4GMFVtsL3kp1YJ/oj7X0eqKuuDKbHj6MdoMZeT2TCvA==}
engines: {node: '>=20.0.0'}
@ -1750,9 +1745,6 @@ packages:
resolution: {integrity: sha512-570oJr93l1RcCNNaMVpOm+PgQkRgno/F65nH1aCWLIKLnw0o7iPoj+8Z5b7mnLMidg9lldVSCcf0dBxqTGE1/w==}
engines: {node: '>=20.0.0'}
'@mistralai/mistralai@1.10.0':
resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==}
'@mistralai/mistralai@1.14.1':
resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==}
@ -5523,18 +5515,6 @@ packages:
oniguruma-to-es@4.3.4:
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
openai@6.10.0:
resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
openai@6.26.0:
resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==}
hasBin: true
@ -8541,30 +8521,6 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.55.3(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.1004.0
'@google/genai': 1.44.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))
'@mistralai/mistralai': 1.10.0
'@sinclair/typebox': 0.34.48
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
chalk: 5.6.2
openai: 6.10.0(ws@8.19.0)(zod@4.3.6)
partial-json: 0.1.7
proxy-agent: 6.5.0
undici: 7.24.1
zod-to-json-schema: 3.25.1(zod@4.3.6)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@mariozechner/pi-ai@0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
@ -8660,11 +8616,6 @@ snapshots:
- debug
- supports-color
'@mistralai/mistralai@1.10.0':
dependencies:
zod: 3.25.75
zod-to-json-schema: 3.25.1(zod@3.25.75)
'@mistralai/mistralai@1.14.1':
dependencies:
ws: 8.19.0
@ -12861,11 +12812,6 @@ snapshots:
regex: 6.1.0
regex-recursion: 6.0.2
openai@6.10.0(ws@8.19.0)(zod@4.3.6):
optionalDependencies:
ws: 8.19.0
zod: 4.3.6
openai@6.26.0(ws@8.19.0)(zod@4.3.6):
optionalDependencies:
ws: 8.19.0
@ -14359,10 +14305,6 @@ snapshots:
- bufferutil
- utf-8-validate
zod-to-json-schema@3.25.1(zod@3.25.75):
dependencies:
zod: 3.25.75
zod-to-json-schema@3.25.1(zod@4.3.6):
dependencies:
zod: 4.3.6

View File

@ -2,6 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, UserMessage, Usage } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
expectOpenAIResponsesStrictSanitizeCall,
loadSanitizeSessionHistoryWithCleanMocks,
makeMockSessionManager,
makeInMemorySessionManager,
@ -247,7 +248,24 @@ describe("sanitizeSessionHistory", () => {
expect(result).toEqual(mockMessages);
});
it("passes simple user-only history through for openai-completions", async () => {
it("sanitizes tool call ids for OpenAI-compatible responses providers", async () => {
setNonGoogleModelApi();
await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "openai-responses",
provider: "custom",
sessionManager: mockSessionManager,
sessionId: TEST_SESSION_ID,
});
expectOpenAIResponsesStrictSanitizeCall(
mockedHelpers.sanitizeSessionMessagesImages,
mockMessages,
);
});
it("sanitizes tool call ids for openai-completions", async () => {
setNonGoogleModelApi();
const result = await sanitizeSessionHistory({

View File

@ -702,6 +702,26 @@ describe("wrapStreamFnTrimToolCallNames", () => {
expect(finalToolCall.name).toBe("read");
expect(finalToolCall.id).toBe("call_42");
});
it("reassigns duplicate tool call ids within a message to unique fallbacks", async () => {
const finalToolCallA = { type: "toolCall", name: " read ", id: " edit:22 " };
const finalToolCallB = { type: "toolCall", name: " write ", id: "edit:22" };
const finalMessage = { role: "assistant", content: [finalToolCallA, finalToolCallB] };
const baseFn = vi.fn(() =>
createFakeStream({
events: [],
resultMessage: finalMessage,
}),
);
const stream = await invokeWrappedStream(baseFn);
await stream.result();
expect(finalToolCallA.name).toBe("read");
expect(finalToolCallB.name).toBe("write");
expect(finalToolCallA.id).toBe("edit:22");
expect(finalToolCallB.id).toBe("call_auto_1");
});
});
describe("wrapStreamFnRepairMalformedToolCallArguments", () => {

View File

@ -667,6 +667,7 @@ function normalizeToolCallIdsInMessage(message: unknown): void {
}
let fallbackIndex = 1;
const assignedIds = new Set<string>();
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
@ -678,20 +679,23 @@ function normalizeToolCallIdsInMessage(message: unknown): void {
if (typeof typedBlock.id === "string") {
const trimmedId = typedBlock.id.trim();
if (trimmedId) {
if (typedBlock.id !== trimmedId) {
typedBlock.id = trimmedId;
if (!assignedIds.has(trimmedId)) {
if (typedBlock.id !== trimmedId) {
typedBlock.id = trimmedId;
}
assignedIds.add(trimmedId);
continue;
}
usedIds.add(trimmedId);
continue;
}
}
let fallbackId = "";
while (!fallbackId || usedIds.has(fallbackId)) {
while (!fallbackId || usedIds.has(fallbackId) || assignedIds.has(fallbackId)) {
fallbackId = `call_auto_${fallbackIndex++}`;
}
typedBlock.id = fallbackId;
usedIds.add(fallbackId);
assignedIds.add(fallbackId);
}
}

View File

@ -29,6 +29,54 @@ const buildDuplicateIdCollisionInput = () =>
},
]);
const buildRepeatedRawIdInput = () =>
castAgentMessages([
{
role: "assistant",
content: [
{ type: "toolCall", id: "edit:22", name: "edit", arguments: {} },
{ type: "toolCall", id: "edit:22", name: "edit", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "edit:22",
toolName: "edit",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: "edit:22",
toolName: "edit",
content: [{ type: "text", text: "two" }],
},
]);
const buildRepeatedSharedToolResultIdInput = () =>
castAgentMessages([
{
role: "assistant",
content: [
{ type: "toolCall", id: "edit:22", name: "edit", arguments: {} },
{ type: "toolCall", id: "edit:22", name: "edit", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "edit:22",
toolUseId: "edit:22",
toolName: "edit",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: "edit:22",
toolUseId: "edit:22",
toolName: "edit",
content: [{ type: "text", text: "two" }],
},
]);
function expectCollisionIdsRemainDistinct(
out: AgentMessage[],
mode: "strict" | "strict9",
@ -111,6 +159,26 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expectCollisionIdsRemainDistinct(out, "strict");
});
it("reuses one rewritten id when a tool result carries matching toolCallId and toolUseId", () => {
const input = buildRepeatedSharedToolResultIdInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
expect(out).not.toBe(input);
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict");
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string };
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string };
expect(r1.toolUseId).toBe(aId);
expect(r2.toolUseId).toBe(bId);
});
it("assigns distinct IDs when identical raw tool call ids repeat", () => {
const input = buildRepeatedRawIdInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
expect(out).not.toBe(input);
expectCollisionIdsRemainDistinct(out, "strict");
});
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
const longA = `call_${"a".repeat(60)}`;
const longB = `call_${"a".repeat(59)}b`;
@ -181,6 +249,16 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(aId).not.toMatch(/[_-]/);
expect(bId).not.toMatch(/[_-]/);
});
it("assigns distinct strict IDs when identical raw tool call ids repeat", () => {
const input = buildRepeatedRawIdInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
expect(out).not.toBe(input);
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict");
expect(aId).not.toMatch(/[_-]/);
expect(bId).not.toMatch(/[_-]/);
});
});
describe("strict9 mode (Mistral tool call IDs)", () => {
@ -231,5 +309,27 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(aId.length).toBe(9);
expect(bId.length).toBe(9);
});
it("assigns distinct strict9 IDs when identical raw tool call ids repeat", () => {
const input = buildRepeatedRawIdInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
expect(out).not.toBe(input);
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict9");
expect(aId.length).toBe(9);
expect(bId.length).toBe(9);
});
it("reuses one rewritten strict9 id when a tool result carries matching toolCallId and toolUseId", () => {
const input = buildRepeatedSharedToolResultIdInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
expect(out).not.toBe(input);
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict9");
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string };
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }> & { toolUseId?: string };
expect(r1.toolUseId).toBe(aId);
expect(r2.toolUseId).toBe(bId);
});
});
});

View File

@ -144,9 +144,55 @@ function makeUniqueToolId(params: { id: string; used: Set<string>; mode: ToolCal
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
}
function createOccurrenceAwareResolver(mode: ToolCallIdMode): {
resolveAssistantId: (id: string) => string;
resolveToolResultId: (id: string) => string;
} {
const used = new Set<string>();
const assistantOccurrences = new Map<string, number>();
const orphanToolResultOccurrences = new Map<string, number>();
const pendingByRawId = new Map<string, string[]>();
const allocate = (seed: string): string => {
const next = makeUniqueToolId({ id: seed, used, mode });
used.add(next);
return next;
};
const resolveAssistantId = (id: string): string => {
const occurrence = (assistantOccurrences.get(id) ?? 0) + 1;
assistantOccurrences.set(id, occurrence);
const next = allocate(occurrence === 1 ? id : `${id}:${occurrence}`);
const pending = pendingByRawId.get(id);
if (pending) {
pending.push(next);
} else {
pendingByRawId.set(id, [next]);
}
return next;
};
const resolveToolResultId = (id: string): string => {
const pending = pendingByRawId.get(id);
if (pending && pending.length > 0) {
const next = pending.shift()!;
if (pending.length === 0) {
pendingByRawId.delete(id);
}
return next;
}
const occurrence = (orphanToolResultOccurrences.get(id) ?? 0) + 1;
orphanToolResultOccurrences.set(id, occurrence);
return allocate(`${id}:tool_result:${occurrence}`);
};
return { resolveAssistantId, resolveToolResultId };
}
function rewriteAssistantToolCallIds(params: {
message: Extract<AgentMessage, { role: "assistant" }>;
resolve: (id: string) => string;
resolveId: (id: string) => string;
}): Extract<AgentMessage, { role: "assistant" }> {
const content = params.message.content;
if (!Array.isArray(content)) {
@ -168,7 +214,7 @@ function rewriteAssistantToolCallIds(params: {
) {
return block;
}
const nextId = params.resolve(id);
const nextId = params.resolveId(id);
if (nextId === id) {
return block;
}
@ -184,7 +230,7 @@ function rewriteAssistantToolCallIds(params: {
function rewriteToolResultIds(params: {
message: Extract<AgentMessage, { role: "toolResult" }>;
resolve: (id: string) => string;
resolveId: (id: string) => string;
}): Extract<AgentMessage, { role: "toolResult" }> {
const toolCallId =
typeof params.message.toolCallId === "string" && params.message.toolCallId
@ -192,9 +238,14 @@ function rewriteToolResultIds(params: {
: undefined;
const toolUseId = (params.message as { toolUseId?: unknown }).toolUseId;
const toolUseIdStr = typeof toolUseId === "string" && toolUseId ? toolUseId : undefined;
const sharedRawId =
toolCallId && toolUseIdStr && toolCallId === toolUseIdStr ? toolCallId : undefined;
const nextToolCallId = toolCallId ? params.resolve(toolCallId) : undefined;
const nextToolUseId = toolUseIdStr ? params.resolve(toolUseIdStr) : undefined;
const sharedResolvedId = sharedRawId ? params.resolveId(sharedRawId) : undefined;
const nextToolCallId =
sharedResolvedId ?? (toolCallId ? params.resolveId(toolCallId) : undefined);
const nextToolUseId =
sharedResolvedId ?? (toolUseIdStr ? params.resolveId(toolUseIdStr) : undefined);
if (nextToolCallId === toolCallId && nextToolUseId === toolUseIdStr) {
return params.message;
@ -219,21 +270,11 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
): AgentMessage[] {
// Strict mode: only [a-zA-Z0-9]
// Strict9 mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
// Sanitization can introduce collisions (e.g. `a|b` and `a:b` -> `ab`).
// Fix by applying a stable, transcript-wide mapping and de-duping via suffix.
const map = new Map<string, string>();
const used = new Set<string>();
const resolve = (id: string) => {
const existing = map.get(id);
if (existing) {
return existing;
}
const next = makeUniqueToolId({ id, used, mode });
map.set(id, next);
used.add(next);
return next;
};
// Sanitization can introduce collisions, and some providers also reject raw
// duplicate tool-call IDs. Track assistant occurrences in-order so repeated
// raw IDs receive distinct rewritten IDs, while matching tool results consume
// the same rewritten IDs in encounter order.
const { resolveAssistantId, resolveToolResultId } = createOccurrenceAwareResolver(mode);
let changed = false;
const out = messages.map((msg) => {
@ -244,7 +285,7 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
if (role === "assistant") {
const next = rewriteAssistantToolCallIds({
message: msg as Extract<AgentMessage, { role: "assistant" }>,
resolve,
resolveId: resolveAssistantId,
});
if (next !== msg) {
changed = true;
@ -254,7 +295,7 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
if (role === "toolResult") {
const next = rewriteToolResultIds({
message: msg as Extract<AgentMessage, { role: "toolResult" }>,
resolve,
resolveId: resolveToolResultId,
});
if (next !== msg) {
changed = true;

View File

@ -78,7 +78,10 @@ export function resolveTranscriptPolicy(params: {
provider,
modelId,
});
const requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions";
const requiresOpenAiCompatibleToolIdSanitization =
params.modelApi === "openai-completions" ||
(!isOpenAi &&
(params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses"));
// Anthropic Claude endpoints can reject replayed `thinking` blocks unless the
// original signatures are preserved byte-for-byte. Drop them at send-time to

View File

@ -226,6 +226,30 @@ describe("ws connect policy", () => {
expect(shouldSkipControlUiPairing(strict, "operator", true)).toBe(true);
});
test("auth.mode=none skips pairing for operator control-ui only", () => {
const controlUi = resolveControlUiAuthPolicy({
isControlUi: true,
controlUiConfig: undefined,
deviceRaw: null,
});
const nonControlUi = resolveControlUiAuthPolicy({
isControlUi: false,
controlUiConfig: undefined,
deviceRaw: null,
});
// Control UI + operator + auth.mode=none: skip pairing (the fix for #42931)
expect(shouldSkipControlUiPairing(controlUi, "operator", false, "none")).toBe(true);
// Control UI + node role + auth.mode=none: still require pairing
expect(shouldSkipControlUiPairing(controlUi, "node", false, "none")).toBe(false);
// Non-Control-UI + operator + auth.mode=none: still require pairing
// (prevents #43478 regression where ALL clients bypassed pairing)
expect(shouldSkipControlUiPairing(nonControlUi, "operator", false, "none")).toBe(false);
// Control UI + operator + auth.mode=shared-key: no change
expect(shouldSkipControlUiPairing(controlUi, "operator", false, "shared-key")).toBe(false);
// Control UI + operator + no authMode: no change
expect(shouldSkipControlUiPairing(controlUi, "operator", false)).toBe(false);
});
test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => {
const cases: Array<{
role: "operator" | "node";

View File

@ -3,6 +3,7 @@ import type { GatewayRole } from "../../role-policy.js";
import { roleCanSkipDeviceIdentity } from "../../role-policy.js";
export type ControlUiAuthPolicy = {
isControlUi: boolean;
allowInsecureAuthConfigured: boolean;
dangerouslyDisableDeviceAuth: boolean;
allowBypass: boolean;
@ -24,6 +25,7 @@ export function resolveControlUiAuthPolicy(params: {
const dangerouslyDisableDeviceAuth =
params.isControlUi && params.controlUiConfig?.dangerouslyDisableDeviceAuth === true;
return {
isControlUi: params.isControlUi,
allowInsecureAuthConfigured,
dangerouslyDisableDeviceAuth,
// `allowInsecureAuth` must not bypass secure-context/device-auth requirements.
@ -36,10 +38,21 @@ export function shouldSkipControlUiPairing(
policy: ControlUiAuthPolicy,
role: GatewayRole,
trustedProxyAuthOk = false,
authMode?: string,
): boolean {
if (trustedProxyAuthOk) {
return true;
}
// When auth is completely disabled (mode=none), there is no shared secret
// or token to gate pairing. Requiring pairing in this configuration adds
// friction without security value since any client can already connect
// without credentials. Guard with policy.isControlUi because this function
// is called for ALL clients (not just Control UI) at the call site.
// Scope to operator role so node-role sessions still need device identity
// (#43478 was reverted for skipping ALL clients).
if (policy.isControlUi && role === "operator" && authMode === "none") {
return true;
}
// dangerouslyDisableDeviceAuth is the break-glass path for Control UI
// operators. Keep pairing aligned with the missing-device bypass, including
// open-auth deployments where there is no shared token/password to prove.

View File

@ -681,7 +681,13 @@ export function attachGatewayWsMessageHandler(params: {
hasBrowserOriginHeader,
sharedAuthOk,
authMethod,
}) || shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk);
}) ||
shouldSkipControlUiPairing(
controlUiAuthPolicy,
role,
trustedProxyAuthOk,
resolvedAuth.mode,
);
if (device && devicePublicKey && !skipPairing) {
const formatAuditList = (items: string[] | undefined): string => {
if (!items || items.length === 0) {