sandoichi 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/proxy.mjs CHANGED
@@ -1,7 +1,12 @@
1
+ import net from 'node:net';
1
2
  import http from 'node:http';
3
+ import tls from 'node:tls';
4
+ import { brotliDecompressSync, gunzipSync, inflateSync, zstdDecompressSync } from 'node:zlib';
2
5
 
3
6
  import { estimateTokens } from './core.mjs';
7
+ import { buildContextCaptureRecord, recordContextCapture } from './context-capture.mjs';
4
8
  import { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './context-transform.mjs';
9
+ import { publishF1Telemetry } from './f1-telemetry.mjs';
5
10
  import { recordProxyRequest } from './proxy-metrics.mjs';
6
11
  import {
7
12
  closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, isDoNotTrack, readTelemetryConfig, recordFailure,
@@ -58,8 +63,10 @@ const DEFAULT_MAX_BODY_BYTES = 16 * 1024 * 1024;
58
63
  const HOP_BY_HOP_HEADERS = new Set([
59
64
  'connection', 'content-length', 'keep-alive', 'proxy-authenticate',
60
65
  'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host',
61
- 'accept-encoding', 'content-encoding',
66
+ 'accept-encoding', 'content-encoding', 'x-sando-session-key',
62
67
  ]);
68
+ const REQUEST_EXCLUDED_HEADERS = new Set(HOP_BY_HOP_HEADERS);
69
+ REQUEST_EXCLUDED_HEADERS.delete('content-encoding');
63
70
 
64
71
  function assertUpstream(value) {
65
72
  let url;
@@ -99,13 +106,32 @@ function targetUrl(upstream, requestUrl) {
99
106
  function forwardedHeaders(request) {
100
107
  const headers = new Headers();
101
108
  for (const [name, value] of Object.entries(request.headers)) {
102
- if (HOP_BY_HOP_HEADERS.has(name.toLowerCase()) || value === undefined) continue;
109
+ if (REQUEST_EXCLUDED_HEADERS.has(name.toLowerCase()) || value === undefined) continue;
103
110
  headers.set(name, Array.isArray(value) ? value.join(', ') : value);
104
111
  }
105
112
  headers.set('accept-encoding', 'identity');
106
113
  return headers;
107
114
  }
108
115
 
116
+ function decodeRequestBody(rawBody, contentEncoding) {
117
+ const encodings = String(contentEncoding ?? '')
118
+ .split(',')
119
+ .map((encoding) => encoding.trim().toLowerCase())
120
+ .filter((encoding) => encoding && encoding !== 'identity');
121
+ if (encodings.length === 0) return rawBody;
122
+ try {
123
+ return encodings.reverse().reduce((body, encoding) => {
124
+ if (encoding === 'gzip' || encoding === 'x-gzip') return gunzipSync(body);
125
+ if (encoding === 'deflate') return inflateSync(body);
126
+ if (encoding === 'br') return brotliDecompressSync(body);
127
+ if (encoding === 'zstd') return zstdDecompressSync(body);
128
+ throw new Error(`unsupported request content encoding: ${encoding}`);
129
+ }, rawBody);
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
109
135
  function responseHeaders(response) {
110
136
  const headers = {};
111
137
  response.headers.forEach((value, name) => {
@@ -116,17 +142,70 @@ function responseHeaders(response) {
116
142
 
117
143
  const MAX_USAGE_SCAN_BYTES = 2 * 1024 * 1024;
118
144
 
119
- /** Merges every `"usage":{...}` object seen across a (possibly streamed) Anthropic
120
- * response: `message_start` carries input/cache token counts, `message_delta`
121
- * carries the final output count, and later objects overwrite matching keys. */
145
+ function readJsonObject(text, start) {
146
+ if (text[start] !== '{') return null;
147
+ let depth = 0;
148
+ let quoted = false;
149
+ let escaped = false;
150
+ for (let index = start; index < text.length; index += 1) {
151
+ const character = text[index];
152
+ if (quoted) {
153
+ if (escaped) escaped = false;
154
+ else if (character === '\\') escaped = true;
155
+ else if (character === '"') quoted = false;
156
+ continue;
157
+ }
158
+ if (character === '"') {
159
+ quoted = true;
160
+ continue;
161
+ }
162
+ if (character === '{') depth += 1;
163
+ else if (character === '}') {
164
+ depth -= 1;
165
+ if (depth === 0) return text.slice(start, index + 1);
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+
171
+ /** Merges every usage object seen across a streamed provider response. */
122
172
  function extractUsage(text) {
123
173
  let usage = null;
124
- for (const match of text.matchAll(/"usage":\s*(\{[^{}]*\})/g)) {
125
- try { usage = { ...usage, ...JSON.parse(match[1]) }; } catch { /* ignore malformed fragment */ }
174
+ for (const match of text.matchAll(/"usage"\s*:/g)) {
175
+ const start = text.indexOf('{', match.index + match[0].length);
176
+ const fragment = start < 0 ? null : readJsonObject(text, start);
177
+ if (!fragment) continue;
178
+ try { usage = { ...usage, ...JSON.parse(fragment) }; } catch { /* ignore malformed fragment */ }
126
179
  }
127
180
  return usage;
128
181
  }
129
182
 
183
+ function detectCaptureProvider(body, headers) {
184
+ const provider = detectProviderBody(body, headers);
185
+ if (provider) return provider;
186
+ if (Array.isArray(body?.input) && typeof body.prompt_cache_key === 'string' && body.prompt_cache_key.length > 0) {
187
+ return 'openai-responses';
188
+ }
189
+ return null;
190
+ }
191
+
192
+ function resolveContextSessionKey(value, { provider, body, headers }) {
193
+ let candidate = value;
194
+ if (typeof value === 'function') {
195
+ try { candidate = value({ provider, body, headers }); } catch { return null; }
196
+ }
197
+ if (typeof candidate === 'string' && candidate.length > 0) return candidate;
198
+ const header = headers['x-sando-session-key'];
199
+ if (typeof header === 'string' && header.length > 0) return header;
200
+ if (provider === 'anthropic' && typeof body?.metadata?.user_id === 'string' && body.metadata.user_id.length > 0) {
201
+ return `anthropic-metadata-user:${body.metadata.user_id}`;
202
+ }
203
+ if (provider === 'openai-responses' && typeof body?.prompt_cache_key === 'string' && body.prompt_cache_key.length > 0) {
204
+ return `openai-responses-prompt-cache:${body.prompt_cache_key}`;
205
+ }
206
+ return null;
207
+ }
208
+
130
209
  async function pipeResponse(response, outgoing, onText) {
131
210
  outgoing.writeHead(response.status, response.statusText, responseHeaders(response));
132
211
  if (!response.body) {
@@ -151,6 +230,151 @@ function jsonResponse(outgoing, status, body) {
151
230
  outgoing.end(text);
152
231
  }
153
232
 
233
+ const MAX_WEBSOCKET_HANDSHAKE_BYTES = 64 * 1024;
234
+ const MAX_WEBSOCKET_MESSAGE_BYTES = 16 * 1024 * 1024;
235
+
236
+ function connectWebSocketUpstream(target) {
237
+ return new Promise((resolve, reject) => {
238
+ const options = {
239
+ host: target.hostname,
240
+ port: Number(target.port) || (target.protocol === 'https:' ? 443 : 80),
241
+ ...(target.protocol === 'https:' ? { servername: target.hostname } : {}),
242
+ };
243
+ let connected = false;
244
+ const onError = (error) => {
245
+ if (!connected) reject(error);
246
+ };
247
+ const socket = target.protocol === 'https:'
248
+ ? tls.connect(options, () => {
249
+ connected = true;
250
+ socket.off('error', onError);
251
+ resolve(socket);
252
+ })
253
+ : net.connect(options, () => {
254
+ connected = true;
255
+ socket.off('error', onError);
256
+ resolve(socket);
257
+ });
258
+ socket.once('error', onError);
259
+ });
260
+ }
261
+
262
+ function websocketUpgradeRequest(request, target) {
263
+ const excluded = new Set([
264
+ 'accept-encoding', 'connection', 'content-length', 'content-encoding',
265
+ 'host', 'sec-websocket-extensions', 'transfer-encoding', 'upgrade',
266
+ 'x-sando-session-key',
267
+ ]);
268
+ const lines = [
269
+ `GET ${target.pathname}${target.search} HTTP/1.1`,
270
+ `Host: ${target.host}`,
271
+ 'Connection: Upgrade',
272
+ 'Upgrade: websocket',
273
+ ];
274
+ for (const [name, value] of Object.entries(request.headers)) {
275
+ if (excluded.has(name.toLowerCase()) || value === undefined) continue;
276
+ const values = Array.isArray(value) ? value : [value];
277
+ for (const item of values) lines.push(`${name}: ${item}`);
278
+ }
279
+ return Buffer.from(`${lines.join('\r\n')}\r\n\r\n`, 'utf8');
280
+ }
281
+
282
+ function websocketInspector(onMessage) {
283
+ let buffered = Buffer.alloc(0);
284
+ let fragments = [];
285
+ let fragmentBytes = 0;
286
+
287
+ function emit(payload) {
288
+ if (payload.length > MAX_WEBSOCKET_MESSAGE_BYTES) return;
289
+ try { onMessage(payload.toString('utf8')); } catch { /* inspection is best-effort */ }
290
+ }
291
+
292
+ return (chunk) => {
293
+ if (!Buffer.isBuffer(chunk) || chunk.length === 0) return;
294
+ buffered = buffered.length === 0 ? chunk : Buffer.concat([buffered, chunk]);
295
+ while (buffered.length >= 2) {
296
+ const first = buffered[0];
297
+ const second = buffered[1];
298
+ const masked = (second & 0x80) !== 0;
299
+ let length = second & 0x7f;
300
+ let offset = 2;
301
+ if (length === 126) {
302
+ if (buffered.length < 4) return;
303
+ length = buffered.readUInt16BE(2);
304
+ offset = 4;
305
+ } else if (length === 127) {
306
+ if (buffered.length < 10) return;
307
+ const longLength = buffered.readBigUInt64BE(2);
308
+ if (longLength > BigInt(MAX_WEBSOCKET_MESSAGE_BYTES)) {
309
+ buffered = Buffer.alloc(0);
310
+ fragments = [];
311
+ fragmentBytes = 0;
312
+ return;
313
+ }
314
+ length = Number(longLength);
315
+ offset = 10;
316
+ }
317
+ const maskOffset = masked ? offset : 0;
318
+ const frameBytes = offset + (masked ? 4 : 0) + length;
319
+ if (buffered.length < frameBytes) return;
320
+ const frame = buffered.subarray(0, frameBytes);
321
+ buffered = buffered.subarray(frameBytes);
322
+ let payload = frame.subarray(offset + (masked ? 4 : 0));
323
+ if (masked) {
324
+ payload = Buffer.from(payload);
325
+ const mask = frame.subarray(maskOffset, maskOffset + 4);
326
+ for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4];
327
+ }
328
+ const opcode = first & 0x0f;
329
+ const fin = (first & 0x80) !== 0;
330
+ if (opcode === 0x1 || opcode === 0x2) {
331
+ fragments = [payload];
332
+ fragmentBytes = payload.length;
333
+ if (fin) {
334
+ emit(payload);
335
+ fragments = [];
336
+ fragmentBytes = 0;
337
+ }
338
+ } else if (opcode === 0x0 && fragments.length > 0) {
339
+ fragmentBytes += payload.length;
340
+ if (fragmentBytes > MAX_WEBSOCKET_MESSAGE_BYTES) {
341
+ fragments = [];
342
+ fragmentBytes = 0;
343
+ } else {
344
+ fragments.push(payload);
345
+ if (fin) {
346
+ emit(Buffer.concat(fragments));
347
+ fragments = [];
348
+ fragmentBytes = 0;
349
+ }
350
+ }
351
+ }
352
+ }
353
+ };
354
+ }
355
+
356
+ function findResponsesRequest(value, seen = new Set(), depth = 0) {
357
+ if (depth > 8 || value === null || typeof value !== 'object' || seen.has(value)) return null;
358
+ seen.add(value);
359
+ if (!Array.isArray(value) && Array.isArray(value.input)
360
+ && typeof value.prompt_cache_key === 'string' && value.prompt_cache_key.length > 0) return value;
361
+ const values = Array.isArray(value) ? value : Object.values(value);
362
+ for (const child of values) {
363
+ const found = findResponsesRequest(child, seen, depth + 1);
364
+ if (found) return found;
365
+ }
366
+ return null;
367
+ }
368
+
369
+ function websocketCompletion(text) {
370
+ try {
371
+ const value = JSON.parse(text);
372
+ return value?.type === 'response.completed' || value?.type === 'response.done';
373
+ } catch {
374
+ return false;
375
+ }
376
+ }
377
+
154
378
  function createSemanticStats(candidates) {
155
379
  return {
156
380
  candidates: candidates.length,
@@ -181,12 +405,50 @@ async function observeSemanticCandidates({ provider, candidates, semanticCompact
181
405
  }
182
406
  }
183
407
 
184
- export async function createProviderProxy({ upstream, host = '127.0.0.1', port = 0, policy = {}, maxBodyBytes = DEFAULT_MAX_BODY_BYTES, semanticCompactor, metricsPath, env = process.env } = {}) {
408
+ export async function createProviderProxy({
409
+ upstream, host = '127.0.0.1', port = 0, policy = {}, maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
410
+ semanticCompactor, metricsPath, contextCapturePath, contextCaptureHost, contextSessionKey,
411
+ f1TelemetryPublisher = publishF1Telemetry, transformProviderRequests = true,
412
+ env = process.env,
413
+ } = {}) {
185
414
  const upstreamUrl = assertUpstream(upstream);
186
415
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new TypeError('port is invalid');
187
416
  if (!Number.isInteger(maxBodyBytes) || maxBodyBytes < 1024) throw new TypeError('maxBodyBytes is invalid');
417
+ if (typeof transformProviderRequests !== 'boolean') throw new TypeError('transformProviderRequests is invalid');
188
418
  let lastStats = null;
189
419
  let lastRequestAt = null;
420
+ const capturedContextSessions = new Set();
421
+ const upgradedSockets = new Set();
422
+
423
+ function persistCapture({ provider, body, rawBody, sessionKey, model, providerUsage }) {
424
+ if (!contextCapturePath || !sessionKey) return false;
425
+ try {
426
+ const record = buildContextCaptureRecord({
427
+ host: contextCaptureHost ?? (provider === 'anthropic' ? 'claude' : 'codex'),
428
+ provider,
429
+ rawBody,
430
+ requestBody: body,
431
+ sessionKey,
432
+ model,
433
+ providerUsage,
434
+ });
435
+ if (!record) return false;
436
+ if (capturedContextSessions.has(record.sessionKeyDigest)) return true;
437
+ const reported = record.report.tokenAccounting.providerReported;
438
+ if (providerUsage !== undefined && providerUsage !== null && !reported) return false;
439
+ if (reported?.outputTokens === 0) return false;
440
+ recordContextCapture({ storagePath: contextCapturePath, record });
441
+ capturedContextSessions.add(record.sessionKeyDigest);
442
+ if (env.SANDO_F1_TELEMETRY === '1' && typeof f1TelemetryPublisher === 'function') {
443
+ try {
444
+ Promise.resolve(f1TelemetryPublisher({ record, endpoint: env.SANDO_F1_TELEMETRY_ENDPOINT }))
445
+ .catch(() => {});
446
+ } catch { /* local telemetry must never affect the proxied response */ }
447
+ }
448
+ return true;
449
+ } catch { /* capture is best-effort and must never affect the proxied response */ }
450
+ return false;
451
+ }
190
452
 
191
453
  const server = http.createServer(async (request, outgoing) => {
192
454
  let failureProvider = null;
@@ -205,21 +467,33 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
205
467
  return;
206
468
  }
207
469
  let body = rawBody;
470
+ const inspectionBody = decodeRequestBody(rawBody, request.headers['content-encoding']);
208
471
  let recordProvider = null;
209
472
  let recordModel = null;
210
473
  let recordStats = null;
211
- if (rawBody.length > 0 && /application\/json/i.test(request.headers['content-type'] ?? '')) {
212
- let parsed;
474
+ let captureProvider = null;
475
+ let captureModel = null;
476
+ let captureSessionKey = null;
477
+ let parsed;
478
+ if (inspectionBody?.length > 0 && /application\/json/i.test(request.headers['content-type'] ?? '')) {
213
479
  try {
214
- parsed = JSON.parse(rawBody.toString('utf8'));
480
+ parsed = JSON.parse(inspectionBody.toString('utf8'));
215
481
  } catch {
216
482
  recordProxyFailure({ env, provider: null, failureStage: 'input' });
217
483
  }
218
484
  if (parsed) {
219
485
  const provider = detectProviderBody(parsed, request.headers);
220
- failureProvider = provider;
486
+ const observedProvider = detectCaptureProvider(parsed, request.headers);
487
+ failureProvider = provider ?? observedProvider;
488
+ if (observedProvider) {
489
+ captureProvider = observedProvider;
490
+ captureModel = typeof parsed?.model === 'string' ? parsed.model : null;
491
+ captureSessionKey = resolveContextSessionKey(contextSessionKey, {
492
+ provider: observedProvider, body: parsed, headers: request.headers,
493
+ });
494
+ }
221
495
  try {
222
- if (provider) {
496
+ if (provider && transformProviderRequests && inspectionBody === rawBody) {
223
497
  const now = Date.now();
224
498
  const idleMs = lastRequestAt === null ? null : now - lastRequestAt;
225
499
  lastRequestAt = now;
@@ -263,7 +537,8 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
263
537
  }
264
538
  let responseText = '';
265
539
  try {
266
- await pipeResponse(response, outgoing, metricsPath ? (chunk) => { responseText += chunk; } : undefined);
540
+ const observeResponse = metricsPath || (contextCapturePath && captureProvider);
541
+ await pipeResponse(response, outgoing, observeResponse ? (chunk) => { responseText += chunk; } : undefined);
267
542
  } catch {
268
543
  recordProxyFailure({ env, provider: failureProvider, failureStage: 'response' });
269
544
  if (!outgoing.headersSent) jsonResponse(outgoing, 502, { error: 'sando proxy upstream failure' });
@@ -278,6 +553,14 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
278
553
  });
279
554
  } catch { /* metrics are best-effort and must never affect the proxied response */ }
280
555
  }
556
+ persistCapture({
557
+ provider: captureProvider,
558
+ body: parsed,
559
+ rawBody: inspectionBody,
560
+ sessionKey: captureSessionKey,
561
+ model: captureModel,
562
+ providerUsage: extractUsage(responseText),
563
+ });
281
564
  } catch (error) {
282
565
  recordProxyFailure({ env, provider: failureProvider, failureStage: 'response' });
283
566
  if (!outgoing.headersSent) jsonResponse(outgoing, error.message === 'request body exceeds proxy limit' ? 413 : 502, { error: 'sando proxy upstream failure' });
@@ -285,6 +568,129 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
285
568
  }
286
569
  });
287
570
 
571
+ server.on('upgrade', async (request, client, head) => {
572
+ upgradedSockets.add(client);
573
+ let upstreamSocket;
574
+ let established = false;
575
+ let closed = false;
576
+ let upstreamBuffer = Buffer.alloc(0);
577
+ const clientQueue = head?.length ? [Buffer.from(head)] : [];
578
+ let requestBody = null;
579
+ let requestBodyRaw = null;
580
+ let requestSessionKey = null;
581
+ let requestModel = null;
582
+ let responseUsage = null;
583
+ let captured = false;
584
+
585
+ const captureMessage = (text, direction) => {
586
+ if (!contextCapturePath) return;
587
+ if (direction === 'client') {
588
+ let parsed;
589
+ try { parsed = JSON.parse(text); } catch { return; }
590
+ const candidate = findResponsesRequest(parsed);
591
+ if (!candidate) return;
592
+ requestBody = candidate;
593
+ requestBodyRaw = Buffer.from(JSON.stringify(candidate), 'utf8');
594
+ requestModel = typeof candidate.model === 'string' ? candidate.model : null;
595
+ requestSessionKey = resolveContextSessionKey(contextSessionKey, {
596
+ provider: 'openai-responses', body: candidate, headers: request.headers,
597
+ });
598
+ } else {
599
+ const usage = extractUsage(text);
600
+ if (usage) responseUsage = { ...responseUsage, ...usage };
601
+ if (websocketCompletion(text)) {
602
+ captured = persistCapture({
603
+ provider: 'openai-responses',
604
+ body: requestBody,
605
+ rawBody: requestBodyRaw,
606
+ sessionKey: requestSessionKey,
607
+ model: requestModel,
608
+ providerUsage: responseUsage,
609
+ });
610
+ }
611
+ }
612
+ };
613
+
614
+ const inspectClient = websocketInspector((text) => captureMessage(text, 'client'));
615
+ const inspectUpstream = websocketInspector((text) => captureMessage(text, 'upstream'));
616
+
617
+ const destroy = () => {
618
+ if (closed) return;
619
+ closed = true;
620
+ client.destroy();
621
+ upstreamSocket?.destroy();
622
+ };
623
+
624
+ const forwardQueuedClientData = () => {
625
+ for (const chunk of clientQueue.splice(0)) {
626
+ inspectClient(chunk);
627
+ if (!upstreamSocket.destroyed) upstreamSocket.write(chunk);
628
+ }
629
+ };
630
+
631
+ const onClientData = (chunk) => {
632
+ if (!established) {
633
+ clientQueue.push(Buffer.from(chunk));
634
+ return;
635
+ }
636
+ inspectClient(chunk);
637
+ if (!upstreamSocket.destroyed) upstreamSocket.write(chunk);
638
+ };
639
+
640
+ client.on('data', onClientData);
641
+ client.once('error', destroy);
642
+ client.once('close', () => {
643
+ upgradedSockets.delete(client);
644
+ if (!captured) persistCapture({
645
+ provider: 'openai-responses', body: requestBody, rawBody: requestBodyRaw,
646
+ sessionKey: requestSessionKey, model: requestModel, providerUsage: responseUsage,
647
+ });
648
+ destroy();
649
+ });
650
+
651
+ try {
652
+ const target = targetUrl(upstreamUrl, request.url);
653
+ upstreamSocket = await connectWebSocketUpstream(target);
654
+ upgradedSockets.add(upstreamSocket);
655
+ upstreamSocket.once('error', destroy);
656
+ upstreamSocket.once('close', () => {
657
+ upgradedSockets.delete(upstreamSocket);
658
+ if (!closed) client.destroy();
659
+ });
660
+ const upgrade = websocketUpgradeRequest(request, target);
661
+ let handshakeComplete = false;
662
+ upstreamSocket.on('data', (chunk) => {
663
+ if (!handshakeComplete) {
664
+ upstreamBuffer = upstreamBuffer.length === 0 ? chunk : Buffer.concat([upstreamBuffer, chunk]);
665
+ if (upstreamBuffer.length > MAX_WEBSOCKET_HANDSHAKE_BYTES) {
666
+ destroy();
667
+ return;
668
+ }
669
+ const boundary = upstreamBuffer.indexOf('\r\n\r\n');
670
+ if (boundary < 0) return;
671
+ const handshake = upstreamBuffer;
672
+ upstreamBuffer = Buffer.alloc(0);
673
+ handshakeComplete = /^HTTP\/1\.1 101\b/m.test(handshake.subarray(0, boundary).toString('latin1'));
674
+ client.write(handshake);
675
+ if (!handshakeComplete) {
676
+ destroy();
677
+ return;
678
+ }
679
+ established = true;
680
+ inspectUpstream(handshake.subarray(boundary + 4));
681
+ forwardQueuedClientData();
682
+ return;
683
+ }
684
+ inspectUpstream(chunk);
685
+ if (!client.destroyed) client.write(chunk);
686
+ });
687
+ upstreamSocket.write(upgrade);
688
+ } catch {
689
+ destroy();
690
+ return;
691
+ }
692
+ });
693
+
288
694
  await new Promise((resolve, reject) => {
289
695
  server.once('error', reject);
290
696
  server.listen(port, host, resolve);
@@ -297,6 +703,9 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
297
703
  port: actualPort,
298
704
  url: `http://${host}:${actualPort}`,
299
705
  get lastStats() { return lastStats; },
300
- close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
706
+ close: () => new Promise((resolve, reject) => {
707
+ for (const socket of upgradedSockets) socket.destroy();
708
+ server.close((error) => error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve());
709
+ }),
301
710
  };
302
711
  }
@@ -0,0 +1,109 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const RESULT_DISCLOSURE_SCHEMA = 'sando-result-disclosure/v1';
4
+ export const RESULT_DISCLOSURE_VERSION = 1;
5
+ export const ARTIFACT_TOOL_NAME = 'sando_artifact_get';
6
+
7
+ function sha256(text) {
8
+ return `sha256:${createHash('sha256').update(text).digest('hex')}`;
9
+ }
10
+
11
+ function stableJson(value, seen = new Set()) {
12
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
13
+ if (seen.has(value)) throw new TypeError('result disclosure must not be cyclic');
14
+ seen.add(value);
15
+ const result = Array.isArray(value)
16
+ ? `[${value.map((item) => stableJson(item, seen)).join(',')}]`
17
+ : `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key], seen)}`).join(',')}}`;
18
+ seen.delete(value);
19
+ return result;
20
+ }
21
+
22
+ function resultType(toolName) {
23
+ const name = typeof toolName === 'string' ? toolName.toLowerCase() : '';
24
+ if (name === 'read') return 'read';
25
+ if (name === 'grep') return 'grep';
26
+ if (name === 'bash' || name === 'exec') return 'bash';
27
+ if (name === 'log') return 'log';
28
+ return 'mcp';
29
+ }
30
+
31
+ function policyName(type, route) {
32
+ if (type === 'read') return route === 'summary' ? 'read-structure' : 'read-bounded';
33
+ if (type === 'grep') return 'grep-matches';
34
+ if (type === 'bash') return 'bash-head-tail';
35
+ if (type === 'log') return 'log-head-tail';
36
+ return 'mcp-bounded';
37
+ }
38
+
39
+ function markers(inline, artifact) {
40
+ const result = [];
41
+ if (artifact) result.push('artifact-handle');
42
+ if (inline.includes('[middle elided]')) result.push('middle-elision');
43
+ if (inline.includes('[sando read structure:')) result.push('structure-preview');
44
+ if (inline.includes('[sando repeated x')) result.push('repetition-elision');
45
+ return result;
46
+ }
47
+
48
+ function bytes(value, name) {
49
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} must be a non-negative safe integer`);
50
+ return value;
51
+ }
52
+
53
+ export function buildResultDisclosure({
54
+ toolName, route, reason, inline, redactedText, inputBytes, redactedBytes, artifact,
55
+ } = {}) {
56
+ if (typeof toolName !== 'string' || !toolName || typeof route !== 'string' || !route
57
+ || typeof reason !== 'string' || !reason || typeof inline !== 'string' || typeof redactedText !== 'string') {
58
+ throw new TypeError('result disclosure input is invalid');
59
+ }
60
+ const original = bytes(inputBytes ?? Buffer.byteLength(redactedText), 'inputBytes');
61
+ const redacted = bytes(redactedBytes ?? Buffer.byteLength(redactedText), 'redactedBytes');
62
+ const visible = Buffer.byteLength(inline);
63
+ const provenanceDigest = sha256(redactedText);
64
+ if (artifact !== undefined && artifact !== null) {
65
+ const validRef = typeof artifact.ref === 'string' && /^sando:sha256:[a-f0-9]{16,64}$/.test(artifact.ref);
66
+ const refDigest = validRef ? artifact.ref.slice('sando:'.length) : null;
67
+ const contentValid = artifact.content === undefined
68
+ || (typeof artifact.content === 'string' && sha256(artifact.content) === provenanceDigest
69
+ && Buffer.byteLength(artifact.content) === redacted);
70
+ if (!validRef || typeof artifact.sourceDigest !== 'string'
71
+ || artifact.sourceDigest !== provenanceDigest
72
+ || !refDigest || !provenanceDigest.startsWith(refDigest)
73
+ || !Number.isSafeInteger(artifact.bytes) || artifact.bytes < 0 || artifact.bytes !== redacted
74
+ || !contentValid) {
75
+ throw new TypeError('result artifact is invalid');
76
+ }
77
+ }
78
+ const type = resultType(toolName);
79
+ const recovery = !artifact && reason === 'artifact-admission-limit'
80
+ ? { mode: 'unavailable', bounded: true }
81
+ : undefined;
82
+ return {
83
+ schema: RESULT_DISCLOSURE_SCHEMA,
84
+ version: RESULT_DISCLOSURE_VERSION,
85
+ type,
86
+ policy: policyName(type, route),
87
+ route,
88
+ reason,
89
+ provenanceDigest,
90
+ bytes: { original, redacted, visible },
91
+ markers: markers(inline, artifact),
92
+ ...(recovery ? { recovery } : {}),
93
+ artifact: artifact ? {
94
+ handle: artifact.ref,
95
+ digest: artifact.sourceDigest,
96
+ bytes: artifact.bytes,
97
+ recovery: {
98
+ tool: ARTIFACT_TOOL_NAME,
99
+ command: `sando artifact get --ref ${artifact.ref} --max-bytes 65536`,
100
+ bounded: true,
101
+ },
102
+ } : null,
103
+ };
104
+ }
105
+
106
+ export function serializeResultDisclosure(report) {
107
+ if (!report || report.schema !== RESULT_DISCLOSURE_SCHEMA) throw new TypeError('result disclosure is invalid');
108
+ return stableJson(report);
109
+ }