@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
@@ -16,22 +16,14 @@
16
16
  import { badRequest, conflict } from '@hapi/boom'
17
17
  import assert from 'node:assert'
18
18
  import { startTunnel, type Tunnel } from '../../../live/tunnel.ts'
19
- import { type LiveView, startLiveView } from '../../../live/viewer.ts'
20
19
  import { LOGGER } from '../../../logger.ts'
21
20
  import { defineTool, type RegisteredTool } from '../../server.ts'
22
- import type { AttachState } from './attach.ts'
23
- import type { CaptureStore } from './capture.ts'
24
-
25
- /** Modes whose browser we reach over our own CDP connection, and which
26
- * therefore have no hosted live view of their own. */
27
- const SHAREABLE_MODES = new Set(['dedicated', 'attach', 'custom'])
21
+ import { type AttachState, SHAREABLE_MODES } from './attach.ts'
28
22
 
29
23
  export interface LiveShare {
30
- view: LiveView
31
24
  tunnel: Tunnel
25
+ /** The public URL handed to whoever is watching. */
32
26
  url: string
33
- interactive: boolean
34
- startedAt: number
35
27
  }
36
28
 
37
29
  export interface LiveShareRef {
@@ -47,9 +39,8 @@ export async function stopLiveShare(ref: LiveShareRef): Promise<boolean> {
47
39
  }
48
40
 
49
41
  ref.current = undefined
50
- await share.view.stop().catch((err) => {
51
- LOGGER.warn({ err }, 'live view: server stop failed')
52
- })
42
+ // Only the tunnel is ours to close. The browser and its viewer belong to the
43
+ // container, which keeps running until the browser is disposed.
53
44
  await share.tunnel.stop().catch((err) => {
54
45
  LOGGER.warn({ err }, 'live view: tunnel stop failed')
55
46
  })
@@ -58,61 +49,52 @@ export async function stopLiveShare(ref: LiveShareRef): Promise<boolean> {
58
49
 
59
50
  export function liveViewTools(
60
51
  attachRef: { current?: AttachState },
61
- captures: CaptureStore,
62
52
  shareRef: LiveShareRef,
63
53
  ): RegisteredTool[] {
64
54
  return [
65
- defineTool<{ tabId?: string, interactive?: boolean }>(
55
+ defineTool<Record<string, never>>(
66
56
  {
67
57
  name: 'share_browser_view',
68
58
  description:
69
- 'Put the attached tab behind a PUBLIC HTTPS URL (a cloudflared '
70
- + 'quick tunnel) so someone elsewhere can watch it live, and by '
71
- + 'default click and type in it. Use it to hand a login or a 2FA '
72
- + 'step to the person who holds the credentials, or to let a '
73
- + 'colleague watch a capture. Returns the URL — relay it to the '
74
- + 'developer VERBATIM, on its own line. '
75
- + 'ONLY for a locally attached browser (attach_browser mode '
76
- + '`dedicated`, `attach`, or `custom`). In `builder` mode the '
77
- + 'Popcorn browser already has a hosted live view: use the '
78
- + '`liveViewUrl` that attach_browser returned instead. '
79
- + 'THE URL IS A CREDENTIAL: anyone holding it can see this tab, '
80
- + 'and with the default `interactive: true` can act in it as the '
81
- + 'signed-in user. Share it only with the person who needs it, and '
82
- + 'call stop_browser_view the moment they are done. Pass '
83
- + '`interactive: false` for a watch-only link. '
59
+ 'Put the attached browser\'s live view behind a PUBLIC HTTPS URL (a '
60
+ + 'cloudflared quick tunnel) so someone elsewhere can watch it and '
61
+ + 'act in it. Use it to hand a login or a 2FA step to the person '
62
+ + 'who holds the credentials, or to let a colleague watch a '
63
+ + 'capture. Returns the URL — relay it to the developer VERBATIM, '
64
+ + 'on its own line. '
65
+ + 'ONLY for `container` mode, whose browser runs its own viewer. '
66
+ + 'A `builder` browser already has a hosted live view: use the '
67
+ + '`liveViewUrl` attach_browser returned. A local browser '
68
+ + '(`dedicated`, `attach`, `custom`) cannot be shared at all — no '
69
+ + 'tunnel can fix that, because there is no viewer behind it. Tell '
70
+ + 'them so, and offer to re-attach: `container` is free and needs '
71
+ + 'Docker, `builder` needs no install but is chargeable. '
72
+ + 'THE URL IS THE ONLY CREDENTIAL: the random hostname is the whole '
73
+ + 'secret, with no token or login behind it, so anyone who learns '
74
+ + 'it can see and drive this browser as the signed-in user. Share '
75
+ + 'it only with the person who needs it, and call stop_browser_view '
76
+ + 'the moment they are done. '
84
77
  + 'Needs cloudflared: an existing install on PATH is used as-is, '
85
78
  + 'otherwise it is downloaded once to ~/.reclaim/bin on first '
86
79
  + 'share (never at install time). Set RECLAIM_CLOUDFLARED_PATH to '
87
80
  + 'point at your own copy instead.',
88
- inputSchema: {
89
- type: 'object',
90
- properties: {
91
- tabId: {
92
- type: 'string',
93
- description:
94
- 'Tab to stream, from list_tabs. Defaults to the tab the '
95
- + 'active capture is on, else the first tab.',
96
- },
97
- interactive: {
98
- type: 'boolean',
99
- description:
100
- 'Let the viewer click and type in the page. Default true. '
101
- + 'Set false to stream a read-only view.',
102
- },
103
- },
104
- },
81
+ inputSchema: { type: 'object', properties: {} },
105
82
  },
106
- async({ tabId, interactive }) => {
83
+ async() => {
84
+
107
85
  const attached = attachRef.current
108
86
  assert(attached, badRequest(
109
87
  'No browser attached. Call attach_browser first.',
110
88
  ))
111
89
  assert(SHAREABLE_MODES.has(attached.mode), badRequest(
112
90
  `share_browser_view does not apply to \`${attached.mode}\` mode. `
113
- + 'That browser already has a hosted live view — use the '
114
- + '`liveViewUrl` from attach_browser'
115
- + (attached.liveViewUrl ? `: ${attached.liveViewUrl}` : '')
91
+ + (attached.liveViewUrl
92
+ ? 'That browser already has a hosted live view — use the '
93
+ + `\`liveViewUrl\` from attach_browser: ${attached.liveViewUrl}`
94
+ : 'That browser has no live view to put behind a URL. To let '
95
+ + 'someone else in, re-attach with mode "container" (the '
96
+ + 'Reclaim runtime in Docker — free, needs Docker) or '
97
+ + '"builder" (hosted, no install, chargeable)')
116
98
  + '.',
117
99
  ))
118
100
  assert(!shareRef.current, conflict(
@@ -121,47 +103,42 @@ export function liveViewTools(
121
103
  + 'reuse that URL.',
122
104
  ))
123
105
 
124
- const tabs = tabId || captures.activeTab
125
- ? []
126
- : await attached.handles.listTabs()
127
- const target = tabId ?? captures.activeTab ?? tabs[0]?.tabId
128
- assert(target, badRequest(
129
- 'No tab to stream. Open a tab, or call list_tabs to pick one.',
106
+ // The container publishes its viewer on loopback and its full CDP
107
+ // port beside it. Only the VIEWER is ever tunnelled: the CDP port is
108
+ // unauthenticated, total control of the browser, and putting it on a
109
+ // public URL would hand that to anyone with the link.
110
+ const viewerPort = attached.viewerPort
111
+ assert(viewerPort, badRequest(
112
+ 'This browser reports no live-view port. Re-attach with mode '
113
+ + '"container".',
130
114
  ))
131
115
 
132
- const tab = await attached.handles.attachToTab(target)
133
- const wantsInput = interactive !== false
134
- const view = await startLiveView({
135
- tab,
136
- interactive: wantsInput,
137
- title: `Reclaim — ${attached.mode} browser`,
138
- })
139
-
140
- let tunnel: Tunnel
141
- try {
142
- tunnel = await startTunnel(view.port)
143
- } catch(err) {
144
- // Don't leave the loopback server behind if the tunnel failed.
145
- await view.stop().catch(() => {})
146
- throw err
147
- }
148
-
149
- const url = `${tunnel.url}${view.path}`
116
+ const tunnel = await startTunnel(viewerPort)
117
+ // The path is the runtime's own viewer page; `magnify=1` is what makes
118
+ // it lay out for the device that opens it.
119
+ const url = `${tunnel.url}/liveview.html?magnify=1`
150
120
  const share: LiveShare = {
151
- view, tunnel, url, interactive: wantsInput, startedAt: Date.now(),
121
+ tunnel, url,
152
122
  }
153
123
  shareRef.current = share
154
- LOGGER.info({ mode: attached.mode, interactive: wantsInput },
155
- 'live view shared')
124
+ LOGGER.info({ mode: attached.mode }, 'live view shared')
156
125
 
157
126
  return {
158
127
  url,
159
- interactive: wantsInput,
160
- tabId: target,
161
128
  ...(share.tunnel.binary.source === 'downloaded'
162
129
  ? { cloudflaredInstalled: true }
163
130
  : {}),
164
- ...(share.tunnel.ready ? {} : { propagating: true }),
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
+ }),
165
142
  _notes: [
166
143
  ...(share.tunnel.binary.source === 'downloaded'
167
144
  ? ['cloudflared was not installed, so it was downloaded once '
@@ -169,14 +146,21 @@ export function liveViewTools(
169
146
  : []),
170
147
  ...(share.tunnel.ready
171
148
  ? []
172
- : ['The tunnel had not started routing yet — if the URL '
173
- + 'errors, wait a few seconds and reload.']),
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.']),
174
155
  'Relay `url` to the developer verbatim, on its own line.',
175
- 'The URL is a credential: it needs no login, and anyone with '
176
- + 'it can see this tab'
177
- + (wantsInput ? ' and act in it as the signed-in user.' : '.'),
178
- 'Call stop_browser_view when they are done — the tunnel stays '
179
- + 'open until you do.',
156
+ 'The URL is a credential: it needs no login, and anyone with it '
157
+ + 'can see this browser and act in it as the signed-in user.',
158
+ 'It shows the whole browser, so a sign-in that opens a separate '
159
+ + 'window is visible too — unlike a single-tab stream.',
160
+ 'Opening it on a phone lays the page out for that phone, with '
161
+ + 'touch, pinch-zoom and the soft keyboard.',
162
+ 'Call stop_browser_view when they are done. That closes the '
163
+ + 'tunnel only — the browser keeps running.',
180
164
  ],
181
165
  }
182
166
  },
@@ -185,11 +169,11 @@ export function liveViewTools(
185
169
  {
186
170
  name: 'stop_browser_view',
187
171
  description:
188
- 'Close the public live view opened by share_browser_view: stops '
189
- + 'the screen stream and shuts the tunnel down, so the URL stops '
190
- + 'resolving. Call it as soon as the person you shared with is '
191
- + 'finished. The browser itself stays attached and the capture '
192
- + 'keeps running.',
172
+ 'Close the public URL opened by share_browser_view: shuts the '
173
+ + 'tunnel down, so the URL stops resolving for whoever still has '
174
+ + 'it. Call it as soon as the person you shared with is finished. '
175
+ + 'Nothing else changes — the browser stays attached, its own live '
176
+ + 'view keeps serving locally, and any capture keeps running.',
193
177
  inputSchema: { type: 'object', properties: {} },
194
178
  },
195
179
  async() => ({ stopped: await stopLiveShare(shareRef) }),
@@ -33,11 +33,7 @@ export function sessionTools(
33
33
  // URL can recover it instead of opening a second tunnel.
34
34
  ...(liveShare.current
35
35
  ? {
36
- sharedView: {
37
- url: liveShare.current.url,
38
- interactive: liveShare.current.interactive,
39
- viewers: liveShare.current.view.viewers(),
40
- },
36
+ sharedView: { url: liveShare.current.url },
41
37
  }
42
38
  : {}),
43
39
  }),
@@ -74,13 +70,23 @@ export function sessionTools(
74
70
  ]
75
71
  }
76
72
 
77
- async function resetAll(
73
+ /**
74
+ * Release everything a session holds: the public tunnel, the captures, and the
75
+ * browser itself.
76
+ *
77
+ * Exported because a session can end WITHOUT anyone calling `dispose_browser` —
78
+ * the developer closes their agent, the client kills the server, the transport
79
+ * drops. Nothing runs on its own then, so `start.ts` runs this. Without it a
80
+ * container keeps running and, worse, a public URL to a live browser outlives
81
+ * the session that created it.
82
+ */
83
+ export async function resetAll(
78
84
  attachRef: { current?: AttachState, config: ConfigStore },
79
85
  captures: CaptureStore,
80
86
  liveShare: LiveShareRef,
81
87
  ) {
82
- // Before the captures: the live view holds a tab session of its own, and a
83
- // public URL must never outlive the browser it points at.
88
+ // Before the captures: a public URL must never outlive the browser it points
89
+ // at.
84
90
  await stopLiveShare(liveShare)
85
91
  await resetCaptures(captures)
86
92
  await disposeAttached(attachRef)
package/src/old/client.ts CHANGED
@@ -99,6 +99,80 @@ export interface LinkNonce {
99
99
  expiresInSeconds: number
100
100
  }
101
101
 
102
+ /** Log-level filter accepted by the devtools backend's session-logs route.
103
+ * It validates against this exact list and rejects anything else with a 400,
104
+ * including the comma-separated lists the log-stream service itself would
105
+ * accept. Each value is hierarchical at the log-stream end: `fine` means
106
+ * FINE+CONFIG+INFO+WARNING+SEVERE, `info` means INFO+WARNING+SEVERE, and so
107
+ * on. `finer`/`finest` (the PII tiers) are not reachable through this route
108
+ * at all. */
109
+ export type SessionLogLevel =
110
+ | 'fine'
111
+ | 'config'
112
+ | 'info'
113
+ | 'warning'
114
+ | 'severe'
115
+ | 'unknown'
116
+
117
+ /** Filters for {@link ReclaimOldClient.getSessionLogs}. Every one is optional
118
+ * and forwarded verbatim; the session id itself is the route's path param, so
119
+ * `providerId`/`deviceId` narrow WITHIN one session rather than searching
120
+ * across sessions. */
121
+ export interface SessionLogsQuery {
122
+ deviceId?: string
123
+ source?: string
124
+ /** The client-side logger NAME (log-stream's `log_type` column, the SDK's
125
+ * `LogEntry.type`) — not the event type. */
126
+ logType?: string
127
+ logLevel?: SessionLogLevel
128
+ providerId?: string
129
+ /** ISO-8601. Omitting BOTH bounds makes the log-stream service default to
130
+ * the last 3 days. */
131
+ startTime?: string
132
+ endTime?: string
133
+ /** Substring match against the log line (`LIKE %…%`). */
134
+ logLine?: string
135
+ /** Exact `LogEventType` name, for example `REQUEST_MATCHED`. */
136
+ eventType?: string
137
+ /** Capped at 1000 by the log-stream service. */
138
+ limit?: number
139
+ offset?: number
140
+ includeCount?: boolean
141
+ }
142
+
143
+ /** One raw log line, in the log-stream service's snake_case ClickHouse shape,
144
+ * passed through unchanged by the devtools backend. `event_type` is the SDK's
145
+ * `LogEventType` milestone marker (`REQUEST_MATCHED`,
146
+ * `CLAIM_CREATION_STARTED`, `PROOF_GENERATED`, …); it is `''` on the majority
147
+ * of lines, which carry no event. */
148
+ export interface SessionLogRow {
149
+ timestamp: string
150
+ request_id: string
151
+ session_id: string
152
+ device_id: string
153
+ source: string
154
+ app_id: string
155
+ provider_id: string
156
+ log_type: string
157
+ log_level: string
158
+ log_line: string
159
+ event_type?: string
160
+ metadata?: string
161
+ }
162
+
163
+ /** `GET /api/logger/:sessionId`'s `data` envelope. The backend queries every
164
+ * region that holds the session, concatenates the pages, sorts them
165
+ * newest-first and re-slices to `limit`, so `data` is one page and
166
+ * `totalCount` is the match count across all of them. */
167
+ export interface SessionLogsPage {
168
+ data: SessionLogRow[]
169
+ count: number
170
+ totalCount?: number
171
+ /** Regions whose log-stream query failed — the page is INCOMPLETE when
172
+ * this is present. */
173
+ failedRegions?: string[]
174
+ }
175
+
102
176
  interface RequestOpts {
103
177
  query?: Record<string, string | number | undefined>
104
178
  body?: unknown
@@ -212,6 +286,60 @@ export class ReclaimOldClient {
212
286
  return json
213
287
  }
214
288
 
289
+ /**
290
+ * Raw log LINES for a verification session — the SDK's own diagnostic
291
+ * logging, the thing the dashboard's session "logs" tab renders, and a
292
+ * strictly richer signal than `getSessionAnalyticsLogs`'s milestone events.
293
+ *
294
+ * This is the devtools backend (`GET /api/logger/:sessionId`), NOT the
295
+ * analytics-logs host — hence `#request`, and hence the same identity and
296
+ * ownership rules as every other authed call here: the session's app must
297
+ * be one you created (admins excepted), or the route 404s.
298
+ *
299
+ * The backend fans the query out to the per-region log-stream services,
300
+ * which read the ClickHouse `logs` table. Two defaults applied down there
301
+ * bite hard if you don't override them:
302
+ * - no `startTime`/`endTime` → only the last 3 days are searched;
303
+ * - no `logLevel` → INFO and above only, so every FINE/CONFIG line is
304
+ * invisible.
305
+ * Entries are dropped entirely after 30 days (table TTL).
306
+ */
307
+ async getSessionLogs(
308
+ sessionId: string,
309
+ query: SessionLogsQuery = {},
310
+ ): Promise<SessionLogsPage> {
311
+ const res = await this.#request(
312
+ 'GET',
313
+ `/logger/${encodeURIComponent(sessionId)}`,
314
+ {
315
+ auth: true,
316
+ query: {
317
+ deviceId: query.deviceId,
318
+ source: query.source,
319
+ logType: query.logType,
320
+ logLevel: query.logLevel,
321
+ providerId: query.providerId,
322
+ startTime: query.startTime,
323
+ endTime: query.endTime,
324
+ logLine: query.logLine,
325
+ eventType: query.eventType,
326
+ limit: query.limit,
327
+ offset: query.offset,
328
+ includeCount: query.includeCount === undefined
329
+ ? undefined
330
+ : String(query.includeCount),
331
+ },
332
+ },
333
+ )
334
+ const page = (res as { data?: Partial<SessionLogsPage> }).data
335
+ return {
336
+ data: page?.data ?? [],
337
+ count: page?.count ?? page?.data?.length ?? 0,
338
+ totalCount: page?.totalCount,
339
+ failedRegions: page?.failedRegions,
340
+ }
341
+ }
342
+
215
343
  /** Mint a brand-new, unassociated (sandboxed) app — no auth required. Link
216
344
  * it to an account afterwards with `getLinkNonce` + `linkAppToAccount`, or
217
345
  * use it as-is within the sandbox session limit. */
@@ -12,6 +12,7 @@ import { getMeProvidersTool } from './me-providers.ts'
12
12
  import { createVersionFromCaptureTool } from './publish.ts'
13
13
  import { scaffoldDemoServerTool } from './scaffold-demo-server.ts'
14
14
  import { sessionAnalyticsLogsTool } from './session-analytics-logs.ts'
15
+ import { sessionLogsTool } from './session-logs.ts'
15
16
  import { updateProviderMetadataTool } from './update-provider-metadata.ts'
16
17
  import { verifySdkSetupTool } from './verify-sdk-setup.ts'
17
18
 
@@ -41,6 +42,7 @@ export function buildOldBackendTools(
41
42
  getProviderInfoTool(sdkClient),
42
43
  updateProviderMetadataTool(client),
43
44
  sessionAnalyticsLogsTool(client),
45
+ sessionLogsTool(client),
44
46
  issueAppCredentialsTool(client),
45
47
  linkAppTool(client),
46
48
  checkAppStatusTool(client),