imprint-mcp 0.2.0

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.
Files changed (97) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -0
  4. package/examples/discoverandgo/README.md +57 -0
  5. package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
  6. package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
  7. package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
  8. package/examples/echo/README.md +37 -0
  9. package/examples/echo/echo_test/index.ts +31 -0
  10. package/examples/google-flights/search_google_flights/index.ts +101 -0
  11. package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
  12. package/examples/google-flights/search_google_flights/parser.ts +189 -0
  13. package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
  14. package/examples/google-flights/search_google_flights/workflow.json +48 -0
  15. package/examples/google-hotels/search_google_hotels/index.ts +194 -0
  16. package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
  17. package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
  18. package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
  19. package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
  20. package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
  21. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
  22. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
  23. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
  24. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
  25. package/examples/southwest/README.md +81 -0
  26. package/examples/southwest/search_southwest_flights/backends.json +23 -0
  27. package/examples/southwest/search_southwest_flights/cron.json +19 -0
  28. package/examples/southwest/search_southwest_flights/index.ts +110 -0
  29. package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
  30. package/examples/southwest/search_southwest_flights/workflow.json +54 -0
  31. package/package.json +78 -0
  32. package/prompts/compile-agent.md +580 -0
  33. package/prompts/intent-detection.md +198 -0
  34. package/prompts/playbook-compilation.md +279 -0
  35. package/prompts/request-triage.md +74 -0
  36. package/prompts/tool-candidate-detection.md +104 -0
  37. package/src/cli.ts +1287 -0
  38. package/src/imprint/agent.ts +468 -0
  39. package/src/imprint/app-api-hosts.ts +53 -0
  40. package/src/imprint/backend-ladder.ts +568 -0
  41. package/src/imprint/check.ts +136 -0
  42. package/src/imprint/chromium.ts +211 -0
  43. package/src/imprint/claude-cli-compile.ts +640 -0
  44. package/src/imprint/cli-credential.ts +394 -0
  45. package/src/imprint/codex-cli-compile.ts +712 -0
  46. package/src/imprint/compile-agent-types.ts +40 -0
  47. package/src/imprint/compile-agent.ts +404 -0
  48. package/src/imprint/compile-tools.ts +1389 -0
  49. package/src/imprint/compile.ts +720 -0
  50. package/src/imprint/cookie-jar.ts +246 -0
  51. package/src/imprint/credential-bundle.ts +195 -0
  52. package/src/imprint/credential-extract.ts +290 -0
  53. package/src/imprint/credential-store.ts +707 -0
  54. package/src/imprint/cron.ts +312 -0
  55. package/src/imprint/doctor.ts +223 -0
  56. package/src/imprint/emit.ts +154 -0
  57. package/src/imprint/etld.ts +134 -0
  58. package/src/imprint/freeform-redact.ts +216 -0
  59. package/src/imprint/inject-listener.ts +137 -0
  60. package/src/imprint/install.ts +795 -0
  61. package/src/imprint/integrations.ts +385 -0
  62. package/src/imprint/is-compiled.ts +2 -0
  63. package/src/imprint/json-path.ts +100 -0
  64. package/src/imprint/llm.ts +998 -0
  65. package/src/imprint/load-json.ts +54 -0
  66. package/src/imprint/log.ts +33 -0
  67. package/src/imprint/login.ts +166 -0
  68. package/src/imprint/mcp-compile-server.ts +282 -0
  69. package/src/imprint/mcp-maintenance.ts +1790 -0
  70. package/src/imprint/mcp-server.ts +350 -0
  71. package/src/imprint/multi-progress.ts +69 -0
  72. package/src/imprint/notify.ts +155 -0
  73. package/src/imprint/paths.ts +64 -0
  74. package/src/imprint/playbook-parser.ts +21 -0
  75. package/src/imprint/playbook-runner.ts +465 -0
  76. package/src/imprint/probe-backends.ts +251 -0
  77. package/src/imprint/progress.ts +28 -0
  78. package/src/imprint/record.ts +470 -0
  79. package/src/imprint/redact.ts +550 -0
  80. package/src/imprint/replay-capture.ts +387 -0
  81. package/src/imprint/request-context.ts +66 -0
  82. package/src/imprint/runtime-link.ts +73 -0
  83. package/src/imprint/runtime.ts +942 -0
  84. package/src/imprint/sensitive-keys.ts +156 -0
  85. package/src/imprint/session-diff.ts +409 -0
  86. package/src/imprint/session-merge.ts +198 -0
  87. package/src/imprint/session-writer.ts +149 -0
  88. package/src/imprint/sites.ts +27 -0
  89. package/src/imprint/stealth-fetch.ts +434 -0
  90. package/src/imprint/teach-state.ts +235 -0
  91. package/src/imprint/teach.ts +2120 -0
  92. package/src/imprint/tool-candidates.ts +423 -0
  93. package/src/imprint/tool-loader.ts +186 -0
  94. package/src/imprint/tool-selection.ts +70 -0
  95. package/src/imprint/tracing.ts +508 -0
  96. package/src/imprint/types.ts +472 -0
  97. package/src/imprint/version.ts +21 -0
