@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
@@ -1,10 +1,10 @@
1
1
  // Debug helper (gated by RECLAIM_AGENT_DUMP_RESPONSE): capture the EXACT
2
2
  // response bytes the attestor validates a claim against, plus a per-match
3
3
  // presence check. A "Response does not contain X" failure means the attestor's
4
- // server-side re-fetch differs from what the browser (replay) saw — e.g. GitHub
5
- // embeds nav/userMenu data for browser requests but not for the attestor's raw
6
- // fetch. Seeing the attestor's actual response is the only way to tell what it
7
- // really returned.
4
+ // server-side re-fetch differs from what the browser (replay) saw — for
5
+ // example, GitHub embeds nav/userMenu data for browser requests but not for the
6
+ // attestor's raw fetch. Seeing the attestor's actual response is the only way
7
+ // to tell what it really returned.
8
8
  //
9
9
  // On each getResponseRedactions call it writes the response to
10
10
  // `attestor-last-response.html` in the process CWD and logs the byte count +,
@@ -1,5 +1,6 @@
1
1
  export type { AttestorClaim, AttestorLogEntry, ProofResult, RunAttestorProofOptions, } from './attestor.ts';
2
2
  export { runAttestorProof, tailLogs, warnsAndErrors } from './attestor.ts';
3
3
  export { buildHttpParams, ensureAttestorCrypto, type ParsedClaim, parseClaim, resolveAttestorUrl, } from './proof-core.ts';
4
+ export { type HttpParams, type HttpSecretParams, type PopcornProveOptions, popcornProve, popcornValidateExtraction, type ValidateExtractionResult, } from './pod-prover.ts';
4
5
  export { ensureZkFiles } from './zk-files.ts';
5
6
  export { createClaimOnAttestor } from '@reclaimprotocol/attestor-core';
@@ -1,5 +1,6 @@
1
1
  export { runAttestorProof, tailLogs, warnsAndErrors } from "./attestor.js";
2
2
  export { buildHttpParams, ensureAttestorCrypto, parseClaim, resolveAttestorUrl, } from "./proof-core.js";
3
+ export { popcornProve, popcornValidateExtraction, } from "./pod-prover.js";
3
4
  export { ensureZkFiles } from "./zk-files.js";
4
5
  // Re-exported attestor-core claim primitive so the agent is the single
5
6
  // integration point with @reclaimprotocol/attestor-core across the builder.
