@reclaimprotocol/agent 0.1.36 → 0.1.37

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 (101) 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 +6 -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/pod-proof.js +5 -1
  24. package/lib/mcp/tools/agent/proof.d.ts +28 -0
  25. package/lib/mcp/tools/agent/proof.js +72 -0
  26. package/lib/mcp/tools/agent/shared.d.ts +8 -1
  27. package/lib/mcp/tools/agent/shared.js +98 -19
  28. package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
  29. package/lib/mcp/tools/agent/start-integration.js +10 -6
  30. package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
  31. package/lib/mcp/tools/authenticate/authenticate.js +212 -116
  32. package/lib/mcp/tools/authenticate/build.d.ts +2 -2
  33. package/lib/mcp/tools/authenticate/build.js +6 -3
  34. package/lib/mcp/tools/authenticate/builder-access.js +5 -2
  35. package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
  36. package/lib/mcp/tools/authenticate/eth-key.js +29 -5
  37. package/lib/mcp/tools/authenticate/index.d.ts +1 -1
  38. package/lib/mcp/tools/authenticate/index.js +1 -1
  39. package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
  40. package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
  41. package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
  42. package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
  43. package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
  44. package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
  45. package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
  46. package/lib/mcp/tools/authenticate/session-cache.js +95 -4
  47. package/lib/mcp/tools/mode/mode.js +6 -6
  48. package/lib/old/login-cdp.js +2 -3
  49. package/lib/proof/attestor.js +2 -3
  50. package/lib/proof/check-attestor.d.ts +12 -2
  51. package/lib/proof/check-attestor.js +40 -10
  52. package/lib/proof/check-matchers.d.ts +36 -0
  53. package/lib/proof/check-matchers.js +161 -0
  54. package/lib/proof/zk-files.d.ts +50 -11
  55. package/lib/proof/zk-files.js +259 -63
  56. package/lib/provider/draft.d.ts +24 -1
  57. package/lib/provider/draft.js +135 -0
  58. package/lib/provider/matcher.js +171 -4
  59. package/package.json +6 -4
  60. package/skill/SKILL.builder.md +7 -2
  61. package/skill/SKILL.common.md +90 -70
  62. package/skill/SKILL.old.md +3 -3
  63. package/skill/builder-mode.md +35 -5
  64. package/skill/old-mode.md +18 -13
  65. package/skill/start-integration.md +48 -33
  66. package/skill/verification-client-integration.md +21 -6
  67. package/src/authoring/backend.ts +12 -1
  68. package/src/authoring/tools.ts +48 -7
  69. package/src/authoring/util.ts +64 -0
  70. package/src/cdp/chrome-for-testing.ts +66 -0
  71. package/src/cdp/geo.ts +36 -21
  72. package/src/cdp/launcher.ts +328 -24
  73. package/src/logger.ts +3 -1
  74. package/src/mcp/instructions.ts +3 -3
  75. package/src/mcp/start.ts +20 -1
  76. package/src/mcp/tools/agent/attach.ts +65 -128
  77. package/src/mcp/tools/agent/build.ts +7 -0
  78. package/src/mcp/tools/agent/capture.ts +54 -11
  79. package/src/mcp/tools/agent/check-attestor.ts +15 -7
  80. package/src/mcp/tools/agent/draft-tools.ts +234 -0
  81. package/src/mcp/tools/agent/pod-proof.ts +5 -1
  82. package/src/mcp/tools/agent/proof.ts +110 -1
  83. package/src/mcp/tools/agent/shared.ts +130 -19
  84. package/src/mcp/tools/agent/start-integration.ts +10 -6
  85. package/src/mcp/tools/authenticate/authenticate.ts +276 -131
  86. package/src/mcp/tools/authenticate/build.ts +6 -3
  87. package/src/mcp/tools/authenticate/builder-access.ts +5 -2
  88. package/src/mcp/tools/authenticate/eth-key.ts +41 -7
  89. package/src/mcp/tools/authenticate/index.ts +1 -1
  90. package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
  91. package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
  92. package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
  93. package/src/mcp/tools/authenticate/session-cache.ts +125 -5
  94. package/src/mcp/tools/mode/mode.ts +6 -6
  95. package/src/old/login-cdp.ts +2 -3
  96. package/src/proof/attestor.ts +2 -3
  97. package/src/proof/check-attestor.ts +55 -12
  98. package/src/proof/check-matchers.ts +227 -0
  99. package/src/proof/zk-files.ts +357 -65
  100. package/src/provider/draft.ts +215 -7
  101. package/src/provider/matcher.ts +237 -4
