imprint-mcp 0.6.0 → 0.6.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.
Files changed (43) hide show
  1. package/README.md +2 -0
  2. package/package.json +4 -2
  3. package/prompts/audit-agent.md +4 -2
  4. package/prompts/auth-compile-agent.md +149 -158
  5. package/prompts/build-planning.md +14 -9
  6. package/prompts/compile-agent.md +46 -23
  7. package/prompts/live-verifier-agent.md +101 -0
  8. package/prompts/playbook-compilation.md +8 -1
  9. package/prompts/tool-candidate-detection.md +9 -29
  10. package/src/cli.ts +83 -1
  11. package/src/imprint/audit.ts +219 -20
  12. package/src/imprint/auth-compile-agent.ts +215 -155
  13. package/src/imprint/auth-compile-tools.ts +300 -118
  14. package/src/imprint/auth-verifier.ts +167 -180
  15. package/src/imprint/backend-cache.ts +18 -11
  16. package/src/imprint/backend-ladder.ts +225 -177
  17. package/src/imprint/build-plan.ts +3 -8
  18. package/src/imprint/cdp-browser-fetch.ts +930 -83
  19. package/src/imprint/claude-cli-compile.ts +46 -20
  20. package/src/imprint/codex-cli-compile.ts +436 -139
  21. package/src/imprint/compile-agent-types.ts +15 -10
  22. package/src/imprint/compile-agent.ts +88 -28
  23. package/src/imprint/compile-tools.ts +228 -39
  24. package/src/imprint/compile-verification.ts +187 -0
  25. package/src/imprint/compile.ts +71 -2
  26. package/src/imprint/credential-store.ts +57 -44
  27. package/src/imprint/emit.ts +3 -1
  28. package/src/imprint/freeform-redact.ts +27 -6
  29. package/src/imprint/live-verifier.ts +1665 -0
  30. package/src/imprint/llm.ts +22 -3
  31. package/src/imprint/login.ts +107 -25
  32. package/src/imprint/mcp-compile-server.ts +447 -114
  33. package/src/imprint/mcp-live-verifier-server.ts +359 -0
  34. package/src/imprint/mcp-server.ts +116 -35
  35. package/src/imprint/probe-backends.ts +88 -7
  36. package/src/imprint/recording-request.ts +36 -0
  37. package/src/imprint/request-capture.ts +40 -17
  38. package/src/imprint/runtime.ts +490 -337
  39. package/src/imprint/teach-state.ts +36 -2
  40. package/src/imprint/teach.ts +302 -146
  41. package/src/imprint/tool-candidates.ts +8 -10
  42. package/src/imprint/tool-loader.ts +12 -4
  43. package/src/imprint/types.ts +137 -68
@@ -14,12 +14,12 @@ import {
14
14
  } from './cookie-jar.ts';
15
15
  import {
16
16
  type StorageRecord,
17
+ commitSiteAuthState,
17
18
  loadSiteCredentials,
18
19
  readSiteManifest,
19
20
  saveSiteCookies,
20
- saveSiteSecret,
21
21
  } from './credential-store.ts';
22
- import { captureHeader, jsonpath } from './request-capture.ts';
22
+ import { captureHeader, captureValueMatches, jsonpath } from './request-capture.ts';
23
23
  import type {
24
24
  RequestCapture,
25
25
  StateCapability,
@@ -41,6 +41,67 @@ export interface CredentialStore {
41
41
  storage?: StorageRecord[];
42
42
  }
43
43
 
44
+ type NavigationOptions = NonNullable<WorkflowRequest['navigation']>;
45
+ type RequestTransformResult =
46
+ | string
47
+ | {
48
+ url?: string;
49
+ body?: string;
50
+ headers?: Record<string, string>;
51
+ navigation?: Partial<NavigationOptions>;
52
+ skip?: boolean;
53
+ };
54
+
55
+ function mergeNavigationOptions(
56
+ base: WorkflowRequest['navigation'],
57
+ override: Partial<NavigationOptions>,
58
+ ): NavigationOptions {
59
+ if (
60
+ override.actions !== undefined &&
61
+ (!Array.isArray(override.actions) ||
62
+ override.actions.length === 0 ||
63
+ override.actions.some(
64
+ (entry) =>
65
+ !entry ||
66
+ entry.action !== 'click' ||
67
+ typeof entry.selector !== 'string' ||
68
+ entry.selector.length === 0,
69
+ ))
70
+ ) {
71
+ throw new Error('request transform returned invalid navigation click actions');
72
+ }
73
+ if (
74
+ override.resultSelector !== undefined &&
75
+ (typeof override.resultSelector !== 'string' || override.resultSelector.length === 0)
76
+ ) {
77
+ throw new Error('request transform returned an invalid navigation result selector');
78
+ }
79
+ return { ...base, ...override } as NavigationOptions;
80
+ }
81
+
82
+ /** Browser-only workflow operations supplied by cdp-replay. Keeping this
83
+ * transport generic lets pages mint coupled browser state through their own
84
+ * JavaScript without adding OAuth-provider or frontend-framework logic here. */
85
+ export interface BrowserNavigationTransport {
86
+ navigate(
87
+ url: string,
88
+ options?: {
89
+ method?: string;
90
+ headers?: Record<string, string>;
91
+ body?: string;
92
+ waitUntil?: 'domcontentloaded' | 'load';
93
+ timeoutMs?: number;
94
+ pollIntervalMs?: number;
95
+ urlIncludes?: string;
96
+ selector?: string;
97
+ actions?: Array<{ action: 'click'; selector: string }>;
98
+ resultSelector?: string;
99
+ cookie?: { name: string; domain?: string; path?: string };
100
+ },
101
+ ): Promise<Response>;
102
+ snapshotCookies(): Promise<RuntimeCookie[]>;
103
+ }
104
+
44
105
  /** Load credentials for a site from the credential manager (OS keychain →
45
106
  * encrypted-file fallback → legacy JSON for backwards compat). Returns
46
107
  * null only if there's truly nothing recorded; a missing keychain entry
@@ -85,6 +146,8 @@ interface ExecuteOptions {
85
146
  credentials?: CredentialStore;
86
147
  /** Override global fetch (tests, stealth-fetch). */
87
148
  fetchImpl?: typeof fetch;
149
+ /** Top-level document navigation, available only on cdp-replay. */
150
+ browser?: BrowserNavigationTransport;
88
151
  /** Per-request timeout in ms. Default 30000. */
89
152
  requestTimeoutMs?: number;
90
153
  /** Absolute path of workflow.json — required for parserModule resolution. */
@@ -156,14 +219,13 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
156
219
  const dependencyPreflight = preflightStateDependencies(opts.workflow, state, stateCapabilities);
157
220
  if (!dependencyPreflight.ok) return dependencyPreflight.result;
158
221
 
159
- type TransformResult = string | { url: string; body?: string; headers?: Record<string, string> };
160
222
  let requestTransform:
161
223
  | ((
162
224
  method: string,
163
225
  url: string,
164
226
  responses: unknown[],
165
227
  params?: Record<string, string | number | boolean>,
166
- ) => TransformResult)
228
+ ) => RequestTransformResult)
167
229
  | null = null;
