@reclaimprotocol/agent 0.1.29 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +28 -9
  2. package/lib/consts.d.ts +5 -5
  3. package/lib/consts.js +7 -7
  4. package/lib/live/tunnel-watchdog.d.ts +1 -0
  5. package/lib/live/tunnel-watchdog.js +66 -0
  6. package/lib/live/tunnel.d.ts +30 -7
  7. package/lib/live/tunnel.js +217 -40
  8. package/lib/mcp/api/operations.gen.d.ts +12 -0
  9. package/lib/mcp/api/operations.gen.js +548 -59
  10. package/lib/mcp/api/tools.js +27 -5
  11. package/lib/mcp/instructions.js +23 -32
  12. package/lib/mcp/remap_errors.js +6 -0
  13. package/lib/mcp/server.js +2 -8
  14. package/lib/mcp/start.js +3 -5
  15. package/lib/mcp/success-response.d.ts +5 -0
  16. package/lib/mcp/success-response.js +21 -0
  17. package/lib/mcp/tools/agent/attach.js +9 -66
  18. package/lib/mcp/tools/agent/build.d.ts +0 -3
  19. package/lib/mcp/tools/agent/build.js +3 -4
  20. package/lib/mcp/tools/agent/how-it-works.d.ts +5 -15
  21. package/lib/mcp/tools/agent/how-it-works.js +9 -22
  22. package/lib/mcp/tools/agent/live-view.d.ts +1 -15
  23. package/lib/mcp/tools/agent/live-view.js +71 -96
  24. package/lib/mcp/tools/agent/propose.js +7 -23
  25. package/lib/mcp/tools/agent/publish.js +80 -18
  26. package/lib/mcp/tools/agent/session.d.ts +4 -7
  27. package/lib/mcp/tools/agent/session.js +8 -23
  28. package/lib/mcp/tools/verification/result.d.ts +2 -1
  29. package/lib/mcp/tools/verification/result.js +29 -28
  30. package/lib/old/client.d.ts +2 -2
  31. package/lib/old/flag.d.ts +2 -5
  32. package/lib/old/flag.js +2 -5
  33. package/lib/old/instructions.d.ts +1 -1
  34. package/lib/old/instructions.js +1 -1
  35. package/lib/old/to-register.d.ts +4 -3
  36. package/lib/old/tools/get-provider-info.js +3 -1
  37. package/lib/old/tools/publish.js +11 -14
  38. package/lib/proof/attestor-auth.d.ts +9 -1
  39. package/lib/proof/attestor-auth.js +15 -2
  40. package/lib/proof/attestor.d.ts +2 -2
  41. package/lib/proof/attestor.js +20 -2
  42. package/lib/proof/pod-prover.js +1 -1
  43. package/lib/proof/proof-core.d.ts +4 -0
  44. package/lib/proof/proof-core.js +20 -0
  45. package/lib/provider/injection-doc.d.ts +3 -2
  46. package/lib/provider/injection-doc.js +12 -10
  47. package/lib/provider/matcher.js +19 -15
  48. package/lib/provider/to-version.d.ts +3 -0
  49. package/lib/provider/to-version.js +2 -1
  50. package/package.json +3 -3
  51. package/skill/SKILL.builder.md +44 -86
  52. package/skill/SKILL.common.md +38 -22
  53. package/skill/SKILL.old.md +21 -5
  54. package/skill/builder-mode.md +62 -4
  55. package/skill/community-contributions.md +12 -3
  56. package/skill/hash-validation.md +9 -9
  57. package/skill/interception.md +39 -46
  58. package/skill/old-mode.md +25 -0
  59. package/skill/user-script.md +24 -8
  60. package/skill/verification-client-integration.md +133 -0
  61. package/src/consts.ts +9 -7
  62. package/src/mcp/api/operations.gen.ts +550 -59
  63. package/src/mcp/api/tools.ts +38 -7
  64. package/src/mcp/instructions.ts +33 -32
  65. package/src/mcp/remap_errors.ts +6 -0
  66. package/src/mcp/server.ts +2 -8
  67. package/src/mcp/start.ts +3 -5
  68. package/src/mcp/success-response.ts +26 -0
  69. package/src/mcp/tools/agent/attach.ts +9 -71
  70. package/src/mcp/tools/agent/build.ts +4 -7
  71. package/src/mcp/tools/agent/how-it-works.ts +9 -22
  72. package/src/mcp/tools/agent/live-view.ts +76 -116
  73. package/src/mcp/tools/agent/propose.ts +7 -23
  74. package/src/mcp/tools/agent/publish.ts +89 -18
  75. package/src/mcp/tools/agent/session.ts +6 -23
  76. package/src/mcp/tools/verification/result.ts +30 -29
  77. package/src/old/client.ts +2 -2
  78. package/src/old/flag.ts +2 -5
  79. package/src/old/instructions.ts +1 -1
  80. package/src/old/to-register.ts +4 -3
  81. package/src/old/tools/get-provider-info.ts +3 -1
  82. package/src/old/tools/publish.ts +16 -14
  83. package/src/proof/attestor-auth.ts +18 -1
  84. package/src/proof/attestor.ts +24 -3
  85. package/src/proof/pod-prover.ts +1 -1
  86. package/src/proof/proof-core.ts +28 -0
  87. package/src/provider/injection-doc.ts +12 -10
  88. package/src/provider/matcher.ts +25 -17
  89. package/src/provider/to-version.ts +7 -1
  90. package/src/live/cloudflared.ts +0 -348
  91. 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`** — not required up front. `share_browser_view` uses an existing install if one is on `PATH`, and otherwise downloads it once to `~/.reclaim/bin` on first share (never at install time). Set `RECLAIM_CLOUDFLARED_PATH` to point at your own copy.
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**, **Codex**, **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 plugin/marketplace is published.
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 production old-devtools backend, so **no env is needed for the common case**. To override, add an `env` block (or `environment` in opencode) to the config above:
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 builder mode (`USE_OLD_DEVTOOLS=false`) and the full env-var list.
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`, `stop_browser_view` | Put the `container` browser's own live view behind a public HTTPS URL (cloudflared quick tunnel), so someone elsewhere can watch it and act in it — from a phone if they like. Container mode only: a `builder` browser already returns a hosted `liveViewUrl`, and a plain local browser has no view to share |
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, selected by `USE_OLD_DEVTOOLS`:
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 (default).** The legacy devtools backend is still in production while the new "builder" backend is built, so this is the default — opt into builder with `USE_OLD_DEVTOOLS=false`. In old mode all auto-generated HTTP-API tools are disabled and a small client (`src/old/`) serves:
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 (`USE_OLD_DEVTOOLS=false`).** Registers the auto-generated HTTP-API tools plus the authentication/credential tools (`authenticate` device-pairing login, `issue_credentials` / `import_credentials` to set up the local eth proof-owner key, `resolve_owner_key`) and a verification-result helper (`get_verification_result`).
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 target a specific backend while developing, add an `env` block (Claude Code / `.mcp.json`) or `environment` (opencode) — for example, `"USE_OLD_DEVTOOLS": "false"` for builder mode. See [Backends](#backends).
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. Defaults to OLD devtools while the builder backend is under
35
- * development. Builder mode must be opted into EXPLICITLY with
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` → default (old).
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 (old). Surfaced by the `get_devtools_mode` tool so the
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. Defaults to OLD devtools while the builder backend is under
53
- * development. Builder mode must be opted into EXPLICITLY with
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() !== 'false';
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` → default (old).
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 true;
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 (old). Surfaced by the `get_devtools_mode` tool so the
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
+ }
@@ -12,13 +12,20 @@ export interface Tunnel {
12
12
  binary: CloudflaredBinary;
13
13
  }