@@ -0,0 +1,1389 @@
1
+ /**
2
+ * Shared compile-agent tool implementations.
3
+ *
4
+ * The same 8 read/write tools and the verification logic are used both by
5
+ * the in-process agent loop (anthropic-api provider) and by the
6
+ * stdio MCP server that claude-cli drives through `--mcp-config`.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join as pathJoin, relative as pathRelative } from 'node:path';
12
+ import type { AgentTool } from './agent.ts';
13
+ import { inferAppApiHosts } from './app-api-hosts.ts';
14
+ import { splitSetCookieHeader } from './cookie-jar.ts';
15
+ import { isSameRegistrableDomain, registrableDomain } from './etld.ts';
16
+ import { compactRequestContexts, requestContextDigest } from './request-context.ts';
17
+ import type { ClassifiedValue } from './session-diff.ts';
18
+ import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
19
+ import { type CapturedRequest, type Session, WorkflowSchema } from './types.ts';
20
+
21
+ const REPO_ROOT = pathJoin(import.meta.dir, '..', '..');
22
+
23
+ // Env var read by the agent-written parser.test.ts to locate the redacted
24
+ // session. The test loads it, finds the load-bearing request seq, and feeds
25
+ // response.body to extract(). Set when we spawn `bun test parser.test.ts`
26
+ // from run_tests / externalVerification — the test never reads from disk
27
+ // without it, so leftover test files won't blow up under default `bun test`.
28
+ const SESSION_PATH_ENV = 'IMPRINT_SESSION_PATH';
29
+
30
+ export function buildCompileTools(
31
+ session: Session,
32
+ toolDir: string,
33
+ sessionPath: string,
34
+ context: CompileToolContext = {},
35
+ ): AgentTool[] {
36
+ const credEnv = context.teachCredentials
37
+ ? { IMPRINT_TEACH_CREDENTIALS: JSON.stringify(context.teachCredentials) }
38
+ : undefined;
39
+ return [
40
+ buildReadSessionSummaryTool(session, context),
41
+ buildReadRequestTool(session),
42
+ buildReadResponseBodyTool(session),
43
+ buildSearchResponseBodyTool(session),
44
+ buildWriteFileTool(toolDir),
45
+ buildReadFileTool(toolDir),
46
+ buildRunBashTool(toolDir, credEnv),
47
+ buildRunTestsTool(toolDir, sessionPath, credEnv),
48
+ ];
49
+ }
50
+
51
+ interface CompileToolContext {
52
+ candidate?: ToolCandidate;
53
+ sharedContext?: SharedCompileContext;
54
+ classifications?: ClassifiedValue[];
55
+ teachCredentials?: { site: string; values: Record<string, string> };
56
+ }
57
+
58
+ // ─── Tool: read_session_summary ──────────────────────────────────────────────
59
+
60
+ function buildReadSessionSummaryTool(session: Session, context: CompileToolContext): AgentTool {
61
+ return {
62
+ name: 'read_session_summary',
63
+ description:
64
+ 'Get a high-level summary of the session including narration, selected candidate scope, load-bearing requests with inline data, and capture hints.',
65
+ input_schema: {
66
+ type: 'object',
67
+ properties: {},
68
+ required: [],
69
+ },
70
+ handler: async () => {
71
+ const allCandidateSeqs = new Set(context.candidate?.requestSeqs ?? []);
72
+ const representativeSeqs = context.candidate?.representativeSeqs ?? [];
73
+ const selectedRequestSeqs = new Set(
74
+ representativeSeqs.length > 0 ? representativeSeqs : (context.candidate?.requestSeqs ?? []),
75
+ );
76
+ const dependencySeqs = new Set([
77
+ ...(context.candidate?.dependencySeqs ?? []),
78
+ ...(context.sharedContext?.loginRequestSeqs ?? []),
79
+ ]);
80
+ const preserveSeqs = new Set([...selectedRequestSeqs, ...dependencySeqs]);
81
+ const summaryRequests = identifySummaryRequests(session, preserveSeqs);
82
+ const loadBearingRequests = compactRequestContexts(
83
+ summaryRequests.map((r) => ({
84
+ seq: r.seq,
85
+ timestamp: r.timestamp,
86
+ selectedForCandidate: selectedRequestSeqs.has(r.seq) || allCandidateSeqs.has(r.seq),
87
+ sharedDependency: dependencySeqs.has(r.seq),
88
+ method: r.method,
89
+ url: r.url,
90
+ status: r.response?.status,
91
+ mimeType: r.response?.mimeType,
92
+ bodySize: r.response?.body?.length,
93
+ responseBodyDigest: requestContextDigest(r.response?.body),
94
+ ...(preserveSeqs.has(r.seq) ? { inlineData: buildInlineData(r) } : {}),
95
+ })),
96
+ compileSummaryRequestGroupKey,
97
+ { preserveSeqs },
98
+ );
99
+ const stateHints = buildStateHints(session, context.classifications);
100
+ const captureHints = buildCaptureHints(
101
+ context.classifications,
102
+ context.candidate,
103
+ context.sharedContext,
104
+ );
105
+ const summary = {
106
+ site: session.site,
107
+ url: session.url,
108
+ selectedCandidate: context.candidate
109
+ ? {
110
+ toolName: context.candidate.toolName,
111
+ description: context.candidate.description,
112
+ expectedOutput: context.candidate.expectedOutput,
113
+ requestSeqs:
114
+ (context.candidate.representativeSeqs?.length ?? 0) > 0
115
+ ? context.candidate.representativeSeqs
116
+ : context.candidate.requestSeqs,
117
+ dependencySeqs: context.candidate.dependencySeqs,
118
+ eventSeqs: context.candidate.eventSeqs,
119
+ likelyParams: context.candidate.likelyParams,
120
+ }
121
+ : undefined,
122
+ sharedContext: context.sharedContext,
123
+ narration: session.narration.map((n) => ({ timestamp: n.timestamp, text: n.text })),
124
+ requestCount: session.requests.length,
125
+ stateHints,
126
+ captureHints: captureHints.length > 0 ? captureHints : undefined,
127
+ loadBearingRequests,
128
+ };
129
+
130
+ const result = JSON.stringify(summary, null, 2);
131
+ if (result.length <= SUMMARY_SIZE_BUDGET) return { result };
132
+
133
+ // Over budget — rebuild with reduced inline data to fit
134
+ const reducedRequests = reduceInlineData(
135
+ loadBearingRequests as Array<Record<string, unknown>>,
136
+ result.length,
137
+ );
138
+ // biome-ignore lint/suspicious/noExplicitAny: type-safe reduction preserves shape
139
+ (summary as any).loadBearingRequests = reducedRequests;
140
+ return { result: JSON.stringify(summary, null, 2) };
141
+ },
142
+ };
143
+ }
144
+
145
+ // ─── Inline request/response data for candidate-scoped requests ─────────────
146
+
147
+ // claude-cli truncates tool results > ~40K chars. Keep the total summary
148
+ // well under that so the agent actually receives the inline data.
149
+ const SUMMARY_SIZE_BUDGET = 30_000;
150
+
151
+ const JSON_BODY_LIMIT = 16 * 1024;
152
+ const JSON_STRUCTURE_THRESHOLD = 50 * 1024;
153
+ const HTML_BODY_LIMIT = 4 * 1024;
154
+
155
+ function buildInlineData(req: CapturedRequest): Record<string, unknown> {
156
+ const result: Record<string, unknown> = {
157
+ requestHeaders: req.headers,
158
+ };
159
+ if (req.body) {
160
+ result.requestBody = req.body;
161
+
162
+ const reqCt = (req.headers['content-type'] ?? req.headers['Content-Type'] ?? '').toLowerCase();
163
+ if (reqCt.includes('form-urlencoded')) {
164
+ try {
165
+ const formParams = new URLSearchParams(req.body);
166
+ const decoded: Record<string, unknown> = {};
167
+ for (const [k, v] of formParams) {
168
+ const trimmed = v.trimStart();
169
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
170
+ try {
171
+ decoded[k] = JSON.parse(v);
172
+ } catch {
173
+ decoded[k] = v;
174
+ }
175
+ } else {
176
+ decoded[k] = v;
177
+ }
178
+ }
179
+ result.requestBodyDecoded = decoded;
180
+ } catch {
181
+ // Non-fatal — raw body is still available.
182
+ }
183
+ }
184
+ }
185
+
186
+ if (req.response) {
187
+ result.responseStatus = req.response.status;
188
+ result.responseHeaders = req.response.headers;
189
+
190
+ const body = req.response.body;
191
+ if (body) {
192
+ const mime = (req.response.mimeType ?? '').toLowerCase();
193
+ const isJson = mime.includes('json') || isJsonBody(body);
194
+ const isHtml = mime.includes('html');
195
+
196
+ if (isJson) {
197
+ if (body.length <= JSON_BODY_LIMIT) {
198
+ result.responseBody = body;
199
+ } else if (body.length > JSON_STRUCTURE_THRESHOLD) {
200
+ result.responseBody = body.slice(0, JSON_BODY_LIMIT / 2);
201
+ result.responseBodyTruncated = true;
202
+ result.responseBodyTotalLength = body.length;
203
+ result.responseBodyStructure = summarizeJsonStructure(body);
204
+ } else {
205
+ result.responseBody = body.slice(0, JSON_BODY_LIMIT);
206
+ result.responseBodyTruncated = true;
207
+ result.responseBodyTotalLength = body.length;
208
+ }
209
+ } else if (isHtml) {
210
+ if (body.length <= HTML_BODY_LIMIT) {
211
+ result.responseBody = body;
212
+ } else {
213
+ result.responseBody = body.slice(0, HTML_BODY_LIMIT);
214
+ result.responseBodyTruncated = true;
215
+ result.responseBodyTotalLength = body.length;
216
+ }
217
+ } else if (body.length <= HTML_BODY_LIMIT) {
218
+ result.responseBody = body;
219
+ } else {
220
+ result.responseBody = `(${mime || 'unknown'} body, ${body.length} bytes)`;
221
+ result.responseBodyTruncated = true;
222
+ result.responseBodyTotalLength = body.length;
223
+ }
224
+ }
225
+ }
226
+ return result;
227
+ }
228
+
229
+ function reduceInlineData(
230
+ requests: Array<Record<string, unknown>>,
231
+ fullSummarySize: number,
232
+ ): Array<Record<string, unknown>> {
233
+ const reduced = requests.map((r) => ({ ...r }));
234
+ const budget = SUMMARY_SIZE_BUDGET;
235
+
236
+ // The caller passes the full summary size. Track the delta from
237
+ // reducing the requests array so we can estimate the full summary
238
+ // size without re-serializing the entire object each phase.
239
+ const arrayBefore = JSON.stringify(requests).length;
240
+ const overhead = fullSummarySize - arrayBefore;
241
+
242
+ const estimateFullSize = () => JSON.stringify(reduced).length + overhead;
243
+
244
+ // Phase 1: drop responseBody from non-candidate requests (shared dependencies)
245
+ if (estimateFullSize() > budget) {
246
+ for (const r of reduced) {
247
+ if (r.sharedDependency && !r.selectedForCandidate && r.inlineData) {
248
+ const inline = r.inlineData as Record<string, unknown>;
249
+ inline.responseBody = undefined;
250
+ inline.responseBodyStructure = undefined;
251
+ inline.responseBodyTruncated = true;
252
+ inline.responseBodyNote = 'omitted to fit summary budget — use read_response_body';
253
+ }
254
+ }
255
+ }
256
+
257
+ // Phase 2: cap all remaining response bodies at 4KB
258
+ if (estimateFullSize() > budget) {
259
+ for (const r of reduced) {
260
+ if (!r.inlineData) continue;
261
+ const inline = r.inlineData as Record<string, unknown>;
262
+ const body = inline.responseBody;
263
+ if (typeof body === 'string' && body.length > 4096) {
264
+ inline.responseBody = body.slice(0, 4096);
265
+ inline.responseBodyTruncated = true;
266
+ }
267
+ }
268
+ }
269
+
270
+ // Phase 3: drop all response bodies, keep only request data + headers
271
+ if (estimateFullSize() > budget) {
272
+ for (const r of reduced) {
273
+ if (!r.inlineData) continue;
274
+ const inline = r.inlineData as Record<string, unknown>;
275
+ inline.responseBody = undefined;
276
+ inline.responseBodyStructure = undefined;
277
+ inline.responseBodyTruncated = true;
278
+ inline.responseBodyNote = 'omitted to fit summary budget — use read_response_body';
279
+ }
280
+ }
281
+
282
+ // Phase 4: drop inline data entirely if still over budget
283
+ if (estimateFullSize() > budget) {
284
+ for (const r of reduced) {
285
+ r.inlineData = undefined;
286
+ }
287
+ }
288
+
289
+ return reduced;
290
+ }
291
+
292
+ function isJsonBody(body: string): boolean {
293
+ const trimmed = body.trimStart();
294
+ return trimmed.startsWith('{') || trimmed.startsWith('[');
295
+ }
296
+
297
+ function summarizeJsonStructure(body: string): string {
298
+ try {
299
+ const parsed = JSON.parse(body);
300
+ return describeStructure(parsed, 0, 3);
301
+ } catch {
302
+ return '(could not parse JSON for structure summary)';
303
+ }
304
+ }
305
+
306
+ function describeStructure(value: unknown, depth: number, maxDepth: number): string {
307
+ if (depth >= maxDepth) return typeof value === 'object' ? '{...}' : String(typeof value);
308
+ if (Array.isArray(value)) {
309
+ if (value.length === 0) return '[]';
310
+ const first = describeStructure(value[0], depth + 1, maxDepth);
311
+ return `Array(${value.length}) of ${first}`;
312
+ }
313
+ if (value && typeof value === 'object') {
314
+ const entries = Object.entries(value);
315
+ if (entries.length === 0) return '{}';
316
+ const fields = entries
317
+ .slice(0, 20)
318
+ .map(([k, v]) => `${k}: ${describeStructure(v, depth + 1, maxDepth)}`);
319
+ if (entries.length > 20) fields.push(`... +${entries.length - 20} more keys`);
320
+ return `{ ${fields.join(', ')} }`;
321
+ }
322
+ return String(typeof value);
323
+ }
324
+
325
+ // ─── Capture hints from dual-pass classifications ───────────────────────────
326
+
327
+ interface CaptureHint {
328
+ producerRequestIndex: number;
329
+ capture: {
330
+ source: 'json' | 'response_header' | 'cookie' | 'text_regex';
331
+ name: string;
332
+ path?: string;
333
+ header?: string;
334
+ cookie?: string;
335
+ pattern?: string;
336
+ group?: number;
337
+ };
338
+ usedBy: Array<{
339
+ requestIndex: number;
340
+ location: string;
341
+ substitution: string;
342
+ }>;
343
+ }
344
+
345
+ function buildCaptureHints(
346
+ classifications: ClassifiedValue[] | undefined,
347
+ candidate: ToolCandidate | undefined,
348
+ sharedContext: SharedCompileContext | undefined,
349
+ ): CaptureHint[] {
350
+ if (!classifications || !candidate) return [];
351
+
352
+ const requestChain = [
353
+ ...(candidate.dependencySeqs ?? []),
354
+ ...(sharedContext?.loginRequestSeqs ?? []),
355
+ ...candidate.requestSeqs,
356
+ ];
357
+ const uniqueChain = [...new Set(requestChain)].sort((a, b) => a - b);
358
+ const seqToIndex = new Map(uniqueChain.map((seq, i) => [seq, i]));
359
+
360
+ const hints: CaptureHint[] = [];
361
+
362
+ for (const c of classifications) {
363
+ if (c.classification !== 'server_derived') continue;
364
+ if (c.producerSeq == null || !c.producerPath) continue;
365
+
366
+ const producerIndex = seqToIndex.get(c.producerSeq);
367
+ if (producerIndex == null) continue;
368
+
369
+ const consumerIndex = seqToIndex.get(c.originalSeq);
370
+ if (consumerIndex == null) continue;
371
+
372
+ const name = c.suggestedStateName ?? `state_${producerIndex}_${consumerIndex}`;
373
+ const capture = buildCaptureFromPath(name, c.producerPath);
374
+ if (!capture) continue;
375
+
376
+ hints.push({
377
+ producerRequestIndex: producerIndex,
378
+ capture,
379
+ usedBy: [
380
+ {
381
+ requestIndex: consumerIndex,
382
+ location: c.location,
383
+ substitution: `\${state.${name}}`,
384
+ },
385
+ ],
386
+ });
387
+ }
388
+
389
+ return deduplicateCaptureHints(hints);
390
+ }
391
+
392
+ function buildCaptureFromPath(name: string, producerPath: string): CaptureHint['capture'] | null {
393
+ if (producerPath.startsWith('response_header:')) {
394
+ return {
395
+ source: 'response_header',
396
+ name,
397
+ header: producerPath.slice('response_header:'.length),
398
+ };
399
+ }
400
+ if (producerPath.startsWith('set-cookie:')) {
401
+ return {
402
+ source: 'cookie',
403
+ name,
404
+ cookie: producerPath.slice('set-cookie:'.length),
405
+ };
406
+ }
407
+ if (producerPath.startsWith('$') || producerPath.startsWith('.')) {
408
+ return { source: 'json', name, path: producerPath };
409
+ }
410
+ if (producerPath.includes('.')) {
411
+ return { source: 'json', name, path: `$.${producerPath}` };
412
+ }
413
+ return null;
414
+ }
415
+
416
+ function deduplicateCaptureHints(hints: CaptureHint[]): CaptureHint[] {
417
+ const byKey = new Map<string, CaptureHint>();
418
+ for (const hint of hints) {
419
+ const key = `${hint.producerRequestIndex}:${hint.capture.name}`;
420
+ const existing = byKey.get(key);
421
+ if (existing) {
422
+ existing.usedBy.push(...hint.usedBy);
423
+ } else {
424
+ byKey.set(key, { ...hint, usedBy: [...hint.usedBy] });
425
+ }
426
+ }
427
+ return [...byKey.values()];
428
+ }
429
+
430
+ function buildStateHints(
431
+ session: Session,
432
+ dualPassClassifications?: ClassifiedValue[],
433
+ ): Array<Record<string, unknown>> {
434
+ const hints: Array<Record<string, unknown>> = [];
435
+ const cookieMarkers = new Map<string, Array<{ requestSeq: number; cookie: string }>>();
436
+ const storageMarkers = new Map<string, { origin: string; kind: string; key: string }>();
437
+
438
+ for (const snap of session.storageSnapshots ?? []) {
439
+ for (const [key, value] of Object.entries(snap.localStorage ?? {})) {
440
+ if (isEqualityMarker(value)) {
441
+ storageMarkers.set(value, { origin: snap.origin, kind: 'localStorage', key });
442
+ }
443
+ }
444
+ for (const [key, value] of Object.entries(snap.sessionStorage ?? {})) {
445
+ if (isEqualityMarker(value)) {
446
+ storageMarkers.set(value, { origin: snap.origin, kind: 'sessionStorage', key });
447
+ }
448
+ }
449
+ }
450
+
451
+ for (const req of session.requests) {
452
+ const setCookie = Object.entries(req.response?.headers ?? {}).find(
453
+ ([name]) => name.toLowerCase() === 'set-cookie',
454
+ )?.[1];
455
+ if (setCookie) {
456
+ for (const cookie of splitSetCookieHeader(setCookie)) {
457
+ const first = cookie.split(';', 1)[0] ?? '';
458
+ const eq = first.indexOf('=');
459
+ if (eq <= 0) continue;
460
+ const name = first.slice(0, eq);
461
+ const marker = first.slice(eq + 1);
462
+ if (isEqualityMarker(marker)) {
463
+ const existing = cookieMarkers.get(marker) ?? [];
464
+ existing.push({ requestSeq: req.seq, cookie: name });
465
+ cookieMarkers.set(marker, existing);
466
+ }
467
+ }
468
+ }
469
+
470
+ for (const [field, value] of requestValues(req)) {
471
+ for (const marker of equalityMarkers(value)) {
472
+ const cookies = cookieMarkers.get(marker);
473
+ if (cookies) {
474
+ for (const cookie of cookies) {
475
+ if (cookie.requestSeq < req.seq) {
476
+ hints.push({
477
+ type: 'request_field_equals_earlier_set_cookie',
478
+ producerSeq: cookie.requestSeq,
479
+ consumerSeq: req.seq,
480
+ cookie: cookie.cookie,
481
+ requestField: field,
482
+ });
483
+ }
484
+ }
485
+ }
486
+ const storage = storageMarkers.get(marker);
487
+ if (storage) {
488
+ hints.push({
489
+ type: 'request_field_equals_storage_key',
490
+ consumerSeq: req.seq,
491
+ requestField: field,
492
+ ...storage,
493
+ });
494
+ }
495
+ }
496
+ }
497
+ }
498
+
499
+ // Detect per-call query params: params whose values change across repeated
500
+ // requests to the same URL path. These are browser-minted (computed by
501
+ // in-page JS per call) and cannot be hardcoded or derived from prior responses.
502
+ const urlsByPath = new Map<string, Array<{ seq: number; params: URLSearchParams }>>();
503
+ for (const req of session.requests) {
504
+ try {
505
+ const url = new URL(req.url);
506
+ const pathKey = `${url.hostname}${url.pathname}`;
507
+ const existing = urlsByPath.get(pathKey) ?? [];
508
+ existing.push({ seq: req.seq, params: url.searchParams });
509
+ urlsByPath.set(pathKey, existing);
510
+ } catch {
511
+ // skip malformed URLs
512
+ }
513
+ }
514
+ for (const [pathKey, entries] of urlsByPath) {
515
+ if (entries.length < 2) continue;
516
+ const firstEntry = entries[0];
517
+ if (!firstEntry) continue;
518
+ for (const paramName of firstEntry.params.keys()) {
519
+ const values = new Set(entries.map((e) => e.params.get(paramName) ?? ''));
520
+ if (values.size > 1) {
521
+ const sample = entries[0]?.params.get(paramName) ?? '';
522
+ const looksHighEntropy = sample.length > 20 && /[+/=A-Z0-9]{10,}/i.test(sample);
523
+ if (looksHighEntropy) {
524
+ hints.push({
525
+ type: 'query_param_changes_across_calls',
526
+ urlPath: pathKey,
527
+ paramName,
528
+ distinctValues: values.size,
529
+ sampleSeqs: entries.slice(0, 3).map((e) => e.seq),
530
+ note: `Query param "${paramName}" has ${values.size} distinct high-entropy values across ${entries.length} requests to the same URL path. This is likely a URL signing token computed by client-side JavaScript. Use search_response_body to find the signing function in .js responses, then write a requestTransformModule that replicates the computation.`,
531
+ });
532
+ }
533
+ }
534
+ }
535
+ }
536
+
537
+ if (dualPassClassifications) {
538
+ for (const c of dualPassClassifications) {
539
+ if (c.classification === 'constant') continue;
540
+ const note =
541
+ c.classification === 'server_derived'
542
+ ? `This value differs across independent executions and was found in response seq ${c.producerSeq} at ${c.producerPath}. Use a capture on that request and reference via \${state.${c.suggestedStateName ?? 'NAME'}}.`
543
+ : 'This value differs across independent executions and is NOT traceable to any prior server response. It is browser-minted (computed by client-side JS). Consider: bootstrap capture (if session-scoped), requestTransformModule (if per-request), or stealth_bootstrap (if bot-defense).';
544
+ hints.push({
545
+ type: 'dual_pass_value_classification',
546
+ classification: c.classification,
547
+ originalSeq: c.originalSeq,
548
+ location: c.location,
549
+ value1: c.value1,
550
+ value2: c.value2,
551
+ producerSeq: c.producerSeq,
552
+ producerPath: c.producerPath,
553
+ suggestedStateName: c.suggestedStateName,
554
+ note,
555
+ });
556
+ }
557
+ }
558
+
559
+ return hints;
560
+ }
561
+
562
+ function requestValues(req: CapturedRequest): Array<[string, string]> {
563
+ const values: Array<[string, string]> = [['url', req.url]];
564
+ for (const [name, value] of Object.entries(req.headers)) values.push([`header:${name}`, value]);
565
+ if (req.body) values.push(['body', req.body]);
566
+ return values;
567
+ }
568
+
569
+ function equalityMarkers(value: string): string[] {
570
+ return value.match(/\[REDACTED:v3:id=\d+:len=\d+\]/g) ?? [];
571
+ }
572
+
573
+ function isEqualityMarker(value: string): boolean {
574
+ return /^\[REDACTED:v3:id=\d+:len=\d+\]$/.test(value);
575
+ }
576
+
577
+ interface CompileSummaryRequestContext {
578
+ seq: number;
579
+ timestamp: number;
580
+ selectedForCandidate: boolean;
581
+ sharedDependency: boolean;
582
+ method: string;
583
+ url: string;
584
+ status?: number;
585
+ mimeType?: string;
586
+ bodySize?: number;
587
+ responseBodyDigest?: string;
588
+ repeatCount?: number;
589
+ repeatedSeqs?: number[];
590
+ lastTimestamp?: number;
591
+ }
592
+
593
+ function compileSummaryRequestGroupKey(request: CompileSummaryRequestContext): unknown[] {
594
+ return [
595
+ request.method,
596
+ request.url,
597
+ request.status,
598
+ request.mimeType,
599
+ request.bodySize,
600
+ request.responseBodyDigest,
601
+ ];
602
+ }
603
+
604
+ function identifyLoadBearingRequests(session: Session): CapturedRequest[] {
605
+ const startUrl = safeUrl(session.url);
606
+ const startRoot = startUrl ? registrableDomain(startUrl.hostname) : null;
607
+ const appApiHosts = inferAppApiHosts(session, startRoot);
608
+
609
+ return session.requests.filter((r) => {
610
+ const url = safeUrl(r.url);
611
+ if (!url) return false;
612
+ if (
613
+ startRoot &&
614
+ !isSameRegistrableDomain(url.hostname, startRoot) &&
615
+ !appApiHosts.has(url.hostname)
616
+ )
617
+ return false;
618
+ if (r.resourceType !== 'XHR' && r.resourceType !== 'Fetch') return false;
619
+ if (!r.response || r.response.status < 200 || r.response.status >= 300) return false;
620
+ if (!r.response.body) return false;
621
+ return true;
622
+ });
623
+ }
624
+
625
+ function identifySummaryRequests(session: Session, preserveSeqs: Set<number>): CapturedRequest[] {
626
+ return session.requests.filter((r) => preserveSeqs.has(r.seq)).sort((a, b) => a.seq - b.seq);
627
+ }
628
+
629
+ function safeUrl(s: string): URL | null {
630
+ try {
631
+ return new URL(s);
632
+ } catch {
633
+ return null;
634
+ }
635
+ }
636
+
637
+ // ─── Tool: read_request ──────────────────────────────────────────────────────
638
+
639
+ function buildReadRequestTool(session: Session): AgentTool {
640
+ return {
641
+ name: 'read_request',
642
+ description: 'Get the full request including method, URL, headers, and body for a given seq.',
643
+ input_schema: {
644
+ type: 'object',
645
+ properties: {
646
+ seq: { type: 'number', description: 'Request sequence number' },
647
+ },
648
+ required: ['seq'],
649
+ },
650
+ handler: async (input: unknown) => {
651
+ const { seq } = input as { seq: number };
652
+ const req = session.requests.find((r) => r.seq === seq);
653
+ if (!req) {
654
+ return { result: `Request seq ${seq} not found`, isError: true };
655
+ }
656
+ const summary = {
657
+ method: req.method,
658
+ url: req.url,
659
+ headers: req.headers,
660
+ body: req.body,
661
+ response: req.response
662
+ ? {
663
+ status: req.response.status,
664
+ headers: req.response.headers,
665
+ mimeType: req.response.mimeType,
666
+ bodyLength: req.response.body?.length,
667
+ }
668
+ : undefined,
669
+ };
670
+
671
+ return { result: JSON.stringify(summary, null, 2) };
672
+ },
673
+ };
674
+ }
675
+
676
+ // ─── Tool: read_response_body ────────────────────────────────────────────────
677
+
678
+ function buildReadResponseBodyTool(session: Session): AgentTool {
679
+ return {
680
+ name: 'read_response_body',
681
+ description:
682
+ 'Get the response body for a given seq, with optional pagination via offset/length.',
683
+ input_schema: {
684
+ type: 'object',
685
+ properties: {
686
+ seq: { type: 'number', description: 'Request sequence number' },
687
+ offset: { type: 'number', description: 'Starting byte offset (default 0)' },
688
+ length: {
689
+ type: 'number',
690
+ description: 'Number of bytes to read (default 50000, max 100000)',
691
+ },
692
+ },
693
+ required: ['seq'],
694
+ },
695
+ handler: async (input: unknown) => {
696
+ const {
697
+ seq,
698
+ offset = 0,
699
+ length = 50000,
700
+ } = input as {
701
+ seq: number;
702
+ offset?: number;
703
+ length?: number;
704
+ };
705
+ const req = session.requests.find((r) => r.seq === seq);
706
+ if (!req) {
707
+ return { result: `Request seq ${seq} not found`, isError: true };
708
+ }
709
+ if (!req.response?.body) {
710
+ return { result: `no response body captured for seq ${seq}`, isError: true };
711
+ }
712
+
713
+ const body = req.response.body;
714
+ const totalLength = body.length;
715
+ const cappedLength = Math.min(length, 100000);
716
+ const slice = body.slice(offset, offset + cappedLength);
717
+
718
+ let isJson = false;
719
+ try {
720
+ JSON.parse(body);
721
+ isJson = true;
722
+ } catch {
723
+ // not JSON
724
+ }
725
+
726
+ return {
727
+ result: JSON.stringify(
728
+ {
729
+ body: slice,
730
+ totalLength,
731
+ isJson,
732
+ offset,
733
+ returnedLength: slice.length,
734
+ },
735
+ null,
736
+ 2,
737
+ ),
738
+ };
739
+ },
740
+ };
741
+ }
742
+
743
+ // ─── Tool: search_response_body ──────────────────────────────────────────────
744
+
745
+ function buildSearchResponseBodyTool(session: Session): AgentTool {
746
+ return {
747
+ name: 'search_response_body',
748
+ description:
749
+ 'Search for a substring in a response body and return matching offsets with context.',
750
+ input_schema: {
751
+ type: 'object',
752
+ properties: {
753
+ seq: { type: 'number', description: 'Request sequence number' },
754
+ query: { type: 'string', description: 'Search string (case-sensitive)' },
755
+ contextChars: {
756
+ type: 'number',
757
+ description: 'Characters to include before and after match (default 80)',
758
+ },
759
+ maxMatches: {
760
+ type: 'number',
761
+ description: 'Maximum number of matches to return (default 20)',
762
+ },
763
+ },
764
+ required: ['seq', 'query'],
765
+ },
766
+ handler: async (input: unknown) => {
767
+ const {
768
+ seq,
769
+ query,
770
+ contextChars = 80,
771
+ maxMatches = 20,
772
+ } = input as {
773
+ seq: number;
774
+ query: string;
775
+ contextChars?: number;
776
+ maxMatches?: number;
777
+ };
778
+ const req = session.requests.find((r) => r.seq === seq);
779
+ if (!req || !req.response?.body) {
780
+ return { result: `no response body for seq ${seq}`, isError: true };
781
+ }
782
+
783
+ const body = req.response.body;
784
+ const matches: { offset: number; snippet: string }[] = [];
785
+ let searchStart = 0;
786
+
787
+ while (matches.length < maxMatches) {
788
+ const idx = body.indexOf(query, searchStart);
789
+ if (idx === -1) break;
790
+
791
+ const start = Math.max(0, idx - contextChars);
792
+ const end = Math.min(body.length, idx + query.length + contextChars);
793
+ const snippet = body.slice(start, end);
794
+
795
+ matches.push({ offset: idx, snippet });
796
+ searchStart = idx + query.length;
797
+ }
798
+
799
+ return { result: JSON.stringify(matches, null, 2) };
800
+ },
801
+ };
802
+ }
803
+
804
+ // ─── Tool: write_file ────────────────────────────────────────────────────────
805
+
806
+ function buildWriteFileTool(toolDir: string): AgentTool {
807
+ return {
808
+ name: 'write_file',
809
+ description:
810
+ 'Write a file to the generated tool directory. Allowed paths: workflow.json, parser.ts, parser.test.ts, notes/*.md',
811
+ input_schema: {
812
+ type: 'object',
813
+ properties: {
814
+ relativePath: { type: 'string', description: 'Relative path within the tool directory' },
815
+ content: { type: 'string', description: 'File content to write' },
816
+ },
817
+ required: ['relativePath', 'content'],
818
+ },
819
+ handler: async (input: unknown) => {
820
+ const { relativePath, content } = input as { relativePath: string; content: string };
821
+
822
+ if (relativePath.includes('..') || relativePath.startsWith('/')) {
823
+ return {
824
+ result: `invalid relativePath: "${relativePath}" — must not contain ".." or start with "/"`,
825
+ isError: true,
826
+ };
827
+ }
828
+
829
+ const allowed = [
830
+ 'workflow.json',
831
+ 'parser.ts',
832
+ 'parser.test.ts',
833
+ 'request-transform.ts',
834
+ 'integration.test.ts',
835
+ ];
836
+ const isNotes = relativePath.startsWith('notes/') && relativePath.endsWith('.md');
837
+ if (!allowed.includes(relativePath) && !isNotes) {
838
+ return {
839
+ result: `relativePath "${relativePath}" not allowed — must be one of: ${allowed.join(', ')}, or notes/*.md`,
840
+ isError: true,
841
+ };
842
+ }
843
+
844
+ const absolutePath = pathJoin(toolDir, relativePath);
845
+ mkdirSync(dirname(absolutePath), { recursive: true });
846
+ writeFileSync(absolutePath, content, 'utf8');
847
+
848
+ return {
849
+ result: JSON.stringify({
850
+ bytesWritten: Buffer.byteLength(content, 'utf8'),
851
+ absolutePath,
852
+ }),
853
+ };
854
+ },
855
+ };
856
+ }
857
+
858
+ // ─── Tool: read_file ─────────────────────────────────────────────────────────
859
+
860
+ function buildReadFileTool(toolDir: string): AgentTool {
861
+ return {
862
+ name: 'read_file',
863
+ description: 'Read a file in the generated tool directory.',
864
+ input_schema: {
865
+ type: 'object',
866
+ properties: {
867
+ path: {
868
+ type: 'string',
869
+ description: 'Relative path within the tool directory (e.g. parser.ts, workflow.json)',
870
+ },
871
+ },
872
+ required: ['path'],
873
+ },
874
+ handler: async (input: unknown) => {
875
+ const { path } = input as { path: string };
876
+
877
+ if (path.includes('..') || path.startsWith('/')) {
878
+ return {
879
+ result: `invalid path: "${path}" — must be a relative path within the tool directory, no ".." or leading "/"`,
880
+ isError: true,
881
+ };
882
+ }
883
+
884
+ const absolutePath = pathJoin(toolDir, path);
885
+ const allowedRoots = [toolDir];
886
+
887
+ const isAllowed = allowedRoots.some((root) => absolutePath.startsWith(root));
888
+ if (!isAllowed) {
889
+ return {
890
+ result: `path "${path}" not allowed — must be a relative path within the tool directory`,
891
+ isError: true,
892
+ };
893
+ }
894
+
895
+ if (!existsSync(absolutePath)) {
896
+ return { result: `file not found: ${absolutePath}`, isError: true };
897
+ }
898
+
899
+ let content = readFileSync(absolutePath, 'utf8');
900
+ const MAX_SIZE = 100 * 1024; // 100KB
901
+ if (content.length > MAX_SIZE) {
902
+ content = `${content.slice(0, MAX_SIZE)}\n[…truncated…]`;
903
+ }
904
+
905
+ return {
906
+ result: JSON.stringify({
907
+ content,
908
+ size: content.length,
909
+ }),
910
+ };
911
+ },
912
+ };
913
+ }
914
+
915
+ // ─── Tool: run_bash ──────────────────────────────────────────────────────────
916
+
917
+ function buildRunBashTool(toolDir: string, credEnv?: Record<string, string>): AgentTool {
918
+ return {
919
+ name: 'run_bash',
920
+ description: 'Run a shell command in the generated tool directory with a timeout.',
921
+ input_schema: {
922
+ type: 'object',
923
+ properties: {
924
+ command: { type: 'string', description: 'Shell command to execute' },
925
+ timeoutSec: { type: 'number', description: 'Timeout in seconds (default 60, max 300)' },
926
+ },
927
+ required: ['command'],
928
+ },
929
+ handler: async (input: unknown) => {
930
+ const { command, timeoutSec = 60 } = input as { command: string; timeoutSec?: number };
931
+
932
+ if (command.match(/rm\s+-rf\s+\//) || command.includes('sudo')) {
933
+ return {
934
+ result: 'blocked destructive command — rm -rf / and sudo are not allowed',
935
+ isError: true,
936
+ };
937
+ }
938
+
939
+ const cappedTimeout = Math.min(timeoutSec, 300) * 1000;
940
+
941
+ return await runCommand(command, toolDir, cappedTimeout, credEnv);
942
+ },
943
+ };
944
+ }
945
+
946
+ async function runCommand(
947
+ command: string,
948
+ cwd: string,
949
+ timeoutMs: number,
950
+ extraEnv?: Record<string, string>,
951
+ ): Promise<{ result: string; isError?: boolean }> {
952
+ return new Promise((resolve) => {
953
+ const proc = spawn('sh', ['-c', command], {
954
+ cwd,
955
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
956
+ });
957
+
958
+ let stdout = '';
959
+ let stderr = '';
960
+ let timedOut = false;
961
+
962
+ const TRUNCATE_LIMIT = 16 * 1024; // 16KB
963
+
964
+ proc.stdout.on('data', (chunk) => {
965
+ stdout += chunk.toString();
966
+ });
967
+
968
+ proc.stderr.on('data', (chunk) => {
969
+ stderr += chunk.toString();
970
+ });
971
+
972
+ const timeout = setTimeout(() => {
973
+ timedOut = true;
974
+ proc.kill();
975
+ }, timeoutMs);
976
+
977
+ proc.on('close', (exitCode) => {
978
+ clearTimeout(timeout);
979
+
980
+ if (stdout.length > TRUNCATE_LIMIT) {
981
+ stdout = `${stdout.slice(0, TRUNCATE_LIMIT)}\n[…truncated…]`;
982
+ }
983
+ if (stderr.length > TRUNCATE_LIMIT) {
984
+ stderr = `${stderr.slice(0, TRUNCATE_LIMIT)}\n[…truncated…]`;
985
+ }
986
+
987
+ resolve({
988
+ result: JSON.stringify({
989
+ stdout,
990
+ stderr,
991
+ exitCode: exitCode ?? -1,
992
+ timedOut,
993
+ }),
994
+ isError: (exitCode ?? -1) !== 0 || timedOut,
995
+ });
996
+ });
997
+ });
998
+ }
999
+
1000
+ async function runGeneratedArtifactTypecheck(
1001
+ exampleDir: string,
1002
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
1003
+ const configPath = pathJoin(exampleDir, '.imprint-typecheck.tsconfig.json');
1004
+ const rootTsconfig = pathJoin(REPO_ROOT, 'tsconfig.json');
1005
+ const extendsPath = normalizeTsconfigPath(pathRelative(exampleDir, rootTsconfig));
1006
+
1007
+ writeFileSync(
1008
+ configPath,
1009
+ JSON.stringify(
1010
+ {
1011
+ extends: extendsPath,
1012
+ include: ['parser.ts', 'request-transform.ts'],
1013
+ exclude: ['*.test.ts'],
1014
+ },
1015
+ null,
1016
+ 2,
1017
+ ),
1018
+ 'utf8',
1019
+ );
1020
+
1021
+ try {
1022
+ const result = await runCommand(
1023
+ 'bunx tsc --noEmit -p .imprint-typecheck.tsconfig.json',
1024
+ exampleDir,
1025
+ 120000,
1026
+ );
1027
+ return JSON.parse(result.result) as {
1028
+ stdout: string;
1029
+ stderr: string;
1030
+ exitCode: number;
1031
+ timedOut: boolean;
1032
+ };
1033
+ } finally {
1034
+ try {
1035
+ unlinkSync(configPath);
1036
+ } catch {
1037
+ // Best-effort cleanup only.
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ function normalizeTsconfigPath(value: string): string {
1043
+ const normalized = value.replace(/\\/g, '/');
1044
+ return normalized.startsWith('.') ? normalized : `./${normalized}`;
1045
+ }
1046
+
1047
+ // ─── Tool: run_tests ─────────────────────────────────────────────────────────
1048
+
1049
+ function buildRunTestsTool(
1050
+ toolDir: string,
1051
+ sessionPath: string,
1052
+ credEnv?: Record<string, string>,
1053
+ ): AgentTool {
1054
+ return {
1055
+ name: 'run_tests',
1056
+ description: 'Run bun test parser.test.ts and parse the output for pass/fail counts.',
1057
+ input_schema: {
1058
+ type: 'object',
1059
+ properties: {},
1060
+ required: [],
1061
+ },
1062
+ handler: async () => {
1063
+ const testPath = pathJoin(toolDir, 'parser.test.ts');
1064
+ if (!existsSync(testPath)) {
1065
+ return {
1066
+ result: 'parser.test.ts does not exist — write it first',
1067
+ isError: true,
1068
+ };
1069
+ }
1070
+
1071
+ const cmdResult = await runCommand('bun test parser.test.ts', toolDir, 120000, {
1072
+ [SESSION_PATH_ENV]: sessionPath,
1073
+ ...credEnv,
1074
+ });
1075
+
1076
+ const output = JSON.parse(cmdResult.result) as {
1077
+ stdout: string;
1078
+ stderr: string;
1079
+ exitCode: number;
1080
+ timedOut: boolean;
1081
+ };
1082
+
1083
+ const passMatch = output.stdout.match(/(\d+)\s+pass/);
1084
+ const failMatch = output.stdout.match(/(\d+)\s+fail/);
1085
+
1086
+ const passed = passMatch?.[1] ? Number.parseInt(passMatch[1], 10) : 0;
1087
+ const failed = failMatch?.[1] ? Number.parseInt(failMatch[1], 10) : 0;
1088
+ const total = passed + failed;
1089
+
1090
+ return {
1091
+ result: JSON.stringify({
1092
+ stdout: output.stdout,
1093
+ stderr: output.stderr,
1094
+ exitCode: output.exitCode,
1095
+ passed,
1096
+ failed,
1097
+ total,
1098
+ timedOut: output.timedOut,
1099
+ }),
1100
+ isError: output.exitCode !== 0 || output.timedOut,
1101
+ };
1102
+ },
1103
+ };
1104
+ }
1105
+
1106
+ // ─── External Verification ──────────────────────────────────────────────────
1107
+
1108
+ export async function externalVerification(
1109
+ toolDir: string,
1110
+ session: Session,
1111
+ sessionPath: string,
1112
+ opts: {
1113
+ expectedToolName?: string;
1114
+ likelyParams?: Array<{ name: string; type?: string; description?: string }>;
1115
+ candidateRequestSeqs?: number[];
1116
+ } = {},
1117
+ ): Promise<{ failures: string[]; warnings: string[] }> {
1118
+ const failures: string[] = [];
1119
+ const warnings: string[] = [];
1120
+
1121
+ const workflowPath = pathJoin(toolDir, 'workflow.json');
1122
+ const parserPath = pathJoin(toolDir, 'parser.ts');
1123
+ const parserTestPath = pathJoin(toolDir, 'parser.test.ts');
1124
+
1125
+ if (!existsSync(workflowPath)) {
1126
+ failures.push('workflow.json was not written');
1127
+ } else {
1128
+ try {
1129
+ const raw = JSON.parse(readFileSync(workflowPath, 'utf8'));
1130
+ const workflow = WorkflowSchema.parse(raw);
1131
+ if (opts.expectedToolName && workflow.toolName !== opts.expectedToolName) {
1132
+ failures.push(
1133
+ `workflow.toolName "${workflow.toolName}" does not match selected candidate "${opts.expectedToolName}"`,
1134
+ );
1135
+ }
1136
+ const wfStr = JSON.stringify(raw);
1137
+ const envMatches = wfStr.match(/\$\{env\.[A-Za-z0-9_.]+\}/g);
1138
+ if (envMatches && envMatches.length > 0) {
1139
+ failures.push(
1140
+ `workflow.json contains \${env.X} placeholders (${envMatches.join(', ')}). These require manual environment setup and break portability. If the value appeared in the recorded session, hardcode it as a literal string instead.`,
1141
+ );
1142
+ }
1143
+
1144
+ if (opts.likelyParams && opts.likelyParams.length > 0) {
1145
+ // Build the set of query param keys from the original recorded URLs
1146
+ // so we can distinguish real API params from invented ones.
1147
+ const originalQueryParamKeys = new Set<string>();
1148
+ if (opts.candidateRequestSeqs) {
1149
+ for (const seq of opts.candidateRequestSeqs) {
1150
+ const recorded = session.requests.find((r) => r.seq === seq);
1151
+ if (recorded) {
1152
+ try {
1153
+ const url = new URL(recorded.url);
1154
+ for (const key of url.searchParams.keys()) {
1155
+ originalQueryParamKeys.add(key);
1156
+ }
1157
+ } catch {
1158
+ /* skip malformed URLs */
1159
+ }
1160
+ }
1161
+ }
1162
+ }
1163
+
1164
+ const notTemplated: string[] = [];
1165
+ const inventedOnly: string[] = [];
1166
+
1167
+ for (const lp of opts.likelyParams) {
1168
+ const placeholder = `\${param.${lp.name}}`;
1169
+ let inBody = false;
1170
+ let inHeader = false;
1171
+ let inOriginalQuery = false;
1172
+ let inInventedQuery = false;
1173
+
1174
+ for (const req of workflow.requests) {
1175
+ if (req.body?.includes(placeholder)) inBody = true;
1176
+
1177
+ for (const hv of Object.values(req.headers)) {
1178
+ if (hv.includes(placeholder)) inHeader = true;
1179
+ }
1180
+
1181
+ if (req.url.includes(placeholder)) {
1182
+ const qIdx = req.url.indexOf('?');
1183
+ if (qIdx >= 0 && req.url.indexOf(placeholder) > qIdx) {
1184
+ const queryStr = req.url.slice(qIdx + 1);
1185
+ for (const pair of queryStr.split('&')) {
1186
+ if (pair.includes(placeholder)) {
1187
+ const eqIdx = pair.indexOf('=');
1188
+ const paramKey = eqIdx >= 0 ? pair.slice(0, eqIdx) : pair;
1189
+ if (originalQueryParamKeys.has(paramKey)) {
1190
+ inOriginalQuery = true;
1191
+ } else {
1192
+ inInventedQuery = true;
1193
+ }
1194
+ }
1195
+ }
1196
+ } else {
1197
+ inBody = true;
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ if (!inBody && !inHeader && !inOriginalQuery && !inInventedQuery) {
1203
+ notTemplated.push(lp.name);
1204
+ } else if (!inBody && !inHeader && !inOriginalQuery && inInventedQuery) {
1205
+ inventedOnly.push(lp.name);
1206
+ }
1207
+ }
1208
+
1209
+ if (notTemplated.length > 0) {
1210
+ failures.push(
1211
+ `${notTemplated.length} likelyParam(s) are not templated in any request: ${notTemplated.join(', ')}. Each must appear as \${param.NAME} in a request URL, body, or header. For parameters recorded as null or [] (filters the user toggled but didn\'t apply), find the correct position in the request body and replace the placeholder value with \${param.NAME}.`,
1212
+ );
1213
+ }
1214
+ if (inventedOnly.length > 0) {
1215
+ warnings.push(
1216
+ `${inventedOnly.length} likelyParam(s) are templated only in URL query params that do not exist in any recorded request URL: ${inventedOnly.join(', ')}. The API server likely ignores these invented params — wire them into the request body or an existing query param instead. For complex body formats, use a requestTransformModule to construct the body programmatically.`,
1217
+ );
1218
+ }
1219
+ }
1220
+ } catch (err) {
1221
+ failures.push(
1222
+ `workflow.json schema invalid: ${err instanceof Error ? err.message : String(err)}`,
1223
+ );
1224
+ }
1225
+ }
1226
+
1227
+ if (existsSync(workflowPath) && existsSync(parserPath)) {
1228
+ try {
1229
+ const raw = JSON.parse(readFileSync(workflowPath, 'utf8'));
1230
+ if (!raw.parserModule) {
1231
+ failures.push(
1232
+ 'parser.ts exists but workflow.json does not declare "parserModule": "./parser.ts" — the parser will be dead code at runtime',
1233
+ );
1234
+ }
1235
+ } catch {
1236
+ // workflow parse already flagged above
1237
+ }
1238
+ }
1239
+
1240
+ if (!existsSync(parserPath)) {
1241
+ failures.push('parser.ts was not written');
1242
+ } else {
1243
+ try {
1244
+ const cacheBust = `?t=${Date.now()}`;
1245
+ const fileUrl = `file://${parserPath}${cacheBust}`;
1246
+ const mod = await import(fileUrl);
1247
+ if (typeof mod.extract !== 'function') {
1248
+ failures.push('parser.ts must export `extract` function');
1249
+ }
1250
+ } catch (err) {
1251
+ failures.push(`parser.ts import failed: ${err instanceof Error ? err.message : String(err)}`);
1252
+ }
1253
+ }
1254
+
1255
+ if (!existsSync(parserTestPath)) {
1256
+ failures.push('parser.test.ts was not written');
1257
+ } else {
1258
+ const src = readFileSync(parserTestPath, 'utf8');
1259
+ const expectMatches = src.match(/expect\s*\(/g) ?? [];
1260
+ if (expectMatches.length < 3) {
1261
+ failures.push(`parser.test.ts has only ${expectMatches.length} expect() calls; need ≥3`);
1262
+ }
1263
+
1264
+ const trivialPatterns = [
1265
+ /expect\s*\(\s*true\s*\)\.toBe\s*\(\s*true\s*\)/,
1266
+ /expect\s*\(\s*false\s*\)\.toBe\s*\(\s*false\s*\)/,
1267
+ /expect\s*\(\s*1\s*\)\.toBe\s*\(\s*1\s*\)/,
1268
+ /expect\s*\(\s*0\s*\)\.toBe\s*\(\s*0\s*\)/,
1269
+ /expect\s*\(\s*null\s*\)\.toBeNull/,
1270
+ /expect\s*\(\s*undefined\s*\)\.toBeUndefined/,
1271
+ /expect\s*\(\s*"[^"]*"\s*\)\.toBe\s*\(\s*"[^"]*"\s*\)/,
1272
+ /expect\s*\(\s*'[^']*'\s*\)\.toBe\s*\(\s*'[^']*'\s*\)/,
1273
+ ];
1274
+ for (const pattern of trivialPatterns) {
1275
+ if (pattern.test(src)) {
1276
+ failures.push(
1277
+ 'parser.test.ts contains trivial tautological assertions like expect(true).toBe(true) — tests must reference real values',
1278
+ );
1279
+ break;
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ if (existsSync(parserTestPath)) {
1285
+ const result = await runCommand(`bun test ${parserTestPath}`, toolDir, 120000, {
1286
+ [SESSION_PATH_ENV]: sessionPath,
1287
+ });
1288
+ const output = JSON.parse(result.result) as {
1289
+ stdout: string;
1290
+ stderr: string;
1291
+ exitCode: number;
1292
+ };
1293
+ if (output.exitCode !== 0) {
1294
+ failures.push(
1295
+ `bun test parser.test.ts exited ${output.exitCode}\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`,
1296
+ );
1297
+ }
1298
+ }
1299
+
1300
+ const integrationTestPath = pathJoin(toolDir, 'integration.test.ts');
1301
+ if (!existsSync(integrationTestPath)) {
1302
+ failures.push(
1303
+ 'integration.test.ts was not written — the tool must include a live API test that calls the workflow and verifies it returns real data',
1304
+ );
1305
+ } else {
1306
+ let integrationPassed = false;
1307
+ let lastOutput = { stdout: '', stderr: '', exitCode: 1 };
1308
+ for (let attempt = 0; attempt < 3; attempt++) {
1309
+ const result = await runCommand(`bun test ${integrationTestPath}`, toolDir, 60000);
1310
+ lastOutput = JSON.parse(result.result) as {
1311
+ stdout: string;
1312
+ stderr: string;
1313
+ exitCode: number;
1314
+ };
1315
+ if (lastOutput.exitCode === 0) {
1316
+ integrationPassed = true;
1317
+ break;
1318
+ }
1319
+ }
1320
+ if (!integrationPassed) {
1321
+ const combined = `${lastOutput.stdout}\n${lastOutput.stderr}`;
1322
+ const botSignatures = /PerimeterX|DataDome|Akamai|captcha|challenge|blocked|rate.?limit/i;
1323
+ const hasStatusBlock = /\b(403|429)\b/.test(combined);
1324
+ if (hasStatusBlock && botSignatures.test(combined)) {
1325
+ warnings.push(
1326
+ `integration test failed with likely bot-detection or rate-limiting (tried 3 times) — treating as non-blocking since parser verification passed.\nstdout:\n${lastOutput.stdout}\nstderr:\n${lastOutput.stderr}`,
1327
+ );
1328
+ } else {
1329
+ failures.push(
1330
+ `bun test integration.test.ts exited ${lastOutput.exitCode} — the workflow failed to produce live data (tried 3 times).\nstdout:\n${lastOutput.stdout}\nstderr:\n${lastOutput.stderr}`,
1331
+ );
1332
+ }
1333
+ }
1334
+ }
1335
+
1336
+ if (existsSync(parserPath) || existsSync(parserTestPath)) {
1337
+ const output = await runGeneratedArtifactTypecheck(toolDir);
1338
+ if (output.exitCode !== 0 || output.timedOut) {
1339
+ failures.push(
1340
+ `generated TypeScript artifacts failed typecheck (bunx tsc --noEmit -p .imprint-typecheck.tsconfig.json) exited ${output.exitCode}${output.timedOut ? ' after timing out' : ''}\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`,
1341
+ );
1342
+ }
1343
+ }
1344
+
1345
+ const loadBearing = identifyLoadBearingRequests(session);
1346
+ if (loadBearing.length > 0 && existsSync(parserPath)) {
1347
+ const firstReq = loadBearing[0];
1348
+ if (firstReq?.response?.body) {
1349
+ try {
1350
+ const cacheBust = `?t=${Date.now()}`;
1351
+ const fileUrl = `file://${parserPath}${cacheBust}`;
1352
+ const mod = await import(fileUrl);
1353
+ if (typeof mod.extract === 'function') {
1354
+ let raw: unknown;
1355
+ const responseBody = firstReq.response.body;
1356
+ try {
1357
+ raw = JSON.parse(responseBody);
1358
+ } catch {
1359
+ raw = responseBody;
1360
+ }
1361
+
1362
+ const allResponses = loadBearing.map((r) => {
1363
+ try {
1364
+ return r.response?.body ? JSON.parse(r.response.body) : r.response?.body;
1365
+ } catch {
1366
+ return r.response?.body;
1367
+ }
1368
+ });
1369
+ const extracted = mod.extract(raw, {
1370
+ params: {},
1371
+ responses: allResponses,
1372
+ });
1373
+ if (
1374
+ extracted == null ||
1375
+ (typeof extracted === 'object' && Object.keys(extracted).length === 0)
1376
+ ) {
1377
+ failures.push(
1378
+ 'parser.extract() returns null or empty when given the captured response body',
1379
+ );
1380
+ }
1381
+ }
1382
+ } catch {
1383
+ // already flagged above if import failed
1384
+ }
1385
+ }
1386
+ }
1387
+
1388
+ return { failures, warnings };
1389
+ }