const MAX_LOGGED_CHARS = 8_000; const MAX_LOGGED_LINES = 20; const MAX_LINE_CHARS = 2_000; const MAX_PENDING_LINE_CHARS = 16_000; interface DiagnosticLogger { warn(bindings: Readonly>, message: string): void; } export interface AgentSdkStderrSink { readonly write: (data: string) => void; readonly flush: () => void; } /** * Turn the SDK subprocess stderr stream into bounded, run-correlated logs. * Complete lines are buffered so a credential split across chunks is still * removed before anything reaches the logger. */ export function createAgentSdkStderrSink(input: { readonly logger: DiagnosticLogger; readonly runId: string; readonly projectId: string; readonly sensitiveValues: readonly (string | undefined)[]; }): AgentSdkStderrSink { const secrets = [...new Set(input.sensitiveValues.filter((value): value is string => value !== undefined && value !== ""))] .sort((left, right) => right.length - left.length); let pending = ""; let droppingOversizedLine = false; let remainingChars = MAX_LOGGED_CHARS; let remainingLines = MAX_LOGGED_LINES; let limitReported = false; const reportLimit = (): void => { if (limitReported) return; limitReported = true; input.logger.warn( { runId: input.runId, projectId: input.projectId }, "agent SDK stderr log limit reached; further diagnostics omitted", ); }; const emit = (line: string): void => { if (line.trim() === "") return; if (remainingChars <= 0 || remainingLines <= 0) { reportLimit(); return; } const redacted = redactDiagnostic(line, secrets); const allowed = Math.min(MAX_LINE_CHARS, remainingChars); const diagnostic = boundedDiagnostic(redacted, allowed); remainingChars -= diagnostic.length; remainingLines--; input.logger.warn( { runId: input.runId, projectId: input.projectId, sdkStderr: diagnostic }, "agent SDK stderr", ); if (redacted.length > allowed || remainingChars <= 0 || remainingLines <= 0) reportLimit(); }; const append = (fragment: string): void => { if (fragment === "" || droppingOversizedLine) return; if (pending.length + fragment.length > MAX_PENDING_LINE_CHARS) { pending = ""; droppingOversizedLine = true; emit("[oversized stderr line omitted]"); return; } pending += fragment; }; const write = (data: string): void => { let lineStart = 0; for (let index = 0; index < data.length; index++) { const character = data[index]; if (character !== "\n" && character !== "\r") continue; append(data.slice(lineStart, index)); if (!droppingOversizedLine) emit(pending); pending = ""; droppingOversizedLine = false; if (character === "\r" && data[index + 1] === "\n") index++; lineStart = index + 1; } append(data.slice(lineStart)); }; const flush = (): void => { if (droppingOversizedLine) { droppingOversizedLine = false; } else { emit(pending); } pending = ""; }; return { write, flush }; } function boundedDiagnostic(value: string, limit: number): string { if (value.length <= limit) return value; const marker = "…[truncated]"; if (limit <= marker.length) return marker.slice(0, limit); return `${value.slice(0, limit - marker.length)}${marker}`; } function redactDiagnostic(value: string, secrets: readonly string[]): string { let redacted = value; for (const secret of secrets) redacted = redacted.replaceAll(secret, "[REDACTED]"); return redacted .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "") .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ") .replace(/\b(postgres(?:ql)?):\/\/([^:\s/@]+):([^@\s]+)@/gi, "$1://$2:[REDACTED]@") .replace(/\b((?:api[_-]?key|token|secret|password)\s*[:=]\s*)\S+/gi, "$1[REDACTED]") .trim(); }