/** * Optional protobuf field-tree tracing for the local Windsurf language server. * * Disabled by default. Enable with WINDSURFAPI_PROTO_TRACE=1. String payloads * are redacted by default: traces keep byte length + hash, not raw API keys, * account emails, session tokens, prompts, or tool preambles. */ import { appendFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { createHash } from 'crypto'; import { gunzipSync } from 'zlib'; import { getAllFields, getField, parseFields } from './proto.js'; let _seq = 0; function enabled() { return process.env.WINDSURFAPI_PROTO_TRACE === '1'; } function traceDir() { return process.env.WINDSURFAPI_PROTO_TRACE_DIR || '/data/proto-trace'; } function positiveIntEnv(name, fallback) { const n = parseInt(process.env[name] || '', 10); return Number.isFinite(n) && n > 0 ? n : fallback; } function shortHash(buf) { return createHash('sha256').update(buf).digest('hex').slice(0, 16); } function safeMethodName(path) { const name = String(path || 'unknown').split('/').filter(Boolean).pop() || 'unknown'; return name.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 80); } function mostlyText(buf) { if (!buf || buf.length === 0) return false; const s = buf.toString('utf8'); let printable = 0; for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); if (c === 9 || c === 10 || c === 13 || (c >= 32 && c !== 127)) printable++; } return printable / Math.max(1, s.length) > 0.9; } function redactPreview(s) { return String(s) .replace(/\b(?:devin-session-token|sessionToken|api[_-]?key|firebase_id_token|idToken|refreshToken)\b\s*[:=]\s*["']?[^"',\s)]+/gi, '') .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '') .replace(/\b[A-Za-z0-9_-]{32,}\b/g, '') .slice(0, 240); } function looksPathLike(s) { const value = String(s || '').trim(); if (!value || value.length > 1024 || /[\r\n<>]/.test(value)) return false; if (/^file:\/\/\/?(?:[A-Za-z]:[\\/]|\/|~[\\/])/.test(value)) return true; if (/^(?:[A-Za-z]:[\\/]|\/|~[\\/]|\.{1,2}[\\/])\S+/.test(value)) return true; return /^[A-Za-z0-9._-]+(?:[\\/][A-Za-z0-9._-]+)*\.[A-Za-z0-9]{1,12}$/.test(value); } function looksPromptLike(s) { const value = String(s || ''); if (!value) return false; if (value.includes('Working directory:') || value.includes('Use the Read tool')) return true; if (value.includes('') || value.includes('') || value.includes('')) return true; return value.length > 512 && /(?:tool|prompt|environment|platform|workspace)/i.test(value); } function basenameOfPath(s) { const value = String(s || '').trim().replace(/^file:\/+/, ''); const parts = value.split(/[\\/]+/).filter(Boolean); return parts.length ? parts[parts.length - 1].slice(0, 120) : ''; } function summarizeReadWrapperStringField(field, value, bytes, sha256) { const text = String(value || ''); const looksPath = looksPathLike(text); const looksPrompt = looksPromptLike(text); const acceptedByParser = (field === 1 || field === 2) && looksPath; return { field, bytes, sha256, looksPathLike: looksPath, looksPromptLike: looksPrompt, acceptedByParser, basename: looksPath ? basenameOfPath(text) : '', }; } function looksLikeMessage(buf) { if (!buf?.length) return false; const key = buf[0]; const wireType = key & 7; const field = key >> 3; if (!field || ![0, 1, 2, 5].includes(wireType)) return false; try { const parsed = parseFields(buf); return parsed.length > 0 && parsed.every(f => f.field > 0); } catch { return false; } } function stringField(fields, num) { const f = getField(fields, num, 2); return f ? f.value.toString('utf8') : ''; } function numberField(fields, num) { const f = getField(fields, num, 0); return f ? Number(f.value || 0) : null; } function boolField(fields, num) { const n = numberField(fields, num); return n == null ? null : n !== 0; } function countRepeatedMessageFields(fields, num) { return getAllFields(fields, num).filter(f => f.wireType === 2).length; } function summarizeMessageChildren(buf, maxFields = 12) { let children = []; try { children = parseFields(buf).slice(0, maxFields).map(child => ({ field: child.field, wireType: child.wireType, type: Buffer.isBuffer(child.value) ? (mostlyText(child.value) ? 'string' : 'message_or_bytes') : 'varint', bytes: Buffer.isBuffer(child.value) ? child.value.length : undefined, value: Buffer.isBuffer(child.value) ? undefined : Number(child.value), })); } catch { children = []; } return { bytes: buf.length, fieldNumbers: children.map(child => child.field), fields: children, }; } function summarizeKnowledgeBaseChunk(buf) { const fields = parseFields(buf); const markdown = getField(fields, 3, 2); let markdownTextBytes = 0; if (markdown) { try { markdownTextBytes = stringField(parseFields(markdown.value), 2).length; } catch {} } return { bytes: buf.length, textBytes: stringField(fields, 1).length, markdownTextBytes, fieldNumbers: fields.map(f => f.field), }; } function summarizeKnowledgeBaseItem(buf) { const fields = parseFields(buf); const chunks = getAllFields(fields, 6) .filter(f => f.wireType === 2) .map(f => summarizeKnowledgeBaseChunk(f.value)); return { bytes: buf.length, textBytes: stringField(fields, 2).length, urlBytes: stringField(fields, 3).length, titleBytes: stringField(fields, 4).length, summaryBytes: stringField(fields, 7).length, chunkCount: chunks.length, chunkTextBytes: chunks.reduce((sum, chunk) => sum + chunk.textBytes + chunk.markdownTextBytes, 0), fieldNumbers: fields.map(f => f.field), chunks: chunks.slice(0, 4), }; } const NATIVE_TOOL_CONFIG_FIELDS = new Map([ [5, 'find'], [8, 'run_command'], [13, 'search_web'], [10, 'view_file'], [19, 'list_dir'], [33, 'grep_v2'], [37, 'read_url_content'], ]); const THIRD_PARTY_WEB_SEARCH_PROVIDER = new Map([ [0, 'UNSPECIFIED'], [1, 'OPENAI'], ]); const THIRD_PARTY_WEB_SEARCH_MODEL = new Map([ [0, 'UNSPECIFIED'], [1, 'O3'], [2, 'GPT_4_1'], [3, 'O4_MINI'], ]); const CASCADE_WEB_REQUESTS_AUTO_EXECUTION = new Map([ [0, 'UNSPECIFIED'], [1, 'DISABLED'], [2, 'ALLOWLIST'], [3, 'TURBO'], ]); const READ_URL_CONTENT_ACTION = new Map([ [1, 'ALLOW_ONCE'], [2, 'REJECT'], [3, 'ALWAYS_ALLOW_ORIGIN'], ]); function enumSummary(value, names) { return value == null ? null : { value, name: names.get(value) || `UNKNOWN_${value}`, }; } function summarizeThirdPartyWebSearchConfig(buf) { const fields = parseFields(buf); return { provider: enumSummary(numberField(fields, 1), THIRD_PARTY_WEB_SEARCH_PROVIDER), model: enumSummary(numberField(fields, 2), THIRD_PARTY_WEB_SEARCH_MODEL), fieldNumbers: fields.map(f => f.field), }; } function summarizeAutoWebRequestConfig(buf) { const fields = parseFields(buf); const allowlist = getAllFields(fields, 1) .filter(f => f.wireType === 2) .map(f => ({ bytes: f.value.length, sha256: shortHash(f.value) })); return { allowlistCount: allowlist.length, allowlist, autoExecutionPolicy: enumSummary(numberField(fields, 2), CASCADE_WEB_REQUESTS_AUTO_EXECUTION), fieldNumbers: fields.map(f => f.field), }; } function summarizeNativeToolSubconfig(fields, num) { const f = getField(fields, num, 2); if (!f) return null; const summary = summarizeMessageChildren(f.value); const kind = NATIVE_TOOL_CONFIG_FIELDS.get(num) || `field_${num}`; let decoded = null; try { const subFields = parseFields(f.value); if (kind === 'search_web') { const thirdParty = getField(subFields, 2, 2); decoded = { forceDisable: boolField(subFields, 1), thirdPartyConfig: thirdParty ? summarizeThirdPartyWebSearchConfig(thirdParty.value) : null, }; } else if (kind === 'read_url_content') { const autoWeb = getField(subFields, 2, 2); decoded = { forceDisable: boolField(subFields, 1), autoWebRequestConfig: autoWeb ? summarizeAutoWebRequestConfig(autoWeb.value) : null, }; } } catch {} return { field: num, kind, bytes: summary.bytes, fieldNumbers: summary.fieldNumbers, fields: summary.fields.map(child => ({ field: child.field, wireType: child.wireType, bytes: child.bytes, })), ...(decoded ? { decoded } : {}), }; } function summarizeNativeToolConfig(toolCfgBuf) { const fields = parseFields(toolCfgBuf); const subconfigs = [...NATIVE_TOOL_CONFIG_FIELDS.keys()] .map(num => summarizeNativeToolSubconfig(fields, num)) .filter(Boolean); const known = new Set([...NATIVE_TOOL_CONFIG_FIELDS.keys(), 32]); const unknownFields = fields .filter(f => f.wireType === 2 && !known.has(f.field)) .slice(0, positiveIntEnv('WINDSURFAPI_PROTO_TRACE_TOOL_CONFIG_UNKNOWN_LIMIT', 24)) .map(f => ({ field: f.field, bytes: f.value.length, summary: summarizeMessageChildren(f.value, 10), })); return { subconfigFields: subconfigs.map(s => s.field), subconfigs, unknownFields, allowlist: getAllFields(fields, 32) .filter(f => f.wireType === 2) .map(f => f.value.toString('utf8')), }; } function summarizeSendUserCascadeMessage(payload) { const top = parseFields(payload); const out = { cascadeIdHash: null, hasText: !!getField(top, 2, 2), hasMetadata: !!getField(top, 3, 2), imageCount: countRepeatedMessageFields(top, 6), additionalStepCount: countRepeatedMessageFields(top, 9), plannerMode: null, hasNativeToolConfig: false, nativeToolConfig: null, }; const cascadeId = stringField(top, 1); if (cascadeId) out.cascadeIdHash = shortHash(Buffer.from(cascadeId, 'utf8')); const cfgField = getField(top, 5, 2); if (!cfgField) return out; const cfg = parseFields(cfgField.value); const plannerField = getField(cfg, 1, 2); if (!plannerField) return out; const planner = parseFields(plannerField.value); const convField = getField(planner, 2, 2); if (convField) { const conv = parseFields(convField.value); out.plannerMode = numberField(conv, 4); out.hasToolCallingSection = !!getField(conv, 10, 2); out.hasAdditionalInstructionsSection = !!getField(conv, 12, 2); } const toolCfgField = getField(planner, 13, 2); if (toolCfgField) { out.hasNativeToolConfig = true; out.nativeToolConfig = summarizeNativeToolConfig(toolCfgField.value); } return out; } const NATIVE_STEP_FIELDS = new Map([ [13, 'grep_search'], [14, 'view_file'], [15, 'list_directory'], [23, 'write_to_file'], [28, 'run_command'], [34, 'find'], [40, 'read_url_content'], [42, 'search_web'], [105, 'grep_search_v2'], ]); const REQUESTED_INTERACTION_FIELDS = new Map([ [2, 'deploy'], [3, 'run_command'], [5, 'run_extension_code'], [11, 'resolve_task'], [13, 'upsert_codemap'], [14, 'read_url_content'], [15, 'ask_user_question'], ]); function summarizeNativeStepBody(kind, bodyBuf) { const f = parseFields(bodyBuf); if (kind === 'view_file') { return { absolutePathUriBytes: stringField(f, 1).length, contentBytes: stringField(f, 4).length, offset: numberField(f, 11) || 0, limit: numberField(f, 12) || 0, }; } if (kind === 'run_command') { const combined = getField(f, 21, 2); let combinedOutputBytes = 0; if (combined) { try { combinedOutputBytes = stringField(parseFields(combined.value), 1).length; } catch {} } return { commandBytes: (stringField(f, 23) || stringField(f, 1)).length, cwdBytes: stringField(f, 2).length, combinedOutputBytes, stdoutBytes: stringField(f, 4).length, stderrBytes: stringField(f, 5).length, }; } if (kind === 'grep_search_v2') { return { patternBytes: stringField(f, 2).length, pathBytes: stringField(f, 3).length, globBytes: stringField(f, 4).length, outputModeBytes: stringField(f, 5).length, headLimit: numberField(f, 12) || 0, rawOutputBytes: stringField(f, 15).length, }; } if (kind === 'grep_search') { return { queryBytes: stringField(f, 1).length, searchPathUriBytes: stringField(f, 11).length, resultBytes: stringField(f, 3).length, }; } if (kind === 'find') { return { patternBytes: stringField(f, 1).length, searchDirectoryBytes: stringField(f, 10).length, rawOutputBytes: stringField(f, 11).length, }; } if (kind === 'list_directory') { return { directoryPathUriBytes: stringField(f, 1).length, childCount: getAllFields(f, 2).filter(x => x.wireType === 2).length, }; } if (kind === 'search_web') { return { queryBytes: stringField(f, 1).length, webDocumentCount: getAllFields(f, 2).filter(x => x.wireType === 2).length, domainBytes: stringField(f, 3).length, summaryBytes: stringField(f, 5).length, fieldNumbers: f.map(x => x.field), messageFields: f .filter(x => x.wireType === 2 && ![1, 3, 5].includes(x.field)) .slice(0, 8) .map(x => ({ field: x.field, ...summarizeMessageChildren(x.value, 8) })), }; } if (kind === 'read_url_content') { const webDocument = getField(f, 2, 2); return { urlBytes: stringField(f, 1).length, webDocument: webDocument ? summarizeKnowledgeBaseItem(webDocument.value) : null, resolvedUrlBytes: stringField(f, 3).length, latencyMs: numberField(f, 4) || 0, legacySummaryBytes: stringField(f, 5).length, userRejected: boolField(f, 6), autoRunDecision: numberField(f, 7), fieldNumbers: f.map(x => x.field), messageFields: f .filter(x => x.wireType === 2 && ![1, 2, 3, 4, 5, 6, 7].includes(x.field)) .slice(0, 8) .map(x => ({ field: x.field, ...summarizeMessageChildren(x.value, 8) })), }; } return { fieldCount: f.length }; } function summarizeRequestedInteractionBody(kind, bodyBuf) { const fields = parseFields(bodyBuf); if (kind === 'read_url_content') { const url = stringField(fields, 1); const origin = stringField(fields, 2); return { urlBytes: url.length, urlHash: url ? shortHash(Buffer.from(url, 'utf8')) : null, originBytes: origin.length, originHash: origin ? shortHash(Buffer.from(origin, 'utf8')) : null, fieldNumbers: fields.map(f => f.field), }; } return summarizeMessageChildren(bodyBuf, 8); } function summarizeRequestedInteraction(buf) { const fields = parseFields(buf); const interactions = []; for (const [fieldNum, kind] of REQUESTED_INTERACTION_FIELDS) { const f = getField(fields, fieldNum, 2); if (!f) continue; interactions.push({ field: fieldNum, kind, bytes: f.value.length, body: summarizeRequestedInteractionBody(kind, f.value), }); } return { bytes: buf.length, fieldNumbers: fields.map(f => f.field), interactions, }; } function summarizeHandleCascadeUserInteraction(payload) { const fields = parseFields(payload); const cascadeId = stringField(fields, 1); const interactionField = getField(fields, 2, 2); const out = { cascadeIdBytes: cascadeId.length, cascadeIdHash: cascadeId ? shortHash(Buffer.from(cascadeId, 'utf8')) : null, fieldNumbers: fields.map(f => f.field), }; if (!interactionField) return out; const interaction = parseFields(interactionField.value); const trajectoryId = stringField(interaction, 1); const readUrlField = getField(interaction, 15, 2); out.interaction = { trajectoryIdBytes: trajectoryId.length, trajectoryIdHash: trajectoryId ? shortHash(Buffer.from(trajectoryId, 'utf8')) : null, stepIndex: numberField(interaction, 2), fieldNumbers: interaction.map(f => f.field), }; if (!readUrlField) return out; const readUrl = parseFields(readUrlField.value); const url = stringField(readUrl, 2); const origin = stringField(readUrl, 3); out.interaction.readUrlContent = { action: enumSummary(numberField(readUrl, 1), READ_URL_CONTENT_ACTION), urlBytes: url.length, urlHash: url ? shortHash(Buffer.from(url, 'utf8')) : null, originBytes: origin.length, originHash: origin ? shortHash(Buffer.from(origin, 'utf8')) : null, fieldNumbers: readUrl.map(f => f.field), }; return out; } function summarizeReadWrapperField19(wrapperBuf) { const fields = parseFields(wrapperBuf); const candidates = []; let acceptedField = null; return { bytes: wrapperBuf.length, fieldNumbers: fields.map(f => f.field), children: fields.slice(0, positiveIntEnv('WINDSURFAPI_PROTO_TRACE_READ_WRAPPER_CHILD_LIMIT', 24)) .map((f) => { const out = { field: f.field, wireType: f.wireType, }; if (f.wireType === 0) { out.type = 'varint'; out.value = typeof f.value === 'bigint' ? f.value.toString() : f.value; return out; } if (!Buffer.isBuffer(f.value)) return out; out.bytes = f.value.length; out.sha256 = shortHash(f.value); if (looksLikeMessage(f.value)) { out.type = 'message_or_bytes'; out.summary = summarizeMessageChildren(f.value, 8); return out; } if (mostlyText(f.value)) { const text = f.value.toString('utf8'); const candidate = summarizeReadWrapperStringField(f.field, text, f.value.length, out.sha256); candidates.push(candidate); if (acceptedField == null && candidate.acceptedByParser) acceptedField = f.field; out.type = 'string'; out.hasNewline = /[\r\n]/.test(text); out.hasAngleBracket = /[<>]/.test(text); out.looksPathLike = candidate.looksPathLike; out.looksPromptLike = candidate.looksPromptLike; out.basename = candidate.basename; out.acceptedByParser = candidate.acceptedByParser; if (process.env.WINDSURFAPI_PROTO_TRACE_READ_WRAPPER_STRINGS === '1') { out.preview = redactPreview(text); } return out; } out.type = 'message_or_bytes'; out.summary = summarizeMessageChildren(f.value, 8); return out; }), candidateSummary: { acceptedField, pathLikeFields: candidates.filter(c => c.looksPathLike).map(c => c.field), rejectedPromptFields: candidates.filter(c => c.looksPromptLike && !c.acceptedByParser).map(c => c.field), ambiguous: candidates.filter(c => c.acceptedByParser).length > 1, candidates, }, }; } function classifyErrorText(text) { const value = String(text || '').toLowerCase(); return { permissionDenied: /permission[_\s-]?denied|forbidden|\b403\b|not authorized/.test(value), failedPrecondition: /failed[_\s-]?precondition|precondition/.test(value), modelNotAvailable: /model[_\s-]?not[_\s-]?available|model not available/.test(value), internalError: /internal[_\s-]?error|an internal error occurred/.test(value), unauthenticated: /unauthenticated|\b401\b|invalid api key|invalid token/.test(value), rateLimited: /rate[_\s-]?limit|too many requests|\b429\b/.test(value), quotaOrEntitlement: /quota|credit|billing|subscription|entitlement/.test(value), }; } function compactTrueFlags(flags) { return Object.fromEntries(Object.entries(flags || {}).filter(([, v]) => !!v)); } function mergeErrorClassifications(target, source) { for (const [key, value] of Object.entries(source || {})) { if (value) target[key] = true; } return target; } function summarizeErrorString(path, buf) { const text = buf.toString('utf8'); const classifications = compactTrueFlags(classifyErrorText(text)); const out = { path, bytes: buf.length, sha256: shortHash(buf), classifications, }; if (process.env.WINDSURFAPI_PROTO_TRACE_ERROR_STRINGS === '1') { out.preview = redactPreview(text); } return out; } function collectErrorStrings(buf, path, out, depth = 0) { if (!Buffer.isBuffer(buf) || out.length >= positiveIntEnv('WINDSURFAPI_PROTO_TRACE_ERROR_STRING_LIMIT', 8)) return; const maxDepth = positiveIntEnv('WINDSURFAPI_PROTO_TRACE_ERROR_DEPTH', 4); if (depth < maxDepth && looksLikeMessage(buf)) { const before = out.length; try { const fields = parseFields(buf); for (const f of fields) { if (out.length >= positiveIntEnv('WINDSURFAPI_PROTO_TRACE_ERROR_STRING_LIMIT', 8)) return; if (f.wireType !== 2 || !Buffer.isBuffer(f.value)) continue; collectErrorStrings(f.value, `${path}.${f.field}`, out, depth + 1); } if (out.length > before) return; } catch {} } if (mostlyText(buf)) { out.push(summarizeErrorString(path, buf)); } } function summarizeErrorSource(field, payload) { const source = { field, bytes: payload.length, sha256: shortHash(payload), fieldNumbers: [], varints: [], strings: [], classifications: {}, }; try { const fields = parseFields(payload); source.fieldNumbers = fields.map(f => f.field); source.varints = fields .filter(f => f.wireType === 0) .slice(0, 8) .map(f => ({ field: f.field, value: typeof f.value === 'bigint' ? f.value.toString() : f.value, })); collectErrorStrings(payload, String(field), source.strings); for (const s of source.strings) { mergeErrorClassifications(source.classifications, s.classifications); } } catch (err) { source.parseError = err.message; } source.classifications = compactTrueFlags(source.classifications); return source; } function summarizeErrorStep(fields) { const sources = []; for (const fieldNum of [24, 31]) { for (const f of getAllFields(fields, fieldNum)) { if (f.wireType !== 2 || !Buffer.isBuffer(f.value)) continue; sources.push(summarizeErrorSource(fieldNum, f.value)); } } if (!sources.length) return null; const classifications = {}; for (const source of sources) { mergeErrorClassifications(classifications, source.classifications); } return { sources, classifications: compactTrueFlags(classifications), }; } function summarizeWebFetchTrajectoryBranch({ type, status, nativeOneofs, requestedInteraction, errorStep }) { const readUrlOneof = nativeOneofs.find(x => x.kind === 'read_url_content') || null; const pendingReadUrl = requestedInteraction?.interactions?.find(x => x.kind === 'read_url_content') || null; const classifications = errorStep?.classifications || {}; const hasWebDocument = !!readUrlOneof?.body?.webDocument; const legacySummaryBytes = readUrlOneof?.body?.legacySummaryBytes || 0; const hasLegacySummary = legacySummaryBytes > 0; const autoRunDecision = readUrlOneof?.body?.autoRunDecision ?? null; let state = null; if (readUrlOneof && hasWebDocument) state = 'completed_web_document'; else if (pendingReadUrl) state = 'pending_permission'; else if (readUrlOneof && autoRunDecision != null && !hasWebDocument && !hasLegacySummary) state = 'auto_run_decision_only'; else if (readUrlOneof && hasLegacySummary && !hasWebDocument) state = 'legacy_summary_only'; else if (readUrlOneof) state = 'native_oneof_no_document'; else if (type === 17 && (classifications.permissionDenied || classifications.failedPrecondition)) state = 'error'; if (!state) return null; return { state, stepType: type, status, hasRequestedInteraction: !!pendingReadUrl, hasReadUrlOneof: !!readUrlOneof, hasWebDocument, legacySummaryBytes, autoRunDecision, readUrlFieldNumbers: readUrlOneof?.body?.fieldNumbers || [], requestedFieldNumbers: pendingReadUrl?.body?.fieldNumbers || [], errorClassifications: compactTrueFlags(classifications), }; } function summarizeTrajectoryStep(stepBuf, index) { const fields = parseFields(stepBuf); const oneofFields = []; for (const [fieldNum, kind] of NATIVE_STEP_FIELDS) { const oneof = getField(fields, fieldNum, 2); if (!oneof) continue; oneofFields.push({ field: fieldNum, kind, bodyBytes: oneof.value.length, body: summarizeNativeStepBody(kind, oneof.value), }); } const interestingFields = fields .filter(f => f.wireType === 2 && ![5, 56].includes(f.field)) .slice(0, positiveIntEnv('WINDSURFAPI_PROTO_TRACE_SEMANTIC_FIELD_LIMIT', 12)) .map(f => ({ field: f.field, ...summarizeMessageChildren(f.value, 8), })); const type = numberField(fields, 1); const status = numberField(fields, 4); // type 14 is CortexStepType.USER_INPUT; field 19 may carry the observed // read-wrapper shape that the inbound parser promotes to a view_file call. const wrapper19 = type === 14 ? getField(fields, 19, 2) : null; const requestedInteraction = getField(fields, 56, 2); const requestedInteractionSummary = requestedInteraction ? summarizeRequestedInteraction(requestedInteraction.value) : null; const errorStep = summarizeErrorStep(fields); const webFetchTrace = summarizeWebFetchTrajectoryBranch({ type, status, nativeOneofs: oneofFields, requestedInteraction: requestedInteractionSummary, errorStep, }); return { index, type, status, fieldNumbers: fields.map(f => f.field), nativeOneofs: oneofFields, messageFields: interestingFields, ...(wrapper19 ? { readWrapperField19: summarizeReadWrapperField19(wrapper19.value) } : {}), ...(requestedInteractionSummary ? { requestedInteraction: requestedInteractionSummary } : {}), ...(webFetchTrace ? { webFetchTrace } : {}), ...(errorStep ? { errorStep } : {}), }; } function summarizeGetCascadeTrajectorySteps(payload) { const top = parseFields(payload); return { stepCount: countRepeatedMessageFields(top, 1), steps: getAllFields(top, 1) .filter(f => f.wireType === 2) .slice(0, positiveIntEnv('WINDSURFAPI_PROTO_TRACE_SEMANTIC_STEP_LIMIT', 40)) .map((f, index) => summarizeTrajectoryStep(f.value, index)), }; } function semanticSummary(method, direction, payload) { try { if (method === 'SendUserCascadeMessage' && direction === 'request') { return summarizeSendUserCascadeMessage(payload); } if (method === 'GetCascadeTrajectorySteps' && direction === 'response') { return summarizeGetCascadeTrajectorySteps(payload); } if (method === 'HandleCascadeUserInteraction' && direction === 'request') { return summarizeHandleCascadeUserInteraction(payload); } } catch (err) { return { error: err.message }; } return null; } function summarizeBytes(buf, depth, maxDepth) { const bytes = buf.length; const hash = shortHash(buf); if (depth < maxDepth) { try { const parsed = parseFields(buf); if (parsed.length && parsed.every(f => f.field > 0)) { const children = summarizeProtoForTrace(buf, { depth: depth + 1, maxDepth }); if (children.length) return { type: 'message', bytes, sha256: hash, fields: children }; } } catch {} } if (mostlyText(buf)) { const out = { type: 'string', bytes, sha256: hash }; if (process.env.WINDSURFAPI_PROTO_TRACE_STRINGS === '1') { out.preview = redactPreview(buf.toString('utf8')); } return out; } if (depth >= maxDepth) return { type: 'bytes', bytes, sha256: hash, truncatedDepth: true }; try { const children = summarizeProtoForTrace(buf, { depth: depth + 1, maxDepth }); if (children.length) return { type: 'message', bytes, sha256: hash, fields: children }; } catch {} return { type: 'bytes', bytes, sha256: hash }; } export function summarizeProtoForTrace(buf, opts = {}) { const depth = opts.depth || 0; const maxDepth = opts.maxDepth || positiveIntEnv('WINDSURFAPI_PROTO_TRACE_DEPTH', 8); const fields = parseFields(Buffer.isBuffer(buf) ? buf : Buffer.from(buf || [])); return fields.map((f) => { const out = { field: f.field, wireType: f.wireType }; if (f.wireType === 0) { out.type = 'varint'; out.value = typeof f.value === 'bigint' ? f.value.toString() : f.value; } else if (f.wireType === 1 || f.wireType === 5) { out.type = f.wireType === 1 ? 'fixed64' : 'fixed32'; out.bytes = f.value.length; out.hex = f.value.toString('hex'); } else if (f.wireType === 2) { Object.assign(out, summarizeBytes(f.value, depth, maxDepth)); } return out; }); } export function unwrapTracePayload(body, transport = 'grpc') { const buf = Buffer.isBuffer(body) ? body : Buffer.from(body || []); if (transport === 'connect') { if (buf.length >= 5) { const flags = buf[0]; const len = buf.readUInt32BE(1); if (len === buf.length - 5) { let payload = buf.subarray(5); if (flags & 0x01) payload = gunzipSync(payload); return payload; } } return buf; } if (buf.length >= 5 && buf[0] === 0) { const len = buf.readUInt32BE(1); if (len <= buf.length - 5) return buf.subarray(5, 5 + len); } return buf; } export function traceGrpcPayload({ port, path, direction, body, transport = 'grpc', framed = false } = {}) { if (!enabled()) return; try { const payload = framed ? unwrapTracePayload(body, transport) : (Buffer.isBuffer(body) ? body : Buffer.from(body || [])); const maxBytes = positiveIntEnv('WINDSURFAPI_PROTO_TRACE_MAX_BYTES', 512 * 1024); const record = { ts: new Date().toISOString(), seq: ++_seq, pid: process.pid, port, path, method: safeMethodName(path), direction, transport, payloadBytes: payload.length, payloadSha256: shortHash(payload), }; const semantic = semanticSummary(record.method, direction, payload); if (semantic) record.semantic = semantic; if (payload.length > maxBytes) { record.skipped = `payload exceeds ${maxBytes} bytes`; } else { record.fields = summarizeProtoForTrace(payload); } const dir = traceDir(); mkdirSync(dir, { recursive: true }); const file = join(dir, `ls-proto-${process.pid}-${safeMethodName(path)}.jsonl`); appendFileSync(file, JSON.stringify(record) + '\n'); } catch (err) { try { const dir = traceDir(); mkdirSync(dir, { recursive: true }); appendFileSync(join(dir, `ls-proto-${process.pid}-errors.log`), `${new Date().toISOString()} ${path || ''} ${direction || ''}: ${err.message}\n`); } catch {} } } export function _resetProtoTraceForTests() { _seq = 0; }