fix(daemon): harden workspace memory failure diagnostics

This commit is contained in:
叶公 2026-07-07 20:53:12 +08:00
parent d1cbf11abf
commit ffceef9568
5 changed files with 101 additions and 24 deletions

View file

@ -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',

View file

@ -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: <redacted>');
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: <redacted>');
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: <redacted>');
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);
});
});

View file

@ -24,21 +24,33 @@ function errorCodeFromRecord(
return undefined;
}
function rawRememberErrorCode(
err: unknown,
seen: WeakSet<object>,
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<string, unknown>;
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<string, unknown>;
const direct = errorCodeFromRecord(record);
if (direct) return direct;
const cause = record['cause'];
if (cause && typeof cause === 'object') {
const causedBy = errorCodeFromRecord(cause as Record<string, unknown>);
if (causedBy) return causedBy;
}
}
return fallback;
return rawRememberErrorCode(err, new WeakSet<object>(), 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 {

View file

@ -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' },

View file

@ -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 {