Compare commits

...

6 Commits

Author SHA1 Message Date
Hiago Silva d683c4e6a4
Merge 133fb1a300 into c4265a5f16 2026-03-15 11:40:34 -03:00
Hiago Silva 133fb1a300
style: fix indentation 2026-03-15 11:40:30 -03: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
Jason 9d3e653ec9
fix(web): handle 515 Stream Error during WhatsApp QR pairing (#27910)
* fix(web): handle 515 Stream Error during WhatsApp QR pairing

getStatusCode() never unwrapped the lastDisconnect wrapper object,
so login.errorStatus was always undefined and the 515 restart path
in restartLoginSocket was dead code.

- Add err.error?.output?.statusCode fallback to getStatusCode()
- Export waitForCredsSaveQueue() so callers can await pending creds
- Await creds flush in restartLoginSocket before creating new socket

Fixes #3942

* test: update session mock for getStatusCode unwrap + waitForCredsSaveQueue

Mirror the getStatusCode fix (err.error?.output?.statusCode fallback)
in the test mock and export waitForCredsSaveQueue so restartLoginSocket
tests work correctly.

* fix(web): scope creds save queue per-authDir to avoid cross-account blocking

The credential save queue was a single global promise chain shared by all
WhatsApp accounts. In multi-account setups, a slow save on one account
blocked credential writes and 515 restart recovery for unrelated accounts.

Replace the global queue with a per-authDir Map so each account's creds
serialize independently. waitForCredsSaveQueue() now accepts an optional
authDir to wait on a single account's queue, or waits on all when omitted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: use real Baileys v7 error shape in 515 restart test

The test was using { output: { statusCode: 515 } } which was already
handled before the fix. Updated to use the actual Baileys v7 shape
{ error: { output: { statusCode: 515 } } } to cover the new fallback
path in getStatusCode.

Co-Authored-By: Claude Code (Opus 4.6) <noreply@anthropic.com>

* fix(web): bound credential-queue wait during 515 restart

Prevents restartLoginSocket from blocking indefinitely if a queued
saveCreds() promise stalls (e.g. hung filesystem write).

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clear flush timeout handle and assert creds queue in test

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: evict settled credsSaveQueues entries to prevent unbounded growth

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: share WhatsApp 515 creds flush handling (#27910) (thanks @asyncjason)

---------

Co-authored-by: Jason Separovic <jason@wilma.dog>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-15 17:00:07 +05:30
19 changed files with 680 additions and 55 deletions

View File

@ -31,6 +31,8 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason. - Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
- 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. - 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/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 ### Fixes
@ -42,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. - 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) - 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 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 ## 2026.3.13

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[] { function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): MarkdownIR[] {
if (!ir.text) { if (!ir.text) {
return []; return [];
@ -523,7 +663,7 @@ function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): Markd
const chunks: MarkdownIR[] = []; const chunks: MarkdownIR[] = [];
let cursor = 0; let cursor = 0;
while (cursor < ir.text.length) { while (cursor < ir.text.length) {
const end = Math.min(ir.text.length, cursor + normalizedLimit); const end = findMarkdownIRPreservedSplitIndex(ir.text, cursor, normalizedLimit);
chunks.push({ chunks.push({
text: ir.text.slice(cursor, end), text: ir.text.slice(cursor, end),
styles: sliceStyleSpans(ir.styles, cursor, end), styles: sliceStyleSpans(ir.styles, cursor, end),
@ -534,32 +674,98 @@ function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): Markd
return chunks; 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( function renderTelegramChunksWithinHtmlLimit(
ir: MarkdownIR, ir: MarkdownIR,
limit: number, limit: number,
): TelegramFormattedChunk[] { ): TelegramFormattedChunk[] {
const normalizedLimit = Math.max(1, Math.floor(limit)); const normalizedLimit = Math.max(1, Math.floor(limit));
const pending = chunkMarkdownIR(ir, normalizedLimit); const pending = chunkMarkdownIR(ir, normalizedLimit);
const rendered: TelegramFormattedChunk[] = []; const finalized: MarkdownIR[] = [];
while (pending.length > 0) { while (pending.length > 0) {
const chunk = pending.shift(); const chunk = pending.shift();
if (!chunk) { if (!chunk) {
continue; continue;
} }
const html = wrapFileReferencesInHtml(renderTelegramHtml(chunk)); const html = renderTelegramChunkHtml(chunk);
if (html.length <= normalizedLimit || chunk.text.length <= 1) { if (html.length <= normalizedLimit || chunk.text.length <= 1) {
rendered.push({ html, text: chunk.text }); finalized.push(chunk);
continue; continue;
} }
const split = splitTelegramChunkByHtmlLimit(chunk, normalizedLimit, html.length); const split = splitTelegramChunkByHtmlLimit(chunk, normalizedLimit, html.length);
if (split.length <= 1) { if (split.length <= 1) {
// Worst-case safety: avoid retry loops, deliver the chunk as-is. // Worst-case safety: avoid retry loops, deliver the chunk as-is.
rendered.push({ html, text: chunk.text }); finalized.push(chunk);
continue; continue;
} }
pending.unshift(...split); pending.unshift(...split);
} }
return rendered; return coalesceWhitespaceOnlyMarkdownIRChunks(finalized, normalizedLimit).map((chunk) => ({
html: renderTelegramChunkHtml(chunk),
text: chunk.text,
}));
} }
export function markdownToTelegramChunks( 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.map((chunk) => chunk.text).join("")).toBe(input);
expect(chunks.every((chunk) => chunk.html.length <= 5)).toBe(true); 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", () => { describe("edge cases", () => {

View File

@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js"; import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js";
import { createWaSocket, logoutWeb, waitForWaConnection } from "./session.js"; import {
createWaSocket,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
vi.mock("./session.js", () => { vi.mock("./session.js", () => {
const createWaSocket = vi.fn( const createWaSocket = vi.fn(
@ -17,11 +22,13 @@ vi.mock("./session.js", () => {
const getStatusCode = vi.fn( const getStatusCode = vi.fn(
(err: unknown) => (err: unknown) =>
(err as { output?: { statusCode?: number } })?.output?.statusCode ?? (err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status, (err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
); );
const webAuthExists = vi.fn(async () => false); const webAuthExists = vi.fn(async () => false);
const readWebSelfId = vi.fn(() => ({ e164: null, jid: null })); const readWebSelfId = vi.fn(() => ({ e164: null, jid: null }));
const logoutWeb = vi.fn(async () => true); const logoutWeb = vi.fn(async () => true);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return { return {
createWaSocket, createWaSocket,
waitForWaConnection, waitForWaConnection,
@ -30,6 +37,7 @@ vi.mock("./session.js", () => {
webAuthExists, webAuthExists,
readWebSelfId, readWebSelfId,
logoutWeb, logoutWeb,
waitForCredsSaveQueueWithTimeout,
}; };
}); });
@ -39,22 +47,43 @@ vi.mock("./qr-image.js", () => ({
const createWaSocketMock = vi.mocked(createWaSocket); const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection); const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const logoutWebMock = vi.mocked(logoutWeb); const logoutWebMock = vi.mocked(logoutWeb);
async function flushTasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("login-qr", () => { describe("login-qr", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("restarts login once on status 515 and completes", async () => { it("restarts login once on status 515 and completes", async () => {
let releaseCredsFlush: (() => void) | undefined;
const credsFlushGate = new Promise<void>((resolve) => {
releaseCredsFlush = resolve;
});
waitForWaConnectionMock waitForWaConnectionMock
.mockRejectedValueOnce({ output: { statusCode: 515 } }) // Baileys v7 wraps the error: { error: BoomError(515) }
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockResolvedValueOnce(undefined); .mockResolvedValueOnce(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
const start = await startWebLoginWithQr({ timeoutMs: 5000 }); const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe("data:image/png;base64,base64"); expect(start.qrDataUrl).toBe("data:image/png;base64,base64");
const result = await waitForWebLogin({ timeoutMs: 5000 }); const resultPromise = waitForWebLogin({ timeoutMs: 5000 });
await flushTasks();
await flushTasks();
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(expect.any(String));
releaseCredsFlush?.();
const result = await resultPromise;
expect(result.connected).toBe(true); expect(result.connected).toBe(true);
expect(createWaSocketMock).toHaveBeenCalledTimes(2); expect(createWaSocketMock).toHaveBeenCalledTimes(2);

View File

@ -12,6 +12,7 @@ import {
getStatusCode, getStatusCode,
logoutWeb, logoutWeb,
readWebSelfId, readWebSelfId,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection, waitForWaConnection,
webAuthExists, webAuthExists,
} from "./session.js"; } from "./session.js";
@ -85,9 +86,10 @@ async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) {
} }
login.restartAttempted = true; login.restartAttempted = true;
runtime.log( runtime.log(
info("WhatsApp asked for a restart after pairing (code 515); retrying connection once…"), info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"),
); );
closeSocket(login.sock); closeSocket(login.sock);
await waitForCredsSaveQueueWithTimeout(login.authDir);
try { try {
const sock = await createWaSocket(false, login.verbose, { const sock = await createWaSocket(false, login.verbose, {
authDir: login.authDir, authDir: login.authDir,

View File

@ -4,7 +4,12 @@ import path from "node:path";
import { DisconnectReason } from "@whiskeysockets/baileys"; import { DisconnectReason } from "@whiskeysockets/baileys";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loginWeb } from "./login.js"; import { loginWeb } from "./login.js";
import { createWaSocket, formatError, waitForWaConnection } from "./session.js"; import {
createWaSocket,
formatError,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
const rmMock = vi.spyOn(fs, "rm"); const rmMock = vi.spyOn(fs, "rm");
@ -35,10 +40,19 @@ vi.mock("./session.js", () => {
const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB)); const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB));
const waitForWaConnection = vi.fn(); const waitForWaConnection = vi.fn();
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`); const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
const getStatusCode = vi.fn(
(err: unknown) =>
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return { return {
createWaSocket, createWaSocket,
waitForWaConnection, waitForWaConnection,
formatError, formatError,
getStatusCode,
waitForCredsSaveQueueWithTimeout,
WA_WEB_AUTH_DIR: authDir, WA_WEB_AUTH_DIR: authDir,
logoutWeb: vi.fn(async (params: { authDir?: string }) => { logoutWeb: vi.fn(async (params: { authDir?: string }) => {
await fs.rm(params.authDir ?? authDir, { await fs.rm(params.authDir ?? authDir, {
@ -52,8 +66,14 @@ vi.mock("./session.js", () => {
const createWaSocketMock = vi.mocked(createWaSocket); const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection); const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const formatErrorMock = vi.mocked(formatError); const formatErrorMock = vi.mocked(formatError);
async function flushTasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("loginWeb coverage", () => { describe("loginWeb coverage", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
@ -65,12 +85,25 @@ describe("loginWeb coverage", () => {
}); });
it("restarts once when WhatsApp requests code 515", async () => { it("restarts once when WhatsApp requests code 515", async () => {
let releaseCredsFlush: (() => void) | undefined;
const credsFlushGate = new Promise<void>((resolve) => {
releaseCredsFlush = resolve;
});
waitForWaConnectionMock waitForWaConnectionMock
.mockRejectedValueOnce({ output: { statusCode: 515 } }) .mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockResolvedValueOnce(undefined); .mockResolvedValueOnce(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
const runtime = { log: vi.fn(), error: vi.fn() } as never; const runtime = { log: vi.fn(), error: vi.fn() } as never;
await loginWeb(false, waitForWaConnectionMock as never, runtime); const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime);
await flushTasks();
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(authDir);
releaseCredsFlush?.();
await pendingLogin;
expect(createWaSocketMock).toHaveBeenCalledTimes(2); expect(createWaSocketMock).toHaveBeenCalledTimes(2);
const firstSock = await createWaSocketMock.mock.results[0]?.value; const firstSock = await createWaSocketMock.mock.results[0]?.value;

View File

@ -5,7 +5,14 @@ import { danger, info, success } from "../../../src/globals.js";
import { logInfo } from "../../../src/logger.js"; import { logInfo } from "../../../src/logger.js";
import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js";
import { resolveWhatsAppAccount } from "./accounts.js"; import { resolveWhatsAppAccount } from "./accounts.js";
import { createWaSocket, formatError, logoutWeb, waitForWaConnection } from "./session.js"; import {
createWaSocket,
formatError,
getStatusCode,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
export async function loginWeb( export async function loginWeb(
verbose: boolean, verbose: boolean,
@ -24,20 +31,17 @@ export async function loginWeb(
await wait(sock); await wait(sock);
console.log(success("✅ Linked! Credentials saved for future sends.")); console.log(success("✅ Linked! Credentials saved for future sends."));
} catch (err) { } catch (err) {
const code = const code = getStatusCode(err);
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode ??
(err as { output?: { statusCode?: number } })?.output?.statusCode;
if (code === 515) { if (code === 515) {
console.log( console.log(
info( info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"),
"WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…",
),
); );
try { try {
sock.ws?.close(); sock.ws?.close();
} catch { } catch {
// ignore // ignore
} }
await waitForCredsSaveQueueWithTimeout(account.authDir);
const retry = await createWaSocket(false, verbose, { const retry = await createWaSocket(false, verbose, {
authDir: account.authDir, authDir: account.authDir,
}); });

View File

@ -204,6 +204,62 @@ describe("web session", () => {
expect(inFlight).toBe(0); expect(inFlight).toBe(0);
}); });
it("lets different authDir queues flush independently", async () => {
let inFlightA = 0;
let inFlightB = 0;
let releaseA: (() => void) | null = null;
let releaseB: (() => void) | null = null;
const gateA = new Promise<void>((resolve) => {
releaseA = resolve;
});
const gateB = new Promise<void>((resolve) => {
releaseB = resolve;
});
const saveCredsA = vi.fn(async () => {
inFlightA += 1;
await gateA;
inFlightA -= 1;
});
const saveCredsB = vi.fn(async () => {
inFlightB += 1;
await gateB;
inFlightB -= 1;
});
useMultiFileAuthStateMock
.mockResolvedValueOnce({
state: { creds: {} as never, keys: {} as never },
saveCreds: saveCredsA,
})
.mockResolvedValueOnce({
state: { creds: {} as never, keys: {} as never },
saveCreds: saveCredsB,
});
await createWaSocket(false, false, { authDir: "/tmp/wa-a" });
const sockA = getLastSocket();
await createWaSocket(false, false, { authDir: "/tmp/wa-b" });
const sockB = getLastSocket();
sockA.ev.emit("creds.update", {});
sockB.ev.emit("creds.update", {});
await flushCredsUpdate();
expect(saveCredsA).toHaveBeenCalledTimes(1);
expect(saveCredsB).toHaveBeenCalledTimes(1);
expect(inFlightA).toBe(1);
expect(inFlightB).toBe(1);
(releaseA as (() => void) | null)?.();
(releaseB as (() => void) | null)?.();
await flushCredsUpdate();
await flushCredsUpdate();
expect(inFlightA).toBe(0);
expect(inFlightB).toBe(0);
});
it("rotates creds backup when creds.json is valid JSON", async () => { it("rotates creds backup when creds.json is valid JSON", async () => {
const creds = mockCredsJsonSpies("{}"); const creds = mockCredsJsonSpies("{}");
const backupSuffix = path.join( const backupSuffix = path.join(

View File

@ -31,17 +31,24 @@ export {
webAuthExists, webAuthExists,
} from "./auth-store.js"; } from "./auth-store.js";
let credsSaveQueue: Promise<void> = Promise.resolve(); // Per-authDir queues so multi-account creds saves don't block each other.
const credsSaveQueues = new Map<string, Promise<void>>();
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
function enqueueSaveCreds( function enqueueSaveCreds(
authDir: string, authDir: string,
saveCreds: () => Promise<void> | void, saveCreds: () => Promise<void> | void,
logger: ReturnType<typeof getChildLogger>, logger: ReturnType<typeof getChildLogger>,
): void { ): void {
credsSaveQueue = credsSaveQueue const prev = credsSaveQueues.get(authDir) ?? Promise.resolve();
const next = prev
.then(() => safeSaveCreds(authDir, saveCreds, logger)) .then(() => safeSaveCreds(authDir, saveCreds, logger))
.catch((err) => { .catch((err) => {
logger.warn({ error: String(err) }, "WhatsApp creds save queue error"); logger.warn({ error: String(err) }, "WhatsApp creds save queue error");
})
.finally(() => {
if (credsSaveQueues.get(authDir) === next) credsSaveQueues.delete(authDir);
}); });
credsSaveQueues.set(authDir, next);
} }
async function safeSaveCreds( async function safeSaveCreds(
@ -186,10 +193,37 @@ export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>)
export function getStatusCode(err: unknown) { export function getStatusCode(err: unknown) {
return ( return (
(err as { output?: { statusCode?: number } })?.output?.statusCode ?? (err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status (err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode
); );
} }
/** Await pending credential saves — scoped to one authDir, or all if omitted. */
export function waitForCredsSaveQueue(authDir?: string): Promise<void> {
if (authDir) {
return credsSaveQueues.get(authDir) ?? Promise.resolve();
}
return Promise.all(credsSaveQueues.values()).then(() => {});
}
/** Await pending credential saves, but don't hang forever on stalled I/O. */
export async function waitForCredsSaveQueueWithTimeout(
authDir: string,
timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS,
): Promise<void> {
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
waitForCredsSaveQueue(authDir),
new Promise<void>((resolve) => {
flushTimeout = setTimeout(resolve, timeoutMs);
}),
]).finally(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
}
});
}
function safeStringify(value: unknown, limit = 800): string { function safeStringify(value: unknown, limit = 800): string {
try { try {
const seen = new WeakSet(); const seen = new WeakSet();

View File

@ -2,6 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, UserMessage, Usage } from "@mariozechner/pi-ai"; import type { AssistantMessage, UserMessage, Usage } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
expectOpenAIResponsesStrictSanitizeCall,
loadSanitizeSessionHistoryWithCleanMocks, loadSanitizeSessionHistoryWithCleanMocks,
makeMockSessionManager, makeMockSessionManager,
makeInMemorySessionManager, makeInMemorySessionManager,
@ -247,7 +248,24 @@ describe("sanitizeSessionHistory", () => {
expect(result).toEqual(mockMessages); 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(); setNonGoogleModelApi();
const result = await sanitizeSessionHistory({ const result = await sanitizeSessionHistory({

View File

@ -702,6 +702,26 @@ describe("wrapStreamFnTrimToolCallNames", () => {
expect(finalToolCall.name).toBe("read"); expect(finalToolCall.name).toBe("read");
expect(finalToolCall.id).toBe("call_42"); 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", () => { describe("wrapStreamFnRepairMalformedToolCallArguments", () => {

View File

@ -667,6 +667,7 @@ function normalizeToolCallIdsInMessage(message: unknown): void {
} }
let fallbackIndex = 1; let fallbackIndex = 1;
const assignedIds = new Set<string>();
for (const block of content) { for (const block of content) {
if (!block || typeof block !== "object") { if (!block || typeof block !== "object") {
continue; continue;
@ -678,20 +679,23 @@ function normalizeToolCallIdsInMessage(message: unknown): void {
if (typeof typedBlock.id === "string") { if (typeof typedBlock.id === "string") {
const trimmedId = typedBlock.id.trim(); const trimmedId = typedBlock.id.trim();
if (trimmedId) { if (trimmedId) {
if (typedBlock.id !== trimmedId) { if (!assignedIds.has(trimmedId)) {
typedBlock.id = trimmedId; if (typedBlock.id !== trimmedId) {
typedBlock.id = trimmedId;
}
assignedIds.add(trimmedId);
continue;
} }
usedIds.add(trimmedId);
continue;
} }
} }
let fallbackId = ""; let fallbackId = "";
while (!fallbackId || usedIds.has(fallbackId)) { while (!fallbackId || usedIds.has(fallbackId) || assignedIds.has(fallbackId)) {
fallbackId = `call_auto_${fallbackIndex++}`; fallbackId = `call_auto_${fallbackIndex++}`;
} }
typedBlock.id = fallbackId; typedBlock.id = fallbackId;
usedIds.add(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( function expectCollisionIdsRemainDistinct(
out: AgentMessage[], out: AgentMessage[],
mode: "strict" | "strict9", mode: "strict" | "strict9",
@ -111,6 +159,26 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expectCollisionIdsRemainDistinct(out, "strict"); 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", () => { it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
const longA = `call_${"a".repeat(60)}`; const longA = `call_${"a".repeat(60)}`;
const longB = `call_${"a".repeat(59)}b`; const longB = `call_${"a".repeat(59)}b`;
@ -181,6 +249,16 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(aId).not.toMatch(/[_-]/); expect(aId).not.toMatch(/[_-]/);
expect(bId).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)", () => { describe("strict9 mode (Mistral tool call IDs)", () => {
@ -231,5 +309,27 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(aId.length).toBe(9); expect(aId.length).toBe(9);
expect(bId.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}`; 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: { function rewriteAssistantToolCallIds(params: {
message: Extract<AgentMessage, { role: "assistant" }>; message: Extract<AgentMessage, { role: "assistant" }>;
resolve: (id: string) => string; resolveId: (id: string) => string;
}): Extract<AgentMessage, { role: "assistant" }> { }): Extract<AgentMessage, { role: "assistant" }> {
const content = params.message.content; const content = params.message.content;
if (!Array.isArray(content)) { if (!Array.isArray(content)) {
@ -168,7 +214,7 @@ function rewriteAssistantToolCallIds(params: {
) { ) {
return block; return block;
} }
const nextId = params.resolve(id); const nextId = params.resolveId(id);
if (nextId === id) { if (nextId === id) {
return block; return block;
} }
@ -184,7 +230,7 @@ function rewriteAssistantToolCallIds(params: {
function rewriteToolResultIds(params: { function rewriteToolResultIds(params: {
message: Extract<AgentMessage, { role: "toolResult" }>; message: Extract<AgentMessage, { role: "toolResult" }>;
resolve: (id: string) => string; resolveId: (id: string) => string;
}): Extract<AgentMessage, { role: "toolResult" }> { }): Extract<AgentMessage, { role: "toolResult" }> {
const toolCallId = const toolCallId =
typeof params.message.toolCallId === "string" && params.message.toolCallId typeof params.message.toolCallId === "string" && params.message.toolCallId
@ -192,9 +238,14 @@ function rewriteToolResultIds(params: {
: undefined; : undefined;
const toolUseId = (params.message as { toolUseId?: unknown }).toolUseId; const toolUseId = (params.message as { toolUseId?: unknown }).toolUseId;
const toolUseIdStr = typeof toolUseId === "string" && toolUseId ? toolUseId : undefined; const toolUseIdStr = typeof toolUseId === "string" && toolUseId ? toolUseId : undefined;
const sharedRawId =
toolCallId && toolUseIdStr && toolCallId === toolUseIdStr ? toolCallId : undefined;
const nextToolCallId = toolCallId ? params.resolve(toolCallId) : undefined; const sharedResolvedId = sharedRawId ? params.resolveId(sharedRawId) : undefined;
const nextToolUseId = toolUseIdStr ? params.resolve(toolUseIdStr) : undefined; const nextToolCallId =
sharedResolvedId ?? (toolCallId ? params.resolveId(toolCallId) : undefined);
const nextToolUseId =
sharedResolvedId ?? (toolUseIdStr ? params.resolveId(toolUseIdStr) : undefined);
if (nextToolCallId === toolCallId && nextToolUseId === toolUseIdStr) { if (nextToolCallId === toolCallId && nextToolUseId === toolUseIdStr) {
return params.message; return params.message;
@ -219,21 +270,11 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
): AgentMessage[] { ): AgentMessage[] {
// Strict mode: only [a-zA-Z0-9] // Strict mode: only [a-zA-Z0-9]
// Strict9 mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement) // 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`). // Sanitization can introduce collisions, and some providers also reject raw
// Fix by applying a stable, transcript-wide mapping and de-duping via suffix. // duplicate tool-call IDs. Track assistant occurrences in-order so repeated
const map = new Map<string, string>(); // raw IDs receive distinct rewritten IDs, while matching tool results consume
const used = new Set<string>(); // the same rewritten IDs in encounter order.
const { resolveAssistantId, resolveToolResultId } = createOccurrenceAwareResolver(mode);
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;
};
let changed = false; let changed = false;
const out = messages.map((msg) => { const out = messages.map((msg) => {
@ -244,7 +285,7 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
if (role === "assistant") { if (role === "assistant") {
const next = rewriteAssistantToolCallIds({ const next = rewriteAssistantToolCallIds({
message: msg as Extract<AgentMessage, { role: "assistant" }>, message: msg as Extract<AgentMessage, { role: "assistant" }>,
resolve, resolveId: resolveAssistantId,
}); });
if (next !== msg) { if (next !== msg) {
changed = true; changed = true;
@ -254,7 +295,7 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
if (role === "toolResult") { if (role === "toolResult") {
const next = rewriteToolResultIds({ const next = rewriteToolResultIds({
message: msg as Extract<AgentMessage, { role: "toolResult" }>, message: msg as Extract<AgentMessage, { role: "toolResult" }>,
resolve, resolveId: resolveToolResultId,
}); });
if (next !== msg) { if (next !== msg) {
changed = true; changed = true;

View File

@ -78,7 +78,10 @@ export function resolveTranscriptPolicy(params: {
provider, provider,
modelId, 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 // Anthropic Claude endpoints can reject replayed `thinking` blocks unless the
// original signatures are preserved byte-for-byte. Drop them at send-time to // 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); 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", () => { test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => {
const cases: Array<{ const cases: Array<{
role: "operator" | "node"; role: "operator" | "node";

View File

@ -3,6 +3,7 @@ import type { GatewayRole } from "../../role-policy.js";
import { roleCanSkipDeviceIdentity } from "../../role-policy.js"; import { roleCanSkipDeviceIdentity } from "../../role-policy.js";
export type ControlUiAuthPolicy = { export type ControlUiAuthPolicy = {
isControlUi: boolean;
allowInsecureAuthConfigured: boolean; allowInsecureAuthConfigured: boolean;
dangerouslyDisableDeviceAuth: boolean; dangerouslyDisableDeviceAuth: boolean;
allowBypass: boolean; allowBypass: boolean;
@ -24,6 +25,7 @@ export function resolveControlUiAuthPolicy(params: {
const dangerouslyDisableDeviceAuth = const dangerouslyDisableDeviceAuth =
params.isControlUi && params.controlUiConfig?.dangerouslyDisableDeviceAuth === true; params.isControlUi && params.controlUiConfig?.dangerouslyDisableDeviceAuth === true;
return { return {
isControlUi: params.isControlUi,
allowInsecureAuthConfigured, allowInsecureAuthConfigured,
dangerouslyDisableDeviceAuth, dangerouslyDisableDeviceAuth,
// `allowInsecureAuth` must not bypass secure-context/device-auth requirements. // `allowInsecureAuth` must not bypass secure-context/device-auth requirements.
@ -36,10 +38,21 @@ export function shouldSkipControlUiPairing(
policy: ControlUiAuthPolicy, policy: ControlUiAuthPolicy,
role: GatewayRole, role: GatewayRole,
trustedProxyAuthOk = false, trustedProxyAuthOk = false,
authMode?: string,
): boolean { ): boolean {
if (trustedProxyAuthOk) { if (trustedProxyAuthOk) {
return true; 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 // dangerouslyDisableDeviceAuth is the break-glass path for Control UI
// operators. Keep pairing aligned with the missing-device bypass, including // operators. Keep pairing aligned with the missing-device bypass, including
// open-auth deployments where there is no shared token/password to prove. // open-auth deployments where there is no shared token/password to prove.

View File

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

View File

@ -729,7 +729,7 @@ export async function edgeTTS(params: {
({ size } = statSync(outputPath)); ({ size } = statSync(outputPath));
if (size === 0) { if (size === 0) {
throw new Error("Edge TTS produced empty audio file after retry"); throw new Error("Edge TTS produced empty audio file after retry.");
} }
} }
} }