@reclaimprotocol/agent 0.1.22 → 0.1.23

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 (48) hide show
  1. package/lib/authoring/backend.d.ts +9 -7
  2. package/lib/authoring/tools.js +98 -1
  3. package/lib/mcp/tools/agent/propose.js +27 -3
  4. package/lib/mcp/tools/agent/publish.js +7 -5
  5. package/lib/mcp/tools/agent/shared.js +17 -8
  6. package/lib/mcp/tools/authenticate/authenticate.d.ts +11 -0
  7. package/lib/mcp/tools/authenticate/authenticate.js +128 -119
  8. package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -3
  9. package/lib/mcp/tools/authenticate/builder-access.js +8 -3
  10. package/lib/mcp/tools/mode/mode.js +32 -1
  11. package/lib/old/client.d.ts +9 -0
  12. package/lib/old/client.js +19 -1
  13. package/lib/old/to-register.d.ts +17 -6
  14. package/lib/old/to-register.js +6 -0
  15. package/lib/old/tools/me-providers.js +3 -1
  16. package/lib/old/tools/publish.js +82 -38
  17. package/lib/proof/page-replay.d.ts +36 -0
  18. package/lib/proof/page-replay.js +76 -0
  19. package/lib/proof/replay.d.ts +28 -0
  20. package/lib/proof/replay.js +39 -18
  21. package/lib/provider/draft.d.ts +3 -1
  22. package/lib/provider/draft.js +39 -7
  23. package/lib/provider/matcher.js +116 -7
  24. package/lib/provider/to-version.d.ts +8 -6
  25. package/lib/provider/to-version.js +15 -3
  26. package/package.json +3 -2
  27. package/skill/SKILL.builder.md +1 -1
  28. package/skill/SKILL.common.md +9 -0
  29. package/skill/SKILL.old.md +1 -1
  30. package/skill/injection-api.md +44 -15
  31. package/skill/old-mode.md +3 -1
  32. package/src/authoring/backend.ts +9 -7
  33. package/src/authoring/tools.ts +119 -1
  34. package/src/mcp/tools/agent/propose.ts +29 -3
  35. package/src/mcp/tools/agent/publish.ts +7 -6
  36. package/src/mcp/tools/agent/shared.ts +19 -8
  37. package/src/mcp/tools/authenticate/authenticate.ts +150 -163
  38. package/src/mcp/tools/authenticate/builder-access.ts +8 -3
  39. package/src/mcp/tools/mode/mode.ts +34 -1
  40. package/src/old/client.ts +30 -1
  41. package/src/old/to-register.ts +25 -6
  42. package/src/old/tools/me-providers.ts +3 -1
  43. package/src/old/tools/publish.ts +86 -40
  44. package/src/proof/page-replay.ts +131 -0
  45. package/src/proof/replay.ts +60 -19
  46. package/src/provider/draft.ts +46 -6
  47. package/src/provider/matcher.ts +145 -5
  48. package/src/provider/to-version.ts +24 -9
@@ -1,6 +1,18 @@
1
1
  import { defaultConfigPath } from "../../../config/store.js";
2
2
  import { oldModeSource } from "../../../consts.js";
3
3
  import { defineTool } from "../../server.js";
