@juspay/neurolink 10.0.1 → 10.1.1
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/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +305 -305
- package/dist/cli/commands/proxyReplay.d.ts +3 -0
- package/dist/cli/commands/proxyReplay.js +175 -0
- package/dist/cli/parser.js +3 -1
- package/dist/lib/proxy/proxyReplay.d.ts +10 -0
- package/dist/lib/proxy/proxyReplay.js +765 -0
- package/dist/lib/proxy/requestLogger.d.ts +13 -0
- package/dist/lib/proxy/requestLogger.js +13 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +1 -0
- package/dist/lib/server/routes/claudeProxyRoutes.js +9 -2
- package/dist/lib/types/cli.d.ts +16 -0
- package/dist/lib/types/proxy.d.ts +121 -0
- package/dist/lib/utils/timeout.d.ts +1 -0
- package/dist/lib/utils/timeout.js +1 -0
- package/dist/proxy/proxyReplay.d.ts +10 -0
- package/dist/proxy/proxyReplay.js +764 -0
- package/dist/proxy/requestLogger.d.ts +13 -0
- package/dist/proxy/requestLogger.js +13 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +1 -0
- package/dist/server/routes/claudeProxyRoutes.js +9 -2
- package/dist/types/cli.d.ts +16 -0
- package/dist/types/proxy.d.ts +121 -0
- package/dist/utils/timeout.d.ts +1 -0
- package/dist/utils/timeout.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants, createReadStream } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink, } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { gunzipSync } from "node:zlib";
|
|
8
|
+
import { prepareProxyBodyForLogging, redactProxyHeadersForLogging, } from "./requestLogger.js";
|
|
9
|
+
const DEBUG_FILE_PATTERN = /^proxy-debug-\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
10
|
+
const BUNDLE_KIND = "neurolink.proxy.replay-bundle";
|
|
11
|
+
const COMPARISON_KIND = "neurolink.proxy.replay-comparison";
|
|
12
|
+
const MAX_COMPRESSED_ARTIFACT_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
|
|
15
|
+
const MAX_DIRECT_RESPONSE_BYTES = 1024 * 1024;
|
|
16
|
+
const REDACTED_VALUE = "[REDACTED]";
|
|
17
|
+
const TRUSTED_CAPTURED_ENDPOINTS = new Set([
|
|
18
|
+
"https://api.anthropic.com/v1/messages?beta=true",
|
|
19
|
+
]);
|
|
20
|
+
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
21
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
22
|
+
const SENSITIVE_QUERY_NAME_PATTERN = /token|secret|key|password|credential|authorization/i;
|
|
23
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
24
|
+
"connection",
|
|
25
|
+
"content-length",
|
|
26
|
+
"host",
|
|
27
|
+
"keep-alive",
|
|
28
|
+
"proxy-authenticate",
|
|
29
|
+
"proxy-authorization",
|
|
30
|
+
"te",
|
|
31
|
+
"trailer",
|
|
32
|
+
"transfer-encoding",
|
|
33
|
+
"upgrade",
|
|
34
|
+
]);
|
|
35
|
+
export function isValidProxyReplayHeaderName(name) {
|
|
36
|
+
return HEADER_NAME_PATTERN.test(name);
|
|
37
|
+
}
|
|
38
|
+
function sha256(value) {
|
|
39
|
+
return createHash("sha256").update(value).digest("hex");
|
|
40
|
+
}
|
|
41
|
+
function stringValue(value) {
|
|
42
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
43
|
+
}
|
|
44
|
+
function finiteNumber(value) {
|
|
45
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
46
|
+
}
|
|
47
|
+
function recordValue(value) {
|
|
48
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
49
|
+
? value
|
|
50
|
+
: null;
|
|
51
|
+
}
|
|
52
|
+
function headersValue(value) {
|
|
53
|
+
const record = recordValue(value);
|
|
54
|
+
if (!record) {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
return Object.fromEntries(Object.entries(record)
|
|
58
|
+
.filter((entry) => typeof entry[1] === "string")
|
|
59
|
+
.sort(([left], [right]) => left.localeCompare(right)));
|
|
60
|
+
}
|
|
61
|
+
function isStringArray(value) {
|
|
62
|
+
return (Array.isArray(value) && value.every((entry) => typeof entry === "string"));
|
|
63
|
+
}
|
|
64
|
+
function isNullableString(value) {
|
|
65
|
+
return value === null || typeof value === "string";
|
|
66
|
+
}
|
|
67
|
+
function isNullableNumber(value) {
|
|
68
|
+
return (value === null || (typeof value === "number" && Number.isFinite(value)));
|
|
69
|
+
}
|
|
70
|
+
function isNullableSha256(value) {
|
|
71
|
+
return (value === null || (typeof value === "string" && SHA256_PATTERN.test(value)));
|
|
72
|
+
}
|
|
73
|
+
function isHeaderRecord(value) {
|
|
74
|
+
const record = recordValue(value);
|
|
75
|
+
return (record !== null &&
|
|
76
|
+
Object.entries(record).every(([name, headerValue]) => isValidProxyReplayHeaderName(name) &&
|
|
77
|
+
typeof headerValue === "string" &&
|
|
78
|
+
!/[\r\n]/.test(headerValue)));
|
|
79
|
+
}
|
|
80
|
+
function isReplayCapture(value) {
|
|
81
|
+
const capture = recordValue(value);
|
|
82
|
+
const source = recordValue(capture?.source);
|
|
83
|
+
return Boolean(capture &&
|
|
84
|
+
typeof capture.timestamp === "string" &&
|
|
85
|
+
typeof capture.phase === "string" &&
|
|
86
|
+
isNullableNumber(capture.attempt) &&
|
|
87
|
+
isNullableString(capture.model) &&
|
|
88
|
+
(capture.stream === null || typeof capture.stream === "boolean") &&
|
|
89
|
+
isNullableString(capture.account) &&
|
|
90
|
+
isNullableString(capture.accountType) &&
|
|
91
|
+
isNullableNumber(capture.responseStatus) &&
|
|
92
|
+
isNullableNumber(capture.durationMs) &&
|
|
93
|
+
isNullableString(capture.contentType) &&
|
|
94
|
+
isHeaderRecord(capture.headers) &&
|
|
95
|
+
isNullableString(capture.body) &&
|
|
96
|
+
isNullableSha256(capture.bodySha256) &&
|
|
97
|
+
typeof capture.bodyTruncated === "boolean" &&
|
|
98
|
+
isNullableNumber(capture.observedBodyBytes) &&
|
|
99
|
+
(capture.metadata === null || recordValue(capture.metadata)) &&
|
|
100
|
+
source &&
|
|
101
|
+
typeof source.indexFile === "string" &&
|
|
102
|
+
Number.isInteger(source.indexLine) &&
|
|
103
|
+
Number(source.indexLine) >= 1 &&
|
|
104
|
+
isNullableString(source.artifactPath) &&
|
|
105
|
+
isStringArray(capture.issues));
|
|
106
|
+
}
|
|
107
|
+
function assertProxyReplayBundle(value) {
|
|
108
|
+
const bundle = recordValue(value);
|
|
109
|
+
const source = recordValue(bundle?.source);
|
|
110
|
+
const completeness = recordValue(bundle?.completeness);
|
|
111
|
+
const request = recordValue(bundle?.request);
|
|
112
|
+
const valid = Boolean(bundle?.kind === BUNDLE_KIND &&
|
|
113
|
+
bundle.schemaVersion === 1 &&
|
|
114
|
+
typeof bundle.requestId === "string" &&
|
|
115
|
+
bundle.requestId.length > 0 &&
|
|
116
|
+
Number.isInteger(bundle.selectedAttempt) &&
|
|
117
|
+
Number(bundle.selectedAttempt) >= 1 &&
|
|
118
|
+
source &&
|
|
119
|
+
typeof source.logsDirectory === "string" &&
|
|
120
|
+
isStringArray(source.indexFiles) &&
|
|
121
|
+
completeness &&
|
|
122
|
+
Number.isInteger(completeness.captures) &&
|
|
123
|
+
isStringArray(completeness.phasesPresent) &&
|
|
124
|
+
isStringArray(completeness.missingRequiredPhases) &&
|
|
125
|
+
Number.isInteger(completeness.truncatedCaptures) &&
|
|
126
|
+
Number.isInteger(completeness.artifactsWithIssues) &&
|
|
127
|
+
typeof completeness.replayable === "boolean" &&
|
|
128
|
+
isStringArray(completeness.blockers) &&
|
|
129
|
+
request &&
|
|
130
|
+
["POST", "PUT", "PATCH"].includes(String(request.method)) &&
|
|
131
|
+
isNullableString(request.url) &&
|
|
132
|
+
isHeaderRecord(request.headers) &&
|
|
133
|
+
isStringArray(request.requiredHeaderInputs) &&
|
|
134
|
+
request.requiredHeaderInputs.every((name) => isValidProxyReplayHeaderName(name)) &&
|
|
135
|
+
isNullableString(request.body) &&
|
|
136
|
+
isNullableSha256(request.bodySha256) &&
|
|
137
|
+
typeof request.bodyTruncated === "boolean" &&
|
|
138
|
+
isNullableString(request.contentType) &&
|
|
139
|
+
isNullableString(request.account) &&
|
|
140
|
+
isNullableString(request.accountType) &&
|
|
141
|
+
isNullableString(request.model) &&
|
|
142
|
+
(request.stream === null || typeof request.stream === "boolean") &&
|
|
143
|
+
Array.isArray(bundle.captures) &&
|
|
144
|
+
bundle.captures.every(isReplayCapture) &&
|
|
145
|
+
(bundle.capturedResponse === null ||
|
|
146
|
+
isReplayCapture(bundle.capturedResponse)));
|
|
147
|
+
if (!valid) {
|
|
148
|
+
throw new Error("Invalid or unsupported NeuroLink proxy replay bundle");
|
|
149
|
+
}
|
|
150
|
+
const typedBundle = value;
|
|
151
|
+
const captures = [
|
|
152
|
+
...typedBundle.captures,
|
|
153
|
+
...(typedBundle.capturedResponse ? [typedBundle.capturedResponse] : []),
|
|
154
|
+
];
|
|
155
|
+
const hashedBodies = [typedBundle.request, ...captures];
|
|
156
|
+
for (const capture of hashedBodies) {
|
|
157
|
+
if (capture.body !== null &&
|
|
158
|
+
capture.bodySha256 !== null &&
|
|
159
|
+
sha256(capture.body) !== capture.bodySha256) {
|
|
160
|
+
throw new Error("Proxy replay bundle contains a body hash mismatch");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (stableJson(redactProxyHeadersForLogging(typedBundle.request.headers)) !==
|
|
164
|
+
stableJson(typedBundle.request.headers)) {
|
|
165
|
+
throw new Error("Proxy replay bundle contains an unredacted request header");
|
|
166
|
+
}
|
|
167
|
+
for (const capture of captures) {
|
|
168
|
+
if (stableJson(redactProxyHeadersForLogging(capture.headers)) !==
|
|
169
|
+
stableJson(capture.headers)) {
|
|
170
|
+
throw new Error("Proxy replay bundle contains an unredacted capture header");
|
|
171
|
+
}
|
|
172
|
+
if (capture.body !== null &&
|
|
173
|
+
prepareProxyBodyForLogging(capture.body).value !== capture.body) {
|
|
174
|
+
throw new Error("Proxy replay bundle contains an unredacted capture body");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (typedBundle.request.body !== null &&
|
|
178
|
+
prepareProxyBodyForLogging(typedBundle.request.body).value !==
|
|
179
|
+
typedBundle.request.body) {
|
|
180
|
+
throw new Error("Proxy replay bundle contains an unredacted request body");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function isWithin(parent, candidate) {
|
|
184
|
+
const child = relative(parent, candidate);
|
|
185
|
+
return child === "" || (!child.startsWith(`..${sep}`) && child !== "..");
|
|
186
|
+
}
|
|
187
|
+
function stableValue(value) {
|
|
188
|
+
if (Array.isArray(value)) {
|
|
189
|
+
return value.map(stableValue);
|
|
190
|
+
}
|
|
191
|
+
if (value && typeof value === "object") {
|
|
192
|
+
return Object.fromEntries(Object.entries(value)
|
|
193
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
194
|
+
.map(([key, child]) => [key, stableValue(child)]));
|
|
195
|
+
}
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
function stableJson(value) {
|
|
199
|
+
return JSON.stringify(stableValue(value));
|
|
200
|
+
}
|
|
201
|
+
export function serializeProxyReplayDocument(value) {
|
|
202
|
+
return `${JSON.stringify(stableValue(value), null, 2)}\n`;
|
|
203
|
+
}
|
|
204
|
+
async function discoverDebugFiles(logsDir) {
|
|
205
|
+
const entries = await readdir(logsDir, { withFileTypes: true }).catch((error) => {
|
|
206
|
+
throw new Error(`Unable to read proxy logs at ${logsDir}: ${error.message}`);
|
|
207
|
+
});
|
|
208
|
+
return entries
|
|
209
|
+
.filter((entry) => entry.isFile() && DEBUG_FILE_PATTERN.test(entry.name))
|
|
210
|
+
.map((entry) => join(logsDir, entry.name))
|
|
211
|
+
.sort();
|
|
212
|
+
}
|
|
213
|
+
async function findRequestIndexEntries(files, requestId) {
|
|
214
|
+
const matches = [];
|
|
215
|
+
const encodedRequestId = JSON.stringify(requestId);
|
|
216
|
+
for (const filePath of files) {
|
|
217
|
+
const lines = createInterface({
|
|
218
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
219
|
+
crlfDelay: Infinity,
|
|
220
|
+
});
|
|
221
|
+
let lineNumber = 0;
|
|
222
|
+
for await (const line of lines) {
|
|
223
|
+
lineNumber += 1;
|
|
224
|
+
if (!line.includes(encodedRequestId)) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
let parsed;
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(line);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const index = recordValue(parsed);
|
|
235
|
+
if (index?.type === "body_capture" && index.requestId === requestId) {
|
|
236
|
+
matches.push({ index, filePath, line: lineNumber });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return matches;
|
|
241
|
+
}
|
|
242
|
+
async function loadArtifact(args) {
|
|
243
|
+
const bodyRoot = resolve(args.logsDir, "bodies");
|
|
244
|
+
const candidate = resolve(isAbsolute(args.bodyPath)
|
|
245
|
+
? args.bodyPath
|
|
246
|
+
: join(args.logsDir, args.bodyPath));
|
|
247
|
+
if (!isWithin(bodyRoot, candidate)) {
|
|
248
|
+
throw new Error(`Body artifact escapes the logs body directory: ${args.bodyPath}`);
|
|
249
|
+
}
|
|
250
|
+
const [bodyRootReal, candidateReal, candidateLstat] = await Promise.all([
|
|
251
|
+
realpath(bodyRoot),
|
|
252
|
+
realpath(candidate),
|
|
253
|
+
lstat(candidate),
|
|
254
|
+
]);
|
|
255
|
+
if (!isWithin(bodyRootReal, candidateReal) ||
|
|
256
|
+
candidateLstat.isSymbolicLink()) {
|
|
257
|
+
throw new Error(`Body artifact failed path-containment checks: ${args.bodyPath}`);
|
|
258
|
+
}
|
|
259
|
+
const handle = await open(candidateReal, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
260
|
+
let compressed;
|
|
261
|
+
try {
|
|
262
|
+
const info = await handle.stat();
|
|
263
|
+
if (!info.isFile()) {
|
|
264
|
+
throw new Error(`Body artifact is not a regular file: ${args.bodyPath}`);
|
|
265
|
+
}
|
|
266
|
+
if (info.size > MAX_COMPRESSED_ARTIFACT_BYTES) {
|
|
267
|
+
throw new Error(`Compressed body artifact exceeds the ${MAX_COMPRESSED_ARTIFACT_BYTES}-byte safety limit`);
|
|
268
|
+
}
|
|
269
|
+
compressed = await handle.readFile();
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
await handle.close();
|
|
273
|
+
}
|
|
274
|
+
const payload = gunzipSync(compressed, {
|
|
275
|
+
maxOutputLength: MAX_ARTIFACT_BYTES,
|
|
276
|
+
});
|
|
277
|
+
const artifact = recordValue(JSON.parse(payload.toString("utf8")));
|
|
278
|
+
if (!artifact) {
|
|
279
|
+
throw new Error(`Body artifact is not a JSON object: ${args.bodyPath}`);
|
|
280
|
+
}
|
|
281
|
+
if (artifact.requestId !== args.expectedRequestId ||
|
|
282
|
+
artifact.phase !== args.expectedPhase) {
|
|
283
|
+
throw new Error(`Body artifact identity does not match its debug index: ${args.bodyPath}`);
|
|
284
|
+
}
|
|
285
|
+
const body = typeof artifact.body === "string" ? artifact.body : null;
|
|
286
|
+
if (body !== null &&
|
|
287
|
+
args.expectedSha256 &&
|
|
288
|
+
sha256(body) !== args.expectedSha256) {
|
|
289
|
+
throw new Error(`Body artifact SHA-256 does not match its debug index: ${args.bodyPath}`);
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
artifact,
|
|
293
|
+
relativePath: relative(args.logsDir, candidateReal).split(sep).join("/"),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
async function materializeCapture(args) {
|
|
297
|
+
const phase = stringValue(args.index.phase) ?? "unknown";
|
|
298
|
+
const expectedSha256 = stringValue(args.index.bodySha256);
|
|
299
|
+
const issues = [];
|
|
300
|
+
let artifact = null;
|
|
301
|
+
let artifactPath = null;
|
|
302
|
+
const bodyPath = stringValue(args.index.bodyPath);
|
|
303
|
+
if (!bodyPath) {
|
|
304
|
+
issues.push("body_artifact_missing");
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
try {
|
|
308
|
+
const loaded = await loadArtifact({
|
|
309
|
+
logsDir: args.logsDir,
|
|
310
|
+
bodyPath,
|
|
311
|
+
expectedRequestId: args.requestId,
|
|
312
|
+
expectedPhase: phase,
|
|
313
|
+
expectedSha256,
|
|
314
|
+
});
|
|
315
|
+
artifact = loaded.artifact;
|
|
316
|
+
artifactPath = loaded.relativePath;
|
|
317
|
+
for (const field of [
|
|
318
|
+
"timestamp",
|
|
319
|
+
"requestId",
|
|
320
|
+
"phase",
|
|
321
|
+
"model",
|
|
322
|
+
"stream",
|
|
323
|
+
"account",
|
|
324
|
+
"accountType",
|
|
325
|
+
"attempt",
|
|
326
|
+
"responseStatus",
|
|
327
|
+
"durationMs",
|
|
328
|
+
"contentType",
|
|
329
|
+
"headers",
|
|
330
|
+
"metadata",
|
|
331
|
+
]) {
|
|
332
|
+
if (stableJson(artifact[field]) !== stableJson(args.index[field])) {
|
|
333
|
+
throw new Error(`Body artifact ${field} does not match its debug index: ${bodyPath}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (error.code === "ENOENT") {
|
|
339
|
+
issues.push("body_artifact_missing");
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const source = artifact ?? args.index;
|
|
347
|
+
const body = typeof artifact?.body === "string" ? artifact.body : null;
|
|
348
|
+
if (body !== null && !expectedSha256) {
|
|
349
|
+
issues.push("body_hash_missing");
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
timestamp: stringValue(source.timestamp) ?? stringValue(args.index.timestamp) ?? "",
|
|
353
|
+
phase,
|
|
354
|
+
attempt: finiteNumber(source.attempt),
|
|
355
|
+
model: stringValue(source.model),
|
|
356
|
+
stream: typeof source.stream === "boolean" ? source.stream : null,
|
|
357
|
+
account: stringValue(source.account),
|
|
358
|
+
accountType: stringValue(source.accountType),
|
|
359
|
+
responseStatus: finiteNumber(source.responseStatus),
|
|
360
|
+
durationMs: finiteNumber(source.durationMs),
|
|
361
|
+
contentType: stringValue(source.contentType),
|
|
362
|
+
headers: headersValue(source.headers),
|
|
363
|
+
body,
|
|
364
|
+
bodySha256: body === null ? expectedSha256 : sha256(body),
|
|
365
|
+
bodyTruncated: args.index.bodyTruncated === true,
|
|
366
|
+
observedBodyBytes: finiteNumber(args.index.observedBodyBytes),
|
|
367
|
+
metadata: recordValue(source.metadata),
|
|
368
|
+
source: {
|
|
369
|
+
indexFile: basename(args.filePath),
|
|
370
|
+
indexLine: args.line,
|
|
371
|
+
artifactPath,
|
|
372
|
+
},
|
|
373
|
+
issues,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function captureSort(left, right) {
|
|
377
|
+
return (left.timestamp.localeCompare(right.timestamp) ||
|
|
378
|
+
(left.attempt ?? -1) - (right.attempt ?? -1) ||
|
|
379
|
+
left.phase.localeCompare(right.phase) ||
|
|
380
|
+
left.source.indexFile.localeCompare(right.source.indexFile) ||
|
|
381
|
+
left.source.indexLine - right.source.indexLine);
|
|
382
|
+
}
|
|
383
|
+
function findCapture(captures, phase, attempt) {
|
|
384
|
+
const matches = captures.filter((capture) => capture.phase === phase &&
|
|
385
|
+
(attempt === undefined || capture.attempt === attempt));
|
|
386
|
+
return matches.at(-1) ?? null;
|
|
387
|
+
}
|
|
388
|
+
export async function exportProxyReplayBundle(options) {
|
|
389
|
+
if (!options.requestId || options.requestId.length > 256) {
|
|
390
|
+
throw new Error("requestId must contain between 1 and 256 characters");
|
|
391
|
+
}
|
|
392
|
+
if (options.attempt !== undefined &&
|
|
393
|
+
(!Number.isInteger(options.attempt) || options.attempt < 1)) {
|
|
394
|
+
throw new Error("attempt must be a positive integer");
|
|
395
|
+
}
|
|
396
|
+
const logsDir = resolve(options.logsDir ?? join(homedir(), ".neurolink", "logs"));
|
|
397
|
+
const debugFiles = await discoverDebugFiles(logsDir);
|
|
398
|
+
const indexEntries = await findRequestIndexEntries(debugFiles, options.requestId);
|
|
399
|
+
if (indexEntries.length === 0) {
|
|
400
|
+
throw new Error(`No body captures found for request ${options.requestId}`);
|
|
401
|
+
}
|
|
402
|
+
const captures = (await Promise.all(indexEntries.map((entry) => materializeCapture({ logsDir, requestId: options.requestId, ...entry })))).sort(captureSort);
|
|
403
|
+
const upstreamAttempts = captures
|
|
404
|
+
.filter((capture) => capture.phase === "upstream_request" && capture.attempt !== null)
|
|
405
|
+
.map((capture) => capture.attempt);
|
|
406
|
+
const selectedAttempt = options.attempt ??
|
|
407
|
+
upstreamAttempts.reduce((highest, attempt) => Math.max(highest, attempt), Number.NEGATIVE_INFINITY);
|
|
408
|
+
if (!Number.isFinite(selectedAttempt)) {
|
|
409
|
+
throw new Error(`Request ${options.requestId} has no captured upstream attempt`);
|
|
410
|
+
}
|
|
411
|
+
const upstreamRequest = findCapture(captures, "upstream_request", selectedAttempt);
|
|
412
|
+
if (!upstreamRequest) {
|
|
413
|
+
throw new Error(`Request ${options.requestId} has no upstream_request capture for attempt ${selectedAttempt}`);
|
|
414
|
+
}
|
|
415
|
+
const capturedResponse = findCapture(captures, "upstream_response", selectedAttempt);
|
|
416
|
+
const phasesPresent = [
|
|
417
|
+
...new Set(captures.map((capture) => capture.phase)),
|
|
418
|
+
].sort();
|
|
419
|
+
const missingRequiredPhases = [
|
|
420
|
+
...(findCapture(captures, "client_request") ? [] : ["client_request"]),
|
|
421
|
+
...(upstreamRequest ? [] : ["upstream_request"]),
|
|
422
|
+
...(capturedResponse ? [] : ["upstream_response"]),
|
|
423
|
+
...(findCapture(captures, "client_response") ? [] : ["client_response"]),
|
|
424
|
+
];
|
|
425
|
+
const requiredHeaderInputs = Object.entries(upstreamRequest.headers)
|
|
426
|
+
.filter(([, value]) => value === REDACTED_VALUE)
|
|
427
|
+
.map(([name]) => name.toLowerCase())
|
|
428
|
+
.sort();
|
|
429
|
+
const metadata = upstreamRequest.metadata ?? {};
|
|
430
|
+
const url = stringValue(metadata.upstreamUrl);
|
|
431
|
+
const method = (stringValue(metadata.upstreamMethod) ?? "POST").toUpperCase();
|
|
432
|
+
const blockers = [
|
|
433
|
+
...missingRequiredPhases.map((phase) => `missing_phase:${phase}`),
|
|
434
|
+
...(url ? [] : ["missing_upstream_url"]),
|
|
435
|
+
...(upstreamRequest.body === null ? ["missing_upstream_request_body"] : []),
|
|
436
|
+
...(upstreamRequest.bodyTruncated
|
|
437
|
+
? ["upstream_request_body_truncated"]
|
|
438
|
+
: []),
|
|
439
|
+
...(upstreamRequest.body?.includes(REDACTED_VALUE)
|
|
440
|
+
? ["upstream_request_body_contains_redactions"]
|
|
441
|
+
: []),
|
|
442
|
+
...captures.flatMap((capture) => capture.issues.map((issue) => `${capture.phase}:${capture.attempt ?? "none"}:${issue}`)),
|
|
443
|
+
].sort();
|
|
444
|
+
return {
|
|
445
|
+
schemaVersion: 1,
|
|
446
|
+
kind: BUNDLE_KIND,
|
|
447
|
+
requestId: options.requestId,
|
|
448
|
+
selectedAttempt,
|
|
449
|
+
source: {
|
|
450
|
+
logsDirectory: basename(logsDir),
|
|
451
|
+
indexFiles: [
|
|
452
|
+
...new Set(captures.map((capture) => capture.source.indexFile)),
|
|
453
|
+
].sort(),
|
|
454
|
+
},
|
|
455
|
+
completeness: {
|
|
456
|
+
captures: captures.length,
|
|
457
|
+
phasesPresent,
|
|
458
|
+
missingRequiredPhases,
|
|
459
|
+
truncatedCaptures: captures.filter((capture) => capture.bodyTruncated)
|
|
460
|
+
.length,
|
|
461
|
+
artifactsWithIssues: captures.filter((capture) => capture.issues.length > 0).length,
|
|
462
|
+
replayable: blockers.length === 0,
|
|
463
|
+
blockers,
|
|
464
|
+
},
|
|
465
|
+
request: {
|
|
466
|
+
method,
|
|
467
|
+
url,
|
|
468
|
+
headers: upstreamRequest.headers,
|
|
469
|
+
requiredHeaderInputs,
|
|
470
|
+
body: upstreamRequest.body,
|
|
471
|
+
bodySha256: upstreamRequest.bodySha256,
|
|
472
|
+
bodyTruncated: upstreamRequest.bodyTruncated,
|
|
473
|
+
contentType: upstreamRequest.contentType,
|
|
474
|
+
account: upstreamRequest.account,
|
|
475
|
+
accountType: upstreamRequest.accountType,
|
|
476
|
+
model: upstreamRequest.model,
|
|
477
|
+
stream: upstreamRequest.stream,
|
|
478
|
+
},
|
|
479
|
+
capturedResponse,
|
|
480
|
+
captures,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
export async function writeProxyReplayDocument(outputPath, document) {
|
|
484
|
+
const resolvedPath = resolve(outputPath);
|
|
485
|
+
const outputDirectory = dirname(resolvedPath);
|
|
486
|
+
await mkdir(outputDirectory, { recursive: true, mode: 0o700 });
|
|
487
|
+
const existing = await lstat(resolvedPath).catch((error) => {
|
|
488
|
+
if (error.code === "ENOENT") {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
throw error;
|
|
492
|
+
});
|
|
493
|
+
if (existing?.isSymbolicLink()) {
|
|
494
|
+
throw new Error(`Refusing to replace symlink output path: ${resolvedPath}`);
|
|
495
|
+
}
|
|
496
|
+
const serialized = serializeProxyReplayDocument(document);
|
|
497
|
+
const documentBytes = Buffer.byteLength(serialized, "utf8");
|
|
498
|
+
if (documentBytes > MAX_BUNDLE_BYTES) {
|
|
499
|
+
throw new Error(`Replay document exceeds the ${MAX_BUNDLE_BYTES}-byte safety limit`);
|
|
500
|
+
}
|
|
501
|
+
const temporaryPath = join(outputDirectory, `.${basename(resolvedPath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
502
|
+
const handle = await open(temporaryPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
|
|
503
|
+
try {
|
|
504
|
+
try {
|
|
505
|
+
await handle.writeFile(serialized);
|
|
506
|
+
await handle.chmod(0o600);
|
|
507
|
+
await handle.sync();
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
await handle.close();
|
|
511
|
+
}
|
|
512
|
+
await rename(temporaryPath, resolvedPath);
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
return { path: resolvedPath, sha256: sha256(serialized) };
|
|
519
|
+
}
|
|
520
|
+
export async function readProxyReplayBundle(bundlePath) {
|
|
521
|
+
const resolvedPath = resolve(bundlePath);
|
|
522
|
+
const info = await stat(resolvedPath);
|
|
523
|
+
if (!info.isFile() || info.size > MAX_BUNDLE_BYTES) {
|
|
524
|
+
throw new Error(`Replay bundle must be a regular file no larger than ${MAX_BUNDLE_BYTES} bytes`);
|
|
525
|
+
}
|
|
526
|
+
const parsed = JSON.parse(await readFile(resolvedPath, "utf8"));
|
|
527
|
+
assertProxyReplayBundle(parsed);
|
|
528
|
+
return parsed;
|
|
529
|
+
}
|
|
530
|
+
function normalizedContentType(value) {
|
|
531
|
+
return value?.split(";", 1)[0]?.trim().toLowerCase() || null;
|
|
532
|
+
}
|
|
533
|
+
function jsonShape(body) {
|
|
534
|
+
let parsed;
|
|
535
|
+
try {
|
|
536
|
+
parsed = JSON.parse(body);
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
const paths = [];
|
|
542
|
+
const walk = (value, path, depth) => {
|
|
543
|
+
if (paths.length >= 2_000 || depth > 20) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (Array.isArray(value)) {
|
|
547
|
+
paths.push(`${path}:array`);
|
|
548
|
+
value
|
|
549
|
+
.slice(0, 20)
|
|
550
|
+
.forEach((child, index) => walk(child, `${path}/${index}`, depth + 1));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (value && typeof value === "object") {
|
|
554
|
+
paths.push(`${path}:object`);
|
|
555
|
+
for (const [key, child] of Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) {
|
|
556
|
+
walk(child, `${path}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`, depth + 1);
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
paths.push(`${path}:${value === null ? "null" : typeof value}`);
|
|
561
|
+
};
|
|
562
|
+
walk(parsed, "", 0);
|
|
563
|
+
return paths;
|
|
564
|
+
}
|
|
565
|
+
function validateDirectEndpoint(value, explicitOverride) {
|
|
566
|
+
if (!value) {
|
|
567
|
+
throw new Error("Replay bundle has no upstream URL; provide an explicit URL override");
|
|
568
|
+
}
|
|
569
|
+
const endpoint = new URL(value);
|
|
570
|
+
if (endpoint.username || endpoint.password || endpoint.hash) {
|
|
571
|
+
throw new Error("Replay endpoint must not contain credentials or a fragment");
|
|
572
|
+
}
|
|
573
|
+
for (const name of endpoint.searchParams.keys()) {
|
|
574
|
+
if (SENSITIVE_QUERY_NAME_PATTERN.test(name)) {
|
|
575
|
+
throw new Error(`Replay endpoint must not contain a sensitive query parameter: ${name}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const loopback = ["127.0.0.1", "::1", "[::1]", "localhost"].includes(endpoint.hostname);
|
|
579
|
+
if (endpoint.protocol !== "https:" &&
|
|
580
|
+
!(endpoint.protocol === "http:" && loopback)) {
|
|
581
|
+
throw new Error("Replay endpoint must use HTTPS; HTTP is allowed only for loopback testing");
|
|
582
|
+
}
|
|
583
|
+
if (!explicitOverride &&
|
|
584
|
+
!TRUSTED_CAPTURED_ENDPOINTS.has(endpoint.toString())) {
|
|
585
|
+
throw new Error("Replay bundle contains an untrusted endpoint; pass an explicit URL override to authorize it");
|
|
586
|
+
}
|
|
587
|
+
return endpoint;
|
|
588
|
+
}
|
|
589
|
+
async function readBoundedResponse(response) {
|
|
590
|
+
if (!response.body) {
|
|
591
|
+
return { body: "", observedBytes: 0, truncated: false };
|
|
592
|
+
}
|
|
593
|
+
const reader = response.body.getReader();
|
|
594
|
+
const chunks = [];
|
|
595
|
+
let storedBytes = 0;
|
|
596
|
+
let observedBytes = 0;
|
|
597
|
+
let truncated = false;
|
|
598
|
+
try {
|
|
599
|
+
while (true) {
|
|
600
|
+
const next = await reader.read();
|
|
601
|
+
if (next.done) {
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
observedBytes += next.value.byteLength;
|
|
605
|
+
if (storedBytes < MAX_DIRECT_RESPONSE_BYTES) {
|
|
606
|
+
const remaining = MAX_DIRECT_RESPONSE_BYTES - storedBytes;
|
|
607
|
+
chunks.push(next.value.slice(0, remaining));
|
|
608
|
+
storedBytes += Math.min(remaining, next.value.byteLength);
|
|
609
|
+
}
|
|
610
|
+
if (observedBytes > MAX_DIRECT_RESPONSE_BYTES) {
|
|
611
|
+
truncated = true;
|
|
612
|
+
await reader
|
|
613
|
+
.cancel("bounded replay comparison capture")
|
|
614
|
+
.catch(() => undefined);
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
reader.releaseLock();
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
body: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8"),
|
|
624
|
+
observedBytes,
|
|
625
|
+
truncated,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
export async function compareProxyReplayBundle(bundle, options) {
|
|
629
|
+
assertProxyReplayBundle(bundle);
|
|
630
|
+
if (!options.execute) {
|
|
631
|
+
throw new Error("Direct replay is disabled unless execute=true is explicitly provided");
|
|
632
|
+
}
|
|
633
|
+
if (!["POST", "PUT", "PATCH"].includes(bundle.request.method)) {
|
|
634
|
+
throw new Error(`Unsupported replay request method: ${bundle.request.method}`);
|
|
635
|
+
}
|
|
636
|
+
const requestBody = options.bodyOverride ?? bundle.request.body;
|
|
637
|
+
if (requestBody === null) {
|
|
638
|
+
throw new Error("Replay request body is missing; provide a body override");
|
|
639
|
+
}
|
|
640
|
+
if (options.bodyOverride === undefined &&
|
|
641
|
+
(bundle.request.bodyTruncated || requestBody.includes(REDACTED_VALUE))) {
|
|
642
|
+
throw new Error("Captured replay body is truncated or redacted; provide a complete body override");
|
|
643
|
+
}
|
|
644
|
+
if (options.bodyOverride === undefined &&
|
|
645
|
+
bundle.request.bodySha256 === null) {
|
|
646
|
+
throw new Error("Captured replay body has no verified hash; provide a complete body override");
|
|
647
|
+
}
|
|
648
|
+
const endpoint = validateDirectEndpoint(options.urlOverride ?? bundle.request.url ?? "", options.urlOverride !== undefined);
|
|
649
|
+
const suppliedHeaders = Object.fromEntries(Object.entries(options.headerValues ?? {}).map(([name, value]) => [
|
|
650
|
+
name.toLowerCase(),
|
|
651
|
+
value,
|
|
652
|
+
]));
|
|
653
|
+
const invalidHeaderNames = Object.keys(suppliedHeaders).filter((name) => !isValidProxyReplayHeaderName(name));
|
|
654
|
+
if (invalidHeaderNames.length > 0) {
|
|
655
|
+
throw new Error(`Invalid replay header names: ${invalidHeaderNames.join(", ")}`);
|
|
656
|
+
}
|
|
657
|
+
const requiredHeaderInputs = [
|
|
658
|
+
...new Set([
|
|
659
|
+
...bundle.request.requiredHeaderInputs.map((name) => name.toLowerCase()),
|
|
660
|
+
...Object.entries(bundle.request.headers)
|
|
661
|
+
.filter(([, value]) => value === REDACTED_VALUE)
|
|
662
|
+
.map(([name]) => name.toLowerCase()),
|
|
663
|
+
]),
|
|
664
|
+
].sort();
|
|
665
|
+
const missingHeaderValues = requiredHeaderInputs.filter((name) => !suppliedHeaders[name]);
|
|
666
|
+
if (missingHeaderValues.length > 0) {
|
|
667
|
+
throw new Error(`Missing environment-backed values for redacted headers: ${missingHeaderValues.join(", ")}`);
|
|
668
|
+
}
|
|
669
|
+
const requestHeaders = {};
|
|
670
|
+
for (const [name, capturedValue] of Object.entries(bundle.request.headers)) {
|
|
671
|
+
const lower = name.toLowerCase();
|
|
672
|
+
if (HOP_BY_HOP_HEADERS.has(lower)) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
requestHeaders[lower] =
|
|
676
|
+
capturedValue === REDACTED_VALUE
|
|
677
|
+
? suppliedHeaders[lower]
|
|
678
|
+
: capturedValue;
|
|
679
|
+
}
|
|
680
|
+
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
681
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 600_000) {
|
|
682
|
+
throw new Error("Replay timeout must be between 1000 and 600000 milliseconds");
|
|
683
|
+
}
|
|
684
|
+
const now = options.now ?? Date.now;
|
|
685
|
+
const startedAt = now();
|
|
686
|
+
const response = await (options.fetchImpl ?? fetch)(endpoint, {
|
|
687
|
+
method: bundle.request.method,
|
|
688
|
+
headers: requestHeaders,
|
|
689
|
+
body: requestBody,
|
|
690
|
+
redirect: "manual",
|
|
691
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
692
|
+
});
|
|
693
|
+
const headersAt = now();
|
|
694
|
+
const rawResponseHeaders = Object.fromEntries(response.headers.entries());
|
|
695
|
+
const directCapture = await readBoundedResponse(response);
|
|
696
|
+
const endedAt = now();
|
|
697
|
+
const preparedBody = prepareProxyBodyForLogging(directCapture.body);
|
|
698
|
+
const redactedBody = preparedBody.value ?? "";
|
|
699
|
+
const captured = bundle.capturedResponse;
|
|
700
|
+
const capturedShape = captured?.body ? jsonShape(captured.body) : null;
|
|
701
|
+
const directShape = jsonShape(redactedBody);
|
|
702
|
+
const capturedContentType = normalizedContentType(captured?.contentType ?? null);
|
|
703
|
+
const directContentType = normalizedContentType(response.headers.get("content-type"));
|
|
704
|
+
return {
|
|
705
|
+
schemaVersion: 1,
|
|
706
|
+
kind: COMPARISON_KIND,
|
|
707
|
+
requestId: bundle.requestId,
|
|
708
|
+
selectedAttempt: bundle.selectedAttempt,
|
|
709
|
+
endpoint: endpoint.toString(),
|
|
710
|
+
request: {
|
|
711
|
+
method: bundle.request.method,
|
|
712
|
+
headers: redactProxyHeadersForLogging(requestHeaders) ?? {},
|
|
713
|
+
bodySha256: sha256(requestBody),
|
|
714
|
+
bodyBytes: Buffer.byteLength(requestBody, "utf8"),
|
|
715
|
+
usedBodyOverride: options.bodyOverride !== undefined,
|
|
716
|
+
},
|
|
717
|
+
captured: captured
|
|
718
|
+
? {
|
|
719
|
+
status: captured.responseStatus,
|
|
720
|
+
contentType: captured.contentType,
|
|
721
|
+
bodySha256: captured.bodySha256,
|
|
722
|
+
bodyBytes: captured.observedBodyBytes,
|
|
723
|
+
bodyTruncated: captured.bodyTruncated,
|
|
724
|
+
jsonShape: capturedShape,
|
|
725
|
+
}
|
|
726
|
+
: null,
|
|
727
|
+
direct: {
|
|
728
|
+
status: response.status,
|
|
729
|
+
headers: redactProxyHeadersForLogging(rawResponseHeaders) ?? {},
|
|
730
|
+
contentType: response.headers.get("content-type"),
|
|
731
|
+
body: redactedBody,
|
|
732
|
+
bodySha256: sha256(redactedBody),
|
|
733
|
+
observedBodyBytes: directCapture.observedBytes,
|
|
734
|
+
storedBodyBytes: Buffer.byteLength(redactedBody, "utf8"),
|
|
735
|
+
bodyTruncated: directCapture.truncated || preparedBody.truncated,
|
|
736
|
+
timeToHeadersMs: headersAt - startedAt,
|
|
737
|
+
totalMs: endedAt - startedAt,
|
|
738
|
+
jsonShape: directShape,
|
|
739
|
+
},
|
|
740
|
+
comparison: {
|
|
741
|
+
statusMatches: captured && captured.responseStatus !== null
|
|
742
|
+
? captured.responseStatus === response.status
|
|
743
|
+
: null,
|
|
744
|
+
contentTypeMatches: captured
|
|
745
|
+
? capturedContentType !== null && directContentType !== null
|
|
746
|
+
? capturedContentType === directContentType
|
|
747
|
+
: null
|
|
748
|
+
: null,
|
|
749
|
+
bodyHashMatches: captured?.bodySha256 &&
|
|
750
|
+
!captured.bodyTruncated &&
|
|
751
|
+
!directCapture.truncated &&
|
|
752
|
+
!preparedBody.truncated
|
|
753
|
+
? captured.bodySha256 === sha256(redactedBody)
|
|
754
|
+
: null,
|
|
755
|
+
jsonShapeMatches: capturedShape &&
|
|
756
|
+
directShape &&
|
|
757
|
+
!captured?.bodyTruncated &&
|
|
758
|
+
!directCapture.truncated &&
|
|
759
|
+
!preparedBody.truncated
|
|
760
|
+
? JSON.stringify(capturedShape) === JSON.stringify(directShape)
|
|
761
|
+
: null,
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
//# sourceMappingURL=proxyReplay.js.map
|