@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.
- package/lib/live/tunnel.d.ts +7 -8
- package/lib/live/tunnel.js +67 -54
- package/lib/mcp/api/operations.gen.d.ts +4 -0
- package/lib/mcp/api/operations.gen.js +40 -0
- package/lib/mcp/instructions.js +2 -0
- package/lib/mcp/tools/agent/live-view.js +3 -20
- package/package.json +2 -2
- package/skill/SKILL.builder.md +16 -0
- package/skill/builder-mode.md +3 -3
- package/src/live/tunnel.ts +97 -65
- package/src/mcp/api/operations.gen.ts +44 -0
- package/src/mcp/instructions.ts +2 -0
- package/src/mcp/tools/agent/live-view.ts +3 -20
package/lib/live/tunnel.d.ts
CHANGED
|
@@ -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>;
|
package/lib/live/tunnel.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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,
|
|
60
|
-
const deadline = Date.now() +
|
|
44
|
+
async function waitUntilRouted(url, { isRunning, pollMs, timeoutMs, }) {
|
|
45
|
+
const deadline = Date.now() + timeoutMs;
|
|
61
46
|
while (Date.now() < deadline) {
|
|
62
|
-
if (
|
|
63
|
-
return
|
|
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(
|
|
54
|
+
signal: AbortSignal.timeout(pollMs * 2),
|
|
70
55
|
});
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
124
|
-
//
|
|
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
|
-
|
|
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,
|
|
124
|
+
resolve({ url, stop, binary });
|
|
136
125
|
return;
|
|
137
126
|
}
|
|
138
|
-
|
|
139
|
-
|
|
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({
|
package/lib/mcp/instructions.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|
package/skill/SKILL.builder.md
CHANGED
|
@@ -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
|
|
package/skill/builder-mode.md
CHANGED
|
@@ -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`,
|
|
13
|
-
`
|
|
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
|
package/src/live/tunnel.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
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() +
|
|
69
|
+
const deadline = Date.now() + timeoutMs
|
|
85
70
|
while(Date.now() < deadline) {
|
|
86
|
-
if(
|
|
87
|
-
return
|
|
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(
|
|
79
|
+
signal: AbortSignal.timeout(pollMs * 2),
|
|
95
80
|
})
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
160
|
-
//
|
|
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
|
-
|
|
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,
|
|
175
|
+
resolve({ url, stop, binary })
|
|
173
176
|
return
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
await stop()
|
|
176
180
|
LOGGER.warn({ url, tail },
|
|
177
|
-
'tunnel
|
|
178
|
-
|
|
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({
|
package/src/mcp/instructions.ts
CHANGED
|
@@ -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.',
|