diff --git a/packages/channels/dws/src/dws-client-process.test.ts b/packages/channels/dws/src/dws-client-process.test.ts index 5441899e2e7..9705eda8c42 100644 --- a/packages/channels/dws/src/dws-client-process.test.ts +++ b/packages/channels/dws/src/dws-client-process.test.ts @@ -11,6 +11,31 @@ import { DwsClient, DwsCommandError } from './dws-client.js'; vi.mock('node:child_process', () => ({ execFile: vi.fn() })); describe('DWS command process', () => { + function mockFailedDwsCommand({ + code = 1, + stdout = '', + stderr = '', + }: { + code?: unknown; + stdout?: string; + stderr?: string; + }) { + vi.mocked(execFile).mockImplementation((( + _file, + _args, + _options, + callback, + ) => { + queueMicrotask(() => { + callback(Object.assign(new Error('exit'), { code }), stdout, stderr); + }); + return { + exitCode: typeof code === 'number' ? code : null, + kill: vi.fn(), + }; + }) as typeof execFile); + } + afterEach(() => { vi.useRealTimers(); vi.mocked(execFile).mockReset(); @@ -49,6 +74,119 @@ describe('DWS command process', () => { await vi.advanceTimersByTimeAsync(50_000); expect(child.kill).toHaveBeenCalledWith('SIGKILL'); - await expect(result).resolves.toBeInstanceOf(DwsCommandError); + const error = await result; + expect(error).toBeInstanceOf(DwsCommandError); + expect((error as Error).message).toBe('DWS command failed.'); + }); + + it('includes sanitized stderr details when a DWS command exits non-zero', async () => { + mockFailedDwsCommand({ + stderr: '\u001b[31mHTTP 400\n{"errorCode":"InvalidArgs"}\u001b[0m', + }); + + const error = await new DwsClient({ executable: '/opt/dws' }) + .assertCompatible() + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(DwsCommandError); + expect((error as Error).message).toContain( + 'DWS command failed (1): HTTP 400\\n{"errorCode":"InvalidArgs"}', + ); + expect((error as Error).message).not.toContain('[31m'); + }); + + it('uses stdout details when stderr is empty', async () => { + mockFailedDwsCommand({ stdout: 'detail on stdout' }); + + const error = await new DwsClient({ executable: '/opt/dws' }) + .assertCompatible() + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(DwsCommandError); + expect((error as Error).message).toContain( + 'DWS command failed (1): detail on stdout', + ); + }); + + it('prefers stderr details over stdout details', async () => { + mockFailedDwsCommand({ + stdout: 'detail on stdout', + stderr: 'detail on stderr', + }); + + const error = await new DwsClient({ executable: '/opt/dws' }) + .assertCompatible() + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(DwsCommandError); + expect((error as Error).message).toContain( + 'DWS command failed (1): detail on stderr', + ); + expect((error as Error).message).not.toContain('detail on stdout'); + }); + + it('falls back to stdout when stderr sanitizes to empty', async () => { + mockFailedDwsCommand({ + stdout: 'error: quota exceeded', + stderr: '\u001b[2K\r', + }); + + const error = await new DwsClient({ executable: '/opt/dws' }) + .assertCompatible() + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(DwsCommandError); + expect((error as Error).message).toContain( + 'DWS command failed (1): error: quota exceeded', + ); + }); + + it('uses a bare message when stderr and stdout sanitize to empty', async () => { + mockFailedDwsCommand({ stderr: '\u001b[2K\r' }); + + const error = await new DwsClient({ executable: '/opt/dws' }) + .assertCompatible() + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(DwsCommandError); + expect((error as Error).message).toBe('DWS command failed (1).'); + }); + + it('strips OSC terminal control sequences from failure details', async () => { + mockFailedDwsCommand({ + stderr: `${String.fromCharCode(27)}]0;evil${String.fromCharCode(7)}boom`, + }); + + const error = await new DwsClient({ executable: '/opt/dws' }) + .assertCompatible() + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(DwsCommandError); + expect((error as Error).message).toContain('boom'); + expect((error as Error).message).not.toContain(']0;evil'); + }); + + it('caps failure details before exposing them in the error message', async () => { + mockFailedDwsCommand({ stderr: `${'x'.repeat(300)}tail` }); + + const error = await new DwsClient({ executable: '/opt/dws' }) + .assertCompatible() + .catch((caught: unknown) => caught); + + const prefix = 'DWS command failed (1): '; + expect(error).toBeInstanceOf(DwsCommandError); + expect((error as Error).message).toBe(`${prefix}${'x'.repeat(252)}tail`); + expect((error as Error).message).not.toContain('x'.repeat(253)); + }); + + it('keeps later diagnostics from long command output', async () => { + mockFailedDwsCommand({ stderr: `${'noise'.repeat(200)}fatal: denied` }); + + const error = await new DwsClient({ executable: '/opt/dws' }) + .assertCompatible() + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(DwsCommandError); + expect((error as Error).message).toContain('fatal: denied'); }); }); diff --git a/packages/channels/dws/src/dws-client.ts b/packages/channels/dws/src/dws-client.ts index 4adb552ae78..664bd1f2ca4 100644 --- a/packages/channels/dws/src/dws-client.ts +++ b/packages/channels/dws/src/dws-client.ts @@ -5,6 +5,8 @@ */ import { execFile } from 'node:child_process'; +import { stripVTControlCharacters } from 'node:util'; +import { sanitizeLogText, truncateCodePoints } from '@qwen-code/channel-base'; import { dwsProcessEnvironment } from './dws-environment.js'; import { startDwsEventProcess, @@ -16,6 +18,8 @@ const DWS_PROCESS_TIMEOUT_MS = 45_000; const DWS_PROCESS_FORCE_KILL_DELAY_MS = 5_000; const MINIMUM_DWS_VERSION = [1, 0, 57] as const; const DWS_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const DWS_ERROR_OUTPUT_MAX_CHARS = 256; +const DWS_ERROR_OUTPUT_WINDOW_CHARS = DWS_ERROR_OUTPUT_MAX_CHARS * 2; const MAX_MESSAGE_PAGES = 100; const MAX_TODO_PAGES = 50; const TODO_PAGE_SIZE = 20; @@ -187,6 +191,36 @@ export function classifyDwsCommandFailure(code: unknown): DwsCommandOutcome { : 'unknown'; } +function dwsCommandFailureMessage( + code: unknown, + stdout: unknown, + stderr: unknown, +): string { + const base = `DWS command failed${code === undefined || code === null ? '' : ` (${String(code)})`}`; + const details = + dwsCommandFailureDetails(stderr) || dwsCommandFailureDetails(stdout); + return details ? `${base}: ${details}` : `${base}.`; +} + +function dwsCommandFailureDetails(output: unknown): string { + const window = String(output ?? '').slice(-DWS_ERROR_OUTPUT_WINDOW_CHARS); + if (!window.trim()) return ''; + const details = truncateCodePointsFromEnd( + sanitizeLogText( + stripVTControlCharacters(window), + DWS_ERROR_OUTPUT_WINDOW_CHARS, + ), + DWS_ERROR_OUTPUT_MAX_CHARS, + ).trim(); + return details; +} + +function truncateCodePointsFromEnd(str: string, max: number): string { + const truncated = truncateCodePoints(str, max); + if (truncated === str) return str; + return Array.from(str).slice(-max).join(''); +} + function runDwsProcess( executable: string, args: string[], @@ -212,7 +246,7 @@ function runDwsProcess( const outcome = classifyDwsCommandFailure(code); reject( new DwsCommandError( - `DWS command failed${code === undefined ? '' : ` (${String(code)})`}.`, + dwsCommandFailureMessage(code, stdout, stderr), outcome, ), );