@@ -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, that is, 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, that is, 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
+ }
@@ -0,0 +1,47 @@
1
+ import type { ProviderParams } from '@reclaimprotocol/attestor-core';
2
+ import type { ProofResult } from './attestor.ts';
3
+ /** Attestor HTTP provider params — the recipe the agent synthesis produces. */
4
+ export type HttpParams = ProviderParams<'http'>;
5
+ /** Captured secrets for the replay, exactly as `/reclaim/prove` wants them. */
6
+ export interface HttpSecretParams {
7
+ cookieStr?: string;
8
+ authorisationHeader?: string;
9
+ headers?: Record<string, string>;
10
+ paramValues?: Record<string, string>;
11
+ }
12
+ export interface PopcornProveOptions {
13
+ /** Pod API base (the session's `apiUrl`). */
14
+ apiUrl: string;
15
+ params: HttpParams;
16
+ secretParams: HttpSecretParams;
17
+ /** Session context, stringified into the claim context if given. */
18
+ context?: Record<string, unknown>;
19
+ fetchImpl?: typeof fetch;
20
+ }
21
+ export interface ValidateExtractionResult {
22
+ valid: boolean;
23
+ extractedValue?: string;
24
+ error?: string;
25
+ steps?: string[];
26
+ }
27
+ /**
28
+ * The cloud "replay": validate that a drafted recipe's selectors extract the
29
+ * expected value from a captured response, inside the TEE — `POST {apiUrl}
30
+ * /reclaim/validate-extraction`. Reuses the prover's own redaction engine, so
31
+ * a pass means the selectors won't diverge at proof time. No proof/secrets.
32
+ */
33
+ export declare function popcornValidateExtraction(opts: {
34
+ apiUrl: string;
35
+ responseBody: string;
36
+ expectedValue: string;
37
+ xPath?: string;
38
+ jsonPath?: string;
39
+ regex?: string;
40
+ fetchImpl?: typeof fetch;
41
+ }): Promise<ValidateExtractionResult>;
42
+ /**
43
+ * Prove a single request through the Popcorn TEE. Returns the shared
44
+ * {@link ProofResult}; on any failure `verified` is false and `error` carries
45
+ * the reason (never throws for an expected proof failure).
46
+ */
47
+ export declare function popcornProve(opts: PopcornProveOptions): Promise<ProofResult>;
@@ -0,0 +1,155 @@
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
+ /**
36
+ * The cloud "replay": validate that a drafted recipe's selectors extract the
37
+ * expected value from a captured response, inside the TEE — `POST {apiUrl}
38
+ * /reclaim/validate-extraction`. Reuses the prover's own redaction engine, so
39
+ * a pass means the selectors won't diverge at proof time. No proof/secrets.
40
+ */
41
+ export async function popcornValidateExtraction(opts) {
42
+ const doFetch = opts.fetchImpl ?? fetch;
43
+ const base = opts.apiUrl.replace(/\/$/, '');
44
+ try {
45
+ const res = await doFetch(`${base}/reclaim/validate-extraction`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({
49
+ responseBody: opts.responseBody,
50
+ expectedValue: opts.expectedValue,
51
+ ...(opts.xPath ? { xPath: opts.xPath } : {}),
52
+ ...(opts.jsonPath ? { jsonPath: opts.jsonPath } : {}),
53
+ ...(opts.regex ? { regex: opts.regex } : {}),
54
+ }),
55
+ });
56
+ if (!res.ok) {
57
+ const detail = await res.text().catch(() => '');
58
+ return { valid: false, error: `${res.status}: ${detail.slice(0, 300)}` };
59
+ }
60
+ const body = (await res.json());
61
+ return body;
62
+ }
63
+ catch (err) {
64
+ const error = err instanceof Error ? err.message : String(err);
65
+ return { valid: false, error };
66
+ }
67
+ }
68
+ /** Decode standard base64 (Go `base64.StdEncoding` from Popcorn) to bytes. */
69
+ function b64ToBytes(b64) {
70
+ return new Uint8Array(Buffer.from(b64, 'base64'));
71
+ }
72
+ /**
73
+ * Prove a single request through the Popcorn TEE. Returns the shared
74
+ * {@link ProofResult}; on any failure `verified` is false and `error` carries
75
+ * the reason (never throws for an expected proof failure).
76
+ */
77
+ export async function popcornProve(opts) {
78
+ const { apiUrl, params, secretParams, context } = opts;
79
+ const doFetch = opts.fetchImpl ?? fetch;
80
+ const base = apiUrl.replace(/\/$/, '');
81
+ const providerParamsJson = JSON.stringify({
82
+ name: 'http',
83
+ params,
84
+ secretParams,
85
+ ...(context ? { context: JSON.stringify(context) } : {}),
86
+ });
87
+ const fail = (error) => ({
88
+ proof: undefined,
89
+ extractedValue: '',
90
+ extractedParameters: {},
91
+ verified: false,
92
+ error,
93
+ });
94
+ let resp;
95
+ let raw = '';
96
+ try {
97
+ const res = await doFetch(`${base}/reclaim/prove`, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({ provider_params_json: providerParamsJson }),
101
+ });
102
+ // Read the body as text first: a proof failure/timeout is a 500 whose
103
+ // `message` we want to surface verbatim, and on an unexpected 200 shape we
104
+ // echo the raw body instead of a blind "no claim/signature".
105
+ raw = await res.text().catch(() => '');
106
+ if (!res.ok) {
107
+ return fail(`/reclaim/prove ${res.status}: ${raw.slice(0, 300)}`);
108
+ }
109
+ resp = JSON.parse(raw);
110
+ }
111
+ catch (err) {
112
+ return fail(err instanceof Error ? err.message : String(err));
113
+ }
114
+ const claim = resp.claim;
115
+ const sig = resp.signature;
116
+ if (!claim || !sig?.attestor_address || !sig?.claim_signature) {
117
+ return fail('popcorn /reclaim/prove returned no claim/signature: '
118
+ + raw.slice(0, 300));
119
+ }
120
+ // claim.context is the JSON string carrying { extractedParameters, ... } —
121
+ // the same place the in-process path reads them from.
122
+ let extractedParameters = {};
123
+ if (typeof claim.context === 'string') {
124
+ try {
125
+ const ctx = JSON.parse(claim.context);
126
+ extractedParameters = ctx.extractedParameters ?? {};
127
+ }
128
+ catch {
129
+ // leave empty if context isn't valid JSON
130
+ }
131
+ }
132
+ const attestorClaim = {
133
+ claim: {
134
+ provider: claim.provider ?? '',
135
+ parameters: claim.parameters ?? '',
136
+ owner: claim.owner ?? '',
137
+ timestampS: claim.timestamp_s ?? 0,
138
+ context: claim.context ?? '',
139
+ identifier: claim.identifier ?? '',
140
+ epoch: claim.epoch ?? 0,
141
+ },
142
+ attestorAddress: sig.attestor_address,
143
+ // Consumer-side verification expects hex; Popcorn signs in base64.
144
+ claimSignature: '0x' + bytesToHex(b64ToBytes(sig.claim_signature)),
145
+ };
146
+ return {
147
+ proof: resp,
148
+ verified: true,
149
+ extractedParameters,
150
+ extractedValue: Object.values(extractedParameters)[0] ?? '',
151
+ ...(claim.identifier ? { identifier: claim.identifier } : {}),
152
+ ...(claim.owner ? { owner: claim.owner } : {}),
153
+ attestorClaim,
154
+ };
155
+ }
@@ -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') {
@@ -137,9 +137,10 @@ export function elementAnchor(html, targets) {
137
137
  /** The tightest element that DIRECTLY holds a target. Prefers a COMPLETE-value
138
138
  * occurrence — an attribute whose value IS the target, or a text child that IS
139
139
  * the target — over one where the target is merely embedded in a longer string
140
- * (e.g. a userid inside a `1234567+user@…` noreply address). The complete
141
- * field is the semantic source and yields a portable anchor; the embedded one
142
- * is incidental. Smallest byte span wins within the chosen tier. */
140
+ * (for example, a userid inside a `1234567+user@…` noreply address). The
141
+ * complete field is the semantic source and yields a portable anchor; the
142
+ * embedded one is incidental. Smallest byte span wins within the chosen
143
+ * tier. */
143
144
  function findElement(root, targets) {
144
145
  const complete = [];
145
146
  const embedded = [];
@@ -196,8 +197,8 @@ function* candidateXPaths(el) {
196
197
  }
197
198
  // A uniquely-identified DIRECT child pins the parent without a positional
198
199
  // path (`//span[span[@id="labels"]]`). Self-verification discards it when the
199
- // child id/attr isn't unique — e.g. GitHub reuses `id="labels"` per email row
200
- // — so it never silently matches the wrong node.
200
+ // child id/attr isn't unique — for example, GitHub reuses `id="labels"` per
201
+ // email row — so it never silently matches the wrong node.
201
202
  for (const child of el.children) {
202
203
  if (!isTag(child)) {
203
204
  continue;
@@ -234,8 +235,9 @@ function distinctiveClass(cls) {
234
235
  .filter(Boolean)
235
236
  .sort((x, y) => y.length - x.length)[0];
236
237
  }
237
- /** Positional path from `anc` (exclusive) down to `el` (inclusive), e.g.
238
- * `/ul[1]/li[3]/a[1]`; empty string when `anc` is not an ancestor of `el`. */
238
+ /** Positional path from `anc` (exclusive) down to `el` (inclusive), for
239
+ * example `/ul[1]/li[3]/a[1]`. Empty string when `anc` is not an ancestor
240
+ * of `el`. */
239
241
  function relativeSteps(anc, el) {
240
242
  const parts = [];
241
243
  let node = el;
@@ -311,8 +313,8 @@ function absolutePath(el) {
311
313
  }
312
314
  /** True when `expression` selects EXACTLY `el` (one match) against `doc`. The
313
315
  * attestor applies the redaction regex to EVERY xPath match and throws if any
314
- * fails, so a non-unique selector (e.g. a class shared by sibling rows) is
315
- * unusable — it must resolve to the single target element. */
316
+ * fails, so a non-unique selector (for example, a class shared by sibling
317
+ * rows) is unusable — it must resolve to the single target element. */
316
318
  function matchesUniquely(doc, expression, el) {
317
319
  try {
318
320
  const engine = xpath;
@@ -14,7 +14,8 @@ export interface DraftTarget {
14
14
  }
15
15
  /** The realized extraction for one target — its value paired with the
16
16
  * redaction that reveals it (absent when matched literally). Lets a consumer
17
- * validate each value against its own selector (e.g. the cloud replay). */
17
+ * validate each value against its own selector (for example, the cloud
18
+ * replay). */
18
19
  export interface Extraction {
19
20
  name: string;
20
21
  value: string;
@@ -30,12 +31,14 @@ export interface DraftResult {
30
31
  * match or redaction — either a neighbouring target's value or a target's own
31
32
  * value left un-templated. The matcher builds each target's window
32
33
  * independently and drops the per-user WORDS it can see, but it cannot know a
33
- * neighbouring value is itself user-specific (e.g. a name sharing a text node
34
- * with an id — a `<b>{{id}}</b>` sitting beside a `<td>{{name}}</td>`).
35
- * Cross-checking every value against every match here catches that: a portable
36
- * provider contains NO captured value as a literal — each appears only as its
37
- * `{{param}}`. Throwing discards the draft so the agent re-captures or
38
- * re-proposes rather than shipping a provider pinned to this one user. */
34
+ * neighbouring value is itself user-specific (for example, a name sharing a
35
+ * text node with an id — a `<b>{{id}}</b>` sitting beside a
36
+ * `<td>{{name}}</td>`). Cross-checking every value against every match here
37
+ * catches
38
+ * that: a portable provider contains NO captured value as a literal — each
39
+ * appears only as its `{{param}}`. Throwing discards the draft so the agent
40
+ * re-captures or re-proposes rather than shipping a provider pinned to this
41
+ * one user. */
39
42
  export declare function assertPortableMatches(matches: ResponseMatch[], redactions: ResponseRedaction[], targets: DraftTarget[]): void;
40
43
  /** Reject a draft that OPRF-hashes a target whose value ALSO appears in the
41
44
  * request URL. The whole point of `hash` is that the attestor never sees
@@ -57,4 +60,6 @@ export declare function assertNoOprfUrlConflict(url: string, targets: DraftTarge
57
60
  * header (same class of problem as {@link assertNoOprfUrlConflict} for
58
61
  * OPRF-hashed targets, which has no such budget at all). */
59
62
  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;
63
+ export declare function draftProvider(req: CapturedRequest, targets: DraftTarget[], name: string, hash?: OprfMode, opts?: {
64
+ shortenNames?: boolean;
65
+ }): DraftResult;
@@ -63,12 +63,14 @@ function sanitizeRequestHeaders(headers) {
63
63
  * match or redaction — either a neighbouring target's value or a target's own
64
64
  * value left un-templated. The matcher builds each target's window
65
65
  * independently and drops the per-user WORDS it can see, but it cannot know a
66
- * neighbouring value is itself user-specific (e.g. a name sharing a text node
67
- * with an id — a `<b>{{id}}</b>` sitting beside a `<td>{{name}}</td>`).
68
- * Cross-checking every value against every match here catches that: a portable
69
- * provider contains NO captured value as a literal — each appears only as its
70
- * `{{param}}`. Throwing discards the draft so the agent re-captures or
71
- * re-proposes rather than shipping a provider pinned to this one user. */
66
+ * neighbouring value is itself user-specific (for example, a name sharing a
67
+ * text node with an id — a `<b>{{id}}</b>` sitting beside a
68
+ * `<td>{{name}}</td>`). Cross-checking every value against every match here
69
+ * catches
70
+ * that: a portable provider contains NO captured value as a literal — each
71
+ * appears only as its `{{param}}`. Throwing discards the draft so the agent
72
+ * re-captures or re-proposes rather than shipping a provider pinned to this
73
+ * one user. */
72
74
  export function assertPortableMatches(matches, redactions, targets) {
73
75
  // A baked email LOCAL-PART ("jane.doe@…") pins the provider to one user
74
76
  // even when only the domain is captured as a param — the classic result of
@@ -192,7 +194,32 @@ function deriveParamName(providerName) {
192
194
  const last = tokens[tokens.length - 1] ?? 'value';
193
195
  return last.toLowerCase();
194
196
  }
195
- export function draftProvider(req, targets, name, hash) {
197
+ /** A `paramName` doubles as a regex NAMED CAPTURE GROUP (`(?<name>...)`) and a
198
+ * `paramValues` object key — both require a plain identifier. Nothing
199
+ * downstream validates this: an invalid name builds a syntactically broken
200
+ * regex that silently fails to extract at verification time instead of
201
+ * erroring here, at draft time, where it's actually fixable. */
202
+ const VALID_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
203
+ /** Resolve a target's `paramName`: shortened (the default — last alphanumeric
204
+ * token, lowercased) or, with `shorten: false`, the name verbatim — the
205
+ * caller's choice, validated so it's still safe as a capture-group
206
+ * identifier. */
207
+ function resolveParamName(targetName, shorten) {
208
+ if (!shorten) {
209
+ if (!VALID_PARAM_NAME.test(targetName)) {
210
+ throw new Error(`target name "${targetName}" isn't safe as a param name with `
211
+ + 'shortenNames:false — it becomes a regex capture-group name AND '
212
+ + 'a paramValues key, so it must start with a letter/underscore and '
213
+ + 'contain only letters, digits, and underscores (we recommend '
214
+ + 'lower_snake_case, for example, "employer_name"). Rename it, or drop '
215
+ + '`shortenNames: false` to auto-derive a safe one.');
216
+ }
217
+ return targetName;
218
+ }
219
+ return deriveParamName(targetName);
220
+ }
221
+ export function draftProvider(req, targets, name, hash, opts) {
222
+ const shortenNames = opts?.shortenNames ?? true;
196
223
  if (!req.responseBody) {
197
224
  throw new Error(`captured request ${req.requestId} has no response body`);
198
225
  }
@@ -207,14 +234,21 @@ export function draftProvider(req, targets, name, hash) {
207
234
  const extractions = [];
208
235
  const effectiveHashes = [];
209
236
  let paramValues;
210
- const seenParams = new Set();
237
+ const seenParams = new Map();
211
238
  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');
239
+ const paramName = resolveParamName(t.name, shortenNames);
240
+ const priorName = seenParams.get(paramName);
241
+ if (priorName !== undefined) {
242
+ throw new Error(`targets "${priorName}" and "${t.name}" both resolve to the same `
243
+ + `param name "${paramName}"`
244
+ + (shortenNames
245
+ ? ' (names are shortened to their last alphanumeric token) — '
246
+ + 'give them more distinct names, for example '
247
+ + '"firstName"/"surname" instead of "first_name"/"last_name"'
248
+ : ' — give them distinct names, or drop `shortenNames: false` '
249
+ + 'to auto-derive distinct ones'));
216
250
  }
217
- seenParams.add(paramName);
251
+ seenParams.set(paramName, t.name);
218
252
  // Precedence: explicit per-target hash → explicit request-wide hash →
219
253
  // the anti-leak floor (force-OPRF values whose SHAPE is unambiguous PII).
220
254
  // The model makes the contextual calls (which named fields are sensitive)