@reclaimprotocol/agent 0.1.36 → 0.1.38

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 (104) hide show
  1. package/README.md +8 -8
  2. package/lib/authoring/backend.d.ts +12 -1
  3. package/lib/authoring/backend.js +3 -0
  4. package/lib/authoring/tools.js +33 -5
  5. package/lib/authoring/util.d.ts +43 -0
  6. package/lib/authoring/util.js +54 -0
  7. package/lib/cdp/chrome-for-testing.d.ts +14 -0
  8. package/lib/cdp/chrome-for-testing.js +43 -0
  9. package/lib/cdp/geo.d.ts +3 -0
  10. package/lib/cdp/geo.js +27 -15
  11. package/lib/cdp/launcher.d.ts +79 -1
  12. package/lib/cdp/launcher.js +244 -24
  13. package/lib/logger.js +3 -1
  14. package/lib/mcp/instructions.js +3 -3
  15. package/lib/mcp/start.js +17 -2
  16. package/lib/mcp/tools/agent/attach.d.ts +6 -0
  17. package/lib/mcp/tools/agent/attach.js +58 -118
  18. package/lib/mcp/tools/agent/build.js +8 -0
  19. package/lib/mcp/tools/agent/capture.js +33 -10
  20. package/lib/mcp/tools/agent/check-attestor.js +15 -7
  21. package/lib/mcp/tools/agent/draft-tools.d.ts +3 -0
  22. package/lib/mcp/tools/agent/draft-tools.js +170 -0
  23. package/lib/mcp/tools/agent/example-app.d.ts +2 -0
  24. package/lib/mcp/tools/agent/example-app.js +167 -0
  25. package/lib/mcp/tools/agent/pod-proof.js +5 -1
  26. package/lib/mcp/tools/agent/proof.d.ts +28 -0
  27. package/lib/mcp/tools/agent/proof.js +72 -0
  28. package/lib/mcp/tools/agent/shared.d.ts +8 -1
  29. package/lib/mcp/tools/agent/shared.js +98 -19
  30. package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
  31. package/lib/mcp/tools/agent/start-integration.js +10 -6
  32. package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
  33. package/lib/mcp/tools/authenticate/authenticate.js +212 -116
  34. package/lib/mcp/tools/authenticate/build.d.ts +2 -2
  35. package/lib/mcp/tools/authenticate/build.js +6 -3
  36. package/lib/mcp/tools/authenticate/builder-access.js +5 -2
  37. package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
  38. package/lib/mcp/tools/authenticate/eth-key.js +29 -5
  39. package/lib/mcp/tools/authenticate/index.d.ts +1 -1
  40. package/lib/mcp/tools/authenticate/index.js +1 -1
  41. package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
  42. package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
  43. package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
  44. package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
  45. package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
  46. package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
  47. package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
  48. package/lib/mcp/tools/authenticate/session-cache.js +95 -4
  49. package/lib/mcp/tools/mode/mode.js +6 -6
  50. package/lib/old/login-cdp.js +2 -3
  51. package/lib/proof/attestor.js +2 -3
  52. package/lib/proof/check-attestor.d.ts +12 -2
  53. package/lib/proof/check-attestor.js +40 -10
  54. package/lib/proof/check-matchers.d.ts +36 -0
  55. package/lib/proof/check-matchers.js +161 -0
  56. package/lib/proof/zk-files.d.ts +50 -11
  57. package/lib/proof/zk-files.js +259 -63
  58. package/lib/provider/draft.d.ts +24 -1
  59. package/lib/provider/draft.js +135 -0
  60. package/lib/provider/matcher.js +171 -4
  61. package/package.json +6 -4
  62. package/skill/SKILL.builder.md +7 -2
  63. package/skill/SKILL.common.md +90 -70
  64. package/skill/SKILL.old.md +3 -3
  65. package/skill/builder-mode.md +35 -5
  66. package/skill/old-mode.md +18 -13
  67. package/skill/start-integration.md +50 -33
  68. package/skill/verification-client-integration.md +21 -6
  69. package/src/authoring/backend.ts +12 -1
  70. package/src/authoring/tools.ts +48 -7
  71. package/src/authoring/util.ts +64 -0
  72. package/src/cdp/chrome-for-testing.ts +66 -0
  73. package/src/cdp/geo.ts +36 -21
  74. package/src/cdp/launcher.ts +328 -24
  75. package/src/logger.ts +3 -1
  76. package/src/mcp/instructions.ts +3 -3
  77. package/src/mcp/start.ts +20 -1
  78. package/src/mcp/tools/agent/attach.ts +65 -128
  79. package/src/mcp/tools/agent/build.ts +9 -0
  80. package/src/mcp/tools/agent/capture.ts +54 -11
  81. package/src/mcp/tools/agent/check-attestor.ts +15 -7
  82. package/src/mcp/tools/agent/draft-tools.ts +234 -0
  83. package/src/mcp/tools/agent/example-app.ts +227 -0
  84. package/src/mcp/tools/agent/pod-proof.ts +5 -1
  85. package/src/mcp/tools/agent/proof.ts +110 -1
  86. package/src/mcp/tools/agent/shared.ts +130 -19
  87. package/src/mcp/tools/agent/start-integration.ts +10 -6
  88. package/src/mcp/tools/authenticate/authenticate.ts +276 -131
  89. package/src/mcp/tools/authenticate/build.ts +6 -3
  90. package/src/mcp/tools/authenticate/builder-access.ts +5 -2
  91. package/src/mcp/tools/authenticate/eth-key.ts +41 -7
  92. package/src/mcp/tools/authenticate/index.ts +1 -1
  93. package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
  94. package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
  95. package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
  96. package/src/mcp/tools/authenticate/session-cache.ts +125 -5
  97. package/src/mcp/tools/mode/mode.ts +6 -6
  98. package/src/old/login-cdp.ts +2 -3
  99. package/src/proof/attestor.ts +2 -3
  100. package/src/proof/check-attestor.ts +55 -12
  101. package/src/proof/check-matchers.ts +227 -0
  102. package/src/proof/zk-files.ts +357 -65
  103. package/src/provider/draft.ts +215 -7
  104. package/src/provider/matcher.ts +237 -4