4
+ /** Whether `config`'s cached builder session token is present and not yet
5
+ * expired. Mirrors `readSession`'s check, but against the CALLER's
6
+ * `ConfigStore` instance (not always the default `~/.reclaim/config.json`
7
+ * path) so this stays test-isolatable and consistent with the rest of this
8
+ * tool's config reads. */
9
+ function hasValidBuilderSession(config) {
10
+ const session = config.read()?.session;
11
+ if (!session?.token || !session.expiresAt) {
12
+ return false;
13
+ }
14
+ return new Date(session.expiresAt) > new Date();
15
+ }
4
16
  /**
5
17
  * The `set_devtools_mode` / `get_devtools_mode` tools: persist which backend
6
18
  * the MCP server targets (`old` devtools vs the `builder`), and
@@ -54,15 +66,34 @@ export function buildModeTools(config, currentOldMode) {
54
66
  + 'backend). Also reports WHY that mode is in effect (source: '
55
67
  + 'a USE_OLD_DEVTOOLS env override, the persisted config, or '
56
68
  + 'the default) and which mode is persisted for the next '
57
- + 'start. Use set_devtools_mode to change it.',
69
+ + 'start. Use set_devtools_mode to change it. Also reports '
70
+ + 'AUTH status for BOTH auth systems — builderAuthenticated (the '
71
+ + 'Builder OAuth login). In BUILDER mode this is the only login '
72
+ + 'that exists and covers everything, including org listing '
73
+ + '(list_orgs) and remote-browser allocation. In OLD-DEVTOOLS '
74
+ + 'mode it is a SEPARATE, additional login (authenticate_builder) '
75
+ + 'needed only for two Builder-exclusive features: remote-browser '
76
+ + '(Popcorn) allocation and org listing (list_builder_organizations '
77
+ + '— old-devtools has no generated org tool of its own). '
78
+ + 'oldDevtoolsAuthenticated (the OLD-mode reclaim_authenticate) is '
79
+ + 'required separately for create_provider_version_from_capture '
80
+ + 'and get_me_providers, and only exists/matters in old-devtools '
81
+ + 'mode. Both are presence checks, not '
82
+ + 'validity checks — a stale old-devtools token still reports '
83
+ + 'true here; a real publish/list call will surface staleness '
84
+ + 'with a clear error pointing back at reclaim_authenticate.',
58
85
  inputSchema: { type: 'object', properties: {} },
59
86
  }, async () => {
60
87
  const persisted = config.read()?.mode;
88
+ const oldIdentity = config.read()?.old_devtools;
61
89
  return {
62
90
  mode: current,
63
91
  source: oldModeSource(persisted),
64
92
  persistedMode: persisted ?? null,
65
93
  configPath: defaultConfigPath(),
94
+ builderAuthenticated: hasValidBuilderSession(config),
95
+ oldDevtoolsAuthenticated: !!oldIdentity,
96
+ oldDevtoolsIdentityKind: oldIdentity?.kind ?? null,
66
97
  };
67
98
  }),
68
99
  ];
@@ -48,6 +48,15 @@ export interface RegisterProviderBody {
48
48
  * controller's destructure + its `providerConfig` write, both of which
49
49
  * include it alongside `pageTitle`/`userAgent`. */
50
50
  stepsToFollow?: string;
51
+ /** Interception mechanism (`NONE` = standard replay flow, the default;
52
+ * `HAWKEYE`/`MSWJS`/`XHOOK` = JS-context interception; `CDP` = browser-
53
+ * level). Column default is `HAWKEYE` when omitted entirely — unlike
54
+ * `stepsToFollow` etc. above, whether the register endpoint actually
55
+ * PERSISTS this field is UNVERIFIED (no source access to confirm); sent
56
+ * here on the chance it does, but the publish tool always follows up
57
+ * with a config (add-version) call regardless, which is confirmed to
58
+ * persist it. */
59
+ injectionType?: 'NONE' | 'MSWJS' | 'XHOOK' | 'CDP' | 'HAWKEYE';
51
60
  }
52
61
  export interface UpdateProviderConfigBody {
53
62
  providerConfig: Record<string, unknown>;
package/lib/old/client.js CHANGED
@@ -202,10 +202,28 @@ _ReclaimOldClient_identity = new WeakMap(), _ReclaimOldClient_fetch = new WeakMa
202
202
  return undefined;
203
203
  }
