From ffceef956888747658076aabe5b08488f49a02ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Tue, 7 Jul 2026 20:53:12 +0800 Subject: [PATCH] fix(daemon): harden workspace memory failure diagnostics --- packages/cli/src/acp-integration/acpAgent.ts | 17 +++++-- .../serve/workspace-remember-errors.test.ts | 46 ++++++++++++++++++ .../src/serve/workspace-remember-errors.ts | 47 ++++++++++++------- .../cli/src/serve/workspace-remember.test.ts | 1 + packages/cli/src/serve/workspace-remember.ts | 14 ++++-- 5 files changed, 101 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 299c442563..5e74a81d0e 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -278,11 +278,15 @@ function workspaceMemoryErrorData( code: string, err: unknown, ): { errorKind: string; details?: string } { - const details = extractRememberErrorDetails(err); - return { - errorKind: code, - ...(details ? { details } : {}), - }; + try { + const details = extractRememberErrorDetails(err); + return { + errorKind: code, + ...(details ? { details } : {}), + }; + } catch { + return { errorKind: code }; + } } function parseAcpLocalReadRootsEnv( @@ -5835,6 +5839,7 @@ class QwenAgent implements Agent { if (err instanceof RequestError) { throw err; } + debugLogger.error('Workspace memory remember failed:', err); if (childSignal.aborted) { throw new RequestError( -32099, @@ -5905,6 +5910,7 @@ class QwenAgent implements Agent { if (err instanceof RequestError) { throw err; } + debugLogger.error('Workspace memory forget failed:', err); if (childSignal.aborted) { throw new RequestError( -32099, @@ -5962,6 +5968,7 @@ class QwenAgent implements Agent { if (err instanceof RequestError) { throw err; } + debugLogger.error('Workspace memory dream failed:', err); if (childSignal.aborted) { throw new RequestError(-32099, 'Workspace memory dream timed out', { errorKind: 'dream_timeout', diff --git a/packages/cli/src/serve/workspace-remember-errors.test.ts b/packages/cli/src/serve/workspace-remember-errors.test.ts index a7a75ea898..de504f9156 100644 --- a/packages/cli/src/serve/workspace-remember-errors.test.ts +++ b/packages/cli/src/serve/workspace-remember-errors.test.ts @@ -28,6 +28,11 @@ describe('extractRememberErrorCode', () => { cause: { code: 'remember_timeout' }, }), ).toBe('remember_timeout'); + expect( + extractRememberErrorCode({ + cause: { cause: { code: 'remember_path_escape' } }, + }), + ).toBe('remember_path_escape'); expect(extractRememberErrorCode(new Error('boom'))).toBe('remember_failed'); expect(extractRememberErrorCode(new Error('boom'), 'forget_failed')).toBe( 'forget_failed', @@ -90,6 +95,18 @@ describe('extractRememberErrorDetails', () => { } }); + it('redacts credentials with hidden separators inside token values', () => { + for (const separator of ['\u200b', '\u2060', '\u2064']) { + const details = extractRememberErrorDetails( + new Error(`Authorization: Bearer secret${separator}token-value`), + ); + + expect(details).toBe('Authorization: '); + expect(details).not.toContain('secret'); + expect(details).not.toContain('token-value'); + } + }); + it('normalizes line separators before redacting credentials', () => { for (const separator of ['\u2028', '\u2029']) { const details = extractRememberErrorDetails( @@ -101,6 +118,26 @@ describe('extractRememberErrorDetails', () => { } }); + it('normalizes bidi isolation characters before redacting credentials', () => { + for (const separator of ['\u2066', '\u2067', '\u2068', '\u2069']) { + const details = extractRememberErrorDetails( + new Error(`Authorization: Bearer${separator}secret-token-value`), + ); + + expect(details).toBe('Authorization: '); + expect(details).not.toContain('secret-token-value'); + } + }); + + it('normalizes BOM before redacting credentials', () => { + const details = extractRememberErrorDetails( + new Error('Authorization: Bearer\ufeffsecret-token-value'), + ); + + expect(details).toBe('Authorization: '); + expect(details).not.toContain('secret-token-value'); + }); + it('sanitizes control characters', () => { expect(extractRememberErrorDetails(new Error('line1\nline2\ttab'))).toBe( 'line1 line2 tab', @@ -133,4 +170,13 @@ describe('extractRememberErrorDetails', () => { expect(details).toMatch(/^x+\.{3} \[truncated\]$/); expect(details).toHaveLength(1000); }); + + it('does not split surrogate pairs when capping long details', () => { + const details = extractRememberErrorDetails( + new Error(`${'x'.repeat(984)}${'😀'.repeat(100)}`), + ); + + expect(details).toBe(`${'x'.repeat(984)}... [truncated]`); + expect(details).toHaveLength(999); + }); }); diff --git a/packages/cli/src/serve/workspace-remember-errors.ts b/packages/cli/src/serve/workspace-remember-errors.ts index 6024c2f1d4..c7ff137820 100644 --- a/packages/cli/src/serve/workspace-remember-errors.ts +++ b/packages/cli/src/serve/workspace-remember-errors.ts @@ -24,21 +24,33 @@ function errorCodeFromRecord( return undefined; } +function rawRememberErrorCode( + err: unknown, + seen: WeakSet, + depth: number, +): string | undefined { + if (depth > MAX_REMEMBER_ERROR_CAUSE_DEPTH) return undefined; + if (!err || typeof err !== 'object') return undefined; + if (seen.has(err)) return undefined; + seen.add(err); + + const record = err as Record; + const direct = errorCodeFromRecord(record); + if (direct) return direct; + + const cause = record['cause']; + if (cause != null) { + return rawRememberErrorCode(cause, seen, depth + 1); + } + + return undefined; +} + export function extractRememberErrorCode( err: unknown, fallback = 'remember_failed', ): string { - if (err && typeof err === 'object') { - const record = err as Record; - const direct = errorCodeFromRecord(record); - if (direct) return direct; - const cause = record['cause']; - if (cause && typeof cause === 'object') { - const causedBy = errorCodeFromRecord(cause as Record); - if (causedBy) return causedBy; - } - } - return fallback; + return rawRememberErrorCode(err, new WeakSet(), 0) ?? fallback; } function detailFromRecord( @@ -112,16 +124,19 @@ function replaceControlChars(details: string): string { } function sanitizeRememberErrorDetails(details: string): string | undefined { - const normalized = redactLogCredentials(replaceControlChars(details)).trim(); + const redacted = redactLogCredentials(details); + const normalized = redactLogCredentials(replaceControlChars(redacted)).trim(); if (!normalized) return undefined; if (normalized.length <= MAX_REMEMBER_ERROR_DETAILS_CHARS) { return normalized; } const truncationSuffix = '... [truncated]'; - return `${normalized.slice( - 0, - MAX_REMEMBER_ERROR_DETAILS_CHARS - truncationSuffix.length, - )}${truncationSuffix}`; + let cutPoint = MAX_REMEMBER_ERROR_DETAILS_CHARS - truncationSuffix.length; + const codeUnit = normalized.charCodeAt(cutPoint - 1); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + cutPoint -= 1; + } + return `${normalized.slice(0, cutPoint)}${truncationSuffix}`; } export function extractRememberErrorDetails(err: unknown): string | undefined { diff --git a/packages/cli/src/serve/workspace-remember.test.ts b/packages/cli/src/serve/workspace-remember.test.ts index 8af10c25e2..fc7058f8b2 100644 --- a/packages/cli/src/serve/workspace-remember.test.ts +++ b/packages/cli/src/serve/workspace-remember.test.ts @@ -865,6 +865,7 @@ describe('workspace memory remember routes', () => { ) .mockRejectedValueOnce({ data: { errorKind: 'managed_memory_unavailable' }, + message: 'internal managed memory config path', }) .mockRejectedValueOnce({ data: { errorKind: 'remember_timeout' }, diff --git a/packages/cli/src/serve/workspace-remember.ts b/packages/cli/src/serve/workspace-remember.ts index 9ad5683b32..e0055eca35 100644 --- a/packages/cli/src/serve/workspace-remember.ts +++ b/packages/cli/src/serve/workspace-remember.ts @@ -187,12 +187,20 @@ function createTaskError( kind: WorkspaceMemoryTaskKind, err: unknown, ): WorkspaceMemoryTaskBaseSnapshot['error'] { - const details = extractRememberErrorDetails(err); - return { + const error: WorkspaceMemoryTaskBaseSnapshot['error'] = { code, message: publicErrorMessage(code, kind), - ...(details ? { details } : {}), }; + if (code === 'managed_memory_unavailable') return error; + try { + const details = extractRememberErrorDetails(err); + return { + ...error, + ...(details ? { details } : {}), + }; + } catch { + return error; + } } export class WorkspaceRememberTaskLane {