From 5176c209b9876022bb446350ba1dde9707aab6e3 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Fri, 12 Jun 2026 01:13:16 +0800 Subject: [PATCH 1/5] fix(daemon): Sanitize ACP delete logs and type MCP restarts Co-authored-by: Qwen-Coder --- packages/cli/src/serve/acpHttp/dispatch.ts | 8 ++++-- .../cli/src/serve/acpHttp/transport.test.ts | 28 +++++++++++++++++++ packages/core/src/tools/mcp-pool-key.ts | 4 +-- .../sdk-typescript/src/daemon/DaemonClient.ts | 8 ++++-- packages/sdk-typescript/src/daemon/types.ts | 9 ++++++ .../test/unit/DaemonClient.test.ts | 23 +++++++++++++++ .../client/components/dialogs/McpDialog.tsx | 13 ++++++++- packages/web-shell/client/i18n.tsx | 4 +++ 8 files changed, 90 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/serve/acpHttp/dispatch.ts b/packages/cli/src/serve/acpHttp/dispatch.ts index 2eca63e79cf..fcbb3e15c04 100644 --- a/packages/cli/src/serve/acpHttp/dispatch.ts +++ b/packages/cli/src/serve/acpHttp/dispatch.ts @@ -1719,8 +1719,10 @@ export class AcpDispatcher { ) { closedIds.push(sid); } else { + const safeSessionId = logSafe(sid.slice(0, 8)); + const safeMessage = logSafe(msg); writeStderrLine( - `qwen serve: /acp sessions/delete closeSession(${sid.slice(0, 8)}) failed: ${msg}`, + `qwen serve: /acp sessions/delete closeSession(${safeSessionId}) failed: ${safeMessage}`, ); closeErrors.push({ sessionId: sid, error: msg }); } @@ -1730,8 +1732,10 @@ export class AcpDispatcher { const svc = new SessionService(this.boundWorkspace); const removeResult = await svc.removeSessions(closedIds); for (const e of removeResult.errors) { + const safeSessionId = logSafe(e.sessionId.slice(0, 8)); + const safeMessage = logSafe(e.error.message); writeStderrLine( - `qwen serve: /acp sessions/delete removeSessions(${e.sessionId.slice(0, 8)}) failed: ${e.error.message}`, + `qwen serve: /acp sessions/delete removeSessions(${safeSessionId}) failed: ${safeMessage}`, ); } this.replyConn(conn, id, { diff --git a/packages/cli/src/serve/acpHttp/transport.test.ts b/packages/cli/src/serve/acpHttp/transport.test.ts index 72c0da78f1a..19fcc906baf 100644 --- a/packages/cli/src/serve/acpHttp/transport.test.ts +++ b/packages/cli/src/serve/acpHttp/transport.test.ts @@ -85,6 +85,7 @@ class FakeBridge { lastSetModel: unknown; lastSpawnScope: string | undefined; closeShouldThrow = false; + closeError: Error | undefined; killed: string[] = []; cancelled: string[] = []; /** When set, spawnOrAttach/loadSession await it (to simulate a slow bridge). */ @@ -207,6 +208,7 @@ class FakeBridge { async closeSession(sessionId: string) { this.closedSessions.push(sessionId); if (this.closeGate) await this.closeGate; + if (this.closeError) throw this.closeError; if (this.closeShouldThrow) throw new Error('bridge close failed'); } async detachClient(sessionId: string, clientId?: string) { @@ -1920,6 +1922,32 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { const frames = await takeFrames(await streamRes, 1); expect(frames[0]).toMatchObject({ error: { code: -32602 } }); }); + + it('_qwen/sessions/delete sanitizes stderr close errors', async () => { + bridge.closeError = new Error('close\nFAILED\r\x1b[31m'); + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 67, + method: '_qwen/sessions/delete', + params: { sessionIds: ['sess\nFAKE\r\x1b[31m'] }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ + result: { removed: [], notFound: [] }, + }); + const deleteLog = stdioMocks.writeStderrLine.mock.calls + .map(([line]) => line) + .find((line) => line.includes('sessions/delete')); + expect(deleteLog).toContain( + 'closeSession(sess FAK) failed: close FAILED [31m', + ); + expect(deleteLog).not.toContain('\n'); + expect(deleteLog).not.toContain('\r'); + expect(deleteLog).not.toContain('\x1b'); + }); }); describe('auth methods', () => { diff --git a/packages/core/src/tools/mcp-pool-key.ts b/packages/core/src/tools/mcp-pool-key.ts index 151bd5621a3..d059121d581 100644 --- a/packages/core/src/tools/mcp-pool-key.ts +++ b/packages/core/src/tools/mcp-pool-key.ts @@ -110,8 +110,8 @@ function sortedEntries( * distinct entry. * * Hashed fields (transport-defining): - * transport, command, args, cwd, env, url, httpUrl, headers, - * timeout, oauth + * transport, command, args, cwd, env, url, httpUrl, tcp, headers, + * timeout, oauth, authProviderType, targetAudience, targetServiceAccount * * Excluded fields (per-session filter / metadata; do NOT change the * underlying transport): diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 696fd87c3bf..a1e7d43fce2 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -1499,10 +1499,14 @@ export class DaemonClient { */ async restartMcpServer( serverName: string, - opts?: { clientId?: string; timeoutMs?: number }, + opts?: { clientId?: string; entryIndex?: number | '*'; timeoutMs?: number }, ): Promise { + const query = + opts?.entryIndex === undefined + ? '' + : `?entryIndex=${encodeURIComponent(String(opts.entryIndex))}`; return await this.fetchWithTimeout( - `${this.baseUrl}/workspace/mcp/${encodeURIComponent(serverName)}/restart`, + `${this.baseUrl}/workspace/mcp/${encodeURIComponent(serverName)}/restart${query}`, { method: 'POST', headers: this.headers( diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 5ff5cf7a8fe..91e88d6ff06 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1141,6 +1141,15 @@ export type DaemonMcpRestartResult = restarted: false; skipped: true; reason: 'in_flight' | 'disabled' | 'budget_would_exceed'; + } + | { + serverName: string; + entries: Array<{ + entryIndex: number; + restarted: boolean; + durationMs?: number; + reason?: string; + }>; }; export type DaemonMcpManageAction = diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 473a1075744..5dd48d5008a 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1701,6 +1701,29 @@ describe('DaemonClient', () => { expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); }); + it('forwards entryIndex and returns pool entry results', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + serverName: 'docs', + entries: [ + { entryIndex: 3, restarted: true, durationMs: 42 }, + { entryIndex: 4, restarted: false, reason: 'in_flight' }, + ], + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const result = await client.restartMcpServer('docs', { entryIndex: 3 }); + expect(calls[0]?.url).toBe( + 'http://daemon/workspace/mcp/docs/restart?entryIndex=3', + ); + expect('entries' in result).toBe(true); + if (!('entries' in result)) throw new Error('expected entry results'); + expect(result.entries).toEqual([ + { entryIndex: 3, restarted: true, durationMs: 42 }, + { entryIndex: 4, restarted: false, reason: 'in_flight' }, + ]); + }); + it('throws on 404 when the daemon reports an unknown server', async () => { const { fetch } = recordingFetch(() => jsonResponse(404, { error: 'no such server' }), diff --git a/packages/web-shell/client/components/dialogs/McpDialog.tsx b/packages/web-shell/client/components/dialogs/McpDialog.tsx index 53375a08c1b..a8aa2f2f858 100644 --- a/packages/web-shell/client/components/dialogs/McpDialog.tsx +++ b/packages/web-shell/client/components/dialogs/McpDialog.tsx @@ -225,7 +225,18 @@ export function McpDialog({ onClose }: McpDialogProps) { setMessage(null); restartServer(serverName) .then((result) => { - if (result.restarted) { + if ('entries' in result) { + const restartedCount = result.entries.filter( + (entry) => entry.restarted, + ).length; + setMessage( + t('mcp.restartEntries', { + name: result.serverName, + restarted: restartedCount, + total: result.entries.length, + }), + ); + } else if (result.restarted) { setMessage( t('mcp.restarted', { name: result.serverName, diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 89232d23b69..39989b9bd84 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -517,6 +517,8 @@ const EN: Messages = { 'Note: First startup may take longer. Tool availability will update automatically.', 'mcp.restartSkipped': (v) => `Skipped ${v?.name ?? ''}: ${v?.reason ?? ''}`, 'mcp.restarted': (v) => `Restarted ${v?.name ?? ''} in ${v?.duration ?? 0}ms`, + 'mcp.restartEntries': (v) => + `Restarted ${v?.restarted ?? 0}/${v?.total ?? 0} ${v?.name ?? ''} entries`, 'mcp.source': 'Source', 'mcp.source.extension': 'Extension', 'mcp.source.project': 'Workspace Settings', @@ -1308,6 +1310,8 @@ const ZH: Messages = { 'mcp.startingNote': '注意:首次启动可能需要更长时间。工具可用性会自动更新。', 'mcp.restartSkipped': (v) => `已跳过 ${v?.name ?? ''}:${v?.reason ?? ''}`, 'mcp.restarted': (v) => `已重启 ${v?.name ?? ''},耗时 ${v?.duration ?? 0}ms`, + 'mcp.restartEntries': (v) => + `已重启 ${v?.name ?? ''} 的 ${v?.restarted ?? 0}/${v?.total ?? 0} 个条目`, 'mcp.source': '来源', 'mcp.source.extension': '扩展', 'mcp.source.project': '工作区设置', From 6cd532ae84b43d7f6d53f0b62b3c76d349ca926e Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Fri, 12 Jun 2026 12:04:54 +0800 Subject: [PATCH 2/5] test(daemon): Cover PR review edge cases Co-authored-by: Qwen-Coder --- .../cli/src/serve/acpHttp/transport.test.ts | 50 +++++++++++++++++++ .../sdk-typescript/src/daemon/DaemonClient.ts | 3 ++ .../test/unit/DaemonClient.test.ts | 16 ++++++ 3 files changed, 69 insertions(+) diff --git a/packages/cli/src/serve/acpHttp/transport.test.ts b/packages/cli/src/serve/acpHttp/transport.test.ts index 19fcc906baf..c46cc36a034 100644 --- a/packages/cli/src/serve/acpHttp/transport.test.ts +++ b/packages/cli/src/serve/acpHttp/transport.test.ts @@ -11,6 +11,7 @@ import type { AddressInfo } from 'node:net'; import WebSocket from 'ws'; import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +import { SessionService } from '@qwen-code/qwen-code-core'; import type { DaemonWorkspaceService } from '../workspace-service/types.js'; import { mountAcpHttp } from './index.js'; @@ -1948,6 +1949,55 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(deleteLog).not.toContain('\r'); expect(deleteLog).not.toContain('\x1b'); }); + + it('_qwen/sessions/delete sanitizes stderr remove errors', async () => { + const sessionId = 'sess\nFAKE\r\x1b[31m'; + const removeSessionsSpy = vi + .spyOn(SessionService.prototype, 'removeSessions') + .mockResolvedValueOnce({ + removed: [], + notFound: [], + errors: [ + { + sessionId, + error: new Error('remove\nFAILED\r\x1b[31m'), + }, + ], + }); + + try { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 68, + method: '_qwen/sessions/delete', + params: { sessionIds: [sessionId] }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ + result: { + removed: [], + notFound: [], + errors: [{ sessionId, error: 'remove\nFAILED\r\x1b[31m' }], + }, + }); + expect(removeSessionsSpy).toHaveBeenCalledWith([sessionId]); + + const deleteLog = stdioMocks.writeStderrLine.mock.calls + .map(([line]) => line) + .find((line) => line.includes('sessions/delete')); + expect(deleteLog).toContain( + 'removeSessions(sess FAK) failed: remove FAILED [31m', + ); + expect(deleteLog).not.toContain('\n'); + expect(deleteLog).not.toContain('\r'); + expect(deleteLog).not.toContain('\x1b'); + } finally { + removeSessionsSpy.mockRestore(); + } + }); }); describe('auth methods', () => { diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index a1e7d43fce2..8ccffc15c31 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -1495,6 +1495,9 @@ export class DaemonClient { * `timeoutMs` when their threat model needs a tighter cap, or `0` * to disable the timeout entirely. * + * `entryIndex` targets one pooled entry by index. Use `'*'` to + * restart all entries for a pooled server. + * * Pre-flight `caps.features.workspace_mcp_restart` before calling. */ async restartMcpServer( diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 5dd48d5008a..4da24589ebd 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1724,6 +1724,22 @@ describe('DaemonClient', () => { ]); }); + it('forwards wildcard entryIndex unchanged', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + serverName: 'docs', + entries: [], + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await client.restartMcpServer('docs', { entryIndex: '*' }); + + expect(calls[0]?.url).toBe( + 'http://daemon/workspace/mcp/docs/restart?entryIndex=*', + ); + }); + it('throws on 404 when the daemon reports an unknown server', async () => { const { fetch } = recordingFetch(() => jsonResponse(404, { error: 'no such server' }), From e35d782d3ecba7acf81924f1db7191c8c22534c3 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Fri, 12 Jun 2026 12:06:25 +0800 Subject: [PATCH 3/5] fix(web-shell): Show MCP restart entry failures Co-authored-by: Qwen-Coder --- packages/web-shell/client/components/dialogs/McpDialog.tsx | 7 +++++++ packages/web-shell/client/i18n.tsx | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/web-shell/client/components/dialogs/McpDialog.tsx b/packages/web-shell/client/components/dialogs/McpDialog.tsx index a8aa2f2f858..f7651d8dc0f 100644 --- a/packages/web-shell/client/components/dialogs/McpDialog.tsx +++ b/packages/web-shell/client/components/dialogs/McpDialog.tsx @@ -229,11 +229,18 @@ export function McpDialog({ onClose }: McpDialogProps) { const restartedCount = result.entries.filter( (entry) => entry.restarted, ).length; + const failedReasons = result.entries + .filter((entry) => !entry.restarted) + .map( + (entry) => `#${entry.entryIndex}: ${entry.reason ?? 'unknown'}`, + ) + .join(', '); setMessage( t('mcp.restartEntries', { name: result.serverName, restarted: restartedCount, total: result.entries.length, + failedReasons, }), ); } else if (result.restarted) { diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 39989b9bd84..0b79f652ee4 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -518,7 +518,7 @@ const EN: Messages = { 'mcp.restartSkipped': (v) => `Skipped ${v?.name ?? ''}: ${v?.reason ?? ''}`, 'mcp.restarted': (v) => `Restarted ${v?.name ?? ''} in ${v?.duration ?? 0}ms`, 'mcp.restartEntries': (v) => - `Restarted ${v?.restarted ?? 0}/${v?.total ?? 0} ${v?.name ?? ''} entries`, + `Restarted ${v?.restarted ?? 0}/${v?.total ?? 0} ${v?.name ?? ''} entries${v?.failedReasons ? ` (failed: ${v.failedReasons})` : ''}`, 'mcp.source': 'Source', 'mcp.source.extension': 'Extension', 'mcp.source.project': 'Workspace Settings', @@ -1311,7 +1311,7 @@ const ZH: Messages = { 'mcp.restartSkipped': (v) => `已跳过 ${v?.name ?? ''}:${v?.reason ?? ''}`, 'mcp.restarted': (v) => `已重启 ${v?.name ?? ''},耗时 ${v?.duration ?? 0}ms`, 'mcp.restartEntries': (v) => - `已重启 ${v?.name ?? ''} 的 ${v?.restarted ?? 0}/${v?.total ?? 0} 个条目`, + `已重启 ${v?.name ?? ''} 的 ${v?.restarted ?? 0}/${v?.total ?? 0} 个条目${v?.failedReasons ? `(失败:${v.failedReasons})` : ''}`, 'mcp.source': '来源', 'mcp.source.extension': '扩展', 'mcp.source.project': '工作区设置', From 7e1d9fbf28ccf20e22b866383b3af1d9698def12 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Fri, 12 Jun 2026 14:18:11 +0800 Subject: [PATCH 4/5] fix(daemon): Harden ACP delete log sanitization Co-authored-by: Qwen-Coder --- packages/cli/src/serve/acpHttp/dispatch.ts | 4 ++-- packages/cli/src/serve/acpHttp/jsonRpc.ts | 7 +++++-- .../cli/src/serve/acpHttp/transport.test.ts | 21 ++++++++++++++----- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/serve/acpHttp/dispatch.ts b/packages/cli/src/serve/acpHttp/dispatch.ts index fcbb3e15c04..bcd9d2948a9 100644 --- a/packages/cli/src/serve/acpHttp/dispatch.ts +++ b/packages/cli/src/serve/acpHttp/dispatch.ts @@ -1733,7 +1733,7 @@ export class AcpDispatcher { const removeResult = await svc.removeSessions(closedIds); for (const e of removeResult.errors) { const safeSessionId = logSafe(e.sessionId.slice(0, 8)); - const safeMessage = logSafe(e.error.message); + const safeMessage = logSafe(errMsg(e.error)); writeStderrLine( `qwen serve: /acp sessions/delete removeSessions(${safeSessionId}) failed: ${safeMessage}`, ); @@ -1745,7 +1745,7 @@ export class AcpDispatcher { ...closeErrors, ...removeResult.errors.map((e) => ({ sessionId: e.sessionId, - error: e.error.message, + error: errMsg(e.error), })), ], } as unknown); diff --git a/packages/cli/src/serve/acpHttp/jsonRpc.ts b/packages/cli/src/serve/acpHttp/jsonRpc.ts index 71339e69c6f..ada5b5fce15 100644 --- a/packages/cli/src/serve/acpHttp/jsonRpc.ts +++ b/packages/cli/src/serve/acpHttp/jsonRpc.ts @@ -121,13 +121,16 @@ export function isResponse(m: unknown): m is JsonRpcResponse { } /** - * Strip C0 control chars + DEL from values interpolated into operator-facing + * Strip terminal control chars from values interpolated into operator-facing * stderr logs, so a client-controlled `sessionId`/`method`/error string can't * forge or split log lines (log injection). Shared by the transport modules. */ export function logSafe(s: string): string { // eslint-disable-next-line no-control-regex - return s.replace(/[\u0000-\u001f\u007f\u0080-\u009f]/g, ' '); + return s.replace( + /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g, + ' ', + ); } export function success(id: JsonRpcId, result: unknown): JsonRpcSuccess { diff --git a/packages/cli/src/serve/acpHttp/transport.test.ts b/packages/cli/src/serve/acpHttp/transport.test.ts index c46cc36a034..a9699c59632 100644 --- a/packages/cli/src/serve/acpHttp/transport.test.ts +++ b/packages/cli/src/serve/acpHttp/transport.test.ts @@ -1925,7 +1925,11 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); it('_qwen/sessions/delete sanitizes stderr close errors', async () => { - bridge.closeError = new Error('close\nFAILED\r\x1b[31m'); + const lineSep = '\u2028'; + const bidiOverride = '\u202e'; + bridge.closeError = new Error( + `close\nFAILED\r\x1b[31m${lineSep}${bidiOverride}`, + ); const connId = await initialize(); const streamRes = openStream(connId); await new Promise((r) => setTimeout(r, 30)); @@ -1933,7 +1937,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { jsonrpc: '2.0', id: 67, method: '_qwen/sessions/delete', - params: { sessionIds: ['sess\nFAKE\r\x1b[31m'] }, + params: { sessionIds: [`sess${lineSep}FAKE\r\x1b[31m`] }, }); const frames = await takeFrames(await streamRes, 1); expect(frames[0]).toMatchObject({ @@ -1948,10 +1952,15 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(deleteLog).not.toContain('\n'); expect(deleteLog).not.toContain('\r'); expect(deleteLog).not.toContain('\x1b'); + expect(deleteLog).not.toContain(lineSep); + expect(deleteLog).not.toContain(bidiOverride); }); it('_qwen/sessions/delete sanitizes stderr remove errors', async () => { - const sessionId = 'sess\nFAKE\r\x1b[31m'; + const lineSep = '\u2028'; + const bidiOverride = '\u202e'; + const sessionId = `sess${lineSep}FAKE\r\x1b[31m`; + const removeError = `remove\nFAILED\r\x1b[31m${lineSep}${bidiOverride}`; const removeSessionsSpy = vi .spyOn(SessionService.prototype, 'removeSessions') .mockResolvedValueOnce({ @@ -1960,7 +1969,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { errors: [ { sessionId, - error: new Error('remove\nFAILED\r\x1b[31m'), + error: removeError as unknown as Error, }, ], }); @@ -1980,7 +1989,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { result: { removed: [], notFound: [], - errors: [{ sessionId, error: 'remove\nFAILED\r\x1b[31m' }], + errors: [{ sessionId, error: removeError }], }, }); expect(removeSessionsSpy).toHaveBeenCalledWith([sessionId]); @@ -1994,6 +2003,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(deleteLog).not.toContain('\n'); expect(deleteLog).not.toContain('\r'); expect(deleteLog).not.toContain('\x1b'); + expect(deleteLog).not.toContain(lineSep); + expect(deleteLog).not.toContain(bidiOverride); } finally { removeSessionsSpy.mockRestore(); } From a9b5f235b0f63e81544d7a1c892a0eda484bca82 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Fri, 12 Jun 2026 14:49:02 +0800 Subject: [PATCH 5/5] fix(daemon): Fix ACP log sanitizer lint Co-authored-by: Qwen-Coder --- packages/cli/src/serve/acpHttp/jsonRpc.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/serve/acpHttp/jsonRpc.ts b/packages/cli/src/serve/acpHttp/jsonRpc.ts index ada5b5fce15..ef755798888 100644 --- a/packages/cli/src/serve/acpHttp/jsonRpc.ts +++ b/packages/cli/src/serve/acpHttp/jsonRpc.ts @@ -120,17 +120,18 @@ export function isResponse(m: unknown): m is JsonRpcResponse { ); } +const LOG_SAFE_RE = new RegExp( + String.raw`[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]`, + 'g', +); + /** * Strip terminal control chars from values interpolated into operator-facing * stderr logs, so a client-controlled `sessionId`/`method`/error string can't * forge or split log lines (log injection). Shared by the transport modules. */ export function logSafe(s: string): string { - // eslint-disable-next-line no-control-regex - return s.replace( - /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g, - ' ', - ); + return s.replace(LOG_SAFE_RE, ' '); } export function success(id: JsonRpcId, result: unknown): JsonRpcSuccess {