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
@@ -18,6 +18,7 @@ import { createHash } from 'node:crypto';
18
18
  import { existsSync, readFileSync } from 'node:fs';
19
19
  import { basename, dirname, resolve as pathResolve } from 'node:path';
20
20
  import type { Page } from 'playwright';
21
+ import { AUTH_DISCOVERY_POOL_KEY } from './auth-browser-discovery.ts';
21
22
  import { loadBackendsCacheStatus } from './backend-cache.ts';
22
23
  import {
23
24
  type CdpBrowserFetch,
@@ -34,7 +35,9 @@ import {
34
35
  } from './cdp-jar-cache.ts';
35
36
  import { proxyUrl } from './chromium.ts';
36
37
  import { abortSignalError, abortableDelay, withAbortSignal } from './concurrency.ts';
37
- import { RuntimeCookieJar } from './cookie-jar.ts';
38
+ import { RuntimeCookieJar, cookieScopeKey } from './cookie-jar.ts';
39
+ import { recordBrowserCookieState } from './credential-observation.ts';
40
+ import { compareAndSwapSiteCookies } from './credential-store.ts';
38
41
  import { redactFreeformText } from './freeform-redact.ts';
39
42
  import { createLog } from './log.ts';
40
43
  import { runPlaybook } from './playbook-runner.ts';
@@ -57,6 +60,7 @@ import {
57
60
  createStealthFetch,
58
61
  } from './stealth-fetch.ts';
59
62
  import { clearCachedToken, loadCachedToken, saveCachedToken } from './stealth-token-cache.ts';
63
+ import { prepareTeachBrowserContext } from './teach-browser-resources.ts';
60
64
  import type { ResolvedTool } from './tool-loader.ts';
61
65
  import { WorkflowSchema } from './types.ts';
62
66
  import type {
@@ -346,6 +350,8 @@ export async function runWithLadder(
346
350
  /** Per-site CDP browser pool so cdp-replay reuses a live Chrome across
347
351
  * calls (~2-5s) instead of launching a fresh one each time (~33s). */
348
352
  cdpPool?: Map<string, CdpBrowserFetch>;
353
+ /** A managed continuation must use the browser that produced it. */
354
+ authBrowser?: CdpBrowserFetch;
349
355
  /** Per-session memo of the backend that last served each tool. Once set, the
350
356
  * next call starts at that backend instead of re-walking the doomed early
351
357
  * rungs — the runtime analog of the compile path's `compileWinningBackend`.
@@ -475,6 +481,7 @@ export async function runWithLadder(
475
481
  options?.onResponse ? onResponse : undefined,
476
482
  options?.onPreparedRequest ? onPreparedRequest : undefined,
477
483
  options?.signal,
484
+ options?.cdpPool,
478
485
  );
479
486
  break;
480
487
  case 'cdp-replay':
@@ -487,11 +494,17 @@ export async function runWithLadder(
487
494
  options?.signal,
488
495
  options?.onResponse ? onResponse : undefined,
489
496
  options?.onPreparedRequest ? onPreparedRequest : undefined,
497
+ options?.authBrowser,
490
498
  );
491
499
  break;
492
500
  case 'stealth-fetch': {
493
501
  const paramsWithDefaults = withWorkflowDefaults(tool.workflow, params);
494
- const sf = await ensureStealthFetch(tool, stealthCache, paramsWithDefaults);
502
+ const sf = await ensureStealthFetch(
503
+ tool,
504
+ stealthCache,
505
+ paramsWithDefaults,
506
+ options?.signal,
507
+ );
495
508
  // When the workflow declares a bootstrap block, mint its declared
496
509
  // session-token state (CSRF cookies etc.) from the SAME stealth
497
510
  // session that provides the transport cookies. Without this, a
@@ -810,8 +823,14 @@ async function getOrMintCdpJar(
810
823
  cacheDir: string,
811
824
  recordingDir: string,
812
825
  forceFresh: boolean,
826
+ cdpPool?: Map<string, CdpBrowserFetch>,
827
+ signal?: AbortSignal,
813
828
  ): Promise<MintedJar | null> {
814
- if (cdpJarMinterForTest) return cdpJarMinterForTest(baseUrl, bootstrapUrl);
829
+ if (cdpJarMinterForTest) {
830
+ await prepareTeachBrowserContext(cdpPool);
831
+ if (signal?.aborted) throw abortSignalError(signal);
832
+ return cdpJarMinterForTest(baseUrl, bootstrapUrl);
833
+ }
815
834
  if (!forceFresh) {
816
835
  let cached = loadJar(cacheDir);
817
836
  // A recording NEWER than the cached jar supersedes it — e.g. the user
@@ -843,6 +862,8 @@ async function getOrMintCdpJar(
843
862
  }
844
863
  let cf: CdpBrowserFetch | undefined;
845
864
  try {
865
+ await prepareTeachBrowserContext(cdpPool);
866
+ if (signal?.aborted) throw abortSignalError(signal);
846
867
  cf = (cdpBrowserFetchFactoryForTest ?? createCdpBrowserFetch)({ baseUrl, bootstrapUrl });
847
868
  const jar = await cf.mintJar();
848
869
  if (jar.abckFlag !== '0') {
@@ -905,7 +926,7 @@ function jarLikelyStale(result: ToolResult): boolean {
905
926
 
906
927
  function credentialSeedCookies(credentials: CredentialStore): MintedJar['cookies'] {
907
928
  return credentials.cookies
908
- .filter((c) => c.name && c.value && c.domain)
929
+ .filter((c) => c.name && c.value && c.domain && !c.httpResponse?.deleted)
909
930
  .map((c) => ({
910
931
  name: c.name,
911
932
  value: c.value,
@@ -915,6 +936,7 @@ function credentialSeedCookies(credentials: CredentialStore): MintedJar['cookies
915
936
  httpOnly: c.httpOnly,
916
937
  secure: c.secure,
917
938
  sameSite: c.sameSite,
939
+ hostOnly: c.hostOnly,
918
940
  }));
919
941
  }
920
942
 
@@ -922,11 +944,38 @@ function uniqueSeedCookies(cookies: MintedJar['cookies']): MintedJar['cookies']
922
944
  if (cookies.length === 0) return undefined;
923
945
  const byScope = new Map<string, MintedJar['cookies'][number]>();
924
946
  for (const c of cookies) {
925
- byScope.set(`${c.domain}\t${c.path ?? '/'}\t${c.name}`, c);
947
+ byScope.set(cookieScopeKey({ ...c, path: c.path ?? '/' }), c);
926
948
  }
927
949
  return [...byScope.values()];
928
950
  }
929
951
 
952
+ /** Content identity only: no cookie names or values imply session semantics. */
953
+ function credentialStateHash(credentials: CredentialStore): string {
954
+ const canonical = (value: unknown): unknown => {
955
+ if (Array.isArray(value)) return value.map(canonical);
956
+ if (value && typeof value === 'object')
957
+ return Object.fromEntries(
958
+ Object.entries(value)
959
+ .sort(([a], [b]) => a.localeCompare(b))
960
+ .map(([key, entry]) => [key, canonical(entry)]),
961
+ );
962
+ return value;
963
+ };
964
+ return createHash('sha256')
965
+ .update(
966
+ JSON.stringify(
967
+ canonical({
968
+ cookies: credentials.cookies,
969
+ values: credentials.values,
970
+ storage: credentials.storage ?? [],
971
+ }),
972
+ ),
973
+ )
974
+ .digest('hex');
975
+ }
976
+
977
+ const cdpCredentialStates = new WeakMap<CdpBrowserFetch, string>();
978
+
930
979
  /**
931
980
  * fetch-bootstrap rung — the API anti-bot path. Mint a validated session jar via
932
981
  * cdp-browser (real Chrome, used ONLY to bootstrap), CLOSE the browser, then
@@ -946,9 +995,10 @@ async function runFetchBootstrap(
946
995
  onResponse?: (observation: ResponseObservation) => void,
947
996
  onPreparedRequest?: (observation: PreparedRequestObservation) => void,
948
997
  signal?: AbortSignal,
998
+ cdpPool?: Map<string, CdpBrowserFetch>,
949
999
  ): Promise<ToolResult> {
950
1000
  const credentials = credentialOverride ??
951
- (await loadCredentialStore(tool.site)) ?? {
1001
+ (await loadCredentialStore(tool.site, signal)) ?? {
952
1002
  site: tool.site,
953
1003
  cookies: [],
954
1004
  values: {},
@@ -978,7 +1028,15 @@ async function runFetchBootstrap(
978
1028
  );
979
1029
 
980
1030
  for (let attempt = 0; attempt < 2; attempt++) {
981
- const jar = await getOrMintCdpJar(baseUrl, bootstrapUrl, cacheDir, recordingDir, attempt > 0);
1031
+ const jar = await getOrMintCdpJar(
1032
+ baseUrl,
1033
+ bootstrapUrl,
1034
+ cacheDir,
1035
+ recordingDir,
1036
+ attempt > 0,
1037
+ cdpPool,
1038
+ signal,
1039
+ );
982
1040
  if (signal?.aborted) throw abortSignalError(signal);
983
1041
  if (!jar) {
984
1042
  // Couldn't even launch the bootstrap browser → let the ladder escalate.
@@ -1076,9 +1134,10 @@ async function runCdpReplay(
1076
1134
  signal?: AbortSignal,
1077
1135
  onResponse?: (observation: ResponseObservation) => void,
1078
1136
  onPreparedRequest?: (observation: PreparedRequestObservation) => void,
1137
+ authBrowser?: CdpBrowserFetch,
1079
1138
  ): Promise<ToolResult> {
1080
1139
  const credentials = credentialOverride ??
1081
- (await loadCredentialStore(tool.site)) ?? {
1140
+ (await loadCredentialStore(tool.site, signal)) ?? {
1082
1141
  site: tool.site,
1083
1142
  cookies: [],
1084
1143
  values: {},
@@ -1101,9 +1160,60 @@ async function runCdpReplay(
1101
1160
  : undefined;
1102
1161
 
1103
1162
  const recordingDir = pathResolve(tool.dir, '..');
1104
- const cacheDir = compileBackendStateDir(tool.dir, 'cdp-replay');
1105
- const poolKey = cdpReplayPoolKey(tool.site, tool.workflow.toolName, bootstrapUrl ?? baseUrl);
1106
- const pooled = cdpPool?.get(poolKey);
1163
+ const cacheDir = pathResolve(
1164
+ compileBackendStateDir(tool.dir, 'cdp-replay'),
1165
+ createHash('sha256')
1166
+ .update(bootstrapUrl ?? baseUrl)
1167
+ .digest('hex'),
1168
+ );
1169
+ const pinned =
1170
+ authBrowser && cdpPool
1171
+ ? [...cdpPool].find(([, browser]) => browser === authBrowser)?.[0]
1172
+ : undefined;
1173
+ if (authBrowser && pinned === undefined) {
1174
+ return {
1175
+ ok: false,
1176
+ error: 'AUTH_EXPIRED',
1177
+ message: 'The browser for this sign-in has closed.',
1178
+ };
1179
+ }
1180
+ const requestedKey = cdpReplayPoolKey(tool.site, tool.workflow.toolName, bootstrapUrl ?? baseUrl);
1181
+ if (
1182
+ pinned &&
1183
+ pinned !== requestedKey &&
1184
+ tool.workflow.bootstrap?.captures?.some((capture) => capture.source !== 'cookie')
1185
+ ) {
1186
+ return {
1187
+ ok: false,
1188
+ error: 'STATE_MISSING',
1189
+ message:
1190
+ 'This sign-in action needs captures from a different bootstrap page. Use standalone sign-in.',
1191
+ };
1192
+ }
1193
+ const poolKey = pinned ?? requestedKey;
1194
+ if (tool.workflow.toolKind !== 'authenticate') await prepareTeachBrowserContext(cdpPool, poolKey);
1195
+ if (signal?.aborted) throw abortSignalError(signal);
1196
+ const credentialHash = credentialStateHash(credentials);
1197
+ let pooled = cdpPool?.get(poolKey);
1198
+ // A compiler may discover the live login before it has written a workflow.
1199
+ // Adopt that same run-owned browser for its first compiled action.
1200
+ if (!pooled && tool.workflow.toolKind === 'authenticate') {
1201
+ pooled = cdpPool?.get(AUTH_DISCOVERY_POOL_KEY);
1202
+ if (pooled) {
1203
+ cdpPool?.delete(AUTH_DISCOVERY_POOL_KEY);
1204
+ cdpPool?.set(poolKey, pooled);
1205
+ }
1206
+ }
1207
+ if (
1208
+ pooled &&
1209
+ tool.workflow.toolKind !== 'authenticate' &&
1210
+ cdpCredentialStates.has(pooled) &&
1211
+ cdpCredentialStates.get(pooled) !== credentialHash
1212
+ ) {
1213
+ cdpPool?.delete(poolKey);
1214
+ await pooled.close();
1215
+ pooled = undefined;
1216
+ }
1107
1217
  const ownsSession = !pooled;
1108
1218
 
1109
1219
  let cf: CdpBrowserFetch;
@@ -1111,7 +1221,7 @@ async function runCdpReplay(
1111
1221
  log('cdp-replay: reusing pooled Chrome session');
1112
1222
  cf = pooled;
1113
1223
  } else {
1114
- const seeds: MintedJar['cookies'] = [];
1224
+ let seeds = credentialSeedCookies(credentials);
1115
1225
  // Cached replay jars are recording-derived and may be stale, so authenticate
1116
1226
  // workflows do not inherit them implicitly. Current credential-store cookies
1117
1227
  // are different: they represent durable device/session state and remain
@@ -1123,13 +1233,26 @@ async function runCdpReplay(
1123
1233
  if (cached && rec && rec.mtimeMs > cached.bootstrapEpoch) cached = null;
1124
1234
  if (!cached && seedJarFromRecording(cacheDir, rec, bootstrapUrl))
1125
1235
  cached = loadJar(cacheDir);
1126
- if (cached?.cookies.length) seeds.push(...cached.cookies);
1236
+ const emptyCredentials =
1237
+ !credentials.cookies.length &&
1238
+ !Object.keys(credentials.values).length &&
1239
+ !credentials.storage?.length;
1240
+ if (
1241
+ cached &&
1242
+ (cached.credentialStateHash === credentialHash ||
1243
+ (cached.credentialStateHash === undefined && emptyCredentials))
1244
+ ) {
1245
+ // A retained full snapshot is authoritative, including its removals.
1246
+ // A credential refresh invalidates it rather than mixing sessions.
1247
+ seeds = cached.cookies;
1248
+ }
1127
1249
  } catch {
1128
1250
  // best-effort
1129
1251
  }
1130
1252
  }
1131
- seeds.push(...credentialSeedCookies(credentials));
1132
1253
  const seedCookies = uniqueSeedCookies(seeds);
1254
+ recordBrowserCookieState({ ...credentials, cookies: seedCookies ?? [] }, 'cdp-before-seed');
1255
+ if (signal?.aborted) throw abortSignalError(signal);
1133
1256
  cf = (cdpBrowserFetchFactoryForTest ?? createCdpBrowserFetch)({
1134
1257
  baseUrl,
1135
1258
  bootstrapUrl,
@@ -1146,30 +1269,39 @@ async function runCdpReplay(
1146
1269
  // declares it — never a blanket default. See AuthConfig.crossOriginCookieReinjection.
1147
1270
  reinjectCrossOriginCookies: tool.workflow.authConfig?.crossOriginCookieReinjection ?? false,
1148
1271
  });
1272
+ cdpCredentialStates.set(cf, credentialHash);
1149
1273
  // Register immediately so caller cancellation can close a browser that is
1150
1274
  // still launching or minting its first jar.
1151
1275
  if (cdpPool) cdpPool.set(poolKey, cf);
1152
1276
  }
1153
1277
 
1278
+ // Existing callers keep their normal timing. Managed auth owns this browser
1279
+ // and closes it when the recovery deadline or client cancellation fires.
1280
+ const authSignal = credentials.authCommitRevision === undefined ? undefined : signal;
1154
1281
  try {
1155
- const jar = await cf.mintJar();
1282
+ const jar = await withAbortSignal(() => cf.mintJar(), authSignal);
1156
1283
  if (signal?.aborted) throw abortSignalError(signal);
1284
+ const observedCookies = jar.cookies.map((c) => ({
1285
+ name: c.name,
1286
+ value: c.value,
1287
+ domain: c.domain,
1288
+ path: c.path,
1289
+ expires: c.expires,
1290
+ httpOnly: c.httpOnly,
1291
+ secure: c.secure,
1292
+ sameSite: c.sameSite,
1293
+ hostOnly: c.hostOnly ?? !c.domain.startsWith('.'),
1294
+ }));
1295
+ recordBrowserCookieState(
1296
+ { ...credentials, cookies: observedCookies },
1297
+ 'cdp-after-bootstrap',
1298
+ jar.seedCookieDiagnostics,
1299
+ );
1300
+ const replayCookies = new RuntimeCookieJar(credentials.cookies);
1301
+ replayCookies.restoreBrowserSnapshot(observedCookies);
1157
1302
  const bootstrappedCredentials: CredentialStore = {
1158
1303
  ...credentials,
1159
- cookies: [
1160
- ...credentials.cookies,
1161
- ...jar.cookies.map((c) => ({
1162
- name: c.name,
1163
- value: c.value,
1164
- domain: c.domain,
1165
- path: c.path,
1166
- expires: c.expires,
1167
- httpOnly: c.httpOnly,
1168
- secure: c.secure,
1169
- sameSite: c.sameSite,
1170
- hostOnly: !c.domain.startsWith('.'),
1171
- })),
1172
- ],
1304
+ cookies: replayCookies.toJSON(),
1173
1305
  };
1174
1306
  const captureResult = jarBootstrapCaptureState(
1175
1307
  tool.workflow.bootstrap,
@@ -1182,30 +1314,117 @@ async function runCdpReplay(
1182
1314
  return captureResult.result;
1183
1315
  }
1184
1316
 
1317
+ const navigate = cf.navigate?.bind(cf);
1318
+ const snapshotCookies = cf.snapshotCookies?.bind(cf);
1319
+ const fetchImpl: typeof fetch = authSignal
1320
+ ? Object.assign(
1321
+ (input: Parameters<typeof fetch>[0], init?: RequestInit) =>
1322
+ withAbortSignal(() => cf.fetchImpl(input, init), authSignal),
1323
+ { preconnect: cf.fetchImpl.preconnect },
1324
+ )
1325
+ : cf.fetchImpl;
1326
+ let runtimeCookies: CredentialStore['cookies'] | undefined;
1185
1327
  let result = await tool.toolFn(paramsWithDefaults, {
1186
1328
  credentials: bootstrappedCredentials,
1187
1329
  initialState: { ...callerState, ...captureResult.state },
1188
- fetchImpl: cf.fetchImpl,
1330
+ fetchImpl,
1189
1331
  browser:
1190
- cf.navigate && cf.snapshotCookies
1332
+ navigate && snapshotCookies
1191
1333
  ? {
1192
- navigate: cf.navigate.bind(cf),
1193
- snapshotCookies: cf.snapshotCookies.bind(cf),
1334
+ navigate: (...args: Parameters<NonNullable<CdpBrowserFetch['navigate']>>) =>
1335
+ withAbortSignal(() => navigate(...args), authSignal),
1336
+ snapshotCookies: () => withAbortSignal(snapshotCookies, authSignal),
1337
+ initialCookies: observedCookies,
1194
1338
  }
1195
1339
  : undefined,
1196
1340
  signal,
1341
+ onCookieState: (cookies: CredentialStore['cookies']) => {
1342
+ runtimeCookies = cookies;
1343
+ },
1197
1344
  ...(onResponse ? { onResponse } : {}),
1198
1345
  ...(onPreparedRequest ? { onPreparedRequest } : {}),
1199
1346
  });
1200
1347
 
1201
1348
  if (signal?.aborted) throw abortSignalError(signal);
1349
+ if (!result.ok && cf.snapshotExistingCookies) {
1350
+ try {
1351
+ const snapshotExistingCookies = cf.snapshotExistingCookies.bind(cf);
1352
+ const snapshotSignal = AbortSignal.timeout(3_000);
1353
+ const cookies = await withAbortSignal(
1354
+ snapshotExistingCookies,
1355
+ signal ? AbortSignal.any([signal, snapshotSignal]) : snapshotSignal,
1356
+ );
1357
+ if (cookies) {
1358
+ recordBrowserCookieState(
1359
+ { ...credentials, cookies },
1360
+ 'cdp-after-request',
1361
+ jar.seedCookieDiagnostics,
1362
+ );
1363
+ }
1364
+ } catch {
1365
+ if (signal?.aborted) throw abortSignalError(signal);
1366
+ // A failed diagnostic must preserve the original consumer failure.
1367
+ }
1368
+ }
1369
+ if (jarLikelyStale(result)) clearJar(cacheDir);
1202
1370
  if (result.ok) {
1371
+ if (authSignal) return result;
1203
1372
  if (cdpPool && ownsSession) cdpPool.set(poolKey, cf);
1204
1373
  try {
1205
- const postJar = await cf.mintJar();
1206
- saveJar(cacheDir, postJar);
1207
- } catch {
1208
- // best-effort
1374
+ const postJar = await withAbortSignal(() => cf.mintJar(), authSignal);
1375
+ recordBrowserCookieState(
1376
+ { ...credentials, cookies: postJar.cookies },
1377
+ 'cdp-after-request',
1378
+ postJar.seedCookieDiagnostics,
1379
+ );
1380
+ let postCredentialHash = credentialHash;
1381
+ // Only a host-loaded durable snapshot opts into writeback. Synthetic
1382
+ // overrides and auth actions retain their existing persistence contract.
1383
+ // A partial seed must never turn restoration failures into deletions.
1384
+ if (
1385
+ tool.workflow.toolKind !== 'authenticate' &&
1386
+ credentials.durableRevision &&
1387
+ !postJar.seedCookieDiagnostics?.rejected &&
1388
+ !postJar.seedCookieDiagnostics?.failed
1389
+ ) {
1390
+ const finalCookies = new RuntimeCookieJar(runtimeCookies ?? credentials.cookies);
1391
+ finalCookies.restoreBrowserSnapshot(
1392
+ postJar.cookies.map((cookie) => ({
1393
+ ...cookie,
1394
+ hostOnly: cookie.hostOnly ?? !cookie.domain.startsWith('.'),
1395
+ })),
1396
+ );
1397
+ // Insertion counters belong to this runtime jar. Do not rotate the
1398
+ // durable version merely because another process reconstructed it.
1399
+ const previousScopes = new Map(
1400
+ credentials.cookies.map((cookie) => [cookieScopeKey(cookie), cookie]),
1401
+ );
1402
+ const cookies = finalCookies.toJSON().map((entry) => {
1403
+ const { creationIndex: _creationIndex, ...cookie } = entry;
1404
+ const previous = previousScopes.get(cookieScopeKey(cookie));
1405
+ return previous?.creationIndex === undefined
1406
+ ? cookie
1407
+ : { ...cookie, creationIndex: previous.creationIndex };
1408
+ });
1409
+ const update = await compareAndSwapSiteCookies(
1410
+ tool.site,
1411
+ credentials.durableRevision,
1412
+ cookies,
1413
+ { signal },
1414
+ );
1415
+ if (update.status !== 'stale') {
1416
+ postCredentialHash = credentialStateHash({ ...credentials, cookies });
1417
+ cdpCredentialStates.set(cf, postCredentialHash);
1418
+ }
1419
+ log(`cdp-replay: durable browser cookies ${update.status}`);
1420
+ }
1421
+ saveJar(cacheDir, { ...postJar, credentialStateHash: postCredentialHash });
1422
+ } catch (err) {
1423
+ if (signal?.aborted) throw abortSignalError(signal);
1424
+ clearJar(cacheDir);
1425
+ log(
1426
+ `cdp-replay post-run cookie snapshot unavailable: ${err instanceof Error ? err.message : String(err)}`,
1427
+ );
1209
1428
  } finally {
1210
1429
  if (!cdpPool && ownsSession) await cf.close();
1211
1430
  }
@@ -1213,11 +1432,16 @@ async function runCdpReplay(
1213
1432
  // A paused auth action is healthy progress. Retain the browser so the next
1214
1433
  // declared action sees the same cookies, page, and browser-owned state.
1215
1434
  if (ownsSession) cdpPool.set(poolKey, cf);
1216
- try {
1217
- const postJar = await cf.mintJar();
1218
- saveJar(cacheDir, postJar);
1219
- } catch {
1220
- // best-effort
1435
+ if (credentials.authCommitRevision === undefined) {
1436
+ try {
1437
+ const postJar = await cf.mintJar();
1438
+ saveJar(cacheDir, { ...postJar, credentialStateHash: credentialHash });
1439
+ } catch (err) {
1440
+ clearJar(cacheDir);
1441
+ log(
1442
+ `cdp-replay paused cookie snapshot unavailable: ${err instanceof Error ? err.message : String(err)}`,
1443
+ );
1444
+ }
1221
1445
  }
1222
1446
  // Deliberately do NOT close cf — the pool retains it for the completion phase.
1223
1447
  } else if (cdpPool) {
@@ -1275,6 +1499,7 @@ async function runCdpReplay(
1275
1499
  }
1276
1500
  }
1277
1501
 
1502
+ if (signal?.aborted) throw abortSignalError(signal);
1278
1503
  return result;
1279
1504
  } catch (err) {
1280
1505
  // Session is dead — evict from pool so the next call creates a fresh one.
@@ -1549,8 +1774,9 @@ async function ensureStealthFetch(
1549
1774
  tool: ResolvedTool,
1550
1775
  cache: Map<string, StealthFetch>,
1551
1776
  params: Record<string, string | number | boolean>,
1777
+ signal?: AbortSignal,
1552
1778
  ): Promise<StealthFetch> {
1553
- const credentials = (await loadCredentialStore(tool.site)) ?? {
1779
+ const credentials = (await loadCredentialStore(tool.site, signal)) ?? {
1554
1780
  site: tool.site,
1555
1781
  cookies: [],
1556
1782
  values: {},
@@ -1910,6 +2136,7 @@ export function resolveWorkflowTool(
1910
2136
  initialState?: Record<string, unknown>;
1911
2137
  credentials?: CredentialStore;
1912
2138
  signal?: AbortSignal;
2139
+ onCookieState?: (cookies: CredentialStore['cookies']) => void;
1913
2140
  onResponse?: (observation: ResponseObservation) => void;
1914
2141
  onPreparedRequest?: (observation: PreparedRequestObservation) => void;
1915
2142
  onResponseEvidence?: (evidence: ResponseEvidence) => void;
@@ -1924,6 +2151,7 @@ export function resolveWorkflowTool(
1924
2151
  browser: o?.browser,
1925
2152
  initialState: o?.initialState,
1926
2153
  signal: o?.signal,
2154
+ onCookieState: o?.onCookieState,
1927
2155
  onResponse: o?.onResponse,
1928
2156
  onPreparedRequest: o?.onPreparedRequest,
1929
2157
  onResponseEvidence: o?.onResponseEvidence,
@@ -13,6 +13,8 @@
13
13
  import { existsSync, readFileSync } from 'node:fs';
14
14
  import { join as pathJoin } from 'node:path';
15
15
  import { z } from 'zod';
16
+ import { AuthCaptureSchema, AuthToolPlanSchema } from './auth-plan.ts';
17
+ export { AuthToolPlanSchema, type AuthToolPlan } from './auth-plan.ts';
16
18
  import { compactUrlForLlm } from './llm-url.ts';
17
19
  import { localSiteDir } from './paths.ts';
18
20
  import { detectPageMintedHeaders } from './redact.ts';
@@ -49,16 +51,6 @@ export const SharedModuleSpecSchema = z.object({
49
51
  });
50
52
  export type SharedModuleSpec = z.infer<typeof SharedModuleSpecSchema>;
51
53
 
52
- const AuthCaptureSchema = z.object({
53
- name: z.string().min(1),
54
- /** Capture source: json | response_header | cookie | text_regex. */
55
- source: z.string().min(1),
56
- /** Path / header name / cookie name / regex that locates the value. */
57
- locator: z.string().min(1),
58
- /** Where the captured value is injected downstream, e.g. "header:Authorization". */
59
- usedAs: z.string().default(''),
60
- });
61
-
62
54
  const AuthRecipeSchema = z
63
55
  .object({
64
56
  required: z.boolean().default(false),
@@ -194,18 +186,6 @@ const PerToolPlanSchema = z.object({
194
186
  });
195
187
  type PerToolPlan = z.infer<typeof PerToolPlanSchema>;
196
188
 
197
- export const AuthToolPlanSchema = z
198
- .object({
199
- toolName: z.string(),
200
- credentialRequestSeqs: z.array(z.number().int().nonnegative()).default([]),
201
- authRequestSeqs: z.array(z.number().int().nonnegative()).default([]),
202
- credentialNames: z.array(z.string()).default([]),
203
- captures: z.array(AuthCaptureSchema).default([]),
204
- notes: z.string().default(''),
205
- })
206
- .optional();
207
- export type AuthToolPlan = z.infer<typeof AuthToolPlanSchema>;
208
-
209
189
  export const BuildPlanSchema = z
210
190
  .object({
211
191
  sharedModules: z.array(SharedModuleSpecSchema).default([]),