@@ -14,11 +14,11 @@ import {
14
14
  import { suggestCountryFromIp } from '../../../cdp/geo.ts'
15
15
  import {
16
16
  defaultProfileDir,
17
- launchChrome,
17
+ launchLocalBrowser,
18
+ type LocalBrowser,
18
19
  waitForCdpReady,
19
20
  } from '../../../cdp/launcher.ts'
20
21
  import type { ConfigStore } from '../../../config/store.ts'
21
- import { defaultConfigPath } from '../../../config/store.ts'
22
22
  import { defineTool, type RegisteredTool } from '../../server.ts'
23
23
  import { ensureLiveViewUrl } from './live-view-url.ts'
24
24
 
@@ -60,6 +60,8 @@ export interface AttachState {
60
60
  viewerPort?: number
61
61
  /** Whether the runtime image had to be downloaded on this attach. */
62
62
  imagePulled?: boolean
63
+ /** `dedicated` mode: the browser this attach launched, if it launched one. */
64
+ launched?: Omit<LocalBrowser, 'process'>
63
65
  disposeRemote?: () => Promise<void>
64
66
  }
65
67
 
@@ -85,94 +87,15 @@ interface AttachArgs {
85
87
  viewerPort?: number
86
88
  }
87
89
 
88
- /** Browser sources returned when the caller omits `mode`. Keep selection detail
89
- * here instead of repeating it in the always-loaded tool description. */
90
- const BROWSER_OPTIONS = [
91
- {
92
- mode: 'dedicated',
93
- useCase: 'Working on your own machine',
94
- label: 'Local Chrome, launched for you',
95
- detail:
96
- 'Launches Chrome with a dedicated profile under '
97
- + '~/.reclaim/chrome-profile. Nothing to set up, and the profile '
98
- + 'persists, so a site you sign in to stays signed in. The easiest '
99
- + 'choice when you are doing the work yourself.',
100
- requires: [],
101
- liveView: 'None, and none needed — the browser is on your screen.',
102
- },
103
- {
104
- mode: 'attach',
105
- useCase: 'Working on your own machine',
106
- label: 'The Chrome you already have open',
107
- detail:
108
- 'Connects to a Chrome you started yourself, keeping the tabs, '
109
- + 'extensions and logins already in it. That Chrome must have been '
110
- + 'started with BOTH --remote-debugging-port AND a non-default '
111
- + '--user-data-dir: since Chrome 136 the debug flag is silently '
112
- + 'ignored on the default profile.',
113
- requires: ['port (default 9222)'],
114
- liveView: 'None, and none needed — the browser is on your screen.',
115
- },
116
- {
117
- mode: 'container',
118
- useCase: 'Someone else has to sign in, or you need a phone',
119
- label: 'Reclaim runtime in Docker — free, runs on your machine',
120
- detail:
121
- 'Runs the Reclaim browser runtime (the same image the remote browser '
122
- + 'uses) as a container on this machine, and gives you a link you can '
123
- + 'send to whoever holds the account. They open it in any browser, '
124
- + 'including a phone, and can see and click in it. Free — it is your '
125
- + 'Docker and your bandwidth. Costs a 432 MB first-run download and '
126
- + 'about 2 GB of RAM. The image is amd64, so Apple Silicon runs it '
127
- + 'translated. OrbStack handles that itself; Docker Desktop needs '
128
- + 'Rosetta enabled.',
129
- requires: ['Docker running'],
130
- liveView:
131
- 'Built in. This call returns a local `viewerUrl`, and '
132
- + 'share_browser_view turns that into a public link for the person '
133
- + 'helping you.',
134
- },
135
- {
136
- mode: 'builder',
137
- useCase: 'Someone else has to sign in, or you need a phone',
138
- label: 'Remote browser, hosted by Reclaim — CHARGEABLE',
139
- detail:
140
- 'A browser allocated in Reclaim\'s cloud, billed against the '
141
- + 'organization\'s quota. Nothing to install, and the shareable link '
142
- + 'comes back immediately. It is also the only way to browse a site '
143
- + 'from a SPECIFIC COUNTRY — its whole egress is routed there. '
144
- + 'Geolocation is optional: omit countryCode to be offered a '
145
- + 'suggestion, or pass skipCountry to allocate without geo-routing.',
146
- requires: [
147
- 'Builder authentication',
148
- 'orgId (auto-resolved when you have only one)',
149
- 'quota — this one costs money',
150
- ],
151
- liveView:
152
- 'Included. This call returns a hosted liveViewUrl to pass on; '
153
- + 'share_browser_view does not apply.',
154
- },
155
- {
156
- mode: 'custom',
157
- useCase: 'Advanced',
158
- label: 'A CDP endpoint you supply',
159
- detail:
160
- 'Connects to any ws:// or wss:// CDP websocket — a browser you run '
161
- + 'elsewhere, or another tool\'s. The URL is treated as a credential: '
162
- + 'never logged, persisted, or returned. Only pick this if you were '
163
- + 'given an endpoint and know why.',
164
- requires: ['cdpUrl'],
165
- liveView:
166
- 'None, unless you pass your own liveViewUrl alongside the cdpUrl.',
167
- },
168
- ] as const
169
-
170
90
  interface AttachDependencies {
171
91
  connectLocal: typeof connectCdp
172
92
  connectRemote: typeof connectCdpUrl
173
93
  /** Injected so container mode is testable without Docker installed. */
174
94
  dockerAvailable: typeof dockerAvailable
175
95
  startContainer: typeof startContainer
96
+ /** Injected so the default launch is testable without a real browser. */
97
+ launchLocal: typeof launchLocalBrowser
98
+ waitForCdp: typeof waitForCdpReady
176
99
  }
177
100
 
178
101
  const DEFAULT_DEPENDENCIES: AttachDependencies = {
@@ -180,6 +103,8 @@ const DEFAULT_DEPENDENCIES: AttachDependencies = {
180
103
  connectRemote: connectCdpUrl,
181
104
  dockerAvailable,
182
105
  startContainer,
106
+ launchLocal: launchLocalBrowser,
107
+ waitForCdp: waitForCdpReady,
183
108
  }
184
109
 
185
110
  export function attachTool(
@@ -197,16 +122,15 @@ export function attachTool(
197
122
  {
198
123
  name: 'attach_browser',
199
124
  description:
200
- 'Connects the authoring tools to a browser that loads the '
201
- + 'developer\'s sign-in page and target site. `builder` and '
202
- + '`container` run the browser remotely or in Docker, away from the '
203
- + 'developer\'s own profile; `dedicated`, `attach`, and `custom` use a '
204
- + 'local or supplied Chrome. Call without `mode` to receive the '
205
- + 'options, ask the developer to choose, then call again once with '
206
- + 'the chosen mode. `builder` consumes quota. Only `container` and '
207
- + '`builder` support a shareable live view. Relay returned live-view '
208
- + 'URLs verbatim. See how_it_works({ topic: "browser" }) for '
209
- + 'selection, geolocation, and lifecycle.',
125
+ 'Opens a visible browser on the developer\'s own machine for '
126
+ + 'sign-in and capture. Call it with no arguments and do not ask '
127
+ + 'which browser to use: it launches installed Chrome (else '
128
+ + 'Chromium, Edge, or a one-time Chrome for Testing download) with '
129
+ + 'the persistent Reclaim profile. Pass `mode` only when the '
130
+ + 'developer explicitly asks for that mode: `attach` (their running '
131
+ + 'Chrome on `port`), `container` (Docker), `custom` (`cdpUrl`), or '
132
+ + '`builder` (billed). Never offer or pick those yourself. See '
133
+ + 'how_it_works({ topic: "browser" }).',
210
134
  inputSchema: {
211
135
  type: 'object',
212
136
  properties: {
@@ -215,6 +139,9 @@ export function attachTool(
215
139
  enum: [
216
140
  'dedicated', 'attach', 'container', 'builder', 'custom',
217
141
  ],
142
+ description:
143
+ 'Omit for the default local browser (`dedicated`). Set '
144
+ + 'only to a mode the developer explicitly asked for.',
218
145
  },
219
146
  attachMode: {
220
147
  type: 'string',
@@ -256,32 +183,12 @@ export function attachTool(
256
183
  },
257
184
  async(parsed) => {
258
185
  const persisted = state.config.read()?.browser_agent
259
- // Deliberately NOT falling back to the persisted mode: which browser
260
- // to drive is the developer's call every time (a remote one costs
261
- // quota, a local one exposes their own profile), and silently reusing
262
- // last session's answer takes that decision away from them. The
263
- // persisted choice is offered back as a default to suggest, not
264
- // applied.
265
- const mode = parsed.mode ?? parsed.attachMode
266
- if(!mode) {
267
- return {
268
- needsChoice: true,
269
- message:
270
- 'Ask FIRST whether they will be doing this themselves or '
271
- + 'someone else has to sign in — that answer decides half the '
272
- + 'list. Themselves: `dedicated` or `attach`. Someone else, or '
273
- + 'a phone-only site: `container` (free, needs Docker) or '
274
- + '`builder` (no install, chargeable) — the only two that give '
275
- + 'you a link to send. Then present the matching options and '
276
- + 'call attach_browser again with the `mode` they pick. Do not '
277
- + 'pick one for them.',
278
- options: BROWSER_OPTIONS,
279
- ...(persisted?.attachMode
280
- ? { previouslyUsed: persisted.attachMode }
281
- : {}),
282
- configPath: defaultConfigPath(),
283
- }
284
- }
186
+ // The default is a visible browser on the developer's own machine,
187
+ // with no question first. Every other mode costs something (Docker,
188
+ // quota, the developer's own profile) and is used only when the
189
+ // developer asked for it by name, so a persisted choice is never
190
+ // reapplied either.
191
+ const mode = parsed.mode ?? parsed.attachMode ?? 'dedicated'
285
192
 
286
193
  let orgId = parsed.orgId
287
194
  if(mode === 'builder' && !orgId) {
@@ -397,7 +304,17 @@ export function attachTool(
397
304
  ...(next.countryCode ? { countryCode: next.countryCode } : {}),
398
305
  ...(next.containerName ? { containerName: next.containerName } : {}),
399
306
  ...(canShare ? { liveViewAvailable: true } : {}),
307
+ ...(next.launched ? { browser: next.launched.source } : {}),
400
308
  _notes: [
309
+ ...(next.launched?.source === 'chrome-for-testing'
310
+ ? ['Launched Chrome for Testing'
311
+ + (next.launched.downloaded
312
+ ? ', downloaded just now into ~/.reclaim/browsers (later '
313
+ + 'launches reuse it)'
314
+ : ' from ~/.reclaim/browsers')
315
+ + `, because: ${next.launched.fallbackReason}. Mention `
316
+ + 'this to the developer in one line.']
317
+ : []),
401
318
  ...(next.imagePulled
402
319
  ? ['The browser runtime image was downloaded just now (about '
403
320
  + '432 MB, about 1.3 GB on disk). Mention it — later attaches '
@@ -421,9 +338,12 @@ export function attachTool(
421
338
  : []),
422
339
  ...(!localView && !hostedView
423
340
  ? ['This browser has no live view: it runs on the developer\'s '
424
- + 'own machine, so they watch it by looking at it. If someone '
425
- + 'ELSE needs to see or drive it, say that `container` mode '
426
- + 'runs a browser with a shareable live view built in.']
341
+ + 'own machine, so they watch it by looking at it. Do not '
342
+ + 'offer other modes. Tell the developer once, as a side '
343
+ + 'note, that if a volunteer is helping them sign in, a '
344
+ + 'shareable link can be set up (`container` mode and '
345
+ + 'share_browser_view); do not ask or set it up unless they '
346
+ + 'ask.']
427
347
  : []),
428
348
  ],
429
349
  }
@@ -532,15 +452,27 @@ async function connect(
532
452
  }
533
453
 
534
454
  const port = parsed.port ?? persisted?.port ?? 9222
455
+ let launched: AttachState['launched']
535
456
  if(mode === 'dedicated') {
536
457
  try {
537
- await waitForCdpReady(port, 500)
458
+ // Reuse the browser an earlier attach launched on this port.
459
+ await dependencies.waitForCdp(port, 500)
538
460
  } catch{
539
- launchChrome({ port, profileDir: defaultProfileDir() })
540
- await waitForCdpReady(port, 10_000)
461
+ const browser = await dependencies
462
+ .launchLocal({ port, profileDir: defaultProfileDir() })
463
+ // The browser outlives this attach on purpose, so its process handle
464
+ // is not kept.
465
+ launched = {
466
+ binary: browser.binary,
467
+ source: browser.source,
468
+ downloaded: browser.downloaded,
469
+ ...(browser.fallbackReason
470
+ ? { fallbackReason: browser.fallbackReason }
471
+ : {}),
472
+ }
541
473
  }
542
474
  } else {
543
- await waitForCdpReady(port, 2_000).catch(() => {
475
+ await dependencies.waitForCdp(port, 2_000).catch(() => {
544
476
  throw new Error(
545
477
  `attach mode: nothing answering CDP on port ${port}. Chrome must `
546
478
  + `be launched with --remote-debugging-port=${port} AND `
@@ -550,7 +482,12 @@ async function connect(
550
482
  })
551
483
  }
552
484
 
553
- return { handles: await dependencies.connectLocal(port), mode, port }
485
+ return {
486
+ handles: await dependencies.connectLocal(port),
487
+ mode,
488
+ port,
489
+ ...(launched ? { launched } : {}),
490
+ }
554
491
  }
555
492
 
556
493
  async function disposeState(state: AttachState) {
@@ -18,6 +18,7 @@ import { analyzeTool } from './analyze.ts'
18
18
  import { type AttachState, attachTool } from './attach.ts'
19
19
  import { type CaptureStore, captureTools, resetCaptures } from './capture.ts'
20
20
  import { checkAttestorTool } from './check-attestor.ts'
21
+ import { draftTools } from './draft-tools.ts'
21
22
  import { listTabsTool } from './eval.ts'
22
23
  import { howItWorksTool } from './how-it-works.ts'
23
24
  import { liveViewTools } from './live-view.ts'
@@ -92,6 +93,12 @@ export function buildAgentTools(
92
93
  tools.push(t)
93
94
  }
94
95
 
96
+ // check_matchers / update_draft: offline matcher check + in-place draft
97
+ // edits, keyed by captureId like run_proof_via_pod.
98
+ for(const t of draftTools(state.captures)) {
99
+ tools.push(t)
100
+ }
101
+
95
102
  if(client) {
96
103
  tools.push(startIntegrationTool())
97
104
  tools.push(createVersionFromCaptureTool(
@@ -3,6 +3,12 @@ import {
3
3
  type KeptScriptRef,
4
4
  makeDraftStore,
5
5
  } from '../../../authoring/index.ts'
6
+ import {
7
+ DEFAULT_MAX_CHARS,
8
+ TEXT_WINDOW_PROPERTIES,
9
+ textWindow,
10
+ type TextWindowArgs,
11
+ } from '../../../authoring/util.ts'
6
12
  import { type CaptureSession, startCapture } from '../../../cdp/capture.ts'
7
13
  import { defineTool, type RegisteredTool } from '../../server.ts'
8
14
  import type { AttachState } from './attach.ts'
@@ -19,6 +25,14 @@ export interface CaptureEntry {
19
25
  drafts: DraftStore
20
26
  }
21
27
 
28
+ interface GetRequestArgs {
29
+ captureId: string
30
+ requestId: string
31
+ maxChars?: number
32
+ offset?: number
33
+ full?: boolean
34
+ }
35
+
22
36
  export interface CaptureStore extends KeptScriptRef {
23
37
  sessions: Map<string, CaptureEntry>
24
38
  activeTab?: string
@@ -96,26 +110,32 @@ export function captureTools(
96
110
  },
97
111
  )
98
112
 
99
- const getTool = defineTool<{ captureId: string, requestId: string }>(
113
+ const getTool = defineTool<GetRequestArgs>(
100
114
  {
101
115
  name: 'get_request',
102
116
  description:
103
- 'Returns the full headers and bodies of one captured request, to '
104
- + 'inspect a candidate that `find_requests_containing` ranked. Unlike '
105
- + 'the other tools, headers are raw: cookie and authorization values '
106
- + 'are visible. Read them for diagnosis; do not repeat them to the '
107
- + 'developer or write them to a file.',
117
+ 'Returns the headers and bodies of one captured request, to inspect '
118
+ + 'a candidate that `find_requests_containing` ranked. Each body is '
119
+ + `cut to ${DEFAULT_MAX_CHARS} characters by default; a cut body `
120
+ + 'comes with `requestBodyTruncation` or `responseBodyTruncation` '
121
+ + '(total size and `nextOffset`). Page with `offset`, or pass '
122
+ + '`maxChars` or `full`. Unlike the other tools, headers are raw: '
123
+ + 'cookie and authorization values are visible. Read them for '
124
+ + 'diagnosis; do not repeat them to the developer or write them to '
125
+ + 'a file.',
108
126
  inputSchema: {
109
127
  type: 'object',
110
128
  properties: {
111
129
  captureId: { type: 'string' },
112
130
  requestId: { type: 'string' },
131
+ ...TEXT_WINDOW_PROPERTIES,
113
132
  },
114
133
  required: ['captureId', 'requestId'],
115
134
  },
116
135
  annotations: { readOnlyHint: true, openWorldHint: false },
117
136
  },
118
- async({ captureId, requestId }) => {
137
+ async(args) => {
138
+ const { captureId, requestId } = args
119
139
  const entry = captures.sessions.get(captureId)
120
140
  if(!entry) {
121
141
  throw new Error(`no capture ${captureId}`)
@@ -126,14 +146,37 @@ export function captureTools(
126
146
  throw new Error(`no request ${requestId} in capture ${captureId}`)
127
147
  }
128
148
 
149
+ const { requestBody, responseBody, ...meta } = r
129
150
  const body = entry.session.bodies.get(requestId)
130
- if(!body.available) {
131
- return { ...r, bodyAvailable: false, reason: body.reason }
151
+ const result = {
152
+ ...meta,
153
+ ...windowed('requestBody', requestBody, args),
154
+ ...windowed(
155
+ 'responseBody', body.available ? body.body : responseBody, args,
156
+ ),
132
157
  }
133
-
134
- return { ...r, responseBody: body.body, bodyAvailable: true }
158
+ return body.available
159
+ ? { ...result, bodyAvailable: true }
160
+ : { ...result, bodyAvailable: false, reason: body.reason }
135
161
  },
136
162
  )
137
163
 
138
164
  return [startTool, stopTool, getTool]
139
165
  }
166
+
167
+ /** `{ [key]: text window }`, plus `[key]Truncation` when the window cut it. */
168
+ function windowed(
169
+ key: 'requestBody' | 'responseBody',
170
+ text: string | undefined,
171
+ args: TextWindowArgs,
172
+ ) {
173
+ if(text === undefined) {
174
+ return {}
175
+ }
176
+
177
+ const { text: part, truncation } = textWindow(text, args)
178
+ return {
179
+ [key]: part,
180
+ ...(truncation ? { [`${key}Truncation`]: truncation } : {}),
181
+ }
182
+ }
@@ -20,13 +20,21 @@ export function checkAttestorTool(
20
20
  + 'seconds (about 8 when healthy). Call it once when you set up a '
21
21
  + 'new provider build, and after `run_proof` fails with a network '
22
22
  + 'error. Returns `{ ok, attestorUrl, durationMs, '
23
- + 'extractedParameters, error, errorKind }`. If `ok` is true, the '
24
- + 'attestor is fine and a failing proof points to the target site. '
25
- + 'If `errorKind` is `attestor-unreachable`, this machine cannot '
26
- + 'reach the attestor (network, VPN, firewall, or proxy): tell the '
27
- + 'developer. If it is `setup-failed`, the ZK file download failed '
28
- + 'before the attestor was contacted: tell the developer to check '
29
- + 'network access to the circuit download. If it is '
23
+ + 'extractedParameters, error, errorKind, proofFiles }`. '
24
+ + '`proofFiles` is the proof file download state: `{ status }` is '
25
+ + '`idle`, `ready`, `downloading` (with `method`, `elapsedMs`, and '
26
+ + '`bytesDownloaded`/`totalBytes` when known), or `failed` (with '
27
+ + '`reason`). If `ok` is true, the attestor is fine and a failing '
28
+ + 'proof points to the target site. If `errorKind` is '
29
+ + '`proof-files-downloading`, the one-time proof file download is '
30
+ + 'still running and the attestor was not contacted: call this tool '
31
+ + 'again shortly; do not report a network problem. If it is '
32
+ + '`proof-files-failed`, the download failed: tell the developer '
33
+ + '`error` (usually no access to github.com or '
34
+ + 'raw.githubusercontent.com). If it is `attestor-unreachable`, the '
35
+ + 'proof files are ready but this machine cannot reach the attestor '
36
+ + '(network, VPN, firewall, or proxy): tell the developer. If it is '
37
+ + '`setup-failed`, other local setup failed: report `error`. If it is '
30
38
  + '`attestor-error`, the attestor rejected the claim: report '
31
39
  + '`error`.',
32
40
  inputSchema: {
@@ -0,0 +1,234 @@
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 type { JSONSchema } from 'zod/v4/core'
11
+ import type { Draft } from '../../../authoring/backend.ts'
12
+ import { checkMatchersOffline } from '../../../proof/check-matchers.ts'
13
+ import { type DraftEdit, editDraftProvider } from '../../../provider/draft.ts'
14
+ import type {
15
+ ResponseMatch,
16
+ ResponseRedaction,
17
+ } from '../../../provider/schema.ts'
18
+ import { verificationWarnings } from '../../../provider/verification-checks.ts'
19
+ import { defineTool, type RegisteredTool } from '../../server.ts'
20
+ import type { CaptureEntry, CaptureStore } from './capture.ts'
21
+
22
+ const MATCHES_SCHEMA = {
23
+ type: 'array',
24
+ minItems: 1,
25
+ items: {
26
+ type: 'object',
27
+ properties: {
28
+ type: { type: 'string', enum: ['contains', 'regex'] },
29
+ value: { type: 'string' },
30
+ invert: { type: 'boolean' },
31
+ },
32
+ required: ['type', 'value'],
33
+ },
34
+ } satisfies JSONSchema.Schema
35
+
36
+ const REDACTIONS_SCHEMA = {
37
+ type: 'array',
38
+ items: {
39
+ type: 'object',
40
+ properties: {
41
+ xPath: { type: 'string' },
42
+ jsonPath: { type: 'string' },
43
+ regex: { type: 'string' },
44
+ hash: { type: 'string', enum: ['oprf', 'oprf-raw', 'oprf-mpc'] },
45
+ },
46
+ },
47
+ } satisfies JSONSchema.Schema
48
+
49
+ const PARAM_VALUES_SCHEMA = {
50
+ type: 'object',
51
+ additionalProperties: { type: 'string' },
52
+ } satisfies JSONSchema.Schema
53
+
54
+ interface MatcherArgs {
55
+ captureId: string
56
+ draftId: string
57
+ responseMatches?: ResponseMatch[]
58
+ responseRedactions?: ResponseRedaction[]
59
+ paramValues?: Record<string, string>
60
+ geoLocation?: string
61
+ }
62
+
63
+ export function draftTools(captures: CaptureStore): RegisteredTool[] {
64
+ const checkMatchers = defineTool<MatcherArgs & { requestId?: string }>(
65
+ {
66
+ name: 'check_matchers',
67
+ description:
68
+ 'Checks a draft\'s `responseRedactions` and `responseMatches` '
69
+ + 'against the captured response, offline, with the attestor\'s own '
70
+ + 'redaction and match engine. No network call. Returns `ok`, '
71
+ + '`failedAt` and `reason` on failure, `extractedParameters`, and the '
72
+ + '`revealed` body slices the attestor would see (truncated). Pass '
73
+ + '`responseMatches`, `responseRedactions`, or `paramValues` to try '
74
+ + 'an edit without saving it, and `requestId` to check against a '
75
+ + 'different captured request. Run it before `run_proof`.',
76
+ inputSchema: {
77
+ type: 'object',
78
+ properties: {
79
+ captureId: { type: 'string' },
80
+ draftId: { type: 'string' },
81
+ requestId: {
82
+ type: 'string',
83
+ description: 'Captured request whose response to check. '
84
+ + 'Defaults to the request the draft was proposed from.',
85
+ },
86
+ responseMatches: MATCHES_SCHEMA,
87
+ responseRedactions: REDACTIONS_SCHEMA,
88
+ paramValues: PARAM_VALUES_SCHEMA,
89
+ },
90
+ required: ['captureId', 'draftId'],
91
+ },
92
+ annotations: { readOnlyHint: true, openWorldHint: false },
93
+ },
94
+ async(args) => {
95
+ const { entry, draft } = resolveDraft(captures, args)
96
+ const provider = hasEdit(args)
97
+ ? editDraftProvider(draft.provider, draft.extractions, args).provider
98
+ : draft.provider
99
+ return checkCaptured(entry, args.requestId ?? draft.requestId, provider)
100
+ },
101
+ )
102
+
103
+ const updateDraft = defineTool<MatcherArgs & { check?: boolean }>(
104
+ {
105
+ name: 'update_draft',
106
+ description:
107
+ 'Replaces a draft\'s `responseMatches`, `responseRedactions`, '
108
+ + '`paramValues`, or `geoLocation` in place; the `draftId` stays '
109
+ + 'the same. Validates '
110
+ + 'regexes and selectors and rejects a matcher that bakes a captured '
111
+ + 'value in as a literal, or redactions that reveal more of the '
112
+ + 'captured response than the draft did. Returns the secret-free '
113
+ + 'provider and, unless `check` is false, a `check_matchers` '
114
+ + 'result for the edit. '
115
+ + 'Prefer `propose_provider` with a better request or target; use '
116
+ + 'this to narrow or fix what it drafted.',
117
+ inputSchema: {
118
+ type: 'object',
119
+ properties: {
120
+ captureId: { type: 'string' },
121
+ draftId: { type: 'string' },
122
+ responseMatches: MATCHES_SCHEMA,
123
+ responseRedactions: REDACTIONS_SCHEMA,
124
+ paramValues: PARAM_VALUES_SCHEMA,
125
+ geoLocation: {
126
+ type: 'string',
127
+ description:
128
+ 'ISO country the published version\'s request goes out from, '
129
+ + 'for example `US`, or `{{DYNAMIC_GEO}}` (the default) for '
130
+ + 'the verifying user\'s own country. Pin a country when '
131
+ + 'run_proof only verified with that `geoLocation`.',
132
+ },
133
+ check: {
134
+ type: 'boolean',
135
+ description: 'Default true: run `check_matchers` on the edit.',
136
+ },
137
+ },
138
+ required: ['captureId', 'draftId'],
139
+ },
140
+ annotations: { openWorldHint: false, destructiveHint: false },
141
+ },
142
+ async(args) => {
143
+ const { entry, draft } = resolveDraft(captures, args)
144
+ assert(
145
+ hasEdit(args),
146
+ badRequest(
147
+ 'Pass at least one of responseMatches, responseRedactions, '
148
+ + 'paramValues, or geoLocation.',
149
+ ),
150
+ )
151
+ const edited = editDraftProvider(draft.provider, draft.extractions, args)
152
+ // Redactions are what the attestor doesn't see, so an edit may narrow
153
+ // them but never reveal more of the captured response than the draft.
154
+ const before = checkCaptured(entry, draft.requestId, draft.provider)
155
+ const check = checkCaptured(entry, draft.requestId, edited.provider)
156
+ assert(
157
+ check.failedAt !== 'request' && check.failedAt !== 'redaction',
158
+ badRequest(
159
+ 'the edited recipe doesn\'t apply to the captured response '
160
+ + `(${check.failedAt}): ${check.reason}`,
161
+ ),
162
+ )
163
+ const comparable = before.failedAt !== 'request'
164
+ && before.failedAt !== 'redaction'
165
+ assert(
166
+ !comparable || check.revealedBytes <= before.revealedBytes,
167
+ badRequest(
168
+ `the edit reveals ${check.revealedBytes} bytes of the response `
169
+ + `to the attestor, more than the draft's ${before.revealedBytes}. `
170
+ + 'Keep redactions that hide everything except the proved values.',
171
+ ),
172
+ )
173
+ // The recipe changed, so an earlier replay no longer describes it.
174
+ const { lastReplay: _stale, ...rest } = draft
175
+ void _stale
176
+ entry.drafts.set(args.draftId, {
177
+ ...rest,
178
+ provider: edited.provider,
179
+ extractions: edited.extractions,
180
+ })
181
+ const verification = verificationWarnings(edited.provider)
182
+ return {
183
+ draftId: args.draftId,
184
+ provider: edited.provider,
185
+ secretRefs: draft.secretRefs,
186
+ ...(edited.warnings.length ? { warnings: edited.warnings } : {}),
187
+ ...(verification.length
188
+ ? { verificationWarnings: verification }
189
+ : {}),
190
+ ...(args.check === false ? {} : { check }),
191
+ }
192
+ },
193
+ )
194
+
195
+ return [checkMatchers, updateDraft]
196
+ }
197
+
198
+ function resolveDraft(
199
+ captures: CaptureStore,
200
+ { captureId, draftId }: { captureId: string, draftId: string },
201
+ ): { entry: CaptureEntry, draft: Draft } {
202
+ const entry = captures.sessions.get(captureId)
203
+ assert(
204
+ entry,
205
+ badRequest(
206
+ `no capture ${captureId} — call start_capture first `
207
+ + '(it has been reset or never started).',
208
+ ),
209
+ )
210
+ const draft = entry.drafts.get(draftId)
211
+ assert(draft, badRequest(`no draft ${draftId} in capture ${captureId}`))
212
+ return { entry, draft }
213
+ }
214
+
215
+ function hasEdit(edit: DraftEdit): boolean {
216
+ return edit.responseMatches !== undefined
217
+ || edit.responseRedactions !== undefined
218
+ || edit.paramValues !== undefined
219
+ || edit.geoLocation !== undefined
220
+ }
221
+
222
+ function checkCaptured(
223
+ entry: CaptureEntry,
224
+ requestId: string,
225
+ provider: Draft['provider'],
226
+ ) {
227
+ const captured = entry.session.requests.get(requestId)
228
+ assert(captured, badRequest(`no captured request ${requestId}`))
229
+ assert(
230
+ captured.responseBody,
231
+ badRequest(`response body unavailable for ${requestId}`),
232
+ )
233
+ return { requestId, ...checkMatchersOffline(provider, captured) }
234
+ }