168
230
  if (opts.workflow.requestTransformModule && opts.workflowPath) {
169
231
  try {
@@ -193,6 +255,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
193
255
  });
194
256
  if (!subbedResult.ok) return subbedResult.result;
195
257
  const subbed = subbedResult.value;
258
+ let navigation = req.navigation;
196
259
 
197
260
  if (requestTransform) {
198
261
  try {
@@ -205,84 +268,140 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
205
268
  if (typeof transformResult === 'string') {
206
269
  subbed.url = transformResult;
207
270
  } else if (transformResult && typeof transformResult === 'object') {
208
- subbed.url = transformResult.url;
271
+ if (transformResult.skip === true) continue;
272
+ if (typeof transformResult.url === 'string') subbed.url = transformResult.url;
209
273
  if (transformResult.body !== undefined) subbed.body = transformResult.body;
210
274
  if (transformResult.headers) {
211
275
  for (const [k, v] of Object.entries(transformResult.headers)) {
212
276
  subbed.headers[k] = v;
213
277
  }
214
278
  }
279
+ if (transformResult.navigation) {
280
+ navigation = mergeNavigationOptions(navigation, transformResult.navigation);
281
+ }
215
282
  }
216
- } catch {
217
- // Non-fatal — proceed with the original request.
283
+ } catch (err) {
284
+ return {
285
+ ok: false,
286
+ error: 'BAD_RESPONSE',
287
+ message: `request transform failed for request ${i}: ${
288
+ err instanceof Error ? err.message : String(err)
289
+ }`,
290
+ };
218
291
  }
219
292
  }
220
293
 
221
294
  const cookieHeader = cookieJar.getCookieHeader(subbed.url);
222
295
  if (cookieHeader && !hasHeader(subbed.headers, 'cookie')) subbed.headers.cookie = cookieHeader;
223
296
 
224
- const controller = new AbortController();
225
- const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
226
-
227
297
  let resp: Response;