204
204
  }, _ReclaimOldClient_asBoom = function _ReclaimOldClient_asBoom(res, json) {
205
- const message = (json && typeof json === 'object' &&
205
+ const rawMessage = (json && typeof json === 'object' &&
206
206
  typeof json.message === 'string'
207
207
  ? json.message
208
208
  : undefined) || res.statusText || 'Old devtools request failed';
209
+ // An identity ALREADY SET (as opposed to `#requireIdentity`'s "never
210
+ // authenticated" case, which never reaches the backend) plus either a
211
+ // 401/403 OR a message that itself names token verification means the
212
+ // backend rejected a token we had — i.e. it's stale/invalid, not merely
213
+ // absent. The message check matters because this backend does NOT
214
+ // reliably use 401/403 for it — confirmed live: an actually-expired
215
+ // Firebase token surfaced as "Error while verifying token" with a
216
+ // status this narrow check (401/403-only) missed entirely, silently
217
+ // skipping the enrichment. The backend's own message gives no hint of
218
+ // the fix, and nothing else distinguishes this from Builder auth, so
219
+ // append repo-side guidance rather than replacing the raw detail.
220
+ const staleAuth = !!__classPrivateFieldGet(this, _ReclaimOldClient_identity, "f") && (res.status === 401 || res.status === 403 ||
221
+ /verify.{0,20}token|token.{0,20}verif/i.test(rawMessage));
222
+ const message = staleAuth
223
+ ? `${rawMessage} — your old-devtools login looks stale or invalid `
224
+ + '(this is separate from Builder auth). Re-run reclaim_authenticate '
225
+ + 'to refresh it, then retry.'
226
+ : rawMessage;
209
227
  // Shape `data` like a ProblemDetails so remapErrorAsResponse can render
210
228
  // `${message}: ${data.detail}` and expose the raw body as structured.
211
229
  return new Boom(message, {
@@ -51,13 +51,24 @@ export interface OldProviderOpts {
51
51
  * field from the request body. Free-form JSON. */
52
52
  extensionConfig?: unknown;
53
53
  /** Provider-level `disableRequestReplay` (HAWKEYE document replay skip).
54
- * Config (add-version) ONLY: the register endpoint hardcodes it to false
55
- * — the publish tool follows a register up with an add-version call to
56
- * apply it for new providers (see `old/tools/publish.ts`). NOTE the
57
- * builder backend has NO equivalent; its inverse is
58
- * `webSettings.clientOptions.inapp.interceptorOptions
59
- * .isDocumentRequestReplayEnabled`. */
54
+ * The publish tool always forces this `true` — there is no author-facing
55
+ * way to enable document replay. Config (add-version) ONLY: the register
56
+ * endpoint hardcodes it to false regardless of what's sent, so the
57
+ * publish tool always follows a register up with an add-version call to
58
+ * actually apply it (see `old/tools/publish.ts`). The builder backend's
59
+ * inverse-named equivalent, `webSettings.clientOptions.inapp
60
+ * .interceptorOptions.isDocumentRequestReplayEnabled`, is likewise always
61
+ * force-disabled by `providerToCreateVersionRequest`. */
60
62
  disableRequestReplay?: boolean;
63
+ /** Interception mechanism — `NONE` (the publish tool's default) for the
64
+ * standard capture→replay flow; `HAWKEYE`/`MSWJS`/`XHOOK`/`CDP` for a
65
+ * genuine live-interception provider. Old-devtools calls this
66
+ * `injectionType`; builder's equivalent is `webSettings.clientOptions
67
+ * .inapp.interceptorOptions.interceptorType` (no `NONE` value there —
68
+ * omitting the whole object is builder's no-interception signal
69
+ * instead). See `old/client.ts`'s `RegisterProviderBody.injectionType`
70
+ * doc for the register-persistence caveat. */
71
+ injectionType?: 'NONE' | 'MSWJS' | 'XHOOK' | 'CDP' | 'HAWKEYE';
61
72
  }
62
73
  interface OldResponseMatch {
63
74
  value: string;
@@ -133,6 +133,9 @@ export function providerToRegisterBody(providers, opts) {
133
133
  if (opts.stepsToFollow !== undefined) {
134
134
  body.stepsToFollow = opts.stepsToFollow;
135
135
  }
136
+ if (opts.injectionType !== undefined) {
137
+ body.injectionType = opts.injectionType;
138
+ }
136
139
  return body;
137
140
  }
138
141
  /** Body for `POST /api/providers/:providerId/config` — adds a new version to
@@ -173,6 +176,9 @@ export function providerToConfigBody(providers, opts, version, versionInfo) {
173
176
  if (opts.disableRequestReplay !== undefined) {
174
177
  providerConfig.disableRequestReplay = opts.disableRequestReplay;
175
178
  }
179
+ if (opts.injectionType !== undefined) {
180
+ providerConfig.injectionType = opts.injectionType;
181
+ }
176
182
  return { providerConfig, version, versionInfo };
177
183
  }
178
184
  /**
@@ -13,7 +13,9 @@ export function getMeProvidersTool(client) {
13
13
  'first). Returns provider metadata including each `providerId` — ' +
14
14
  'pass one back to create_provider_version_from_capture to publish a ' +
15
15
  'new version of that provider instead of creating a duplicate. ' +
16
- 'Requires reclaim_authenticate first.',
16
+ 'Requires reclaim_authenticate (old-devtools login) first — this ' +
17
+ 'is separate from authenticate_builder/Builder auth, which does ' +
18
+ 'not authorize this call. Check status with get_devtools_mode.',
17
19
  inputSchema: {
18
20
  type: 'object',
19
21
  properties: {
@@ -57,6 +57,9 @@ export function createVersionFromCaptureTool(client, attachRef) {
57
57
  return defineTool({
58
58
  name: 'create_provider_version_from_capture',
59
59
  description: 'Publish drafted ReclaimProvider(s) to the devtools backend. ' +
60
+ 'Requires its OWN old-devtools login via reclaim_authenticate — ' +
61
+ 'separate from authenticate_builder/Builder auth, which does not ' +
62
+ 'authorize this call. Check status with get_devtools_mode. \n\n' +
60
63
  'Pass the `provider` object from propose_provider VERBATIM and the ' +
61
64
  '`initialUrl` the user landed on (the login/landing page, NOT the ' +
62
65
  'API url). \n\n' +
@@ -74,8 +77,9 @@ export function createVersionFromCaptureTool(client, attachRef) {
74
77
  "PUBLIC provider you don't own, omit `providerId` to register your " +
75
78
  'own copy (you cannot edit someone else\'s provider here). \n\n' +
76
79
  'For data gated behind login/navigation, pass `customInjection` — a ' +
77
- 'page-injection script (the devtools name for the builder\'s ' +
78
- 'jsUserScripts) that drives the page to where the request fires. \n\n' +
80
+ 'USER SCRIPT (same concept as the builder\'s `jsUserScripts` — ' +
81
+ 'devtools just calls the field `customInjection`) that drives the ' +
82
+ 'page to where the request fires. \n\n' +
79
83
  'BY DESIGN, this backend only ever CREATES A NEW VERSION (POST) — ' +
80
84
  'there is no partial-patch/edit endpoint, and this tool does NOT ' +
81
85
  'read or carry anything forward from the previous version. Every ' +
@@ -91,15 +95,20 @@ export function createVersionFromCaptureTool(client, attachRef) {
91
95
  'values plus your change) back into this call. ' +
92
96
  '`allowedInjectedRequestData`/`useIncognitoWebview`/' +
93
97
  '`extensionConfig` only take effect on add-version — the register ' +
94
- 'endpoint (no `providerId`) ignores them. `disableRequestReplay` ' +
95
- 'is also add-version-only, but when it is true the tool follows ' +
96
- 'a register up with an add-version call automatically so it takes ' +
97
- 'effect on new providers too. Per-request fields you may set on ' +
98
+ 'endpoint (no `providerId`) ignores them, so on a fresh register ' +
99
+ 'this tool automatically follows up with an add-version call that ' +
100
+ 'applies them (also where document replay gets force-disabled — ' +
101
+ 'see below). Per-request fields you may set on ' +
98
102
  '`provider`/`providers`: `credentials` (omit/same-origin/include, ' +
99
103
  'default include), `urlType` (REGEX/CONSTANT/TEMPLATE, default ' +
100
104
  'derived from {{placeholders}}), and `isOptional` on each ' +
101
105
  'responseMatch (default false). \n\n' +
102
106
  'Captured requests must be GET/POST. OPRF redactions are supported. ' +
107
+ 'Document replay — the HAWKEYE-level flag controlling whether the ' +
108
+ 'page\'s own document/navigation request gets genuinely re-sent ' +
109
+ 'through the interceptor at verification time — is ALWAYS ' +
110
+ 'published disabled (`disableRequestReplay: true`); there is no ' +
111
+ 'way to enable it here. ' +
103
112
  'On success, if a browser is still attached, the response\'s ' +
104
113
  '`_notes` reminds you to confirm with the developer before ' +
105
114
  'calling `dispose_browser` — never close it unprompted, they ' +
@@ -388,13 +397,24 @@ export function createVersionFromCaptureTool(client, attachRef) {
388
397
  description: 'Free-form JSON passed through to the client. Add-version ' +
389
398
  'ONLY.',
390
399
  },
391
- disableRequestReplay: {
392
- type: 'boolean',
393
- description: 'Skip replaying the main document request (HAWKEYE ' +
394
- 'provider-level flag). Honored on add-version; for a ' +
395
- 'provider (no `providerId`) the register endpoint ignores ' +
396
- 'it, so when true the tool automatically follows the ' +
397
- 'register up with an add-version call that applies it.',
400
+ injectionType: {
401
+ type: 'string',
402
+ enum: ['NONE', 'MSWJS', 'XHOOK', 'CDP', 'HAWKEYE'],
403
+ description: 'Interception mechanism for this version. Omit to leave it ' +
404
+ 'unset — the devtools backend\'s own column default ' +
405
+ '(`HAWKEYE`) applies, which is inert and harmless for the ' +
406
+ 'standard capture→replay flow with no interceptor script ' +
407
+ 'installed. Only set this to `HAWKEYE`/`MSWJS`/`XHOOK` ' +
408
+ 'when you are ALSO supplying a `customInjection` script ' +
409
+ 'that calls ' +
410
+ '`window.reclaimInterceptor.addResponseMiddleware(...)` for ' +
411
+ 'genuine live interception, or set `NONE` explicitly if you ' +
412
+ 'want to be certain no interception is configured — see ' +
413
+ 'injection-api.md\'s "Recognizing this on a provider you ' +
414
+ 'didn\'t author" section for what these values actually do. ' +
415
+ 'Builder calls the equivalent concept `interceptorType` (no ' +
416
+ '`NONE` there — omitting the whole `interceptorOptions` ' +
417
+ 'object is its no-interception signal instead).',
398
418
  },
399
419
  },
400
420
  required: ['initialUrl'],
@@ -429,7 +449,13 @@ export function createVersionFromCaptureTool(client, attachRef) {
429
449
  stepsToFollow: args.stepsToFollow,
430
450
  useIncognitoWebview: args.useIncognitoWebview,
431
451
  extensionConfig: args.extensionConfig,
432
- disableRequestReplay: args.disableRequestReplay,
452
+ // Author-settable, no forced default — the backend's own HAWKEYE
453
+ // column default applies when omitted, same as before this field
454
+ // existed at all.
455
+ injectionType: args.injectionType,
456
+ // Always disabled — see the tool description above; there is no
457
+ // author-facing way to enable document replay.
458
+ disableRequestReplay: true,
433
459
  allowedInjectedRequestData: args.allowedInjectedRequestData
434
460
  ? injectedRequestDataFrom(args.allowedInjectedRequestData)
435
461
  : undefined
@@ -468,11 +494,12 @@ export function createVersionFromCaptureTool(client, attachRef) {
468
494
  'every page load (before navigating to initialUrl), matching ' +
469
495
  'the builder\'s jsUserScripts.');
470
496
  }
471
- // disableRequestReplay is honored ONLY on add-version, so a
472
- // register can't set it — when true we immediately follow the
473
- // register with an add-version call (which also lands any other
474
- // register-ignored fields).
475
- const followUp = args.disableRequestReplay === true;
497
+ // disableRequestReplay is honored ONLY on add-version, and it's now
498
+ // always forced true — so every register is immediately followed by
499
+ // an add-version call that actually applies it (which also lands
500
+ // an explicit injectionType, when passed — unverified whether
501
+ // register persists it — and any other register-ignored fields the
502
+ // caller passed).
476
503
  const REGISTER_IGNORED_KEYS = [
477
504
  'allowedInjectedRequestData',
478
505
  'useIncognitoWebview',
@@ -484,34 +511,51 @@ export function createVersionFromCaptureTool(client, attachRef) {
484
511
  notes.push(`${ignoredOnRegister.join(', ')} ` +
485
512
  `${ignoredOnRegister.length > 1 ? 'are' : 'is'} ` +
486
513
  'ignored on register (no providerId) — the devtools backend only ' +
487
- 'reads them when adding a version. ' +
488
- (followUp
489
- ? 'They were applied by the follow-up version the tool ' +
490
- 'created to set disableRequestReplay.'
491
- : 'Publish once to create the provider, then call this ' +
492
- 'again WITH the returned providerId to set them.'));
514
+ 'reads them when adding a version. They were applied by the ' +
515
+ 'automatic follow-up version this tool always creates to ' +
516
+ 'force-disable document replay.');
493
517
  }
494
518
  const body = providerToRegisterBody(providers, opts);
495
519
  const registered = await client.registerProvider(body);
496
- if (followUp) {
497
- const newProviderId = extractProviderId(registered);
498
- if (newProviderId) {
520
+ const newProviderId = extractProviderId(registered);
521
+ if (newProviderId) {
522
+ // The provider already exists as of the register call above —
523
+ // a failure past this point must NOT reject the tool and
524
+ // discard `registered`/`newProviderId`, or a retry without
525
+ // `providerId` would register a DUPLICATE provider. Catch and
526
+ // warn instead, telling the caller to resume WITH this
527
+ // providerId.
528
+ try {
499
529
  const versionsResp = await client.listVersions(newProviderId);
500
530
  const followVersion = bumpPatchString(extractVersions(versionsResp));
501
- const configBody = providerToConfigBody(providers, { ...opts, disableRequestReplay: true }, followVersion, DEFAULT_VERSION_INFO);
531
+ const configBody = providerToConfigBody(providers, opts, followVersion, DEFAULT_VERSION_INFO);
502
532
  await client.updateProviderConfig(newProviderId, configBody);
503
- notes.push('disableRequestReplay is ignored by the register endpoint — ' +
504
- `applied it via a follow-up version (${followVersion}) on ` +
505
- 'the new provider, which also lands any register-ignored ' +
506
- 'fields you passed.');
533
+ notes.push(`Created an automatic follow-up version (${followVersion}) to ` +
534
+ 'force-disable document replay (disableRequestReplay)' +
535
+ (opts.injectionType !== undefined
536
+ ? ` and set injectionType (${opts.injectionType})`
537
+ : '') +
538
+ ' — the register endpoint ignores/may ignore those fields, so ' +
539
+ 'every fresh register gets one. It also landed any other ' +
540
+ 'register-ignored fields you passed.');
507
541
  }
508
- else {
509
- notes.push('Could not apply disableRequestReplay: the register ' +
510
- 'response did not include the new providerId. Call this ' +
511
- 'tool again WITH `providerId` and `disableRequestReplay: ' +
512
- 'true` to set it.');
542
+ catch (err) {
543
+ const detail = isBoom(err) ? err.message : String(err);
544
+ notes.push(`Provider ${newProviderId} WAS created, but the automatic ` +
545
+ `follow-up version (to force-disable document replay${opts.injectionType !== undefined
546
+ ? ' and set injectionType'
547
+ : ''}) failed: ${detail}. Do NOT retry without providerId — that ` +
548
+ 'would register a duplicate. Instead, re-run ' +
549
+ 'create_provider_version_from_capture WITH ' +
550
+ `providerId: "${newProviderId}" to add the follow-up version.`);
513
551
  }
514
552
  }
553
+ else {
554
+ notes.push('Could not force-disable document replay: the ' +
555
+ 'register response did not include the new providerId. Call ' +
556
+ 'this tool again WITH `providerId` to create a version that ' +
557
+ 'applies them.');
558
+ }
515
559
  return withNotes(registered);
516
560
  }
517
561
  // New version of an existing provider — pick the next patch above
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Replay a drafted provider's AUTHENTICATED request FROM INSIDE the live
3
+ * attached browser tab, over CDP `Runtime.evaluate` — so cookies/session are
4
+ * always the real browser's, for every browser source the MCP can attach to
5
+ * (local Chrome, remote Popcorn, or a caller-supplied custom CDP endpoint all
6
+ * funnel through one `AuthoringBackend.sendCdp`). Ports the pattern already
7
+ * proven in the cloud/Popcorn authoring backend
8
+ * (`packages/app/src/author/backend.ts` `pageFetch`/`authenticatedFetch`),
9
+ * adapted to the MCP's single-page `sendCdp(method, params)` (no separate
10
+ * `pageSession` argument). The ANONYMOUS auth-bound check does NOT go through
11
+ * the page — see `replayProviderInPage`'s doc comment below for why.
12
+ */
13
+ import type { ReclaimProvider } from '../provider/schema.ts';
14
+ import { type ReplayResult } from './replay.ts';
15
+ /** Everything this module needs from an `AuthoringBackend` — just the raw CDP
16
+ * seam, so callers can pass a minimal object instead of a full backend. */
17
+ export interface PageReplayTarget {
18
+ sendCdp(method: string, params?: Record<string, unknown>): Promise<unknown>;
19
+ }
20
+ /**
21
+ * Replay `provider` and judge the response. Without `withoutSecrets`, replays
22
+ * as the logged-in session — run FROM INSIDE the live tab
23
+ * (`authenticatedPageFetch`) so cookies are the real browser's, which a
24
+ * detached call could never reproduce without manually re-deriving them.
25
+ * WITH `withoutSecrets` (the auth-bound check — "does this still work with
26
+ * no credentials, i.e. is it actually public"), replay instead through the
27
+ * detached Node-side `replayProvider`: the whole point of that check is to
28
+ * prove NO secret rides along, and the live page's own global `fetch` can't
29
+ * give that guarantee — a site (or a script installed via test_user_script)
30
+ * can wrap `fetch` and re-attach a token from localStorage/a service worker
31
+ * even under `credentials:'omit'`. A fresh, page-uninvolved fetch has no
32
+ * such surface.
33
+ */
34
+ export declare function replayProviderInPage(backend: PageReplayTarget, provider: ReclaimProvider, secrets: Record<string, string>, opts?: {
35
+ withoutSecrets?: boolean;
36
+ }): Promise<ReplayResult>;
@@ -0,0 +1,76 @@
1
+ import { buildReplayRequest, evaluateReplay, replayProvider } from "./replay.js";
2
+ /** Run `fetch(...)` for `provider`'s (already-templated) request inside the
3
+ * attached tab, via `Runtime.evaluate`. `credentials` selects the visitor:
4
+ * `'omit'` = anonymous (the auth-bound check), `'include'` = the logged-in
5
+ * user (the reproducibility gate). Same-origin only — the tab is already on
6
+ * the target site; a cross-origin/CORS failure comes back as `error`. */
7
+ async function pageFetch(backend, provider, credentials, extraSecrets) {
8
+ const req = buildReplayRequest(provider, extraSecrets);
9
+ const expression = `(async () => {
10
+ try {
11
+ const r = await fetch(${JSON.stringify(req.url)}, {
12
+ method: ${JSON.stringify(req.method)},
13
+ headers: ${JSON.stringify(req.headers)},
14
+ credentials: ${JSON.stringify(credentials)},${req.body !== undefined
15
+ ? `\n\t\t\t\tbody: ${JSON.stringify(req.body)},`
16
+ : ''}
17
+ })
18
+ return { status: r.status, body: await r.text() }
19
+ } catch(e) { return { error: String((e && e.message) || e) } }
20
+ })()`;
21
+ try {
22
+ const res = (await backend.sendCdp('Runtime.evaluate', {
23
+ expression,
24
+ awaitPromise: true,
25
+ returnByValue: true,
26
+ }));
27
+ if (res.exceptionDetails) {
28
+ return {
29
+ error: res.exceptionDetails.exception?.description
30
+ ?? res.exceptionDetails.text
31
+ ?? 'page fetch evaluation error',
32
+ };
33
+ }
34
+ return res.result?.value ?? { error: 'page fetch returned no value' };
35
+ }
36
+ catch (err) {
37
+ return { error: err instanceof Error ? err.message : String(err) };
38
+ }
39
+ }
40
+ /** Authenticated re-issue for the reproducibility gate. Cookies ride along
41
+ * via `credentials: 'include'` (a `Cookie` header can't be set from
42
+ * `fetch()` — it's a forbidden header name), so only NON-cookie secrets
43
+ * (Authorization, X-* tokens) are injected explicitly. */
44
+ function authenticatedPageFetch(backend, provider, secrets) {
45
+ const extra = {};
46
+ for (const [k, v] of Object.entries(secrets)) {
47
+ if (k.toLowerCase() !== 'cookie') {
48
+ extra[k] = v;
49
+ }
50
+ }
51
+ return pageFetch(backend, provider, 'include', extra);
52
+ }
53
+ /**
54
+ * Replay `provider` and judge the response. Without `withoutSecrets`, replays
55
+ * as the logged-in session — run FROM INSIDE the live tab
56
+ * (`authenticatedPageFetch`) so cookies are the real browser's, which a
57
+ * detached call could never reproduce without manually re-deriving them.
58
+ * WITH `withoutSecrets` (the auth-bound check — "does this still work with
59
+ * no credentials, i.e. is it actually public"), replay instead through the
60
+ * detached Node-side `replayProvider`: the whole point of that check is to
61
+ * prove NO secret rides along, and the live page's own global `fetch` can't
62
+ * give that guarantee — a site (or a script installed via test_user_script)
63
+ * can wrap `fetch` and re-attach a token from localStorage/a service worker
64
+ * even under `credentials:'omit'`. A fresh, page-uninvolved fetch has no
65
+ * such surface.
66
+ */
67
+ export async function replayProviderInPage(backend, provider, secrets, opts) {
68
+ if (opts?.withoutSecrets) {
69
+ return replayProvider(provider, {});
70
+ }
71
+ const result = await authenticatedPageFetch(backend, provider, secrets);
72
+ if (result.error !== undefined || result.status === undefined) {
73
+ throw new Error(`in-browser replay failed: ${result.error ?? 'no response'}`);
74
+ }
75
+ return evaluateReplay(result.status, result.body ?? '', provider);
76
+ }
@@ -5,4 +5,32 @@ export interface ReplayResult {
5
5
  matchesOriginal: boolean;
6
6
  hints?: string[];
7
7
  }
8
+ /** Expand `{{name}}` placeholders from `paramValues`. Matches the attestor
9
+ * SDK's behavior so replay and proof both hit the same final URL. */
10
+ export declare function expandTemplate(s: string, params: Record<string, string> | undefined): string;
11
+ /** Build the request the wire replay actually sends: `{{name}}` placeholders
12
+ * in the URL/headers/body expanded from `provider.paramValues`, secrets
13
+ * layered onto the public headers. Shared by every replay transport (Node
14
+ * `fetch` here, or a `fetch` run inside the live browser tab) so they all
15
+ * send the identical request. */
16
+ export interface ReplayRequest {
17
+ url: string;
18
+ method: string;
19
+ headers: Record<string, string>;
20
+ body?: string;
21
+ }
22
+ export declare function buildReplayRequest(provider: ReclaimProvider, secrets: Record<string, string>): ReplayRequest;
23
+ /**
24
+ * Judge a replayed response against `provider`'s matchers — the part of
25
+ * replay that's identical regardless of HOW the request was sent (detached
26
+ * Node `fetch` or a live in-browser `fetch`). Separated from the actual
27
+ * network call so both transports share one evaluation.
28
+ */
29
+ export declare function evaluateReplay(status: number, body: string, provider: ReclaimProvider): ReplayResult;
30
+ /** Detached Node-side replay: sends the request directly from this process,
31
+ * NOT from any attached browser tab — its cookies/secrets are exactly the
32
+ * `secrets` passed in, never a real browser session. Used by tests and by
33
+ * any caller with no live browser to replay through (the MCP's authoring
34
+ * backend instead runs the SAME request inside the attached tab — see
35
+ * `proof/page-replay.ts` — so replay reflects real session cookies). */
8
36
  export declare function replayProvider(provider: ReclaimProvider, secrets: Record<string, string>): Promise<ReplayResult>;
@@ -1,6 +1,6 @@
1
1
  /** Expand `{{name}}` placeholders from `paramValues`. Matches the attestor
2
2
  * SDK's behavior so replay and proof both hit the same final URL. */
3
- function expandTemplate(s, params) {
3
+ export function expandTemplate(s, params) {
4
4
  if (!params) {
5
5
  return s;
6
6
  }
@@ -9,7 +9,7 @@ function expandTemplate(s, params) {
9
9
  return v !== undefined ? v : match;
10
10
  });
11
11
  }
12
- export async function replayProvider(provider, secrets) {
12
+ export function buildReplayRequest(provider, secrets) {
13
13
  const params = provider.paramValues;
14
14
  const url = expandTemplate(provider.url, params);
15
15
  const expandedHeaders = {};
@@ -17,21 +17,29 @@ export async function replayProvider(provider, secrets) {
17
17
  expandedHeaders[k] = expandTemplate(v, params);
18
18
  }
19
19
  const headers = { ...expandedHeaders, ...secrets };
20
- const fetchInit = {
20
+ const body = provider.body !== undefined
21
+ ? expandTemplate(provider.body, params)
22
+ : undefined;
23
+ return {
24
+ url,
21
25
  method: provider.method,
22
26
  headers,
23
- redirect: 'manual',
27
+ ...(body !== undefined ? { body } : {}),
24
28
  };
25
- if (provider.body !== undefined) {
26
- fetchInit.body = expandTemplate(provider.body, params);
27
- }
28
- const res = await fetch(url, fetchInit);
29
- const body = await res.text();
29
+ }
30
+ /**
31
+ * Judge a replayed response against `provider`'s matchers — the part of
32
+ * replay that's identical regardless of HOW the request was sent (detached
33
+ * Node `fetch` or a live in-browser `fetch`). Separated from the actual
34
+ * network call so both transports share one evaluation.
35
+ */
36
+ export function evaluateReplay(status, body, provider) {
37
+ const params = provider.paramValues;
30
38
  const hints = [];
31
- if (res.status === 401 || res.status === 403) {
39
+ if (status === 401 || status === 403) {
32
40
  hints.push('auth/permission failed');
33
41
  }
34
- if (res.status >= 500) {
42
+ if (status >= 500) {
35
43
  hints.push('upstream server error');
36
44
  }
37
45
  for (const m of provider.responseMatches) {
@@ -45,22 +53,35 @@ export async function replayProvider(provider, secrets) {
45
53
  value: expandTemplate(m.value, params),
46
54
  };
47
55
  if (!matchesBody(body, expanded)) {
48
- if (res.status >= 200 && res.status < 300) {
56
+ if (status >= 200 && status < 300) {
49
57
  hints.push(`matcher ${expanded.type} did not match — ` +
50
58
  'value may have changed or matcher is too tight');
51
59
  }
52
- const result = {
53
- status: res.status,
54
- body,
55
- matchesOriginal: false,
56
- };
60
+ const result = { status, body, matchesOriginal: false };
57
61
  if (hints.length > 0) {
58
62
  result.hints = hints;
59
63
  }
60
64
  return result;
61
65
  }
62
66
  }
63
- return { status: res.status, body, matchesOriginal: res.status < 400 };
67
+ return { status, body, matchesOriginal: status < 400 };
68
+ }
69
+ /** Detached Node-side replay: sends the request directly from this process,
70
+ * NOT from any attached browser tab — its cookies/secrets are exactly the
71
+ * `secrets` passed in, never a real browser session. Used by tests and by
72
+ * any caller with no live browser to replay through (the MCP's authoring
73
+ * backend instead runs the SAME request inside the attached tab — see
74
+ * `proof/page-replay.ts` — so replay reflects real session cookies). */
75
+ export async function replayProvider(provider, secrets) {
76
+ const req = buildReplayRequest(provider, secrets);
77
+ const res = await fetch(req.url, {
78
+ method: req.method,
79
+ headers: req.headers,
80
+ redirect: 'manual',
81
+ ...(req.body !== undefined ? { body: req.body } : {}),
82
+ });
83
+ const body = await res.text();
84
+ return evaluateReplay(res.status, body, provider);
64
85
  }
65
86
  function matchesBody(body, m) {
66
87
  if (m.type === 'regex') {
@@ -57,4 +57,6 @@ export declare function assertNoOprfUrlConflict(url: string, targets: DraftTarge
57
57
  * header (same class of problem as {@link assertNoOprfUrlConflict} for
58
58
  * OPRF-hashed targets, which has no such budget at all). */
59
59
  export declare function assertNoSecretUrlConflict(url: string, secrets: Record<string, string>, writeRedactionMode?: ReclaimProvider['writeRedactionMode']): void;
60
- export declare function draftProvider(req: CapturedRequest, targets: DraftTarget[], name: string, hash?: OprfMode): DraftResult;
60
+ export declare function draftProvider(req: CapturedRequest, targets: DraftTarget[], name: string, hash?: OprfMode, opts?: {
61
+ shortenNames?: boolean;
62
+ }): DraftResult;