@reclaimprotocol/agent 0.1.29 → 0.1.31
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 +28 -9
- package/lib/consts.d.ts +5 -5
- package/lib/consts.js +7 -7
- package/lib/live/tunnel-watchdog.d.ts +1 -0
- package/lib/live/tunnel-watchdog.js +66 -0
- package/lib/live/tunnel.d.ts +30 -7
- package/lib/live/tunnel.js +217 -40
- package/lib/mcp/api/operations.gen.d.ts +12 -0
- package/lib/mcp/api/operations.gen.js +537 -59
- package/lib/mcp/api/tools.js +27 -5
- package/lib/mcp/instructions.js +23 -32
- package/lib/mcp/remap_errors.js +6 -0
- package/lib/mcp/server.js +2 -8
- package/lib/mcp/start.js +3 -5
- package/lib/mcp/success-response.d.ts +5 -0
- package/lib/mcp/success-response.js +21 -0
- package/lib/mcp/tools/agent/attach.js +9 -66
- package/lib/mcp/tools/agent/build.d.ts +0 -3
- package/lib/mcp/tools/agent/build.js +3 -4
- package/lib/mcp/tools/agent/how-it-works.d.ts +5 -15
- package/lib/mcp/tools/agent/how-it-works.js +9 -22
- package/lib/mcp/tools/agent/live-view.d.ts +1 -15
- package/lib/mcp/tools/agent/live-view.js +71 -96
- package/lib/mcp/tools/agent/propose.js +7 -23
- package/lib/mcp/tools/agent/publish.js +80 -18
- package/lib/mcp/tools/agent/session.d.ts +4 -7
- package/lib/mcp/tools/agent/session.js +8 -23
- package/lib/mcp/tools/verification/result.d.ts +2 -1
- package/lib/mcp/tools/verification/result.js +29 -28
- package/lib/old/client.d.ts +2 -2
- package/lib/old/flag.d.ts +2 -5
- package/lib/old/flag.js +2 -5
- package/lib/old/instructions.d.ts +1 -1
- package/lib/old/instructions.js +1 -1
- package/lib/old/to-register.d.ts +4 -3
- package/lib/old/tools/publish.js +11 -14
- package/lib/proof/attestor-auth.d.ts +9 -1
- package/lib/proof/attestor-auth.js +15 -2
- package/lib/proof/attestor.d.ts +2 -2
- package/lib/proof/attestor.js +20 -2
- package/lib/proof/pod-prover.js +1 -1
- package/lib/proof/proof-core.d.ts +4 -0
- package/lib/proof/proof-core.js +20 -0
- package/lib/provider/injection-doc.d.ts +3 -2
- package/lib/provider/injection-doc.js +12 -10
- package/lib/provider/matcher.js +19 -15
- package/lib/provider/to-version.d.ts +3 -0
- package/lib/provider/to-version.js +2 -1
- package/package.json +3 -3
- package/skill/SKILL.builder.md +30 -83
- package/skill/SKILL.common.md +38 -22
- package/skill/SKILL.old.md +17 -3
- package/skill/builder-mode.md +37 -4
- package/skill/community-contributions.md +12 -3
- package/skill/hash-validation.md +9 -9
- package/skill/interception.md +39 -46
- package/skill/old-mode.md +13 -0
- package/skill/user-script.md +24 -8
- package/skill/verification-client-integration.md +130 -0
- package/src/consts.ts +9 -7
- package/src/mcp/api/operations.gen.ts +539 -59
- package/src/mcp/api/tools.ts +38 -7
- package/src/mcp/instructions.ts +33 -32
- package/src/mcp/remap_errors.ts +6 -0
- package/src/mcp/server.ts +2 -8
- package/src/mcp/start.ts +3 -5
- package/src/mcp/success-response.ts +26 -0
- package/src/mcp/tools/agent/attach.ts +9 -71
- package/src/mcp/tools/agent/build.ts +4 -7
- package/src/mcp/tools/agent/how-it-works.ts +9 -22
- package/src/mcp/tools/agent/live-view.ts +76 -116
- package/src/mcp/tools/agent/propose.ts +7 -23
- package/src/mcp/tools/agent/publish.ts +89 -18
- package/src/mcp/tools/agent/session.ts +6 -23
- package/src/mcp/tools/verification/result.ts +30 -29
- package/src/old/client.ts +2 -2
- package/src/old/flag.ts +2 -5
- package/src/old/instructions.ts +1 -1
- package/src/old/to-register.ts +4 -3
- package/src/old/tools/publish.ts +16 -14
- package/src/proof/attestor-auth.ts +18 -1
- package/src/proof/attestor.ts +24 -3
- package/src/proof/pod-prover.ts +1 -1
- package/src/proof/proof-core.ts +28 -0
- package/src/provider/injection-doc.ts +12 -10
- package/src/provider/matcher.ts +25 -17
- package/src/provider/to-version.ts +7 -1
- package/src/live/cloudflared.ts +0 -348
- package/src/live/tunnel.ts +0 -254
package/README.md
CHANGED
|
@@ -6,6 +6,18 @@ Under the hood it's Chrome-CDP + zkTLS provider-authoring tooling: it captures n
|
|
|
6
6
|
|
|
7
7
|
This package **is** the Reclaim MCP server (bin: `reclaim-mcp-server`). It depends on `@reclaimprotocol/client` for the typed HTTP client and registers the provider-authoring tools below alongside the backend-facing tools (credentials, publishing) in a single MCP surface. A standalone entry (`src/index.ts`) re-exports the client SDK plus `buildAgentTools` for embedding.
|
|
8
8
|
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
- [Install](#install)
|
|
12
|
+
- [Use it from an agent](#using-it-from-your-agent)
|
|
13
|
+
- [Provider-authoring tools](#provider-authoring-tools)
|
|
14
|
+
- [Backends](#backends)
|
|
15
|
+
- [Local development](#local-development-from-source)
|
|
16
|
+
- [Platform notes](#platform-notes)
|
|
17
|
+
- [Tests](#tests)
|
|
18
|
+
- [Disable browser tools](#disable-the-browser-tools)
|
|
19
|
+
- [Contributor setup](#for-contributors--wiring-a-local-build-into-claude-code--opencode)
|
|
20
|
+
|
|
9
21
|
## Install
|
|
10
22
|
|
|
11
23
|
**Prerequisites:**
|
|
@@ -13,7 +25,7 @@ This package **is** the Reclaim MCP server (bin: `reclaim-mcp-server`). It depen
|
|
|
13
25
|
- **Node 20 or later.** Any line works, including non-LTS ones such as 25, but a current LTS line (22 or 24) is recommended.
|
|
14
26
|
- **A local Chrome, Chromium, or Edge**, used for capture and for the dashboard login.
|
|
15
27
|
- **Docker** — only for `attach_browser`'s `container` mode, which runs the Reclaim browser runtime (the same image the hosted browser uses) and gives you a link you can send to whoever holds the account. It is the free way to get that link; the hosted remote browser is the other, and it is chargeable. Not needed if you are doing the work yourself. The image is amd64, so Apple Silicon runs it translated. OrbStack handles that itself; Docker Desktop needs Rosetta enabled. `RECLAIM_BROWSER_RUNTIME_IMAGE` overrides the pinned digest.
|
|
16
|
-
- **`cloudflared`** —
|
|
28
|
+
- **`cloudflared`** — only to share a `container` browser with someone remote. The MCP server does not run or install it: `share_browser_view` returns the command, and the agent runs `cloudflared tunnel --url http://localhost:<port>` itself, so install it yourself first.
|
|
17
29
|
|
|
18
30
|
The first time you call `run_proof`, the agent downloads the ZK circuit files it needs (about 280 MB), so allow a moment for that first proof. The download is deferred to the first proof rather than run at install time, so the MCP server itself starts immediately.
|
|
19
31
|
|
|
@@ -27,7 +39,7 @@ npx -y --package=@reclaimprotocol/agent reclaim-mcp-server
|
|
|
27
39
|
|
|
28
40
|
### Installing into your coding agent
|
|
29
41
|
|
|
30
|
-
Copy-paste setup for **Claude Code** (plugin marketplace), **Claude Desktop**, **Cursor**, **opencode**, **
|
|
42
|
+
Copy-paste setup for **Claude Code** (plugin marketplace), **Codex** (plugin marketplace), **Claude Desktop**, **Cursor**, **opencode**, **Windsurf**, **VS Code**, **Cline** and other MCP clients lives in the public **[reclaimprotocol/developer-tools](https://github.com/reclaimprotocol/developer-tools)** repo — that's where the Claude Code and Codex plugins are published.
|
|
31
43
|
|
|
32
44
|
The quickest path, in Claude Code:
|
|
33
45
|
|
|
@@ -36,11 +48,18 @@ The quickest path, in Claude Code:
|
|
|
36
48
|
/plugin install reclaim@reclaim
|
|
37
49
|
```
|
|
38
50
|
|
|
51
|
+
In Codex:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
codex plugin marketplace add reclaimprotocol/developer-tools
|
|
55
|
+
codex plugin add reclaim@reclaim
|
|
56
|
+
```
|
|
57
|
+
|
|
39
58
|
For any other client, point it at the stdio command above (`npx -y --package=@reclaimprotocol/agent reclaim-mcp-server`) — see the developer-tools README for per-client config and the Claude Desktop / GUI-app notes.
|
|
40
59
|
|
|
41
60
|
### Optional environment
|
|
42
61
|
|
|
43
|
-
Defaults target the
|
|
62
|
+
Defaults target the Builder backend, so **no env is needed for the common case**. To use legacy devtools explicitly, add an `env` block (or `environment` in opencode):
|
|
44
63
|
|
|
45
64
|
```json
|
|
46
65
|
"env": {
|
|
@@ -49,7 +68,7 @@ Defaults target the production old-devtools backend, so **no env is needed for t
|
|
|
49
68
|
}
|
|
50
69
|
```
|
|
51
70
|
|
|
52
|
-
See [Backends](#backends) for
|
|
71
|
+
See [Backends](#backends) for the full env-var list.
|
|
53
72
|
|
|
54
73
|
### Provider-authoring guide
|
|
55
74
|
|
|
@@ -101,21 +120,21 @@ These capture/synthesis/proof tools are backend-independent and registered in bo
|
|
|
101
120
|
| `create_provider_version_from_capture` | Publish a draft as a new immutable semantic version (patch by default) |
|
|
102
121
|
| `authenticate_builder` | In old-devtools mode only, sign in to Builder for remote-browser allocation |
|
|
103
122
|
| `list_builder_organizations` | In old-devtools mode only, list organization ids for a quota-accounted Builder browser |
|
|
104
|
-
| `share_browser_view
|
|
123
|
+
| `share_browser_view` | Share the `container` browser's own live view with someone elsewhere. Returns the local viewer port and the `cloudflared tunnel` command for the agent to run; call it again with the printed `https://*.trycloudflare.com` URL as `publicUrl` to get the link to relay. Container mode only: a `builder` browser already returns a hosted `liveViewUrl`, and a plain local browser has no view to share |
|
|
105
124
|
| `dispose_browser` | Stop captures, disconnect CDP, and immediately release a Builder-hosted browser |
|
|
106
125
|
| `get_session`, `reset_session` | Session state |
|
|
107
126
|
|
|
108
127
|
## Backends
|
|
109
128
|
|
|
110
|
-
The MCP server targets one of two backends
|
|
129
|
+
The MCP server targets one of two backends. An explicit `USE_OLD_DEVTOOLS` setting overrides the persisted choice; otherwise Builder is the default:
|
|
111
130
|
|
|
112
|
-
- **Old devtools (
|
|
131
|
+
- **Old devtools (`USE_OLD_DEVTOOLS=true`).** In old mode all auto-generated HTTP-API tools are disabled and a small client (`src/old/`) serves:
|
|
113
132
|
- `reclaim_authenticate` — by default opens the dashboard in local Chrome over loopback CDP, waits for sign-in, reads the Firebase token from page storage, and closes the tab. It never uses a remote or shared authoring browser. Pass a `token` / `ethAddress` to skip the browser.
|
|
114
133
|
- `create_provider_version_from_capture` — registers a new provider, or adds a new immutable semantic version when given a `providerId`. It never edits an existing version.
|
|
115
134
|
- `get_me_providers` — list the providers you own (the only provider-listing tool in this mode).
|
|
116
135
|
- `session_analytics_logs` — the milestone events for a verification session, which show how far it got.
|
|
117
136
|
- `session_logs` — the log entries the in-app SDK emitted during that session, each with its event type, log level, and logger name. Filter by event type, level, or substring, page with `limit` and `offset`, or set `saveTo` to write every matching entry to an NDJSON file. The backend searches only the last 3 days unless you pass `startTime` and `endTime`, and it deletes entries after 30 days.
|
|
118
|
-
- **Builder (
|
|
137
|
+
- **Builder (default).** Registers the auto-generated HTTP-API tools, including `create_org`, `issue_org_token`, and `create_org_callback_subscription`, plus authentication/credential tools and `get_verification_result`. `issue_credentials` creates a local proof-owner key; `issue_org_token` issues the organization's API secret.
|
|
119
138
|
|
|
120
139
|
Relevant env vars: `RECLAIM_OLD_API_URL` (default `https://devapi.reclaimprotocol.org`), `RECLAIM_OLD_API_TOKEN` / `RECLAIM_OLD_ETH_UID` (headless identity), `RECLAIM_OLD_LOGIN_URL` (default `https://dev.reclaimprotocol.org`); builder mode uses `RECLAIM_API_URL` / `RECLAIM_API_TOKEN`.
|
|
121
140
|
|
|
@@ -214,7 +233,7 @@ claude mcp add reclaim -- node "$(pwd)/packages/agent/lib/mcp-bin.js"
|
|
|
214
233
|
}
|
|
215
234
|
```
|
|
216
235
|
|
|
217
|
-
To
|
|
236
|
+
To override the default backend while developing, add an `env` block (Claude Code / `.mcp.json`) or `environment` (opencode) with `"USE_OLD_DEVTOOLS": "true"` for legacy devtools. See [Backends](#backends).
|
|
218
237
|
|
|
219
238
|
### Authoring guide
|
|
220
239
|
|
package/lib/consts.d.ts
CHANGED
|
@@ -31,14 +31,14 @@ export declare const LOG_LEVEL: string;
|
|
|
31
31
|
export declare function reclaimHome(): string;
|
|
32
32
|
/**
|
|
33
33
|
* `USE_OLD_DEVTOOLS` — single source of truth for which backend the MCP
|
|
34
|
-
* server targets.
|
|
35
|
-
*
|
|
36
|
-
* `USE_OLD_DEVTOOLS=false`; anything else keeps old mode on.
|
|
34
|
+
* server targets. Builder is the default. Set `USE_OLD_DEVTOOLS=true` to
|
|
35
|
+
* explicitly use the legacy backend.
|
|
37
36
|
*/
|
|
38
37
|
export declare function useOldDevtools(): boolean;
|
|
39
38
|
/**
|
|
40
39
|
* Effective mode, layering the persisted preference under the env var:
|
|
41
|
-
* explicit `USE_OLD_DEVTOOLS` wins → persisted config `mode` →
|
|
40
|
+
* explicit `USE_OLD_DEVTOOLS` wins → persisted config `mode` →
|
|
41
|
+
* default (builder).
|
|
42
42
|
* The persisted value comes from `~/.reclaim/config.json` (set via the
|
|
43
43
|
* `set_devtools_mode` tool); the caller reads it so this module needn't import
|
|
44
44
|
* `ConfigStore` (which would create an import cycle).
|
|
@@ -47,7 +47,7 @@ export declare function resolveOldMode(persisted?: 'old' | 'builder'): boolean;
|
|
|
47
47
|
/**
|
|
48
48
|
* Where the effective mode came from — the same precedence as
|
|
49
49
|
* `resolveOldMode`: explicit `USE_OLD_DEVTOOLS` env var → persisted config
|
|
50
|
-
* `mode` → default (
|
|
50
|
+
* `mode` → default (builder). Surfaced by the `get_devtools_mode` tool so the
|
|
51
51
|
* user can tell why the server is in the mode it reports.
|
|
52
52
|
*/
|
|
53
53
|
export declare function oldModeSource(persisted?: 'old' | 'builder'): 'env' | 'config' | 'default';
|
package/lib/consts.js
CHANGED
|
@@ -49,12 +49,11 @@ export function reclaimHome() {
|
|
|
49
49
|
// ---------------------------------------------------------------------------
|
|
50
50
|
/**
|
|
51
51
|
* `USE_OLD_DEVTOOLS` — single source of truth for which backend the MCP
|
|
52
|
-
* server targets.
|
|
53
|
-
*
|
|
54
|
-
* `USE_OLD_DEVTOOLS=false`; anything else keeps old mode on.
|
|
52
|
+
* server targets. Builder is the default. Set `USE_OLD_DEVTOOLS=true` to
|
|
53
|
+
* explicitly use the legacy backend.
|
|
55
54
|
*/
|
|
56
55
|
export function useOldDevtools() {
|
|
57
|
-
return (process.env.USE_OLD_DEVTOOLS ?? '').trim().toLowerCase()
|
|
56
|
+
return ['true', '1'].includes((process.env.USE_OLD_DEVTOOLS ?? '').trim().toLowerCase());
|
|
58
57
|
}
|
|
59
58
|
/** True when `USE_OLD_DEVTOOLS` is set to a non-empty value (explicit). */
|
|
60
59
|
function oldDevtoolsEnvSet() {
|
|
@@ -62,7 +61,8 @@ function oldDevtoolsEnvSet() {
|
|
|
62
61
|
}
|
|
63
62
|
/**
|
|
64
63
|
* Effective mode, layering the persisted preference under the env var:
|
|
65
|
-
* explicit `USE_OLD_DEVTOOLS` wins → persisted config `mode` →
|
|
64
|
+
* explicit `USE_OLD_DEVTOOLS` wins → persisted config `mode` →
|
|
65
|
+
* default (builder).
|
|
66
66
|
* The persisted value comes from `~/.reclaim/config.json` (set via the
|
|
67
67
|
* `set_devtools_mode` tool); the caller reads it so this module needn't import
|
|
68
68
|
* `ConfigStore` (which would create an import cycle).
|
|
@@ -74,12 +74,12 @@ export function resolveOldMode(persisted) {
|
|
|
74
74
|
if (persisted) {
|
|
75
75
|
return persisted === 'old';
|
|
76
76
|
}
|
|
77
|
-
return
|
|
77
|
+
return false;
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
80
|
* Where the effective mode came from — the same precedence as
|
|
81
81
|
* `resolveOldMode`: explicit `USE_OLD_DEVTOOLS` env var → persisted config
|
|
82
|
-
* `mode` → default (
|
|
82
|
+
* `mode` → default (builder). Surfaced by the `get_devtools_mode` tool so the
|
|
83
83
|
* user can tell why the server is in the mode it reports.
|
|
84
84
|
*/
|
|
85
85
|
export function oldModeSource(persisted) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Own cloudflared on behalf of one MCP process.
|
|
3
|
+
*
|
|
4
|
+
* A normal child survives when its Node parent crashes: the OS reparents it and
|
|
5
|
+
* the old public hostname keeps pointing at a viewer port that no longer
|
|
6
|
+
* exists.
|
|
7
|
+
* This small process stays between MCP and cloudflared, checks the original
|
|
8
|
+
* owner's PID, and tears cloudflared down when that owner disappears.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
const [ownerValue, binary, ...args] = process.argv.slice(2);
|
|
12
|
+
const ownerPid = Number(ownerValue);
|
|
13
|
+
if (!Number.isInteger(ownerPid) || ownerPid <= 0 || !binary) {
|
|
14
|
+
process.stderr.write('Invalid tunnel-watchdog arguments.\n');
|
|
15
|
+
process.exit(2);
|
|
16
|
+
}
|
|
17
|
+
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
18
|
+
let stopping = false;
|
|
19
|
+
let forceTimer;
|
|
20
|
+
// A dead MCP closes these pipes. Ignore EPIPE while the watchdog finishes the
|
|
21
|
+
// more important job of terminating cloudflared.
|
|
22
|
+
process.stdout.on('error', () => { });
|
|
23
|
+
process.stderr.on('error', () => { });
|
|
24
|
+
child.stdout.on('data', (chunk) => process.stdout.write(chunk));
|
|
25
|
+
child.stderr.on('data', (chunk) => process.stderr.write(chunk));
|
|
26
|
+
const ownerTimer = setInterval(() => {
|
|
27
|
+
if (!processAlive(ownerPid)) {
|
|
28
|
+
stop();
|
|
29
|
+
}
|
|
30
|
+
}, 1000);
|
|
31
|
+
child.on('error', (error) => {
|
|
32
|
+
process.stderr.write(`Could not start cloudflared: ${error.message}\n`);
|
|
33
|
+
});
|
|
34
|
+
child.on('exit', (code, signal) => {
|
|
35
|
+
clearInterval(ownerTimer);
|
|
36
|
+
clearTimeout(forceTimer);
|
|
37
|
+
process.exitCode = stopping ? 0 : code ?? (signal ? 1 : 0);
|
|
38
|
+
});
|
|
39
|
+
process.once('SIGINT', stop);
|
|
40
|
+
process.once('SIGTERM', stop);
|
|
41
|
+
function processAlive(pid) {
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
return error?.code === 'EPERM';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function stop() {
|
|
51
|
+
if (stopping) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
stopping = true;
|
|
55
|
+
clearInterval(ownerTimer);
|
|
56
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
child.kill('SIGTERM');
|
|
60
|
+
forceTimer = setTimeout(() => {
|
|
61
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
62
|
+
child.kill('SIGKILL');
|
|
63
|
+
}
|
|
64
|
+
}, 5000);
|
|
65
|
+
forceTimer.unref();
|
|
66
|
+
}
|
package/lib/live/tunnel.d.ts
CHANGED
|
@@ -12,13 +12,20 @@ export interface Tunnel {
|
|
|
12
12
|
binary: CloudflaredBinary;
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* The probe's verdict, plus — on failure — which component said no. Carrying
|
|
16
|
+
* the reason forward means the error a caller eventually sees names the
|
|
17
|
+
* actual failing part (a specific DNS resolver, the viewer document, or a
|
|
18
|
+
* specific WebSocket path) instead of a bare "not ready" that leaves the next
|
|
19
|
+
* person to guess.
|
|
20
20
|
*/
|
|
21
|
-
|
|
21
|
+
type ProbeResult = {
|
|
22
|
+
ok: true;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
reason: string;
|
|
26
|
+
};
|
|
27
|
+
type RouteProbe = (url: string, timeoutMs: number) => Promise<ProbeResult>;
|
|
28
|
+
export interface StartTunnelOptions {
|
|
22
29
|
autoInstall?: boolean;
|
|
23
30
|
/** Public path that must answer before the tunnel is returned. */
|
|
24
31
|
probePath?: string;
|
|
@@ -26,4 +33,20 @@ export declare function startTunnel(port: number, { autoInstall, probePath, read
|
|
|
26
33
|
readyPollMs?: number;
|
|
27
34
|
/** Test-only timing override. */
|
|
28
35
|
readyTimeoutMs?: number;
|
|
29
|
-
|
|
36
|
+
/** Test-only delay after cloudflared confirms route registration. */
|
|
37
|
+
registeredSettleMs?: number;
|
|
38
|
+
/** Test-only public-route probe override. */
|
|
39
|
+
probe?: RouteProbe;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Open a quick tunnel to `http://127.0.0.1:<port>` and resolve once
|
|
43
|
+
* Cloudflare has assigned a hostname AND it has been proven publicly routed.
|
|
44
|
+
*
|
|
45
|
+
* Rejects with an actionable message when `cloudflared` is not installed,
|
|
46
|
+
* exits early, or never reports a hostname. When a hostname is assigned but
|
|
47
|
+
* fails its readiness probe, this retries with a fresh hostname up to
|
|
48
|
+
* MAX_TUNNEL_ATTEMPTS times before giving up — see that constant for why the
|
|
49
|
+
* SAME hostname is never retried.
|
|
50
|
+
*/
|
|
51
|
+
export declare function startTunnel(port: number, options?: StartTunnelOptions): Promise<Tunnel>;
|
|
52
|
+
export {};
|
package/lib/live/tunnel.js
CHANGED
|
@@ -19,19 +19,45 @@
|
|
|
19
19
|
* is unauthenticated total control of the browser.
|
|
20
20
|
*/
|
|
21
21
|
import { spawn } from 'node:child_process';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
22
23
|
import { LOGGER } from "../logger.js";
|
|
23
24
|
import { resolveCloudflared } from "./cloudflared.js";
|
|
24
25
|
/** How long to wait for cloudflared to report its hostname before giving up. */
|
|
25
26
|
const URL_TIMEOUT_MS = 30000;
|
|
26
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
const READY_TIMEOUT_MS =
|
|
27
|
+
/** Never hand out a hostname before public DNS and the edge both carry the
|
|
28
|
+
* viewer. Opening an unready hostname can leave a remote browser with a cached
|
|
29
|
+
* NXDOMAIN response after the route itself becomes healthy. */
|
|
30
|
+
const READY_TIMEOUT_MS = 60000;
|
|
30
31
|
const READY_POLL_MS = 1000;
|
|
32
|
+
const PROBE_TIMEOUT_MS = 10000;
|
|
33
|
+
/** Avoid asking recursive DNS for the hostname during the short interval
|
|
34
|
+
* between quick-tunnel allocation and route registration. An early NXDOMAIN
|
|
35
|
+
* can be cached for 30 minutes, making the link work for some users only. */
|
|
36
|
+
const REGISTERED_SETTLE_MS = 2000;
|
|
37
|
+
/**
|
|
38
|
+
* Total hostnames to try before giving up: the first, plus one fresh one. A
|
|
39
|
+
* hostname that fails the readiness probe does not recover on its own — see
|
|
40
|
+
* REGISTERED_SETTLE_MS above — so retrying the SAME hostname spends the whole
|
|
41
|
+
* timeout on a route that cannot come back. A new `cloudflared` invocation
|
|
42
|
+
* mints a new random hostname, unaffected by whatever public resolver cached
|
|
43
|
+
* the old one's NXDOMAIN, so a fresh hostname is where the recovery odds
|
|
44
|
+
* actually are. A third hostname would cost the caller another full
|
|
45
|
+
* READY_TIMEOUT_MS without materially better odds, so this stays at two: the
|
|
46
|
+
* same bound the live-view skill already tells a caller to apply by hand
|
|
47
|
+
* ("retry once to request a fresh one").
|
|
48
|
+
*/
|
|
49
|
+
const MAX_TUNNEL_ATTEMPTS = 2;
|
|
31
50
|
const STOP_TIMEOUT_MS = 5000;
|
|
51
|
+
const WATCHDOG_PATH = fileURLToPath(new URL('./tunnel-watchdog.js', import.meta.url));
|
|
32
52
|
/** `https://<something>.trycloudflare.com`, as printed on cloudflared's log
|
|
33
53
|
* stream. Anchored to the scheme so a hostname inside prose can't match. */
|
|
34
54
|
const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
55
|
+
const REGISTERED_TUNNEL = 'Registered tunnel connection';
|
|
56
|
+
const PUBLIC_DNS_RESOLVERS = [
|
|
57
|
+
'https://cloudflare-dns.com/dns-query',
|
|
58
|
+
'https://dns.google/resolve',
|
|
59
|
+
];
|
|
60
|
+
const VIEWER_WEBSOCKET_PATHS = ['/websockify?keep=1', '/input', '/kbd'];
|
|
35
61
|
/** Tail of cloudflared's log kept for error messages. */
|
|
36
62
|
const LOG_TAIL_BYTES = 4096;
|
|
37
63
|
/**
|
|
@@ -41,42 +67,189 @@ const LOG_TAIL_BYTES = 4096;
|
|
|
41
67
|
* can report one while the assigned hostname still returns DNS NXDOMAIN. The
|
|
42
68
|
* public viewer itself must return a successful response.
|
|
43
69
|
*/
|
|
44
|
-
async function waitUntilRouted(url, { isRunning, pollMs, timeoutMs, }) {
|
|
70
|
+
async function waitUntilRouted(url, { isRunning, pollMs, probe, timeoutMs, }) {
|
|
45
71
|
const deadline = Date.now() + timeoutMs;
|
|
72
|
+
let lastReason;
|
|
46
73
|
while (Date.now() < deadline) {
|
|
47
74
|
if (!isRunning()) {
|
|
48
|
-
return false;
|
|
75
|
+
return { ready: false, reason: lastReason };
|
|
49
76
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
signal: AbortSignal.timeout(pollMs * 2),
|
|
55
|
-
});
|
|
56
|
-
if (response.ok) {
|
|
57
|
-
return true;
|
|
58
|
-
}
|
|
77
|
+
const remainingMs = deadline - Date.now();
|
|
78
|
+
const result = await probe(url, Math.min(PROBE_TIMEOUT_MS, remainingMs));
|
|
79
|
+
if (result.ok) {
|
|
80
|
+
return { ready: true };
|
|
59
81
|
}
|
|
60
|
-
|
|
82
|
+
lastReason = result.reason;
|
|
61
83
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
62
84
|
}
|
|
63
|
-
return false;
|
|
85
|
+
return { ready: false, reason: lastReason };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Require independent public DNS resolvers, the viewer document, and every
|
|
89
|
+
* viewer WebSocket to work. This represents the path another user takes; a
|
|
90
|
+
* healthy Docker browser or loopback viewer alone is insufficient.
|
|
91
|
+
*
|
|
92
|
+
* The three stages share ONE deadline instead of each getting a fresh
|
|
93
|
+
* `timeoutMs`. Chaining them with independent budgets let one slow-to-fail
|
|
94
|
+
* stage (say, a DNS query that hangs for the full timeout) leave the later
|
|
95
|
+
* stages no time at all while still reporting as if the whole probe had spent
|
|
96
|
+
* only `timeoutMs` — in practice, up to three times that per call, which
|
|
97
|
+
* starves `waitUntilRouted` of the retries it needs while a cold tunnel warms
|
|
98
|
+
* up. A shared deadline keeps the caller's per-attempt budget honest.
|
|
99
|
+
*
|
|
100
|
+
* DNS is checked against every resolver, not a majority: the question this
|
|
101
|
+
* answers is "would an arbitrary remote viewer's own resolver see this host",
|
|
102
|
+
* and a viewer's resolver could be either one. Weakening this to a majority
|
|
103
|
+
* would let a link through that fails for whoever happens to use the
|
|
104
|
+
* lagging resolver — the exact false positive this probe exists to prevent.
|
|
105
|
+
*/
|
|
106
|
+
async function probePublicRoute(url, timeoutMs) {
|
|
107
|
+
const parsed = new URL(url);
|
|
108
|
+
const deadline = Date.now() + timeoutMs;
|
|
109
|
+
const remainingMs = () => Math.max(1, deadline - Date.now());
|
|
110
|
+
try {
|
|
111
|
+
const dnsResults = await Promise.all(PUBLIC_DNS_RESOLVERS.map((resolver) => resolvesThrough(resolver, parsed.hostname, remainingMs())));
|
|
112
|
+
const unresolved = PUBLIC_DNS_RESOLVERS[dnsResults.indexOf(false)];
|
|
113
|
+
if (unresolved) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
reason: `public DNS resolver ${unresolved} does not resolve `
|
|
117
|
+
+ `${parsed.hostname} yet`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const response = await fetch(parsed, {
|
|
121
|
+
signal: AbortSignal.timeout(remainingMs()),
|
|
122
|
+
});
|
|
123
|
+
if (response.status < 200 || response.status >= 400) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
reason: `viewer document returned HTTP ${response.status}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
await response.body?.cancel();
|
|
130
|
+
const websocketPaths = await Promise.all(VIEWER_WEBSOCKET_PATHS.map((path) => probeWebSocket(new URL(path, parsed).toString().replace(/^http/, 'ws'), remainingMs())));
|
|
131
|
+
const unconnected = VIEWER_WEBSOCKET_PATHS[websocketPaths.indexOf(false)];
|
|
132
|
+
if (unconnected) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: `viewer WebSocket ${unconnected} did not connect`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return { ok: true };
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function resolvesThrough(resolver, hostname, timeoutMs) {
|
|
148
|
+
const url = new URL(resolver);
|
|
149
|
+
url.searchParams.set('name', hostname);
|
|
150
|
+
url.searchParams.set('type', 'A');
|
|
151
|
+
const response = await fetch(url, {
|
|
152
|
+
headers: { accept: 'application/dns-json' },
|
|
153
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
154
|
+
});
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const payload = await response.json();
|
|
159
|
+
return typeof payload === 'object'
|
|
160
|
+
&& payload !== null
|
|
161
|
+
&& 'Status' in payload
|
|
162
|
+
&& payload.Status === 0
|
|
163
|
+
&& 'Answer' in payload
|
|
164
|
+
&& Array.isArray(payload.Answer)
|
|
165
|
+
&& payload.Answer.length > 0;
|
|
166
|
+
}
|
|
167
|
+
function probeWebSocket(url, timeoutMs) {
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
const websocket = new WebSocket(url);
|
|
170
|
+
let settled = false;
|
|
171
|
+
const finish = (result) => {
|
|
172
|
+
if (settled) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
settled = true;
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
websocket.close();
|
|
178
|
+
resolve(result);
|
|
179
|
+
};
|
|
180
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
181
|
+
websocket.addEventListener('open', () => finish(true), { once: true });
|
|
182
|
+
websocket.addEventListener('error', () => finish(false), { once: true });
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A hostname's readiness probe never came back positive while cloudflared
|
|
187
|
+
* kept running. This is the one failure `startTunnel` retries with a fresh
|
|
188
|
+
* hostname: everything else (cloudflared missing, cloudflared exiting, no
|
|
189
|
+
* hostname ever reported) points at a problem a new hostname would not fix.
|
|
190
|
+
*/
|
|
191
|
+
class HostnameNotRoutedError extends Error {
|
|
192
|
+
constructor(message, cloudflaredLog) {
|
|
193
|
+
super(message);
|
|
194
|
+
this.name = 'HostnameNotRoutedError';
|
|
195
|
+
this.cloudflaredLog = cloudflaredLog;
|
|
196
|
+
}
|
|
64
197
|
}
|
|
65
198
|
/**
|
|
66
199
|
* Open a quick tunnel to `http://127.0.0.1:<port>` and resolve once
|
|
67
|
-
* Cloudflare has assigned a hostname.
|
|
200
|
+
* Cloudflare has assigned a hostname AND it has been proven publicly routed.
|
|
68
201
|
*
|
|
69
202
|
* Rejects with an actionable message when `cloudflared` is not installed,
|
|
70
|
-
* exits early, or never reports a hostname.
|
|
203
|
+
* exits early, or never reports a hostname. When a hostname is assigned but
|
|
204
|
+
* fails its readiness probe, this retries with a fresh hostname up to
|
|
205
|
+
* MAX_TUNNEL_ATTEMPTS times before giving up — see that constant for why the
|
|
206
|
+
* SAME hostname is never retried.
|
|
71
207
|
*/
|
|
72
|
-
export async function startTunnel(port,
|
|
208
|
+
export async function startTunnel(port, options = {}) {
|
|
73
209
|
// Resolves an existing cloudflared, or fetches one on first use. Throws
|
|
74
|
-
// with an actionable message when neither is possible.
|
|
75
|
-
|
|
76
|
-
|
|
210
|
+
// with an actionable message when neither is possible. Resolved once, not
|
|
211
|
+
// per attempt: the binary is the same across retries, only the hostname
|
|
212
|
+
// changes.
|
|
213
|
+
const binary = await resolveCloudflared({ autoInstall: options.autoInstall });
|
|
214
|
+
for (let attempt = 1; attempt <= MAX_TUNNEL_ATTEMPTS; attempt++) {
|
|
215
|
+
try {
|
|
216
|
+
return await openTunnelAttempt(port, binary, options);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
if (!(err instanceof HostnameNotRoutedError)) {
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
if (attempt < MAX_TUNNEL_ATTEMPTS) {
|
|
223
|
+
LOGGER.warn({ attempt, reason: err.message }, 'live view: hostname failed to route, trying a fresh one');
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const retries = MAX_TUNNEL_ATTEMPTS - 1;
|
|
227
|
+
const retryWord = retries === 1 ? 'retry' : 'retries';
|
|
228
|
+
throw new Error(`${err.message} ${retries} automatic ${retryWord} with a fresh `
|
|
229
|
+
+ 'hostname also failed to route. Call share_browser_view again '
|
|
230
|
+
+ 'in a few minutes, or check network egress to Cloudflare.'
|
|
231
|
+
+ '\ncloudflared output:\n' + err.cloudflaredLog);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Unreachable: the loop above always returns from a successful attempt or
|
|
235
|
+
// throws once attempts are exhausted. Kept so a future change to the loop
|
|
236
|
+
// bounds can't silently fall through without returning a Tunnel.
|
|
237
|
+
throw new Error('startTunnel: exhausted attempts without a result');
|
|
238
|
+
}
|
|
239
|
+
async function openTunnelAttempt(port, binary, { probePath = '/', readyPollMs = READY_POLL_MS, readyTimeoutMs = READY_TIMEOUT_MS, registeredSettleMs = REGISTERED_SETTLE_MS, probe = probePublicRoute, }) {
|
|
240
|
+
const cloudflaredArgs = [
|
|
77
241
|
'tunnel',
|
|
78
242
|
'--no-autoupdate',
|
|
243
|
+
// HTTP/2 carries WebSockets and survives networks that advertise QUIC
|
|
244
|
+
// support but silently age out its UDP flow after the pre-check succeeds.
|
|
245
|
+
'--protocol', 'http2',
|
|
79
246
|
'--url', `http://127.0.0.1:${port}`,
|
|
247
|
+
];
|
|
248
|
+
const child = spawn(process.execPath, [
|
|
249
|
+
WATCHDOG_PATH,
|
|
250
|
+
String(process.pid),
|
|
251
|
+
binary.path,
|
|
252
|
+
...cloudflaredArgs,
|
|
80
253
|
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
81
254
|
const exited = new Promise((resolve) => {
|
|
82
255
|
child.once('exit', () => resolve());
|
|
@@ -85,6 +258,9 @@ export async function startTunnel(port, { autoInstall = true, probePath = '/', r
|
|
|
85
258
|
const stop = () => stopPromise ?? (stopPromise = stopChild());
|
|
86
259
|
return new Promise((resolve, reject) => {
|
|
87
260
|
let tail = '';
|
|
261
|
+
let url;
|
|
262
|
+
let registered = false;
|
|
263
|
+
let readinessScheduled = false;
|
|
88
264
|
let settled = false;
|
|
89
265
|
const finish = (fn) => {
|
|
90
266
|
if (settled) {
|
|
@@ -96,7 +272,7 @@ export async function startTunnel(port, { autoInstall = true, probePath = '/', r
|
|
|
96
272
|
};
|
|
97
273
|
const timer = setTimeout(() => {
|
|
98
274
|
finish(() => {
|
|
99
|
-
void stop().then(() => reject(new Error('cloudflared did not
|
|
275
|
+
void stop().then(() => reject(new Error('cloudflared did not establish a public route within '
|
|
100
276
|
+ `${URL_TIMEOUT_MS / 1000}s. Check network egress to `
|
|
101
277
|
+ 'Cloudflare, or run `cloudflared tunnel --url '
|
|
102
278
|
+ `http://127.0.0.1:${port}` + '` by hand to see its output.')));
|
|
@@ -111,19 +287,23 @@ export async function startTunnel(port, { autoInstall = true, probePath = '/', r
|
|
|
111
287
|
tail = (tail + text).slice(-LOG_TAIL_BYTES);
|
|
112
288
|
// Search the accumulated tail so a URL split across stream chunks is not
|
|
113
289
|
// missed. The tail is bounded and the URL appears near startup.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
290
|
+
url ?? (url = QUICK_TUNNEL_URL.exec(tail)?.[0]);
|
|
291
|
+
registered || (registered = tail.includes(REGISTERED_TUNNEL));
|
|
292
|
+
if (url && registered && !readinessScheduled) {
|
|
293
|
+
const registeredUrl = url;
|
|
294
|
+
readinessScheduled = true;
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
setTimeout(() => finish(() => {
|
|
297
|
+
const probeUrl = new URL(probePath, `${registeredUrl}/`).toString();
|
|
119
298
|
void waitUntilRouted(probeUrl, {
|
|
120
299
|
isRunning: () => child.exitCode === null
|
|
121
300
|
&& child.signalCode === null,
|
|
122
301
|
pollMs: readyPollMs,
|
|
302
|
+
probe,
|
|
123
303
|
timeoutMs: readyTimeoutMs,
|
|
124
|
-
}).then((ready) => {
|
|
304
|
+
}).then(({ ready, reason }) => {
|
|
125
305
|
if (ready) {
|
|
126
|
-
resolve({ url, ready, stop, binary });
|
|
306
|
+
resolve({ url: registeredUrl, ready, stop, binary });
|
|
127
307
|
return;
|
|
128
308
|
}
|
|
129
309
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
@@ -131,16 +311,13 @@ export async function startTunnel(port, { autoInstall = true, probePath = '/', r
|
|
|
131
311
|
+ 'cloudflared output:\n' + tail));
|
|
132
312
|
return;
|
|
133
313
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
stop,
|
|
140
|
-
binary,
|
|
141
|
-
});
|
|
314
|
+
void stop().then(() => reject(new HostnameNotRoutedError('Cloudflare assigned a hostname, but the public viewer did '
|
|
315
|
+
+ `not become reachable within ${readyTimeoutMs / 1000}s. `
|
|
316
|
+
+ (reason ? `Last check: ${reason}. ` : '')
|
|
317
|
+
+ 'The unverified hostname was closed instead of being '
|
|
318
|
+
+ 'shared.', tail)));
|
|
142
319
|
});
|
|
143
|
-
});
|
|
320
|
+
}), registeredSettleMs);
|
|
144
321
|
}
|
|
145
322
|
};
|
|
146
323
|
child.stdout?.on('data', onChunk);
|