228
- try {
229
- resp = await fetchFn(subbed.url, {
230
- method: subbed.method,
231
- headers: subbed.headers,
232
- body: subbed.body,
233
- signal: controller.signal,
234
- redirect: 'follow',
235
- });
236
- } catch (err) {
237
- clearTimeout(timeoutHandle);
238
- const msg = err instanceof Error ? err.message : String(err);
239
- if (msg.includes('aborted') || msg.includes('AbortError')) {
298
+ let responseAbortTimer: ReturnType<typeof setTimeout> | undefined;
299
+ if (req.mode === 'navigate') {
300
+ if (!opts.browser) {
240
301
  return {
241
302
  ok: false,
242
- error: 'NETWORK',
243
- message: `Request ${i} timed out after ${timeoutMs}ms`,
244
- remediation: 'Retry, or increase the timeout if the endpoint is slow.',
303
+ error: 'BAD_RESPONSE',
304
+ message: `Request ${i} requires top-level browser navigation; retry with cdp-replay.`,
245
305
  };
246
306
  }
247
- return { ok: false, error: 'NETWORK', message: `Request ${i} failed: ${msg}` };
307
+ try {
308
+ resp = await opts.browser.navigate(subbed.url, {
309
+ ...navigation,
310
+ method: subbed.method,
311
+ headers: subbed.headers,
312
+ body: subbed.body,
313
+ });
314
+ for (const cookie of await opts.browser.snapshotCookies()) cookieJar.setCookie(cookie);
315
+ liveCredentials.cookies = cookieJar.toJSON();
316
+ } catch (err) {
317
+ const msg = err instanceof Error ? err.message : String(err);
318
+ return { ok: false, error: 'NETWORK', message: `Navigation ${i} failed: ${msg}` };
319
+ }
320
+ } else {
321
+ const controller = new AbortController();
322
+ responseAbortTimer = setTimeout(() => controller.abort(), timeoutMs);
323
+ try {
324
+ resp = await fetchFn(subbed.url, {
325
+ method: subbed.method,
326
+ headers: subbed.headers,
327
+ body: subbed.body,
328
+ signal: controller.signal,
329
+ redirect: 'follow',
330
+ });
331
+ } catch (err) {
332
+ if (responseAbortTimer) clearTimeout(responseAbortTimer);
333
+ const msg = err instanceof Error ? err.message : String(err);
334
+ if (msg.includes('aborted') || msg.includes('AbortError')) {
335
+ return {
336
+ ok: false,
337
+ error: 'NETWORK',
338
+ message: `Request ${i} timed out after ${timeoutMs}ms`,
339
+ remediation: 'Retry, or increase the timeout if the endpoint is slow.',
340
+ };
341
+ }
342
+ return { ok: false, error: 'NETWORK', message: `Request ${i} failed: ${msg}` };
343
+ }
248
344
  }
249
- clearTimeout(timeoutHandle);
345
+
346
+ let text = '';
347
+ let responseReadError: string | undefined;
348
+ try {
349
+ text = await resp.text();
350
+ } catch (err) {
351
+ responseReadError = err instanceof Error ? err.message : String(err);
352
+ } finally {
353
+ if (responseAbortTimer) clearTimeout(responseAbortTimer);
354
+ }
355
+
356
+ const bodyPreview = responseReadError
357
+ ? `[response body unavailable: ${responseReadError}]`
358
+ : text;
250
359
 
251
360
  if (resp.status === 401) {
252
- const text = await safeText(resp);
253
361
  return {
254
362
  ok: false,
255
363
  error: 'AUTH_EXPIRED',
256
- message: `Request ${i} returned 401 — auth has likely expired: ${text.slice(0, 300)}`,
364
+ message: `Request ${i} returned 401 — auth has likely expired: ${bodyPreview.slice(0, 300)}`,
257
365
  remediation: `Run \`imprint login ${opts.workflow.site}\` to refresh credentials.`,
258
366
  };
259
367
  }
260
368
  if (resp.status === 403) {
261
369
  // 403 = bot detection / geo / ToS / missing capability. The body
262
370
  // usually disambiguates — surface it rather than guessing.
263
- const text = await safeText(resp);
264
371
  return {
265
372
  ok: false,
266
373
  error: 'FORBIDDEN',
267
- message: `Request ${i} returned 403: ${text.slice(0, 300)}`,
374
+ message: `Request ${i} returned 403: ${bodyPreview.slice(0, 300)}`,
268
375
  remediation: `Common causes: bot detection (Akamai/Cloudflare/DataDome), geo-block, expired credential, or ToS violation. Inspect the response body above; if it looks like bot detection, the captured workflow can't replay against this site without a real browser. If it's auth, try \`imprint login ${opts.workflow.site}\`.`,
269
376
  };
270
377
  }
271
378
  if (resp.status === 429) {
272
- const text = await safeText(resp);
273
379
  return {
274
380
  ok: false,
275
381
  error: 'RATE_LIMITED',
276
- message: `Request ${i} returned 429: ${text.slice(0, 300)}`,
382
+ message: `Request ${i} returned 429: ${bodyPreview.slice(0, 300)}`,
277
383
  remediation: 'Back off and retry after the Retry-After interval.',
278
384
  };
279
385
  }
280
386
  if (resp.status >= 400) {
281
- const text = await safeText(resp);
282
387
  return {
283
388
  ok: false,
284
389
  error: 'BAD_RESPONSE',
285
- message: `Request ${i} (${subbed.method} ${subbed.url}) returned ${resp.status}: ${text.slice(0, 500)}`,
390
+ message: `Request ${i} (${subbed.method} ${subbed.url}) returned ${resp.status}: ${bodyPreview.slice(0, 500)}`,
391
+ };
392
+ }
393
+ if (responseReadError) {
394
+ const timedOut =
395
+ responseReadError.includes('aborted') || responseReadError.includes('AbortError');
396
+ return {
397
+ ok: false,
398
+ error: 'NETWORK',
399
+ message: timedOut
400
+ ? `Request ${i} timed out after ${timeoutMs}ms`
401
+ : `Response ${i} could not be read: ${responseReadError}`,
402
+ remediation: timedOut
403
+ ? 'Retry, or increase the timeout if the endpoint is slow.'
404
+ : undefined,
286
405
  };
287
406
  }
288
407
 
@@ -296,7 +415,6 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
296
415
  // Non-fatal; cookies stay as they were.
297
416
  }
298
417
 
299
- const text = await safeText(resp);
300
418
  let parsed: unknown = text;
301
419
  try {
302
420
  parsed = JSON.parse(text);
@@ -354,349 +472,389 @@ function emptyStore(site: string): CredentialStore {
354
472
  }
355
473
 
356
474
  async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
357
- const fetchFn = opts.fetchImpl ?? fetch;
358
- const timeoutMs = opts.requestTimeoutMs ?? 30_000;
359
- const action = String(opts.params?.action ?? 'initiate');
360
475
  const authConfig = opts.workflow.authConfig;
361
- const initiateCount = authConfig?.initiateRequestCount ?? opts.workflow.requests.length;
476
+ const params = { ...opts.params };
477
+ for (const parameter of opts.workflow.parameters) {
478
+ if (!(parameter.name in params) && parameter.default !== undefined) {
479
+ params[parameter.name] = parameter.default;
480
+ }
481
+ }
482
+ const actionName = String(params.action ?? authConfig?.entry ?? '');
483
+ const action = authConfig?.actions[actionName];
484
+ if (!authConfig || !action) {
485
+ return {
486
+ ok: false,
487
+ error: 'UNKNOWN',
488
+ message: `Unknown auth action ${JSON.stringify(actionName)}. Available actions: ${Object.keys(
489
+ authConfig?.actions ?? {},
490
+ ).join(', ')}`,
491
+ };
492
+ }
362
493
 
494
+ const fetchFn = opts.fetchImpl ?? fetch;
495
+ const timeoutMs = opts.requestTimeoutMs ?? 30_000;
363
496
  const credentials =
364
497
  opts.credentials ??
365
498
  (await loadCredentialStore(opts.workflow.site)) ??
366
499
  emptyStore(opts.workflow.site);
367
-
368
500
  const cookieJar = new RuntimeCookieJar(credentials.cookies);
369
501
  const liveCredentials: CredentialStore = { ...credentials, cookies: cookieJar.toJSON() };
370
502
  const responseSlots: ResponseSlot[] = [];
371
- const state: Record<string, unknown> = { ...(opts.initialState ?? {}) };
503
+ const initialState: Record<string, unknown> = { ...(opts.initialState ?? {}) };
504
+ const state: Record<string, unknown> = { ...initialState };
372
505
  const stateCapabilities = collectStateCapabilities(opts.workflow);
373
- const params: Record<string, string | number | boolean> = { ...opts.params };
374
- let loginResponsePreview: string | undefined;
375
- /** Latest response context, used to resolve authConfig.sessionCapture against
376
- * the final completion response when the login succeeds. */
377
- let lastAuthResponseCtx:
378
- | { parsed: unknown; text: string; headers: Headers; requestUrl: string }
506
+ const missingParameters = action.parameters.filter((name) => !(name in params));
507
+ if (missingParameters.length > 0) {
508
+ return {
509
+ ok: false,
510
+ error: 'STATE_MISSING',
511
+ message: `Auth action ${JSON.stringify(actionName)} requires: ${missingParameters.join(', ')}.`,
512
+ };
513
+ }
514
+ let requestTransform:
515
+ | ((
516
+ method: string,
517
+ url: string,
518
+ responses: unknown[],
519
+ params?: Record<string, string | number | boolean>,
520
+ ) => RequestTransformResult)
379
521
  | undefined;
522
+ if (opts.workflow.requestTransformModule && opts.workflowPath) {
523
+ try {
524
+ const mod = await import(
525
+ pathResolve(dirname(opts.workflowPath), opts.workflow.requestTransformModule)
526
+ );
527
+ if (typeof mod.transform === 'function') requestTransform = mod.transform;
528
+ } catch {
529
+ // A missing transform is surfaced by the request that depended on it.
530
+ }
531
+ }
380
532
 
381
- const runRequests = async (startIdx: number, endIdx: number): Promise<ToolResult | null> => {
382
- for (let i = startIdx; i < endIdx; i++) {
383
- const req = opts.workflow.requests[i];
384
- if (!req) continue;
533
+ type ResponseContext = {
534
+ parsed: unknown;
535
+ text: string;
536
+ headers: Headers;
537
+ requestUrl: string;
538
+ };
539
+ let lastResponse: ResponseContext | undefined;
385
540
 
386
- let subbedReq: SubstitutedRequest;
387
- const subbedResult = substituteRequest(req, {
388
- params,
389
- credentials: liveCredentials,
390
- responseSlots,
391
- state,
392
- cookieJar,
393
- stateCapabilities,
394
- requestUrlTemplate: req.url,
395
- });
396
- if (!subbedResult.ok) return subbedResult.result;
397
- subbedReq = subbedResult.value;
541
+ const fail = (result: RuntimeErrorResult): RuntimeErrorResult => {
542
+ return {
543
+ ...result,
544
+ continuation: Object.keys(initialState).length > 0 ? initialState : undefined,
545
+ };
546
+ };
398
547
 
399
- const cookieHeader = cookieJar.getCookieHeader(subbedReq.url);
400
- if (cookieHeader && !hasHeader(subbedReq.headers, 'cookie'))
401
- subbedReq.headers.cookie = cookieHeader;
548
+ const persistenceFailure = (material: string, err: unknown): RuntimeErrorResult =>
549
+ fail({
550
+ ok: false,
551
+ error: 'BAD_RESPONSE',
552
+ message: `Auth action ${JSON.stringify(actionName)} could not persist ${material}: ${
553
+ err instanceof Error ? err.message : String(err)
554
+ }`,
555
+ });
402
556
 
403
- const controller = new AbortController();
404
- const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
405
- let resp: Response;
557
+ const persistCookies = async (): Promise<RuntimeErrorResult | undefined> => {
558
+ try {
559
+ await saveSiteCookies(opts.workflow.site, cookieJar.toJSON());
560
+ return undefined;
561
+ } catch (err) {
562
+ return persistenceFailure('session cookies', err);
563
+ }
564
+ };
565
+
566
+ const executeRequest = async (
567
+ requestIndex: number,
568
+ ): Promise<RuntimeResult<ResponseContext | undefined>> => {
569
+ const req = opts.workflow.requests[requestIndex];
570
+ if (!req) {
571
+ return {
572
+ ok: false,
573
+ result: {
574
+ ok: false,
575
+ error: 'BAD_RESPONSE',
576
+ message: `Auth action ${JSON.stringify(actionName)} references missing request ${requestIndex}.`,
577
+ },
578
+ };
579
+ }
580
+
581
+ const substituted = substituteRequest(req, {
582
+ params,
583
+ credentials: liveCredentials,
584
+ responseSlots,
585
+ state,
586
+ cookieJar,
587
+ stateCapabilities,
588
+ requestUrlTemplate: req.url,
589
+ });
590
+ if (!substituted.ok) return substituted;
591
+ const request = substituted.value;
592
+ let navigation = req.navigation;
593
+
594
+ if (requestTransform) {
406
595
  try {
407
- resp = await fetchFn(subbedReq.url, {
408
- method: subbedReq.method,
409
- headers: subbedReq.headers,
410
- body: subbedReq.body,
411
- signal: controller.signal,
412
- redirect: 'follow',
413
- });
414
- } catch (err) {
415
- clearTimeout(timeoutHandle);
416
- const msg = err instanceof Error ? err.message : String(err);
417
- return { ok: false, error: 'NETWORK', message: `Auth request ${i} failed: ${msg}` };
418
- }
419
- clearTimeout(timeoutHandle);
420
-
421
- if (resp.status >= 400) {
422
- const text = await safeText(resp);
423
- // An OPTIONAL request (e.g. a "remember this device" registration that
424
- // 4xxs when the device is already trusted, or a telemetry beacon) must
425
- // not abort the flow — log and continue to the next (terminal) request.
426
- if (req.optional) {
427
- process.stderr.write(
428
- `[imprint runtime] optional auth request ${i} (${subbedReq.url}) returned ${resp.status} — skipping, continuing\n`,
429
- );
430
- continue;
596
+ const transformed = requestTransform(
597
+ request.method,
598
+ request.url,
599
+ responseSlots.map((slot) => slot.raw),
600
+ params,
601
+ );
602
+ if (typeof transformed === 'string') request.url = transformed;
603
+ else if (transformed) {
604
+ if (transformed.skip) return { ok: true, value: undefined };
605
+ if (transformed.url !== undefined) request.url = transformed.url;
606
+ if (transformed.body !== undefined) request.body = transformed.body;
607
+ if (transformed.headers) Object.assign(request.headers, transformed.headers);
608
+ if (transformed.navigation) {
609
+ navigation = mergeNavigationOptions(navigation, transformed.navigation);
610
+ }
431
611
  }
612
+ } catch (err) {
432
613
  return {
433
614
  ok: false,
434
- error: resp.status === 401 ? 'AUTH_EXPIRED' : 'BAD_RESPONSE',
435
- message: `Auth request ${i} (${subbedReq.method} ${subbedReq.url}) returned ${resp.status}: ${text.slice(0, 500)}`,
436
- // Surface the concrete status + body so the auth compile agent sees the
437
- // server's actual error (e.g. a 401 "tokens missing" vs a 400 schema
438
- // error) without re-running — see run_verification's result.
439
- status: resp.status,
440
- responseBodyPreview: text.slice(0, 500),
615
+ result: {
616
+ ok: false,
617
+ error: 'BAD_RESPONSE',
618
+ message: `request transform failed for request ${requestIndex}: ${
619
+ err instanceof Error ? err.message : String(err)
620
+ }`,
621
+ },
441
622
  };
442
623
  }
624
+ }
625
+
626
+ const cookieHeader = cookieJar.getCookieHeader(request.url);
627
+ if (cookieHeader && !hasHeader(request.headers, 'cookie'))
628
+ request.headers.cookie = cookieHeader;
443
629
 
630
+ let response: Response;
631
+ let responseAbortTimer: ReturnType<typeof setTimeout> | undefined;
632
+ if (req.mode === 'navigate') {
633
+ if (!opts.browser) {
634
+ return {
635
+ ok: false,
636
+ result: {
637
+ ok: false,
638
+ error: 'BAD_RESPONSE',
639
+ message: `Request ${requestIndex} requires a browser navigation transport.`,
640
+ },
641
+ };
642
+ }
444
643
  try {
445
- for (const sc of extractSetCookieHeaders(resp.headers))
446
- cookieJar.setCookieFromHeader(sc, subbedReq.url);
447
- liveCredentials.cookies = cookieJar.toJSON();
448
- } catch {
449
- // Non-fatal
644
+ response = await opts.browser.navigate(request.url, {
645
+ ...navigation,
646
+ method: request.method,
647
+ headers: request.headers,
648
+ body: request.body,
649
+ });
650
+ for (const cookie of await opts.browser.snapshotCookies()) cookieJar.setCookie(cookie);
651
+ } catch (err) {
652
+ return {
653
+ ok: false,
654
+ result: {
655
+ ok: false,
656
+ error: 'NETWORK',
657
+ message: `Navigation ${requestIndex} failed: ${
658
+ err instanceof Error ? err.message : String(err)
659
+ }`,
660
+ },
661
+ };
450
662
  }
451
-
452
- const text = await safeText(resp);
453
- // Capture the last login-phase response for shape comparison
454
- if (i < initiateCount) loginResponsePreview = text.slice(0, 500);
455
-
456
- let parsed: unknown = text;
663
+ } else {
664
+ const controller = new AbortController();
665
+ responseAbortTimer = setTimeout(() => controller.abort(), timeoutMs);
457
666
  try {
458
- parsed = JSON.parse(text);
459
- } catch {
460
- // keep raw
667
+ response = await fetchFn(request.url, {
668
+ method: request.method,
669
+ headers: request.headers,
670
+ body: request.body,
671
+ signal: controller.signal,
672
+ redirect: 'follow',
673
+ });
674
+ } catch (err) {
675
+ if (responseAbortTimer) clearTimeout(responseAbortTimer);
676
+ return {
677
+ ok: false,
678
+ result: {
679
+ ok: false,
680
+ error: 'NETWORK',
681
+ message: `Auth request ${requestIndex} failed: ${
682
+ err instanceof Error ? err.message : String(err)
683
+ }`,
684
+ },
685
+ };
461
686
  }
462
- // Remember the latest response so sessionCapture can be resolved against
463
- // the final completion response after the login finishes.
464
- lastAuthResponseCtx = { parsed, text, headers: resp.headers, requestUrl: subbedReq.url };
465
- const aliases = evaluateLegacyExtract(req, parsed);
466
- responseSlots.push({ raw: parsed, aliases });
467
-
468
- const captureResult = evaluateRequestCaptures(req.captures ?? [], {
469
- parsed,
470
- text,
471
- headers: resp.headers,
472
- requestUrl: subbedReq.url,
473
- cookieJar,
474
- });
475
- if (!captureResult.ok) return captureResult.result;
476
- Object.assign(state, captureResult.value);
477
687
  }
478
- return null;
479
- };
480
688
 
481
- // Persist durable session tokens (authConfig.sessionCapture) after a SUCCESSFUL
482
- // login completion so data tools reuse them as ${credential.NAME} without
483
- // re-running auth. Best-effort: a token that can't be resolved is skipped, not
484
- // fatal. Cookies are persisted separately (saveSiteCookies). General — driven
485
- // only by the declared captures + the recorded response shape.
486
- const persistSessionCapture = async (): Promise<void> => {
487
- const caps = authConfig?.sessionCapture ?? [];
488
- if (caps.length === 0) return;
489
- for (const cap of caps) {
490
- let value: unknown;
491
- if (lastAuthResponseCtx) {
492
- const res = evaluateRequestCaptures([cap], {
493
- parsed: lastAuthResponseCtx.parsed,
494
- text: lastAuthResponseCtx.text,
495
- headers: lastAuthResponseCtx.headers,
496
- requestUrl: lastAuthResponseCtx.requestUrl,
497
- cookieJar,
498
- });
499
- if (res.ok) value = res.value[cap.name];
500
- }
501
- if (value === undefined || value === null) value = state[cap.name];
502
- if (value !== undefined && value !== null && String(value).length > 0) {
503
- try {
504
- await saveSiteSecret(opts.workflow.site, cap.name, String(value));
505
- } catch {
506
- /* non-fatal — cookies are the primary token */
507
- }
508
- }
689
+ let text = '';
690
+ let responseReadError: string | undefined;
691
+ try {
692
+ text = await response.text();
693
+ } catch (err) {
694
+ responseReadError = err instanceof Error ? err.message : String(err);
695
+ } finally {
696
+ if (responseAbortTimer) clearTimeout(responseAbortTimer);
697
+ }
698
+ if (response.status >= 400) {
699
+ const bodyPreview = responseReadError
700
+ ? `[response body unavailable: ${responseReadError}]`
701
+ : text;
702
+ return {
703
+ ok: false,
704
+ result: {
705
+ ok: false,
706
+ error: response.status === 401 ? 'AUTH_EXPIRED' : 'BAD_RESPONSE',
707
+ message: `Auth request ${requestIndex} (${request.method} ${request.url}) returned ${response.status}: ${bodyPreview.slice(0, 500)}`,
708
+ status: response.status,
709
+ responseBodyPreview: bodyPreview.slice(0, 500),
710
+ },
711
+ };
712
+ }
713
+ if (responseReadError) {
714
+ return {
715
+ ok: false,
716
+ result: {
717
+ ok: false,
718
+ error: 'NETWORK',
719
+ message: `Auth response ${requestIndex} could not be read: ${responseReadError}`,
720
+ },
721
+ };
509
722
  }
510
- };
511
723
 
512
- if (action === 'initiate') {
513
- const err = await runRequests(0, initiateCount);
514
- if (err) return err;
724
+ for (const header of extractSetCookieHeaders(response.headers)) {
725
+ cookieJar.setCookieFromHeader(header, request.url);
726
+ }
727
+ liveCredentials.cookies = cookieJar.toJSON();
515
728
 
516
- if (authConfig && authConfig.twoFactorType !== 'none') {
517
- await saveSiteCookies(opts.workflow.site, cookieJar.toJSON());
518
- // Stateless state-chain bridge: each MCP call is a fresh executeAuthWorkflow
519
- // with a fresh `state`, so a token the login response returned in its body
520
- // (e.g. a reauth mfaId) would be lost before submit_otp. Project the
521
- // declared twoFactorContext names out of the captured state and echo them
522
- // to the caller, who passes them back as initialState on the next call.
523
- const ctx: Record<string, unknown> = {};
524
- for (const name of authConfig.twoFactorContext ?? []) {
525
- if (name in state) ctx[name] = state[name];
526
- }
729
+ let parsed: unknown = text;
730
+ try {
731
+ parsed = JSON.parse(text);
732
+ } catch {
733
+ // Keep the raw body for text captures.
734
+ }
735
+ const context = { parsed, text, headers: response.headers, requestUrl: request.url };
736
+ responseSlots.push({ raw: parsed, aliases: evaluateLegacyExtract(req, parsed) });
737
+ const captures = evaluateRequestCaptures(req.captures ?? [], {
738
+ ...context,
739
+ cookieJar,
740
+ });
741
+ if (!captures.ok) {
527
742
  return {
528
743
  ok: false,
529
- error: 'AWAITING_2FA',
530
- twoFactorType: authConfig.twoFactorType,
531
- twoFactorContext: Object.keys(ctx).length > 0 ? ctx : undefined,
532
- loginResponsePreview,
533
- message: `2FA required (${authConfig.twoFactorType}). ${
534
- authConfig.twoFactorType === 'push'
535
- ? 'Approve the push notification on your device, then call again with action=complete.'
536
- : 'Enter the code and call again with action=submit_otp, otp_code, and the echoed twoFactorContext.'
537
- }`,
744
+ result: {
745
+ ...captures.result,
746
+ status: response.status,
747
+ responseBodyPreview: text.slice(0, 500),
748
+ },
538
749
  };
539
750
  }
751
+ Object.assign(state, captures.value);
752
+ lastResponse = context;
753
+ return { ok: true, value: context };
754
+ };
540
755
 
541
- await saveSiteCookies(opts.workflow.site, cookieJar.toJSON());
542
- return { ok: true, data: { authenticated: true }, loginResponsePreview };
543
- }
544
-
545
- if (action === 'complete') {
546
- if (authConfig?.twoFactorType === 'push' && authConfig.pollEndpoint) {
547
- // The recorded default is generous (≈3 min) for a real run where a human
548
- // approves the push. An unattended *attempt* (e.g. `imprint teach
549
- // --no-interactive`) wants a short bound so it fails fast instead of
550
- // blocking. IMPRINT_AUTH_POLL_ATTEMPTS lets any caller cap the poll
551
- // without mutating the artifact; the runtime default stays generous.
552
- const pollOverride = parsePositiveInt(process.env.IMPRINT_AUTH_POLL_ATTEMPTS);
553
- const pollMax = pollOverride ?? authConfig.maxPollAttempts ?? 60;
554
- const pollInterval = authConfig.pollIntervalMs ?? 3000;
555
- const pollMethod = authConfig.pollMethod ?? 'POST';
556
- // Many poll/status endpoints reject an empty body (they need the recorded
557
- // JSON payload, e.g. `{mfaId,...}`). Substitute the declared pollBody once
558
- // (state is fixed during the completion phase) against the same runtime as
559
- // any request, so `${state.X}`/`${credential.X}`/`${param.X}` resolve.
560
- const pollContentType =
561
- authConfig.pollBody !== undefined
562
- ? (authConfig.pollContentType ?? 'application/json')
563
- : undefined;
564
- let pollBody: string | undefined;
565
- if (authConfig.pollBody !== undefined) {
566
- const ctLower = (pollContentType ?? '').toLowerCase();
567
- const bodyCtx: SubstitutionContext = ctLower.includes('json')
568
- ? 'json-body'
569
- : ctLower.includes('urlencoded') || authConfig.pollBody.includes('=')
570
- ? 'form-body'
571
- : 'opaque-body';
572
- const pollBodyResult = substituteStringInternal(
573
- authConfig.pollBody,
574
- {
575
- params,
576
- credentials: liveCredentials,
577
- responseSlots,
578
- state,
579
- cookieJar,
580
- stateCapabilities,
581
- requestUrlTemplate: authConfig.pollEndpoint,
582
- },
583
- bodyCtx,
584
- );
585
- if (!pollBodyResult.ok) return pollBodyResult.result;
586
- pollBody = pollBodyResult.value;
587
- }
588
- let approved = false;
589
- for (let attempt = 0; attempt < pollMax; attempt++) {
590
- await sleep(pollInterval);
591
- const cookieHeader = cookieJar.getCookieHeader(authConfig.pollEndpoint);
592
- const pollHeaders: Record<string, string> = {};
593
- if (cookieHeader) pollHeaders.cookie = cookieHeader;
594
- if (pollContentType) pollHeaders['content-type'] = pollContentType;
595
- // Bound each poll the same way as a normal request: without a timeout a
596
- // pollEndpoint that accepts the connection but never responds hangs this
597
- // single fetch forever, so the poll budget never advances and `complete`
598
- // hangs indefinitely. An abort throws → caught below → next attempt.
599
- const pollController = new AbortController();
600
- const pollTimeout = setTimeout(() => pollController.abort(), timeoutMs);
601
- try {
602
- const pollResp = await fetchFn(authConfig.pollEndpoint, {
603
- method: pollMethod,
604
- headers: pollHeaders,
605
- body: pollBody,
606
- signal: pollController.signal,
607
- });
608
- if (pollResp.ok) {
609
- const body = await safeText(pollResp);
610
- let newSessionCookie = false;
611
- try {
612
- for (const sc of extractSetCookieHeaders(pollResp.headers)) {
613
- cookieJar.setCookieFromHeader(sc, authConfig.pollEndpoint);
614
- newSessionCookie = true;
615
- }
616
- liveCredentials.cookies = cookieJar.toJSON();
617
- } catch {
618
- /* non-fatal */
619
- }
620
- // Approval is recognized from the recording, not from hardcoded
621
- // strings: `pollTerminal` is a capture the compile agent grounds in
622
- // the recorded *approved* poll response (and which is absent on the
623
- // pending ones). It is "done" once that capture yields a value.
624
- // Fallback when no terminal was declared: a fresh session Set-Cookie
625
- // appeared, the universal sign of a completed login.
626
- if (authConfig.pollTerminal) {
627
- let parsed: unknown = body;
628
- try {
629
- parsed = JSON.parse(body);
630
- } catch {
631
- /* keep raw */
632
- }
633
- const term = evaluateRequestCaptures([authConfig.pollTerminal], {
634
- parsed,
635
- text: body,
636
- headers: pollResp.headers,
637
- requestUrl: authConfig.pollEndpoint,
638
- cookieJar,
639
- });
640
- if (term.ok && Object.keys(term.value).length > 0) {
641
- approved = true;
642
- break;
643
- }
644
- } else if (newSessionCookie) {
645
- approved = true;
646
- break;
647
- }
648
- }
649
- } catch {
650
- // retry (a network error or a timed-out abort falls through to the
651
- // next poll attempt rather than failing the whole completion)
652
- } finally {
653
- clearTimeout(pollTimeout);
756
+ for (let stepIndex = 0; stepIndex < action.steps.length; stepIndex++) {
757
+ const step = action.steps[stepIndex];
758
+ if (!step) continue;
759
+ let matched = !step.repeat;
760
+
761
+ for (let attempt = 0; attempt < (step.repeat?.maxAttempts ?? 1); attempt++) {
762
+ if (attempt > 0 && step.repeat) await sleep(step.repeat.intervalMs);
763
+ const executed = await executeRequest(step.request);
764
+ if (!executed.ok) {
765
+ if (step.onError === 'continue') {
766
+ matched = true;
767
+ break;
654
768
  }
769
+ if (step.onError === 'retry' && step.repeat) continue;
770
+ return fail(executed.result);
655
771
  }
656
- if (!approved) {
657
- return {
658
- ok: false,
659
- error: 'UNKNOWN',
660
- message: `Push notification was not approved after ${pollMax} attempts.`,
661
- };
772
+ if (!step.repeat) break;
773
+ if (!executed.value) continue;
774
+
775
+ const until = evaluateRequestCaptures(
776
+ [{ ...step.repeat.until, required: false } as RequestCapture],
777
+ { ...executed.value, cookieJar },
778
+ );
779
+ if (until.ok && Object.hasOwn(until.value, step.repeat.until.name)) {
780
+ Object.assign(state, until.value);
781
+ matched = true;
782
+ break;
662
783
  }
663
784
  }
664
785
 
665
- const err = await runRequests(initiateCount, opts.workflow.requests.length);
666
- if (err) return err;
667
-
668
- await saveSiteCookies(opts.workflow.site, cookieJar.toJSON());
669
- await persistSessionCapture();
670
- return { ok: true, data: { authenticated: true } };
786
+ if (!matched) {
787
+ return fail({
788
+ ok: false,
789
+ error: 'BAD_RESPONSE',
790
+ message: `Auth action ${JSON.stringify(actionName)} did not satisfy the declared repeat condition for request ${step.request} after ${step.repeat?.maxAttempts ?? 1} attempts.`,
791
+ });
792
+ }
671
793
  }
672
794
 
673
- if (action === 'submit_otp') {
674
- const err = await runRequests(initiateCount, opts.workflow.requests.length);
675
- if (err) return err;
795
+ const missingEvidence = action.outcome.evidence.filter((name) => !Object.hasOwn(state, name));
796
+ if (missingEvidence.length > 0) {
797
+ return fail({
798
+ ok: false,
799
+ error: 'BAD_RESPONSE',
800
+ message: `Auth action ${JSON.stringify(actionName)} completed without declared evidence: ${missingEvidence.join(', ')}.`,
801
+ });
802
+ }
676
803
 
677
- await saveSiteCookies(opts.workflow.site, cookieJar.toJSON());
678
- await persistSessionCapture();
679
- return { ok: true, data: { authenticated: true } };
804
+ if (action.outcome.type === 'pause') {
805
+ const missingCarry = action.outcome.carry.filter((name) => !Object.hasOwn(state, name));
806
+ if (missingCarry.length > 0) {
807
+ return fail({
808
+ ok: false,
809
+ error: 'BAD_RESPONSE',
810
+ message: `Auth action ${JSON.stringify(actionName)} completed without declared carry state: ${missingCarry.join(', ')}.`,
811
+ });
812
+ }
813
+ const continuation = Object.fromEntries(
814
+ action.outcome.carry.map((name) => [name, state[name]]),
815
+ );
816
+ const cookieFailure = await persistCookies();
817
+ if (cookieFailure && !opts.browser) return cookieFailure;
818
+ const persistenceWarning = cookieFailure
819
+ ? ` ${cookieFailure.message} Continue in the current browser session.`
820
+ : '';
821
+ return {
822
+ ok: false,
823
+ error: 'ACTION_REQUIRED',
824
+ message: `${action.outcome.message}${persistenceWarning}`,
825
+ nextAction: action.outcome.next,
826
+ continuation,
827
+ responseBodyPreview: lastResponse?.text.slice(0, 500),
828
+ };
680
829
  }
681
830
 
682
- return {
683
- ok: false,
684
- error: 'UNKNOWN',
685
- message: `Unknown auth action: ${action}. Use 'initiate', 'complete', or 'submit_otp'.`,
686
- };
831
+ const missingPersisted = authConfig.persist.filter((name) => !Object.hasOwn(state, name));
832
+ if (missingPersisted.length > 0) {
833
+ return fail({
834
+ ok: false,
835
+ error: 'BAD_RESPONSE',
836
+ message: `Auth action ${JSON.stringify(actionName)} completed without declared persisted state: ${missingPersisted.join(', ')}.`,
837
+ });
838
+ }
839
+ const persistedSecrets: Record<string, string> = {};
840
+ for (const name of authConfig.persist) {
841
+ const value = state[name];
842
+ if (value !== undefined && value !== null && String(value) !== '') {
843
+ persistedSecrets[name] = String(value);
844
+ }
845
+ }
846
+ try {
847
+ await commitSiteAuthState(opts.workflow.site, cookieJar.toJSON(), persistedSecrets);
848
+ } catch (err) {
849
+ return persistenceFailure('authenticated session state', err);
850
+ }
851
+ return { ok: true, data: { authenticated: true } };
687
852
  }
688
853
 
689
854
  function sleep(ms: number): Promise<void> {
690
855
  return new Promise((resolve) => setTimeout(resolve, ms));
691
856
  }
692
857
 
693
- /** Parse a strictly-positive integer from an env string; undefined otherwise. */
694
- function parsePositiveInt(value: string | undefined): number | undefined {
695
- if (value === undefined) return undefined;
696
- const n = Number.parseInt(value, 10);
697
- return Number.isFinite(n) && n > 0 ? n : undefined;
698
- }
699
-
700
858
  interface SubstitutedRequest {
701
859
  method: string;
702
860
  url: string;
@@ -1099,14 +1257,17 @@ function evaluateRequestCaptures(
1099
1257
  }
1100
1258
  }
1101
1259
 
1102
- if (value === undefined || value === null || value === '') {
1260
+ if (!captureValueMatches(value, capture.equals)) {
1103
1261
  if (capture.required === false) continue;
1104
1262
  return missingState({
1105
1263
  name: capture.name,
1106
1264
  source: capture.source === 'cookie' ? 'cookie' : 'response',
1107
1265
  capability: capture.capability,
1108
1266
  failure: 'producer_ran_value_absent',
1109
- message: `Required capture "${capture.name}" (${capture.source}) did not produce a value.`,
1267
+ message:
1268
+ capture.equals === undefined
1269
+ ? `Required capture "${capture.name}" (${capture.source}) did not produce a value.`
1270
+ : `Required capture "${capture.name}" (${capture.source}) did not produce the expected value.`,
1110
1271
  });
1111
1272
  }
1112
1273
  values[capture.name] = value;
@@ -1207,14 +1368,6 @@ function encodePart(
1207
1368
  return inQuery ? encodeURIComponent(s) : encodeURI(s);
1208
1369
  }
1209
1370
 
1210
- async function safeText(resp: Response): Promise<string> {
1211
- try {
1212
- return await resp.text();
1213
- } catch {
1214
- return '';
1215
- }
1216
- }
1217
-
1218
1371
  /** Build a clear, actionable error when a `${credential.NAME}` placeholder
1219
1372
  * can't be resolved. Reads the per-site manifest (if present) so the
1220
1373
  * message can list ALL missing credentials at once and explain the kinds