source dump of claude code
at main 123 lines 4.0 kB view raw
1import { registerCleanup } from './cleanupRegistry.js' 2import { logForDebugging } from './debug.js' 3 4/** 5 * Sentinel written to stderr ahead of any diverted non-JSON line, so that 6 * log scrapers and tests can grep for guard activity. 7 */ 8export const STDOUT_GUARD_MARKER = '[stdout-guard]' 9 10let installed = false 11let buffer = '' 12let originalWrite: typeof process.stdout.write | null = null 13 14function isJsonLine(line: string): boolean { 15 // Empty lines are tolerated in NDJSON streams — treat them as valid so a 16 // trailing newline or a blank separator doesn't trip the guard. 17 if (line.length === 0) { 18 return true 19 } 20 try { 21 JSON.parse(line) 22 return true 23 } catch { 24 return false 25 } 26} 27 28/** 29 * Install a runtime guard on process.stdout.write for --output-format=stream-json. 30 * 31 * SDK clients consuming stream-json parse stdout line-by-line as NDJSON. Any 32 * stray write — a console.log from a dependency, a debug print that slipped 33 * past review, a library banner — breaks the client's parser mid-stream with 34 * no recovery path. 35 * 36 * This guard wraps process.stdout.write at the same layer the asciicast 37 * recorder does (see asciicast.ts). Writes are buffered until a newline 38 * arrives, then each complete line is JSON-parsed. Lines that parse are 39 * forwarded to the real stdout; lines that don't are diverted to stderr 40 * tagged with STDOUT_GUARD_MARKER so they remain visible without corrupting 41 * the JSON stream. 42 * 43 * The blessed JSON path (structuredIO.write → writeToStdout → stdout.write) 44 * always emits `ndjsonSafeStringify(msg) + '\n'`, so it passes straight 45 * through. Only out-of-band writes are diverted. 46 * 47 * Installing twice is a no-op. Call before any stream-json output is emitted. 48 */ 49export function installStreamJsonStdoutGuard(): void { 50 if (installed) { 51 return 52 } 53 installed = true 54 55 originalWrite = process.stdout.write.bind( 56 process.stdout, 57 ) as typeof process.stdout.write 58 59 process.stdout.write = function ( 60 chunk: string | Uint8Array, 61 encodingOrCb?: BufferEncoding | ((err?: Error) => void), 62 cb?: (err?: Error) => void, 63 ): boolean { 64 const text = 65 typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8') 66 67 buffer += text 68 let newlineIdx: number 69 let wrote = true 70 while ((newlineIdx = buffer.indexOf('\n')) !== -1) { 71 const line = buffer.slice(0, newlineIdx) 72 buffer = buffer.slice(newlineIdx + 1) 73 if (isJsonLine(line)) { 74 wrote = originalWrite!(line + '\n') 75 } else { 76 process.stderr.write(`${STDOUT_GUARD_MARKER} ${line}\n`) 77 logForDebugging( 78 `streamJsonStdoutGuard diverted non-JSON stdout line: ${line.slice(0, 200)}`, 79 ) 80 } 81 } 82 83 // Fire the callback once buffering is done. We report success even when 84 // a line was diverted — the caller's intent (emit text) was honored, 85 // just on a different fd. 86 const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb 87 if (callback) { 88 queueMicrotask(() => callback()) 89 } 90 return wrote 91 } as typeof process.stdout.write 92 93 registerCleanup(async () => { 94 // Flush any partial line left in the buffer at shutdown. If it's a JSON 95 // fragment it won't parse — divert it rather than drop it silently. 96 if (buffer.length > 0) { 97 if (originalWrite && isJsonLine(buffer)) { 98 originalWrite(buffer + '\n') 99 } else { 100 process.stderr.write(`${STDOUT_GUARD_MARKER} ${buffer}\n`) 101 } 102 buffer = '' 103 } 104 if (originalWrite) { 105 process.stdout.write = originalWrite 106 originalWrite = null 107 } 108 installed = false 109 }) 110} 111 112/** 113 * Testing-only reset. Restores the real stdout.write and clears the line 114 * buffer so subsequent tests start from a clean slate. 115 */ 116export function _resetStreamJsonStdoutGuardForTesting(): void { 117 if (originalWrite) { 118 process.stdout.write = originalWrite 119 originalWrite = null 120 } 121 buffer = '' 122 installed = false 123}