@reclaimprotocol/agent 0.1.24 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/lib/cdp/claims.d.ts +21 -0
- package/lib/cdp/claims.js +129 -0
- package/lib/cdp/client.d.ts +0 -10
- package/lib/cdp/container.d.ts +85 -0
- package/lib/cdp/container.js +481 -0
- package/lib/live/tunnel.d.ts +6 -3
- package/lib/live/tunnel.js +57 -17
- package/lib/mcp/instructions.js +2 -1
- package/lib/mcp/start.js +58 -3
- package/lib/mcp/tools/agent/attach.d.ts +22 -2
- package/lib/mcp/tools/agent/attach.js +197 -55
- package/lib/mcp/tools/agent/build.d.ts +5 -0
- package/lib/mcp/tools/agent/build.js +9 -2
- package/lib/mcp/tools/agent/index.d.ts +1 -2
- package/lib/mcp/tools/agent/index.js +1 -1
- package/lib/mcp/tools/agent/live-view.d.ts +3 -7
- package/lib/mcp/tools/agent/live-view.js +74 -81
- package/lib/mcp/tools/agent/session.d.ts +14 -0
- package/lib/mcp/tools/agent/session.js +14 -8
- package/package.json +2 -2
- package/skill/SKILL.common.md +115 -38
- package/skill/builder-mode.md +9 -7
- package/skill/interception.md +3 -2
- package/skill/old-mode.md +10 -8
- package/src/cdp/claims.ts +153 -0
- package/src/cdp/client.ts +0 -8
- package/src/cdp/container.ts +593 -0
- package/src/live/tunnel.ts +71 -20
- package/src/mcp/instructions.ts +2 -1
- package/src/mcp/start.ts +65 -2
- package/src/mcp/tools/agent/attach.ts +229 -60
- package/src/mcp/tools/agent/build.ts +10 -2
- package/src/mcp/tools/agent/index.ts +6 -2
- package/src/mcp/tools/agent/live-view.ts +77 -93
- package/src/mcp/tools/agent/session.ts +14 -8
- package/lib/cdp/browser-proxy.d.ts +0 -78
- package/lib/cdp/browser-proxy.js +0 -186
- package/lib/live/page.d.ts +0 -20
- package/lib/live/page.js +0 -134
- package/lib/live/viewer.d.ts +0 -28
- package/lib/live/viewer.js +0 -232
- package/lib/mcp/tools/agent/proxy.d.ts +0 -3
- package/lib/mcp/tools/agent/proxy.js +0 -199
- package/src/live/page.ts +0 -145
- package/src/live/viewer.ts +0 -293
package/src/live/tunnel.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public HTTPS front door for
|
|
3
|
-
* quick tunnel.
|
|
2
|
+
* Public HTTPS front door for a local port, via a `cloudflared` quick tunnel.
|
|
4
3
|
*
|
|
5
4
|
* A quick tunnel needs no Cloudflare account and no DNS: `cloudflared` dials
|
|
6
5
|
* out, Cloudflare hands back a throwaway `*.trycloudflare.com` hostname, and
|
|
7
6
|
* traffic arrives on the loopback port we pass in. That is the whole reason it
|
|
8
7
|
* is used here — the developer's machine keeps no inbound port open.
|
|
9
8
|
*
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* **The hostname IS the secret, and it is the only one.** The tunnel carries no
|
|
10
|
+
* authorization of its own, and the browser runtime's viewer behind it has no
|
|
11
|
+
* login either — so whoever learns the URL has the browser. Cloudflare's
|
|
12
|
+
* hostnames are randomly generated, which is what makes that tolerable for a
|
|
13
|
+
* short, deliberate hand-off, and `stop_browser_view` is how it ends.
|
|
14
|
+
*
|
|
15
|
+
* That is weaker than what this file used to front. The agent's own screencast
|
|
16
|
+
* viewer added a 24-byte token to every path, so the hostname alone was not
|
|
17
|
+
* enough; the container's viewer has no equivalent. Callers must say plainly
|
|
18
|
+
* what the URL grants, and it must never front the container's CDP port, which
|
|
19
|
+
* is unauthenticated total control of the browser.
|
|
13
20
|
*/
|
|
14
21
|
import { spawn } from 'node:child_process'
|
|
15
22
|
import { LOGGER } from '../logger.ts'
|
|
@@ -18,8 +25,8 @@ import { type CloudflaredBinary, resolveCloudflared } from './cloudflared.ts'
|
|
|
18
25
|
/** How long to wait for cloudflared to report its hostname before giving up. */
|
|
19
26
|
const URL_TIMEOUT_MS = 30_000
|
|
20
27
|
/** After the hostname is printed, Cloudflare needs a moment before the edge
|
|
21
|
-
* routes to it — measured at ~3s.
|
|
22
|
-
*
|
|
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. */
|
|
23
30
|
const READY_TIMEOUT_MS = 20_000
|
|
24
31
|
const READY_POLL_MS = 1_000
|
|
25
32
|
|
|
@@ -27,13 +34,32 @@ const READY_POLL_MS = 1_000
|
|
|
27
34
|
* stream. Anchored to the scheme so a hostname inside prose can't match. */
|
|
28
35
|
const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
|
|
29
36
|
|
|
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
|
+
/** Tail of cloudflared's log kept for error messages. */
|
|
51
|
+
const LOG_TAIL_BYTES = 4096
|
|
52
|
+
|
|
30
53
|
export interface Tunnel {
|
|
31
54
|
/** The public HTTPS origin Cloudflare assigned. */
|
|
32
55
|
url: string
|
|
33
|
-
/** Whether the
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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. */
|
|
36
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
|
|
37
63
|
/** Terminate the tunnel. Safe to call more than once. */
|
|
38
64
|
stop: () => Promise<void>
|
|
39
65
|
/** Which cloudflared ran, and whether we had to fetch it. */
|
|
@@ -41,18 +67,31 @@ export interface Tunnel {
|
|
|
41
67
|
}
|
|
42
68
|
|
|
43
69
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
70
|
+
* Wait until the tunnel is actually carrying traffic.
|
|
71
|
+
*
|
|
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.
|
|
47
79
|
*/
|
|
48
|
-
async function waitUntilRouted(
|
|
80
|
+
async function waitUntilRouted(
|
|
81
|
+
url: string,
|
|
82
|
+
isRegistered: () => boolean,
|
|
83
|
+
): Promise<boolean> {
|
|
49
84
|
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
50
85
|
while(Date.now() < deadline) {
|
|
86
|
+
if(isRegistered()) {
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
|
|
51
90
|
try {
|
|
52
91
|
await fetch(url, {
|
|
53
92
|
method: 'GET',
|
|
54
93
|
redirect: 'manual',
|
|
55
|
-
signal: AbortSignal.timeout(READY_POLL_MS *
|
|
94
|
+
signal: AbortSignal.timeout(READY_POLL_MS * 2),
|
|
56
95
|
})
|
|
57
96
|
return true
|
|
58
97
|
} catch{
|
|
@@ -60,7 +99,7 @@ async function waitUntilRouted(url: string): Promise<boolean> {
|
|
|
60
99
|
}
|
|
61
100
|
}
|
|
62
101
|
|
|
63
|
-
return
|
|
102
|
+
return isRegistered()
|
|
64
103
|
}
|
|
65
104
|
|
|
66
105
|
/**
|
|
@@ -100,6 +139,8 @@ export async function startTunnel(
|
|
|
100
139
|
))
|
|
101
140
|
}, URL_TIMEOUT_MS)
|
|
102
141
|
|
|
142
|
+
let registered = false
|
|
143
|
+
let tail = ''
|
|
103
144
|
let settled = false
|
|
104
145
|
const finish = (fn: () => void) => {
|
|
105
146
|
if(settled) {
|
|
@@ -115,16 +156,26 @@ export async function startTunnel(
|
|
|
115
156
|
// both so a future version that switches streams still works.
|
|
116
157
|
const onChunk = (chunk: Buffer) => {
|
|
117
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.
|
|
161
|
+
tail = (tail + text).slice(-LOG_TAIL_BYTES)
|
|
162
|
+
if(REGISTERED.test(text)) {
|
|
163
|
+
registered = true
|
|
164
|
+
}
|
|
165
|
+
|
|
118
166
|
const match = QUICK_TUNNEL_URL.exec(text)
|
|
119
167
|
if(match) {
|
|
120
168
|
const url = match[0]
|
|
121
169
|
finish(() => {
|
|
122
|
-
void waitUntilRouted(url).then((ready) => {
|
|
123
|
-
if(
|
|
124
|
-
|
|
170
|
+
void waitUntilRouted(url, () => registered).then((ready) => {
|
|
171
|
+
if(ready) {
|
|
172
|
+
resolve({ url, ready, stop, binary })
|
|
173
|
+
return
|
|
125
174
|
}
|
|
126
175
|
|
|
127
|
-
|
|
176
|
+
LOGGER.warn({ url, tail },
|
|
177
|
+
'tunnel never registered with an edge; returning anyway')
|
|
178
|
+
resolve({ url, ready, stop, binary, diagnostics: tail })
|
|
128
179
|
})
|
|
129
180
|
})
|
|
130
181
|
}
|
package/src/mcp/instructions.ts
CHANGED
|
@@ -71,7 +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
|
-
'browser': 'Choosing a browser source (local
|
|
74
|
+
'browser': 'Choosing a browser source (local Chrome, the Reclaim runtime in '
|
|
75
|
+
+ 'Docker, a remote Popcorn browser, or a custom CDP endpoint), '
|
|
75
76
|
+ 'geolocation, live view, and launch troubleshooting.',
|
|
76
77
|
'capture': 'Tabs, login detection, driving the page, and picking an '
|
|
77
78
|
+ 'authenticated request to prove.',
|
package/src/mcp/start.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
resolveOldMode,
|
|
12
12
|
sdkApiUrl,
|
|
13
13
|
} from '../consts.ts'
|
|
14
|
+
import { LOGGER } from '../logger.ts'
|
|
14
15
|
import { ReclaimOldClient } from '../old/client.ts'
|
|
15
16
|
import { resolveInitialIdentity } from '../old/identity.ts'
|
|
16
17
|
import { OLD_MODE_INSTRUCTIONS } from '../old/instructions.ts'
|
|
@@ -19,6 +20,7 @@ import { buildOldBackendTools } from '../old/tools/build.ts'
|
|
|
19
20
|
import { NAME, VERSION } from '../pkg.ts'
|
|
20
21
|
import { registerTools } from './api/tools.ts'
|
|
21
22
|
import {
|
|
23
|
+
type AgentState,
|
|
22
24
|
buildAgentState,
|
|
23
25
|
buildAgentTools,
|
|
24
26
|
buildBuilderAuthenticationTools,
|
|
@@ -27,6 +29,7 @@ import {
|
|
|
27
29
|
buildLocalCredentialTools,
|
|
28
30
|
buildModeTools,
|
|
29
31
|
buildVerificationTools,
|
|
32
|
+
disposeAgentState,
|
|
30
33
|
readSession,
|
|
31
34
|
} from './tools/index.ts'
|
|
32
35
|
import { BUILDER_MODE_INSTRUCTIONS } from './instructions.ts'
|
|
@@ -52,6 +55,8 @@ export async function startMcpServer() {
|
|
|
52
55
|
// deployments — set RECLAIM_AGENT_DISABLED=1 to register only the API
|
|
53
56
|
// surface.
|
|
54
57
|
const agentToolsEnabled = !agentDisabled()
|
|
58
|
+
/** The agent state this server registered, for shutdown to release. */
|
|
59
|
+
let agentState: AgentState | undefined
|
|
55
60
|
const builderClient = new ReclaimClient({
|
|
56
61
|
baseUrl: apiUrl(),
|
|
57
62
|
// Builder access is orthogonal to publishing mode. A legacy-devtools
|
|
@@ -77,7 +82,7 @@ export async function startMcpServer() {
|
|
|
77
82
|
// Shared with buildAgentTools below so the old-mode publish tool can see
|
|
78
83
|
// the live attach state (for example, to flag a still-open browser
|
|
79
84
|
// session).
|
|
80
|
-
|
|
85
|
+
agentState = buildAgentState()
|
|
81
86
|
|
|
82
87
|
registerToolList(server, buildBuilderAuthenticationTools(builderClient))
|
|
83
88
|
registerToolList(server, buildBuilderOrganizationTools(builderClient))
|
|
@@ -115,10 +120,68 @@ export async function startMcpServer() {
|
|
|
115
120
|
registerTools(server, client)
|
|
116
121
|
|
|
117
122
|
if(agentToolsEnabled) {
|
|
118
|
-
|
|
123
|
+
agentState = buildAgentState()
|
|
124
|
+
registerToolList(server, buildAgentTools(client, client, agentState))
|
|
119
125
|
}
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
// Anything the session holds has to be released when the session ends, and
|
|
129
|
+
// the session can end without a single tool call: the developer closes their
|
|
130
|
+
// agent, the client kills the server, stdin drops. Nothing ran here before,
|
|
131
|
+
// which left a container up and — worse — a public tunnel still pointing at a
|
|
132
|
+
// live browser after the session that made it was gone.
|
|
133
|
+
if(agentToolsEnabled) {
|
|
134
|
+
installShutdownHooks(() => agentState)
|
|
135
|
+
}
|
|
136
|
+
|
|
122
137
|
const transport = new StdioServerTransport()
|
|
123
138
|
await server.connect(transport)
|
|
124
139
|
}
|
|
140
|
+
|
|
141
|
+
/** How long teardown gets before the process leaves anyway. */
|
|
142
|
+
const SHUTDOWN_GRACE_MS = 10_000
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Release the browser on every way out we can observe.
|
|
146
|
+
*
|
|
147
|
+
* `SIGTERM`/`SIGINT` is how a client stops us; stdin ending is how a stdio
|
|
148
|
+
* transport says the other side is gone. A `SIGKILL` cannot be caught at all,
|
|
149
|
+
* which is why `reapOrphanedContainers` exists as the backstop.
|
|
150
|
+
*/
|
|
151
|
+
function installShutdownHooks(state: () => AgentState | undefined): void {
|
|
152
|
+
let shuttingDown = false
|
|
153
|
+
const shutdown = (reason: string) => {
|
|
154
|
+
if(shuttingDown) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
shuttingDown = true
|
|
159
|
+
const current = state()
|
|
160
|
+
if(!current) {
|
|
161
|
+
process.exit(0)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
LOGGER.info({ reason }, 'releasing the browser before exit')
|
|
165
|
+
// Bounded: a wedged `docker stop` must not keep the process alive, but
|
|
166
|
+
// nor should we skip cleanup that would have finished in a second.
|
|
167
|
+
const timer = setTimeout(() => {
|
|
168
|
+
LOGGER.warn({ reason }, 'teardown timed out; exiting anyway')
|
|
169
|
+
process.exit(0)
|
|
170
|
+
}, SHUTDOWN_GRACE_MS)
|
|
171
|
+
timer.unref()
|
|
172
|
+
void disposeAgentState(current)
|
|
173
|
+
.catch((err: unknown) => {
|
|
174
|
+
LOGGER.warn({ err }, 'teardown failed')
|
|
175
|
+
})
|
|
176
|
+
.finally(() => {
|
|
177
|
+
clearTimeout(timer)
|
|
178
|
+
process.exit(0)
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'))
|
|
183
|
+
process.once('SIGINT', () => shutdown('SIGINT'))
|
|
184
|
+
// The transport's own end of the conversation.
|
|
185
|
+
process.stdin.once('end', () => shutdown('stdin ended'))
|
|
186
|
+
process.stdin.once('close', () => shutdown('stdin closed'))
|
|
187
|
+
}
|