@reclaimprotocol/agent 0.1.23 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -20
- package/lib/authoring/backend.d.ts +5 -4
- package/lib/authoring/tools.js +30 -30
- package/lib/cdp/browser-proxy.d.ts +78 -0
- package/lib/cdp/browser-proxy.js +186 -0
- package/lib/cdp/capture-engine.js +8 -7
- package/lib/cdp/client.d.ts +10 -0
- package/lib/cdp/geo.d.ts +10 -0
- package/lib/cdp/geo.js +36 -0
- package/lib/cdp/index.d.ts +1 -0
- package/lib/cdp/index.js +1 -0
- package/lib/live/cloudflared.d.ts +15 -0
- package/lib/live/cloudflared.js +268 -0
- package/lib/live/page.d.ts +20 -0
- package/lib/live/page.js +134 -0
- package/lib/live/tunnel.d.ts +23 -0
- package/lib/live/tunnel.js +120 -0
- package/lib/live/viewer.d.ts +28 -0
- package/lib/live/viewer.js +232 -0
- package/lib/mcp/api/operations.gen.js +34 -26
- package/lib/mcp/api/to-zod.d.ts +1 -1
- package/lib/mcp/api/to-zod.js +1 -1
- package/lib/mcp/api/tools.js +1 -1
- package/lib/mcp/instructions.d.ts +15 -2
- package/lib/mcp/instructions.js +101 -38
- package/lib/mcp/start.js +4 -3
- package/lib/mcp/tools/agent/analyze.js +5 -1
- package/lib/mcp/tools/agent/attach.d.ts +8 -1
- package/lib/mcp/tools/agent/attach.js +166 -22
- package/lib/mcp/tools/agent/build.d.ts +13 -8
- package/lib/mcp/tools/agent/build.js +22 -11
- package/lib/mcp/tools/agent/capture.js +5 -1
- package/lib/mcp/tools/agent/eval.js +3 -1
- package/lib/mcp/tools/agent/find.js +6 -5
- package/lib/mcp/tools/agent/how-it-works.d.ts +15 -7
- package/lib/mcp/tools/agent/how-it-works.js +57 -17
- package/lib/mcp/tools/agent/live-view.d.ts +21 -0
- package/lib/mcp/tools/agent/live-view.js +153 -0
- package/lib/mcp/tools/agent/pod-proof.d.ts +8 -0
- package/lib/mcp/tools/agent/pod-proof.js +64 -0
- package/lib/mcp/tools/agent/propose.js +4 -2
- package/lib/mcp/tools/agent/proxy.d.ts +3 -0
- package/lib/mcp/tools/agent/proxy.js +199 -0
- package/lib/mcp/tools/agent/publish.js +47 -77
- package/lib/mcp/tools/agent/session.d.ts +2 -1
- package/lib/mcp/tools/agent/session.js +26 -7
- package/lib/mcp/tools/authenticate/authenticate.js +26 -13
- package/lib/mcp/tools/mode/mode.d.ts +1 -1
- package/lib/mcp/tools/mode/mode.js +10 -22
- package/lib/old/client.d.ts +1 -1
- package/lib/old/client.js +1 -1
- package/lib/old/instructions.d.ts +9 -1
- package/lib/old/instructions.js +4 -6
- package/lib/old/login-cdp.d.ts +2 -2
- package/lib/old/login-cdp.js +46 -6
- package/lib/old/to-register.d.ts +3 -2
- package/lib/old/to-register.js +1 -1
- package/lib/old/tools/authenticate.js +9 -1
- package/lib/old/tools/get-provider-info.js +6 -5
- package/lib/old/tools/issue-app-credentials.js +3 -2
- package/lib/old/tools/publish.d.ts +1 -1
- package/lib/old/tools/publish.js +90 -131
- package/lib/old/tools/update-provider-metadata.d.ts +1 -1
- package/lib/old/tools/update-provider-metadata.js +1 -1
- package/lib/old/tools/verify-sdk-setup.js +1 -1
- package/lib/proof/attestor.d.ts +3 -3
- package/lib/proof/debug-response-dump.js +4 -4
- package/lib/proof/index.d.ts +1 -0
- package/lib/proof/index.js +1 -0
- package/lib/proof/page-replay.d.ts +1 -1
- package/lib/proof/page-replay.js +1 -1
- package/lib/proof/pod-prover.d.ts +47 -0
- package/lib/proof/pod-prover.js +155 -0
- package/lib/provider/dom-xpath.js +11 -9
- package/lib/provider/draft.d.ts +10 -7
- package/lib/provider/draft.js +11 -9
- package/lib/provider/injection-doc.d.ts +23 -1
- package/lib/provider/injection-doc.js +34 -21
- package/lib/provider/match-redaction-warning.d.ts +2 -2
- package/lib/provider/match-redaction-warning.js +3 -3
- package/lib/provider/matcher.js +13 -12
- package/lib/provider/secrets.d.ts +3 -3
- package/lib/provider/secrets.js +3 -3
- package/lib/provider/to-version.d.ts +7 -6
- package/lib/provider/to-version.js +3 -3
- package/lib/tools/registry.d.ts +5 -5
- package/lib/tools/registry.js +5 -5
- package/package.json +3 -3
- package/samples/demo-server/README.md +7 -6
- package/skill/SKILL.builder.md +356 -189
- package/skill/SKILL.common.md +323 -97
- package/skill/SKILL.old.md +340 -155
- package/skill/builder-mode.md +58 -17
- package/skill/hash-validation.md +170 -0
- package/skill/interception.md +235 -0
- package/skill/js-sdk-integration.md +13 -7
- package/skill/old-mode.md +60 -21
- package/skill/user-script.md +61 -0
- package/src/authoring/backend.ts +5 -4
- package/src/authoring/tools.ts +31 -30
- package/src/cdp/capture-engine.ts +8 -7
- package/src/cdp/client.ts +8 -0
- package/src/cdp/geo.ts +52 -0
- package/src/cdp/index.ts +1 -0
- package/src/consts.ts +1 -0
- package/src/live/cloudflared.ts +348 -0
- package/src/live/page.ts +145 -0
- package/src/live/tunnel.ts +157 -0
- package/src/live/viewer.ts +293 -0
- package/src/mcp/api/operations.gen.ts +34 -26
- package/src/mcp/api/to-zod.ts +1 -1
- package/src/mcp/api/tools.ts +1 -1
- package/src/mcp/instructions.ts +121 -48
- package/src/mcp/start.ts +4 -3
- package/src/mcp/tools/agent/analyze.ts +5 -1
- package/src/mcp/tools/agent/attach.ts +194 -22
- package/src/mcp/tools/agent/build.ts +26 -15
- package/src/mcp/tools/agent/capture.ts +5 -1
- package/src/mcp/tools/agent/eval.ts +4 -1
- package/src/mcp/tools/agent/find.ts +6 -5
- package/src/mcp/tools/agent/how-it-works.ts +62 -21
- package/src/mcp/tools/agent/live-view.ts +198 -0
- package/src/mcp/tools/agent/pod-proof.ts +90 -0
- package/src/mcp/tools/agent/propose.ts +4 -2
- package/src/mcp/tools/agent/publish.ts +52 -77
- package/src/mcp/tools/agent/session.ts +26 -5
- package/src/mcp/tools/authenticate/authenticate.ts +28 -13
- package/src/mcp/tools/mode/mode.ts +10 -22
- package/src/old/client.ts +2 -2
- package/src/old/instructions.ts +4 -10
- package/src/old/login-cdp.ts +50 -6
- package/src/old/to-register.ts +3 -2
- package/src/old/tools/authenticate.ts +9 -1
- package/src/old/tools/get-provider-info.ts +6 -5
- package/src/old/tools/issue-app-credentials.ts +3 -2
- package/src/old/tools/publish.ts +95 -131
- package/src/old/tools/update-provider-metadata.ts +1 -1
- package/src/old/tools/verify-sdk-setup.ts +1 -1
- package/src/proof/attestor.ts +3 -3
- package/src/proof/debug-response-dump.ts +4 -4
- package/src/proof/index.ts +8 -0
- package/src/proof/page-replay.ts +1 -1
- package/src/proof/pod-prover.ts +231 -0
- package/src/provider/dom-xpath.ts +11 -9
- package/src/provider/draft.ts +13 -10
- package/src/provider/injection-doc.ts +37 -24
- package/src/provider/match-redaction-warning.ts +3 -3
- package/src/provider/matcher.ts +13 -12
- package/src/provider/secrets.ts +3 -3
- package/src/provider/to-version.ts +11 -9
- package/src/tools/registry.ts +5 -5
- package/skill/injection-api.md +0 -364
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# User scripts (`jsUserScripts` / `customInjection`)
|
|
2
|
+
|
|
3
|
+
A provider can inject **plain JavaScript** that runs in the page before every
|
|
4
|
+
page load. Builder stores it as `webSettings.jsUserScripts`; old-devtools stores
|
|
5
|
+
it as `customInjection`. Identical semantics, different field name.
|
|
6
|
+
|
|
7
|
+
## What the script is for
|
|
8
|
+
|
|
9
|
+
The verification client **already intercepts the page's network requests
|
|
10
|
+
automatically** — the script does not need to capture, forward, or "claim" them.
|
|
11
|
+
Its job is to get the page into the state where the target request happens,
|
|
12
|
+
using standard browser APIs:
|
|
13
|
+
|
|
14
|
+
- **Navigate** — `window.location.href = '…'`, `location.assign(…)`, the
|
|
15
|
+
history API.
|
|
16
|
+
- **Interact** — select an element and `.click()` it, submit a form, dispatch
|
|
17
|
+
events.
|
|
18
|
+
- **Wait** — poll the DOM until login/MFA completes or content loads, then
|
|
19
|
+
trigger the next step.
|
|
20
|
+
|
|
21
|
+
Write it like any page script. It runs before EVERY page load, so keep it
|
|
22
|
+
idempotent, cheap, and guarded against firing at the wrong time.
|
|
23
|
+
|
|
24
|
+
Both backends run it the same way: before every page load, including before the
|
|
25
|
+
first navigation to `initialUrl`/`loginUrl`.
|
|
26
|
+
|
|
27
|
+
## `window.Reclaim` — rarely needed
|
|
28
|
+
|
|
29
|
+
`window.Reclaim` is an OPTIONAL bridge for a handful of edge cases. Most
|
|
30
|
+
providers never touch it, and **it does not do navigation** — use the standard
|
|
31
|
+
browser APIs above for that.
|
|
32
|
+
|
|
33
|
+
**On the Builder verification client it is installed but INERT.** Both
|
|
34
|
+
`window.Reclaim` and `window.reclaimInterceptor` are injected before every
|
|
35
|
+
document load, so a call won't throw — but the run never drains the
|
|
36
|
+
`Reclaim` outbox and never delivers traffic to
|
|
37
|
+
`reclaimInterceptor.addResponseMiddleware`, so nothing you send is acted on
|
|
38
|
+
and no middleware ever fires. Write Builder-targeted scripts in plain browser
|
|
39
|
+
JS unconditionally, and let the capture loop find the request itself.
|
|
40
|
+
|
|
41
|
+
Don't reach for it by default. When an edge case genuinely requires it, consult
|
|
42
|
+
the authoritative API surface rather than guessing:
|
|
43
|
+
<https://github.com/reclaimprotocol/provider-web-script-dev/blob/main/src/support/env.d.ts>
|
|
44
|
+
|
|
45
|
+
## Declaring the requests the script fires
|
|
46
|
+
|
|
47
|
+
Requests the script triggers beyond the proven one (for example, pagination) are
|
|
48
|
+
declared as `allowedJsRequests` (Builder) / `allowedInjectedRequestData`
|
|
49
|
+
(old-devtools). Same concept, different field name.
|
|
50
|
+
|
|
51
|
+
⚠️ **Before populating them to gate `verifyProof(proof, { providerId })`**, read
|
|
52
|
+
topic `hash-validation` — the declared `url` is hashed VERBATIM, so a
|
|
53
|
+
session-unique URL there makes `verifyProof` reject every real proof. Leaving
|
|
54
|
+
the field empty is better than populating it wrong.
|
|
55
|
+
|
|
56
|
+
## Related topics
|
|
57
|
+
|
|
58
|
+
- `interception` — `injectionType`/`interceptorType`, live interception, and the
|
|
59
|
+
document-replay flag.
|
|
60
|
+
- `hash-validation` — `bodySniff`, and how `verifyProof({ providerId })` gates
|
|
61
|
+
on the provider's declared spec.
|
package/src/authoring/backend.ts
CHANGED
|
@@ -85,9 +85,10 @@ export interface AuthoringBackend {
|
|
|
85
85
|
/** This session's drafts (propose → replay/run_proof). */
|
|
86
86
|
readonly drafts: DraftStore
|
|
87
87
|
/** Prove a drafted recipe. Local: attestor SDK (rich claim view). Remote: TEE
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
88
|
+
* `/reclaim/prove` (lean, secret-free summary). Returns the environment's
|
|
89
|
+
* display shape. A backend may record the proven recipe internally (for
|
|
90
|
+
* example, the cloud publish step) — that side effect is the backend's, not
|
|
91
|
+
* ours. */
|
|
91
92
|
prove(draft: Draft, opts?: ProveOptions): Promise<unknown>
|
|
92
93
|
/**
|
|
93
94
|
* Validate/replay a drafted recipe before proving. Both local and remote
|
|
@@ -96,7 +97,7 @@ export interface AuthoringBackend {
|
|
|
96
97
|
* always reflects the real browser session. With `withoutSecrets` it is
|
|
97
98
|
* the AUTH-BOUND check: re-issue anonymously (`credentials: 'omit'`, no
|
|
98
99
|
* secret headers) and report whether the values are STILL extractable —
|
|
99
|
-
*
|
|
100
|
+
* that is, the endpoint is public. Without it, validate extraction for the
|
|
100
101
|
* authenticated request. The shape is environment-specific and passed
|
|
101
102
|
* straight through as the tool's result.
|
|
102
103
|
*/
|
package/src/authoring/tools.ts
CHANGED
|
@@ -116,7 +116,10 @@ export function authoringTools(
|
|
|
116
116
|
defineTool<Record<string, unknown>>(
|
|
117
117
|
{
|
|
118
118
|
name: 'navigate',
|
|
119
|
-
description:
|
|
119
|
+
description:
|
|
120
|
+
'Navigate the captured tab to a URL and wait for its load event. '
|
|
121
|
+
+ 'Returns the URL the tab actually landed on, so you can detect a '
|
|
122
|
+
+ 'redirect to a login page.',
|
|
120
123
|
inputSchema: objectSchema(
|
|
121
124
|
{ url: { type: 'string' }, timeoutMs: { type: 'number' } },
|
|
122
125
|
['url'],
|
|
@@ -144,8 +147,10 @@ export function authoringTools(
|
|
|
144
147
|
{
|
|
145
148
|
name: 'eval_in_page',
|
|
146
149
|
description:
|
|
147
|
-
'Evaluate a JavaScript expression in the page and return its value '
|
|
148
|
-
+ '
|
|
150
|
+
'Evaluate a JavaScript expression in the page and return its value. '
|
|
151
|
+
+ 'Use it to read the value being proven out of the DOM, to check '
|
|
152
|
+
+ 'login state, or to trigger the target request (for example, '
|
|
153
|
+
+ '`location.reload()`).',
|
|
149
154
|
inputSchema: objectSchema(
|
|
150
155
|
{ expression: { type: 'string' } }, ['expression'],
|
|
151
156
|
),
|
|
@@ -156,8 +161,10 @@ export function authoringTools(
|
|
|
156
161
|
{
|
|
157
162
|
name: 'wait_for_page',
|
|
158
163
|
description:
|
|
159
|
-
'Poll a JavaScript expression until it is truthy or
|
|
160
|
-
+ 'elapses
|
|
164
|
+
'Poll a JavaScript expression once a second until it is truthy or '
|
|
165
|
+
+ 'the timeout elapses. Use it to wait for content to appear. To '
|
|
166
|
+
+ 'wait for a login, use wait_for_login instead — page JavaScript '
|
|
167
|
+
+ 'cannot see the httpOnly session cookie.',
|
|
161
168
|
inputSchema: objectSchema(
|
|
162
169
|
{ expression: { type: 'string' }, timeoutMs: { type: 'number' } },
|
|
163
170
|
['expression'],
|
|
@@ -183,26 +190,18 @@ export function authoringTools(
|
|
|
183
190
|
{
|
|
184
191
|
name: 'test_user_script',
|
|
185
192
|
description:
|
|
186
|
-
'Dry-run a USER SCRIPT
|
|
187
|
-
+ '
|
|
188
|
-
+ '
|
|
189
|
-
+ '
|
|
190
|
-
+ '
|
|
191
|
-
+ '
|
|
192
|
-
+ '
|
|
193
|
-
+ '
|
|
194
|
-
+
|
|
195
|
-
+ '
|
|
196
|
-
+ '`navigateUrl`
|
|
197
|
-
+ '
|
|
198
|
-
+ '— safe to call repeatedly). Without `navigateUrl` the script is '
|
|
199
|
-
+ 'left installed for you to navigate yourself (any way you like) '
|
|
200
|
-
+ 'then re-call this tool, or read '
|
|
201
|
-
+ '`window.__reclaimUserScriptResult` via eval_in_page — in that '
|
|
202
|
-
+ 'manual mode it is NOT auto-removed and keeps running on every '
|
|
203
|
-
+ 'later navigation until a follow-up call with `navigateUrl` '
|
|
204
|
-
+ 'removes it, so avoid stacking several manual-mode calls in a '
|
|
205
|
-
+ 'row.',
|
|
193
|
+
'Dry-run a USER SCRIPT (the `jsUserScripts` / `customInjection` '
|
|
194
|
+
+ 'field) in the LIVE attached tab, BEFORE publishing. Installs '
|
|
195
|
+
+ '`script` via CDP so it runs on the next navigation exactly as '
|
|
196
|
+
+ 'the real field would, then reports whether it ran and what it '
|
|
197
|
+
+ 'threw. With `navigateUrl`, it navigates there and removes the '
|
|
198
|
+
+ 'script once the result is read — self-cleaning, safe to repeat. '
|
|
199
|
+
+ 'WITHOUT `navigateUrl` the script stays installed (navigate '
|
|
200
|
+
+ 'yourself, then re-call this tool or read '
|
|
201
|
+
+ '`window.__reclaimUserScriptResult` via eval_in_page) and keeps '
|
|
202
|
+
+ 'running on every later navigation until a call WITH '
|
|
203
|
+
+ '`navigateUrl` removes it — so don\'t stack manual-mode calls. '
|
|
204
|
+
+ 'Guide: how_it_works({ topic: "user-script" }).',
|
|
206
205
|
inputSchema: objectSchema(
|
|
207
206
|
{
|
|
208
207
|
script: { type: 'string' },
|
|
@@ -246,8 +245,9 @@ export function authoringTools(
|
|
|
246
245
|
'Installed for the NEXT navigation only — it did not run on '
|
|
247
246
|
+ 'the current page, and stays installed (runs on every later '
|
|
248
247
|
+ 'navigation too) until a follow-up call with navigateUrl '
|
|
249
|
-
+ 'removes it. Navigate now (
|
|
250
|
-
+ 'window.__reclaimUserScriptResult via
|
|
248
|
+
+ 'removes it. Navigate now (for example, with the navigate '
|
|
249
|
+
+ 'tool), then read window.__reclaimUserScriptResult via '
|
|
250
|
+
+ 'eval_in_page, or call '
|
|
251
251
|
+ 'test_user_script again with navigateUrl.',
|
|
252
252
|
}
|
|
253
253
|
}
|
|
@@ -298,7 +298,8 @@ export function authoringTools(
|
|
|
298
298
|
'List the NAMES of cookies currently set for the page (values are '
|
|
299
299
|
+ 'NEVER returned). The most reliable, language- and DOM-independent '
|
|
300
300
|
+ 'login signal: after the user logs in, an auth/session cookie '
|
|
301
|
-
+ 'appears (
|
|
301
|
+
+ 'appears (for example, session, sid, token, auth, jwt, _user, '
|
|
302
|
+
+ 'logged_in). '
|
|
302
303
|
+ 'Includes httpOnly cookies the page JS cannot see.',
|
|
303
304
|
inputSchema: objectSchema({}),
|
|
304
305
|
},
|
|
@@ -354,8 +355,8 @@ export function authoringTools(
|
|
|
354
355
|
'userFirstVisit', 'cf_', '_dd_', 'bcookie', 'bscookie', 'lidc',
|
|
355
356
|
].join('|') + ')', 'i')
|
|
356
357
|
// A real session/auth cookie name. Deliberately NOT bare `token` — a
|
|
357
|
-
// challenge/CSRF token (
|
|
358
|
-
// set BEFORE auth completes. Any new httpOnly cookie is also a
|
|
358
|
+
// challenge/CSRF token (for example, LinkedIn `chp_token`) contains it
|
|
359
|
+
// and is set BEFORE auth completes. Any new httpOnly cookie is also a
|
|
359
360
|
// candidate (server-set session cookies are usually httpOnly).
|
|
360
361
|
const SESSION = /sess|sid|jwt|_session|auth|logged.?in|remember|li_at/i
|
|
361
362
|
// Auth/challenge URL paths — while the page is still on one, login is
|
|
@@ -89,9 +89,10 @@ export function isStaticAsset(url: string): boolean {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/** Response Content-Types for static assets. The URL-extension filter misses
|
|
92
|
-
* extension-less bundler/CDN URLs (
|
|
93
|
-
* MIME at response time. `text/html`, `application/json`, and
|
|
94
|
-
* are NOT here — those are the document/API shapes recipes
|
|
92
|
+
* extension-less bundler/CDN URLs (for example, `/assets/3b8f1c`), so we also
|
|
93
|
+
* drop by MIME at response time. `text/html`, `application/json`, and
|
|
94
|
+
* `text/plain` are NOT here — those are the document/API shapes recipes
|
|
95
|
+
* match. */
|
|
95
96
|
const STATIC_ASSET_MIME_EXACT = new Set([
|
|
96
97
|
'text/css',
|
|
97
98
|
'application/javascript', 'text/javascript', 'application/x-javascript',
|
|
@@ -272,9 +273,9 @@ export function createCaptureEngine(opts: CaptureEngineOptions): CaptureEngine {
|
|
|
272
273
|
function onResponseReceived(p: ResponseReceivedLike) {
|
|
273
274
|
const f = inFlight.get(p.requestId)
|
|
274
275
|
if(!f) {
|
|
275
|
-
// No in-flight entry → the request was skipped (
|
|
276
|
-
// or never seen. Discard any buffered response extra-info so a
|
|
277
|
-
// asset can't leave a dangling entry.
|
|
276
|
+
// No in-flight entry → the request was skipped (for example, a static
|
|
277
|
+
// asset) or never seen. Discard any buffered response extra-info so a
|
|
278
|
+
// skipped asset can't leave a dangling entry.
|
|
278
279
|
pendingResponseExtra.delete(p.requestId)
|
|
279
280
|
return
|
|
280
281
|
}
|
|
@@ -339,7 +340,7 @@ export function createCaptureEngine(opts: CaptureEngineOptions): CaptureEngine {
|
|
|
339
340
|
r.finishedAt = Date.now()
|
|
340
341
|
} catch{
|
|
341
342
|
// Body might be unavailable for
|
|
342
|
-
// some content-types (
|
|
343
|
+
// some content-types (for example, websocket). Skip.
|
|
343
344
|
}
|
|
344
345
|
}
|
|
345
346
|
}
|
package/src/cdp/client.ts
CHANGED
|
@@ -30,6 +30,14 @@ interface CdpTabEventMap {
|
|
|
30
30
|
headers: Record<string, string>
|
|
31
31
|
}
|
|
32
32
|
'Network.loadingFinished': { requestId: string }
|
|
33
|
+
/** Screencast frames, for the live view (`live/viewer.ts`). Each carries a
|
|
34
|
+
* `sessionId` that MUST be acked with `Page.screencastFrameAck`, or CDP
|
|
35
|
+
* stops sending. */
|
|
36
|
+
'Page.screencastFrame': {
|
|
37
|
+
data: string
|
|
38
|
+
sessionId: number
|
|
39
|
+
metadata?: { timestamp?: number }
|
|
40
|
+
}
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export interface CdpTabClient {
|
package/src/cdp/geo.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Country-code suggestion for a country-routed Popcorn browser session, when
|
|
3
|
+
* the caller doesn't name one explicitly. `attach_browser` returns this as the
|
|
4
|
+
* suggestion to confirm with the developer — the calling machine's own
|
|
5
|
+
* public-IP country, resolved the same way reclaim-portal's `ip-geolocation`
|
|
6
|
+
* util does.
|
|
7
|
+
*/
|
|
8
|
+
import { LOGGER } from '../logger.ts'
|
|
9
|
+
|
|
10
|
+
export interface IpGeolocation {
|
|
11
|
+
countryCode: string
|
|
12
|
+
country?: string
|
|
13
|
+
region?: string
|
|
14
|
+
city?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const IPINFO_URL = 'https://ipinfo.io/json'
|
|
18
|
+
const FALLBACK_COUNTRY_CODE = 'US'
|
|
19
|
+
|
|
20
|
+
/** Resolve the calling machine's own public-IP country. Never throws — a
|
|
21
|
+
* lookup failure just loses the "best guess" convenience, falling back to
|
|
22
|
+
* {@link FALLBACK_COUNTRY_CODE} rather than blocking applying a proxy. */
|
|
23
|
+
export async function suggestCountryFromIp(
|
|
24
|
+
fetchImpl: typeof fetch = fetch,
|
|
25
|
+
): Promise<IpGeolocation> {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetchImpl(IPINFO_URL)
|
|
28
|
+
if(!res.ok) {
|
|
29
|
+
throw new Error(`ipinfo.io ${res.status}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const data = await res.json() as {
|
|
33
|
+
country?: string
|
|
34
|
+
region?: string
|
|
35
|
+
city?: string
|
|
36
|
+
}
|
|
37
|
+
const countryCode = data.country?.toUpperCase()
|
|
38
|
+
if(!countryCode) {
|
|
39
|
+
throw new Error('ipinfo.io response had no country')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
countryCode,
|
|
44
|
+
country: data.country,
|
|
45
|
+
region: data.region,
|
|
46
|
+
city: data.city,
|
|
47
|
+
}
|
|
48
|
+
} catch(err) {
|
|
49
|
+
LOGGER.warn({ err }, 'suggestCountryFromIp failed; using fallback')
|
|
50
|
+
return { countryCode: FALLBACK_COUNTRY_CODE }
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/cdp/index.ts
CHANGED
package/src/consts.ts
CHANGED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding — and if necessary fetching — the `cloudflared` binary the live view
|
|
3
|
+
* tunnels through.
|
|
4
|
+
*
|
|
5
|
+
* Resolution order, first hit wins:
|
|
6
|
+
* 1. `RECLAIM_CLOUDFLARED_PATH`, for an air-gapped box or a corporate mirror.
|
|
7
|
+
* 2. `cloudflared` on PATH — someone who installed it with a package manager
|
|
8
|
+
* keeps their own copy, updated their way.
|
|
9
|
+
* 3. `~/.reclaim/bin/cloudflared`, ours from a previous run.
|
|
10
|
+
* 4. Download it from Cloudflare's GitHub releases into `~/.reclaim/bin`.
|
|
11
|
+
*
|
|
12
|
+
* Step 4 happens on FIRST USE, never at install time. The npm `cloudflared`
|
|
13
|
+
* package would have done this for us, but its postinstall pulls a ~38 MB
|
|
14
|
+
* binary on every `npm install` of this package — a cost paid by everyone who
|
|
15
|
+
* never shares a live view. `run_proof`'s ZK circuits are deferred for exactly
|
|
16
|
+
* the same reason.
|
|
17
|
+
*
|
|
18
|
+
* Every download is SHA-256 verified. Cloudflare publishes a checksum table in
|
|
19
|
+
* the release notes (not as a release asset), so we read the release metadata
|
|
20
|
+
* first, take the digest for our asset, and refuse to install anything that
|
|
21
|
+
* doesn't match. The release TAG from that same response is used to build the
|
|
22
|
+
* download URL, so the checksum and the bytes always come from one release —
|
|
23
|
+
* `/latest/download/` could otherwise hand us a newer build than the table we
|
|
24
|
+
* just read.
|
|
25
|
+
*
|
|
26
|
+
* Verification is not optional: this writes an executable to the developer's
|
|
27
|
+
* machine. If the checksum can't be established, we fail rather than install.
|
|
28
|
+
* The escape hatch is to install cloudflared yourself — step 2 then wins and
|
|
29
|
+
* nothing is downloaded.
|
|
30
|
+
*/
|
|
31
|
+
import { execFile } from 'node:child_process'
|
|
32
|
+
import { createHash } from 'node:crypto'
|
|
33
|
+
import {
|
|
34
|
+
chmod,
|
|
35
|
+
mkdir,
|
|
36
|
+
rename,
|
|
37
|
+
rm,
|
|
38
|
+
stat,
|
|
39
|
+
writeFile
|
|
40
|
+
} from 'node:fs/promises'
|
|
41
|
+
import { arch, platform } from 'node:os'
|
|
42
|
+
import { join } from 'node:path'
|
|
43
|
+
import { promisify } from 'node:util'
|
|
44
|
+
import { gunzipSync } from 'node:zlib'
|
|
45
|
+
import { LOGGER } from '../logger.ts'
|
|
46
|
+
import { reclaimHomeDirPath } from '../paths.ts'
|
|
47
|
+
|
|
48
|
+
const execFileAsync = promisify(execFile)
|
|
49
|
+
|
|
50
|
+
/** Release metadata: the tag to pin to, and the checksum table in its notes. */
|
|
51
|
+
const LATEST_RELEASE_API =
|
|
52
|
+
'https://api.github.com/repos/cloudflare/cloudflared/releases/latest'
|
|
53
|
+
/** Asset downloads, keyed by the tag we read the checksums from. */
|
|
54
|
+
function releaseDownloadUrl(tag: string, asset: string): string {
|
|
55
|
+
return 'https://github.com/cloudflare/cloudflared/releases/download'
|
|
56
|
+
+ `/${tag}/${asset}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Give up on a slow or stalled download rather than hanging the tool. */
|
|
60
|
+
const DOWNLOAD_TIMEOUT_MS = 180_000
|
|
61
|
+
|
|
62
|
+
export interface CloudflaredBinary {
|
|
63
|
+
/** Path to run. */
|
|
64
|
+
path: string
|
|
65
|
+
/** How it was found — surfaced so the caller can tell the developer when a
|
|
66
|
+
* download just happened on their machine. */
|
|
67
|
+
source: 'env' | 'path' | 'cached' | 'downloaded'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The release asset for this machine, or `undefined` when Cloudflare ships no
|
|
72
|
+
* build for it. macOS assets are gzipped tarballs; everything else is the bare
|
|
73
|
+
* executable.
|
|
74
|
+
*/
|
|
75
|
+
function assetFor(
|
|
76
|
+
os: string,
|
|
77
|
+
cpu: string,
|
|
78
|
+
): { name: string, tarball: boolean } | undefined {
|
|
79
|
+
if(os === 'darwin') {
|
|
80
|
+
const suffix = cpu === 'arm64' ? 'arm64' : 'amd64'
|
|
81
|
+
return { name: `cloudflared-darwin-${suffix}.tgz`, tarball: true }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if(os === 'linux') {
|
|
85
|
+
const map: Record<string, string> = {
|
|
86
|
+
x64: 'amd64',
|
|
87
|
+
arm64: 'arm64',
|
|
88
|
+
arm: 'arm',
|
|
89
|
+
ia32: '386',
|
|
90
|
+
}
|
|
91
|
+
const suffix = map[cpu]
|
|
92
|
+
return suffix
|
|
93
|
+
? { name: `cloudflared-linux-${suffix}`, tarball: false }
|
|
94
|
+
: undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if(os === 'win32') {
|
|
98
|
+
const suffix = cpu === 'ia32' ? '386' : 'amd64'
|
|
99
|
+
return { name: `cloudflared-windows-${suffix}.exe`, tarball: false }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Our install location. Kept beside the other `~/.reclaim` caches. */
|
|
106
|
+
function cachedBinaryPath(): string {
|
|
107
|
+
const name = platform() === 'win32' ? 'cloudflared.exe' : 'cloudflared'
|
|
108
|
+
return join(reclaimHomeDirPath(), 'bin', name)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether `command` runs. Used both for a PATH probe and to smoke-test a
|
|
112
|
+
* freshly downloaded binary before we hand it back. */
|
|
113
|
+
async function runsOk(command: string): Promise<boolean> {
|
|
114
|
+
try {
|
|
115
|
+
await execFileAsync(command, ['--version'], { timeout: 15_000 })
|
|
116
|
+
return true
|
|
117
|
+
} catch{
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function exists(path: string): Promise<boolean> {
|
|
123
|
+
try {
|
|
124
|
+
await stat(path)
|
|
125
|
+
return true
|
|
126
|
+
} catch{
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve a runnable cloudflared, downloading one only if `autoInstall` allows
|
|
133
|
+
* it and nothing else turned up. Throws with an actionable message when this
|
|
134
|
+
* platform has no build, or the download fails.
|
|
135
|
+
*/
|
|
136
|
+
export async function resolveCloudflared(
|
|
137
|
+
{ autoInstall = true }: { autoInstall?: boolean } = {},
|
|
138
|
+
): Promise<CloudflaredBinary> {
|
|
139
|
+
const override = process.env.RECLAIM_CLOUDFLARED_PATH?.trim()
|
|
140
|
+
if(override) {
|
|
141
|
+
if(!await runsOk(override)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`RECLAIM_CLOUDFLARED_PATH is set to "${override}", but that did not `
|
|
144
|
+
+ 'run. Point it at a working cloudflared binary, or unset it to let '
|
|
145
|
+
+ 'the agent find one.',
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { path: override, source: 'env' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if(await runsOk('cloudflared')) {
|
|
153
|
+
return { path: 'cloudflared', source: 'path' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cached = cachedBinaryPath()
|
|
157
|
+
if(await exists(cached) && await runsOk(cached)) {
|
|
158
|
+
return { path: cached, source: 'cached' }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if(!autoInstall) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
'cloudflared is not installed. Install it (macOS: `brew install '
|
|
164
|
+
+ 'cloudflared`; other platforms: https://developers.cloudflare.com/'
|
|
165
|
+
+ 'cloudflare-one/connections/connect-networks/downloads/), or allow '
|
|
166
|
+
+ 'the agent to fetch it.',
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await install(cached)
|
|
171
|
+
return { path: cached, source: 'downloaded' }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `cloudflared-linux-amd64: <64 hex>` lines inside the notes' fenced block. */
|
|
175
|
+
const CHECKSUM_LINE = /^\s*(\S+):\s*([a-f0-9]{64})\s*$/gim
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The current release's tag and its published SHA-256 table.
|
|
179
|
+
*
|
|
180
|
+
* Both come from one response on purpose: pinning the download to this tag is
|
|
181
|
+
* what makes the digest meaningful.
|
|
182
|
+
*/
|
|
183
|
+
async function fetchRelease(): Promise<{
|
|
184
|
+
tag: string
|
|
185
|
+
checksums: Map<string, string>
|
|
186
|
+
}> {
|
|
187
|
+
const res = await fetch(LATEST_RELEASE_API, {
|
|
188
|
+
headers: {
|
|
189
|
+
accept: 'application/vnd.github+json',
|
|
190
|
+
'user-agent': '@reclaimprotocol/agent',
|
|
191
|
+
},
|
|
192
|
+
signal: AbortSignal.timeout(30_000),
|
|
193
|
+
})
|
|
194
|
+
if(!res.ok) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Could not read cloudflared release metadata: ${res.status} `
|
|
197
|
+
+ `${res.statusText}. Needed to verify the download's checksum. `
|
|
198
|
+
+ 'Install cloudflared yourself, or set RECLAIM_CLOUDFLARED_PATH.',
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const release = await res.json() as { tag_name?: string, body?: string }
|
|
203
|
+
const tag = release.tag_name
|
|
204
|
+
if(!tag) {
|
|
205
|
+
throw new Error('cloudflared release metadata carried no tag.')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const checksums = new Map<string, string>()
|
|
209
|
+
for(const match of (release.body ?? '').matchAll(CHECKSUM_LINE)) {
|
|
210
|
+
checksums.set(match[1], match[2].toLowerCase())
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { tag, checksums }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Download the release asset for this machine to `target`, verified. */
|
|
217
|
+
async function install(target: string): Promise<void> {
|
|
218
|
+
const asset = assetFor(platform(), arch())
|
|
219
|
+
if(!asset) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Cloudflare publishes no cloudflared build for ${platform()}/${arch()}. `
|
|
222
|
+
+ 'Install it another way and set RECLAIM_CLOUDFLARED_PATH, or use '
|
|
223
|
+
+ '`attach_browser` in builder mode, whose Popcorn browser returns a '
|
|
224
|
+
+ 'hosted liveViewUrl and needs no tunnel.',
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Read the checksum table BEFORE downloading, and pin the download to the
|
|
229
|
+
// tag it came from.
|
|
230
|
+
const { tag, checksums } = await fetchRelease()
|
|
231
|
+
const expected = checksums.get(asset.name)
|
|
232
|
+
if(!expected) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`cloudflared release ${tag} publishes no SHA-256 for ${asset.name}, so `
|
|
235
|
+
+ 'the download cannot be verified. Refusing to install an unverified '
|
|
236
|
+
+ 'binary. Install cloudflared yourself, or set '
|
|
237
|
+
+ 'RECLAIM_CLOUDFLARED_PATH.',
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const url = releaseDownloadUrl(tag, asset.name)
|
|
242
|
+
LOGGER.info({ url, target, tag }, 'cloudflared: downloading (first use)')
|
|
243
|
+
const res = await fetch(url, {
|
|
244
|
+
redirect: 'follow',
|
|
245
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
246
|
+
})
|
|
247
|
+
if(!res.ok) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Downloading cloudflared failed: ${res.status} ${res.statusText} `
|
|
250
|
+
+ `from ${url}. Install it manually and retry, or set `
|
|
251
|
+
+ 'RECLAIM_CLOUDFLARED_PATH.',
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const downloaded = Buffer.from(await res.arrayBuffer())
|
|
256
|
+
|
|
257
|
+
// The macOS assets are tarballs, so unpack before hashing. Unpacked
|
|
258
|
+
// in-process rather than by shelling out to `tar`: that would add a
|
|
259
|
+
// dependency on something being on PATH, which is exactly the assumption
|
|
260
|
+
// this module exists to stop making.
|
|
261
|
+
//
|
|
262
|
+
// This ordering is not incidental. Cloudflare's table lists a digest for
|
|
263
|
+
// `cloudflared-darwin-arm64.tgz`, but that digest is of the BINARY INSIDE,
|
|
264
|
+
// not of the tarball (verified against a real release: the tarball hashes
|
|
265
|
+
// to something else entirely, and the binary self-reports the published
|
|
266
|
+
// value as its own checksum). The linux and windows entries, which ship a
|
|
267
|
+
// bare executable, do match the downloaded file. Hashing the payload we are
|
|
268
|
+
// about to write covers both, and is the stronger invariant anyway: what
|
|
269
|
+
// gets verified is what gets executed.
|
|
270
|
+
const payload = asset.tarball
|
|
271
|
+
? readTarEntry(gunzipSync(downloaded), 'cloudflared')
|
|
272
|
+
: downloaded
|
|
273
|
+
|
|
274
|
+
const actual = createHash('sha256').update(payload).digest('hex')
|
|
275
|
+
if(actual !== expected) {
|
|
276
|
+
// Nothing is written to disk on a mismatch — not even a partial file.
|
|
277
|
+
throw new Error(
|
|
278
|
+
`cloudflared checksum mismatch for ${asset.name} (release ${tag}). `
|
|
279
|
+
+ `Expected ${expected}, got ${actual}. The download was discarded. `
|
|
280
|
+
+ 'Retry; if it persists, install cloudflared yourself and set '
|
|
281
|
+
+ 'RECLAIM_CLOUDFLARED_PATH.',
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
LOGGER.info({ asset: asset.name, tag }, 'cloudflared: checksum verified')
|
|
286
|
+
|
|
287
|
+
// Write beside the target, then rename: a half-written or unverified
|
|
288
|
+
// binary is never left at a path a later run would trust.
|
|
289
|
+
await mkdir(join(target, '..'), { recursive: true })
|
|
290
|
+
const partial = `${target}.download`
|
|
291
|
+
await writeFile(partial, payload)
|
|
292
|
+
await rename(partial, target)
|
|
293
|
+
|
|
294
|
+
if(platform() !== 'win32') {
|
|
295
|
+
await chmod(target, 0o755)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if(!await runsOk(target)) {
|
|
299
|
+
await rm(target, { force: true })
|
|
300
|
+
throw new Error(
|
|
301
|
+
'Downloaded cloudflared, but it did not run. Removed it. Install '
|
|
302
|
+
+ 'cloudflared manually and retry.',
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
LOGGER.info({ target }, 'cloudflared: installed')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A tar header block is 512 bytes, and so is every data block. */
|
|
310
|
+
const TAR_BLOCK = 512
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Pull one file out of an uncompressed tar archive.
|
|
314
|
+
*
|
|
315
|
+
* Only as much of the format as this needs: walk the 512-byte headers, read
|
|
316
|
+
* each entry's name and octal size, and return the payload of the first
|
|
317
|
+
* regular file whose basename matches. Cloudflare's macOS tarball holds a
|
|
318
|
+
* single binary, so there is nothing more to handle.
|
|
319
|
+
*/
|
|
320
|
+
function readTarEntry(tar: Buffer, wanted: string): Buffer {
|
|
321
|
+
let offset = 0
|
|
322
|
+
while(offset + TAR_BLOCK <= tar.length) {
|
|
323
|
+
const header = tar.subarray(offset, offset + TAR_BLOCK)
|
|
324
|
+
const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
|
|
325
|
+
if(!name) {
|
|
326
|
+
break // two zero blocks mark the end of the archive
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const size = parseInt(
|
|
330
|
+
header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim(),
|
|
331
|
+
8,
|
|
332
|
+
) || 0
|
|
333
|
+
const type = header.subarray(156, 157).toString('utf8')
|
|
334
|
+
const dataStart = offset + TAR_BLOCK
|
|
335
|
+
if((type === '0' || type === '\0') && name.split('/').pop() === wanted) {
|
|
336
|
+
return tar.subarray(dataStart, dataStart + size)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Data is padded up to the next block boundary.
|
|
340
|
+
offset = dataStart + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
throw new Error(
|
|
344
|
+
`The cloudflared archive did not contain "${wanted}". Cloudflare may `
|
|
345
|
+
+ 'have changed its release layout — install cloudflared manually and '
|
|
346
|
+
+ 'set RECLAIM_CLOUDFLARED_PATH.',
|
|
347
|
+
)
|
|
348
|
+
}
|