@reclaimprotocol/agent 0.1.22 → 0.1.24

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 (163) hide show
  1. package/README.md +55 -20
  2. package/lib/authoring/backend.d.ts +13 -10
  3. package/lib/authoring/tools.js +106 -9
  4. package/lib/cdp/browser-proxy.d.ts +78 -0
  5. package/lib/cdp/browser-proxy.js +186 -0
  6. package/lib/cdp/capture-engine.js +8 -7
  7. package/lib/cdp/client.d.ts +10 -0
  8. package/lib/cdp/geo.d.ts +10 -0
  9. package/lib/cdp/geo.js +36 -0
  10. package/lib/cdp/index.d.ts +1 -0
  11. package/lib/cdp/index.js +1 -0
  12. package/lib/live/cloudflared.d.ts +15 -0
  13. package/lib/live/cloudflared.js +268 -0
  14. package/lib/live/page.d.ts +20 -0
  15. package/lib/live/page.js +134 -0
  16. package/lib/live/tunnel.d.ts +23 -0
  17. package/lib/live/tunnel.js +120 -0
  18. package/lib/live/viewer.d.ts +28 -0
  19. package/lib/live/viewer.js +232 -0
  20. package/lib/mcp/api/operations.gen.js +34 -26
  21. package/lib/mcp/api/to-zod.d.ts +1 -1
  22. package/lib/mcp/api/to-zod.js +1 -1
  23. package/lib/mcp/api/tools.js +1 -1
  24. package/lib/mcp/instructions.d.ts +15 -2
  25. package/lib/mcp/instructions.js +101 -38
  26. package/lib/mcp/start.js +4 -3
  27. package/lib/mcp/tools/agent/analyze.js +5 -1
  28. package/lib/mcp/tools/agent/attach.d.ts +8 -1
  29. package/lib/mcp/tools/agent/attach.js +166 -22
  30. package/lib/mcp/tools/agent/build.d.ts +13 -8
  31. package/lib/mcp/tools/agent/build.js +22 -11
  32. package/lib/mcp/tools/agent/capture.js +5 -1
  33. package/lib/mcp/tools/agent/eval.js +3 -1
  34. package/lib/mcp/tools/agent/find.js +6 -5
  35. package/lib/mcp/tools/agent/how-it-works.d.ts +15 -7
  36. package/lib/mcp/tools/agent/how-it-works.js +57 -17
  37. package/lib/mcp/tools/agent/live-view.d.ts +21 -0
  38. package/lib/mcp/tools/agent/live-view.js +153 -0
  39. package/lib/mcp/tools/agent/pod-proof.d.ts +8 -0
  40. package/lib/mcp/tools/agent/pod-proof.js +64 -0
  41. package/lib/mcp/tools/agent/propose.js +29 -3
  42. package/lib/mcp/tools/agent/proxy.d.ts +3 -0
  43. package/lib/mcp/tools/agent/proxy.js +199 -0
  44. package/lib/mcp/tools/agent/publish.js +49 -77
  45. package/lib/mcp/tools/agent/session.d.ts +2 -1
  46. package/lib/mcp/tools/agent/session.js +26 -7
  47. package/lib/mcp/tools/agent/shared.js +17 -8
  48. package/lib/mcp/tools/authenticate/authenticate.d.ts +11 -0
  49. package/lib/mcp/tools/authenticate/authenticate.js +143 -121
  50. package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -3
  51. package/lib/mcp/tools/authenticate/builder-access.js +8 -3
  52. package/lib/mcp/tools/mode/mode.d.ts +1 -1
  53. package/lib/mcp/tools/mode/mode.js +26 -7
  54. package/lib/old/client.d.ts +9 -0
  55. package/lib/old/client.js +19 -1
  56. package/lib/old/instructions.d.ts +9 -1
  57. package/lib/old/instructions.js +4 -6
  58. package/lib/old/login-cdp.d.ts +2 -2
  59. package/lib/old/login-cdp.js +46 -6
  60. package/lib/old/to-register.d.ts +20 -8
  61. package/lib/old/to-register.js +7 -1
  62. package/lib/old/tools/authenticate.js +9 -1
  63. package/lib/old/tools/get-provider-info.js +6 -5
  64. package/lib/old/tools/issue-app-credentials.js +3 -2
  65. package/lib/old/tools/me-providers.js +3 -1
  66. package/lib/old/tools/publish.d.ts +1 -1
  67. package/lib/old/tools/publish.js +132 -129
  68. package/lib/old/tools/update-provider-metadata.d.ts +1 -1
  69. package/lib/old/tools/update-provider-metadata.js +1 -1
  70. package/lib/old/tools/verify-sdk-setup.js +1 -1
  71. package/lib/proof/attestor.d.ts +3 -3
  72. package/lib/proof/debug-response-dump.js +4 -4
  73. package/lib/proof/index.d.ts +1 -0
  74. package/lib/proof/index.js +1 -0
  75. package/lib/proof/page-replay.d.ts +36 -0
  76. package/lib/proof/page-replay.js +76 -0
  77. package/lib/proof/pod-prover.d.ts +47 -0
  78. package/lib/proof/pod-prover.js +155 -0
  79. package/lib/proof/replay.d.ts +28 -0
  80. package/lib/proof/replay.js +39 -18
  81. package/lib/provider/dom-xpath.js +11 -9
  82. package/lib/provider/draft.d.ts +13 -8
  83. package/lib/provider/draft.js +47 -13
  84. package/lib/provider/injection-doc.d.ts +23 -1
  85. package/lib/provider/injection-doc.js +34 -21
  86. package/lib/provider/match-redaction-warning.d.ts +2 -2
  87. package/lib/provider/match-redaction-warning.js +3 -3
  88. package/lib/provider/matcher.js +122 -12
  89. package/lib/provider/secrets.d.ts +3 -3
  90. package/lib/provider/secrets.js +3 -3
  91. package/lib/provider/to-version.d.ts +12 -9
  92. package/lib/provider/to-version.js +16 -4
  93. package/lib/tools/registry.d.ts +5 -5
  94. package/lib/tools/registry.js +5 -5
  95. package/package.json +4 -3
  96. package/samples/demo-server/README.md +7 -6
  97. package/skill/SKILL.builder.md +356 -189
  98. package/skill/SKILL.common.md +314 -79
  99. package/skill/SKILL.old.md +340 -155
  100. package/skill/builder-mode.md +58 -17
  101. package/skill/hash-validation.md +170 -0
  102. package/skill/interception.md +235 -0
  103. package/skill/js-sdk-integration.md +13 -7
  104. package/skill/old-mode.md +53 -12
  105. package/skill/user-script.md +61 -0
  106. package/src/authoring/backend.ts +13 -10
  107. package/src/authoring/tools.ts +128 -9
  108. package/src/cdp/capture-engine.ts +8 -7
  109. package/src/cdp/client.ts +8 -0
  110. package/src/cdp/geo.ts +52 -0
  111. package/src/cdp/index.ts +1 -0
  112. package/src/consts.ts +1 -0
  113. package/src/live/cloudflared.ts +348 -0
  114. package/src/live/page.ts +145 -0
  115. package/src/live/tunnel.ts +157 -0
  116. package/src/live/viewer.ts +293 -0
  117. package/src/mcp/api/operations.gen.ts +34 -26
  118. package/src/mcp/api/to-zod.ts +1 -1
  119. package/src/mcp/api/tools.ts +1 -1
  120. package/src/mcp/instructions.ts +121 -48
  121. package/src/mcp/start.ts +4 -3
  122. package/src/mcp/tools/agent/analyze.ts +5 -1
  123. package/src/mcp/tools/agent/attach.ts +194 -22
  124. package/src/mcp/tools/agent/build.ts +26 -15
  125. package/src/mcp/tools/agent/capture.ts +5 -1
  126. package/src/mcp/tools/agent/eval.ts +4 -1
  127. package/src/mcp/tools/agent/find.ts +6 -5
  128. package/src/mcp/tools/agent/how-it-works.ts +62 -21
  129. package/src/mcp/tools/agent/live-view.ts +198 -0
  130. package/src/mcp/tools/agent/pod-proof.ts +90 -0
  131. package/src/mcp/tools/agent/propose.ts +31 -3
  132. package/src/mcp/tools/agent/publish.ts +54 -78
  133. package/src/mcp/tools/agent/session.ts +26 -5
  134. package/src/mcp/tools/agent/shared.ts +19 -8
  135. package/src/mcp/tools/authenticate/authenticate.ts +167 -165
  136. package/src/mcp/tools/authenticate/builder-access.ts +8 -3
  137. package/src/mcp/tools/mode/mode.ts +28 -7
  138. package/src/old/client.ts +30 -1
  139. package/src/old/instructions.ts +4 -10
  140. package/src/old/login-cdp.ts +50 -6
  141. package/src/old/to-register.ts +28 -8
  142. package/src/old/tools/authenticate.ts +9 -1
  143. package/src/old/tools/get-provider-info.ts +6 -5
  144. package/src/old/tools/issue-app-credentials.ts +3 -2
  145. package/src/old/tools/me-providers.ts +3 -1
  146. package/src/old/tools/publish.ts +140 -130
  147. package/src/old/tools/update-provider-metadata.ts +1 -1
  148. package/src/old/tools/verify-sdk-setup.ts +1 -1
  149. package/src/proof/attestor.ts +3 -3
  150. package/src/proof/debug-response-dump.ts +4 -4
  151. package/src/proof/index.ts +8 -0
  152. package/src/proof/page-replay.ts +131 -0
  153. package/src/proof/pod-prover.ts +231 -0
  154. package/src/proof/replay.ts +60 -19
  155. package/src/provider/dom-xpath.ts +11 -9
  156. package/src/provider/draft.ts +56 -13
  157. package/src/provider/injection-doc.ts +37 -24
  158. package/src/provider/match-redaction-warning.ts +3 -3
  159. package/src/provider/matcher.ts +151 -10
  160. package/src/provider/secrets.ts +3 -3
  161. package/src/provider/to-version.ts +30 -13
  162. package/src/tools/registry.ts +5 -5
  163. package/skill/injection-api.md +0 -335
