@reclaimprotocol/agent 0.1.26 → 0.1.27

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.
@@ -2,13 +2,6 @@ import { type CloudflaredBinary } from './cloudflared.ts';
2
2
  export interface Tunnel {
3
3
  /** The public HTTPS origin Cloudflare assigned. */
4
4
  url: string;
5
- /** Whether the tunnel was carrying traffic before {@link READY_TIMEOUT_MS}
6
- * elapsed. False means it may still be propagating rather than broken — say
7
- * so rather than treating the share as failed. */
8
- ready: boolean;
9
- /** cloudflared's own last words, for when `ready` is false and someone has
10
- * to work out why. Absent when the tunnel came up normally. */
11
- diagnostics?: string;
12
5
  /** Terminate the tunnel. Safe to call more than once. */
13
6
  stop: () => Promise<void>;
14
7
  /** Which cloudflared ran, and whether we had to fetch it. */
@@ -21,6 +14,12 @@ export interface Tunnel {
21
14
  * Rejects with an actionable message when `cloudflared` is not installed,
22
15
  * exits early, or never reports a hostname.
23
16
  */
24
- export declare function startTunnel(port: number, { autoInstall }?: {
17
+ export declare function startTunnel(port: number, { autoInstall, probePath, readyPollMs, readyTimeoutMs, }?: {
25
18
  autoInstall?: boolean;
19
+ /** Public path that must answer before the tunnel is returned. */
20
+ probePath?: string;
21
+ /** Test-only timing override. */
22
+ readyPollMs?: number;
23
+ /** Test-only timing override. */
24
+ readyTimeoutMs?: number;
26
25
  }): Promise<Tunnel>;
@@ -25,56 +25,42 @@ import { resolveCloudflared } from "./cloudflared.js";
25
25
  const URL_TIMEOUT_MS = 30000;
26
26
  /** After the hostname is printed, Cloudflare needs a moment before the edge
27
27
  * routes to it — measured at ~3s. Wait for it so the caller never hands out a
28
- * URL that errors, but don't fail the share if it's slow. */
28
+ * URL that errors. */
29
29
  const READY_TIMEOUT_MS = 20000;
30
30
  const READY_POLL_MS = 1000;
31
+ const STOP_TIMEOUT_MS = 5000;
31
32
  /** `https://<something>.trycloudflare.com`, as printed on cloudflared's log
32
33
  * stream. Anchored to the scheme so a hostname inside prose can't match. */
33
34
  const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
34
- /**
35
- * cloudflared says this once per edge connection it establishes, and until it
36
- * does, the hostname resolves but routes nowhere.
37
- *
38
- * This is the signal to trust, because it is LOCAL. Asking whether we can fetch
39
- * the public URL ourselves conflates two questions — is the tunnel up, and does
40
- * this machine have egress to Cloudflare's edge — and reports "not ready" when
41
- * only the second fails. Observed: a tunnel printed its hostname and registered
42
- * nothing for 12s, which is indistinguishable from a blocked fetch unless you
43
- * read cloudflared's own account of it.
44
- */
45
- const REGISTERED = /Registered tunnel connection|connIndex=/i;
46
35
  /** Tail of cloudflared's log kept for error messages. */
47
36
  const LOG_TAIL_BYTES = 4096;
48
37
  /**
49
38
  * Wait until the tunnel is actually carrying traffic.
50
39
  *
51
- * Two independent signals, either of which is proof:
52
- * - cloudflared reported an edge registration (see {@link REGISTERED}). Local,
53
- * so it works regardless of this machine's own egress.
54
- * - the public origin answered. ANY HTTP response counts, including a 404:
55
- * the live-view server 404s unknown paths, so a status code at all proves
56
- * the hop is live. Kept as a second opinion in case a cloudflared release
57
- * rewords its log.
40
+ * A cloudflared "Registered tunnel connection" log is not enough: Cloudflare
41
+ * can report one while the assigned hostname still returns DNS NXDOMAIN. The
42
+ * public viewer itself must return a successful response.
58
43
  */
59
- async function waitUntilRouted(url, isRegistered) {
60
- const deadline = Date.now() + READY_TIMEOUT_MS;
44
+ async function waitUntilRouted(url, { isRunning, pollMs, timeoutMs, }) {
45
+ const deadline = Date.now() + timeoutMs;
61
46
  while (Date.now() < deadline) {
62
- if (isRegistered()) {
63
- return true;
47
+ if (!isRunning()) {
48
+ return false;
64
49
  }
65
50
  try {
66
- await fetch(url, {
51
+ const response = await fetch(url, {
67
52
  method: 'GET',
68
53
  redirect: 'manual',
69
- signal: AbortSignal.timeout(READY_POLL_MS * 2),
54
+ signal: AbortSignal.timeout(pollMs * 2),
70
55
  });
71
- return true;
72
- }
73
- catch {
74
- await new Promise((r) => setTimeout(r, READY_POLL_MS));
56
+ if (response.ok) {
57
+ return true;
58
+ }
75
59
  }
60
+ catch { }
61
+ await new Promise((r) => setTimeout(r, pollMs));
76
62
  }
77
- return isRegistered();
63
+ return false;
78
64
  }
79
65
  /**
80
66
  * Open a quick tunnel to `http://127.0.0.1:<port>` and resolve once
@@ -83,7 +69,7 @@ async function waitUntilRouted(url, isRegistered) {
83
69
  * Rejects with an actionable message when `cloudflared` is not installed,
84
70
  * exits early, or never reports a hostname.
85
71
  */
86
- export async function startTunnel(port, { autoInstall = true } = {}) {
72
+ export async function startTunnel(port, { autoInstall = true, probePath = '/', readyPollMs = READY_POLL_MS, readyTimeoutMs = READY_TIMEOUT_MS, } = {}) {
87
73
  // Resolves an existing cloudflared, or fetches one on first use. Throws
88
74
  // with an actionable message when neither is possible.
89
75
  const binary = await resolveCloudflared({ autoInstall });
@@ -92,20 +78,12 @@ export async function startTunnel(port, { autoInstall = true } = {}) {
92
78
  '--no-autoupdate',
93
79
  '--url', `http://127.0.0.1:${port}`,
94
80
  ], { stdio: ['ignore', 'pipe', 'pipe'] });
95
- const stop = async () => {
96
- if (!child.killed) {
97
- child.kill('SIGTERM');
98
- }
99
- };
81
+ const exited = new Promise((resolve) => {
82
+ child.once('exit', () => resolve());
83
+ });
84
+ let stopPromise;
85
+ const stop = () => stopPromise ?? (stopPromise = stopChild());
100
86
  return new Promise((resolve, reject) => {
101
- const timer = setTimeout(() => {
102
- void stop();
103
- reject(new Error('cloudflared did not report a tunnel hostname within '
104
- + `${URL_TIMEOUT_MS / 1000}s. Check network egress to Cloudflare, `
105
- + 'or run `cloudflared tunnel --url http://127.0.0.1:'
106
- + `${port}` + '` by hand to see its output.'));
107
- }, URL_TIMEOUT_MS);
108
- let registered = false;
109
87
  let tail = '';
110
88
  let settled = false;
111
89
  const finish = (fn) => {
@@ -116,27 +94,42 @@ export async function startTunnel(port, { autoInstall = true } = {}) {
116
94
  clearTimeout(timer);
117
95
  fn();
118
96
  };
97
+ const timer = setTimeout(() => {
98
+ finish(() => {
99
+ void stop().then(() => reject(new Error('cloudflared did not report a tunnel hostname within '
100
+ + `${URL_TIMEOUT_MS / 1000}s. Check network egress to `
101
+ + 'Cloudflare, or run `cloudflared tunnel --url '
102
+ + `http://127.0.0.1:${port}` + '` by hand to see its output.')));
103
+ });
104
+ }, URL_TIMEOUT_MS);
119
105
  // cloudflared logs the assigned hostname to stderr, not stdout. Watch
120
106
  // both so a future version that switches streams still works.
121
107
  const onChunk = (chunk) => {
122
108
  const text = chunk.toString();
123
- // Keep watching after the hostname: registration lands a beat later, and
124
- // on a bad run it never lands at all, which is the case worth reporting.
109
+ // Keep watching after the hostname so a readiness failure can include
110
+ // cloudflared's account of what happened.
125
111
  tail = (tail + text).slice(-LOG_TAIL_BYTES);
126
- if (REGISTERED.test(text)) {
127
- registered = true;
128
- }
129
112
  const match = QUICK_TUNNEL_URL.exec(text);
130
113
  if (match) {
131
114
  const url = match[0];
132
115
  finish(() => {
133
- void waitUntilRouted(url, () => registered).then((ready) => {
116
+ const probeUrl = new URL(probePath, `${url}/`).toString();
117
+ void waitUntilRouted(probeUrl, {
118
+ isRunning: () => child.exitCode === null
119
+ && child.signalCode === null,
120
+ pollMs: readyPollMs,
121
+ timeoutMs: readyTimeoutMs,
122
+ }).then(async (ready) => {
134
123
  if (ready) {
135
- resolve({ url, ready, stop, binary });
124
+ resolve({ url, stop, binary });
136
125
  return;
137
126
  }
138
- LOGGER.warn({ url, tail }, 'tunnel never registered with an edge; returning anyway');
139
- resolve({ url, ready, stop, binary, diagnostics: tail });
127
+ await stop();
128
+ LOGGER.warn({ url, tail }, 'tunnel public URL never became reachable');
129
+ reject(new Error('cloudflared created a hostname, but the public viewer did '
130
+ + `not become reachable within ${readyTimeoutMs / 1000}s. `
131
+ + 'Retry share_browser_view. cloudflared output:\n'
132
+ + tail));
140
133
  });
141
134
  });
142
135
  }
@@ -157,4 +150,24 @@ export async function startTunnel(port, { autoInstall = true } = {}) {
157
150
  + 'hostname.')));
158
151
  });
159
152
  });
153
+ async function stopChild() {
154
+ if (child.exitCode !== null || child.signalCode !== null) {
155
+ return;
156
+ }
157
+ let timeout;
158
+ const timedOut = new Promise((resolve) => {
159
+ timeout = setTimeout(resolve, STOP_TIMEOUT_MS);
160
+ });
161
+ child.kill('SIGTERM');
162
+ await Promise.race([
163
+ exited,
164
+ timedOut,
165
+ ]);
166
+ clearTimeout(timeout);
167
+ if (child.exitCode !== null || child.signalCode !== null) {
168
+ return;
169
+ }
170
+ child.kill('SIGKILL');
171
+ await exited;
172
+ }
160
173
  }
@@ -111,6 +111,10 @@ export declare const MCP_TOOLS: {
111
111
  description: string;
112
112
  inputSchema: import("zod").ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>>;
113
113
  };
114
+ ImportDevtoolsProvider: {
115
+ description: string;
116
+ inputSchema: import("zod").ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>>;
117
+ };
114
118
  IssueOrgToken: {
115
119
  description: string;
116
120
  inputSchema: import("zod").ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>>;
@@ -1812,6 +1812,46 @@ export const MCP_TOOLS = {
1812
1812
  "additionalProperties": false
1813
1813
  }),
1814
1814
  },
1815
+ "ImportDevtoolsProvider": {
1816
+ description: "Import a provider from devtools\n\nImport the latest version of a public provider from the legacy devtools\nservice. Use this when moving an existing provider to Builder instead\nof recreating it.\n\nThe import preserves the legacy provider UUID. The server reads the\ncurrent configuration from\n`https://api.reclaimprotocol.org/api/providers/{providerId}` and\ntranslates it to the Builder schema.\n\nIf the provider already exists in the seeded anonymous catalog, the\nimport assigns that record to the target organization and adds the\nimported version idempotently. A provider owned by another Builder\norganization returns `409`.\n",
1817
+ inputSchema: toZod({
1818
+ "type": "object",
1819
+ "properties": {
1820
+ "params": {
1821
+ "type": "object",
1822
+ "properties": {}
1823
+ },
1824
+ "query": {
1825
+ "type": "object",
1826
+ "properties": {}
1827
+ },
1828
+ "body": {
1829
+ "type": "object",
1830
+ "additionalProperties": false,
1831
+ "required": [
1832
+ "orgId",
1833
+ "providerId"
1834
+ ],
1835
+ "properties": {
1836
+ "orgId": {
1837
+ "type": "string",
1838
+ "format": "uuid"
1839
+ },
1840
+ "providerId": {
1841
+ "type": "string",
1842
+ "format": "uuid"
1843
+ }
1844
+ }
1845
+ }
1846
+ },
1847
+ "required": [
1848
+ "params",
1849
+ "query",
1850
+ "body"
1851
+ ],
1852
+ "additionalProperties": false
1853
+ }),
1854
+ },
1815
1855
  "IssueOrgToken": {
1816
1856
  description: "Issue a new organization API key\n\nIssues a fresh per-organization API key (`rorg_…`). The caller must be an active **OWNER** of the organization. An org may hold several keys; issuing a new one does **not** invalidate existing keys (revoke those explicitly). The plaintext key is returned **only once** in this response (the server stores only its HMAC); treat it like a password.",
1817
1857
  inputSchema: toZod({
@@ -61,6 +61,8 @@ const SHARED_TOPIC_FILES = {
61
61
  const TOPIC_SUMMARIES = {
62
62
  'overview': 'Terminology, your stance, and the whole loop at a glance.',
63
63
  'auth': 'Signing in to the backend, and which token authorizes what.',
64
+ 'devtools-migration': 'Importing a public legacy provider with the same '
65
+ + 'UUID.',
64
66
  'browser': 'Choosing a browser source (local Chrome, the Reclaim runtime in '
65
67
  + 'Docker, a remote Popcorn browser, or a custom CDP endpoint), '
66
68
  + 'geolocation, live view, and launch troubleshooting.',
@@ -83,7 +83,9 @@ export function liveViewTools(attachRef, shareRef) {
83
83
  const viewerPort = attached.viewerPort;
84
84
  assert(viewerPort, badRequest('This browser reports no live-view port. Re-attach with mode '
85
85
  + '"container".'));
86
- const tunnel = await startTunnel(viewerPort);
86
+ const tunnel = await startTunnel(viewerPort, {
87
+ probePath: '/liveview.html?magnify=1',
88
+ });
87
89
  // The path is the runtime's own viewer page; `magnify=1` is what makes
88
90
  // it lay out for the device that opens it.
89
91
  const url = `${tunnel.url}/liveview.html?magnify=1`;
@@ -97,30 +99,11 @@ export function liveViewTools(attachRef, shareRef) {
97
99
  ...(share.tunnel.binary.source === 'downloaded'
98
100
  ? { cloudflaredInstalled: true }
99
101
  : {}),
100
- ...(share.tunnel.ready
101
- ? {}
102
- : {
103
- propagating: true,
104
- // cloudflared's own last words. Without them, "the URL does
105
- // not connect" is unactionable: the tunnel, the local server
106
- // and this machine's egress all look identical from outside.
107
- ...(share.tunnel.diagnostics
108
- ? { cloudflaredLog: share.tunnel.diagnostics }
109
- : {}),
110
- }),
111
102
  _notes: [
112
103
  ...(share.tunnel.binary.source === 'downloaded'
113
104
  ? ['cloudflared was not installed, so it was downloaded once '
114
105
  + 'to ~/.reclaim/bin. Mention that; later shares reuse it.']
115
106
  : []),
116
- ...(share.tunnel.ready
117
- ? []
118
- : ['The tunnel never confirmed a connection to Cloudflare, so '
119
- + 'the URL may not resolve yet. Give it ~10s and reload '
120
- + 'first. If it still fails, say so and offer to retry '
121
- + '(stop_browser_view then share_browser_view) — a quick '
122
- + 'tunnel does sometimes fail to register. '
123
- + '`cloudflaredLog` has its output if it keeps happening.']),
124
107
  'Relay `url` to the developer verbatim, on its own line.',
125
108
  'The URL is a credential: it needs no login, and anyone with it '
126
109
  + 'can see this browser and act in it as the signed-in user.',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/agent",
3
3
  "title": "Reclaim Agent",
4
- "version": "0.1.26",
4
+ "version": "0.1.27",
5
5
  "description": "Reclaim Protocol tools for AI coding agents. Reclaim lets your app verify data points about its users, such as a follower count, an account balance, or an order history. This package is the Reclaim MCP server: it gives your agent the tools to find, build, test, and fix data verification providers by capturing and replaying browser requests, drafting provider recipes, running zkTLS proofs, and publishing provider versions.",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -83,7 +83,7 @@
83
83
  "@modelcontextprotocol/sdk": "^1.29.0",
84
84
  "@noble/hashes": "^2.2.0",
85
85
  "@reclaimprotocol/attestor-core": "^5.0.8",
86
- "@reclaimprotocol/client": "0.1.26",
86
+ "@reclaimprotocol/client": "0.1.27",
87
87
  "@reclaimprotocol/tls": "^0.1.3",
88
88
  "chrome-remote-interface": "^0.33.0",
89
89
  "domhandler": "^6.0.0",
@@ -49,6 +49,22 @@ immediately with a `code` and `attachUrl` — it does NOT poll.
49
49
  The 2-day token is cached in `~/.reclaim/config.json` and reused on later runs
50
50
  (`RECLAIM_API_TOKEN` in the server env overrides it).
51
51
 
52
+ <!-- topic: devtools-migration -->
53
+ ## Import providers from devtools
54
+
55
+ Use these operations only when the developer is migrating an existing legacy
56
+ integration. Prefer Builder for new integrations.
57
+
58
+ ### Import from devtools
59
+
60
+ 1. Get the target `orgId` with `list_orgs`.
61
+ 2. Ask the developer for the legacy provider UUID.
62
+ 3. Call `import_devtools_provider(orgId, providerId)`.
63
+ 4. Use the returned provider UUID for all Builder operations. Import preserves
64
+ the UUID and latest legacy semantic version. It claims an anonymous catalog
65
+ seed idempotently, but never moves a provider from another Builder org. The
66
+ server reads the public devtools API; no legacy token is required.
67
+
52
68
  <!-- topic: browser -->
53
69
  <!-- common: browser -->
54
70
 
@@ -9,9 +9,9 @@ call a provider a "verification."**
9
9
 
10
10
  **Read the guide before authoring.** `how_it_works({ topic })` serves it, one
11
11
  topic at a time — call `how_it_works({})` for the topic index. Topics:
12
- `overview`, `auth`, `browser`, `capture`, `draft`, `params`, `prove`, `publish`,
13
- `discover`, `user-script`, `interception`, `hash-validation`, `credentials`,
14
- `never-do`, `troubleshooting`.
12
+ `overview`, `auth`, `devtools-migration`, `browser`, `capture`, `draft`,
13
+ `params`, `prove`, `publish`, `discover`, `user-script`, `interception`,
14
+ `hash-validation`, `credentials`, `never-do`, `troubleshooting`.
15
15
 
16
16
  **Authentication:** try `get_me` first. On 401, `authenticate({})` returns a
17
17
  device-pairing `code` + `attachUrl` — give the user the URL, then poll
@@ -26,40 +26,21 @@ import { type CloudflaredBinary, resolveCloudflared } from './cloudflared.ts'
26
26
  const URL_TIMEOUT_MS = 30_000
27
27
  /** After the hostname is printed, Cloudflare needs a moment before the edge
28
28
  * routes to it — measured at ~3s. Wait for it so the caller never hands out a
29
- * URL that errors, but don't fail the share if it's slow. */
29
+ * URL that errors. */
30
30
  const READY_TIMEOUT_MS = 20_000
31
31
  const READY_POLL_MS = 1_000
32
+ const STOP_TIMEOUT_MS = 5_000
32
33
 
33
34
  /** `https://<something>.trycloudflare.com`, as printed on cloudflared's log
34
35
  * stream. Anchored to the scheme so a hostname inside prose can't match. */
35
36
  const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
36
37
 
37
- /**
38
- * cloudflared says this once per edge connection it establishes, and until it
39
- * does, the hostname resolves but routes nowhere.
40
- *
41
- * This is the signal to trust, because it is LOCAL. Asking whether we can fetch
42
- * the public URL ourselves conflates two questions — is the tunnel up, and does
43
- * this machine have egress to Cloudflare's edge — and reports "not ready" when
44
- * only the second fails. Observed: a tunnel printed its hostname and registered
45
- * nothing for 12s, which is indistinguishable from a blocked fetch unless you
46
- * read cloudflared's own account of it.
47
- */
48
- const REGISTERED = /Registered tunnel connection|connIndex=/i
49
-
50
38
  /** Tail of cloudflared's log kept for error messages. */
51
39
  const LOG_TAIL_BYTES = 4096
52
40
 
53
41
  export interface Tunnel {
54
42
  /** The public HTTPS origin Cloudflare assigned. */
55
43
  url: string
56
- /** Whether the tunnel was carrying traffic before {@link READY_TIMEOUT_MS}
57
- * elapsed. False means it may still be propagating rather than broken — say
58
- * so rather than treating the share as failed. */
59
- ready: boolean
60
- /** cloudflared's own last words, for when `ready` is false and someone has
61
- * to work out why. Absent when the tunnel came up normally. */
62
- diagnostics?: string
63
44
  /** Terminate the tunnel. Safe to call more than once. */
64
45
  stop: () => Promise<void>
65
46
  /** Which cloudflared ran, and whether we had to fetch it. */
@@ -69,37 +50,43 @@ export interface Tunnel {
69
50
  /**
70
51
  * Wait until the tunnel is actually carrying traffic.
71
52
  *
72
- * Two independent signals, either of which is proof:
73
- * - cloudflared reported an edge registration (see {@link REGISTERED}). Local,
74
- * so it works regardless of this machine's own egress.
75
- * - the public origin answered. ANY HTTP response counts, including a 404:
76
- * the live-view server 404s unknown paths, so a status code at all proves
77
- * the hop is live. Kept as a second opinion in case a cloudflared release
78
- * rewords its log.
53
+ * A cloudflared "Registered tunnel connection" log is not enough: Cloudflare
54
+ * can report one while the assigned hostname still returns DNS NXDOMAIN. The
55
+ * public viewer itself must return a successful response.
79
56
  */
80
57
  async function waitUntilRouted(
81
58
  url: string,
82
- isRegistered: () => boolean,
59
+ {
60
+ isRunning,
61
+ pollMs,
62
+ timeoutMs,
63
+ }: {
64
+ isRunning: () => boolean
65
+ pollMs: number
66
+ timeoutMs: number
67
+ },
83
68
  ): Promise<boolean> {
84
- const deadline = Date.now() + READY_TIMEOUT_MS
69
+ const deadline = Date.now() + timeoutMs
85
70
  while(Date.now() < deadline) {
86
- if(isRegistered()) {
87
- return true
71
+ if(!isRunning()) {
72
+ return false
88
73
  }
89
74
 
90
75
  try {
91
- await fetch(url, {
76
+ const response = await fetch(url, {
92
77
  method: 'GET',
93
78
  redirect: 'manual',
94
- signal: AbortSignal.timeout(READY_POLL_MS * 2),
79
+ signal: AbortSignal.timeout(pollMs * 2),
95
80
  })
96
- return true
97
- } catch{
98
- await new Promise((r) => setTimeout(r, READY_POLL_MS))
99
- }
81
+ if(response.ok) {
82
+ return true
83
+ }
84
+ } catch{}
85
+
86
+ await new Promise((r) => setTimeout(r, pollMs))
100
87
  }
101
88
 
102
- return isRegistered()
89
+ return false
103
90
  }
104
91
 
105
92
  /**
@@ -111,7 +98,20 @@ async function waitUntilRouted(
111
98
  */
112
99
  export async function startTunnel(
113
100
  port: number,
114
- { autoInstall = true }: { autoInstall?: boolean } = {},
101
+ {
102
+ autoInstall = true,
103
+ probePath = '/',
104
+ readyPollMs = READY_POLL_MS,
105
+ readyTimeoutMs = READY_TIMEOUT_MS,
106
+ }: {
107
+ autoInstall?: boolean
108
+ /** Public path that must answer before the tunnel is returned. */
109
+ probePath?: string
110
+ /** Test-only timing override. */
111
+ readyPollMs?: number
112
+ /** Test-only timing override. */
113
+ readyTimeoutMs?: number
114
+ } = {},
115
115
  ): Promise<Tunnel> {
116
116
  // Resolves an existing cloudflared, or fetches one on first use. Throws
117
117
  // with an actionable message when neither is possible.
@@ -121,25 +121,14 @@ export async function startTunnel(
121
121
  '--no-autoupdate',
122
122
  '--url', `http://127.0.0.1:${port}`,
123
123
  ], { stdio: ['ignore', 'pipe', 'pipe'] })
124
+ const exited = new Promise<void>((resolve) => {
125
+ child.once('exit', () => resolve())
126
+ })
124
127
 
125
- const stop = async() => {
126
- if(!child.killed) {
127
- child.kill('SIGTERM')
128
- }
129
- }
128
+ let stopPromise: Promise<void> | undefined
129
+ const stop = () => stopPromise ??= stopChild()
130
130
 
131
131
  return new Promise<Tunnel>((resolve, reject) => {
132
- const timer = setTimeout(() => {
133
- void stop()
134
- reject(new Error(
135
- 'cloudflared did not report a tunnel hostname within '
136
- + `${URL_TIMEOUT_MS / 1000}s. Check network egress to Cloudflare, `
137
- + 'or run `cloudflared tunnel --url http://127.0.0.1:'
138
- + `${port}` + '` by hand to see its output.',
139
- ))
140
- }, URL_TIMEOUT_MS)
141
-
142
- let registered = false
143
132
  let tail = ''
144
133
  let settled = false
145
134
  const finish = (fn: () => void) => {
@@ -152,30 +141,50 @@ export async function startTunnel(
152
141
  fn()
153
142
  }
154
143
 
144
+ const timer = setTimeout(() => {
145
+ finish(() => {
146
+ void stop().then(() => reject(new Error(
147
+ 'cloudflared did not report a tunnel hostname within '
148
+ + `${URL_TIMEOUT_MS / 1000}s. Check network egress to `
149
+ + 'Cloudflare, or run `cloudflared tunnel --url '
150
+ + `http://127.0.0.1:${port}` + '` by hand to see its output.',
151
+ )))
152
+ })
153
+ }, URL_TIMEOUT_MS)
154
+
155
155
  // cloudflared logs the assigned hostname to stderr, not stdout. Watch
156
156
  // both so a future version that switches streams still works.
157
157
  const onChunk = (chunk: Buffer) => {
158
158
  const text = chunk.toString()
159
- // Keep watching after the hostname: registration lands a beat later, and
160
- // on a bad run it never lands at all, which is the case worth reporting.
159
+ // Keep watching after the hostname so a readiness failure can include
160
+ // cloudflared's account of what happened.
161
161
  tail = (tail + text).slice(-LOG_TAIL_BYTES)
162
- if(REGISTERED.test(text)) {
163
- registered = true
164
- }
165
162
 
166
163
  const match = QUICK_TUNNEL_URL.exec(text)
167
164
  if(match) {
168
165
  const url = match[0]
169
166
  finish(() => {
170
- void waitUntilRouted(url, () => registered).then((ready) => {
167
+ const probeUrl = new URL(probePath, `${url}/`).toString()
168
+ void waitUntilRouted(probeUrl, {
169
+ isRunning: () => child.exitCode === null
170
+ && child.signalCode === null,
171
+ pollMs: readyPollMs,
172
+ timeoutMs: readyTimeoutMs,
173
+ }).then(async(ready) => {
171
174
  if(ready) {
172
- resolve({ url, ready, stop, binary })
175
+ resolve({ url, stop, binary })
173
176
  return
174
177
  }
175
178
 
179
+ await stop()
176
180
  LOGGER.warn({ url, tail },
177
- 'tunnel never registered with an edge; returning anyway')
178
- resolve({ url, ready, stop, binary, diagnostics: tail })
181
+ 'tunnel public URL never became reachable')
182
+ reject(new Error(
183
+ 'cloudflared created a hostname, but the public viewer did '
184
+ + `not become reachable within ${readyTimeoutMs / 1000}s. `
185
+ + 'Retry share_browser_view. cloudflared output:\n'
186
+ + tail,
187
+ ))
179
188
  })
180
189
  })
181
190
  }
@@ -205,4 +214,27 @@ export async function startTunnel(
205
214
  )))
206
215
  })
207
216
  })
217
+
218
+ async function stopChild() {
219
+ if(child.exitCode !== null || child.signalCode !== null) {
220
+ return
221
+ }
222
+
223
+ let timeout: NodeJS.Timeout | undefined
224
+ const timedOut = new Promise<void>((resolve) => {
225
+ timeout = setTimeout(resolve, STOP_TIMEOUT_MS)
226
+ })
227
+ child.kill('SIGTERM')
228
+ await Promise.race([
229
+ exited,
230
+ timedOut,
231
+ ])
232
+ clearTimeout(timeout)
233
+ if(child.exitCode !== null || child.signalCode !== null) {
234
+ return
235
+ }
236
+
237
+ child.kill('SIGKILL')
238
+ await exited
239
+ }
208
240
  }
@@ -1886,6 +1886,50 @@ export const MCP_TOOLS = {
1886
1886
  "additionalProperties": false
1887
1887
  }),
1888
1888
  },
1889
+ "ImportDevtoolsProvider": {
1890
+ description: "Import a provider from devtools\n\nImport the latest version of a public provider from the legacy devtools\nservice. Use this when moving an existing provider to Builder instead\nof recreating it.\n\nThe import preserves the legacy provider UUID. The server reads the\ncurrent configuration from\n`https://api.reclaimprotocol.org/api/providers/{providerId}` and\ntranslates it to the Builder schema.\n\nIf the provider already exists in the seeded anonymous catalog, the\nimport assigns that record to the target organization and adds the\nimported version idempotently. A provider owned by another Builder\norganization returns `409`.\n",
1891
+ inputSchema: toZod({
1892
+ "type": "object",
1893
+ "properties": {
1894
+ "params": {
1895
+ "type": "object",
1896
+ "properties": {
1897
+
1898
+ }
1899
+ },
1900
+ "query": {
1901
+ "type": "object",
1902
+ "properties": {
1903
+
1904
+ }
1905
+ },
1906
+ "body": {
1907
+ "type": "object",
1908
+ "additionalProperties": false,
1909
+ "required": [
1910
+ "orgId",
1911
+ "providerId"
1912
+ ],
1913
+ "properties": {
1914
+ "orgId": {
1915
+ "type": "string",
1916
+ "format": "uuid"
1917
+ },
1918
+ "providerId": {
1919
+ "type": "string",
1920
+ "format": "uuid"
1921
+ }
1922
+ }
1923
+ }
1924
+ },
1925
+ "required": [
1926
+ "params",
1927
+ "query",
1928
+ "body"
1929
+ ],
1930
+ "additionalProperties": false
1931
+ }),
1932
+ },
1889
1933
  "IssueOrgToken": {
1890
1934
  description: "Issue a new organization API key\n\nIssues a fresh per-organization API key (`rorg_…`). The caller must be an active **OWNER** of the organization. An org may hold several keys; issuing a new one does **not** invalidate existing keys (revoke those explicitly). The plaintext key is returned **only once** in this response (the server stores only its HMAC); treat it like a password.",
1891
1935
  inputSchema: toZod({
@@ -71,6 +71,8 @@ const SHARED_TOPIC_FILES: Record<string, string> = {
71
71
  const TOPIC_SUMMARIES: Record<string, string> = {
72
72
  'overview': 'Terminology, your stance, and the whole loop at a glance.',
73
73
  'auth': 'Signing in to the backend, and which token authorizes what.',
74
+ 'devtools-migration': 'Importing a public legacy provider with the same '
75
+ + 'UUID.',
74
76
  'browser': 'Choosing a browser source (local Chrome, the Reclaim runtime in '
75
77
  + 'Docker, a remote Popcorn browser, or a custom CDP endpoint), '
76
78
  + 'geolocation, live view, and launch troubleshooting.',
@@ -113,7 +113,9 @@ export function liveViewTools(
113
113
  + '"container".',
114
114
  ))
115
115
 
116
- const tunnel = await startTunnel(viewerPort)
116
+ const tunnel = await startTunnel(viewerPort, {
117
+ probePath: '/liveview.html?magnify=1',
118
+ })
117
119
  // The path is the runtime's own viewer page; `magnify=1` is what makes
118
120
  // it lay out for the device that opens it.
119
121
  const url = `${tunnel.url}/liveview.html?magnify=1`
@@ -128,30 +130,11 @@ export function liveViewTools(
128
130
  ...(share.tunnel.binary.source === 'downloaded'
129
131
  ? { cloudflaredInstalled: true }
130
132
  : {}),
131
- ...(share.tunnel.ready
132
- ? {}
133
- : {
134
- propagating: true,
135
- // cloudflared's own last words. Without them, "the URL does
136
- // not connect" is unactionable: the tunnel, the local server
137
- // and this machine's egress all look identical from outside.
138
- ...(share.tunnel.diagnostics
139
- ? { cloudflaredLog: share.tunnel.diagnostics }
140
- : {}),
141
- }),
142
133
  _notes: [
143
134
  ...(share.tunnel.binary.source === 'downloaded'
144
135
  ? ['cloudflared was not installed, so it was downloaded once '
145
136
  + 'to ~/.reclaim/bin. Mention that; later shares reuse it.']
146
137
  : []),
147
- ...(share.tunnel.ready
148
- ? []
149
- : ['The tunnel never confirmed a connection to Cloudflare, so '
150
- + 'the URL may not resolve yet. Give it ~10s and reload '
151
- + 'first. If it still fails, say so and offer to retry '
152
- + '(stop_browser_view then share_browser_view) — a quick '
153
- + 'tunnel does sometimes fail to register. '
154
- + '`cloudflaredLog` has its output if it keeps happening.']),
155
138
  'Relay `url` to the developer verbatim, on its own line.',
156
139
  'The URL is a credential: it needs no login, and anyone with it '
157
140
  + 'can see this browser and act in it as the signed-in user.',