imprint-mcp 0.7.0 → 0.7.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 (58) hide show
  1. package/README.md +66 -2
  2. package/package.json +1 -1
  3. package/prompts/auth-compile-agent.md +173 -22
  4. package/prompts/compile-agent.md +45 -8
  5. package/prompts/component-recovery.md +40 -0
  6. package/prompts/master-teach-decision.md +28 -3
  7. package/prompts/master-teach-focused-planner.md +18 -5
  8. package/src/cli.ts +26 -18
  9. package/src/imprint/agent-tool.ts +15 -0
  10. package/src/imprint/agent.ts +2 -11
  11. package/src/imprint/audit.ts +35 -13
  12. package/src/imprint/auth-browser-discovery.ts +276 -0
  13. package/src/imprint/auth-compile-agent.ts +513 -224
  14. package/src/imprint/auth-compile-tools.ts +213 -25
  15. package/src/imprint/auth-discovery-evidence.ts +81 -0
  16. package/src/imprint/auth-plan.ts +28 -0
  17. package/src/imprint/auth-verifier.ts +234 -20
  18. package/src/imprint/authentication-interaction.ts +137 -0
  19. package/src/imprint/backend-ladder.ts +271 -43
  20. package/src/imprint/build-plan.ts +2 -22
  21. package/src/imprint/cdp-browser-fetch.ts +242 -60
  22. package/src/imprint/chromium.ts +93 -29
  23. package/src/imprint/claude-cli-compile.ts +2 -0
  24. package/src/imprint/codex-cli-compile.ts +99 -46
  25. package/src/imprint/compile-agent-types.ts +9 -5
  26. package/src/imprint/compile-agent.ts +33 -0
  27. package/src/imprint/compile-tool-executor.ts +783 -0
  28. package/src/imprint/compile-tools.ts +42 -9
  29. package/src/imprint/compile-verification.ts +2 -0
  30. package/src/imprint/compile.ts +3 -0
  31. package/src/imprint/compiler-log.ts +16 -0
  32. package/src/imprint/component-recovery.ts +120 -0
  33. package/src/imprint/cookie-jar.ts +117 -7
  34. package/src/imprint/credential-extract.ts +9 -2
  35. package/src/imprint/credential-lock.ts +147 -0
  36. package/src/imprint/credential-observation.ts +261 -0
  37. package/src/imprint/credential-store.ts +284 -44
  38. package/src/imprint/live-component-recovery.ts +73 -0
  39. package/src/imprint/llm.ts +4 -3
  40. package/src/imprint/log.ts +16 -1
  41. package/src/imprint/login.ts +9 -0
  42. package/src/imprint/master-teach-agent-contracts.ts +40 -3
  43. package/src/imprint/master-teach-agents.ts +229 -14
  44. package/src/imprint/master-teach-checks.ts +8 -1
  45. package/src/imprint/master-teach-controller.ts +681 -138
  46. package/src/imprint/master-teach-prompt-projections.ts +2 -0
  47. package/src/imprint/mcp-cdp-sessions.ts +7 -2
  48. package/src/imprint/mcp-compile-server.ts +62 -850
  49. package/src/imprint/mcp-server.ts +667 -91
  50. package/src/imprint/mcp-session-recovery.ts +167 -0
  51. package/src/imprint/native-teach-agents.ts +232 -108
  52. package/src/imprint/runtime.ts +416 -47
  53. package/src/imprint/stealth-chromium.ts +67 -7
  54. package/src/imprint/teach-browser-resources.ts +88 -0
  55. package/src/imprint/teach-stage-progress.ts +38 -0
  56. package/src/imprint/terminal-user-prompt.ts +42 -0
  57. package/src/imprint/types.ts +51 -18
  58. package/src/imprint/user-interaction.ts +836 -0
@@ -7,12 +7,18 @@
7
7
 
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { dirname, resolve as pathResolve } from 'node:path';
10
+ import { withAbortSignal } from './concurrency.ts';
10
11
  import {
11
12
  type CookieLookupConstraints,
12
13
  type RuntimeCookie,
13
14
  RuntimeCookieJar,
14
15
  extractSetCookieHeaders,
15
16
  } from './cookie-jar.ts';
