imprint-mcp 0.7.1 → 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.
package/README.md CHANGED
@@ -34,6 +34,8 @@ renewed cookies for later calls without overwriting newer saved authentication.
34
34
  Same-name host-only and domain cookies keep their distinct scopes across replay
35
35
  and browser restoration.
36
36
  That continuity still needs verification against the requested data operation.
37
+ Local stdio MCP servers can opt in to [session renewal](docs/getting-started.md#renew-authenticated-mcp-sessions)
38
+ for read-only tools, with client approval before sending a new sign-in challenge.
37
39
 
38
40
  ## Quick Start
39
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imprint-mcp",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -217,6 +217,12 @@ semantics.
217
217
  Verify the session-reuse path and each login/MFA path actually offered by the
218
218
  observed flow separately. Do not restart login just to repeat an already
219
219
  passing reuse check, and do not claim an untested branch works.
220
+ - For a pause during managed MCP recovery, use `resume: "automatic"` only if the next action can begin
221
+ without waiting for input through the auth tool. A bounded poll for an
222
+ already sent approval request can start while the user approves it with the
223
+ provider. Keep each new approval request in its own action and classify
224
+ its dispatch effect. The MCP host asks for consent before unsafe actions.
225
+ After handing off to the auth tool, callers follow one declared action per call.
220
226
  - `persist` names durable credential interfaces that data tools consume later as
221
227
  `${credential.NAME}`. Cookies persist automatically. These interface names do
222
228
  not constrain your internal capture names. When they differ, set
@@ -350,6 +350,8 @@ export async function runWithLadder(
350
350
  /** Per-site CDP browser pool so cdp-replay reuses a live Chrome across
351
351
  * calls (~2-5s) instead of launching a fresh one each time (~33s). */
352
352
  cdpPool?: Map<string, CdpBrowserFetch>;
353
+ /** A managed continuation must use the browser that produced it. */
354
+ authBrowser?: CdpBrowserFetch;
353
355
  /** Per-session memo of the backend that last served each tool. Once set, the
354
356
  * next call starts at that backend instead of re-walking the doomed early
355
357
  * rungs — the runtime analog of the compile path's `compileWinningBackend`.
@@ -492,6 +494,7 @@ export async function runWithLadder(
492
494
  options?.signal,
493
495
  options?.onResponse ? onResponse : undefined,
494
496
  options?.onPreparedRequest ? onPreparedRequest : undefined,
497
+ options?.authBrowser,
495
498
  );
496
499
  break;
497
500
  case 'stealth-fetch': {
@@ -1131,6 +1134,7 @@ async function runCdpReplay(
1131
1134
  signal?: AbortSignal,
1132
1135
  onResponse?: (observation: ResponseObservation) => void,
1133
1136
  onPreparedRequest?: (observation: PreparedRequestObservation) => void,
1137
+ authBrowser?: CdpBrowserFetch,
1134
1138
  ): Promise<ToolResult> {
1135
1139
  const credentials = credentialOverride ??
1136
1140
  (await loadCredentialStore(tool.site, signal)) ?? {
@@ -1162,7 +1166,31 @@ async function runCdpReplay(
1162
1166
  .update(bootstrapUrl ?? baseUrl)
1163
1167
  .digest('hex'),
1164
1168
  );
1165
- const poolKey = cdpReplayPoolKey(tool.site, tool.workflow.toolName, bootstrapUrl ?? baseUrl);
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;
1166
1194
  if (tool.workflow.toolKind !== 'authenticate') await prepareTeachBrowserContext(cdpPool, poolKey);
1167
1195
  if (signal?.aborted) throw abortSignalError(signal);
1168
1196
  const credentialHash = credentialStateHash(credentials);
@@ -1247,8 +1275,11 @@ async function runCdpReplay(
1247
1275
  if (cdpPool) cdpPool.set(poolKey, cf);
1248
1276
  }
1249
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;
1250
1281
  try {
1251
- const jar = await cf.mintJar();
1282
+ const jar = await withAbortSignal(() => cf.mintJar(), authSignal);
1252
1283
  if (signal?.aborted) throw abortSignalError(signal);
1253
1284
  const observedCookies = jar.cookies.map((c) => ({
1254
1285
  name: c.name,
@@ -1283,16 +1314,26 @@ async function runCdpReplay(
1283
1314
  return captureResult.result;
1284
1315
  }
1285
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;
1286
1326
  let runtimeCookies: CredentialStore['cookies'] | undefined;
1287
1327
  let result = await tool.toolFn(paramsWithDefaults, {
1288
1328
  credentials: bootstrappedCredentials,
1289
1329
  initialState: { ...callerState, ...captureResult.state },
1290
- fetchImpl: cf.fetchImpl,
1330
+ fetchImpl,
1291
1331
  browser:
1292
- cf.navigate && cf.snapshotCookies
1332
+ navigate && snapshotCookies
1293
1333
  ? {
1294
- navigate: cf.navigate.bind(cf),
1295
- snapshotCookies: cf.snapshotCookies.bind(cf),
1334
+ navigate: (...args: Parameters<NonNullable<CdpBrowserFetch['navigate']>>) =>
1335
+ withAbortSignal(() => navigate(...args), authSignal),
1336
+ snapshotCookies: () => withAbortSignal(snapshotCookies, authSignal),
1296
1337
  initialCookies: observedCookies,
1297
1338
  }
1298
1339
  : undefined,
@@ -1327,9 +1368,10 @@ async function runCdpReplay(
1327
1368
  }
1328
1369
  if (jarLikelyStale(result)) clearJar(cacheDir);
1329
1370
  if (result.ok) {
1371
+ if (authSignal) return result;
1330
1372
  if (cdpPool && ownsSession) cdpPool.set(poolKey, cf);
1331
1373
  try {
1332
- const postJar = await cf.mintJar();
1374
+ const postJar = await withAbortSignal(() => cf.mintJar(), authSignal);
1333
1375
  recordBrowserCookieState(
1334
1376
  { ...credentials, cookies: postJar.cookies },
1335
1377
  'cdp-after-request',
@@ -1390,14 +1432,16 @@ async function runCdpReplay(
1390
1432
  // A paused auth action is healthy progress. Retain the browser so the next
1391
1433
  // declared action sees the same cookies, page, and browser-owned state.
1392
1434
  if (ownsSession) cdpPool.set(poolKey, cf);
1393
- try {
1394
- const postJar = await cf.mintJar();
1395
- saveJar(cacheDir, { ...postJar, credentialStateHash: credentialHash });
1396
- } catch (err) {
1397
- clearJar(cacheDir);
1398
- log(
1399
- `cdp-replay paused cookie snapshot unavailable: ${err instanceof Error ? err.message : String(err)}`,
1400
- );
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
+ }
1401
1445
  }
1402
1446
  // Deliberately do NOT close cf — the pool retains it for the completion phase.
1403
1447
  } else if (cdpPool) {
@@ -45,6 +45,7 @@ import {
45
45
  } from './chromium.ts';
46
46
  import { recordBrowserCredentialObservation } from './credential-observation.ts';
47
47
  import { createLog } from './log.ts';
48
+ import type { BrowserRequestInit } from './types.ts';
48
49
  import type { NavigationNetworkResponseMatcher } from './types.ts';
49
50
 
50
51
  const log = createLog('cdp-browser');
@@ -1106,6 +1107,7 @@ export function buildInPageFetchExpr(
1106
1107
  body: string | null,
1107
1108
  reqTimeoutMs: number,
1108
1109
  sameOriginIframeSrc?: string,
1110
+ allowAmbiguousRetry = true,
1109
1111
  ): string {
1110
1112
  const iframeNativeFetchSetup = `
1111
1113
  ifr = document.createElement('iframe');
@@ -1157,10 +1159,12 @@ export function buildInPageFetchExpr(
1157
1159
  if (ifr) try { ifr.remove(); } catch (_) {}
1158
1160
  return { ok: false, error: String(e) };
1159
1161
  }`;
1162
+ const primarySetup =
1163
+ !allowAmbiguousRetry && !sameOriginIframeSrc ? iframeNativeFetchSetup : iframeSetup;
1160
1164
  return `(async () => {
1161
- const primary = await (async () => {${fetchOnce(iframeSetup)}
1165
+ const primary = await (async () => {${fetchOnce(primarySetup)}
1162
1166
  })();
1163
- if (${sameOriginIframeSrc ? 'false' : 'true'} && !primary.ok) {
1167
+ if (${allowAmbiguousRetry && !sameOriginIframeSrc} && !primary.ok) {
1164
1168
  const fallback = await (async () => {${fetchOnce(iframeNativeFetchSetup)}
1165
1169
  })();
1166
1170
  return JSON.stringify(fallback);
@@ -1258,10 +1262,12 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1258
1262
  const activeNetworkBodyReads = new Set<Promise<void>>();
1259
1263
  let finishActiveNetworkResponse: ((requestId: string) => void) | undefined;
1260
1264
  let navigationInProgress = false;
1265
+ let lifecycle = 0;
1261
1266
  let discoveryRequested = false;
1262
1267
  let discovery: AuthBrowserDiscovery | undefined;
1263
1268
 
1264
1269
  async function close(): Promise<void> {
1270
+ lifecycle++;
1265
1271
  discovery?.close();
1266
1272
  discovery = undefined;
1267
1273
  const c = client;
@@ -1300,6 +1306,10 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1300
1306
 
1301
1307
  async function ensure(): Promise<CdpClient> {
1302
1308
  if (client && bootstrapped) return client;
1309
+ const startedIn = lifecycle;
1310
+ const checkOpen = () => {
1311
+ if (lifecycle !== startedIn) throw new Error('Browser closed during CDP startup.');
1312
+ };
1303
1313
  try {
1304
1314
  const headed = opts.headed ?? false;
1305
1315
  if (!chrome) {
@@ -1309,24 +1319,52 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1309
1319
  // Page.navigate AFTER the override rather than passing the URL at launch.
1310
1320
  // headless renders offscreen (no display); headed needs one (Xvfb on Linux).
1311
1321
  const launch = chromiumLauncherForTest ?? launchChromium;
1312
- chrome = await withTimeout(
1322
+ const launched = await withTimeout<LaunchedChromium>(
1313
1323
  launch({
1314
1324
  headless: !headed,
1315
1325
  extraArgs: gpuLaunchArgs(),
1316
1326
  ...(headed ? { display: opts.display } : {}),
1327
+ }).then(async (launched) => {
1328
+ // Closing a late handle rejects readiness even if no caller adopted it.
1329
+ void launched.ready.catch(() => {});
1330
+ if (lifecycle === startedIn) return launched;
1331
+ await launched.close();
1332
+ throw new Error('Browser closed during Chromium launch.');
1317
1333
  }),
1318
1334
  'Chromium launch',
1319
1335
  cdpCommandTimeoutMs,
1320
1336
  );
1321
- await withTimeout(chrome.ready, 'Chromium CDP readiness', cdpCommandTimeoutMs);
1337
+ // Close can run after the launch promise resolves and before this await
1338
+ // resumes. Keep the handle local until ownership has been checked.
1339
+ if (lifecycle !== startedIn) {
1340
+ await launched.close();
1341
+ checkOpen();
1342
+ }
1343
+ chrome = launched;
1344
+ await withTimeout(launched.ready, 'Chromium CDP readiness', cdpCommandTimeoutMs);
1345
+ checkOpen();
1322
1346
  // Register the Chrome PID for exit-time cleanup (leak prevention for
1323
1347
  // probe subprocesses that exit before the idle-close timer fires).
1324
1348
  if (chrome.process.pid !== undefined) __registerChromePid(chrome.process.pid);
1325
1349
  }
1326
1350
  if (!client) {
1327
1351
  const connectCdp = cdpConnectorForTest ?? ((port: number) => CDP({ port }));
1328
- client = await withTimeout(connectCdp(chrome.port), 'CDP connect', cdpCommandTimeoutMs);
1352
+ const connected = await withTimeout<CdpClient>(
1353
+ connectCdp(chrome.port).then(async (connected) => {
1354
+ if (lifecycle === startedIn) return connected;
1355
+ await connected.close();
1356
+ throw new Error('Browser closed during CDP connect.');
1357
+ }),
1358
+ 'CDP connect',
1359
+ cdpCommandTimeoutMs,
1360
+ );
1361
+ if (lifecycle !== startedIn) {
1362
+ await connected.close();
1363
+ checkOpen();
1364
+ }
1365
+ client = connected;
1329
1366
  }
1367
+ checkOpen();
1330
1368
  const { Runtime, Network, Input, Page } = client;
1331
1369
  await withTimeout(Runtime.enable(), 'CDP Runtime.enable', cdpCommandTimeoutMs);
1332
1370
  await withTimeout(Network.enable(), 'CDP Network.enable', cdpCommandTimeoutMs);
@@ -1685,10 +1723,11 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1685
1723
  i++;
1686
1724
  }
1687
1725
  log(`_abck status after interaction: ~${status}~`);
1726
+ checkOpen();
1688
1727
  bootstrapped = true;
1689
1728
  return client;
1690
1729
  } catch (err) {
1691
- await close();
1730
+ if (lifecycle === startedIn) await close();
1692
1731
  throw err;
1693
1732
  }
1694
1733
  }
@@ -1713,7 +1752,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1713
1752
 
1714
1753
  const fetchImpl: typeof fetch = (async (
1715
1754
  input: string | URL | Request,
1716
- init?: RequestInit,
1755
+ init?: BrowserRequestInit,
1717
1756
  ): Promise<Response> => {
1718
1757
  const c = await ensure();
1719
1758
  const url =
@@ -1766,6 +1805,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1766
1805
  body,
1767
1806
  reqTimeoutMs,
1768
1807
  sameOriginIframeSrc,
1808
+ init?.allowAmbiguousRetry,
1769
1809
  );
1770
1810
  const { result } = await withTimeout(
1771
1811
  c.Runtime.evaluate({
@@ -1908,6 +1948,9 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
1908
1948
  headers: new Headers(payload.headers),
1909
1949
  });
1910
1950
  }
1951
+ if (init?.allowAmbiguousRetry === false) {
1952
+ throw new Error('The browser could not confirm the request outcome. No retry was sent.');
1953
+ }
1911
1954
  // The in-page sibling fetch failed — almost always a genuine CORS block
1912
1955
  // (the page can't read the cross-origin response). Fall back to plain
1913
1956
  // fetch so the request still goes out (it just won't carry the live
@@ -95,6 +95,7 @@ export async function withCredentialLock<T>(
95
95
  directory: string,
96
96
  signal: AbortSignal | undefined,
97
97
  work: () => Promise<T>,
98
+ pollMs = 20,
98
99
  ): Promise<T> {
99
100
  signal?.throwIfAborted();
100
101
  mkdirSync(directory, { recursive: true, mode: 0o700 });
@@ -123,7 +124,7 @@ export async function withCredentialLock<T>(
123
124
  (other.ticket === owner.ticket && other.nonce < nonce)),
124
125
  );
125
126
  if (!ahead) break;
126
- await abortableDelay(20, signal);
127
+ await abortableDelay(pollMs, signal);
127
128
  }
128
129
  signal?.throwIfAborted();
129
130
  result = await work();
@@ -694,6 +694,7 @@ export interface SiteCredentialView {
694
694
  storage: StorageRecord[];
695
695
  /** Raw durable material identity, before runtime normalization or env input. */
696
696
  revision: string;
697
+ legacy?: true;
697
698
  }
698
699
 
699
700
  function credentialRevision(material: Omit<SiteCredentialView, 'revision'>): string {
@@ -739,6 +740,34 @@ async function withSiteCredentialLock<T>(
739
740
  });
740
741
  }