14
14
  /**
15
- * Open a quick tunnel to `http://127.0.0.1:<port>` and resolve once
16
- * Cloudflare has assigned a hostname.
17
- *
18
- * Rejects with an actionable message when `cloudflared` is not installed,
19
- * exits early, or never reports a hostname.
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
- export declare function startTunnel(port: number, { autoInstall, probePath, readyPollMs, readyTimeoutMs, }?: {
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
- }): Promise<Tunnel>;
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 {};
@@ -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
- /** After the hostname is printed, Cloudflare needs time before the edge routes
27
- * to it. Probe briefly, then return the still-running tunnel as propagating so
28
- * slow DNS or edge setup does not destroy an otherwise healthy share. */
29
- const READY_TIMEOUT_MS = 20000;
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
- try {
51
- const response = await fetch(url, {
52
- method: 'GET',
53
- redirect: 'manual',
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
- catch { }
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, { autoInstall = true, probePath = '/', readyPollMs = READY_POLL_MS, readyTimeoutMs = READY_TIMEOUT_MS, } = {}) {
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
- const binary = await resolveCloudflared({ autoInstall });
76
- const child = spawn(binary.path, [
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 report a tunnel hostname within '
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
- const match = QUICK_TUNNEL_URL.exec(tail);
115
- if (match) {
116
- const url = match[0];
117
- finish(() => {
118
- const probeUrl = new URL(probePath, `${url}/`).toString();
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
- LOGGER.warn({ url, tail }, 'tunnel still propagating; returning the live hostname');
135
- resolve({
136
- url,
137
- ready,
138
- cloudflaredLog: tail,
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);