From d1cbf11abfcd7ce5f99b2a9eac44b37bdbfdbaaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Tue, 7 Jul 2026 19:51:05 +0800 Subject: [PATCH] fix(daemon): harden workspace memory detail extraction --- .../serve/workspace-remember-errors.test.ts | 34 ++++++++++++++++--- .../src/serve/workspace-remember-errors.ts | 16 ++++++--- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/serve/workspace-remember-errors.test.ts b/packages/cli/src/serve/workspace-remember-errors.test.ts index 491f269e7d..a7a75ea898 100644 --- a/packages/cli/src/serve/workspace-remember-errors.test.ts +++ b/packages/cli/src/serve/workspace-remember-errors.test.ts @@ -46,6 +46,12 @@ describe('extractRememberErrorDetails', () => { expect(extractRememberErrorDetails({ data: 'raw data detail' })).toBe( 'raw data detail', ); + expect( + extractRememberErrorDetails({ + data: 'ERR_BRIDGE_INTERNAL', + message: 'Connection to memory service refused', + }), + ).toBe('Connection to memory service refused'); expect( extractRememberErrorDetails({ data: { message: 'provider rejected the request' }, @@ -56,6 +62,9 @@ describe('extractRememberErrorDetails', () => { cause: new Error('nested failure reason'), }), ).toBe('nested failure reason'); + expect(extractRememberErrorDetails({ cause: 'string cause reason' })).toBe( + 'string cause reason', + ); expect(extractRememberErrorDetails('raw string error')).toBe( 'raw string error', ); @@ -71,12 +80,14 @@ describe('extractRememberErrorDetails', () => { }); it('normalizes hidden separators before redacting credentials', () => { - const details = extractRememberErrorDetails( - new Error('Authorization: Bearer\u200bsecret-token-value'), - ); + for (const separator of ['\u200b', '\u2060', '\u2064']) { + const details = extractRememberErrorDetails( + new Error(`Authorization: Bearer${separator}secret-token-value`), + ); - expect(details).toBe('Authorization: '); - expect(details).not.toContain('secret-token-value'); + expect(details).toBe('Authorization: '); + expect(details).not.toContain('secret-token-value'); + } }); it('normalizes line separators before redacting credentials', () => { @@ -103,6 +114,19 @@ describe('extractRememberErrorDetails', () => { expect(extractRememberErrorDetails(cyclic)).toBeUndefined(); }); + it('limits cause traversal depth', () => { + const root: Record = {}; + let current = root; + for (let index = 0; index < 60; index += 1) { + const next: Record = {}; + current['cause'] = next; + current = next; + } + current['message'] = 'too deep'; + + expect(extractRememberErrorDetails(root)).toBeUndefined(); + }); + it('caps long details', () => { const details = extractRememberErrorDetails(new Error('x'.repeat(1100))); diff --git a/packages/cli/src/serve/workspace-remember-errors.ts b/packages/cli/src/serve/workspace-remember-errors.ts index 6e72450780..6024c2f1d4 100644 --- a/packages/cli/src/serve/workspace-remember-errors.ts +++ b/packages/cli/src/serve/workspace-remember-errors.ts @@ -7,6 +7,7 @@ import { redactLogCredentials } from '@qwen-code/acp-bridge/logRedaction'; const MAX_REMEMBER_ERROR_DETAILS_CHARS = 1000; +const MAX_REMEMBER_ERROR_CAUSE_DEPTH = 50; function errorCodeFromRecord( record: Record, @@ -43,11 +44,11 @@ export function extractRememberErrorCode( function detailFromRecord( record: Record, seen: WeakSet, + depth: number, ): string | undefined { // Bridge errors carry the best failure reason in `data`; top-level // `message` and `cause` are generic fallbacks. const data = record['data']; - if (typeof data === 'string' && data.length > 0) return data; if (data && typeof data === 'object') { const dataRecord = data as Record; const details = dataRecord['details']; @@ -59,9 +60,11 @@ function detailFromRecord( const message = record['message']; if (typeof message === 'string' && message.length > 0) return message; + if (typeof data === 'string' && data.length > 0) return data; + const cause = record['cause']; - if (cause && typeof cause === 'object') { - return rawRememberErrorDetails(cause, seen); + if (cause != null) { + return rawRememberErrorDetails(cause, seen, depth + 1); } return undefined; @@ -70,12 +73,14 @@ function detailFromRecord( function rawRememberErrorDetails( err: unknown, seen: WeakSet, + depth: number, ): string | undefined { + if (depth > MAX_REMEMBER_ERROR_CAUSE_DEPTH) return undefined; if (typeof err === 'string' && err.length > 0) return err; if (!err || typeof err !== 'object') return undefined; if (seen.has(err)) return undefined; seen.add(err); - return detailFromRecord(err as Record, seen); + return detailFromRecord(err as Record, seen, depth); } function shouldReplaceControlChar(code: number): boolean { @@ -84,6 +89,7 @@ function shouldReplaceControlChar(code: number): boolean { (code >= 127 && code <= 159) || (code >= 0x200b && code <= 0x200f) || (code >= 0x2028 && code <= 0x202e) || + (code >= 0x2060 && code <= 0x2064) || (code >= 0x2066 && code <= 0x2069) || code === 0xfeff ); @@ -119,7 +125,7 @@ function sanitizeRememberErrorDetails(details: string): string | undefined { } export function extractRememberErrorDetails(err: unknown): string | undefined { - const raw = rawRememberErrorDetails(err, new WeakSet()); + const raw = rawRememberErrorDetails(err, new WeakSet(), 0); if (!raw) return undefined; return sanitizeRememberErrorDetails(raw); }