@@ -0,0 +1,231 @@
1
+ /**
2
+ * PopcornPodProver — run the zkTLS proof inside a Popcorn browser session's
3
+ * OWN TEE pod instead of the in-process attestor SDK (`runAttestorProof`).
4
+ * POSTs the recipe + captured secrets to the pod's `/reclaim/prove` (the same
5
+ * attestor TEE+MPC protocol) and re-shapes the response into the shared
6
+ * {@link ProofResult}, so every caller consumes proof results identically
7
+ * regardless of which path produced them.
8
+ *
9
+ * This is the pod's OWN api for creating a proof from a live session — used
10
+ * by both the app's server-driven verification runner and the MCP's
11
+ * `run_proof_via_pod` tool. Proving here (rather than locally) matters
12
+ * specifically when the session is country-routed: Popcorn allocated the
13
+ * browser with an exit country, and this is the SAME enclave holding that
14
+ * browser's TLS session, so the proof reaches the target from the identical
15
+ * exit IP the browser used — a local attestor SDK proof runs from the
16
+ * app/agent's OWN network instead.
17
+ *
18
+ * Contract (verified against ../popcorn-images oapi.go + ../reclaim-tee):
19
+ * POST {apiUrl}/reclaim/prove body { provider_params_json, config_json? }
20
+ * provider_params_json = JSON of { name, params, secretParams, context? }
21
+ * → 200 { session_id, claim, signature } (ReclaimProveResult, snake_case):
22
+ * claim has provider/parameters/owner/timestamp_s/context/identifier/epoch;
23
+ * signature has attestor_address + claim_signature(b64) +
24
+ * result_signature(b64). A proof failure/timeout is a 500 whose `message`
25
+ * carries the reason.
26
+ *
27
+ * The pod API is unauthenticated (gateway/session is the trust boundary); call
28
+ * it over the same internal host used for CDP. Consumers verify
29
+ * `claim_signature` only (see `@reclaimprotocol/client`'s `verifyNestedProof`),
30
+ * so this hands back a {@link AttestorClaim} whose hex `claimSignature` the
31
+ * shared `wrapProofAsJws` (app-side) turns into the nested result proof.
32
+ * `result_signature` is unused by any consumer.
33
+ */
34
+ import { bytesToHex } from '@noble/hashes/utils.js'
35
+ import type { ProviderParams } from '@reclaimprotocol/attestor-core'
36
+ import type { AttestorClaim, ProofResult } from './attestor.ts'
37
+
38
+ /** Attestor HTTP provider params — the recipe the agent synthesis produces. */
39
+ export type HttpParams = ProviderParams<'http'>
40
+
41
+ /** Captured secrets for the replay, exactly as `/reclaim/prove` wants them. */
42
+ export interface HttpSecretParams {
43
+ cookieStr?: string
44
+ authorisationHeader?: string
45
+ headers?: Record<string, string>
46
+ paramValues?: Record<string, string>
47
+ }
48
+
49
+ /**
50
+ * The pod's `/reclaim/prove` 200 body (ReclaimProveResult, snake_case; fields
51
+ * optional because we validate). Note `timestamp_s` and the whole `signature`
52
+ * object are snake_case; matching them is exactly what fixes the false
53
+ * "no claim/signature".
54
+ */
55
+ interface ReclaimProveResponse {
56
+ session_id?: string
57
+ claim?: {
58
+ provider?: string
59
+ parameters?: string
60
+ owner?: string
61
+ timestamp_s?: number
62
+ context?: string
63
+ identifier?: string
64
+ epoch?: number
65
+ }
66
+ signature?: {
67
+ attestor_address?: string
68
+ claim_signature?: string
69
+ result_signature?: string
70
+ }
71
+ }
72
+
73
+ export interface PopcornProveOptions {
74
+ /** Pod API base (the session's `apiUrl`). */
75
+ apiUrl: string
76
+ params: HttpParams
77
+ secretParams: HttpSecretParams
78
+ /** Session context, stringified into the claim context if given. */
79
+ context?: Record<string, unknown>
80
+ fetchImpl?: typeof fetch
81
+ }
82
+
83
+ export interface ValidateExtractionResult {
84
+ valid: boolean
85
+ extractedValue?: string
86
+ error?: string
87
+ steps?: string[]
88
+ }
89
+
90
+ /**
91
+ * The cloud "replay": validate that a drafted recipe's selectors extract the
92
+ * expected value from a captured response, inside the TEE — `POST {apiUrl}
93
+ * /reclaim/validate-extraction`. Reuses the prover's own redaction engine, so
94
+ * a pass means the selectors won't diverge at proof time. No proof/secrets.
95
+ */
96
+ export async function popcornValidateExtraction(opts: {
97
+ apiUrl: string
98
+ responseBody: string
99
+ expectedValue: string
100
+ xPath?: string
101
+ jsonPath?: string
102
+ regex?: string
103
+ fetchImpl?: typeof fetch
104
+ }): Promise<ValidateExtractionResult> {
105
+ const doFetch = opts.fetchImpl ?? fetch
106
+ const base = opts.apiUrl.replace(/\/$/, '')
107
+ try {
108
+ const res = await doFetch(`${base}/reclaim/validate-extraction`, {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify({
112
+ responseBody: opts.responseBody,
113
+ expectedValue: opts.expectedValue,
114
+ ...(opts.xPath ? { xPath: opts.xPath } : {}),
115
+ ...(opts.jsonPath ? { jsonPath: opts.jsonPath } : {}),
116
+ ...(opts.regex ? { regex: opts.regex } : {}),
117
+ }),
118
+ })
119
+ if(!res.ok) {
120
+ const detail = await res.text().catch(() => '')
121
+ return { valid: false, error: `${res.status}: ${detail.slice(0, 300)}` }
122
+ }
123
+
124
+ const body = (await res.json()) as ValidateExtractionResult
125
+ return body
126
+ } catch(err) {
127
+ const error = err instanceof Error ? err.message : String(err)
128
+ return { valid: false, error }
129
+ }
130
+ }
131
+
132
+ /** Decode standard base64 (Go `base64.StdEncoding` from Popcorn) to bytes. */
133
+ function b64ToBytes(b64: string): Uint8Array {
134
+ return new Uint8Array(Buffer.from(b64, 'base64'))
135
+ }
136
+
137
+ /**
138
+ * Prove a single request through the Popcorn TEE. Returns the shared
139
+ * {@link ProofResult}; on any failure `verified` is false and `error` carries
140
+ * the reason (never throws for an expected proof failure).
141
+ */
142
+ export async function popcornProve(
143
+ opts: PopcornProveOptions,
144
+ ): Promise<ProofResult> {
145
+ const { apiUrl, params, secretParams, context } = opts
146
+ const doFetch = opts.fetchImpl ?? fetch
147
+ const base = apiUrl.replace(/\/$/, '')
148
+
149
+ const providerParamsJson = JSON.stringify({
150
+ name: 'http',
151
+ params,
152
+ secretParams,
153
+ ...(context ? { context: JSON.stringify(context) } : {}),
154
+ })
155
+ const fail = (error: string): ProofResult => ({
156
+ proof: undefined,
157
+ extractedValue: '',
158
+ extractedParameters: {},
159
+ verified: false,
160
+ error,
161
+ })
162
+
163
+ let resp: ReclaimProveResponse
164
+ let raw = ''
165
+ try {
166
+ const res = await doFetch(`${base}/reclaim/prove`, {
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify({ provider_params_json: providerParamsJson }),
170
+ })
171
+ // Read the body as text first: a proof failure/timeout is a 500 whose
172
+ // `message` we want to surface verbatim, and on an unexpected 200 shape we
173
+ // echo the raw body instead of a blind "no claim/signature".
174
+ raw = await res.text().catch(() => '')
175
+ if(!res.ok) {
176
+ return fail(`/reclaim/prove ${res.status}: ${raw.slice(0, 300)}`)
177
+ }
178
+
179
+ resp = JSON.parse(raw) as ReclaimProveResponse
180
+ } catch(err) {
181
+ return fail(err instanceof Error ? err.message : String(err))
182
+ }
183
+
184
+ const claim = resp.claim
185
+ const sig = resp.signature
186
+ if(!claim || !sig?.attestor_address || !sig?.claim_signature) {
187
+ return fail(
188
+ 'popcorn /reclaim/prove returned no claim/signature: '
189
+ + raw.slice(0, 300),
190
+ )
191
+ }
192
+
193
+ // claim.context is the JSON string carrying { extractedParameters, ... } —
194
+ // the same place the in-process path reads them from.
195
+ let extractedParameters: Record<string, string> = {}
196
+ if(typeof claim.context === 'string') {
197
+ try {
198
+ const ctx = JSON.parse(claim.context) as {
199
+ extractedParameters?: Record<string, string>
200
+ }
201
+ extractedParameters = ctx.extractedParameters ?? {}
202
+ } catch{
203
+ // leave empty if context isn't valid JSON
204
+ }
205
+ }
206
+
207
+ const attestorClaim: AttestorClaim = {
208
+ claim: {
209
+ provider: claim.provider ?? '',
210
+ parameters: claim.parameters ?? '',
211
+ owner: claim.owner ?? '',
212
+ timestampS: claim.timestamp_s ?? 0,
213
+ context: claim.context ?? '',
214
+ identifier: claim.identifier ?? '',
215
+ epoch: claim.epoch ?? 0,
216
+ },
217
+ attestorAddress: sig.attestor_address,
218
+ // Consumer-side verification expects hex; Popcorn signs in base64.
219
+ claimSignature: '0x' + bytesToHex(b64ToBytes(sig.claim_signature)),
220
+ }
221
+
222
+ return {
223
+ proof: resp,
224
+ verified: true,
225
+ extractedParameters,
226
+ extractedValue: Object.values(extractedParameters)[0] ?? '',
227
+ ...(claim.identifier ? { identifier: claim.identifier } : {}),
228
+ ...(claim.owner ? { owner: claim.owner } : {}),
229
+ attestorClaim,
230
+ }
231
+ }
@@ -9,7 +9,7 @@ export interface ReplayResult {
9
9
 
10
10
  /** Expand `{{name}}` placeholders from `paramValues`. Matches the attestor
11
11
  * SDK's behavior so replay and proof both hit the same final URL. */
12
- function expandTemplate(
12
+ export function expandTemplate(
13
13
  s: string,
14
14
  params: Record<string, string> | undefined,
15
15
  ): string {
@@ -23,10 +23,22 @@ function expandTemplate(
23
23
  })
24
24
  }
25
25
 
26
- export async function replayProvider(
26
+ /** Build the request the wire replay actually sends: `{{name}}` placeholders
27
+ * in the URL/headers/body expanded from `provider.paramValues`, secrets
28
+ * layered onto the public headers. Shared by every replay transport (Node
29
+ * `fetch` here, or a `fetch` run inside the live browser tab) so they all
30
+ * send the identical request. */
31
+ export interface ReplayRequest {
32
+ url: string
33
+ method: string
34
+ headers: Record<string, string>
35
+ body?: string
36
+ }
37
+
38
+ export function buildReplayRequest(
27
39
  provider: ReclaimProvider,
28
40
  secrets: Record<string, string>,
29
- ): Promise<ReplayResult> {
41
+ ): ReplayRequest {
30
42
  const params = provider.paramValues
31
43
  const url = expandTemplate(provider.url, params)
32
44
  const expandedHeaders: Record<string, string> = {}
@@ -35,23 +47,35 @@ export async function replayProvider(
35
47
  }
36
48
 
37
49
  const headers = { ...expandedHeaders, ...secrets }
38
- const fetchInit: RequestInit = {
50
+ const body = provider.body !== undefined
51
+ ? expandTemplate(provider.body, params)
52
+ : undefined
53
+ return {
54
+ url,
39
55
  method: provider.method,
40
56
  headers,
41
- redirect: 'manual',
42
- }
43
- if(provider.body !== undefined) {
44
- fetchInit.body = expandTemplate(provider.body, params)
57
+ ...(body !== undefined ? { body } : {}),
45
58
  }
59
+ }
46
60
 
47
- const res = await fetch(url, fetchInit)
48
- const body = await res.text()
61
+ /**
62
+ * Judge a replayed response against `provider`'s matchers — the part of
63
+ * replay that's identical regardless of HOW the request was sent (detached
64
+ * Node `fetch` or a live in-browser `fetch`). Separated from the actual
65
+ * network call so both transports share one evaluation.
66
+ */
67
+ export function evaluateReplay(
68
+ status: number,
69
+ body: string,
70
+ provider: ReclaimProvider,
71
+ ): ReplayResult {
72
+ const params = provider.paramValues
49
73
  const hints: string[] = []
50
- if(res.status === 401 || res.status === 403) {
74
+ if(status === 401 || status === 403) {
51
75
  hints.push('auth/permission failed')
52
76
  }
53
77
 
54
- if(res.status >= 500) {
78
+ if(status >= 500) {
55
79
  hints.push('upstream server error')
56
80
  }
57
81
 
@@ -66,18 +90,14 @@ export async function replayProvider(
66
90
  value: expandTemplate(m.value, params),
67
91
  }
68
92
  if(!matchesBody(body, expanded)) {
69
- if(res.status >= 200 && res.status < 300) {
93
+ if(status >= 200 && status < 300) {
70
94
  hints.push(
71
95
  `matcher ${expanded.type} did not match — ` +
72
96
  'value may have changed or matcher is too tight',
73
97
  )
74
98
  }
75
99
 
76
- const result: ReplayResult = {
77
- status: res.status,
78
- body,
79
- matchesOriginal: false,
80
- }
100
+ const result: ReplayResult = { status, body, matchesOriginal: false }
81
101
  if(hints.length > 0) {
82
102
  result.hints = hints
83
103
  }
@@ -86,7 +106,28 @@ export async function replayProvider(
86
106
  }
87
107
  }
88
108
 
89
- return { status: res.status, body, matchesOriginal: res.status < 400 }
109
+ return { status, body, matchesOriginal: status < 400 }
110
+ }
111
+
112
+ /** Detached Node-side replay: sends the request directly from this process,
113
+ * NOT from any attached browser tab — its cookies/secrets are exactly the
114
+ * `secrets` passed in, never a real browser session. Used by tests and by
115
+ * any caller with no live browser to replay through (the MCP's authoring
116
+ * backend instead runs the SAME request inside the attached tab — see
117
+ * `proof/page-replay.ts` — so replay reflects real session cookies). */
118
+ export async function replayProvider(
119
+ provider: ReclaimProvider,
120
+ secrets: Record<string, string>,
121
+ ): Promise<ReplayResult> {
122
+ const req = buildReplayRequest(provider, secrets)
123
+ const res = await fetch(req.url, {
124
+ method: req.method,
125
+ headers: req.headers,
126
+ redirect: 'manual',
127
+ ...(req.body !== undefined ? { body: req.body } : {}),
128
+ })
129
+ const body = await res.text()
130
+ return evaluateReplay(res.status, body, provider)
90
131
  }
91
132
 
92
133
  function matchesBody(body: string, m: ResponseMatch): boolean {
@@ -183,9 +183,10 @@ export function elementAnchor(
183
183
  /** The tightest element that DIRECTLY holds a target. Prefers a COMPLETE-value
184
184
  * occurrence — an attribute whose value IS the target, or a text child that IS
185
185
  * the target — over one where the target is merely embedded in a longer string
186
- * (e.g. a userid inside a `1234567+user@…` noreply address). The complete
187
- * field is the semantic source and yields a portable anchor; the embedded one
188
- * is incidental. Smallest byte span wins within the chosen tier. */
186
+ * (for example, a userid inside a `1234567+user@…` noreply address). The
187
+ * complete field is the semantic source and yields a portable anchor; the
188
+ * embedded one is incidental. Smallest byte span wins within the chosen
189
+ * tier. */
189
190
  function findElement(root: Node, targets: string[]): Element | undefined {
190
191
  const complete: Element[] = []
191
192
  const embedded: Element[] = []
@@ -250,8 +251,8 @@ function* candidateXPaths(el: Element): Generator<Candidate> {
250
251
 
251
252
  // A uniquely-identified DIRECT child pins the parent without a positional
252
253
  // path (`//span[span[@id="labels"]]`). Self-verification discards it when the
253
- // child id/attr isn't unique — e.g. GitHub reuses `id="labels"` per email row
254
- // — so it never silently matches the wrong node.
254
+ // child id/attr isn't unique — for example, GitHub reuses `id="labels"` per
255
+ // email row — so it never silently matches the wrong node.
255
256
  for(const child of el.children) {
256
257
  if(!isTag(child)) {
257
258
  continue
@@ -295,8 +296,9 @@ function distinctiveClass(cls: string | undefined): string | undefined {
295
296
  .sort((x, y) => y.length - x.length)[0]
296
297
  }
297
298
 
298
- /** Positional path from `anc` (exclusive) down to `el` (inclusive), e.g.
299
- * `/ul[1]/li[3]/a[1]`; empty string when `anc` is not an ancestor of `el`. */
299
+ /** Positional path from `anc` (exclusive) down to `el` (inclusive), for
300
+ * example `/ul[1]/li[3]/a[1]`. Empty string when `anc` is not an ancestor
301
+ * of `el`. */
300
302
  function relativeSteps(anc: Element, el: Element): string {
301
303
  const parts: string[] = []
302
304
  let node: Node | null = el
@@ -392,8 +394,8 @@ function absolutePath(el: Element): string {
392
394
 
393
395
  /** True when `expression` selects EXACTLY `el` (one match) against `doc`. The
394
396
  * attestor applies the redaction regex to EVERY xPath match and throws if any
395
- * fails, so a non-unique selector (e.g. a class shared by sibling rows) is
396
- * unusable — it must resolve to the single target element. */
397
+ * fails, so a non-unique selector (for example, a class shared by sibling
398
+ * rows) is unusable — it must resolve to the single target element. */
397
399
  function matchesUniquely(doc: unknown, expression: string, el: Node): boolean {
398
400
  try {
399
401
  const engine = xpath as unknown as {
@@ -57,7 +57,8 @@ export interface DraftTarget {
57
57
 
58
58
  /** The realized extraction for one target — its value paired with the
59
59
  * redaction that reveals it (absent when matched literally). Lets a consumer
60
- * validate each value against its own selector (e.g. the cloud replay). */
60
+ * validate each value against its own selector (for example, the cloud
61
+ * replay). */
61
62
  export interface Extraction {
62
63
  name: string
63
64
  value: string
@@ -110,12 +111,14 @@ function sanitizeRequestHeaders(
110
111
  * match or redaction — either a neighbouring target's value or a target's own
111
112
  * value left un-templated. The matcher builds each target's window
112
113
  * independently and drops the per-user WORDS it can see, but it cannot know a
113
- * neighbouring value is itself user-specific (e.g. a name sharing a text node
114
- * with an id — a `<b>{{id}}</b>` sitting beside a `<td>{{name}}</td>`).
115
- * Cross-checking every value against every match here catches that: a portable
116
- * provider contains NO captured value as a literal — each appears only as its
117
- * `{{param}}`. Throwing discards the draft so the agent re-captures or
118
- * re-proposes rather than shipping a provider pinned to this one user. */
114
+ * neighbouring value is itself user-specific (for example, a name sharing a
115
+ * text node with an id — a `<b>{{id}}</b>` sitting beside a
116
+ * `<td>{{name}}</td>`). Cross-checking every value against every match here
117
+ * catches
118
+ * that: a portable provider contains NO captured value as a literal — each
119
+ * appears only as its `{{param}}`. Throwing discards the draft so the agent
120
+ * re-captures or re-proposes rather than shipping a provider pinned to this
121
+ * one user. */
119
122
  export function assertPortableMatches(
120
123
  matches: ResponseMatch[],
121
124
  redactions: ResponseRedaction[],
@@ -275,12 +278,45 @@ function deriveParamName(providerName: string): string {
275
278
  return last.toLowerCase()
276
279
  }
277
280
 
281
+ /** A `paramName` doubles as a regex NAMED CAPTURE GROUP (`(?<name>...)`) and a
282
+ * `paramValues` object key — both require a plain identifier. Nothing
283
+ * downstream validates this: an invalid name builds a syntactically broken
284
+ * regex that silently fails to extract at verification time instead of
285
+ * erroring here, at draft time, where it's actually fixable. */
286
+ const VALID_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
287
+
288
+ /** Resolve a target's `paramName`: shortened (the default — last alphanumeric
289
+ * token, lowercased) or, with `shorten: false`, the name verbatim — the
290
+ * caller's choice, validated so it's still safe as a capture-group
291
+ * identifier. */
292
+ function resolveParamName(targetName: string, shorten: boolean): string {
293
+ if(!shorten) {
294
+ if(!VALID_PARAM_NAME.test(targetName)) {
295
+ throw new Error(
296
+ `target name "${targetName}" isn't safe as a param name with `
297
+ + 'shortenNames:false — it becomes a regex capture-group name AND '
298
+ + 'a paramValues key, so it must start with a letter/underscore and '
299
+ + 'contain only letters, digits, and underscores (we recommend '
300
+ + 'lower_snake_case, for example, "employer_name"). Rename it, or drop '
301
+ + '`shortenNames: false` to auto-derive a safe one.',
302
+ )
303
+ }
304
+
305
+ return targetName
306
+ }
307
+
308
+ return deriveParamName(targetName)
309
+ }
310
+
278
311
  export function draftProvider(
279
312
  req: CapturedRequest,
280
313
  targets: DraftTarget[],
281
314
  name: string,
282
315
  hash?: OprfMode,
316
+ opts?: { shortenNames?: boolean },
283
317
  ): DraftResult {
318
+ const shortenNames = opts?.shortenNames ?? true
319
+
284
320
  if(!req.responseBody) {
285
321
  throw new Error(`captured request ${req.requestId} has no response body`)
286
322
  }
@@ -297,17 +333,24 @@ export function draftProvider(
297
333
  const extractions: Extraction[] = []
298
334
  const effectiveHashes: (OprfMode | undefined)[] = []
299
335
  let paramValues: Record<string, string> | undefined
300
- const seenParams = new Set<string>()
336
+ const seenParams = new Map<string, string>()
301
337
  for(const t of targets) {
302
- const paramName = deriveParamName(t.name)
303
- if(seenParams.has(paramName)) {
338
+ const paramName = resolveParamName(t.name, shortenNames)
339
+ const priorName = seenParams.get(paramName)
340
+ if(priorName !== undefined) {
304
341
  throw new Error(
305
- `two targets resolve to the same param "${paramName}"; `
306
- + 'give them distinct names',
342
+ `targets "${priorName}" and "${t.name}" both resolve to the same `
343
+ + `param name "${paramName}"`
344
+ + (shortenNames
345
+ ? ' (names are shortened to their last alphanumeric token) — '
346
+ + 'give them more distinct names, for example '
347
+ + '"firstName"/"surname" instead of "first_name"/"last_name"'
348
+ : ' — give them distinct names, or drop `shortenNames: false` '
349
+ + 'to auto-derive distinct ones'),
307
350
  )
308
351
  }
309
352
 
310
- seenParams.add(paramName)
353
+ seenParams.set(paramName, t.name)
311
354
  // Precedence: explicit per-target hash → explicit request-wide hash →
312
355
  // the anti-leak floor (force-OPRF values whose SHAPE is unambiguous PII).
313
356
  // The model makes the contextual calls (which named fields are sensitive)
@@ -1,28 +1,41 @@
1
1
  /**
2
- * Guidance for authoring page-injection scripts (`webSettings.jsUserScripts` on
3
- * the builder, `customInjection` on old-devtools), surfaced to the authoring
4
- * model from the publish tool description. The prose lives in
5
- * `skill/injection-api.md` so it can be edited as markdown without touching
6
- * code — the same pattern as `mcp/instructions.ts`. The path resolves the same
7
- * from `src/provider` (dev) and `lib/provider` (published): both are two levels
8
- * below the package root.
2
+ * Short field hints for the user-script inputs on both publish tools
3
+ * (`webSettings.jsUserScripts` on the builder, `customInjection` on
4
+ * old-devtools).
9
5
  *
10
- * The guide leads with plain browser JavaScript (navigation/interaction via
11
- * standard APIs; the client already intercepts page requests) and treats
12
- * `window.Reclaim` as a rarely-needed edge-case bridge, pointing at the
13
- * authoritative `src/support/env.d.ts` rather than reproducing its surface.
14
- * It also covers the HAWKEYE-style live-interception path (`injectionType` /
15
- * `interceptorType`, `disableRequestReplay` / `isDocumentRequestReplayEnabled`)
16
- * and `bodySniff`, reverse-engineered from the old-devtools Prisma schema,
17
- * controller code, and dashboard field hints. The replay flag IS settable via
18
- * the publish tools (`disableRequestReplay` on old-devtools — add-version or
19
- * the register follow-up; `interceptorOptions` on the builder), so this is
20
- * recognition + authoring guidance for the remaining internals.
6
+ * These used to inline the whole `skill/injection-api.md` guide (~22 KB) into
7
+ * the tool's input schema. That guide is needed by a small minority of
8
+ * providers, but an input-schema description is sent to the model on EVERY
9
+ * request — it was roughly half the old-devtools tool surface on its own. The
10
+ * prose now lives in `skill/user-script.md`, `skill/interception.md` and
11
+ * `skill/hash-validation.md`, served on demand as `how_it_works` topics; what
12
+ * stays here is the pointer plus the one warning that must be seen BEFORE the
13
+ * field is filled in, since acting on it wrongly is worse than not acting.
14
+ *
15
+ * Shared by both publish tools so the two can't drift apart.
21
16
  */
22
- import { readFileSync } from 'node:fs'
23
- import { fileURLToPath } from 'node:url'
24
17
 
25
- export const INJECTION_GUIDE = readFileSync(
26
- fileURLToPath(new URL('../../skill/injection-api.md', import.meta.url)),
27
- 'utf8',
28
- ).trim()
18
+ /** What the script is and where the real guidance lives. */
19
+ export const USER_SCRIPT_HINT =
20
+ 'Plain JavaScript injected into the provider page before every page load. '
21
+ + 'Use standard browser APIs (window.location, element.click(), forms) to '
22
+ + 'drive login/navigation so the target request fires — the client already '
23
+ + 'intercepts page requests, and window.Reclaim is only for rare edge cases, '
24
+ + 'not navigation. Full guide: how_it_works({ topic: "user-script" }).'
25
+
26
+ /** Prefixes the `allowedJsRequests` / `allowedInjectedRequestData` field. The
27
+ * trap it describes is silent and total — a mis-declared entry makes
28
+ * `verifyProof({ providerId })` reject EVERY real proof — so the warning
29
+ * itself stays in the schema even though its explanation moved to a topic. */
30
+ export const ALLOWED_REQUESTS_HINT =
31
+ '⚠️ Only populate this if you intend to gate '
32
+ + '`verifyProof(proof, { providerId })`: each entry feeds a client-side '
33
+ + 'hash-equality check and its `url` is hashed VERBATIM (no regex/template '
34
+ + 'resolution, even with urlType REGEX/TEMPLATE). If the real captured URL '
35
+ + 'carries ANY per-session part (nonce, CSRF token, cache-busting '
36
+ + 'timestamp), no declared spec can ever hash-match a real proof — '
37
+ + 'populating it then makes verifyProof reject every real proof, which is '
38
+ + 'worse than leaving it empty. Diff the same endpoint\'s captured URL '
39
+ + 'across two sessions first. Mechanics, the body-templating rules and the '
40
+ + 'dangerouslyDisableContentValidation fallback: '
41
+ + 'how_it_works({ topic: "hash-validation" }).'
@@ -6,8 +6,8 @@
6
6
  * index and may not behave correctly when there are fewer matches than
7
7
  * redactions. This is NOT enforced — the author keeps full control over what
8
8
  * they publish in either old-devtools or builder mode. The tools surface these
9
- * strings so the dev can make an informed decision (e.g. add a functional match
10
- * per redaction) rather than being blocked.
9
+ * strings so the dev can make an informed decision (for example, add a
10
+ * functional match per redaction) rather than being blocked.
11
11
  */
12
12
 
13
13
  /** One request's match/redaction counts, with a human label for the message. */
@@ -26,7 +26,7 @@ export function matchRedactionWarnings(requests: RequestCounts[]): string[] {
26
26
  warnings.push(
27
27
  `${req.label}: ${req.matchCount} response match(es) but ` +
28
28
  `${req.redactionCount} response redaction(s). Some verification ` +
29
- 'clients (e.g. the InApp SDK) may not work correctly when ' +
29
+ 'clients (for example, the InApp SDK) may not work correctly when ' +
30
30
  'responseMatches is shorter than responseRedactions — they pair the ' +
31
31
  'two by index. This is allowed; add a functional match per redaction ' +
32
32
  'if you hit verification issues.',