741
742
 
743
+ /** Serializes a complete sign-in independently of the credential reads and commits it performs. */
744
+ export async function acquireSiteAuthRecoveryLock(
745
+ site: string,
746
+ signal: AbortSignal,
747
+ ): Promise<() => Promise<void>> {
748
+ signal.throwIfAborted();
749
+ const backend = await getCredentialBackend();
750
+ const directory = pathJoin(
751
+ `${credentialLockDirectory(site, backend)}-auth-recovery`,
752
+ createHash('sha256').update(site).digest('hex'),
753
+ );
754
+ return new Promise((resolve, reject) => {
755
+ const held = withCredentialLock(
756
+ directory,
757
+ signal,
758
+ () =>
759
+ new Promise<void>((release) => {
760
+ resolve(() => {
761
+ release();
762
+ return held;
763
+ });
764
+ }),
765
+ 250,
766
+ );
767
+ void held.catch(reject);
768
+ });
769
+ }
770
+
742
771
  /** Loads everything we know about a site, falling back through the backends.
743
772
  * Used at request time by runtime.executeWorkflow. */
744
773
  export async function loadSiteCredentials(
@@ -779,7 +808,7 @@ async function loadSiteCredentialsUnlocked(
779
808
  values: legacy.values,
780
809
  storage: [],
781
810
  });