@@ -15,7 +15,9 @@ import { analyzeTool } from "./analyze.js";
15
15
  import { attachTool } from "./attach.js";
16
16
  import { captureTools, resetCaptures } from "./capture.js";
17
17
  import { checkAttestorTool } from "./check-attestor.js";
18
+ import { draftTools } from "./draft-tools.js";
18
19
  import { listTabsTool } from "./eval.js";
20
+ import { exampleAppTool } from "./example-app.js";
19
21
  import { howItWorksTool } from "./how-it-works.js";
20
22
  import { liveViewTools } from "./live-view.js";
21
23
  import { podProofTools } from "./pod-proof.js";
@@ -73,8 +75,14 @@ export function buildAgentTools(client, builderClient = client, externalState) {
73
75
  for (const t of sharedAuthoringTools(state.captures)) {
74
76
  tools.push(t);
75
77
  }
78
+ // check_matchers / update_draft: offline matcher check + in-place draft
79
+ // edits, keyed by captureId like run_proof_via_pod.
80
+ for (const t of draftTools(state.captures)) {
81
+ tools.push(t);
82
+ }
76
83
  if (client) {
77
84
  tools.push(startIntegrationTool());
85
+ tools.push(exampleAppTool());
78
86
  tools.push(createVersionFromCaptureTool(client, state.attach, state.captures));
79
87
  }
80
88
  return tools;
@@ -1,4 +1,5 @@
1
1
  import { makeDraftStore, } from "../../../authoring/index.js";
2
+ import { DEFAULT_MAX_CHARS, TEXT_WINDOW_PROPERTIES, textWindow, } from "../../../authoring/util.js";
2
3
  import { startCapture } from "../../../cdp/capture.js";
3
4
  import { defineTool } from "../../server.js";
4
5
  export async function resetCaptures(captures) {
@@ -57,21 +58,27 @@ export function captureTools(attachRef, captures) {
57
58
  });
58
59
  const getTool = defineTool({
59
60
  name: 'get_request',
60
- description: 'Returns the full headers and bodies of one captured request, to '
61
- + 'inspect a candidate that `find_requests_containing` ranked. Unlike '
62
- + 'the other tools, headers are raw: cookie and authorization values '
63
- + 'are visible. Read them for diagnosis; do not repeat them to the '
64
- + 'developer or write them to a file.',
61
+ description: 'Returns the headers and bodies of one captured request, to inspect '
62
+ + 'a candidate that `find_requests_containing` ranked. Each body is '
63
+ + `cut to ${DEFAULT_MAX_CHARS} characters by default; a cut body `
64
+ + 'comes with `requestBodyTruncation` or `responseBodyTruncation` '
65
+ + '(total size and `nextOffset`). Page with `offset`, or pass '
66
+ + '`maxChars` or `full`. Unlike the other tools, headers are raw: '
67
+ + 'cookie and authorization values are visible. Read them for '
68
+ + 'diagnosis; do not repeat them to the developer or write them to '
69
+ + 'a file.',
65
70
  inputSchema: {
66
71
  type: 'object',
67
72
  properties: {
68
73
  captureId: { type: 'string' },
69
74
  requestId: { type: 'string' },
75
+ ...TEXT_WINDOW_PROPERTIES,
70
76
  },
71
77
  required: ['captureId', 'requestId'],
72
78
  },
73
79
  annotations: { readOnlyHint: true, openWorldHint: false },
74
- }, async ({ captureId, requestId }) => {
80
+ }, async (args) => {
81
+ const { captureId, requestId } = args;
75
82
  const entry = captures.sessions.get(captureId);
76
83
  if (!entry) {
77
84
  throw new Error(`no capture ${captureId}`);
@@ -80,11 +87,27 @@ export function captureTools(attachRef, captures) {
80
87
  if (!r) {
81
88
  throw new Error(`no request ${requestId} in capture ${captureId}`);
82
89
  }
90
+ const { requestBody, responseBody, ...meta } = r;
83
91
  const body = entry.session.bodies.get(requestId);
84
- if (!body.available) {
85
- return { ...r, bodyAvailable: false, reason: body.reason };
86
- }
87
- return { ...r, responseBody: body.body, bodyAvailable: true };
92
+ const result = {
93
+ ...meta,
94
+ ...windowed('requestBody', requestBody, args),
95
+ ...windowed('responseBody', body.available ? body.body : responseBody, args),
96
+ };
97
+ return body.available
98
+ ? { ...result, bodyAvailable: true }
99
+ : { ...result, bodyAvailable: false, reason: body.reason };
88
100
  });
89
101
  return [startTool, stopTool, getTool];
90
102
  }
103
+ /** `{ [key]: text window }`, plus `[key]Truncation` when the window cut it. */
104
+ function windowed(key, text, args) {
105
+ if (text === undefined) {
106
+ return {};
107
+ }
108
+ const { text: part, truncation } = textWindow(text, args);
109
+ return {
110
+ [key]: part,
111
+ ...(truncation ? { [`${key}Truncation`]: truncation } : {}),
112
+ };
113
+ }
@@ -11,13 +11,21 @@ export function checkAttestorTool(check = checkAttestor) {
11
11
  + 'seconds (about 8 when healthy). Call it once when you set up a '
12
12
  + 'new provider build, and after `run_proof` fails with a network '
13
13
  + 'error. Returns `{ ok, attestorUrl, durationMs, '
14
- + 'extractedParameters, error, errorKind }`. If `ok` is true, the '
15
- + 'attestor is fine and a failing proof points to the target site. '
16
- + 'If `errorKind` is `attestor-unreachable`, this machine cannot '
17
- + 'reach the attestor (network, VPN, firewall, or proxy): tell the '
18
- + 'developer. If it is `setup-failed`, the ZK file download failed '
19
- + 'before the attestor was contacted: tell the developer to check '
20
- + 'network access to the circuit download. If it is '
14
+ + 'extractedParameters, error, errorKind, proofFiles }`. '
15
+ + '`proofFiles` is the proof file download state: `{ status }` is '
16
+ + '`idle`, `ready`, `downloading` (with `method`, `elapsedMs`, and '
17
+ + '`bytesDownloaded`/`totalBytes` when known), or `failed` (with '
18
+ + '`reason`). If `ok` is true, the attestor is fine and a failing '
19
+ + 'proof points to the target site. If `errorKind` is '
20
+ + '`proof-files-downloading`, the one-time proof file download is '
21
+ + 'still running and the attestor was not contacted: call this tool '
22
+ + 'again shortly; do not report a network problem. If it is '
23
+ + '`proof-files-failed`, the download failed: tell the developer '
24
+ + '`error` (usually no access to github.com or '
25
+ + 'raw.githubusercontent.com). If it is `attestor-unreachable`, the '
26
+ + 'proof files are ready but this machine cannot reach the attestor '
27
+ + '(network, VPN, firewall, or proxy): tell the developer. If it is '
28
+ + '`setup-failed`, other local setup failed: report `error`. If it is '
21
29
  + '`attestor-error`, the attestor rejected the claim: report '
22
30
  + '`error`.',
23
31
  inputSchema: {
@@ -0,0 +1,3 @@
1
+ import { type RegisteredTool } from '../../server.ts';
2
+ import type { CaptureStore } from './capture.ts';
3
+ export declare function draftTools(captures: CaptureStore): RegisteredTool[];
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `check_matchers` and `update_draft` — inspect and edit a draft's matchers
3
+ * without leaving MCP. `check_matchers` runs a recipe's redactions + matches
4
+ * against a captured response with attestor-core's own engine, offline (see
5
+ * proof/check-matchers.ts); `update_draft` replaces a draft's matchers in place
6
+ * after the same validation and portability gate `propose_provider` applies.
7
+ */
8
+ import { badRequest } from '@hapi/boom';
9
+ import assert from 'node:assert';
10
+ import { checkMatchersOffline } from "../../../proof/check-matchers.js";
11
+ import { editDraftProvider } from "../../../provider/draft.js";
12
+ import { verificationWarnings } from "../../../provider/verification-checks.js";
13
+ import { defineTool } from "../../server.js";
14
+ const MATCHES_SCHEMA = {
15
+ type: 'array',
16
+ minItems: 1,
17
+ items: {
18
+ type: 'object',
19
+ properties: {
20
+ type: { type: 'string', enum: ['contains', 'regex'] },
21
+ value: { type: 'string' },
22
+ invert: { type: 'boolean' },
23
+ },
24
+ required: ['type', 'value'],
25
+ },
26
+ };
27
+ const REDACTIONS_SCHEMA = {
28
+ type: 'array',
29
+ items: {
30
+ type: 'object',
31
+ properties: {
32
+ xPath: { type: 'string' },
33
+ jsonPath: { type: 'string' },
34
+ regex: { type: 'string' },
35
+ hash: { type: 'string', enum: ['oprf', 'oprf-raw', 'oprf-mpc'] },
36
+ },
37
+ },
38
+ };
39
+ const PARAM_VALUES_SCHEMA = {
40
+ type: 'object',
41
+ additionalProperties: { type: 'string' },
42
+ };
43
+ export function draftTools(captures) {
44
+ const checkMatchers = defineTool({
45
+ name: 'check_matchers',
46
+ description: 'Checks a draft\'s `responseRedactions` and `responseMatches` '
47
+ + 'against the captured response, offline, with the attestor\'s own '
48
+ + 'redaction and match engine. No network call. Returns `ok`, '
49
+ + '`failedAt` and `reason` on failure, `extractedParameters`, and the '
50
+ + '`revealed` body slices the attestor would see (truncated). Pass '
51
+ + '`responseMatches`, `responseRedactions`, or `paramValues` to try '
52
+ + 'an edit without saving it, and `requestId` to check against a '
53
+ + 'different captured request. Run it before `run_proof`.',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ captureId: { type: 'string' },
58
+ draftId: { type: 'string' },
59
+ requestId: {
60
+ type: 'string',
61
+ description: 'Captured request whose response to check. '
62
+ + 'Defaults to the request the draft was proposed from.',
63
+ },
64
+ responseMatches: MATCHES_SCHEMA,
65
+ responseRedactions: REDACTIONS_SCHEMA,
66
+ paramValues: PARAM_VALUES_SCHEMA,
67
+ },
68
+ required: ['captureId', 'draftId'],
69
+ },
70
+ annotations: { readOnlyHint: true, openWorldHint: false },
71
+ }, async (args) => {
72
+ const { entry, draft } = resolveDraft(captures, args);
73
+ const provider = hasEdit(args)
74
+ ? editDraftProvider(draft.provider, draft.extractions, args).provider
75
+ : draft.provider;
76
+ return checkCaptured(entry, args.requestId ?? draft.requestId, provider);
77
+ });
78
+ const updateDraft = defineTool({
79
+ name: 'update_draft',
80
+ description: 'Replaces a draft\'s `responseMatches`, `responseRedactions`, '
81
+ + '`paramValues`, or `geoLocation` in place; the `draftId` stays '
82
+ + 'the same. Validates '
83
+ + 'regexes and selectors and rejects a matcher that bakes a captured '
84
+ + 'value in as a literal, or redactions that reveal more of the '
85
+ + 'captured response than the draft did. Returns the secret-free '
86
+ + 'provider and, unless `check` is false, a `check_matchers` '
87
+ + 'result for the edit. '
88
+ + 'Prefer `propose_provider` with a better request or target; use '
89
+ + 'this to narrow or fix what it drafted.',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ captureId: { type: 'string' },
94
+ draftId: { type: 'string' },
95
+ responseMatches: MATCHES_SCHEMA,
96
+ responseRedactions: REDACTIONS_SCHEMA,
97
+ paramValues: PARAM_VALUES_SCHEMA,
98
+ geoLocation: {
99
+ type: 'string',
100
+ description: 'ISO country the published version\'s request goes out from, '
101
+ + 'for example `US`, or `{{DYNAMIC_GEO}}` (the default) for '
102
+ + 'the verifying user\'s own country. Pin a country when '
103
+ + 'run_proof only verified with that `geoLocation`.',
104
+ },
105
+ check: {
106
+ type: 'boolean',
107
+ description: 'Default true: run `check_matchers` on the edit.',
108
+ },
109
+ },
110
+ required: ['captureId', 'draftId'],
111
+ },
112
+ annotations: { openWorldHint: false, destructiveHint: false },
113
+ }, async (args) => {
114
+ const { entry, draft } = resolveDraft(captures, args);
115
+ assert(hasEdit(args), badRequest('Pass at least one of responseMatches, responseRedactions, '
116
+ + 'paramValues, or geoLocation.'));
117
+ const edited = editDraftProvider(draft.provider, draft.extractions, args);
118
+ // Redactions are what the attestor doesn't see, so an edit may narrow
119
+ // them but never reveal more of the captured response than the draft.
120
+ const before = checkCaptured(entry, draft.requestId, draft.provider);
121
+ const check = checkCaptured(entry, draft.requestId, edited.provider);
122
+ assert(check.failedAt !== 'request' && check.failedAt !== 'redaction', badRequest('the edited recipe doesn\'t apply to the captured response '
123
+ + `(${check.failedAt}): ${check.reason}`));
124
+ const comparable = before.failedAt !== 'request'
125
+ && before.failedAt !== 'redaction';
126
+ assert(!comparable || check.revealedBytes <= before.revealedBytes, badRequest(`the edit reveals ${check.revealedBytes} bytes of the response `
127
+ + `to the attestor, more than the draft's ${before.revealedBytes}. `
128
+ + 'Keep redactions that hide everything except the proved values.'));
129
+ // The recipe changed, so an earlier replay no longer describes it.
130
+ const { lastReplay: _stale, ...rest } = draft;
131
+ void _stale;
132
+ entry.drafts.set(args.draftId, {
133
+ ...rest,
134
+ provider: edited.provider,
135
+ extractions: edited.extractions,
136
+ });
137
+ const verification = verificationWarnings(edited.provider);
138
+ return {
139
+ draftId: args.draftId,
140
+ provider: edited.provider,
141
+ secretRefs: draft.secretRefs,
142
+ ...(edited.warnings.length ? { warnings: edited.warnings } : {}),
143
+ ...(verification.length
144
+ ? { verificationWarnings: verification }
145
+ : {}),
146
+ ...(args.check === false ? {} : { check }),
147
+ };
148
+ });
149
+ return [checkMatchers, updateDraft];
150
+ }
151
+ function resolveDraft(captures, { captureId, draftId }) {
152
+ const entry = captures.sessions.get(captureId);
153
+ assert(entry, badRequest(`no capture ${captureId} — call start_capture first `
154
+ + '(it has been reset or never started).'));
155
+ const draft = entry.drafts.get(draftId);
156
+ assert(draft, badRequest(`no draft ${draftId} in capture ${captureId}`));
157
+ return { entry, draft };
158
+ }
159
+ function hasEdit(edit) {
160
+ return edit.responseMatches !== undefined
161
+ || edit.responseRedactions !== undefined
162
+ || edit.paramValues !== undefined
163
+ || edit.geoLocation !== undefined;
164
+ }
165
+ function checkCaptured(entry, requestId, provider) {
166
+ const captured = entry.session.requests.get(requestId);
167
+ assert(captured, badRequest(`no captured request ${requestId}`));
168
+ assert(captured.responseBody, badRequest(`response body unavailable for ${requestId}`));
169
+ return { requestId, ...checkMatchersOffline(provider, captured) };
170
+ }
@@ -0,0 +1,2 @@
1
+ import { type RegisteredTool } from '../../server.ts';
2
+ export declare function exampleAppTool(fetchFn?: typeof fetch): RegisteredTool;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `create_example_app` — write reclaimprotocol/builder-example into the
3
+ * developer's workspace, so a project with no app starts from a ready-to-run
4
+ * backend and frontend instead of agent-written code. The MCP server fetches
5
+ * the files itself (each checked against its git blob hash) and writes them at
6
+ * the workspace root: the coding agent's later `npm install` and `npm run dev`
7
+ * then run in the workspace it started in, not in a clone. The backend's
8
+ * `@reclaimprotocol/client` is set to the latest published version.
9
+ */
10
+ import { badGateway, badRequest, conflict } from '@hapi/boom';
11
+ import assert from 'node:assert';
12
+ import { createHash } from 'node:crypto';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
15
+ import { dirname, isAbsolute, join } from 'node:path';
16
+ import { defineTool } from "../../server.js";
17
+ const REPO = 'reclaimprotocol/builder-example';
18
+ const BACKEND_PACKAGE = join('packages', 'backend', 'package.json');
19
+ const CLIENT_PACKAGE = '@reclaimprotocol/client';
20
+ /** The example is ~50 small files; anything far past that isn't it. */
21
+ const MAX_FILES = 500;
22
+ const MAX_BYTES = 10000000;
23
+ export function exampleAppTool(fetchFn = fetch) {
24
+ return defineTool({
25
+ name: 'create_example_app',
26
+ description: 'Writes the Reclaim example app (reclaimprotocol/builder-example: '
27
+ + 'a Fastify backend and a Vite frontend that create sessions, '
28
+ + 'verify callbacks, and render every verification state) into '
29
+ + '`projectDir`, and sets its backend to the latest '
30
+ + '`@reclaimprotocol/client`. Use it when the workspace has no app '
31
+ + 'yet, with the workspace root you were started in as '
32
+ + '`projectDir`; don\'t clone the repository yourself. Refuses '
33
+ + 'when a file it would write already exists with different '
34
+ + 'content. Returns the commit, file count, client version, and '
35
+ + 'next steps.',
36
+ inputSchema: {
37
+ type: 'object',
38
+ required: ['projectDir'],
39
+ properties: {
40
+ projectDir: {
41
+ type: 'string',
42
+ description: 'Absolute path of the workspace root you were started in.',
43
+ },
44
+ },
45
+ },
46
+ annotations: { openWorldHint: true, destructiveHint: false },
47
+ }, async ({ projectDir }) => {
48
+ assert(isAbsolute(projectDir), badRequest('`projectDir` must be an absolute path.'));
49
+ const commit = await latestCommit();
50
+ const files = await listFiles(commit);
51
+ const clientVersion = await latestClientVersion();
52
+ const staged = await Promise.all(files.map(async (file) => {
53
+ const bytes = await download(commit, file);
54
+ return {
55
+ path: file.path,
56
+ bytes: file.path === BACKEND_PACKAGE
57
+ ? withClientVersion(bytes, clientVersion)
58
+ : bytes,
59
+ };
60
+ }));
61
+ const conflicts = staged
62
+ .filter(({ path, bytes }) => {
63
+ const dest = join(projectDir, path);
64
+ return existsSync(dest) && !readFileSync(dest).equals(bytes);
65
+ })
66
+ .map(({ path }) => path);
67
+ assert(!conflicts.length, conflict('These files already exist with different content: '
68
+ + `${conflicts.join(', ')}. Ask the developer whether to use `
69
+ + 'another directory or move them.'));
70
+ // Stage inside projectDir, so the moves below stay on one filesystem.
71
+ const tmp = await mkdtemp(join(projectDir, '.reclaim-example-'));
72
+ try {
73
+ for (const { path, bytes } of staged) {
74
+ await mkdir(dirname(join(tmp, path)), { recursive: true });
75
+ await writeFile(join(tmp, path), bytes);
76
+ }
77
+ for (const { path } of staged) {
78
+ const dest = join(projectDir, path);
79
+ await mkdir(dirname(dest), { recursive: true });
80
+ await rename(join(tmp, path), dest);
81
+ }
82
+ }
83
+ finally {
84
+ await rm(tmp, { recursive: true, force: true });
85
+ }
86
+ return {
87
+ projectDir,
88
+ repository: `https://github.com/${REPO}`,
89
+ commit,
90
+ files: staged.length,
91
+ clientVersion,
92
+ next: [
93
+ 'Call issue_org_token with this projectDir and envFile '
94
+ + '"packages/backend/.env".',
95
+ 'Run `npm install`, then `npm run dev`, from projectDir. The '
96
+ + 'backend listens on port 3000; the app opens at '
97
+ + 'http://localhost:5173.',
98
+ 'Call proxy_callback_events({ port: 3000, path: '
99
+ + '"/reclaim/callback" }).',
100
+ 'Put the provider in packages/backend/config.json and restart '
101
+ + 'the backend.',
102
+ ],
103
+ };
104
+ });
105
+ async function latestCommit() {
106
+ const res = await fetchFn(`https://api.github.com/repos/${REPO}/commits/main`, { headers: { Accept: 'application/vnd.github.sha' } });
107
+ assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`));
108
+ const text = await res.text();
109
+ const commit = text.trim();
110
+ assert(/^[0-9a-f]{40}$/.test(commit), badGateway('invalid commit SHA'));
111
+ return commit;
112
+ }
113
+ async function listFiles(commit) {
114
+ const res = await fetchFn(`https://api.github.com/repos/${REPO}/git/trees/${commit}?recursive=1`, { headers: { Accept: 'application/vnd.github+json' } });
115
+ assert(res.ok, badGateway(`${REPO} file listing: HTTP ${res.status}`));
116
+ const tree = await res.json();
117
+ assert(typeof tree === 'object' && tree !== null && 'tree' in tree
118
+ && Array.isArray(tree.tree) && !('truncated' in tree && tree.truncated), badGateway(`unexpected ${REPO} file listing`));
119
+ const files = tree.tree
120
+ .filter((e) => {
121
+ return typeof e === 'object' && e !== null && 'type' in e
122
+ && e.type === 'blob';
123
+ })
124
+ .map((entry) => {
125
+ assert(typeof entry === 'object' && entry !== null
126
+ && 'path' in entry && typeof entry.path === 'string'
127
+ && entry.path.split('/').every((s) => /^[\w@.-]+$/.test(s)
128
+ && s !== '.' && s !== '..' && s !== '.git')
129
+ && 'sha' in entry && typeof entry.sha === 'string'
130
+ && 'size' in entry && typeof entry.size === 'number', badGateway(`unexpected entry in the ${REPO} file listing`));
131
+ return { path: entry.path, sha: entry.sha, size: entry.size };
132
+ });
133
+ assert(files.length > 0 && files.length <= MAX_FILES
134
+ && files.reduce((n, f) => n + f.size, 0) <= MAX_BYTES, badGateway(`unexpected ${REPO} size`));
135
+ return files;
136
+ }
137
+ async function download(commit, file) {
138
+ const res = await fetchFn(`https://raw.githubusercontent.com/${REPO}/${commit}/${file.path}`);
139
+ assert(res.ok, badGateway(`${file.path}: HTTP ${res.status}`));
140
+ const bytes = Buffer.from(await res.arrayBuffer());
141
+ assert(gitBlobSha(bytes) === file.sha, badGateway(`${file.path} failed its integrity check`));
142
+ return bytes;
143
+ }
144
+ async function latestClientVersion() {
145
+ const res = await fetchFn(`https://registry.npmjs.org/${CLIENT_PACKAGE.replace('/', '%2f')}/latest`);
146
+ assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`));
147
+ const body = await res.json();
148
+ assert(typeof body === 'object' && body !== null && 'version' in body
149
+ && typeof body.version === 'string'
150
+ && /^\d+\.\d+\.\d+$/.test(body.version), badGateway(`npm ${CLIENT_PACKAGE}: no version`));
151
+ return body.version;
152
+ }
153
+ }
154
+ /** The backend's package.json with the client range set to `^version`. */
155
+ function withClientVersion(bytes, version) {
156
+ const text = bytes.toString('utf8');
157
+ const next = text.replace(/("@reclaimprotocol\/client"\s*:\s*")[^"]*(")/, (_, open, close) => `${open}^${version}${close}`);
158
+ assert(next !== text || text.includes(`"^${version}"`), badGateway(`${BACKEND_PACKAGE} has no ${CLIENT_PACKAGE} dependency`));
159
+ return Buffer.from(next);
160
+ }
161
+ /** Git's blob object id: sha1 of `blob <size>\0<content>`. */
162
+ function gitBlobSha(bytes) {
163
+ return createHash('sha1')
164
+ .update(`blob ${bytes.length}\0`)
165
+ .update(bytes)
166
+ .digest('hex');
167
+ }
@@ -27,7 +27,10 @@ export function podProofTools(attachRef, captures) {
27
27
  + 'instead of `run_proof` when the attach mode is "builder": the '
28
28
  + 'proof then reaches the target from the browser\'s own exit IP and '
29
29
  + 'country. Needs no `ownerAddress`; the pod signs with its own TEE '
30
- + 'key.',
30
+ + 'key. It matches the browser\'s exit IP and country, not '
31
+ + 'necessarily its TLS fingerprint, so it is not a way past bot '
32
+ + 'protection: if `run_proof` reported '
33
+ + '`blocked-non-browser-client`, expect the same here.',
31
34
  inputSchema: {
32
35
  type: 'object',
33
36
  properties: {
@@ -54,6 +57,7 @@ export function podProofTools(attachRef, captures) {
54
57
  return shapeRichProofResult(result, {
55
58
  provider: draft.provider,
56
59
  hasSecrets: Object.keys(draft.secrets).length > 0,
60
+ ...(draft.lastReplay ? { lastReplay: draft.lastReplay } : {}),
57
61
  });
58
62
  });
59
63
  return [tool];
@@ -11,7 +11,35 @@ export declare function shapeRichProofResult(result: ProofResult, opts: {
11
11
  provider: unknown;
12
12
  hasSecrets: boolean;
13
13
  attestorUrl?: string;
14
+ /** The draft's latest authenticated in-browser replay, when tracked. */
15
+ lastReplay?: {
16
+ status: number;
17
+ ok: boolean;
18
+ };
19
+ /** Where the attestor's request went out from. */
20
+ egress?: {
21
+ geoLocation?: string;
22
+ };
14
23
  }): Record<string, unknown>;
24
+ export interface BlockedClientDiagnosis {
25
+ kind: 'blocked-non-browser-client';
26
+ /** HTTP status the attestor's request got, when the error carries one. */
27
+ status?: number;
28
+ /** Set when the same draft's in-browser replay succeeded. */
29
+ replayedInBrowser?: true;
30
+ likelyCause: string;
31
+ next: string;
32
+ }
33
+ /**
34
+ * Says when a failed proof looks like the site blocking non-browser clients:
35
+ * the attestor's request got 403/406/429/503, or a tiny or challenge-looking
36
+ * page where the browser got the real one. Returns undefined for verified
37
+ * proofs and other failures. The raw `error` stays on the result.
38
+ */
39
+ export declare function diagnoseBlockedClient(result: Pick<ProofResult, 'verified' | 'error'>, lastReplay?: {
40
+ status: number;
41
+ ok: boolean;
42
+ }, geoLocation?: string): BlockedClientDiagnosis | undefined;
15
43
  export interface ProofDiagnosis {
16
44
  attestor: AttestorCheckResult;
17
45
  likelyCause: string;
@@ -117,6 +117,10 @@ export function shapeRichProofResult(result, opts) {
117
117
  if (result.errorKind !== undefined) {
118
118
  out.errorKind = result.errorKind;
119
119
  }
120
+ const blocked = diagnoseBlockedClient(result, opts.lastReplay, opts.egress?.geoLocation);
121
+ if (blocked !== undefined) {
122
+ out.diagnosis = blocked;
123
+ }
120
124
  // Trim the LLM-visible logs — full stream goes to runLogPath.
121
125
  if (result.attestorLogs !== undefined && result.attestorLogs.length > 0) {
122
126
  const logs = result.verified
@@ -158,6 +162,74 @@ export function shapeRichProofResult(result, opts) {
158
162
  }
159
163
  return out;
160
164
  }
165
+ /** HTTP statuses a site's bot protection answers non-browser clients with. */
166
+ const BLOCKING_STATUSES = new Set([403, 406, 429, 503]);
167
+ /** Markers of a bot-protection / challenge page rather than real content. */
168
+ const CHALLENGE_BODY = new RegExp([
169
+ 'captcha', 'challenge', 'cf-chl', 'cf-ray', 'just a moment',
170
+ 'access denied', 'request unsuccessful', '_incapsula_', 'px-captcha',
171
+ 'perimeterx', 'datadome', 'akamai', 'bot detection', 'enable javascript',
172
+ 'are you a robot', 'unusual traffic',
173
+ ].join('|'), 'i');
174
+ /** Below this, a response the browser rendered with the value in it is
175
+ * likely a shell or challenge page served to a non-browser client. */
176
+ const TINY_BODY_BYTES = 4096;
177
+ /**
178
+ * Says when a failed proof looks like the site blocking non-browser clients:
179
+ * the attestor's request got 403/406/429/503, or a tiny or challenge-looking
180
+ * page where the browser got the real one. Returns undefined for verified
181
+ * proofs and other failures. The raw `error` stays on the result.
182
+ */
183
+ export function diagnoseBlockedClient(result, lastReplay, geoLocation) {
184
+ if (result.verified || !result.error) {
185
+ return undefined;
186
+ }
187
+ const replayedInBrowser = lastReplay?.ok === true;
188
+ const statusMatch = new RegExp('Expected status 2xx, got (\\d{3})|Provider returned error (\\d{3})').exec(result.error);
189
+ const status = Number(statusMatch?.[1] ?? statusMatch?.[2]) || undefined;
190
+ const blockedStatus = status !== undefined && BLOCKING_STATUSES.has(status);
191
+ if (!blockedStatus && !(replayedInBrowser && challengeBody(result.error))) {
192
+ return undefined;
193
+ }
194
+ return {
195
+ kind: 'blocked-non-browser-client',
196
+ ...(status !== undefined ? { status } : {}),
197
+ ...(replayedInBrowser ? { replayedInBrowser: true } : {}),
198
+ likelyCause: (replayedInBrowser
199
+ ? 'The same request succeeded from inside the browser, but '
200
+ : '')
201
+ + 'the site answered the attestor'
202
+ + (geoLocation
203
+ ? `, whose request went out from ${geoLocation}, with `
204
+ : ', whose request went out from its own network, with ')
205
+ + (blockedStatus ? `HTTP ${status}` : 'a challenge or shell page')
206
+ + '. The site blocks traffic from that country, or non-browser '
207
+ + 'clients (bot protection or TLS fingerprinting). Copying headers or '
208
+ + 'cookies will not help.',
209
+ next: 'If the site serves one country (for example, a US airline), retry '
210
+ + 'run_proof once with `geoLocation` set to that country. If that '
211
+ + 'verifies, pin it with update_draft({ geoLocation }) so the '
212
+ + 'published version goes out from there too. Otherwise, try a '
213
+ + 'different endpoint that returns the value, such as a JSON API the '
214
+ + 'page calls (find_requests_containing), then propose and prove '
215
+ + 'that. If none works, stop and report to the developer that this '
216
+ + 'site blocks the attestor. Do not tweak headers.',
217
+ };
218
+ }
219
+ /** True when an attestor error embeds a response element (base64, from a
220
+ * regex redaction that didn't match) that is tiny or a challenge page. */
221
+ function challengeBody(error) {
222
+ const b64 = /does not match found element '([A-Za-z0-9+/=]*)/.exec(error)?.[1];
223
+ if (b64 === undefined) {
224
+ return false;
225
+ }
226
+ // "Tiny" only means something for a whole document; an xPath-selected
227
+ // element is small by design.
228
+ const text = Buffer.from(b64, 'base64').toString('utf8');
229
+ const tinyDocument = Buffer.byteLength(text) < TINY_BODY_BYTES
230
+ && /<!doctype|<html/i.test(text);
231
+ return tinyDocument || CHALLENGE_BODY.test(text);
232
+ }
161
233
  /**
162
234
  * When an unverified proof failed with a network-like error, runs the
163
235
  * attestor check once and says whether the attestor or the target site is the
@@ -1,9 +1,16 @@
1
+ import { runAttestorProof } from '../../../proof/attestor.ts';
1
2
  import { checkAttestor } from '../../../proof/check-attestor.ts';
2
3
  import type { RegisteredTool } from '../../server.ts';
3
4
  import type { CaptureStore } from './capture.ts';
5
+ export interface ProofEgress {
6
+ /** ISO country (or param template) the attestor's request goes out from;
7
+ * absent when it goes out from the attestor's own network. */
8
+ geoLocation?: string;
9
+ source: 'argument' | 'draft' | 'developer-ip' | 'none';
10
+ }
4
11
  /** The shared authoring tools mapped to the MCP's shape: resolve each call's
5
12
  * local backend from `args.captureId`, and widen the input schema — every tool
6
13
  * takes `captureId`; `run_proof` also gets the credential-aware contract. The
7
14
  * tools are authored plain in `../../../authoring`; this only wraps them with
8
15
  * `extendTool` (handlers untouched — loose arg parse keeps `captureId`). */
9
- export declare function sharedAuthoringTools(captures: CaptureStore, check?: typeof checkAttestor): RegisteredTool[];
16
+ export declare function sharedAuthoringTools(captures: CaptureStore, check?: typeof checkAttestor, runProof?: typeof runAttestorProof, lookupCountry?: () => Promise<string | undefined>): RegisteredTool[];