fix(daemon): surface workspace memory task error details (#6431)

* fix(daemon): surface workspace memory task error details

* fix(daemon): harden workspace memory error details

* fix(daemon): cover workspace memory detail edge cases

* fix(daemon): harden workspace memory detail extraction

* fix(daemon): harden workspace memory failure diagnostics

* fix(daemon): sanitize workspace memory diagnostics

* fix(daemon): sanitize workspace memory debug logs

* fix(daemon): preserve sanitized memory task stack logs

* fix(daemon): harden memory diagnostics redaction

* fix(daemon): refine memory task diagnostics

* fix(daemon): preserve workspace memory stack diagnostics

* fix(daemon): harden workspace memory diagnostics

* fix(daemon): guard workspace memory error code extraction

* fix(daemon): share workspace memory extraction logging

* fix(daemon): suppress workspace memory unavailable details

* fix(daemon): clarify workspace memory unavailable timeout logs

* fix(daemon): preserve memory diagnostic separators

* fix(daemon): harden workspace memory failure handling

* fix(daemon): redact split platform tokens

* fix(daemon): redact split memory error credentials

* fix(daemon): harden workspace memory error diagnostics
This commit is contained in:
callmeYe 2026-07-09 09:44:48 +08:00 committed by GitHub
parent ba709c2c2c
commit 6e48077532
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1877 additions and 80 deletions

View file

@ -54,6 +54,15 @@ const { mockRunManagedAutoMemoryDream, mockRunManagedRememberByAgent } =
mockRunManagedRememberByAgent: vi.fn(),
}));
const { mockDebugLogger } = vi.hoisted(() => ({
mockDebugLogger: {
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock('@agentclientprotocol/sdk', () => ({
AgentSideConnection: vi.fn().mockImplementation(() => ({
get closed() {
@ -119,12 +128,7 @@ vi.mock('node:stream', async (importOriginal) => {
// Mock core dependencies
vi.mock('@qwen-code/qwen-code-core', () => ({
createDebugLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
}),
createDebugLogger: () => mockDebugLogger,
registerAcpEventLoopLagGauge: vi.fn(),
startEventLoopLagMonitor: vi.fn(() => ({
snapshot: vi.fn(() => ({
@ -3431,6 +3435,354 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it('rejects workspace memory remember when the bridge reports managed memory unavailable', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
let rejection: unknown;
try {
await agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
});
} catch (err) {
rejection = err;
}
expect(rejection).toMatchObject({
code: -32009,
message: 'Managed memory is unavailable for this daemon workspace',
data: { errorKind: 'managed_memory_unavailable' },
});
expect(
(rejection as { data: Record<string, unknown> }).data,
).not.toHaveProperty('details');
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember failed:',
expect.objectContaining({
code: 'managed_memory_unavailable',
details: 'memory service stopped',
}),
);
mockConnectionState.resolve();
await agentPromise;
});
it('includes details for workspace memory remember failures', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue(
new Error('remember agent stopped early'),
);
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
}),
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory remember failed',
data: {
errorKind: 'remember_failed',
details: 'remember agent stopped early',
},
});
mockConnectionState.resolve();
await agentPromise;
});
it('logs sanitized details for workspace memory failures', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue(
new Error('Authorization: Bearer secret-token-value'),
);
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
}),
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory remember failed',
data: {
errorKind: 'remember_failed',
details: 'Authorization: <redacted>',
},
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember failed:',
expect.objectContaining({
code: 'remember_failed',
details: 'Authorization: <redacted>',
stack: expect.stringContaining('Authorization: <redacted>'),
}),
);
expect(JSON.stringify(mockDebugLogger.error.mock.calls)).not.toContain(
'secret-token-value',
);
mockConnectionState.resolve();
await agentPromise;
});
it('falls back when workspace memory error code extraction throws', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
const err = new Proxy(
{},
{
get() {
throw new Error('code getter failed');
},
},
);
mockRunManagedRememberByAgent.mockRejectedValue(err);
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32099,
message: 'Workspace memory remember failed',
data: { errorKind: 'remember_failed' },
});
expect(
(error as { data?: Record<string, unknown> }).data,
).not.toHaveProperty('details');
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
'Failed to extract workspace memory error code:',
{ extractionError: 'code getter failed' },
);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember failed:',
expect.objectContaining({
code: 'remember_failed',
details: '<details unavailable>',
}),
);
mockConnectionState.resolve();
await agentPromise;
});
it('uses remember-specific error codes for workspace memory remember timeouts', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue(new Error('late abort'));
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const controller = new AbortController();
controller.abort();
const timeoutSpy = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValue(controller.signal);
try {
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
}),
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory remember timed out',
data: { errorKind: 'remember_timeout', details: 'late abort' },
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember timed out:',
expect.objectContaining({
code: 'remember_timeout',
details: 'late abort',
stack: expect.stringContaining('late abort'),
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();
await agentPromise;
}
});
it('preserves timeout codes for timed out workspace memory remember unavailable errors', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const controller = new AbortController();
controller.abort();
const timeoutSpy = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValue(controller.signal);
try {
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32099,
message: 'Workspace memory remember timed out',
data: {
errorKind: 'remember_timeout',
details: 'memory service stopped',
},
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember timed out:',
expect.objectContaining({
code: 'remember_timeout',
details: 'memory service stopped',
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();
await agentPromise;
}
});
it('omits details for workspace memory failures without a detail source', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
});
mockRunManagedRememberByAgent.mockRejectedValue({
code: 'remember_failed',
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
content: 'Remember me.',
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32099,
data: { errorKind: 'remember_failed' },
});
expect(
(error as { data?: Record<string, unknown> }).data,
).not.toHaveProperty('details');
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember failed:',
expect.objectContaining({ details: '<details unavailable>' }),
);
mockConnectionState.resolve();
await agentPromise;
});
it('runs workspace memory forget without requiring a session', async () => {
const forget = vi.fn().mockResolvedValue({
systemMessage: 'Forgot 1 entry.',
@ -3556,8 +3908,71 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory forget failed',
data: { errorKind: 'forget_failed' },
data: { errorKind: 'forget_failed', details: 'boom' },
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory forget failed:',
expect.objectContaining({
code: 'forget_failed',
details: 'boom',
stack: expect.stringContaining('boom'),
}),
);
mockConnectionState.resolve();
await agentPromise;
});
it('rejects workspace memory forget when the bridge reports managed memory unavailable', async () => {
const forget = vi.fn().mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
getMemoryManager: vi.fn().mockReturnValue({ forget }),
getChatRecordingService: vi.fn().mockReturnValue({
recordUiTelemetryEvent: vi.fn(),
}),
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, {
query: 'old preference',
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32009,
message: 'Managed memory is unavailable for this daemon workspace',
data: { errorKind: 'managed_memory_unavailable' },
});
expect(
(error as { data?: Record<string, unknown> }).data,
).not.toHaveProperty('details');
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory forget failed:',
expect.objectContaining({
code: 'managed_memory_unavailable',
details: 'memory service stopped',
}),
);
mockConnectionState.resolve();
await agentPromise;
@ -3600,8 +4015,79 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory forget timed out',
data: { errorKind: 'forget_timeout' },
data: { errorKind: 'forget_timeout', details: 'late abort' },
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory forget timed out:',
expect.objectContaining({
code: 'forget_timeout',
details: 'late abort',
stack: expect.stringContaining('late abort'),
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();
await agentPromise;
}
});
it('preserves timeout codes for timed out workspace memory forget unavailable errors', async () => {
const forget = vi.fn().mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
getMemoryManager: vi.fn().mockReturnValue({ forget }),
getChatRecordingService: vi.fn().mockReturnValue({
recordUiTelemetryEvent: vi.fn(),
}),
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const controller = new AbortController();
controller.abort();
const timeoutSpy = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValue(controller.signal);
try {
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, {
query: 'old preference',
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32099,
message: 'Workspace memory forget timed out',
data: {
errorKind: 'forget_timeout',
details: 'memory service stopped',
},
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory forget timed out:',
expect.objectContaining({
code: 'forget_timeout',
details: 'memory service stopped',
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();
@ -3690,8 +4176,68 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory dream failed',
data: { errorKind: 'dream_failed' },
data: { errorKind: 'dream_failed', details: 'boom' },
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory dream failed:',
expect.objectContaining({
code: 'dream_failed',
details: 'boom',
stack: expect.stringContaining('boom'),
}),
);
mockConnectionState.resolve();
await agentPromise;
});
it('rejects workspace memory dream when the bridge reports managed memory unavailable', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
getChatRecordingService: vi.fn().mockReturnValue({
recordUiTelemetryEvent: vi.fn(),
}),
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
});
mockRunManagedAutoMemoryDream.mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, {})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32009,
message: 'Managed memory is unavailable for this daemon workspace',
data: { errorKind: 'managed_memory_unavailable' },
});
expect(
(error as { data?: Record<string, unknown> }).data,
).not.toHaveProperty('details');
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory dream failed:',
expect.objectContaining({
code: 'managed_memory_unavailable',
details: 'memory service stopped',
}),
);
mockConnectionState.resolve();
await agentPromise;
@ -3731,8 +4277,76 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
).rejects.toMatchObject({
code: -32099,
message: 'Workspace memory dream timed out',
data: { errorKind: 'dream_timeout' },
data: { errorKind: 'dream_timeout', details: 'late abort' },
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory dream timed out:',
expect.objectContaining({
code: 'dream_timeout',
details: 'late abort',
stack: expect.stringContaining('late abort'),
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();
await agentPromise;
}
});
it('preserves timeout codes for timed out workspace memory dream unavailable errors', async () => {
Object.assign(mockConfig, {
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
getChatRecordingService: vi.fn().mockReturnValue({
recordUiTelemetryEvent: vi.fn(),
}),
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
});
mockRunManagedAutoMemoryDream.mockRejectedValue({
data: {
errorKind: 'managed_memory_unavailable',
details: 'memory service stopped',
},
});
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const controller = new AbortController();
controller.abort();
const timeoutSpy = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValue(controller.signal);
try {
const error = await agent
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, {})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: -32099,
message: 'Workspace memory dream timed out',
data: {
errorKind: 'dream_timeout',
details: 'memory service stopped',
},
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory dream timed out:',
expect.objectContaining({
code: 'dream_timeout',
details: 'memory service stopped',
}),
);
} finally {
timeoutSpy.mockRestore();
mockConnectionState.resolve();

View file

@ -168,7 +168,12 @@ import {
buildDisabledSkillNamesProvider,
loadCliConfig,
} from '../config/config.js';
import { extractRememberErrorCode } from '../serve/workspace-remember-errors.js';
import {
createWorkspaceMemoryExtractionErrorLogger,
shouldSuppressRememberErrorDetails,
workspaceMemoryFailureCode,
workspaceMemoryFailureDiagnostics,
} from '../serve/workspace-remember-errors.js';
import { formatWorkspaceMemoryForgetSummary } from '../serve/workspace-memory-summaries.js';
import { mapSkillConfigToStatus } from '../serve/workspace-skills-mapping.js';
import { Session, buildAvailableCommandsSnapshot } from './session/Session.js';
@ -272,6 +277,19 @@ const BTW_CHILD_TIMEOUT_MS = 55_000;
// Must be less than WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS (300s) in bridge.ts.
const WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS = 295_000;
function workspaceMemoryErrorData(
code: string,
diagnostics: { details?: string },
): { errorKind: string; details?: string } {
return {
errorKind: code,
...(diagnostics.details ? { details: diagnostics.details } : {}),
};
}
const logWorkspaceMemoryExtractionError =
createWorkspaceMemoryExtractionErrorLogger(debugLogger);
function parseAcpLocalReadRootsEnv(
raw = process.env[QWEN_ACP_LOCAL_READ_ROOTS_ENV],
): string[] {
@ -5812,10 +5830,12 @@ class QwenAgent implements Agent {
const childSignal = AbortSignal.timeout(
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
);
let projectRoot = '<unknown>';
try {
projectRoot = this.config.getProjectRoot();
const result = await runManagedRememberByAgent({
config: this.config,
projectRoot: this.config.getProjectRoot(),
projectRoot,
content: content.trim(),
contextMode,
abortSignal: childSignal,
@ -5825,15 +5845,36 @@ class QwenAgent implements Agent {
if (err instanceof RequestError) {
throw err;
}
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
const code = workspaceMemoryFailureCode(
err,
'remember_failed',
logWorkspaceMemoryExtractionError,
);
if (childSignal.aborted) {
const timeoutCode = 'remember_timeout';
debugLogger.error('Workspace memory remember timed out:', {
projectRoot,
code: timeoutCode,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
throw new RequestError(
-32099,
'Workspace memory remember timed out',
{ errorKind: 'remember_timeout' },
workspaceMemoryErrorData(timeoutCode, diagnostics),
);
}
const code = extractRememberErrorCode(err);
if (code === 'managed_memory_unavailable') {
debugLogger.error('Workspace memory remember failed:', {
projectRoot,
code,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
if (shouldSuppressRememberErrorDetails(code)) {
throw new RequestError(
-32009,
'Managed memory is unavailable for this daemon workspace',
@ -5842,12 +5883,8 @@ class QwenAgent implements Agent {
}
throw new RequestError(
-32099,
err instanceof Error && err.message
? err.message
: 'Workspace memory remember failed',
{
errorKind: code,
},
'Workspace memory remember failed',
workspaceMemoryErrorData(code, diagnostics),
);
}
}
@ -5879,8 +5916,9 @@ class QwenAgent implements Agent {
const childSignal = AbortSignal.timeout(
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
);
let projectRoot = '<unknown>';
try {
const projectRoot = this.config.getProjectRoot();
projectRoot = this.config.getProjectRoot();
const hiddenConfig = createHiddenWorkspaceMemoryConfig(this.config);
const result = await this.config
.getMemoryManager()
@ -5900,26 +5938,47 @@ class QwenAgent implements Agent {
if (err instanceof RequestError) {
throw err;
}
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
const code = workspaceMemoryFailureCode(
err,
'forget_failed',
logWorkspaceMemoryExtractionError,
);
if (childSignal.aborted) {
const timeoutCode = 'forget_timeout';
debugLogger.error('Workspace memory forget timed out:', {
projectRoot,
code: timeoutCode,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
throw new RequestError(
-32099,
'Workspace memory forget timed out',
{
errorKind: 'forget_timeout',
},
workspaceMemoryErrorData(timeoutCode, diagnostics),
);
}
const code = extractRememberErrorCode(err, 'forget_failed');
if (code === 'managed_memory_unavailable') {
debugLogger.error('Workspace memory forget failed:', {
projectRoot,
code,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
if (shouldSuppressRememberErrorDetails(code)) {
throw new RequestError(
-32009,
'Managed memory is unavailable for this daemon workspace',
{ errorKind: 'managed_memory_unavailable' },
);
}
throw new RequestError(-32099, 'Workspace memory forget failed', {
errorKind: code,
});
throw new RequestError(
-32099,
'Workspace memory forget failed',
workspaceMemoryErrorData(code, diagnostics),
);
}
}
case SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream: {
@ -5934,9 +5993,11 @@ class QwenAgent implements Agent {
const childSignal = AbortSignal.timeout(
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
);
let projectRoot = '<unknown>';
try {
projectRoot = this.config.getProjectRoot();
const result = await runManagedAutoMemoryDream(
this.config.getProjectRoot(),
projectRoot,
new Date(),
createHiddenWorkspaceMemoryConfig(this.config),
childSignal,
@ -5955,22 +6016,47 @@ class QwenAgent implements Agent {
if (err instanceof RequestError) {
throw err;
}
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
const code = workspaceMemoryFailureCode(
err,
'dream_failed',
logWorkspaceMemoryExtractionError,
);
if (childSignal.aborted) {
throw new RequestError(-32099, 'Workspace memory dream timed out', {
errorKind: 'dream_timeout',
const timeoutCode = 'dream_timeout';
debugLogger.error('Workspace memory dream timed out:', {
projectRoot,
code: timeoutCode,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
throw new RequestError(
-32099,
'Workspace memory dream timed out',
workspaceMemoryErrorData(timeoutCode, diagnostics),
);
}
const code = extractRememberErrorCode(err, 'dream_failed');
if (code === 'managed_memory_unavailable') {
debugLogger.error('Workspace memory dream failed:', {
projectRoot,
code,
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
if (shouldSuppressRememberErrorDetails(code)) {
throw new RequestError(
-32009,
'Managed memory is unavailable for this daemon workspace',
{ errorKind: 'managed_memory_unavailable' },
);
}
throw new RequestError(-32099, 'Workspace memory dream failed', {
errorKind: code,
});
throw new RequestError(
-32099,
'Workspace memory dream failed',
workspaceMemoryErrorData(code, diagnostics),
);
}
}
case SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart: {

View file

@ -5,7 +5,14 @@
*/
import { describe, expect, it } from 'vitest';
import { extractRememberErrorCode } from './workspace-remember-errors.js';
import {
extractRememberErrorCode,
extractRememberErrorDetails,
extractRememberErrorStack,
shouldSuppressRememberErrorDetails,
workspaceMemoryFailureCode,
workspaceMemoryFailureDiagnostics,
} from './workspace-remember-errors.js';
describe('extractRememberErrorCode', () => {
it('extracts remember error codes from common error shapes', () => {
@ -25,9 +32,553 @@ 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',
);
});
it('limits cause traversal depth', () => {
const root: Record<string, unknown> = {};
let current = root;
for (let index = 0; index < 60; index += 1) {
const next: Record<string, unknown> = {};
current['cause'] = next;
current = next;
}
current['code'] = 'too_deep';
expect(extractRememberErrorCode(root)).toBe('remember_failed');
});
it('falls back when code extraction throws', () => {
const extractionErrors: Array<{ target: string; message: string }> = [];
const err = new Proxy(
{},
{
get() {
throw new Error('code getter failed');
},
},
);
expect(
workspaceMemoryFailureCode(err, 'dream_failed', (target, cause) =>
extractionErrors.push({
target,
message: cause instanceof Error ? cause.message : String(cause),
}),
),
).toBe('dream_failed');
expect(extractionErrors).toEqual([
{ target: 'code', message: 'code getter failed' },
]);
});
it('keeps fallback behavior when code extraction logging throws', () => {
const err = new Proxy(
{},
{
get() {
throw new Error('code getter failed');
},
},
);
expect(
workspaceMemoryFailureCode(err, 'dream_failed', () => {
throw new Error('logger failed');
}),
).toBe('dream_failed');
});
});
describe('extractRememberErrorDetails', () => {
it('extracts details from common error shapes', () => {
expect(extractRememberErrorDetails(new Error('boom'))).toBe('boom');
expect(
extractRememberErrorDetails({
data: { details: 'agent stopped because max turns exceeded' },
}),
).toBe('agent stopped because max turns exceeded');
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' },
}),
).toBe('provider rejected the request');
expect(
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',
);
});
it('prefers bridge details over generic messages', () => {
expect(
extractRememberErrorDetails({
data: { details: 'specific bridge reason' },
message: 'generic message',
}),
).toBe('specific bridge reason');
});
it('redacts credentials before exposing details', () => {
const details = extractRememberErrorDetails(
new Error('Authorization: Bearer secret-token-value'),
);
expect(details).toBe('Authorization: <redacted>');
expect(details).not.toContain('secret-token-value');
});
it('normalizes hidden separators before redacting credentials', () => {
for (const separator of [
'\u00ad',
'\u061c',
'\u180e',
'\u200b',
'\u2060',
'\u2064',
]) {
const details = extractRememberErrorDetails(
new Error(`Authorization: Bearer${separator}secret-token-value`),
);
expect(details).toBe('Authorization: <redacted>');
expect(details).not.toContain('secret-token-value');
}
});
it('redacts credentials with hidden separators inside token values', () => {
for (const separator of [
'\u00ad',
'\u061c',
'\u180e',
'\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('redacts bearer tokens split by control characters', () => {
const details = extractRememberErrorDetails(
new Error('Authorization: Bearer secret\x01token-value'),
);
expect(details).toBe('Authorization: <redacted>');
expect(details).not.toContain('secret');
expect(details).not.toContain('token-value');
});
it('redacts bearer tokens split by unicode space separators', () => {
for (const separator of [
'\u00a0',
'\u1680',
'\u2000',
'\u2009',
'\u200a',
'\u202f',
'\u205f',
'\u3000',
]) {
const details = extractRememberErrorDetails(
new Error(`Bearer sk-AAAAAAAAAA${separator}BBBBBBBBBBBBBBB`),
);
expect(details).toBe('Bearer <redacted>');
expect(details).not.toContain('AAAAAAAAAA');
expect(details).not.toContain('BBBBBBBBBBBBBBB');
}
});
it('redacts bearer tokens after unicode space separators', () => {
for (const separator of ['\u00a0', '\u2009', '\u202f']) {
const details = extractRememberErrorDetails(
new Error(`Bearer${separator}secret-token-value`),
);
expect(details).toBe('Bearer <redacted>');
expect(details).not.toContain('secret-token-value');
}
});
it('normalizes unicode space separators without collapsing words', () => {
const details = extractRememberErrorDetails(
new Error('missing column\u00a0name'),
);
expect(details).toBe('missing column name');
});
it('redacts bare tokens split by invisible characters', () => {
const details = extractRememberErrorDetails(
new Error('OpenAI key sk-AAAAAAAAAA\u2062BBBBBBBBBBBBBBB'),
);
expect(details).toBe('OpenAI key sk-<redacted>');
expect(details).not.toContain('AAAAAAAAAA');
expect(details).not.toContain('BBBBBBBBBBBBBBB');
});
it('redacts platform tokens split by invisible characters', () => {
for (const prefix of [
'ghp_',
'gho_',
'ghs_',
'ghu_',
'github_pat_',
'glpat-',
'xoxb-',
'xoxp-',
]) {
const details = extractRememberErrorDetails(
new Error(`Platform token ${prefix}AAAAAAAAAA\u200bBBBBBBBBBBBBBBB`),
);
expect(details).toBe('Platform token <redacted>');
expect(details).not.toContain('AAAAAAAAAA');
expect(details).not.toContain('BBBBBBBBBBBBBBB');
}
});
it('redacts platform tokens split by unicode space separators', () => {
const details = extractRememberErrorDetails(
new Error('Platform token ghp_AAAAAAAAAA\u00a0BBBBBBBBBBBBBBB'),
);
expect(details).toBe('Platform token <redacted>');
expect(details).not.toContain('AAAAAAAAAA');
expect(details).not.toContain('BBBBBBBBBBBBBBB');
});
it('redacts AWS access keys split by credential separators', () => {
for (const separator of ['\x01', '\u00a0', '\u200b']) {
const details = extractRememberErrorDetails(
new Error(`AWS key AKIA12345678${separator}90123456`),
);
expect(details).toBe('AWS key <redacted>');
expect(details).not.toContain('12345678');
expect(details).not.toContain('90123456');
}
});
it('redacts secret assignments split by credential separators', () => {
for (const separator of ['\x01', '\u00a0', '\u200b']) {
const details = extractRememberErrorDetails(
new Error(`token=aaaaaaaaaa${separator}bbbbbbbb`),
);
expect(details).toBe('token=<redacted>');
expect(details).not.toContain('aaaaaaaaaa');
expect(details).not.toContain('bbbbbbbb');
}
});
it('redacts env secret assignments split by credential separators', () => {
const details = extractRememberErrorDetails(
new Error('QWEN_DAEMON_TOKEN=AAAAAAAAAA\u00a0BBBBBBBB'),
);
expect(details).toBe('QWEN_DAEMON_TOKEN=<redacted>');
expect(details).not.toContain('AAAAAAAAAA');
expect(details).not.toContain('BBBBBBBB');
});
it('redacts URL credentials split by credential separators', () => {
for (const separator of ['\x01', '\u00a0', '\u200b']) {
const details = extractRememberErrorDetails(
new Error(
`postgresql://admin:S3cret${separator}P4ssw0rd@db.internal:5432/mydb`,
),
);
expect(details).toBe('postgresql://<redacted>@db.internal:5432/mydb');
expect(details).not.toContain('admin');
expect(details).not.toContain('S3cret');
expect(details).not.toContain('P4ssw0rd');
}
});
it('redacts bare bearer tokens separated by invisible characters', () => {
const details = extractRememberErrorDetails(
new Error('Bearer\u200BeyJhbGciOiABCDEFGHIJKLMN'),
);
expect(details).toBe('Bearer <redacted>');
expect(details).not.toContain('eyJhbGciOiABCDEFGHIJKLMN');
});
it('redacts QQBot tokens separated by invisible characters', () => {
const details = extractRememberErrorDetails(
new Error('QQBot\u200Bsecret-token-value'),
);
expect(details).toBe('QQBot <redacted>');
expect(details).not.toContain('secret-token-value');
});
it('normalizes line separators before redacting credentials', () => {
for (const separator of ['\u2028', '\u2029']) {
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 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',
);
});
it('omits empty details after sanitization', () => {
expect(extractRememberErrorDetails(new Error('\u200b'))).toBeUndefined();
});
it('guards against circular causes', () => {
const cyclic: Record<string, unknown> = {};
cyclic['cause'] = cyclic;
expect(extractRememberErrorDetails(cyclic)).toBeUndefined();
});
it('limits cause traversal depth', () => {
const root: Record<string, unknown> = {};
let current = root;
for (let index = 0; index < 60; index += 1) {
const next: Record<string, unknown> = {};
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)));
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);
});
it('keeps the full prefix when the cut point falls before a surrogate pair', () => {
const details = extractRememberErrorDetails(
new Error(`${'x'.repeat(985)}${'😀'.repeat(100)}`),
);
expect(details).toBe(`${'x'.repeat(985)}... [truncated]`);
expect(details).toHaveLength(1000);
});
});
describe('extractRememberErrorStack', () => {
it('redacts and caps error stacks before logging', () => {
const err = new Error('Authorization: Bearer secret-token-value');
err.stack = `Error: Authorization: Bearer secret-token-value\n\tat handler (/workspace/file.ts:1:1)\n${'x'.repeat(1100)}`;
const stack = extractRememberErrorStack(err);
expect(stack).toContain('Authorization: <redacted>');
expect(stack).toContain('\n\tat handler');
expect(stack).not.toContain('secret-token-value');
expect(stack).toHaveLength(1000);
});
it('preserves CRLF stack line endings before logging', () => {
const err = new Error('boom');
err.stack = 'Error: boom\r\n\tat handler (/workspace/file.ts:1:1)';
const stack = extractRememberErrorStack(err);
expect(stack).toBe('Error: boom\r\n\tat handler (/workspace/file.ts:1:1)');
});
it('normalizes unicode space separators in stacks without collapsing words', () => {
const err = new Error('boom');
err.stack =
'Error: missing column\u202fname\n\tat handler (/workspace/file.ts:1:1)';
const stack = extractRememberErrorStack(err);
expect(stack).toBe(
'Error: missing column name\n\tat handler (/workspace/file.ts:1:1)',
);
});
it('redacts stack bearer tokens split by control characters', () => {
const err = new Error('Authorization: Bearer secret\x01token-value');
err.stack =
'Error: Authorization: Bearer secret\x01token-value\n\tat handler (/workspace/file.ts:1:1)';
const stack = extractRememberErrorStack(err);
expect(stack).toContain('Authorization: <redacted>');
expect(stack).not.toContain('secret');
expect(stack).not.toContain('token-value');
});
});
describe('workspaceMemoryFailureDiagnostics', () => {
it('falls back when detail extraction throws', () => {
const extractionErrors: Array<{ target: string; message: string }> = [];
const err = new Proxy(
{},
{
get() {
throw new Error('detail getter failed');
},
},
);
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
(target, cause) =>
extractionErrors.push({
target,
message: cause instanceof Error ? cause.message : String(cause),
}),
);
expect(diagnostics).toEqual({ debugDetails: '<details unavailable>' });
expect(extractionErrors).toEqual([
{ target: 'details', message: 'detail getter failed' },
]);
});
it('keeps fallback diagnostics when detail extraction logging throws', () => {
const err = new Proxy(
{},
{
get() {
throw new Error('detail getter failed');
},
},
);
const diagnostics = workspaceMemoryFailureDiagnostics(err, () => {
throw new Error('logger failed');
});
expect(diagnostics).toEqual({ debugDetails: '<details unavailable>' });
});
it('falls back when stack extraction throws', () => {
const extractionErrors: Array<{ target: string; message: string }> = [];
const err = new Proxy(new Error('boom'), {
get(target, property, receiver) {
if (property === 'stack') {
throw new Error('stack getter failed');
}
return Reflect.get(target, property, receiver);
},
});
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
(target, cause) =>
extractionErrors.push({
target,
message: cause instanceof Error ? cause.message : String(cause),
}),
);
expect(diagnostics).toEqual({
details: 'boom',
debugDetails: 'boom',
});
expect(extractionErrors).toEqual([
{ target: 'stack', message: 'stack getter failed' },
]);
});
it('keeps fallback diagnostics when stack extraction logging throws', () => {
const err = new Proxy(new Error('boom'), {
get(target, property, receiver) {
if (property === 'stack') {
throw new Error('stack getter failed');
}
return Reflect.get(target, property, receiver);
},
});
const diagnostics = workspaceMemoryFailureDiagnostics(err, () => {
throw new Error('logger failed');
});
expect(diagnostics).toEqual({
details: 'boom',
debugDetails: 'boom',
});
});
});
describe('shouldSuppressRememberErrorDetails', () => {
it('suppresses details only for configured public errors', () => {
expect(
shouldSuppressRememberErrorDetails('managed_memory_unavailable'),
).toBe(true);
expect(shouldSuppressRememberErrorDetails('remember_failed')).toBe(false);
});
});

View file

@ -4,6 +4,71 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { redactLogCredentials } from '@qwen-code/acp-bridge/logRedaction';
const MAX_REMEMBER_ERROR_DETAILS_CHARS = 1000;
const MAX_REMEMBER_ERROR_CAUSE_DEPTH = 50;
const REMEMBER_ERROR_FORMAT_RE = /[\p{Cf}]|\p{Variation_Selector}/gu;
const REMEMBER_ERROR_SPACE_SEPARATOR_RE =
/[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/gu;
/* eslint-disable no-control-regex */
const REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE =
/[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector}/gu;
const REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE =
/\b(Bearer|QQBot)(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+(?=[A-Za-z0-9._~+/=-])/giu;
const REMEMBER_ERROR_AUTH_TOKEN_WITH_SEPARATORS_RE =
/\b(Bearer|QQBot)(?:\s|[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+((?:[A-Za-z0-9._~+/=-]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Za-z0-9._~+/=-]+)/giu;
const REMEMBER_ERROR_BARE_TOKEN_WITH_SEPARATORS_RE =
/\b(?:sk-|ghp_|gho_|ghs_|ghu_|github_pat_|glpat-|xox[b]-|xox[p]-)(?:[A-Za-z0-9_-]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Za-z0-9_-]+/gu;
const REMEMBER_ERROR_AWS_KEY_WITH_SEPARATORS_RE =
/\b(?:AKIA|ASIA)(?:[A-Z0-9]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Z0-9]+/gu;
const REMEMBER_ERROR_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE =
/((?:api[_-]?key|token|secret|password|pwd)[_-]?[=:]\s*)((?:\S+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*\S+)/giu;
const REMEMBER_ERROR_ENV_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE =
/([A-Z][A-Z0-9]{0,50}(?:_[A-Z0-9]{1,50}){0,10}_(?:KEY|TOKEN|SECRET|PASSWORD)\s*[=:]\s*)((?:\S+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*\S+)/gu;
const REMEMBER_ERROR_URL_CREDENTIALS_WITH_SEPARATORS_RE =
/(\b[a-z][a-z0-9+.-]{0,31}:\/\/)([^/@\s]*(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+[^/@\s]*)@/giu;
const REMEMBER_ERROR_CONTROL_RE = /[\x00-\x1f\x7f-\x9f]/g;
/* eslint-enable no-control-regex */
const DETAIL_SUPPRESSED_REMEMBER_ERROR_CODES = new Set([
'managed_memory_unavailable',
]);
export interface WorkspaceMemoryFailureDiagnostics {
details?: string;
debugDetails: string;
stack?: string;
}
type RememberErrorExtractionTarget = 'code' | 'details' | 'stack';
type WorkspaceMemoryExtractionLogger = {
warn(message: string, context: { extractionError: string }): void;
};
export function createWorkspaceMemoryExtractionErrorLogger(
logger: WorkspaceMemoryExtractionLogger,
): (target: RememberErrorExtractionTarget, err: unknown) => void {
return (target, err) => {
logger.warn(`Failed to extract workspace memory error ${target}:`, {
extractionError: err instanceof Error ? err.message : String(err),
});
};
}
function reportRememberErrorExtractionFailure(
onExtractionError:
| ((target: RememberErrorExtractionTarget, err: unknown) => void)
| undefined,
target: RememberErrorExtractionTarget,
err: unknown,
): void {
try {
onExtractionError?.(target, err);
} catch {
// Preserve fallback behavior if extraction logging fails.
}
}
function errorCodeFromRecord(
record: Record<string, unknown>,
): string | undefined {
@ -19,19 +84,253 @@ 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;
}
export function workspaceMemoryFailureCode(
err: unknown,
fallback = 'remember_failed',
onExtractionError?: (
target: RememberErrorExtractionTarget,
err: unknown,
) => void,
): string {
try {
return extractRememberErrorCode(err, fallback);
} catch (extractionErr) {
reportRememberErrorExtractionFailure(
onExtractionError,
'code',
extractionErr,
);
return fallback;
}
}
function detailFromRecord(
record: Record<string, unknown>,
seen: WeakSet<object>,
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 (data && typeof data === 'object') {
const dataRecord = data as Record<string, unknown>;
const details = dataRecord['details'];
if (typeof details === 'string' && details.length > 0) return details;
const message = dataRecord['message'];
if (typeof message === 'string' && message.length > 0) return message;
}
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 != null) {
return rawRememberErrorDetails(cause, seen, depth + 1);
}
return undefined;
}
function rawRememberErrorDetails(
err: unknown,
seen: WeakSet<object>,
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<string, unknown>, seen, depth);
}
function replaceControlChars(details: string): string {
return details
.replace(REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE, '$1 ')
.replace(REMEMBER_ERROR_FORMAT_RE, '')
.replace(REMEMBER_ERROR_SPACE_SEPARATOR_RE, ' ')
.replace(REMEMBER_ERROR_CONTROL_RE, ' ');
}
function replaceStackControlChars(stack: string): string {
return stack
.replace(REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE, '$1 ')
.replace(REMEMBER_ERROR_FORMAT_RE, '')
.replace(REMEMBER_ERROR_SPACE_SEPARATOR_RE, ' ')
.replace(REMEMBER_ERROR_CONTROL_RE, (char) =>
char === '\n' || char === '\r' || char === '\t' ? char : ' ',
);
}
function collapseCredentialSeparators(text: string): string {
return text
.replace(
REMEMBER_ERROR_URL_CREDENTIALS_WITH_SEPARATORS_RE,
(_match, scheme: string, userinfo: string) =>
`${scheme}${userinfo.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}@`,
)
.replace(
REMEMBER_ERROR_AUTH_TOKEN_WITH_SEPARATORS_RE,
(_match, scheme: string, token: string) =>
`${scheme} ${token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`,
)
.replace(REMEMBER_ERROR_BARE_TOKEN_WITH_SEPARATORS_RE, (token) =>
token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, ''),
)
.replace(REMEMBER_ERROR_AWS_KEY_WITH_SEPARATORS_RE, (token) =>
token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, ''),
)
.replace(
REMEMBER_ERROR_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE,
(_match, prefix: string, value: string) =>
`${prefix}${value.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`,
)
.replace(
REMEMBER_ERROR_ENV_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE,
(_match, prefix: string, value: string) =>
`${prefix}${value.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`,
);
}
function collapseCredentialSeparatorsByStackLine(stack: string): string {
return stack
.split(/(\r\n|\n|\r)/)
.map((part) =>
part === '\r\n' || part === '\n' || part === '\r'
? part
: collapseCredentialSeparators(part),
)
.join('');
}
function isHighSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
}
function isLowSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
}
function truncateBeforeDanglingSurrogate(
details: string,
cutPoint: number,
): number {
const beforeCut = details.charCodeAt(cutPoint - 1);
const atCut = details.charCodeAt(cutPoint);
if (isHighSurrogate(beforeCut) && isLowSurrogate(atCut)) {
return cutPoint - 1;
}
return cutPoint;
}
function capRememberErrorText(redacted: string): string {
if (redacted.length <= MAX_REMEMBER_ERROR_DETAILS_CHARS) return redacted;
const truncationSuffix = '... [truncated]';
const cutPoint = truncateBeforeDanglingSurrogate(
redacted,
MAX_REMEMBER_ERROR_DETAILS_CHARS - truncationSuffix.length,
);
return `${redacted.slice(0, cutPoint)}${truncationSuffix}`;
}
function redactAndCapRememberErrorText(normalized: string): string | undefined {
const redacted = redactLogCredentials(normalized).trim();
if (!redacted) return undefined;
return capRememberErrorText(redacted);
}
function sanitizeRememberErrorDetails(details: string): string | undefined {
// Keep credential normalization before redaction; auth schemes and token
// values may otherwise be split by invisible characters.
return redactAndCapRememberErrorText(
replaceControlChars(collapseCredentialSeparators(details)),
);
}
function sanitizeRememberErrorStack(stack: string): string | undefined {
return redactAndCapRememberErrorText(
replaceStackControlChars(collapseCredentialSeparatorsByStackLine(stack)),
);
}
export function extractRememberErrorDetails(err: unknown): string | undefined {
const raw = rawRememberErrorDetails(err, new WeakSet<object>(), 0);
if (!raw) return undefined;
return sanitizeRememberErrorDetails(raw);
}
export function extractRememberErrorStack(err: unknown): string | undefined {
if (!(err instanceof Error) || !err.stack) return undefined;
return sanitizeRememberErrorStack(err.stack);
}
export function shouldSuppressRememberErrorDetails(code: string): boolean {
return DETAIL_SUPPRESSED_REMEMBER_ERROR_CODES.has(code);
}
export function workspaceMemoryFailureDiagnostics(
err: unknown,
onExtractionError?: (
target: RememberErrorExtractionTarget,
err: unknown,
) => void,
): WorkspaceMemoryFailureDiagnostics {
let details: string | undefined;
let stack: string | undefined;
try {
details = extractRememberErrorDetails(err);
} catch (extractionErr) {
reportRememberErrorExtractionFailure(
onExtractionError,
'details',
extractionErr,
);
details = undefined;
}
try {
stack = extractRememberErrorStack(err);
} catch (extractionErr) {
reportRememberErrorExtractionFailure(
onExtractionError,
'stack',
extractionErr,
);
stack = undefined;
}
return {
...(details ? { details } : {}),
debugDetails: details ?? '<details unavailable>',
...(stack ? { stack } : {}),
};
}

View file

@ -23,6 +23,19 @@ import {
} from './workspace-remember.js';
import { MAX_REMEMBER_CONTENT_BYTES } from './workspace-memory-remember-constants.js';
const { mockDebugLogger } = vi.hoisted(() => ({
mockDebugLogger: {
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock('@qwen-code/qwen-code-core', () => ({
createDebugLogger: () => mockDebugLogger,
}));
type RecordedEvent = Omit<BridgeEvent, 'id' | 'v'>;
interface Deferred<T> {
@ -211,12 +224,13 @@ function buildApp(
tokenConfigured: true,
requireAuth: false,
},
lane = new WorkspaceRememberTaskLane(bridge),
) {
const app = express();
app.use(express.json({ limit: '1mb' }));
mountWorkspaceMemoryRememberRoutes(app, {
bridge,
lane: new WorkspaceRememberTaskLane(bridge),
lane,
mutate: createMutationGate(auth),
parseClientId: (req, res) => {
const raw = req.get('x-qwen-client-id');
@ -863,13 +877,78 @@ describe('workspace memory remember routes', () => {
expect(bridge.dreamCalls).toBe(0);
});
it('falls back to kind-specific codes when enqueue code extraction throws', async () => {
mockDebugLogger.warn.mockClear();
const bridge = buildBridgeStub({});
const lane = new WorkspaceRememberTaskLane(bridge);
const err = new Proxy(
{},
{
get() {
throw new Error('code getter failed');
},
},
);
vi.spyOn(lane, 'enqueue').mockImplementation(() => {
throw err;
});
vi.spyOn(lane, 'enqueueForget').mockImplementation(() => {
throw err;
});
vi.spyOn(lane, 'enqueueDream').mockImplementation(() => {
throw err;
});
const app = buildApp(bridge, undefined, lane);
await request(app)
.post('/workspace/memory/remember')
.send({ content: 'remember me' })
.expect(500)
.expect((res) => {
expect(res.body).toEqual({
error: 'Workspace memory remember failed.',
code: 'remember_failed',
});
});
await request(app)
.post('/workspace/memory/forget')
.send({ query: 'old preference' })
.expect(500)
.expect((res) => {
expect(res.body).toEqual({
error: 'Workspace memory forget failed.',
code: 'forget_failed',
});
});
await request(app)
.post('/workspace/memory/dream')
.send({})
.expect(500)
.expect((res) => {
expect(res.body).toEqual({
error: 'Workspace memory dream failed.',
code: 'dream_failed',
});
});
expect(mockDebugLogger.warn).toHaveBeenCalledTimes(3);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
'Failed to extract workspace memory error code:',
{ extractionError: 'code getter failed' },
);
});
it('records bridge failures with stable public error codes', async () => {
const bridge = buildBridgeStub({
rememberImpl: vi
.fn()
.mockRejectedValueOnce({ code: 'remember_path_escape' })
.mockRejectedValueOnce(
Object.assign(new Error('agent wrote /tmp/outside'), {
code: 'remember_path_escape',
}),
)
.mockRejectedValueOnce({
data: { errorKind: 'managed_memory_unavailable' },
message: 'internal managed memory config path',
})
.mockRejectedValueOnce({
data: { errorKind: 'remember_timeout' },
@ -890,6 +969,7 @@ describe('workspace memory remember routes', () => {
expect(res.body.error).toEqual({
code: 'remember_path_escape',
message: 'Remember agent touched a path outside managed memory.',
details: 'agent wrote /tmp/outside',
});
});
@ -926,7 +1006,96 @@ describe('workspace memory remember routes', () => {
});
});
it('logs sanitized details for task-lane failures', async () => {
mockDebugLogger.error.mockClear();
const bridge = buildBridgeStub({
rememberImpl: vi
.fn()
.mockRejectedValue(
new Error('Authorization: Bearer secret-token-value'),
),
});
const app = buildApp(bridge);
const post = await request(app)
.post('/workspace/memory/remember')
.send({ content: 'secret' })
.expect(202);
await waitFor(() => bridge.rememberCalls.length === 1);
await request(app)
.get(`/workspace/memory/remember/${post.body.taskId}`)
.expect(200)
.expect((res) => {
expect(res.body.status).toBe('failed');
expect(res.body.error).toEqual({
code: 'remember_failed',
message: 'Workspace memory remember failed.',
details: 'Authorization: <redacted>',
});
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember task failed:',
expect.objectContaining({
taskId: post.body.taskId,
code: 'remember_failed',
details: 'Authorization: <redacted>',
stack: expect.stringContaining('Authorization: <redacted>'),
}),
);
expect(JSON.stringify(mockDebugLogger.error.mock.calls)).not.toContain(
'secret-token-value',
);
});
it('falls back when task-lane error code extraction throws', async () => {
mockDebugLogger.error.mockClear();
mockDebugLogger.warn.mockClear();
const err = new Proxy(
{},
{
get() {
throw new Error('code getter failed');
},
},
);
const bridge = buildBridgeStub({
rememberImpl: vi.fn().mockRejectedValue(err),
});
const app = buildApp(bridge);
const post = await request(app)
.post('/workspace/memory/remember')
.send({ content: 'proxy failure' })
.expect(202);
await waitFor(() => bridge.rememberCalls.length === 1);
await request(app)
.get(`/workspace/memory/remember/${post.body.taskId}`)
.expect(200)
.expect((res) => {
expect(res.body.status).toBe('failed');
expect(res.body.error).toEqual({
code: 'remember_failed',
message: 'Workspace memory remember failed.',
});
});
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
'Failed to extract workspace memory error code:',
{ extractionError: 'code getter failed' },
);
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory remember task failed:',
expect.objectContaining({
taskId: post.body.taskId,
code: 'remember_failed',
details: '<details unavailable>',
}),
);
});
it('records forget and dream failures with kind-specific error codes', async () => {
mockDebugLogger.error.mockClear();
const bridge = buildBridgeStub({
forgetImpl: vi.fn().mockRejectedValue(new Error('forget failed')),
dreamImpl: vi.fn().mockRejectedValue(new Error('dream failed')),
@ -946,8 +1115,18 @@ describe('workspace memory remember routes', () => {
expect(res.body.error).toEqual({
code: 'forget_failed',
message: 'Workspace memory forget failed.',
details: 'forget failed',
});
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory forget task failed:',
expect.objectContaining({
taskId: forgetPost.body.taskId,
code: 'forget_failed',
details: 'forget failed',
stack: expect.stringContaining('forget failed'),
}),
);
const dreamPost = await request(app)
.post('/workspace/memory/dream')
@ -962,7 +1141,17 @@ describe('workspace memory remember routes', () => {
expect(res.body.error).toEqual({
code: 'dream_failed',
message: 'Workspace memory dream failed.',
details: 'dream failed',
});
});
expect(mockDebugLogger.error).toHaveBeenCalledWith(
'Workspace memory dream task failed:',
expect.objectContaining({
taskId: dreamPost.body.taskId,
code: 'dream_failed',
details: 'dream failed',
stack: expect.stringContaining('dream failed'),
}),
);
});
});

View file

@ -15,7 +15,12 @@ import type {
BridgeWorkspaceMemoryRememberContextMode,
BridgeWorkspaceMemoryRememberResult,
} from './acp-session-bridge.js';
import { extractRememberErrorCode } from './workspace-remember-errors.js';
import {
createWorkspaceMemoryExtractionErrorLogger,
shouldSuppressRememberErrorDetails,
workspaceMemoryFailureCode,
workspaceMemoryFailureDiagnostics,
} from './workspace-remember-errors.js';
import { MAX_REMEMBER_CONTENT_BYTES } from './workspace-memory-remember-constants.js';
import {
formatWorkspaceMemoryDreamSummary,
@ -43,6 +48,7 @@ interface WorkspaceMemoryTaskBaseSnapshot {
error?: {
code: string;
message: string;
details?: string;
};
}
@ -179,6 +185,25 @@ export function publicErrorStatus(code: string): number {
return 500;
}
function createTaskError(
code: string,
kind: WorkspaceMemoryTaskKind,
details?: string,
): WorkspaceMemoryTaskBaseSnapshot['error'] {
const error: WorkspaceMemoryTaskBaseSnapshot['error'] = {
code,
message: publicErrorMessage(code, kind),
};
if (shouldSuppressRememberErrorDetails(code)) return error;
return {
...error,
...(details ? { details } : {}),
};
}
const logWorkspaceMemoryExtractionError =
createWorkspaceMemoryExtractionErrorLogger(debugLogger);
export class WorkspaceRememberTaskLane {
private static readonly MAX_TASKS = 1000;
private static readonly TERMINAL_TASK_TTL_MS = 5 * 60_000;
@ -331,17 +356,23 @@ export class WorkspaceRememberTaskLane {
};
task.updatedAt = nowIso();
} catch (err) {
const code = extractRememberErrorCode(err);
debugLogger.error(
'Workspace memory remember task failed:',
{ taskId: task.taskId },
const code = workspaceMemoryFailureCode(
err,
'remember_failed',
logWorkspaceMemoryExtractionError,
);
task.status = 'failed';
task.error = {
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
debugLogger.error('Workspace memory remember task failed:', {
taskId: task.taskId,
code,
message: publicErrorMessage(code, task.kind),
};
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
task.status = 'failed';
task.error = createTaskError(code, task.kind, diagnostics.details);
task.updatedAt = nowIso();
}
try {
@ -397,17 +428,23 @@ export class WorkspaceRememberTaskLane {
};
task.updatedAt = nowIso();
} catch (err) {
const code = extractRememberErrorCode(err, 'forget_failed');
debugLogger.error(
'Workspace memory forget task failed:',
{ taskId: task.taskId },
const code = workspaceMemoryFailureCode(
err,
'forget_failed',
logWorkspaceMemoryExtractionError,
);
task.status = 'failed';
task.error = {
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
debugLogger.error('Workspace memory forget task failed:', {
taskId: task.taskId,
code,
message: publicErrorMessage(code, task.kind),
};
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
task.status = 'failed';
task.error = createTaskError(code, task.kind, diagnostics.details);
task.updatedAt = nowIso();
}
try {
@ -459,17 +496,23 @@ export class WorkspaceRememberTaskLane {
};
task.updatedAt = nowIso();
} catch (err) {
const code = extractRememberErrorCode(err, 'dream_failed');
debugLogger.error(
'Workspace memory dream task failed:',
{ taskId: task.taskId },
const code = workspaceMemoryFailureCode(
err,
'dream_failed',
logWorkspaceMemoryExtractionError,
);
task.status = 'failed';
task.error = {
const diagnostics = workspaceMemoryFailureDiagnostics(
err,
logWorkspaceMemoryExtractionError,
);
debugLogger.error('Workspace memory dream task failed:', {
taskId: task.taskId,
code,
message: publicErrorMessage(code, task.kind),
};
details: diagnostics.debugDetails,
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
});
task.status = 'failed';
task.error = createTaskError(code, task.kind, diagnostics.details);
task.updatedAt = nowIso();
}
try {
@ -610,7 +653,11 @@ export function mountWorkspaceMemoryRememberRoutes(
...(originatorClientId ? { originatorClientId } : {}),
});
} catch (err) {
const code = extractRememberErrorCode(err);
const code = workspaceMemoryFailureCode(
err,
'remember_failed',
logWorkspaceMemoryExtractionError,
);
res.status(publicErrorStatus(code)).json({
error: publicErrorMessage(code, 'remember'),
code,
@ -678,7 +725,11 @@ export function mountWorkspaceMemoryRememberRoutes(
});
res.status(202).json(task);
} catch (err) {
const code = extractRememberErrorCode(err, 'forget_failed');
const code = workspaceMemoryFailureCode(
err,
'forget_failed',
logWorkspaceMemoryExtractionError,
);
res.status(publicErrorStatus(code)).json({
error: publicErrorMessage(code, 'forget'),
code,
@ -723,7 +774,11 @@ export function mountWorkspaceMemoryRememberRoutes(
});
res.status(202).json(task);
} catch (err) {
const code = extractRememberErrorCode(err, 'dream_failed');
const code = workspaceMemoryFailureCode(
err,
'dream_failed',
logWorkspaceMemoryExtractionError,
);
res.status(publicErrorStatus(code)).json({
error: publicErrorMessage(code, 'dream'),
code,

View file

@ -1086,6 +1086,7 @@ export interface DaemonWorkspaceMemoryRememberTask {
error?: {
code: string;
message: string;
details?: string;
};
}
@ -1116,6 +1117,7 @@ export interface DaemonWorkspaceMemoryForgetTask {
error?: {
code: string;
message: string;
details?: string;
};
}
@ -1138,6 +1140,7 @@ export interface DaemonWorkspaceMemoryDreamTask {
error?: {
code: string;
message: string;
details?: string;
};
}