@reclaimprotocol/agent 0.1.24 → 0.1.26

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 (55) hide show
  1. package/README.md +5 -2
  2. package/lib/cdp/claims.d.ts +21 -0
  3. package/lib/cdp/claims.js +129 -0
  4. package/lib/cdp/client.d.ts +0 -10
  5. package/lib/cdp/container.d.ts +85 -0
  6. package/lib/cdp/container.js +481 -0
  7. package/lib/live/tunnel.d.ts +6 -3
  8. package/lib/live/tunnel.js +57 -17
  9. package/lib/mcp/instructions.js +2 -1
  10. package/lib/mcp/start.js +58 -3
  11. package/lib/mcp/tools/agent/attach.d.ts +22 -2
  12. package/lib/mcp/tools/agent/attach.js +197 -55
  13. package/lib/mcp/tools/agent/build.d.ts +5 -0
  14. package/lib/mcp/tools/agent/build.js +9 -2
  15. package/lib/mcp/tools/agent/index.d.ts +1 -2
  16. package/lib/mcp/tools/agent/index.js +1 -1
  17. package/lib/mcp/tools/agent/live-view.d.ts +3 -7
  18. package/lib/mcp/tools/agent/live-view.js +74 -81
  19. package/lib/mcp/tools/agent/session.d.ts +14 -0
  20. package/lib/mcp/tools/agent/session.js +14 -8
  21. package/lib/old/client.d.ts +83 -0
  22. package/lib/old/client.js +46 -0
  23. package/lib/old/tools/build.js +2 -0
  24. package/lib/old/tools/session-logs.d.ts +3 -0
  25. package/lib/old/tools/session-logs.js +310 -0
  26. package/package.json +2 -2
  27. package/skill/SKILL.common.md +115 -38
  28. package/skill/SKILL.old.md +28 -2
  29. package/skill/builder-mode.md +9 -7
  30. package/skill/interception.md +3 -2
  31. package/skill/old-mode.md +14 -10
  32. package/src/cdp/claims.ts +153 -0
  33. package/src/cdp/client.ts +0 -8
  34. package/src/cdp/container.ts +593 -0
  35. package/src/live/tunnel.ts +71 -20
  36. package/src/mcp/instructions.ts +2 -1
  37. package/src/mcp/start.ts +65 -2
  38. package/src/mcp/tools/agent/attach.ts +229 -60
  39. package/src/mcp/tools/agent/build.ts +10 -2
  40. package/src/mcp/tools/agent/index.ts +6 -2
  41. package/src/mcp/tools/agent/live-view.ts +77 -93
  42. package/src/mcp/tools/agent/session.ts +14 -8
  43. package/src/old/client.ts +128 -0
  44. package/src/old/tools/build.ts +2 -0
  45. package/src/old/tools/session-logs.ts +395 -0
  46. package/lib/cdp/browser-proxy.d.ts +0 -78
  47. package/lib/cdp/browser-proxy.js +0 -186
  48. package/lib/live/page.d.ts +0 -20
  49. package/lib/live/page.js +0 -134
  50. package/lib/live/viewer.d.ts +0 -28
  51. package/lib/live/viewer.js +0 -232
  52. package/lib/mcp/tools/agent/proxy.d.ts +0 -3
  53. package/lib/mcp/tools/agent/proxy.js +0 -199
  54. package/src/live/page.ts +0 -145
  55. package/src/live/viewer.ts +0 -293
@@ -1,15 +1,22 @@
1
1
  /**
2
- * Public HTTPS front door for the local live-view server, via a `cloudflared`
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 tunnel itself carries NO authorization. Anyone holding the hostname can
11
- * reach the local server, so the server is the thing that must authenticate
12
- * (see `viewer.ts`, which puts an unguessable token in every path).
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. Poll until it answers so the caller never
22
- * hands out a URL that errors, but don't fail the share if it's slow. */
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 edge answered before {@link READY_TIMEOUT_MS} elapsed. False
34
- * means the tunnel is probably still propagating rather than broken — tell
35
- * the viewer to retry rather than treating the share as failed. */
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
- * Poll the public origin until Cloudflare's edge routes to it. ANY HTTP
45
- * response counts, including a 404: the live-view server 404s unknown paths,
46
- * so a status code at all proves the hop is live.
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(url: string): Promise<boolean> {
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 * 4),
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 false
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(!ready) {
124
- LOGGER.warn({ url }, 'tunnel not routed yet; returning anyway')
170
+ void waitUntilRouted(url, () => registered).then((ready) => {
171
+ if(ready) {
172
+ resolve({ url, ready, stop, binary })
173
+ return
125
174
  }
126
175
 
127
- resolve({ url, ready, stop, binary })
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
  }
@@ -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 / remote / custom CDP), '
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
- const agentState = buildAgentState()
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
- registerToolList(server, buildAgentTools(client))
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
+ }