782
- const result = { ...material, revision: credentialRevision(material) };
811
+ const result = { ...material, revision: credentialRevision(material), legacy: true as const };
783
812
  const observation = credentialObservation(result, 'legacy-json');
784
813
  if (observation) recordCredentialEvent('load', observation);
785
814
  return result;
@@ -881,14 +910,22 @@ export async function commitSiteAuthState(
881
910
  site: string,
882
911
  cookies: CookieRecord[],
883
912
  secrets: Record<string, string>,
884
- options?: { signal?: AbortSignal },
913
+ options?: { signal?: AbortSignal; expectedRevision?: string },
885
914
  ): Promise<void> {
886
915
  const signal = options?.signal;
887
916
  await withSiteCredentialLock(site, signal, async (backend) => {
888
917
  signal?.throwIfAborted();
918
+ let nextSecrets = secrets;
919
+ if (options?.expectedRevision !== undefined) {
920
+ const current = await loadSiteCredentialsUnlocked(site, backend);
921
+ if (current.revision !== options.expectedRevision) {
922
+ throw new Error('the saved session changed while this sign-in was running');
923
+ }
924
+ if (current.legacy) nextSecrets = { ...current.values, ...secrets };
925
+ }
889
926
  const previousCookies = structuredClone(await backend.getCookies(site));
890
927
  const previousSecrets = new Map<string, string | null>();
891
- for (const name of Object.keys(secrets)) {
928
+ for (const name of Object.keys(nextSecrets)) {
892
929
  signal?.throwIfAborted();
893
930
  previousSecrets.set(name, await backend.getSecret(site, name));
894
931
  }
@@ -897,7 +934,7 @@ export async function commitSiteAuthState(
897
934
  signal?.throwIfAborted();
898
935
  await backend.setCookies(site, cookies);
899
936
  signal?.throwIfAborted();
900
- for (const [name, value] of Object.entries(secrets)) {
937
+ for (const [name, value] of Object.entries(nextSecrets)) {
901
938
  await backend.setSecret(site, name, value);
902
939
  signal?.throwIfAborted();
903
940
  }
@@ -19,7 +19,11 @@ export class McpCdpSessions {
19
19
  }
20
20
  }
21
21
 
22
- finishCall(site: string, toolName: string): void {
22
+ finishCall(
23
+ site: string,
24
+ toolName: string,
25
+ onIdle?: (browser: CdpBrowserFetch, closing: Promise<void>) => void,
26
+ ): void {
23
27
  this.beginCall(site, toolName);
24
28
  const prefix = cdpReplayPoolKey(site, toolName, '');
25
29
  for (const [key, session] of this.pool) {
@@ -29,7 +33,8 @@ export class McpCdpSessions {
29
33
  if (this.pool.get(key) !== session) return;
30
34
  this.pool.delete(key);
31
35
  this.winners.delete(`${site}:${toolName}`);
32
- void session.close().catch(() => {});
36
+ const closing = session.close().catch(() => {});
37
+ onIdle?.(session, closing);
33
38
  }, this.idleMs);
34
39
  timer.unref();
35
40
  this.timers.set(key, timer);