17
+ import {
18
+ type DispatchObservation,
19
+ dispatchObservation,
20
+ recordCredentialEvent,
21
+ } from './credential-observation.ts';
16
22
  import {
17
23
  type StorageRecord,
18
24
  commitSiteAuthState,
@@ -29,6 +35,7 @@ import {
29
35
  resolveJsonCapture,
30
36
  } from './request-capture.ts';
31
37
  import type {
38
+ BrowserRequestInit,
32
39
  RequestCapture,
33
40
  RequestStageFact,
34
41
  StateCapability,
@@ -49,6 +56,13 @@ export interface CredentialStore {
49
56
  values: Record<string, string>;
50
57
  /** Durable browser storage captured by `imprint login`; V1 seeds localStorage only. */
51
58
  storage?: StorageRecord[];
59
+ /** Factual source label, not a validity judgment. */
60
+ source?: string;
61
+ /** Version of the durable snapshot read by the host, before environment
62
+ * overlays or browser mutations. Enables conditional cookie writeback. */
63
+ durableRevision?: string;
64
+ /** Commit managed authentication against the snapshot used to start it. */
65
+ authCommitRevision?: string;
52
66
  }
53
67
 
54
68
  type NavigationOptions = NonNullable<WorkflowRequest['navigation']>;
@@ -68,7 +82,7 @@ type RequestTransform = (
68
82
  url: string,
69
83
  responses: unknown[],
70
84
  params?: Record<string, string | number | boolean>,
71
- ) => RequestTransformResult;
85
+ ) => RequestTransformResult | Promise<RequestTransformResult>;
72
86
 
73
87
  function mergeNavigationOptions(
74
88
  base: WorkflowRequest['navigation'],
@@ -133,6 +147,8 @@ function responseProvenanceUrl(
133
147
  * transport generic lets pages mint coupled browser state through their own
134
148
  * JavaScript without adding OAuth-provider or frontend-framework logic here. */
135
149
  export interface BrowserNavigationTransport {
150
+ /** Actual browser-owned cookies before this workflow starts. */
151
+ initialCookies?: RuntimeCookie[];
136
152
  navigate(
137
153
  url: string,
138
154
  options?: {
@@ -150,6 +166,7 @@ export interface BrowserNavigationTransport {
150
166
  cookie?: { name: string; domain?: string; path?: string };
151
167
  },
152
168
  ): Promise<Response>;
169
+ /** Complete cookie snapshot for this isolated browser context; rejects when unavailable. */
153
170
  snapshotCookies(): Promise<RuntimeCookie[]>;
154
171
  }
155
172
 
@@ -157,13 +174,19 @@ export interface BrowserNavigationTransport {
157
174
  * encrypted-file fallback → legacy JSON for backwards compat). Returns
158
175
  * null only if there's truly nothing recorded; a missing keychain entry
159
176
  * with no legacy file still yields an empty store. */
160
- export async function loadCredentialStore(site: string): Promise<CredentialStore | null> {
161
- const view = await loadSiteCredentials(site);
177
+ export async function loadCredentialStore(
178
+ site: string,
179
+ signal?: AbortSignal,
180
+ options?: { includeEmpty?: boolean },
181
+ ): Promise<CredentialStore | null> {
182
+ const view = await loadSiteCredentials(site, { signal });
162
183
  const store: CredentialStore = {
163
184
  site: view.site,
164
185
  cookies: view.cookies,
165
186
  values: { ...view.values },
166
187
  storage: view.storage,
188
+ source: 'durable-store',
189
+ durableRevision: view.revision,
167
190
  };
168
191
 
169
192
  const envCreds = process.env.IMPRINT_TEACH_CREDENTIALS;
@@ -171,6 +194,7 @@ export async function loadCredentialStore(site: string): Promise<CredentialStore
171
194
  try {
172
195
  const parsed = JSON.parse(envCreds) as { site: string; values: Record<string, string> };
173
196
  if (parsed.site === site && parsed.values) {
197
+ store.source = 'durable-store+environment';
174
198
  for (const [k, v] of Object.entries(parsed.values)) {
175
199
  if (!(k in store.values)) store.values[k] = v;
176
200
  }
@@ -181,6 +205,7 @@ export async function loadCredentialStore(site: string): Promise<CredentialStore
181
205
  }
182
206
 
183
207
  if (
208
+ !options?.includeEmpty &&
184
209
  Object.keys(store.values).length === 0 &&
185
210
  store.cookies.length === 0 &&
186
211
  (store.storage?.length ?? 0) === 0
@@ -201,6 +226,7 @@ export interface ResponseObservation {
201
226
  valueType: 'null' | 'array' | 'object' | 'string' | 'number' | 'boolean';
202
227
  topLevelKeys?: string[];
203
228
  arrayLength?: number;
229
+ dispatch?: DispatchObservation;
204
230
  }
205
231
 
206
232
  /** Host-only per-request evidence, emitted before status, capture or parser failures.
@@ -235,6 +261,11 @@ interface ExecuteOptions {
235
261
  signal?: AbortSignal;
236
262
  /** Disable auth cookie/secret writes during offline request rendering. */
237
263
  persistAuthState?: boolean;
264
+ /** Host-only snapshot for isolated action tests; never ordinary tool output. */
265
+ onAuthState?: (credentials: CredentialStore) => void;
266
+ /** Host-only complete runtime cookie jar after a completed data request chain.
267
+ * Includes independent HTTP mutations; never returned as ordinary tool data. */
268
+ onCookieState?: (cookies: RuntimeCookie[]) => void;
238
269
  /** Internal observation hook used by teach/debug tooling after every
239
270
  * substitution and request transform. Raw values stay inside the host and
240
271
  * must be projected to bounded, value-free facts before reaching an agent.
@@ -353,6 +384,7 @@ function responseObservation(
353
384
  text: string,
354
385
  parsed: unknown,
355
386
  credentials: CredentialStore,
387
+ dispatch?: DispatchObservation,
356
388
  ): ResponseObservation {
357
389
  let valueType: ResponseObservation['valueType'];
358
390
  if (parsed === null) valueType = 'null';
@@ -364,6 +396,7 @@ function responseObservation(
364
396
  const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.slice(0, 200);
365
397
  return {
366
398
  requestIndex,
399
+ ...(dispatch ? { dispatch } : {}),
367
400
  status: response.status,
368
401
  bodyByteLength: new TextEncoder().encode(text).byteLength,
369
402
  redactedBodyPreview: boundedResponsePreview(text, credentials),
@@ -417,7 +450,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
417
450
 
418
451
  const credentials =
419
452
  opts.credentials ??
420
- (await loadCredentialStore(opts.workflow.site)) ??
453
+ (await loadCredentialStore(opts.workflow.site, opts.signal)) ??
421
454
  emptyStore(opts.workflow.site);
422
455
 
423
456
  // Validate required parameters are present and merge declared defaults
@@ -448,6 +481,9 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
448
481
 
449
482
  // Per-execution mutable jar. Never shared across MCP/cron calls.
450
483
  const cookieJar = new RuntimeCookieJar(credentials.cookies);
484
+ const browserCookieJar = new RuntimeCookieJar(
485
+ opts.browser?.initialCookies ?? credentials.cookies,
486
+ );
451
487
  const liveCredentials: CredentialStore = { ...credentials, cookies: cookieJar.toJSON() };
452
488
  const stateCapabilities = collectStateCapabilities(opts.workflow);
453
489
  const dependencyPreflight = preflightStateDependencies(opts.workflow, state, stateCapabilities);
@@ -534,7 +570,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
534
570
  if (requestTransform) {
535
571
  const bodyBeforeTransform = subbed.body;
536
572
  try {
537
- const transformResult = requestTransform(
573
+ const transformResult = await requestTransform(
538
574
  subbed.method,
539
575
  subbed.url,
540
576
  responseSlots.map((s) => s.raw),
@@ -603,6 +639,18 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
603
639
  );
604
640
  }
605
641
 
642
+ const dispatch = dispatchObservation({
643
+ invocationId: attemptId,
644
+ requestIndex: i,
645
+ material: credentials,
646
+ effectiveMaterial: liveCredentials,
647
+ source: opts.credentials
648
+ ? `explicit-override:${credentials.source ?? 'caller'}`
649
+ : (credentials.source ?? 'empty-store'),
650
+ request: subbed,
651
+ browserOwned: !!opts.browser,
652
+ });
653
+
606
654
  let resp: Response;
607
655
  let responseAbortTimer: ReturnType<typeof setTimeout> | undefined;
608
656
  if (req.mode === 'navigate') {
@@ -624,7 +672,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
624
672
  headers: subbed.headers,
625
673
  body: subbed.body,
626
674
  });
627
- for (const cookie of await opts.browser.snapshotCookies()) cookieJar.setCookie(cookie);
675
+ cookieJar.reconcileBrowserSnapshot(await opts.browser.snapshotCookies(), browserCookieJar);
628
676
  liveCredentials.cookies = cookieJar.toJSON();
629
677
  } catch (err) {
630
678
  const msg = err instanceof Error ? err.message : String(err);
@@ -647,6 +695,13 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
647
695
  : controller.signal,
648
696
  redirect: 'follow',
649
697
  });
698
+ if (opts.browser) {
699
+ cookieJar.reconcileBrowserSnapshot(
700
+ await opts.browser.snapshotCookies(),
701
+ browserCookieJar,
702
+ );
703
+ liveCredentials.cookies = cookieJar.toJSON();
704
+ }
650
705
  } catch (err) {
651
706
  if (responseAbortTimer) clearTimeout(responseAbortTimer);
652
707
  const msg = err instanceof Error ? err.message : String(err);
@@ -676,6 +731,11 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
676
731
  );
677
732
  }
678
733
  }
734
+ recordCredentialEvent('response', {
735
+ invocationId: attemptId,
736
+ requestIndex: i,
737
+ status: resp.status,
738
+ });
679
739
  requestStageFacts.push({
680
740
  requestIndex: i,
681
741
  stage: 'send',
@@ -723,7 +783,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
723
783
  // Not valid JSON — keep as raw text string.
724
784
  }
725
785
  try {
726
- opts.onResponse?.(responseObservation(i, resp, text, parsed, liveCredentials));
786
+ opts.onResponse?.(responseObservation(i, resp, text, parsed, liveCredentials, dispatch));
727
787
  } catch {
728
788
  // Diagnostics must never change the workflow's execution result.
729
789
  }
@@ -734,6 +794,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
734
794
  {
735
795
  ok: false,
736
796
  error: 'AUTH_EXPIRED',
797
+ status: resp.status,
737
798
  message: `Request ${i} returned 401 — auth has likely expired: ${bodyPreview.slice(0, 300)}`,
738
799
  remediation: `Run \`imprint login ${opts.workflow.site}\` to refresh credentials.`,
739
800
  },
@@ -747,6 +808,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
747
808
  {
748
809
  ok: false,
749
810
  error: 'FORBIDDEN',
811
+ status: resp.status,
750
812
  message: `Request ${i} returned 403: ${bodyPreview.slice(0, 300)}`,
751
813
  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}\`.`,
752
814
  },
@@ -758,6 +820,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
758
820
  {
759
821
  ok: false,
760
822
  error: 'RATE_LIMITED',
823
+ status: resp.status,
761
824
  message: `Request ${i} returned 429: ${bodyPreview.slice(0, 300)}`,
762
825
  remediation: 'Back off and retry after the Retry-After interval.',
763
826
  },
@@ -769,6 +832,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
769
832
  {
770
833
  ok: false,
771
834
  error: 'BAD_RESPONSE',
835
+ status: resp.status,
772
836
  message: `Request ${i} (${subbed.method} ${subbed.url}) returned ${resp.status}: ${bodyPreview.slice(0, 500)}`,
773
837
  },
774
838
  requestStageFacts,
@@ -797,7 +861,11 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
797
861
  const capturedResponseUrl = responseProvenanceUrl(subbed.url, navigation, resp);
798
862
  try {
799
863
  for (const sc of extractSetCookieHeaders(resp.headers))
800
- cookieJar.setCookieFromHeader(sc, capturedResponseUrl);
864
+ cookieJar.setCookieFromHeader(
865
+ sc,
866
+ capturedResponseUrl,
867
+ opts.browser ? browserCookieJar : undefined,
868
+ );
801
869
  liveCredentials.cookies = cookieJar.toJSON();
802
870
  } catch {
803
871
  // Non-fatal; cookies stay as they were.
@@ -817,6 +885,12 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
817
885
  Object.assign(state, captureResult.value);
818
886
  }
819
887
 
888
+ try {
889
+ opts.onCookieState?.(cookieJar.toJSON());
890
+ } catch {
891
+ // Observation cannot change the request or parser result.
892
+ }
893
+
820
894
  // Apply parser if present
821
895
  let finalData = responseSlots.at(-1)?.raw ?? null;
822
896
  if (opts.onRawResponses) {
@@ -891,9 +965,12 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
891
965
  const timeoutMs = opts.requestTimeoutMs ?? 30_000;
892
966
  const credentials =
893
967
  opts.credentials ??
894
- (await loadCredentialStore(opts.workflow.site)) ??
968
+ (await loadCredentialStore(opts.workflow.site, opts.signal)) ??
895
969
  emptyStore(opts.workflow.site);
896
970
  const cookieJar = new RuntimeCookieJar(credentials.cookies);
971
+ const browserCookieJar = new RuntimeCookieJar(
972
+ opts.browser?.initialCookies ?? credentials.cookies,
973
+ );
897
974
  const liveCredentials: CredentialStore = { ...credentials, cookies: cookieJar.toJSON() };
898
975
  const responseSlots: ResponseSlot[] = [];
899
976
  const initialState: Record<string, unknown> = { ...(opts.initialState ?? {}) };
@@ -907,12 +984,15 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
907
984
  message: `Auth action ${JSON.stringify(actionName)} requires: ${missingParameters.join(', ')}.`,
908
985
  };
909
986
  }
987
+ const authSignal = credentials.authCommitRevision === undefined ? undefined : opts.signal;
910
988
  let requestTransform: RequestTransform | undefined;
911
989
  if (opts.workflow.requestTransformModule && opts.workflowPath) {
912
990
  try {
913
- const mod = await importModuleFresh(
914
- pathResolve(dirname(opts.workflowPath), opts.workflow.requestTransformModule),
991
+ const modulePath = pathResolve(
992
+ dirname(opts.workflowPath),
993
+ opts.workflow.requestTransformModule,
915
994
  );
995
+ const mod = await withAbortSignal(() => importModuleFresh(modulePath), authSignal);
916
996
  if (typeof mod.transform === 'function') {
917
997
  requestTransform = mod.transform as RequestTransform;
918
998
  }
@@ -928,11 +1008,65 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
928
1008
  requestUrl: string;
929
1009
  };
930
1010
  let lastResponse: ResponseContext | undefined;
931
-
1011
+ let outcomeResponse: ResponseContext | undefined;
1012
+
1013
+ const stageFacts: RequestStageFact[] = [];
1014
+ let stepIndex = 0;
1015
+ let attemptCount = 0;
1016
+ let currentRequestIndex = 0;
1017
+ let phase: RequestStageFact['stage'] = 'preparation';
1018
+ let transportInvoked = false;
1019
+ let managedUnsafeDispatches = 0;
1020
+ let httpStatus: number | undefined;
1021
+ const recordStage = (
1022
+ stage: RequestStageFact['stage'],
1023
+ outcome: RequestStageFact['outcome'],
1024
+ ): void => {
1025
+ phase = stage;
1026
+ stageFacts.push({
1027
+ requestIndex: currentRequestIndex,
1028
+ stepIndex,
1029
+ attempt: attemptCount,
1030
+ stage,
1031
+ outcome,
1032
+ transportInvoked,
1033
+ ...(httpStatus === undefined ? {} : { httpStatus }),
1034
+ });
1035
+ if (stageFacts.length > 32) stageFacts.shift();
1036
+ };
932
1037
  const fail = (result: RuntimeErrorResult): RuntimeErrorResult => {
1038
+ // A user can correct input after the provider rejects this submission.
1039
+ // Managed MCP still requires consent before sending the corrected request.
1040
+ const canCorrectInput =
1041
+ managedUnsafeDispatches === 1 &&
1042
+ action.parameters.length > 0 &&
1043
+ phase === 'send' &&
1044
+ transportInvoked &&
1045
+ opts.workflow.requests[currentRequestIndex]?.effect === 'unsafe' &&
1046
+ result.error !== 'NETWORK' &&
1047
+ httpStatus !== undefined &&
1048
+ httpStatus >= 400 &&
1049
+ httpStatus < 500;
933
1050
  return {
934
1051
  ...result,
935
- continuation: Object.keys(initialState).length > 0 ? initialState : undefined,
1052
+ requestStageFacts: [...stageFacts, ...(result.requestStageFacts ?? [])].slice(-32),
1053
+ authExecution: {
1054
+ action: actionName,
1055
+ stepIndex,
1056
+ requestIndex: currentRequestIndex,
1057
+ attempts: attemptCount,
1058
+ phase,
1059
+ transportInvoked,
1060
+ responseReceived: httpStatus !== undefined,
1061
+ ...(httpStatus === undefined ? {} : { httpStatus }),
1062
+ ...result.authExecution,
1063
+ },
1064
+ continuation:
1065
+ (managedUnsafeDispatches === 0 || canCorrectInput) &&
1066
+ (Object.keys(initialState).length > 0 ||
1067
+ (credentials.authCommitRevision !== undefined && opts.initialState !== undefined))
1068
+ ? initialState
1069
+ : undefined,
936
1070
  };
937
1071
  };
938
1072
 
@@ -960,13 +1094,24 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
960
1094
  }`,
961
1095
  });
962
1096
 
1097
+ const cancellationFailure = (): RuntimeErrorResult =>
1098
+ fail({
1099
+ ok: false,
1100
+ error: 'NETWORK',
1101
+ message: `Auth action ${JSON.stringify(actionName)} was cancelled.`,
1102
+ ...(httpStatus === undefined ? {} : { status: httpStatus }),
1103
+ });
1104
+
963
1105
  const persistCookies = async (): Promise<RuntimeErrorResult | undefined> => {
1106
+ if (opts.signal?.aborted) return cancellationFailure();
964
1107
  if (opts.persistAuthState === false) return undefined;
965
1108
  try {
966
- await saveSiteCookies(opts.workflow.site, cookieJar.toJSON());
967
- return undefined;
1109
+ await saveSiteCookies(opts.workflow.site, cookieJar.toJSON(), { signal: opts.signal });
1110
+ return opts.signal?.aborted ? cancellationFailure() : undefined;
968
1111
  } catch (err) {
969
- return persistenceFailure('session cookies', err);
1112
+ return opts.signal?.aborted
1113
+ ? cancellationFailure()
1114
+ : persistenceFailure('session cookies', err);
970
1115
  }
971
1116
  };
972
1117
 
@@ -1004,21 +1149,33 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1004
1149
  stateCapabilities,
1005
1150
  requestUrlTemplate: req.url,
1006
1151
  });
1007
- if (!substituted.ok) return substituted;
1152
+ if (!substituted.ok) {
1153
+ recordStage('preparation', 'failed');
1154
+ return substituted;
1155
+ }
1156
+ recordStage('preparation', 'passed');
1008
1157
  const request = substituted.value;
1009
1158
  let navigation = req.navigation;
1010
1159
 
1011
- if (requestTransform) {
1160
+ const transform = requestTransform;
1161
+ if (transform) {
1012
1162
  try {
1013
- const transformed = requestTransform(
1014
- request.method,
1015
- request.url,
1016
- responseSlots.map((slot) => slot.raw),
1017
- params,
1163
+ const transformed = await withAbortSignal(
1164
+ async () =>
1165
+ transform(
1166
+ request.method,
1167
+ request.url,
1168
+ responseSlots.map((slot) => slot.raw),
1169
+ params,
1170
+ ),
1171
+ authSignal,
1018
1172
  );
1019
1173
  if (typeof transformed === 'string') request.url = transformed;
1020
1174
  else if (transformed) {
1021
- if (transformed.skip) return { ok: true, value: undefined };
1175
+ if (transformed.skip) {
1176
+ recordStage('transform', 'skipped');
1177
+ return { ok: true, value: undefined };
1178
+ }
1022
1179
  if (transformed.url !== undefined) request.url = transformed.url;
1023
1180
  if (transformed.body !== undefined) request.body = transformed.body;
1024
1181
  if (transformed.headers) Object.assign(request.headers, transformed.headers);
@@ -1026,7 +1183,9 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1026
1183
  navigation = mergeNavigationOptions(navigation, transformed.navigation);
1027
1184
  }
1028
1185
  }
1186
+ recordStage('transform', 'passed');
1029
1187
  } catch (err) {
1188
+ recordStage('transform', 'failed');
1030
1189
  return {
1031
1190
  ok: false,
1032
1191
  result: {
@@ -1044,11 +1203,33 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1044
1203
  if (cookieHeader && !hasHeader(request.headers, 'cookie'))
1045
1204
  request.headers.cookie = cookieHeader;
1046
1205
  observePreparedRequest(opts, requestIndex, request);
1206
+ if (opts.signal?.aborted) {
1207
+ return {
1208
+ ok: false,
1209
+ result: {
1210
+ ok: false,
1211
+ error: 'NETWORK',
1212
+ message: `Auth action ${JSON.stringify(actionName)} was cancelled before dispatch.`,
1213
+ },
1214
+ };
1215
+ }
1216
+ const dispatch = dispatchObservation({
1217
+ invocationId: attemptId,
1218
+ requestIndex,
1219
+ material: credentials,
1220
+ effectiveMaterial: liveCredentials,
1221
+ source: opts.credentials
1222
+ ? `explicit-override:${credentials.source ?? 'caller'}`
1223
+ : (credentials.source ?? 'empty-store'),
1224
+ request,
1225
+ browserOwned: !!opts.browser,
1226
+ });
1047
1227
 
1048
1228
  let response: Response;
1049
1229
  let responseAbortTimer: ReturnType<typeof setTimeout> | undefined;
1050
1230
  if (req.mode === 'navigate') {
1051
1231
  if (!opts.browser) {
1232
+ recordStage('send', 'unavailable');
1052
1233
  return {
1053
1234
  ok: false,
1054
1235
  result: {
@@ -1059,14 +1240,18 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1059
1240
  };
1060
1241
  }
1061
1242
  try {
1243
+ transportInvoked = true;
1244
+ phase = 'send';
1062
1245
  response = await opts.browser.navigate(request.url, {
1063
1246
  ...navigation,
1064
1247
  method: request.method,
1065
1248
  headers: request.headers,
1066
1249
  body: request.body,
1067
1250
  });
1068
- for (const cookie of await opts.browser.snapshotCookies()) cookieJar.setCookie(cookie);
1251
+ httpStatus = response.status;
1252
+ cookieJar.reconcileBrowserSnapshot(await opts.browser.snapshotCookies(), browserCookieJar);
1069
1253
  } catch (err) {
1254
+ recordStage('send', 'failed');
1070
1255
  return {
1071
1256
  ok: false,
1072
1257
  result: {
@@ -1080,19 +1265,33 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1080
1265
  }
1081
1266
  } else {
1082
1267
  const controller = new AbortController();
1083
- const cancel = (): void => controller.abort();
1084
- opts.signal?.addEventListener('abort', cancel, { once: true });
1085
1268
  responseAbortTimer = setTimeout(() => controller.abort(), timeoutMs);
1086
1269
  try {
1087
- response = await fetchFn(request.url, {
1270
+ transportInvoked = true;
1271
+ phase = 'send';
1272
+ const managedUnsafe =
1273
+ credentials.authCommitRevision !== undefined && req.effect === 'unsafe';
1274
+ if (managedUnsafe) managedUnsafeDispatches++;
1275
+ const init: BrowserRequestInit = {
1088
1276
  method: request.method,
1089
1277
  headers: request.headers,
1090
1278
  body: request.body,
1091
- signal: controller.signal,
1279
+ signal: opts.signal
1280
+ ? AbortSignal.any([opts.signal, controller.signal])
1281
+ : controller.signal,
1092
1282
  redirect: 'follow',
1093
- });
1283
+ ...(managedUnsafe ? { allowAmbiguousRetry: false } : {}),
1284
+ };
1285
+ response = await fetchFn(request.url, init);
1286
+ httpStatus = response.status;
1287
+ if (opts.browser)
1288
+ cookieJar.reconcileBrowserSnapshot(
1289
+ await opts.browser.snapshotCookies(),
1290
+ browserCookieJar,
1291
+ );
1094
1292
  } catch (err) {
1095
1293
  if (responseAbortTimer) clearTimeout(responseAbortTimer);
1294
+ recordStage('send', 'failed');
1096
1295
  return {
1097
1296
  ok: false,
1098
1297
  result: {
@@ -1103,11 +1302,16 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1103
1302
  }`,
1104
1303
  },
1105
1304
  };
1106
- } finally {
1107
- opts.signal?.removeEventListener('abort', cancel);
1108
1305
  }
1109
1306
  }
1110
1307
 
1308
+ httpStatus = response.status;
1309
+ recordCredentialEvent('response', {
1310
+ invocationId: attemptId,
1311
+ requestIndex,
1312
+ status: response.status,
1313
+ });
1314
+ recordStage('send', 'passed');
1111
1315
  let text = '';
1112
1316
  let responseReadError: string | undefined;
1113
1317
  try {
@@ -1117,6 +1321,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1117
1321
  } finally {
1118
1322
  if (responseAbortTimer) clearTimeout(responseAbortTimer);
1119
1323
  }
1324
+ if (opts.signal?.aborted) return { ok: false, result: cancellationFailure() };
1120
1325
  observeResponseEvidence(
1121
1326
  opts,
1122
1327
  attemptId,
@@ -1127,6 +1332,21 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1127
1332
  responseReadError,
1128
1333
  liveCredentials,
1129
1334
  );
1335
+ if (!responseReadError) {
1336
+ let observed: unknown = text;
1337
+ try {
1338
+ observed = JSON.parse(text);
1339
+ } catch {
1340
+ /* Text is a valid observation. */
1341
+ }
1342
+ try {
1343
+ opts.onResponse?.(
1344
+ responseObservation(requestIndex, response, text, observed, liveCredentials, dispatch),
1345
+ );
1346
+ } catch {
1347
+ /* Diagnostics cannot alter action execution. */
1348
+ }
1349
+ }
1130
1350
  if (response.status >= 400) {
1131
1351
  const bodyPreview = responseReadError
1132
1352
  ? `[response body unavailable: ${responseReadError}]`
@@ -1155,7 +1375,11 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1155
1375
 
1156
1376
  const capturedResponseUrl = responseProvenanceUrl(request.url, navigation, response);
1157
1377
  for (const header of extractSetCookieHeaders(response.headers)) {
1158
- cookieJar.setCookieFromHeader(header, capturedResponseUrl);
1378
+ cookieJar.setCookieFromHeader(
1379
+ header,
1380
+ capturedResponseUrl,
1381
+ opts.browser ? browserCookieJar : undefined,
1382
+ );
1159
1383
  }
1160
1384
  liveCredentials.cookies = cookieJar.toJSON();
1161
1385
 
@@ -1167,11 +1391,13 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1167
1391
  }
1168
1392
  const context = { parsed, text, headers: response.headers, requestUrl: capturedResponseUrl };
1169
1393
  responseSlots.push({ raw: parsed, aliases: evaluateLegacyExtract(req, parsed) });
1394
+ for (const capture of req.captures ?? []) delete state[capture.name];
1170
1395
  const captures = evaluateRequestCaptures(req.captures ?? [], {
1171
1396
  ...context,
1172
1397
  cookieJar,
1173
1398
  });
1174
1399
  if (!captures.ok) {
1400
+ recordStage('capture', 'failed');
1175
1401
  return {
1176
1402
  ok: false,
1177
1403
  result: {
@@ -1181,15 +1407,17 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1181
1407
  },
1182
1408
  };
1183
1409
  }
1410
+ recordStage('capture', 'passed');
1184
1411
  Object.assign(state, captures.value);
1185
1412
  lastResponse = context;
1186
1413
  return { ok: true, value: context };
1187
1414
  };
1188
1415
 
1189
- for (let stepIndex = 0; stepIndex < action.steps.length; stepIndex++) {
1416
+ for (stepIndex = 0; stepIndex < action.steps.length; stepIndex++) {
1190
1417
  const step = action.steps[stepIndex];
1191
1418
  if (!step) continue;
1192
1419
  let matched = !step.repeat;
1420
+ let lastFailure: RuntimeErrorResult | undefined;
1193
1421
 
1194
1422
  for (let attempt = 0; attempt < (step.repeat?.maxAttempts ?? 1); attempt++) {
1195
1423
  if (attempt > 0 && step.repeat) await sleep(step.repeat.intervalMs, opts.signal);
@@ -1200,8 +1428,30 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1200
1428
  message: `Auth action ${JSON.stringify(actionName)} was cancelled.`,
1201
1429
  });
1202
1430
  }
1431
+ attemptCount = attempt + 1;
1432
+ currentRequestIndex = step.request;
1433
+ phase = 'preparation';
1434
+ transportInvoked = false;
1435
+ httpStatus = undefined;
1203
1436
  const executed = await executeRequest(step.request);
1437
+ // A failed or skipped final step cannot branch on an earlier response.
1438
+ outcomeResponse = executed.ok ? executed.value : undefined;
1439
+ if (!outcomeResponse) {
1440
+ for (const capture of opts.workflow.requests[step.request]?.captures ?? []) {
1441
+ delete state[capture.name];
1442
+ }
1443
+ }
1444
+ if (opts.signal?.aborted) return cancellationFailure();
1204
1445
  if (!executed.ok) {
1446
+ lastFailure = executed.result;
1447
+ if (
1448
+ managedUnsafeDispatches > 0 &&
1449
+ lastFailure.error === 'NETWORK' &&
1450
+ transportInvoked &&
1451
+ opts.workflow.requests[step.request]?.effect === 'unsafe'
1452
+ ) {
1453
+ return fail(lastFailure);
1454
+ }
1205
1455
  if (step.onError === 'continue') {
1206
1456
  matched = true;
1207
1457
  break;
@@ -1209,6 +1459,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1209
1459
  if (step.onError === 'retry' && step.repeat) continue;
1210
1460
  return fail(executed.result);
1211
1461
  }
1462
+ lastFailure = undefined;
1212
1463
  if (!step.repeat) break;
1213
1464
  if (!executed.value) continue;
1214
1465
 
@@ -1219,20 +1470,59 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1219
1470
  if (until.ok && Object.hasOwn(until.value, step.repeat.until.name)) {
1220
1471
  Object.assign(state, until.value);
1221
1472
  matched = true;
1473
+ recordStage('repeat', 'passed');
1222
1474
  break;
1223
1475
  }
1476
+ recordStage('repeat', 'failed');
1224
1477
  }
1225
1478
 
1226
1479
  if (!matched) {
1480
+ return fail({
1481
+ ...(lastFailure ?? { ok: false as const, error: 'BAD_RESPONSE' as const }),
1482
+ message: `Auth action ${JSON.stringify(actionName)} did not satisfy the declared repeat condition for request ${step.request} after ${attemptCount} attempts.${lastFailure ? ` Last failure: ${lastFailure.message}` : ''}`,
1483
+ authExecution: {
1484
+ action: actionName,
1485
+ stepIndex,
1486
+ requestIndex: step.request,
1487
+ attempts: attemptCount,
1488
+ phase,
1489
+ transportInvoked,
1490
+ responseReceived: httpStatus !== undefined,
1491
+ ...(httpStatus === undefined ? {} : { httpStatus }),
1492
+ ...(lastFailure
1493
+ ? { lastFailure: { error: lastFailure.error, message: lastFailure.message } }
1494
+ : {}),
1495
+ },
1496
+ });
1497
+ }
1498
+ }
1499
+
1500
+ if (opts.signal?.aborted) return cancellationFailure();
1501
+ phase = 'outcome';
1502
+ let outcome = action.outcome;
1503
+ for (const { when } of action.branches ?? []) delete state[when.name];
1504
+ for (const branch of action.branches ?? []) {
1505
+ if (!outcomeResponse) break;
1506
+ let matched: RuntimeResult<Record<string, unknown>>;
1507
+ try {
1508
+ matched = evaluateRequestCaptures([{ ...branch.when, required: false } as RequestCapture], {
1509
+ ...outcomeResponse,
1510
+ cookieJar,
1511
+ });
1512
+ } catch (error) {
1227
1513
  return fail({
1228
1514
  ok: false,
1229
1515
  error: 'BAD_RESPONSE',
1230
- message: `Auth action ${JSON.stringify(actionName)} did not satisfy the declared repeat condition for request ${step.request} after ${step.repeat?.maxAttempts ?? 1} attempts.`,
1516
+ message: `Auth action ${JSON.stringify(actionName)} could not evaluate branch ${JSON.stringify(branch.when.name)}: ${error instanceof Error ? error.message : String(error)}`,
1231
1517
  });
1232
1518
  }
1519
+ if (matched.ok && Object.hasOwn(matched.value, branch.when.name)) {
1520
+ Object.assign(state, matched.value);
1521
+ outcome = branch.outcome;
1522
+ break;
1523
+ }
1233
1524
  }
1234
-
1235
- const missingEvidence = action.outcome.evidence.filter((name) => !Object.hasOwn(state, name));
1525
+ const missingEvidence = outcome.evidence.filter((name) => !Object.hasOwn(state, name));
1236
1526
  if (missingEvidence.length > 0) {
1237
1527
  return fail({
1238
1528
  ok: false,
@@ -1241,8 +1531,8 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1241
1531
  });
1242
1532
  }
1243
1533
 
1244
- if (action.outcome.type === 'pause') {
1245
- const missingCarry = action.outcome.carry.filter((name) => !Object.hasOwn(state, name));
1534
+ if (outcome.type === 'pause') {
1535
+ const missingCarry = outcome.carry.filter((name) => !Object.hasOwn(state, name));
1246
1536
  if (missingCarry.length > 0) {
1247
1537
  return fail({
1248
1538
  ok: false,
@@ -1250,19 +1540,22 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1250
1540
  message: `Auth action ${JSON.stringify(actionName)} completed without declared carry state: ${missingCarry.join(', ')}.`,
1251
1541
  });
1252
1542
  }
1253
- const continuation = Object.fromEntries(
1254
- action.outcome.carry.map((name) => [name, state[name]]),
1255
- );
1256
- const cookieFailure = await persistCookies();
1543
+ const continuation = Object.fromEntries(outcome.carry.map((name) => [name, state[name]]));
1544
+ if (opts.signal?.aborted) return cancellationFailure();
1545
+ const cookieFailure =
1546
+ credentials.authCommitRevision === undefined ? await persistCookies() : undefined;
1547
+ if (opts.signal?.aborted) return cancellationFailure();
1257
1548
  if (cookieFailure && !opts.browser) return cookieFailure;
1258
1549
  const persistenceWarning = cookieFailure
1259
1550
  ? ` ${cookieFailure.message} Continue in the current browser session.`
1260
1551
  : '';
1552
+ observeAuthState(opts, liveCredentials);
1261
1553
  return {
1262
1554
  ok: false,
1263
1555
  error: 'ACTION_REQUIRED',
1264
- message: `${action.outcome.message}${persistenceWarning}`,
1265
- nextAction: action.outcome.next,
1556
+ message: `${outcome.message}${persistenceWarning}`,
1557
+ nextAction: outcome.next,
1558
+ ...(outcome.resume ? { resume: outcome.resume } : {}),
1266
1559
  continuation,
1267
1560
  responseBodyPreview: lastResponse?.text.slice(0, 500),
1268
1561
  };
@@ -1285,16 +1578,86 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1285
1578
  persistedSecrets[name] = String(value);
1286
1579
  }
1287
1580
  }
1581
+ if (opts.signal?.aborted) return cancellationFailure();
1288
1582
  if (opts.persistAuthState !== false) {
1289
1583
  try {
1290
- await commitSiteAuthState(opts.workflow.site, cookieJar.toJSON(), persistedSecrets);
1584
+ await commitSiteAuthState(opts.workflow.site, cookieJar.toJSON(), persistedSecrets, {
1585
+ signal: opts.signal,
1586
+ expectedRevision: credentials.authCommitRevision,
1587
+ });
1291
1588
  } catch (err) {
1292
- return persistenceFailure('authenticated session state', err);
1589
+ return opts.signal?.aborted
1590
+ ? cancellationFailure()
1591
+ : persistenceFailure('authenticated session state', err);
1293
1592
  }
1294
1593
  }
1594
+ if (opts.signal?.aborted) return cancellationFailure();
1595
+ observeAuthState(opts, {
1596
+ ...liveCredentials,
1597
+ values: { ...liveCredentials.values, ...persistedSecrets },
1598
+ });
1295
1599
  return { ok: true, data: { authenticated: true } };
1296
1600
  }
1297
1601
 
1602
+ function observeAuthState(opts: ExecuteOptions, credentials: CredentialStore): void {
1603
+ try {
1604
+ opts.onAuthState?.(structuredClone(credentials));
1605
+ } catch {
1606
+ // Observation cannot change execution or persistence.
1607
+ }
1608
+ }
1609
+
1610
+ /** Offline synthetic fixture runner using the real action executor. Every call
1611
+ * starts with empty response history, keeps only declared continuation and
1612
+ * session mutations, and disables durable writes. It never contacts the network
1613
+ * unless the fixture's explicit transport does so. */
1614
+ export function createAuthActionTestHarness(input: {
1615
+ workflow: Workflow;
1616
+ workflowPath?: string;
1617
+ credentials?: CredentialStore;
1618
+ respond: (
1619
+ request: PreparedRequestObservation & { action: string },
1620
+ ) => Response | Promise<Response>;
1621
+ browser?: BrowserNavigationTransport;
1622
+ }): {
1623
+ run: (
1624
+ action: string,
1625
+ parameters?: Record<string, string | number | boolean>,
1626
+ ) => Promise<ToolResult>;
1627
+ } {
1628
+ if (input.workflow.toolKind !== 'authenticate')
1629
+ throw new Error('An authenticate workflow is required.');
1630
+ let credentials = structuredClone(input.credentials ?? emptyStore(input.workflow.site));
1631
+ let continuation: Record<string, unknown> | undefined;
1632
+ return {
1633
+ async run(action, parameters = {}) {
1634
+ let prepared: PreparedRequestObservation | undefined;
1635
+ const result = await executeWorkflow({
1636
+ workflow: input.workflow,
1637
+ workflowPath: input.workflowPath,
1638
+ params: { ...parameters, action },
1639
+ credentials,
1640
+ initialState: continuation,
1641
+ persistAuthState: false,
1642
+ browser: input.browser,
1643
+ onPreparedRequest: (request) => {
1644
+ prepared = request;
1645
+ },
1646
+ onAuthState: (next) => {
1647
+ credentials = next;
1648
+ },
1649
+ fetchImpl: (async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
1650
+ if (!prepared)
1651
+ throw new Error('Synthetic response requested without a prepared request.');
1652
+ return await input.respond({ ...prepared, action });
1653
+ }) as typeof fetch,
1654
+ });
1655
+ continuation = result.ok ? undefined : (result.continuation ?? continuation);
1656
+ return result;
1657
+ },
1658
+ };
1659
+ }
1660
+
1298
1661
  function sleep(ms: number, signal?: AbortSignal): Promise<void> {
1299
1662
  if (signal?.aborted) return Promise.resolve();
1300
1663
  return new Promise((resolve) => {
@@ -1603,6 +1966,12 @@ function collectStateCapabilities(workflow: Workflow): Map<string, StateCapabili
1603
1966
  for (const req of workflow.requests) {
1604
1967
  for (const c of req.captures ?? []) out.set(c.name, c.capability);
1605
1968
  }
1969
+ for (const action of Object.values(workflow.authConfig?.actions ?? {})) {
1970
+ for (const step of action.steps) {
1971
+ if (step.repeat) out.set(step.repeat.until.name, step.repeat.until.capability);
1972
+ }
1973
+ for (const { when } of action.branches ?? []) out.set(when.name, when.capability);
1974
+ }
1606
1975
  return out;
1607
1976
  }
1608
1977