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/README.md +4 -4
- package/index.mjs +70 -0
- package/package.json +1 -1
- package/src/artifact-cli.mjs +67 -0
- package/src/artifact-recovery.mjs +133 -0
- package/src/artifact-store.mjs +43 -0
- package/src/context-audit-cli.mjs +104 -0
- package/src/context-capture.mjs +200 -0
- package/src/context-classifier.mjs +142 -0
- package/src/context-footprint.mjs +299 -0
- package/src/context-transform.mjs +18 -1
- package/src/core.mjs +18 -2
- package/src/f1-telemetry.mjs +80 -0
- package/src/f4-telemetry.mjs +183 -0
- package/src/gateway-gate-cli.mjs +88 -0
- package/src/gateway-gate.mjs +412 -0
- package/src/history-disclosure.mjs +70 -0
- package/src/lazy-mcp-gateway-stdio.mjs +59 -0
- package/src/lazy-mcp-gateway.mjs +291 -0
- package/src/mcp-server.mjs +31 -5
- package/src/proxy.mjs +424 -15
- package/src/result-disclosure.mjs +109 -0
- package/src/statusline.mjs +8 -19
- package/src/telemetry.mjs +61 -17
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 (
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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"
|
|
125
|
-
|
|
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({
|
|
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
|
-
|
|
212
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
+
}
|