@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
@@ -192,7 +192,32 @@ function deriveParamName(providerName) {
192
192
  const last = tokens[tokens.length - 1] ?? 'value';
193
193
  return last.toLowerCase();
194
194
  }
195
- export function draftProvider(req, targets, name, hash) {
195
+ /** A `paramName` doubles as a regex NAMED CAPTURE GROUP (`(?<name>...)`) and a
196
+ * `paramValues` object key — both require a plain identifier. Nothing
197
+ * downstream validates this: an invalid name builds a syntactically broken
198
+ * regex that silently fails to extract at verification time instead of
199
+ * erroring here, at draft time, where it's actually fixable. */
200
+ const VALID_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
201
+ /** Resolve a target's `paramName`: shortened (the default — last alphanumeric
202
+ * token, lowercased) or, with `shorten: false`, the name verbatim — the
203
+ * caller's choice, validated so it's still safe as a capture-group
204
+ * identifier. */
205
+ function resolveParamName(targetName, shorten) {
206
+ if (!shorten) {
207
+ if (!VALID_PARAM_NAME.test(targetName)) {
208
+ throw new Error(`target name "${targetName}" isn't safe as a param name with `
209
+ + 'shortenNames:false — it becomes a regex capture-group name AND '
210
+ + 'a paramValues key, so it must start with a letter/underscore and '
211
+ + 'contain only letters, digits, and underscores (we recommend '
212
+ + 'lower_snake_case, e.g. "employer_name"). Rename it, or drop '
213
+ + '`shortenNames: false` to auto-derive a safe one.');
214
+ }
215
+ return targetName;
216
+ }
217
+ return deriveParamName(targetName);
218
+ }
219
+ export function draftProvider(req, targets, name, hash, opts) {
220
+ const shortenNames = opts?.shortenNames ?? true;
196
221
  if (!req.responseBody) {
197
222
  throw new Error(`captured request ${req.requestId} has no response body`);
198
223
  }
@@ -207,14 +232,21 @@ export function draftProvider(req, targets, name, hash) {
207
232
  const extractions = [];
208
233
  const effectiveHashes = [];
209
234
  let paramValues;
210
- const seenParams = new Set();
235
+ const seenParams = new Map();
211
236
  for (const t of targets) {
212
- const paramName = deriveParamName(t.name);
213
- if (seenParams.has(paramName)) {
214
- throw new Error(`two targets resolve to the same param "${paramName}"; `
215
- + 'give them distinct names');
237
+ const paramName = resolveParamName(t.name, shortenNames);
238
+ const priorName = seenParams.get(paramName);
239
+ if (priorName !== undefined) {
240
+ throw new Error(`targets "${priorName}" and "${t.name}" both resolve to the same `
241
+ + `param name "${paramName}"`
242
+ + (shortenNames
243
+ ? ' (names are shortened to their last alphanumeric token) — '
244
+ + 'give them more distinct names, e.g. "firstName"/"surname" '
245
+ + 'instead of "first_name"/"last_name"'
246
+ : ' — give them distinct names, or drop `shortenNames: false` '
247
+ + 'to auto-derive distinct ones'));
216
248
  }
217
- seenParams.add(paramName);
249
+ seenParams.set(paramName, t.name);
218
250
  // Precedence: explicit per-target hash → explicit request-wide hash →
219
251
  // the anti-leak floor (force-OPRF values whose SHAPE is unambiguous PII).
220
252
  // The model makes the contextual calls (which named fields are sensitive)
@@ -117,18 +117,34 @@ function jsonMatcher(body, target, paramName, valueClass, hash) {
117
117
  if (!jsonPath) {
118
118
  return undefined;
119
119
  }
120
- const anchor = jsonKeyPrefix(body, target);
120
+ const anchor = jsonKeyPrefix(body, target, lastPathKey(jsonPath));
121
121
  if (!anchor) {
122
122
  return undefined;
123
123
  }
124
124
  return keyValueResult(anchor.prefix, anchor.close, target, paramName, valueClass, { jsonPath }, hash);
125
125
  }
126
+ /** The trailing object-property name of a jsonPath (`$.total_records` →
127
+ * `"total_records"`), or undefined for an array-index/bracket leaf, which
128
+ * has no literal key to anchor a search on. */
129
+ function lastPathKey(jsonPath) {
130
+ const m = jsonPath ? /\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(jsonPath) : null;
131
+ return m?.[1];
132
+ }
126
133
  /**
127
134
  * The literal `"key":"` (or `"key":` for a raw number/bool) immediately before
128
135
  * `target` in `text`, plus the closing quote (`"` for a string value, `''`
129
136
  * otherwise). Returns undefined when the value isn't preceded by a JSON key.
137
+ *
138
+ * When `key` is given, anchors the search on occurrences of the KEY (not the
139
+ * value) — so a short/common value elsewhere in the body (e.g. the digit "5"
140
+ * inside `"investment_amount":354.85`) can't be mistaken for this field. Only
141
+ * falls back to a whole-body value search when there's no resolvable key
142
+ * (e.g. `findJsonPath` returned an array-index leaf).
130
143
  */
131
- function jsonKeyPrefix(text, target) {
144
+ function jsonKeyPrefix(text, target, key) {
145
+ if (key !== undefined) {
146
+ return jsonKeyPrefixByKey(text, target, key);
147
+ }
132
148
  const { idx } = firstIndex(text, target);
133
149
  if (idx < 0) {
134
150
  return undefined;
@@ -157,6 +173,61 @@ function jsonKeyPrefix(text, target) {
157
173
  }
158
174
  return { prefix: text.slice(keyStart, idx), close: quoted ? '"' : '' };
159
175
  }
176
+ /** `jsonKeyPrefix`'s key-anchored path: scan occurrences of `"key"`, and for
177
+ * each, check whether the value that follows its `:` matches `target` (or a
178
+ * normalized candidate) before accepting it. */
179
+ function jsonKeyPrefixByKey(text, target, key) {
180
+ const keyToken = `"${key}"`;
181
+ let from = 0;
182
+ for (;;) {
183
+ const keyIdx = text.indexOf(keyToken, from);
184
+ if (keyIdx < 0) {
185
+ return undefined;
186
+ }
187
+ from = keyIdx + keyToken.length;
188
+ let i = from;
189
+ while (i < text.length && /\s/.test(text[i] ?? '')) {
190
+ i++;
191
+ }
192
+ if (text[i] !== ':') {
193
+ continue;
194
+ }
195
+ i++;
196
+ while (i < text.length && /\s/.test(text[i] ?? '')) {
197
+ i++;
198
+ }
199
+ const matched = matchJsonValueAt(text, i, target);
200
+ if (matched) {
201
+ return {
202
+ prefix: text.slice(keyIdx, matched.valueStart),
203
+ close: matched.close,
204
+ };
205
+ }
206
+ }
207
+ }
208
+ /** Whether the JSON value starting at `pos` in `text` — quoted string or a
209
+ * raw number/bool/null token — equals `target` (or a normalized candidate).
210
+ * Returns the position right after any opening quote (matching `firstIndex`'s
211
+ * convention) and the delimiter to close the match template with. */
212
+ function matchJsonValueAt(text, pos, target) {
213
+ const candidates = [target, ...normalizeCandidates(target)];
214
+ if (text[pos] === '"') {
215
+ let j = pos + 1;
216
+ while (j < text.length && text[j] !== '"') {
217
+ j += text[j] === '\\' ? 2 : 1;
218
+ }
219
+ const raw = text.slice(pos + 1, j);
220
+ return candidates.includes(raw)
221
+ ? { valueStart: pos + 1, close: '"' }
222
+ : undefined;
223
+ }
224
+ let j = pos;
225
+ while (j < text.length && !/[,}\]\s]/.test(text[j] ?? '')) {
226
+ j++;
227
+ }
228
+ const raw = text.slice(pos, j);
229
+ return candidates.includes(raw) ? { valueStart: pos, close: '' } : undefined;
230
+ }
160
231
  function findJsonPath(body, target) {
161
232
  let parsed;
162
233
  try {
@@ -226,7 +297,9 @@ function htmlMatcher(body, target, paramName, valueClass, hash) {
226
297
  const scriptJson = scriptJsonContent(revealSlice);
227
298
  if (scriptJson) {
228
299
  const jsonPath = findJsonPath(scriptJson, target);
229
- const kp = jsonPath ? jsonKeyPrefix(scriptJson, target) : undefined;
300
+ const kp = jsonPath
301
+ ? jsonKeyPrefix(scriptJson, target, lastPathKey(jsonPath))
302
+ : undefined;
230
303
  if (jsonPath && kp) {
231
304
  return keyValueResult(kp.prefix, kp.close, target, paramName, valueClass, { xPath: anchor.xPath, jsonPath }, hash);
232
305
  }
@@ -396,7 +469,7 @@ function contentMatcher(body, target, paramName, valueClass, hash) {
396
469
  return {
397
470
  match: {
398
471
  type: 'contains',
399
- value: buildContextualTemplate(body, target, paramName),
472
+ value: buildContextualTemplate(body, target, paramName, valueClass),
400
473
  },
401
474
  redaction,
402
475
  paramValues: { [paramName]: target },
@@ -421,12 +494,47 @@ function contentMatcher(body, target, paramName, valueClass, hash) {
421
494
  * must CONTAIN the match template (buildContextualTemplate), so the trailing
422
495
  * delimiter is kept unless it's whitespace (which the template also drops).
423
496
  */
497
+ /** Indices where `cand` occurs in `body` as a WHOLE match of `valueClass` —
498
+ * i.e. not a substring inside a longer run of the same class (the digit "5"
499
+ * inside "354.85" is never a whole `\d+` match; "354" and "85" are). Only
500
+ * meaningful for a structural (non-free-form) class. */
501
+ function wholeTokenIndices(body, cand, valueClass) {
502
+ const indices = [];
503
+ let re;
504
+ try {
505
+ re = new RegExp(valueClass, 'g');
506
+ }
507
+ catch {
508
+ return indices;
509
+ }
510
+ let m;
511
+ while ((m = re.exec(body))) {
512
+ if (m[0] === cand) {
513
+ indices.push(m.index);
514
+ }
515
+ if (re.lastIndex === m.index) {
516
+ re.lastIndex++;
517
+ }
518
+ }
519
+ return indices;
520
+ }
424
521
  function buildContentRegex(body, target, paramName, valueClass) {
522
+ const structural = inferValueClass(target) !== undefined;
425
523
  for (const cand of normalizeCandidates(target)) {
426
- const idx = body.indexOf(cand);
524
+ const occurrences = structural
525
+ ? wholeTokenIndices(body, cand, valueClass)
526
+ : undefined;
527
+ const idx = occurrences ? occurrences[0] ?? -1 : body.indexOf(cand);
427
528
  if (idx < 0) {
428
529
  continue;
429
530
  }
531
+ if (occurrences && occurrences.length > 1
532
+ && cand.replace(/[^A-Za-z0-9]/g, '').length < 4) {
533
+ throw new Error(`target value "${target}" occurs ${occurrences.length} times as a `
534
+ + 'standalone token with no JSON key or HTML anchor to '
535
+ + "disambiguate — capture a request where it's unique, or use a "
536
+ + 'longer/more specific target.');
537
+ }
430
538
  const endIdx = idx + cand.length;
431
539
  const rawRight = findRightContext(body, endIdx);
432
540
  const right = /^\s/.test(rawRight) ? '' : escapeRegexFlexWs(rawRight);
@@ -456,8 +564,9 @@ function buildContentRegex(body, target, paramName, valueClass) {
456
564
  * literal contains can't flex whitespace); stable context (a JSON key, an HTML
457
565
  * opening tag) is kept so the match anchors on it.
458
566
  */
459
- function buildContextualTemplate(body, target, paramName) {
460
- const idx = body.indexOf(target);
567
+ function buildContextualTemplate(body, target, paramName, valueClass) {
568
+ const idx = wholeTokenIndices(body, target, valueClass)[0]
569
+ ?? body.indexOf(target);
461
570
  if (idx < 0) {
462
571
  return `{{${paramName}}}`;
463
572
  }
@@ -54,14 +54,16 @@ export interface CreateVersionOptions {
54
54
  jsUserScripts?: string;
55
55
  /** Requests the injection script may fire, as `RequestSelectionTemplate`s. */
56
56
  allowedJsRequests?: AllowedJsRequest[];
57
- /** In-app interception options, mapped verbatim to
58
- * `webSettings.clientOptions.inapp.interceptorOptions`. `interceptorType`
59
- * is required by the backend whenever the object is present.
60
- * `isDocumentRequestReplayEnabled` is the INVERSE of old-devtools'
61
- * `disableRequestReplay` (default false). */
57
+ /** In-app interception options, mapped to
58
+ * `webSettings.clientOptions.inapp.interceptorOptions` — ONLY when this
59
+ * is explicitly passed (builder's schema has no `NONE` interceptorType;
60
+ * omitting `inapp` entirely IS the "no interception" signal, so omitting
61
+ * this must omit `inapp`, not default it to HAWKEYE). When it IS passed,
62
+ * `isDocumentRequestReplayEnabled` is still ALWAYS force-disabled
63
+ * regardless of what's passed here — see `providerToCreateVersionRequest`;
64
+ * there is no author-facing way to enable it. */
62
65
  interceptorOptions?: {
63
66
  interceptorType: 'HAWKEYE' | 'MSWJS' | 'CDP';
64
- isDocumentRequestReplayEnabled?: boolean;
65
67
  interceptorSettings?: string;
66
68
  };
67
69
  }
@@ -130,14 +130,26 @@ export function providerToCreateVersionRequest(providers, initialUrl, opts = {})
130
130
  geoLocation: pickGeoLocation(providers),
131
131
  clientOptions: {
132
132
  portal: { useProxy: true },
133
+ // Builder's schema has NO "NONE" interceptorType — the real
134
+ // no-interception signal is OMITTING `inapp` entirely (it's not
135
+ // `required` on `InAppClientOptions`). So `inapp` is only built at
136
+ // ALL when the author explicitly opts into a real interceptorType;
137
+ // omitting `interceptorOptions` must omit `inapp` too, or every
138
+ // standard (non-interception) provider would incorrectly publish
139
+ // as HAWKEYE. Within that block, `isDocumentRequestReplayEnabled`
140
+ // is still force-disabled — see `CreateVersionOptions
141
+ // .interceptorOptions`'s doc comment; there is no way to enable it.
133
142
  ...(interceptorOptions
134
143
  ? {
135
144
  inapp: {
136
145
  interceptorOptions: {
137
- // openapi `default: false` — the generated type
138
- // requires the key even when the author omits it.
146
+ interceptorType: interceptorOptions.interceptorType,
139
147
  isDocumentRequestReplayEnabled: false,
140
- ...interceptorOptions,
148
+ ...(interceptorOptions.interceptorSettings
149
+ ? {
150
+ interceptorSettings: interceptorOptions.interceptorSettings,
151
+ }
152
+ : {}),
141
153
  },
142
154
  },
143
155
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/agent",
3
3
  "title": "Reclaim Agent",
4
- "version": "0.1.22",
4
+ "version": "0.1.23",
5
5
  "description": "Reclaim Protocol's tools for agents — Reclaim Protocol lets your app verify **data points** about its users — a follower count, an account balance, an order history. This package gives your AI coding agent the tools to **find, build, test and fix the _data verification providers_** (the recipes that verify a data point) by inspecting and replaying captured requests, checking sessions, evaluate proofs, publishing updates, and much more.",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -83,10 +83,11 @@
83
83
  "@modelcontextprotocol/sdk": "^1.29.0",
84
84
  "@noble/hashes": "^2.2.0",
85
85
  "@reclaimprotocol/attestor-core": "^5.0.8",
86
- "@reclaimprotocol/client": "0.1.22",
86
+ "@reclaimprotocol/client": "0.1.23",
87
87
  "@reclaimprotocol/tls": "^0.1.3",
88
88
  "chrome-remote-interface": "^0.33.0",
89
89
  "domhandler": "^6.0.0",
90
+ "openid-client": "^6.8.5",
90
91
  "parse5": "^8.0.0",
91
92
  "parse5-htmlparser2-tree-adapter": "^8.0.0",
92
93
  "pino": "^9.14.0",
@@ -208,4 +208,4 @@ If `replay_request` or `run_proof` errors with `unknown draftId` (or `no capture
208
208
 
209
209
  ## Recognizing a HAWKEYE / live-interception provider
210
210
 
211
- Everything above builds the STANDARD flow: capture → draft → replay → prove. There is a second flow — `webSettings.inapp.interceptorOptions.interceptorType: HAWKEYE` (or `MSWJS`/`CDP`) plus a `jsUserScripts` script that calls `window.reclaimInterceptor.addResponseMiddleware(...)` to observe the page's REAL responses as they happen, instead of an independent replay. `create_provider_version_from_capture` never sets `inapp` at all, so you will not author this flow with the current tools — but you may see these fields on a version someone else published outside this flow. `isDocumentRequestReplayEnabled` is a NARROWER, separate toggle from the interception itself — it does not gate `addResponseMiddleware` in general (that fires for every `interceptorType` including `CDP`); it only controls whether the current page's own document/navigation request specifically gets pulled through a non-CDP interceptor, as a site-compatibility escape hatch. See `injection-api.md` (embedded in the `jsUserScripts` field description) for the source-verified breakdown before guessing at what they mean.
211
+ Everything above builds the STANDARD flow: capture → draft → replay → prove. There is a second flow — `webSettings.inapp.interceptorOptions.interceptorType: HAWKEYE` (or `MSWJS`/`CDP`) plus a `jsUserScripts` script that calls `window.reclaimInterceptor.addResponseMiddleware(...)` to observe the page's REAL responses as they happen, instead of an independent replay. `create_provider_version_from_capture` exposes `interceptorOptions.interceptorType` as an author-settable input for exactly this — pass it (plus a script that calls `addResponseMiddleware`) to opt INTO real interception. Omit `interceptorOptions` entirely for the standard flow (the default) — the tool correctly omits `inapp` rather than defaulting it to `HAWKEYE`, since builder's schema has no `NONE` interceptorType and omitting the whole object IS the no-interception signal. `isDocumentRequestReplayEnabled` is a NARROWER, separate toggle from the interception itself — it does not gate `addResponseMiddleware` in general (that fires for every `interceptorType` including `CDP`); it only controls whether the current page's own document/navigation request specifically gets pulled through a non-CDP interceptor, as a site-compatibility escape hatch. The tool always force-disables it (`false`) whenever `interceptorOptions` is passed at all — there is no way to enable it through `create_provider_version_from_capture`. See `injection-api.md` (embedded in the `jsUserScripts` field description) for the source-verified breakdown before guessing at what they mean.
@@ -11,6 +11,15 @@
11
11
  > If a developer calls the recipe a "verification," gently use "provider"
12
12
  > yourself rather than correcting them.
13
13
 
14
+ > **Terminology note:** "JS injection", "custom injection", "user script",
15
+ > and "web script" all refer to the SAME thing when talking about a
16
+ > provider — the plain browser JS that runs before every page load to drive
17
+ > navigation for data gated behind login/routing. Builder's schema field is
18
+ > `jsUserScripts`; old-devtools' equivalent field is `customInjection`
19
+ > (identical semantics, different name). Prefer **"user script"** in your
20
+ > own prose regardless of which backend you're on; see `injection-api.md`
21
+ > for the full field-level reference.
22
+
14
23
  You drive the browser through the capture → draft → replay → prove loop using the `reclaim-agent` MCP server. **Your default stance is driver, not orchestrator.** Only pause to ask the dev for things that genuinely require a human: typing credentials, completing 2FA, solving a captcha, confirming an ambiguous value. Navigation, reloads, DOM inspection, request capture — all yours.
15
24
 
16
25
  <!-- section: step-attach -->
@@ -174,4 +174,4 @@ If `replay_request` or `run_proof` errors with `unknown secretParamsRef`:
174
174
 
175
175
  ## Recognizing a HAWKEYE / live-interception provider
176
176
 
177
- Everything above builds the STANDARD flow: capture → draft → replay (the backend independently re-sends the request) → prove. There is a second, separate flow — `injectionType: HAWKEYE` (or `MSWJS`/`XHOOK`/`CDP`) plus a `customInjection` script that calls `window.reclaimInterceptor.addResponseMiddleware(...)` to observe the page's REAL responses as they happen, instead of the backend replaying anything. You will not author this flow with the current tools — `create_provider_version_from_capture` only builds the standard one. But you WILL see the fields when reading an existing provider (`get_provider_version_config`, `get_provider_info`): don't assume `injectionType` present-but-not-`NONE` means something is broken — the old backend's own schema DEFAULTS `injectionType` to `HAWKEYE` for every provider, standard-flow ones included; it's inert with no interceptor script installed. `disableRequestReplay` is a NARROWER, separate toggle — it does not gate interception in general (that fires regardless, for every `injectionType` including `CDP`); it only controls whether the current page's own document/navigation request specifically gets pulled through a non-CDP interceptor, and exists as a site-compatibility escape hatch, not an architecture choice. See `injection-api.md` (embedded in the `customInjection` field description) for the full, source-verified breakdown of `injectionType`/`disableRequestReplay`/`bodySniff` before guessing at any of these three.
177
+ Everything above builds the STANDARD flow: capture → draft → replay (the backend independently re-sends the request) → prove. There is a second, separate flow — `injectionType: HAWKEYE` (or `MSWJS`/`XHOOK`/`CDP`) plus a `customInjection` script that calls `window.reclaimInterceptor.addResponseMiddleware(...)` to observe the page's REAL responses as they happen, instead of the backend replaying anything. `create_provider_version_from_capture` now exposes `injectionType` as an author-settable input — pass one of `HAWKEYE`/`MSWJS`/`XHOOK`/`CDP` (plus a `customInjection` script that calls `addResponseMiddleware`) to author this flow. Omit it for the standard flow: there's no forced default, so the old backend's own schema default (`HAWKEYE`) applies, same as before this field was exposed — that's fine, it's inert with no interceptor script installed. Pass `NONE` explicitly instead if you want certainty rather than relying on the backend default. Don't assume `injectionType` present-but-not-`NONE` on an EXISTING provider (`get_provider_version_config`, `get_provider_info`) means something is broken — HAWKEYE-by-default is normal and harmless for the standard flow on both new and old providers. `disableRequestReplay` is a NARROWER, separate toggle — it does not gate interception in general (that fires regardless, for every `injectionType` including `CDP`); it only controls whether the current page's own document/navigation request specifically gets pulled through a non-CDP interceptor, and exists as a site-compatibility escape hatch, not an architecture choice. `create_provider_version_from_capture` always force-publishes this `true` now (auto-following a fresh register with an add-version call to apply it, since register hardcodes it `false`) — there is no author-facing way to leave it off. See `injection-api.md` (embedded in the `customInjection` field description) for the full, source-verified breakdown of `injectionType`/`disableRequestReplay`/`bodySniff` before guessing at any of these three.
@@ -53,18 +53,30 @@ under EVERY mechanism below, including `CDP` — it is not a HAWKEYE-specific
53
53
  thing.
54
54
 
55
55
  - **old-devtools** field: `injectionType`, one of `NONE | MSWJS | XHOOK | CDP |
56
- HAWKEYE`. Verified from the backend's Prisma schema: **the column default is
57
- `HAWKEYE`**, not `NONE` — every provider `create_provider_version_from_capture`
58
- registers gets `injectionType: HAWKEYE` implicitly, since neither this tool
59
- nor the old backend's register/config controllers ever set it explicitly.
60
- This is harmless for the normal capture→replay→prove flow: with no
61
- interceptor script installed, there's nothing calling
62
- `addResponseMiddleware`, so the standard flow behaves identically
63
- regardless of this field's value.
56
+ HAWKEYE` — confirmed the literal field name against a real snapshot of
57
+ legacy-backend provider data (`packages/app/scripts/data/
58
+ provider-configs.snapshot.json`, captured straight from the old-devtools
59
+ Mongo `Provider`/`ProviderVersion.providerConfig`). The backend's own
60
+ column default is `HAWKEYE`, not `NONE` — harmless for the standard flow
61
+ (no interceptor script installed, so nothing calls
62
+ `addResponseMiddleware`). `create_provider_version_from_capture`
63
+ (old-devtools mode) now exposes `injectionType` as an author-settable
64
+ input, with NO forced default — omit it and the backend's own `HAWKEYE`
65
+ default applies, same as before this field was exposed at all; pass an
66
+ explicit value (including `NONE`) if you want certainty either way. When
67
+ passed, it's sent on both register and add-version; since it's unverified
68
+ whether the register controller actually persists it (no source access to
69
+ confirm), the tool's existing always-on add-version follow-up (see
70
+ `disableRequestReplay` below) reapplies it regardless, so an explicit value
71
+ is guaranteed correct by the time the follow-up version lands.
64
72
  - **builder** field: `webSettings.inapp.interceptorOptions.interceptorType`,
65
- one of `HAWKEYE | MSWJS | CDP` (no `NONE`/`XHOOK` in this schema).
66
- `create_provider_version_from_capture` (builder mode) never sets `inapp` at
67
- all, so this is a no-op for the standard flow there too.
73
+ one of `HAWKEYE | MSWJS | CDP` (no `NONE`/`XHOOK` in this schema — omitting
74
+ `interceptorOptions` entirely is builder's NONE-equivalent).
75
+ `create_provider_version_from_capture` (builder mode) exposes
76
+ `interceptorOptions.interceptorType` as an author-settable input; passing
77
+ it is how you opt INTO real interception. Omit it (the default) for the
78
+ standard flow, and the tool correctly omits `inapp` — it does NOT default
79
+ to `HAWKEYE` (that was a bug in an earlier version of this tool; fixed).
68
80
 
69
81
  **The mechanisms, and why you'd pick one over plain replay:**
70
82
  - **`CDP`** — interception at the browser/DevTools-protocol level, below the
@@ -83,6 +95,24 @@ thing.
83
95
 
84
96
  ## `disableRequestReplay` / `isDocumentRequestReplayEnabled`
85
97
 
98
+ **`create_provider_version_from_capture` always force-disables this now, in
99
+ BOTH backends** — old-devtools always publishes `disableRequestReplay: true`
100
+ (the tool auto-follows a fresh register with an add-version call to apply
101
+ it, since the register endpoint hardcodes it `false` regardless of what's
102
+ sent). Builder always publishes `isDocumentRequestReplayEnabled: false`
103
+ under `webSettings.clientOptions.inapp.interceptorOptions` **whenever you
104
+ pass `interceptorOptions` at all** — but if you don't (the standard,
105
+ non-interception flow), the tool omits `inapp` entirely rather than forcing
106
+ a default `interceptorType`, because builder's schema has no `NONE`
107
+ interceptorType: omitting the whole object IS its no-interception signal,
108
+ and defaulting it to (even inert) `HAWKEYE` would misrepresent the
109
+ provider. There is no author-facing way to turn document replay back on
110
+ through either tool once `interceptorOptions`/`injectionType` is in play.
111
+ The rest of this section explains the underlying platform semantics for
112
+ context (e.g. reading an existing provider someone else published outside
113
+ these tools) — the replay sub-flag itself is no longer something you
114
+ configure here.
115
+
86
116
  **This does NOT gate `addResponseMiddleware` in general** — that fires for
87
117
  any intercepted request regardless of this field. What it actually controls:
88
118
  whether the CURRENT PAGE'S OWN document/navigation request (the request that
@@ -125,10 +155,9 @@ Field names, per backend:
125
155
  schema's stated default is "not sent"; verify against whichever backend
126
156
  you're publishing to rather than carrying an assumption across.
127
157
 
128
- If you're not authoring a non-CDP interceptor script that specifically needs
129
- to observe the current page's own document request, leave this alone (the
130
- default already matches the standard flow on old-devtools; on builder, this
131
- field isn't touched by `create_provider_version_from_capture` at all).
158
+ There is nothing to configure here through `create_provider_version_from_capture`
159
+ any more (see the note at the top of this section) — both backends always
160
+ publish with document replay disabled.
132
161
 
133
162
  ## `bodySniff` — unrelated to the above
134
163
 
package/skill/old-mode.md CHANGED
@@ -2,7 +2,7 @@ This server is running against the devtools backend, so its capabilities are int
2
2
 
3
3
  **Provider-authoring flow:**
4
4
 
5
- 1. `reclaim_authenticate` — by default opens the dashboard in Chrome, waits for you to sign in, captures the token automatically, and closes the tab (or paste a `token` / pass an eth `ethAddress` to skip the browser). Do this once before publishing or listing.
5
+ 1. `reclaim_authenticate` — by default opens the dashboard in Chrome, waits for you to sign in, captures the token automatically, and closes the tab (or paste a `token` / pass an eth `ethAddress` to skip the browser). Do this once before publishing or listing. (Note: this old-devtools login is separate from `authenticate_builder`, which is needed only for the two Builder-exclusive features below — remote-browser allocation and org listing — see the auth troubleshooting note below.)
6
6
  2. `attach_browser` → `start_capture` → drive the page (`navigate`, `wait_for_page`) → inspect (`list_requests`, `get_request`, `find_requests_containing`). Browser source is independent of this publishing mode: local Chrome works as before; `builder` uses `authenticate_builder` + `list_builder_organizations` to allocate a quota-accounted Popcorn browser and returns a shareable live-view URL; `custom` accepts a caller-supplied CDP websocket.
7
7
  3. `propose_provider` to draft a provider, `replay_request` to pre-flight, `run_proof` to verify with the attestor. OPRF/`hash` redactions are optional and supported. When the data spans MULTIPLE requests (e.g. a landing call + a details call), propose + `run_proof` EACH request to get one proven draft per request.
8
8
  4. `create_provider_version_from_capture` to publish. With NO `providerId` it REGISTERS A NEW provider (re-running creates a duplicate); pass a `providerId` from `get_me_providers` to add a new version to an existing provider instead. For a MULTI-REQUEST provider, pass `providers: [draftA, draftB, …]` (every proven draft) so EACH becomes a parallel `requestData` entry on the one version — passing a single `provider` saves only that one request.
@@ -10,6 +10,8 @@ This server is running against the devtools backend, so its capabilities are int
10
10
 
11
11
  **Editing / new versions (ownership):** you can only add a version to a provider **you own** — the `providerId` must come from `get_me_providers`. Trying to version a public provider you don't own fails with a permission error; that is expected. To publish your own version of such a site, re-run `create_provider_version_from_capture` **without** `providerId` to register your own copy (a separate provider you own). If versioning a provider you *do* own fails, confirm you authenticated as the account that created it.
12
12
 
13
+ **Two independent auth systems — don't confuse them:** `authenticate_builder` and `reclaim_authenticate` log into DIFFERENT backends and are NOT interchangeable. In THIS mode (old-devtools), `authenticate_builder` is needed only for two Builder-exclusive features — allocating a Popcorn remote browser (`attach_browser mode="builder"`) and listing Builder organizations (`list_builder_organizations`, since old-devtools has no generated org tool of its own). It does not authorize `create_provider_version_from_capture`, `get_me_providers`, or any other old-devtools call — those need their own `reclaim_authenticate` login. (Builder mode, by contrast, needs no separate login at all — its one native auth tool covers everything, org listing included via `list_orgs`.) `get_devtools_mode` reports the presence of both. If a publish or `get_me_providers` call fails with an auth error even though you (or the dev) already ran `reclaim_authenticate` once before, the old-devtools token has likely expired (Firebase tokens are short-lived, ~1h) — re-run `reclaim_authenticate` and retry; don't assume `authenticate_builder` fixes it.
14
+
13
15
  Only GET/POST endpoints can be captured into providers here. There is no Builder organization-management, credential-linking, or public-review workflow in this mode; the narrow `authenticate_builder` and `list_builder_organizations` exception exists only to allocate Builder-hosted authoring browsers.
14
16
 
15
17
  **Diagnosing a session:** `session_analytics_logs` fetches the analytics logs for a verification session by id (from the logs service) — use it to see what happened in a session (matches, claims, failures).
@@ -90,13 +90,15 @@ export interface AuthoringBackend {
90
90
  * the cloud publish step) — that side effect is the backend's, not ours. */
91
91
  prove(draft: Draft, opts?: ProveOptions): Promise<unknown>
92
92
  /**
93
- * Validate/replay a drafted recipe before proving. With `withoutSecrets` it
94
- * is the AUTH-BOUND check: re-issue the request stripped of the captured
95
- * secrets (local: a Node re-fetch without them; remote: an anonymous `fetch`
96
- * in the Popcorn page, `credentials: 'omit'`) and report whether the values
97
- * are STILL extractable — i.e. the endpoint is public. Without it, validate
98
- * extraction for the authenticated request. The shape is environment-specific
99
- * and passed straight through as the tool's result.
93
+ * Validate/replay a drafted recipe before proving. Both local and remote
94
+ * backends re-issue the request FROM INSIDE the live attached page (a
95
+ * `fetch` run over CDP), never a detached out-of-band call, so replay
96
+ * always reflects the real browser session. With `withoutSecrets` it is
97
+ * the AUTH-BOUND check: re-issue anonymously (`credentials: 'omit'`, no
98
+ * secret headers) and report whether the values are STILL extractable —
99
+ * i.e. the endpoint is public. Without it, validate extraction for the
100
+ * authenticated request. The shape is environment-specific and passed
101
+ * straight through as the tool's result.
100
102
  */
101
103
  replay(draft: Draft, opts?: { withoutSecrets?: boolean }): Promise<unknown>
102
104
  }
@@ -179,6 +179,118 @@ export function authoringTools(
179
179
  return { ready: false }
180
180
  },
181
181
  ),
182
+ defineTool<Record<string, unknown>>(
183
+ {
184
+ name: 'test_user_script',
185
+ description:
186
+ 'Dry-run a USER SCRIPT — the same thing as the `jsUserScripts` / '
187
+ + '`customInjection` field on a published provider: plain browser '
188
+ + 'JS that runs before every page load, used to drive navigation '
189
+ + 'for data gated behind login/routing — in the LIVE attached tab, '
190
+ + 'BEFORE publishing. Installs `script` via CDP so it runs on the '
191
+ + 'next navigation exactly like the real field would, then reports '
192
+ + 'whether it ran and any errors it threw. Catches bugs (e.g. a '
193
+ + 'listener registered with setTimeout that never fires because the '
194
+ + "page was already past 'load' by the time it ran) before they "
195
+ + 'surface in a real end-user session instead of after. If '
196
+ + '`navigateUrl` is given, navigates there after installing, then '
197
+ + 'removes the script again once the result is read (self-cleaning '
198
+ + '— safe to call repeatedly). Without `navigateUrl` the script is '
199
+ + 'left installed for you to navigate yourself (any way you like) '
200
+ + 'then re-call this tool, or read '
201
+ + '`window.__reclaimUserScriptResult` via eval_in_page — in that '
202
+ + 'manual mode it is NOT auto-removed and keeps running on every '
203
+ + 'later navigation until a follow-up call with `navigateUrl` '
204
+ + 'removes it, so avoid stacking several manual-mode calls in a '
205
+ + 'row.',
206
+ inputSchema: objectSchema(
207
+ {
208
+ script: { type: 'string' },
209
+ navigateUrl: { type: 'string' },
210
+ timeoutMs: { type: 'number' },
211
+ },
212
+ ['script'],
213
+ ),
214
+ },
215
+ async(args) => {
216
+ const backend = resolveBackend(args)
217
+ const script = String(args.script)
218
+ // The user's script is spliced in as a STRING and compiled at
219
+ // runtime via `new Function`, not as raw top-level source: a
220
+ // syntax error in it would otherwise fail to parse the entire
221
+ // wrapper (result init, error listener, and try/catch included),
222
+ // so the tool would report ran:false / errors:[] instead of the
223
+ // actual SyntaxError.
224
+ const wrapped = `
225
+ window.__reclaimUserScriptResult = { ran: false, errors: [] };
226
+ window.addEventListener('error', function(e) {
227
+ window.__reclaimUserScriptResult.errors.push(String(e.message));
228
+ });
229
+ try {
230
+ (new Function(${JSON.stringify(script)}))();
231
+ window.__reclaimUserScriptResult.ran = true;
232
+ } catch(e) {
233
+ window.__reclaimUserScriptResult.errors.push(
234
+ String((e && e.message) || e),
235
+ );
236
+ }
237
+ `
238
+ const installed = (await backend.sendCdp(
239
+ 'Page.addScriptToEvaluateOnNewDocument', { source: wrapped },
240
+ )) as { identifier?: string }
241
+
242
+ if(typeof args.navigateUrl !== 'string') {
243
+ return {
244
+ installed: true,
245
+ note:
246
+ 'Installed for the NEXT navigation only — it did not run on '
247
+ + 'the current page, and stays installed (runs on every later '
248
+ + 'navigation too) until a follow-up call with navigateUrl '
249
+ + 'removes it. Navigate now (e.g. the navigate tool), then read '
250
+ + 'window.__reclaimUserScriptResult via eval_in_page, or call '
251
+ + 'test_user_script again with navigateUrl.',
252
+ }
253
+ }
254
+
255
+ try {
256
+ const timeoutMs = Number(args.timeoutMs) || 30_000
257
+ await backend.sendCdp('Page.enable')
258
+ const load = backend.waitForEvent('Page.loadEventFired', timeoutMs)
259
+ const nav = (await backend.sendCdp(
260
+ 'Page.navigate', { url: String(args.navigateUrl) },
261
+ )) as { errorText?: string }
262
+ if(nav.errorText) {
263
+ return { installed: true, navigated: false, error: nav.errorText }
264
+ }
265
+
266
+ await load
267
+ const { value, error } = await evalInPage(
268
+ backend, 'window.__reclaimUserScriptResult',
269
+ )
270
+ if(error) {
271
+ return { installed: true, navigated: true, error }
272
+ }
273
+
274
+ const result =
275
+ value as { ran?: boolean, errors?: string[] } | undefined
276
+ return {
277
+ installed: true,
278
+ navigated: true,
279
+ ran: result?.ran ?? false,
280
+ errors: result?.errors ?? [],
281
+ }
282
+ } finally {
283
+ // One-shot test: remove it so it doesn't keep running (and
284
+ // potentially redirecting the tab) on every later navigation.
285
+ if(installed.identifier) {
286
+ await backend.sendCdp(
287
+ 'Page.removeScriptToEvaluateOnNewDocument',
288
+ { identifier: installed.identifier },
289
+ )
290
+ }
291
+ }
292
+ },
293
+ ),
182
294
  defineTool<Record<string, unknown>>(
183
295
  {
184
296
  name: 'get_cookies',
@@ -321,7 +433,13 @@ export function authoringTools(
321
433
  name: 'run_proof',
322
434
  description:
323
435
  'Prove the drafted recipe (zkTLS). Only call after replay_request '
324
- + 'looks right. Returns the proof result.',
436
+ + 'looks right. Returns the proof result. NOTE: unlike '
437
+ + 'replay_request (which re-issues from inside the live attached '
438
+ + 'browser tab), the ATTESTOR calls the target from its OWN '
439
+ + 'network/IP, separate from the browser\'s. A request that '
440
+ + 'replayed fine can still fail here for geo/IP-bound content '
441
+ + '(country-locked pages, IP-allowlisted APIs) — if that happens, '
442
+ + 'it\'s a property of the site, not a bug in the draft.',
325
443
  inputSchema: objectSchema({ draftId: { type: 'string' } }, ['draftId']),
326
444
  },
327
445
  // The backend shapes its own result: the cloud returns a lean,