@reclaimprotocol/agent 0.1.3 → 0.1.5
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.
- package/README.md +11 -42
- package/lib/authoring/backend.d.ts +95 -0
- package/lib/authoring/backend.js +24 -0
- package/lib/authoring/index.d.ts +4 -0
- package/lib/authoring/index.js +5 -0
- package/lib/authoring/tools.d.ts +9 -0
- package/lib/authoring/tools.js +378 -0
- package/lib/cdp/capture-engine.d.ts +7 -0
- package/lib/cdp/capture-engine.js +78 -0
- package/lib/cdp/capture.d.ts +1 -1
- package/lib/cdp/capture.js +1 -1
- package/lib/consts.d.ts +4 -2
- package/lib/consts.js +6 -2
- package/lib/mcp/api/operations.gen.js +46 -18
- package/lib/mcp/instructions.d.ts +2 -0
- package/lib/mcp/instructions.js +16 -0
- package/lib/mcp/server.d.ts +18 -2
- package/lib/mcp/server.js +19 -4
- package/lib/mcp/start.js +2 -1
- package/lib/mcp/tools/agent/build.js +4 -0
- package/lib/mcp/tools/agent/how-it-works.d.ts +11 -0
- package/lib/mcp/tools/agent/how-it-works.js +24 -0
- package/lib/mcp/tools/agent/publish.js +2 -2
- package/lib/mcp/tools/agent/shared.d.ts +6 -0
- package/lib/mcp/tools/agent/shared.js +115 -0
- package/lib/old/client.d.ts +10 -0
- package/lib/old/client.js +27 -0
- package/lib/old/login-cdp.d.ts +5 -0
- package/lib/old/login-cdp.js +12 -1
- package/lib/old/tools/authenticate.js +7 -3
- package/lib/old/tools/build.d.ts +2 -2
- package/lib/old/tools/build.js +4 -2
- package/lib/old/tools/session-analytics-logs.d.ts +9 -0
- package/lib/old/tools/session-analytics-logs.js +25 -0
- package/lib/proof/proof-core.d.ts +1 -1
- package/lib/proof/proof-core.js +1 -1
- package/lib/tools/registry.d.ts +36 -0
- package/lib/tools/registry.js +17 -0
- package/package.json +2 -3
- package/skill/{SKILL.md → SKILL.builder.md} +16 -45
- package/skill/SKILL.old.md +225 -0
- package/skill/old-mode.md +2 -0
- package/src/cdp/capture-engine.ts +89 -0
- package/src/cdp/capture.ts +5 -1
- package/src/consts.ts +7 -2
- package/src/mcp/api/operations.gen.ts +46 -18
- package/src/mcp/instructions.ts +18 -0
- package/src/mcp/server.ts +43 -6
- package/src/mcp/start.ts +2 -0
- package/src/mcp/tools/agent/build.ts +4 -0
- package/src/mcp/tools/agent/how-it-works.ts +32 -0
- package/src/mcp/tools/agent/publish.ts +2 -2
- package/src/old/client.ts +32 -0
- package/src/old/login-cdp.ts +20 -1
- package/src/old/tools/authenticate.ts +11 -3
- package/src/old/tools/build.ts +4 -2
- package/src/old/tools/session-analytics-logs.ts +33 -0
- package/src/proof/proof-core.ts +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ This package **is** the Reclaim MCP server (bin: `reclaim-mcp-server`). It depen
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
**Prerequisites:** Node ≥ 22.18 and a local Chrome / Chromium / Edge (used for capture and the dashboard login). The first
|
|
9
|
+
**Prerequisites:** Node ≥ 22.18 and a local Chrome / Chromium / Edge (used for capture and the dashboard login). The first time you call `run_proof`, the agent downloads the ZK circuit files it needs (~280 MB) — so allow a moment for that first proof. The download is deferred to first proof on purpose (not an install step), so the MCP server itself starts immediately.
|
|
10
10
|
|
|
11
11
|
It runs as a **stdio MCP server**. Point any MCP-capable agent at this command:
|
|
12
12
|
|
|
@@ -111,13 +111,13 @@ Defaults target the production old-devtools backend, so **no env is needed for t
|
|
|
111
111
|
|
|
112
112
|
See [Backends](#backends) for builder mode (`USE_OLD_DEVTOOLS=false`) and the full env-var list.
|
|
113
113
|
|
|
114
|
-
###
|
|
114
|
+
### Provider-authoring guide
|
|
115
115
|
|
|
116
|
-
The MCP server is all you need
|
|
116
|
+
The MCP server is all you need — it ships a `how_it_works` tool that returns the full capture → prove → publish guide (login detection, choosing auth-bound endpoints, OPRF decisions, troubleshooting). Any MCP client can call it on demand; there's nothing extra to install.
|
|
117
117
|
|
|
118
118
|
## Using it from your agent
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
Tip: have your agent call the `how_it_works` tool first so it follows the full authoring flow.
|
|
121
121
|
|
|
122
122
|
Then just ask in plain language, e.g.:
|
|
123
123
|
|
|
@@ -138,28 +138,9 @@ The agent orchestrates the tools for you:
|
|
|
138
138
|
|
|
139
139
|
Your only manual steps are signing in when the browser windows open and confirming the value you want to prove.
|
|
140
140
|
|
|
141
|
-
##
|
|
141
|
+
## How it works (guide tool)
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
```bash
|
|
146
|
-
# macOS / Linux — from a global install of the package
|
|
147
|
-
npm i -g @reclaimprotocol/agent
|
|
148
|
-
mkdir -p ~/.claude/skills/reclaim-provider
|
|
149
|
-
cp "$(npm root -g)/@reclaimprotocol/agent/skill/SKILL.md" ~/.claude/skills/reclaim-provider/SKILL.md
|
|
150
|
-
|
|
151
|
-
# …or from a checkout of this repo
|
|
152
|
-
cp packages/agent/skill/SKILL.md ~/.claude/skills/reclaim-provider/SKILL.md
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
```powershell
|
|
156
|
-
# Windows (PowerShell) — from a global install
|
|
157
|
-
npm i -g @reclaimprotocol/agent
|
|
158
|
-
New-Item -ItemType Directory -Force $env:USERPROFILE\.claude\skills\reclaim-provider | Out-Null
|
|
159
|
-
Copy-Item "$(npm root -g)\@reclaimprotocol\agent\skill\SKILL.md" $env:USERPROFILE\.claude\skills\reclaim-provider\SKILL.md
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
Claude Code auto-loads it once it's in `~/.claude/skills/`. **Other MCP clients** (Cursor, opencode, Antigravity, …) don't read `SKILL.md` automatically — the server's built-in instructions already cover the essentials, and you can paste or adapt the skill's content into that client's rules / system-prompt for the same depth.
|
|
143
|
+
The full, mode-aware authoring guide (`skill/SKILL.builder.md` / `skill/SKILL.old.md`, one per backend) is served on demand by the **`how_it_works`** MCP tool — call it from any MCP client (Claude Code, Cursor, opencode, Antigravity, …) to get the deep capture → prove → publish workflow, decision frameworks, and troubleshooting. The server's always-on `instructions` cover the essentials; `how_it_works` is the in-depth version, with nothing to install.
|
|
163
144
|
|
|
164
145
|
## Provider-authoring tools
|
|
165
146
|
|
|
@@ -194,13 +175,13 @@ Relevant env vars: `RECLAIM_OLD_API_URL` (default `https://devapi.reclaimprotoco
|
|
|
194
175
|
|
|
195
176
|
## Local development (from source)
|
|
196
177
|
|
|
197
|
-
For working on the package inside the monorepo (consumers should use [Install](#install) instead). A workspace install fetches dependencies
|
|
178
|
+
For working on the package inside the monorepo (consumers should use [Install](#install) instead). A workspace install just fetches dependencies:
|
|
198
179
|
|
|
199
180
|
```bash
|
|
200
181
|
npm install
|
|
201
182
|
```
|
|
202
183
|
|
|
203
|
-
|
|
184
|
+
The ZK circuit resources needed by `@reclaimprotocol/attestor-core` are **not** fetched at install time — `run_proof` downloads them on the first proof (see the [first-run note](#install)). To pre-fetch them instead of paying the cost on that first proof, run:
|
|
204
185
|
|
|
205
186
|
```bash
|
|
206
187
|
npm run download:zk-files --workspace=packages/agent
|
|
@@ -208,7 +189,7 @@ npm run download:zk-files --workspace=packages/agent
|
|
|
208
189
|
|
|
209
190
|
## Platform notes
|
|
210
191
|
|
|
211
|
-
- **Node >=22.18** is required (declared in `engines`). The floor is 22.18 because the
|
|
192
|
+
- **Node >=22.18** is required (declared in `engines`). The floor is 22.18 because the test suite and dev workflow run bare `.ts` files directly, and unflagged TypeScript type-stripping landed in Node 22.18.
|
|
212
193
|
- **Chrome 136+ silently disables `--remote-debugging-port`** when launched against the default user profile. `dedicated` mode (the default) sidesteps this by always passing `--user-data-dir=~/.reclaim/chrome-profile`.
|
|
213
194
|
|
|
214
195
|
## Tests
|
|
@@ -285,18 +266,6 @@ claude mcp add reclaim -- node "$(pwd)/packages/agent/lib/mcp-bin.js"
|
|
|
285
266
|
|
|
286
267
|
To target a specific backend while developing, add an `env` block (Claude Code / `.mcp.json`) or `environment` (opencode) — e.g. `"USE_OLD_DEVTOOLS": "false"` for builder mode. See [Backends](#backends).
|
|
287
268
|
|
|
288
|
-
###
|
|
289
|
-
|
|
290
|
-
Symlink the source `skill/` folder into `~/.claude/skills/` so edits to `SKILL.md` are picked up live (no re-copy):
|
|
291
|
-
|
|
292
|
-
```bash
|
|
293
|
-
# macOS / Linux — from the repo root
|
|
294
|
-
ln -s "$(pwd)/packages/agent/skill" ~/.claude/skills/reclaim-provider
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
```powershell
|
|
298
|
-
# Windows (PowerShell, Developer Mode or elevated) — from the repo root
|
|
299
|
-
New-Item -ItemType SymbolicLink -Path $env:USERPROFILE\.claude\skills\reclaim-provider -Target (Resolve-Path packages\agent\skill)
|
|
300
|
-
```
|
|
269
|
+
### Authoring guide
|
|
301
270
|
|
|
302
|
-
|
|
271
|
+
`skill/SKILL.builder.md` and `skill/SKILL.old.md` are the sources the `how_it_works` tool serves — one per backend, so an agent is never shown tools its mode doesn't have. The server picks by `USE_OLD_DEVTOOLS`. Edit the relevant file and rebuild (`npm run build`) to update what the tool returns.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { CaptureSession } from '../cdp/capture-engine.ts';
|
|
2
|
+
import type { Extraction } from '../provider/draft.ts';
|
|
3
|
+
import type { ReclaimProvider, SecretParamRef } from '../provider/schema.ts';
|
|
4
|
+
/** Secret request params (cookie/auth headers), materialized only for the
|
|
5
|
+
* replay/prove call — never surfaced in tool output. */
|
|
6
|
+
export interface SecretParams {
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
/** A drafted recipe held server-side so the model only handles an opaque
|
|
10
|
+
* `draftId` — the provider is secret-free but the captured secrets never enter
|
|
11
|
+
* the transcript. Shared by propose → replay → run_proof. */
|
|
12
|
+
export interface Draft {
|
|
13
|
+
provider: ReclaimProvider;
|
|
14
|
+
secretRefs: SecretParamRef[];
|
|
15
|
+
/** Real secret header values (cookie/auth) for replay/prove — never returned
|
|
16
|
+
* to the model. */
|
|
17
|
+
secrets: Record<string, string>;
|
|
18
|
+
/** The captured request this draft was proposed from. */
|
|
19
|
+
requestId: string;
|
|
20
|
+
/** One entry per extracted value (value + its redaction), so replay can
|
|
21
|
+
* validate each against its own selector. */
|
|
22
|
+
extractions: Extraction[];
|
|
23
|
+
}
|
|
24
|
+
/** Per-session draft store: `propose` puts, `replay`/`run_proof` get by id. */
|
|
25
|
+
export interface DraftStore {
|
|
26
|
+
put(draft: Draft): string;
|
|
27
|
+
get(id: string): Draft | undefined;
|
|
28
|
+
}
|
|
29
|
+
export declare function makeDraftStore(): DraftStore;
|
|
30
|
+
/** Options for a proof run. `ownerAddress` selects the local signing credential
|
|
31
|
+
* and `attestorUrl` overrides the attestor endpoint (both MCP-only); the
|
|
32
|
+
* remote TEE backend ignores them (it signs in-enclave, own attestor). */
|
|
33
|
+
export interface ProveOptions {
|
|
34
|
+
ownerAddress?: string;
|
|
35
|
+
attestorUrl?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Everything the shared authoring tools need from one live session. Browser ops
|
|
39
|
+
* go through `sendCdp` (+ `waitForEvent` for load-style waits); capture reads
|
|
40
|
+
* use `capture`; proof/replay are whole operations because they genuinely
|
|
41
|
+
* differ (local attestor + live re-fetch vs remote TEE) — each backend shapes
|
|
42
|
+
* its own result, passed straight through as the tool's output.
|
|
43
|
+
*/
|
|
44
|
+
export interface AuthoringBackend {
|
|
45
|
+
/** Send a raw CDP command scoped to this session's page. */
|
|
46
|
+
sendCdp(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
47
|
+
/** Resolve once `method` (a CDP event) fires on this session's page, or
|
|
48
|
+
* after `timeoutMs`. Used for `Page.loadEventFired`-style waits. */
|
|
49
|
+
waitForEvent(method: string, timeoutMs: number): Promise<'fired' | 'timeout'>;
|
|
50
|
+
/** This session's captured requests (the shared capture engine). */
|
|
51
|
+
readonly capture: CaptureSession;
|
|
52
|
+
/** This session's drafts (propose → replay/run_proof). */
|
|
53
|
+
readonly drafts: DraftStore;
|
|
54
|
+
/** Prove a drafted recipe. Local: attestor SDK (rich claim view). Remote: TEE
|
|
55
|
+
* `/reclaim/prove` (lean, secret-free summary). Returns the environment's
|
|
56
|
+
* display shape. A backend may record the proven recipe internally (e.g.
|
|
57
|
+
* the cloud publish step) — that side effect is the backend's, not ours. */
|
|
58
|
+
prove(draft: Draft, opts?: ProveOptions): Promise<unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* Validate/replay a drafted recipe before proving. With `withoutSecrets` it
|
|
61
|
+
* is the AUTH-BOUND check: re-issue the request stripped of the captured
|
|
62
|
+
* secrets (local: a Node re-fetch without them; remote: an anonymous `fetch`
|
|
63
|
+
* in the Popcorn page, `credentials: 'omit'`) and report whether the values
|
|
64
|
+
* are STILL extractable — i.e. the endpoint is public. Without it, validate
|
|
65
|
+
* extraction for the authenticated request. The shape is environment-specific
|
|
66
|
+
* and passed straight through as the tool's result.
|
|
67
|
+
*/
|
|
68
|
+
replay(draft: Draft, opts?: {
|
|
69
|
+
withoutSecrets?: boolean;
|
|
70
|
+
}): Promise<unknown>;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* How a host (MCP / cloud) supplies its environment to the shared authoring
|
|
74
|
+
* tools, which are authored once with the common `defineTool` primitive and
|
|
75
|
+
* built per host by `buildAuthoringTools(host)`:
|
|
76
|
+
* - `resolveBackend`: hand back the backend for one call. The MCP resolves it
|
|
77
|
+
* from `args.captureId` (multi-session); the cloud returns its one run
|
|
78
|
+
* backend.
|
|
79
|
+
* - `sessionSchema`: input-schema props merged into EVERY tool (the MCP adds
|
|
80
|
+
* `captureId`; the cloud adds nothing — the backend is implicit).
|
|
81
|
+
* - `toolExtras`: per-tool schema/description additions (the MCP's rich,
|
|
82
|
+
* credential-aware `run_proof` contract).
|
|
83
|
+
*/
|
|
84
|
+
export interface AuthoringHost {
|
|
85
|
+
resolveBackend(args: Record<string, unknown>): AuthoringBackend;
|
|
86
|
+
sessionSchema?: {
|
|
87
|
+
properties: Record<string, unknown>;
|
|
88
|
+
required?: string[];
|
|
89
|
+
};
|
|
90
|
+
toolExtras?: Record<string, {
|
|
91
|
+
description?: string;
|
|
92
|
+
properties?: Record<string, unknown>;
|
|
93
|
+
required?: string[];
|
|
94
|
+
}>;
|
|
95
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend seam for the authoring tools. The tool LOGIC (drive the browser →
|
|
3
|
+
* capture → propose → replay → prove) is written once against this interface;
|
|
4
|
+
* each environment supplies a backend:
|
|
5
|
+
* - local (MCP): a Chrome tab over CDP + the in-process attestor SDK.
|
|
6
|
+
* - remote (cloud): the user's Popcorn browser over CDP + the TEE prover.
|
|
7
|
+
*
|
|
8
|
+
* One backend instance == one live session (a page + its capture). Multi
|
|
9
|
+
* session falls out of instancing: the MCP resolves a backend per tab/capture,
|
|
10
|
+
* the cloud one per run — so the shared tools never carry a session id and hold
|
|
11
|
+
* no cross-session state.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
export function makeDraftStore() {
|
|
15
|
+
const map = new Map();
|
|
16
|
+
return {
|
|
17
|
+
put: (draft) => {
|
|
18
|
+
const id = `draft_${randomUUID()}`;
|
|
19
|
+
map.set(id, draft);
|
|
20
|
+
return id;
|
|
21
|
+
},
|
|
22
|
+
get: (id) => map.get(id),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { AuthoringBackend, AuthoringHost, Draft, DraftStore, ProveOptions, SecretParams, } from './backend.ts';
|
|
2
|
+
export { makeDraftStore } from './backend.ts';
|
|
3
|
+
export { buildAuthoringTools } from './tools.ts';
|
|
4
|
+
export { defineTool, type RegisteredTool, type ToolDefinition, } from '../tools/registry.ts';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { makeDraftStore } from "./backend.js";
|
|
2
|
+
export { buildAuthoringTools } from "./tools.js";
|
|
3
|
+
// The shared tool primitive — re-exported so consumers (the cloud loop) get the
|
|
4
|
+
// RegisteredTool type/`defineTool` without importing the MCP subsystem.
|
|
5
|
+
export { defineTool, } from "../tools/registry.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type RegisteredTool } from '../tools/registry.ts';
|
|
2
|
+
import type { AuthoringHost } from './backend.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Build the authoring tools for a host as plain {@link RegisteredTool}s — the
|
|
5
|
+
* same primitive the MCP-only tools use. Each handler resolves the backend via
|
|
6
|
+
* `host.resolveBackend(args)`; the schema merges the host's `sessionSchema`
|
|
7
|
+
* (e.g. `captureId`) and any per-tool `toolExtras`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildAuthoringTools(host: AuthoringHost): RegisteredTool[];
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { draftProvider } from "../provider/draft.js";
|
|
2
|
+
import { OPRF_MAX_BYTES } from "../provider/matcher.js";
|
|
3
|
+
import { normalizeCandidates } from "../provider/normalize.js";
|
|
4
|
+
import { verificationWarnings } from "../provider/verification-checks.js";
|
|
5
|
+
import { defineTool } from "../tools/registry.js";
|
|
6
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7
|
+
/** Secret-free projection of a captured request for tool output. */
|
|
8
|
+
function publicRequest(r) {
|
|
9
|
+
return {
|
|
10
|
+
requestId: r.requestId,
|
|
11
|
+
method: r.method,
|
|
12
|
+
url: r.url,
|
|
13
|
+
status: r.status,
|
|
14
|
+
contentType: r.contentType,
|
|
15
|
+
size: r.size,
|
|
16
|
+
...(r.graphqlOp ? { graphqlOp: r.graphqlOp } : {}),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function matchesFilter(r, f) {
|
|
20
|
+
if (f.method && r.method !== f.method.toUpperCase()) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (f.urlGlob && !globMatch(f.urlGlob, r.url)) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (f.statusMin !== undefined && r.status < f.statusMin) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (f.statusMax !== undefined && r.status > f.statusMax) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (f.contentType && !r.contentType.includes(f.contentType)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (f.graphqlOp && r.graphqlOp !== f.graphqlOp) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
function globMatch(pattern, str) {
|
|
41
|
+
const re = new RegExp('^'
|
|
42
|
+
+ pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
|
|
43
|
+
+ '$');
|
|
44
|
+
return re.test(str);
|
|
45
|
+
}
|
|
46
|
+
async function evalInPage(backend, expression) {
|
|
47
|
+
const res = (await backend.sendCdp('Runtime.evaluate', {
|
|
48
|
+
expression, returnByValue: true, awaitPromise: true,
|
|
49
|
+
}));
|
|
50
|
+
if (res.exceptionDetails) {
|
|
51
|
+
return {
|
|
52
|
+
error: res.exceptionDetails.exception?.description
|
|
53
|
+
?? res.exceptionDetails.text
|
|
54
|
+
?? 'evaluation error',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { value: res.result?.value };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Build the authoring tools for a host as plain {@link RegisteredTool}s — the
|
|
61
|
+
* same primitive the MCP-only tools use. Each handler resolves the backend via
|
|
62
|
+
* `host.resolveBackend(args)`; the schema merges the host's `sessionSchema`
|
|
63
|
+
* (e.g. `captureId`) and any per-tool `toolExtras`.
|
|
64
|
+
*/
|
|
65
|
+
export function buildAuthoringTools(host) {
|
|
66
|
+
const mk = (spec) => {
|
|
67
|
+
const extra = host.toolExtras?.[spec.name];
|
|
68
|
+
const required = [
|
|
69
|
+
...(host.sessionSchema?.required ?? []),
|
|
70
|
+
...(spec.required ?? []),
|
|
71
|
+
...(extra?.required ?? []),
|
|
72
|
+
];
|
|
73
|
+
const inputSchema = {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
...(host.sessionSchema?.properties ?? {}),
|
|
77
|
+
...spec.properties,
|
|
78
|
+
...(extra?.properties ?? {}),
|
|
79
|
+
},
|
|
80
|
+
...(required.length ? { required } : {}),
|
|
81
|
+
};
|
|
82
|
+
return defineTool({
|
|
83
|
+
name: spec.name,
|
|
84
|
+
description: extra?.description ?? spec.description,
|
|
85
|
+
inputSchema,
|
|
86
|
+
}, (args) => spec.run(host.resolveBackend(args), args));
|
|
87
|
+
};
|
|
88
|
+
return [
|
|
89
|
+
mk({
|
|
90
|
+
name: 'navigate',
|
|
91
|
+
description: 'Navigate the browser tab to a URL, waiting for load.',
|
|
92
|
+
properties: {
|
|
93
|
+
url: { type: 'string' },
|
|
94
|
+
timeoutMs: { type: 'number' },
|
|
95
|
+
},
|
|
96
|
+
required: ['url'],
|
|
97
|
+
run: async (backend, args) => {
|
|
98
|
+
const timeoutMs = Number(args.timeoutMs) || 30_000;
|
|
99
|
+
await backend.sendCdp('Page.enable');
|
|
100
|
+
// Start listening BEFORE navigating so the load event isn't missed.
|
|
101
|
+
const load = backend.waitForEvent('Page.loadEventFired', timeoutMs);
|
|
102
|
+
const nav = (await backend.sendCdp('Page.navigate', { url: String(args.url) }));
|
|
103
|
+
if (nav.errorText) {
|
|
104
|
+
return { ok: false, error: nav.errorText };
|
|
105
|
+
}
|
|
106
|
+
const loadStatus = await load;
|
|
107
|
+
const final = await evalInPage(backend, 'location.href');
|
|
108
|
+
return { ok: true, loadStatus, finalUrl: final.value };
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
mk({
|
|
112
|
+
name: 'eval_in_page',
|
|
113
|
+
description: 'Evaluate a JavaScript expression in the page and return its value '
|
|
114
|
+
+ '(use to read the displayed value, check login state, etc.).',
|
|
115
|
+
properties: { expression: { type: 'string' } },
|
|
116
|
+
required: ['expression'],
|
|
117
|
+
run: (backend, args) => evalInPage(backend, String(args.expression)),
|
|
118
|
+
}),
|
|
119
|
+
mk({
|
|
120
|
+
name: 'wait_for_page',
|
|
121
|
+
description: 'Poll a JavaScript expression until it is truthy or the timeout '
|
|
122
|
+
+ 'elapses (e.g. wait for the user to finish logging in).',
|
|
123
|
+
properties: {
|
|
124
|
+
expression: { type: 'string' },
|
|
125
|
+
timeoutMs: { type: 'number' },
|
|
126
|
+
},
|
|
127
|
+
required: ['expression'],
|
|
128
|
+
run: async (backend, args) => {
|
|
129
|
+
const expr = String(args.expression);
|
|
130
|
+
const deadline = Date.now() + (Number(args.timeoutMs) || 180_000);
|
|
131
|
+
while (Date.now() < deadline) {
|
|
132
|
+
const { value } = await evalInPage(backend, expr);
|
|
133
|
+
if (value) {
|
|
134
|
+
return { ready: true };
|
|
135
|
+
}
|
|
136
|
+
await sleep(1000);
|
|
137
|
+
}
|
|
138
|
+
return { ready: false };
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
mk({
|
|
142
|
+
name: 'get_cookies',
|
|
143
|
+
description: 'List the NAMES of cookies currently set for the page (values are '
|
|
144
|
+
+ 'NEVER returned). The most reliable, language- and DOM-independent '
|
|
145
|
+
+ 'login signal: after the user logs in, an auth/session cookie '
|
|
146
|
+
+ 'appears (e.g. session/sid/token/auth/jwt/_user/logged_in). '
|
|
147
|
+
+ 'Includes httpOnly cookies the page JS cannot see.',
|
|
148
|
+
properties: {},
|
|
149
|
+
run: async (backend) => {
|
|
150
|
+
const res = (await backend.sendCdp('Network.getCookies'));
|
|
151
|
+
return (res.cookies ?? []).map((c) => ({
|
|
152
|
+
name: c.name,
|
|
153
|
+
...(c.domain ? { domain: c.domain } : {}),
|
|
154
|
+
...(c.session ? { session: true } : {}),
|
|
155
|
+
...(c.httpOnly ? { httpOnly: true } : {}),
|
|
156
|
+
}));
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
mk({
|
|
160
|
+
name: 'list_requests',
|
|
161
|
+
description: 'List captured network requests (method/url/status/size), newest '
|
|
162
|
+
+ 'last. Optionally filter by method / urlGlob / status range / '
|
|
163
|
+
+ 'contentType / graphqlOp. Metadata only — secrets never included.',
|
|
164
|
+
properties: {
|
|
165
|
+
filter: {
|
|
166
|
+
type: 'object',
|
|
167
|
+
properties: {
|
|
168
|
+
method: { type: 'string' },
|
|
169
|
+
urlGlob: { type: 'string' },
|
|
170
|
+
statusMin: { type: 'number' },
|
|
171
|
+
statusMax: { type: 'number' },
|
|
172
|
+
contentType: { type: 'string' },
|
|
173
|
+
graphqlOp: { type: 'string' },
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
run: async (backend, args) => {
|
|
178
|
+
const filter = (args.filter ?? {});
|
|
179
|
+
return [...backend.capture.requests.values()]
|
|
180
|
+
.filter((r) => matchesFilter(r, filter))
|
|
181
|
+
.map(publicRequest);
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
mk({
|
|
185
|
+
name: 'find_requests_containing',
|
|
186
|
+
description: 'Search captured responses for any encoding of `value` (raw, '
|
|
187
|
+
+ 'URL/JSON/HTML-escaped, …). Returns a ranked candidate list with '
|
|
188
|
+
+ 'the matched variant, score, and a short snippet — used to locate '
|
|
189
|
+
+ 'the request that returns a target. Server-side; bodies stay here.',
|
|
190
|
+
properties: {
|
|
191
|
+
value: { type: 'string' },
|
|
192
|
+
limit: { type: 'number' },
|
|
193
|
+
},
|
|
194
|
+
required: ['value'],
|
|
195
|
+
run: async (backend, args) => {
|
|
196
|
+
const variants = normalizeCandidates(String(args.value));
|
|
197
|
+
const ranked = [];
|
|
198
|
+
for (const r of backend.capture.requests.values()) {
|
|
199
|
+
if (!r.responseBody) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
let hit;
|
|
203
|
+
for (const v of variants) {
|
|
204
|
+
const idx = r.responseBody.indexOf(v);
|
|
205
|
+
if (idx >= 0) {
|
|
206
|
+
const start = Math.max(0, idx - 40);
|
|
207
|
+
const end = Math.min(r.responseBody.length, idx + v.length + 40);
|
|
208
|
+
hit = {
|
|
209
|
+
snippet: r.responseBody.slice(start, end), variant: v,
|
|
210
|
+
};
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!hit) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// Prefer JSON responses + smaller bodies (likely API endpoints).
|
|
218
|
+
const score = (r.contentType.includes('json') ? 10 : 0)
|
|
219
|
+
+ Math.max(0, 5 - Math.log10(Math.max(r.size, 1)));
|
|
220
|
+
ranked.push({
|
|
221
|
+
...publicRequest(r),
|
|
222
|
+
matchedVariant: hit.variant,
|
|
223
|
+
snippet: hit.snippet,
|
|
224
|
+
score,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
ranked.sort((a, b) => b.score - a.score);
|
|
228
|
+
const limit = Number(args.limit) || 10;
|
|
229
|
+
return { candidates: ranked.slice(0, limit) };
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
mk({
|
|
233
|
+
name: 'propose_provider',
|
|
234
|
+
description: 'Draft a provider from ONE captured request. Pass `targets` (an '
|
|
235
|
+
+ 'array): one entry per value to extract, each with its `value` '
|
|
236
|
+
+ '(verbatim) and a distinct `name` (its param). The provider gets '
|
|
237
|
+
+ 'one match + redaction per target. Returns a draftId + recipe '
|
|
238
|
+
+ '(never secrets). Prefer a SINGLE request holding ALL values. '
|
|
239
|
+
+ 'Single-value shorthand: `target` + `name`. Per-target `hash` '
|
|
240
|
+
+ 'OPRF-hashes that value.',
|
|
241
|
+
properties: {
|
|
242
|
+
requestId: { type: 'string' },
|
|
243
|
+
name: { type: 'string' },
|
|
244
|
+
targets: {
|
|
245
|
+
type: 'array',
|
|
246
|
+
items: {
|
|
247
|
+
type: 'object',
|
|
248
|
+
properties: {
|
|
249
|
+
value: { type: 'string' },
|
|
250
|
+
name: { type: 'string' },
|
|
251
|
+
hash: {
|
|
252
|
+
type: 'string',
|
|
253
|
+
enum: ['oprf-raw', 'oprf', 'oprf-mpc'],
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
required: ['value', 'name'],
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
target: { type: 'string' },
|
|
260
|
+
hash: { type: 'string', enum: ['oprf-raw', 'oprf', 'oprf-mpc'] },
|
|
261
|
+
},
|
|
262
|
+
required: ['requestId', 'name'],
|
|
263
|
+
run: async (backend, args) => {
|
|
264
|
+
const requestId = String(args.requestId);
|
|
265
|
+
const providerName = String(args.name);
|
|
266
|
+
const defaultHash = args.hash;
|
|
267
|
+
// Explicit multi-value `targets`, else the single `target` shorthand.
|
|
268
|
+
const raw = Array.isArray(args.targets) ? args.targets : [];
|
|
269
|
+
const targets = raw.length
|
|
270
|
+
? raw.map((t) => {
|
|
271
|
+
const o = t;
|
|
272
|
+
return {
|
|
273
|
+
value: String(o.value),
|
|
274
|
+
name: String(o.name),
|
|
275
|
+
...(typeof o.hash === 'string'
|
|
276
|
+
? { hash: o.hash }
|
|
277
|
+
: {}),
|
|
278
|
+
};
|
|
279
|
+
})
|
|
280
|
+
: typeof args.target === 'string'
|
|
281
|
+
? [{
|
|
282
|
+
value: args.target,
|
|
283
|
+
name: providerName,
|
|
284
|
+
...(defaultHash ? { hash: defaultHash } : {}),
|
|
285
|
+
}]
|
|
286
|
+
: [];
|
|
287
|
+
if (targets.length === 0) {
|
|
288
|
+
throw new Error('propose_provider needs a `target` value or a non-empty '
|
|
289
|
+
+ '`targets` array');
|
|
290
|
+
}
|
|
291
|
+
for (const t of targets) {
|
|
292
|
+
if ((t.hash ?? defaultHash) !== undefined
|
|
293
|
+
&& t.value.length > OPRF_MAX_BYTES) {
|
|
294
|
+
throw new Error(`OPRF caps hashed values at ${OPRF_MAX_BYTES} bytes; got `
|
|
295
|
+
+ `${t.value.length}-byte "${t.value}". Use a tighter `
|
|
296
|
+
+ 'target or drop `hash`.');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const captured = backend.capture.requests.get(requestId);
|
|
300
|
+
if (!captured) {
|
|
301
|
+
throw new Error(`no captured request ${requestId}`);
|
|
302
|
+
}
|
|
303
|
+
if (!captured.responseBody) {
|
|
304
|
+
throw new Error(`request body unavailable for ${requestId}`);
|
|
305
|
+
}
|
|
306
|
+
const { provider, secretRefs, extractions } = draftProvider(captured, targets, providerName, defaultHash);
|
|
307
|
+
// Materialize the real secret header values for later replay/prove;
|
|
308
|
+
// they stay in the server-side draft, never in this result.
|
|
309
|
+
const secrets = {};
|
|
310
|
+
for (const ref of secretRefs) {
|
|
311
|
+
const real = captured.requestHeaders[ref.name];
|
|
312
|
+
if (real !== undefined) {
|
|
313
|
+
secrets[ref.name] = real;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const draftId = backend.drafts.put({
|
|
317
|
+
provider, secretRefs, secrets, requestId, extractions,
|
|
318
|
+
});
|
|
319
|
+
const warnings = verificationWarnings(provider);
|
|
320
|
+
return {
|
|
321
|
+
draftId,
|
|
322
|
+
provider,
|
|
323
|
+
secretRefs,
|
|
324
|
+
...(warnings.length ? { verificationWarnings: warnings } : {}),
|
|
325
|
+
};
|
|
326
|
+
},
|
|
327
|
+
}),
|
|
328
|
+
mk({
|
|
329
|
+
name: 'replay_request',
|
|
330
|
+
description: 'Validate the drafted recipe before proving — confirm its selectors '
|
|
331
|
+
+ 'extract the target value. Returns diagnostics; run before '
|
|
332
|
+
+ 'proving. Set `withoutSecrets: true` for the AUTH-BOUND check: it '
|
|
333
|
+
+ 're-issues the request with NO secrets/cookies (an anonymous '
|
|
334
|
+
+ 'visitor). If the values still extract (valid:true), the endpoint '
|
|
335
|
+
+ 'is PUBLIC and the proof would not bind to the user — reject it and '
|
|
336
|
+
+ 'find an auth-bound request instead.',
|
|
337
|
+
properties: {
|
|
338
|
+
draftId: { type: 'string' },
|
|
339
|
+
withoutSecrets: { type: 'boolean' },
|
|
340
|
+
},
|
|
341
|
+
required: ['draftId'],
|
|
342
|
+
run: async (backend, args) => {
|
|
343
|
+
const draft = backend.drafts.get(String(args.draftId));
|
|
344
|
+
if (!draft) {
|
|
345
|
+
throw new Error(`no draft ${String(args.draftId)}`);
|
|
346
|
+
}
|
|
347
|
+
return backend.replay(draft, {
|
|
348
|
+
withoutSecrets: Boolean(args.withoutSecrets),
|
|
349
|
+
});
|
|
350
|
+
},
|
|
351
|
+
}),
|
|
352
|
+
mk({
|
|
353
|
+
name: 'run_proof',
|
|
354
|
+
description: 'Prove the drafted recipe (zkTLS). Only call after replay_request '
|
|
355
|
+
+ 'looks right. Returns the proof result.',
|
|
356
|
+
properties: { draftId: { type: 'string' } },
|
|
357
|
+
required: ['draftId'],
|
|
358
|
+
// The backend shapes its own result: the cloud returns a lean,
|
|
359
|
+
// secret-free summary (and records the proven recipe for publish); the
|
|
360
|
+
// MCP persists the claim + returns the rich developer view. The MCP adds
|
|
361
|
+
// `ownerAddress`/`attestorUrl` to the schema via the host's toolExtras.
|
|
362
|
+
run: async (backend, args) => {
|
|
363
|
+
const draft = backend.drafts.get(String(args.draftId));
|
|
364
|
+
if (!draft) {
|
|
365
|
+
throw new Error(`no draft ${String(args.draftId)}`);
|
|
366
|
+
}
|
|
367
|
+
const opts = {};
|
|
368
|
+
if (typeof args.ownerAddress === 'string') {
|
|
369
|
+
opts.ownerAddress = args.ownerAddress;
|
|
370
|
+
}
|
|
371
|
+
if (typeof args.attestorUrl === 'string') {
|
|
372
|
+
opts.attestorUrl = args.attestorUrl;
|
|
373
|
+
}
|
|
374
|
+
return backend.prove(draft, opts);
|
|
375
|
+
},
|
|
376
|
+
}),
|
|
377
|
+
];
|
|
378
|
+
}
|
|
@@ -30,6 +30,13 @@ export interface ResponseReceivedLike {
|
|
|
30
30
|
* `:`. Stripping at capture time keeps every downstream consumer
|
|
31
31
|
* (`get_request`, draft, replay, proof) clean by construction. */
|
|
32
32
|
export declare function stripPseudoHeaders(headers: Record<string, string>): Record<string, string>;
|
|
33
|
+
/** True when the URL's path names a static asset (by file extension; query and
|
|
34
|
+
* hash are ignored, so `chunk.js?v=1` still counts). The capture engine drops
|
|
35
|
+
* these up front — they never carry JSON/HTML a recipe matches. */
|
|
36
|
+
export declare function isStaticAsset(url: string): boolean;
|
|
37
|
+
/** True when a response Content-Type is a static asset (scripts/styles/fonts/
|
|
38
|
+
* images/media). Parameters (`; charset=…`) and case are ignored. */
|
|
39
|
+
export declare function isStaticAssetMime(mimeType: string | undefined): boolean;
|
|
33
40
|
export interface CaptureSession {
|
|
34
41
|
captureId: string;
|
|
35
42
|
requests: Map<string, CapturedRequest>;
|