@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.
- package/README.md +5 -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/lib/old/client.d.ts +83 -0
- package/lib/old/client.js +46 -0
- package/lib/old/tools/build.js +2 -0
- package/lib/old/tools/session-logs.d.ts +3 -0
- package/lib/old/tools/session-logs.js +310 -0
- package/package.json +2 -2
- package/skill/SKILL.common.md +115 -38
- package/skill/SKILL.old.md +28 -2
- package/skill/builder-mode.md +9 -7
- package/skill/interception.md +3 -2
- package/skill/old-mode.md +14 -10
- 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/src/old/client.ts +128 -0
- package/src/old/tools/build.ts +2 -0
- package/src/old/tools/session-logs.ts +395 -0
- 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
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
3
|
+
import { defineTool, type RegisteredTool } from '../../mcp/server.ts'
|
|
4
|
+
import { findProjectRoot } from '../../paths.ts'
|
|
5
|
+
import type {
|
|
6
|
+
ReclaimOldClient,
|
|
7
|
+
SessionLogRow,
|
|
8
|
+
SessionLogsQuery,
|
|
9
|
+
} from '../client.ts'
|
|
10
|
+
|
|
11
|
+
/** The log-stream service caps a page at 1000 entries however large a
|
|
12
|
+
* `limit` asks for, so paging is the only way past it. */
|
|
13
|
+
const MAX_PAGE = 1000
|
|
14
|
+
|
|
15
|
+
/** Inline default. Small enough to stay readable in a tool result; a bigger
|
|
16
|
+
* pull belongs in `saveTo`. */
|
|
17
|
+
const DEFAULT_LIMIT = 200
|
|
18
|
+
|
|
19
|
+
/** Ceiling on a `saveTo` dump, so a chatty session can't page forever. */
|
|
20
|
+
const DEFAULT_MAX_ENTRIES = 20_000
|
|
21
|
+
|
|
22
|
+
interface Args extends SessionLogsQuery {
|
|
23
|
+
sessionId: string
|
|
24
|
+
/** Substring match on the log line — `logLine` under its dashboard name. */
|
|
25
|
+
contains?: string
|
|
26
|
+
/** Keep only entries that carry an `event_type`. Applied here rather than
|
|
27
|
+
* server-side: the devtools route has no `eventTypeOnly` passthrough. */
|
|
28
|
+
eventsOnly?: boolean
|
|
29
|
+
saveTo?: string
|
|
30
|
+
maxEntries?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An entry's fields minus the ones hoisted into `context`. `event` and
|
|
34
|
+
* `metadata` drop out when empty; `device` and `source` appear only on an
|
|
35
|
+
* entry that disagrees with a single-valued context. */
|
|
36
|
+
interface CompactEntry {
|
|
37
|
+
ts: string
|
|
38
|
+
level: string
|
|
39
|
+
event?: string
|
|
40
|
+
logger: string
|
|
41
|
+
line: string
|
|
42
|
+
device?: string
|
|
43
|
+
source?: string
|
|
44
|
+
metadata?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function sessionLogsTool(client: ReclaimOldClient): RegisteredTool {
|
|
48
|
+
return defineTool<Args>(
|
|
49
|
+
{
|
|
50
|
+
name: 'session_logs',
|
|
51
|
+
description:
|
|
52
|
+
'Fetch the raw log entries the in-app SDK emitted during a '
|
|
53
|
+
+ 'verification session, each with its event type (for example '
|
|
54
|
+
+ 'REQUEST_MATCHED, CLAIM_CREATION_STARTED, or PROOF_GENERATED), '
|
|
55
|
+
+ 'log level, and logger name. This is the data behind the '
|
|
56
|
+
+ 'dashboard session-logs tab, and it is far more detailed than '
|
|
57
|
+
+ 'session_analytics_logs, which returns only the coarse milestone '
|
|
58
|
+
+ 'events. Use it to diagnose a session that stalled, matched '
|
|
59
|
+
+ 'nothing, or produced no claim. Narrow the result with eventType, '
|
|
60
|
+
+ 'logLevel, or contains; page through it with limit and offset; or '
|
|
61
|
+
+ 'set saveTo to write every matching entry to an NDJSON file and '
|
|
62
|
+
+ 'read that file with your own tools. Old-devtools mode only. You '
|
|
63
|
+
+ 'must own the app the session belongs to, so call '
|
|
64
|
+
+ 'reclaim_authenticate first.',
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
sessionId: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description:
|
|
71
|
+
'Id of the verification session to read log entries for.',
|
|
72
|
+
},
|
|
73
|
+
eventType: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
description:
|
|
76
|
+
'Keep only entries that carry this exact event type, for '
|
|
77
|
+
+ 'example REQUEST_MATCHED or PROOF_GENERATION_FAILED_'
|
|
78
|
+
+ 'EXCEPTION. Most entries carry no event type at all, so '
|
|
79
|
+
+ 'prefer eventsOnly when you want the milestone timeline '
|
|
80
|
+
+ 'rather than guessing a name.',
|
|
81
|
+
},
|
|
82
|
+
eventsOnly: {
|
|
83
|
+
type: 'boolean',
|
|
84
|
+
description:
|
|
85
|
+
'Keep only entries that carry an event type, which gives you '
|
|
86
|
+
+ 'a compact milestone timeline for the session. This tool '
|
|
87
|
+
+ 'applies the filter after fetching, so it narrows what you '
|
|
88
|
+
+ 'read rather than what it fetches.',
|
|
89
|
+
},
|
|
90
|
+
logLevel: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
enum: ['fine', 'config', 'info', 'warning', 'severe', 'unknown'],
|
|
93
|
+
description:
|
|
94
|
+
'Lowest severity to include. Each value is hierarchical: '
|
|
95
|
+
+ '`fine` includes FINE, CONFIG, INFO, WARNING, and SEVERE; '
|
|
96
|
+
+ '`info` drops FINE and CONFIG; `severe` keeps only '
|
|
97
|
+
+ 'failures. Defaults to `fine`, which is wider than the '
|
|
98
|
+
+ 'backend default of `info`. The FINER and FINEST tiers '
|
|
99
|
+
+ 'contain PII, and this route rejects them.',
|
|
100
|
+
},
|
|
101
|
+
logType: {
|
|
102
|
+
type: 'string',
|
|
103
|
+
description:
|
|
104
|
+
'Exact client-side logger name, which the SDK calls `type` — '
|
|
105
|
+
+ 'for example reclaim_flutter_sdk.ClaimCreation. This is not '
|
|
106
|
+
+ 'the event type.',
|
|
107
|
+
},
|
|
108
|
+
contains: {
|
|
109
|
+
type: 'string',
|
|
110
|
+
description:
|
|
111
|
+
'Substring that an entry\'s log line must contain.',
|
|
112
|
+
},
|
|
113
|
+
providerId: {
|
|
114
|
+
type: 'string',
|
|
115
|
+
description:
|
|
116
|
+
'Keep only entries for this provider. A session can touch '
|
|
117
|
+
+ 'more than one provider, so this narrows within the '
|
|
118
|
+
+ 'session; it cannot search across sessions.',
|
|
119
|
+
},
|
|
120
|
+
deviceId: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
description:
|
|
123
|
+
'Keep only entries for this device id, which is the claimant '
|
|
124
|
+
+ 'id.',
|
|
125
|
+
},
|
|
126
|
+
source: {
|
|
127
|
+
type: 'string',
|
|
128
|
+
description:
|
|
129
|
+
'Keep only entries from this client build. The source string '
|
|
130
|
+
+ 'carries the SDK, platform, and version, so it is the field '
|
|
131
|
+
+ 'to compare a working run against a broken one.',
|
|
132
|
+
},
|
|
133
|
+
startTime: {
|
|
134
|
+
type: 'string',
|
|
135
|
+
description:
|
|
136
|
+
'Lower bound, as an ISO-8601 timestamp. If you set neither '
|
|
137
|
+
+ 'bound, the backend searches only the last 3 days.',
|
|
138
|
+
},
|
|
139
|
+
endTime: {
|
|
140
|
+
type: 'string',
|
|
141
|
+
description: 'Upper bound, as an ISO-8601 timestamp.',
|
|
142
|
+
},
|
|
143
|
+
limit: {
|
|
144
|
+
type: 'number',
|
|
145
|
+
description:
|
|
146
|
+
`Log entries per page. Defaults to ${DEFAULT_LIMIT} and is `
|
|
147
|
+
+ `capped at ${MAX_PAGE}. Ignored when you set saveTo.`,
|
|
148
|
+
},
|
|
149
|
+
offset: {
|
|
150
|
+
type: 'number',
|
|
151
|
+
description:
|
|
152
|
+
'Entries to skip, counting back from the newest match. '
|
|
153
|
+
+ 'Ignored when you set saveTo.',
|
|
154
|
+
},
|
|
155
|
+
saveTo: {
|
|
156
|
+
type: 'string',
|
|
157
|
+
description:
|
|
158
|
+
'Path to write every matching entry to, as NDJSON: one raw '
|
|
159
|
+
+ 'entry per line, oldest first. When you set this, the tool '
|
|
160
|
+
+ 'returns a summary instead of the entries. Relative paths '
|
|
161
|
+
+ 'resolve against the project root. Use it when a session '
|
|
162
|
+
+ 'has more entries than you want to read inline.',
|
|
163
|
+
},
|
|
164
|
+
maxEntries: {
|
|
165
|
+
type: 'number',
|
|
166
|
+
description:
|
|
167
|
+
'Maximum entries a saveTo dump collects. Defaults to '
|
|
168
|
+
+ `${DEFAULT_MAX_ENTRIES}.`,
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
required: ['sessionId'],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
async(args) => {
|
|
175
|
+
// The backend's own default is `info`, which silently hides every
|
|
176
|
+
// FINE and CONFIG entry — the ones that say what the interceptor
|
|
177
|
+
// actually saw. Debugging is the whole point of this tool, so widen
|
|
178
|
+
// the default here.
|
|
179
|
+
const query: SessionLogsQuery = {
|
|
180
|
+
logLevel: (args.logLevel ?? 'fine'),
|
|
181
|
+
logType: args.logType,
|
|
182
|
+
eventType: args.eventType,
|
|
183
|
+
providerId: args.providerId,
|
|
184
|
+
deviceId: args.deviceId,
|
|
185
|
+
source: args.source,
|
|
186
|
+
startTime: args.startTime,
|
|
187
|
+
endTime: args.endTime,
|
|
188
|
+
logLine: args.contains,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return args.saveTo
|
|
192
|
+
? await dump(client, args, query, args.saveTo)
|
|
193
|
+
: await page(client, args, query)
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One page, returned inline. */
|
|
199
|
+
async function page(
|
|
200
|
+
client: ReclaimOldClient,
|
|
201
|
+
args: Args,
|
|
202
|
+
query: SessionLogsQuery,
|
|
203
|
+
) {
|
|
204
|
+
const limit = clampLimit(args.limit ?? DEFAULT_LIMIT)
|
|
205
|
+
const offset = Math.max(args.offset ?? 0, 0)
|
|
206
|
+
const res = await client.getSessionLogs(args.sessionId, {
|
|
207
|
+
...query,
|
|
208
|
+
limit,
|
|
209
|
+
offset,
|
|
210
|
+
includeCount: true,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// Newest-first off the wire; a debugger reads a session forwards.
|
|
214
|
+
const chronological = [...res.data].reverse()
|
|
215
|
+
const entries = args.eventsOnly
|
|
216
|
+
? chronological.filter(hasEvent)
|
|
217
|
+
: chronological
|
|
218
|
+
const fetched = res.data.length
|
|
219
|
+
const total = res.totalCount
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
...summarize(args, res.failedRegions, entries, total),
|
|
223
|
+
page: {
|
|
224
|
+
limit,
|
|
225
|
+
offset,
|
|
226
|
+
fetched,
|
|
227
|
+
...(args.eventsOnly ? { keptWithEvent: entries.length } : {}),
|
|
228
|
+
hasMore: total === undefined
|
|
229
|
+
? fetched === limit
|
|
230
|
+
: offset + fetched < total,
|
|
231
|
+
nextOffset: offset + fetched,
|
|
232
|
+
},
|
|
233
|
+
entries: compact(entries),
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Every match, paged out to an NDJSON file. */
|
|
238
|
+
async function dump(
|
|
239
|
+
client: ReclaimOldClient,
|
|
240
|
+
args: Args,
|
|
241
|
+
query: SessionLogsQuery,
|
|
242
|
+
saveTo: string,
|
|
243
|
+
) {
|
|
244
|
+
const maxEntries = Math.max(args.maxEntries ?? DEFAULT_MAX_ENTRIES, 1)
|
|
245
|
+
const all: SessionLogRow[] = []
|
|
246
|
+
const failedRegions = new Set<string>()
|
|
247
|
+
let total: number | undefined
|
|
248
|
+
let offset = 0
|
|
249
|
+
|
|
250
|
+
while(all.length < maxEntries) {
|
|
251
|
+
const res = await client.getSessionLogs(args.sessionId, {
|
|
252
|
+
...query,
|
|
253
|
+
limit: Math.min(MAX_PAGE, maxEntries - all.length),
|
|
254
|
+
offset,
|
|
255
|
+
// Only the first page pays for the count; it does not move.
|
|
256
|
+
includeCount: offset === 0,
|
|
257
|
+
})
|
|
258
|
+
for(const region of res.failedRegions ?? []) {
|
|
259
|
+
failedRegions.add(region)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if(offset === 0) {
|
|
263
|
+
total = res.totalCount
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
all.push(...res.data)
|
|
267
|
+
offset += res.data.length
|
|
268
|
+
// A short page means the match set is exhausted.
|
|
269
|
+
if(!res.data.length || res.data.length < MAX_PAGE) {
|
|
270
|
+
break
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if(total !== undefined && offset >= total) {
|
|
274
|
+
break
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
all.reverse()
|
|
279
|
+
const entries = args.eventsOnly ? all.filter(hasEvent) : all
|
|
280
|
+
|
|
281
|
+
const path = isAbsolute(saveTo)
|
|
282
|
+
? saveTo
|
|
283
|
+
: join(findProjectRoot(process.cwd()), saveTo)
|
|
284
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
285
|
+
writeFileSync(path, entries.map(e => JSON.stringify(e)).join('\n') + '\n')
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
...summarize(args, [...failedRegions], entries, total),
|
|
289
|
+
file: {
|
|
290
|
+
path,
|
|
291
|
+
format: 'ndjson',
|
|
292
|
+
entries: entries.length,
|
|
293
|
+
order: 'oldest first',
|
|
294
|
+
note: 'Each line holds one raw log entry with all of its columns: '
|
|
295
|
+
+ 'timestamp, log_level, event_type, log_type, log_line, '
|
|
296
|
+
+ 'session_id, app_id, provider_id, device_id, source, and '
|
|
297
|
+
+ 'metadata.',
|
|
298
|
+
truncated: entries.length >= maxEntries
|
|
299
|
+
? `Stopped at maxEntries=${maxEntries}. Narrow the filters, or `
|
|
300
|
+
+ 'raise the limit.'
|
|
301
|
+
: undefined,
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The header both modes share: what was asked for, what came back, and the
|
|
307
|
+
* event-type histogram — usually enough on its own to see where a session
|
|
308
|
+
* died. */
|
|
309
|
+
function summarize(
|
|
310
|
+
args: Args,
|
|
311
|
+
failedRegions: string[] | undefined,
|
|
312
|
+
entries: SessionLogRow[],
|
|
313
|
+
total: number | undefined,
|
|
314
|
+
) {
|
|
315
|
+
const events: Record<string, number> = {}
|
|
316
|
+
for(const entry of entries) {
|
|
317
|
+
if(entry.event_type) {
|
|
318
|
+
events[entry.event_type] = (events[entry.event_type] ?? 0) + 1
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
sessionId: args.sessionId,
|
|
324
|
+
context: contextOf(entries),
|
|
325
|
+
totalMatching: total,
|
|
326
|
+
span: entries.length
|
|
327
|
+
? {
|
|
328
|
+
from: entries[0].timestamp,
|
|
329
|
+
to: entries[entries.length - 1].timestamp,
|
|
330
|
+
}
|
|
331
|
+
: undefined,
|
|
332
|
+
eventTypes: Object.keys(events).length ? events : undefined,
|
|
333
|
+
...(failedRegions?.length
|
|
334
|
+
? {
|
|
335
|
+
warning: 'The log-stream query failed in region '
|
|
336
|
+
+ `${failedRegions.join(', ')}, so this result is incomplete.`,
|
|
337
|
+
}
|
|
338
|
+
: {}),
|
|
339
|
+
...(entries.length
|
|
340
|
+
? {}
|
|
341
|
+
: {
|
|
342
|
+
hint: 'No entries matched. Check these causes in order: the '
|
|
343
|
+
+ 'search window defaults to the last 3 days, so pass startTime '
|
|
344
|
+
+ 'and endTime for an older session; the backend deletes entries '
|
|
345
|
+
+ 'after 30 days; the backend picks which regions to query from '
|
|
346
|
+
+ 'the analytics table, so a session that never recorded an '
|
|
347
|
+
+ 'analytics event reads as empty here even when entries exist; '
|
|
348
|
+
+ 'or the filters are too narrow. Cross-check with '
|
|
349
|
+
+ 'session_analytics_logs.',
|
|
350
|
+
}),
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Fields that are identical on every entry, hoisted out so they are not
|
|
355
|
+
* repeated hundreds of times. */
|
|
356
|
+
function contextOf(entries: SessionLogRow[]) {
|
|
357
|
+
const uniq = (pick: (entry: SessionLogRow) => string | undefined) => {
|
|
358
|
+
const set = new Set(entries.map(pick).filter(Boolean) as string[])
|
|
359
|
+
return [...set]
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const one = (values: string[]) => (values.length === 1 ? values[0] : values)
|
|
363
|
+
return entries.length
|
|
364
|
+
? {
|
|
365
|
+
appId: one(uniq(e => e.app_id)),
|
|
366
|
+
providerId: one(uniq(e => e.provider_id)),
|
|
367
|
+
deviceId: one(uniq(e => e.device_id)),
|
|
368
|
+
source: one(uniq(e => e.source)),
|
|
369
|
+
}
|
|
370
|
+
: undefined
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function compact(entries: SessionLogRow[]): CompactEntry[] {
|
|
374
|
+
const devices = new Set(entries.map(e => e.device_id))
|
|
375
|
+
const sources = new Set(entries.map(e => e.source))
|
|
376
|
+
return entries.map(e => ({
|
|
377
|
+
ts: e.timestamp,
|
|
378
|
+
level: e.log_level,
|
|
379
|
+
...(e.event_type ? { event: e.event_type } : {}),
|
|
380
|
+
logger: e.log_type,
|
|
381
|
+
line: e.log_line,
|
|
382
|
+
// Only worth repeating per entry once the page actually mixes them.
|
|
383
|
+
...(devices.size > 1 ? { device: e.device_id } : {}),
|
|
384
|
+
...(sources.size > 1 ? { source: e.source } : {}),
|
|
385
|
+
...(e.metadata && e.metadata !== '{}' ? { metadata: e.metadata } : {}),
|
|
386
|
+
}))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function hasEvent(entry: SessionLogRow): boolean {
|
|
390
|
+
return !!entry.event_type
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function clampLimit(limit: number): number {
|
|
394
|
+
return Math.min(Math.max(Math.trunc(limit), 1), MAX_PAGE)
|
|
395
|
+
}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
/** The minimal CDP surface this module needs. Both the agent's `CdpTabClient`
|
|
2
|
-
* and an app-side session-scoped wrapper around `chrome-remote-interface`'s
|
|
3
|
-
* `CDP.Client` already satisfy this. */
|
|
4
|
-
export interface CdpProxySender {
|
|
5
|
-
send: (method: string, params?: object) => Promise<unknown>;
|
|
6
|
-
subscribe: <T = unknown>(method: string, listener: (params: T) => void) => () => void;
|
|
7
|
-
}
|
|
8
|
-
export interface BrowserProxyCredentials {
|
|
9
|
-
host: string;
|
|
10
|
-
port: number;
|
|
11
|
-
username: string;
|
|
12
|
-
password: string;
|
|
13
|
-
}
|
|
14
|
-
export interface TemplatedProxyUsernameOptions {
|
|
15
|
-
/** Vendor's env-configured base, e.g.
|
|
16
|
-
* `brd-customer-<id>-zone-<zone>-country-{{COUNTRY_LOCATION}}`. */
|
|
17
|
-
template: string;
|
|
18
|
-
countryCode: string;
|
|
19
|
-
/** Sticky-session key — the same value must be used for every request
|
|
20
|
-
* that should exit through the same proxy IP. */
|
|
21
|
-
proxySessionId: string;
|
|
22
|
-
}
|
|
23
|
-
/** Substitute `{{COUNTRY_LOCATION}}` in the vendor's username template (or
|
|
24
|
-
* append `-country-<cc>` if the template has no placeholder), then append
|
|
25
|
-
* the sticky-session suffix. */
|
|
26
|
-
export declare function buildTemplatedProxyUsername(opts: TemplatedProxyUsernameOptions): string;
|
|
27
|
-
/** A short alphanumeric sticky-session id suggestion for a proxy vendor's
|
|
28
|
-
* session placeholder (e.g. Bright Data's `-session-<id>` suffix) —
|
|
29
|
-
* deliberately not `captureId`/`sessionId` (long UUIDs, wrong shape for
|
|
30
|
-
* most vendors' conventions and for the attestor's own `proxySessionId`
|
|
31
|
-
* constraint: smallcase alphanumeric, 8-14 chars). Only a suggestion — the
|
|
32
|
-
* caller's own `proxySessionId`, when given, is used instead. */
|
|
33
|
-
export declare function generateProxySessionId(): string;
|
|
34
|
-
/** Parse a `PROXY_SERVER`-style `host:port` string. Throws on a malformed
|
|
35
|
-
* value — a bad env var should fail loudly, not silently proxy nowhere. */
|
|
36
|
-
export declare function parseProxyServer(server: string): {
|
|
37
|
-
host: string;
|
|
38
|
-
port: number;
|
|
39
|
-
};
|
|
40
|
-
/**
|
|
41
|
-
* Route the browser's network traffic through `host:port` via the Popcorn
|
|
42
|
-
* extension's page API. Never throws — a `false` return means the extension
|
|
43
|
-
* couldn't be reached (e.g. this isn't a Popcorn browser); callers still get
|
|
44
|
-
* proxy auth wired up by {@link setupProxyAuthViaCdp} independently, since a
|
|
45
|
-
* caller-supplied proxy might already be configured another way.
|
|
46
|
-
*/
|
|
47
|
-
export declare function configureBrowserProxyViaExtension(sender: CdpProxySender, config: BrowserProxyCredentials): Promise<boolean>;
|
|
48
|
-
/** Clear the extension-configured proxy, routing the browser direct again. */
|
|
49
|
-
export declare function clearBrowserProxyViaExtension(sender: CdpProxySender): Promise<boolean>;
|
|
50
|
-
/**
|
|
51
|
-
* Answer the proxy's Basic-auth challenge over CDP `Fetch`, since Popcorn's
|
|
52
|
-
* extension only sets host/port routing (not credentials — the proxy vendor
|
|
53
|
-
* still challenges the browser for them the normal way). Self-disables once
|
|
54
|
-
* the first challenge is answered (or after a timeout with none), rather
|
|
55
|
-
* than intercepting every request for the session's whole lifetime.
|
|
56
|
-
*/
|
|
57
|
-
export declare function setupProxyAuthViaCdp(sender: CdpProxySender, credentials: Pick<BrowserProxyCredentials, 'username' | 'password'>): Promise<void>;
|
|
58
|
-
export interface ApplyBrowserProxyOptions extends BrowserProxyCredentials {
|
|
59
|
-
/** Defaults to true — proxy routing only affects new connections, so
|
|
60
|
-
* existing page state must reload to actually go through it. */
|
|
61
|
-
reload?: boolean;
|
|
62
|
-
}
|
|
63
|
-
export interface ApplyBrowserProxyResult {
|
|
64
|
-
/** False means the extension wasn't reachable (not a Popcorn browser, or
|
|
65
|
-
* it isn't installed) — auth handling was still armed regardless, in
|
|
66
|
-
* case the proxy is configured another way. */
|
|
67
|
-
appliedViaExtension: boolean;
|
|
68
|
-
}
|
|
69
|
-
/** Configure routing + auth + reload, in the right order: routing must be
|
|
70
|
-
* live before the reload navigates, and auth must be armed before that
|
|
71
|
-
* navigation's requests go out. */
|
|
72
|
-
export declare function applyBrowserProxy(sender: CdpProxySender, opts: ApplyBrowserProxyOptions): Promise<ApplyBrowserProxyResult>;
|
|
73
|
-
/** Clear proxy routing and reload so subsequent requests go direct again. */
|
|
74
|
-
export declare function clearBrowserProxy(sender: CdpProxySender, opts?: {
|
|
75
|
-
reload?: boolean;
|
|
76
|
-
}): Promise<{
|
|
77
|
-
cleared: boolean;
|
|
78
|
-
}>;
|
package/lib/cdp/browser-proxy.js
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Native browser-level proxy for a Popcorn remote browser session.
|
|
3
|
-
*
|
|
4
|
-
* Routes the browser's OWN network traffic (not the attestor's zkTLS egress —
|
|
5
|
-
* that's `geoLocation`/`useProxy` on `ProviderWebSettings`) through a
|
|
6
|
-
* user-supplied proxy. Applied via the Popcorn kiosk image's `window.__pcn`
|
|
7
|
-
* page API rather than a Playwright/CDP launch arg or a new browser context —
|
|
8
|
-
* both of those would spawn a browser outside the pod's `--kiosk` window,
|
|
9
|
-
* breaking the live-view stream this session is already being watched
|
|
10
|
-
* through. Proxy auth (username/password) is answered separately over CDP's
|
|
11
|
-
* `Fetch` domain, since Popcorn's extension only sets host/port routing.
|
|
12
|
-
*
|
|
13
|
-
* Shared between the app's verification runner and the agent's MCP tools —
|
|
14
|
-
* both drive a Popcorn browser over `chrome-remote-interface`, just through
|
|
15
|
-
* different wrapper types, so this module only depends on the minimal
|
|
16
|
-
* {@link CdpProxySender} shape rather than either package's CDP client.
|
|
17
|
-
*/
|
|
18
|
-
import { randomInt } from 'node:crypto';
|
|
19
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
20
|
-
import { LOGGER } from "../logger.js";
|
|
21
|
-
/** Substitute `{{COUNTRY_LOCATION}}` in the vendor's username template (or
|
|
22
|
-
* append `-country-<cc>` if the template has no placeholder), then append
|
|
23
|
-
* the sticky-session suffix. */
|
|
24
|
-
export function buildTemplatedProxyUsername(opts) {
|
|
25
|
-
const { template, countryCode, proxySessionId } = opts;
|
|
26
|
-
const cc = countryCode.toLowerCase();
|
|
27
|
-
const base = template.includes('{{COUNTRY_LOCATION}}')
|
|
28
|
-
? template.replaceAll('{{COUNTRY_LOCATION}}', cc)
|
|
29
|
-
: `${template}-country-${cc}`;
|
|
30
|
-
return `${base}-session-${proxySessionId}`;
|
|
31
|
-
}
|
|
32
|
-
const PROXY_SESSION_ID_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
33
|
-
const PROXY_SESSION_ID_LENGTH = 10;
|
|
34
|
-
/** A short alphanumeric sticky-session id suggestion for a proxy vendor's
|
|
35
|
-
* session placeholder (e.g. Bright Data's `-session-<id>` suffix) —
|
|
36
|
-
* deliberately not `captureId`/`sessionId` (long UUIDs, wrong shape for
|
|
37
|
-
* most vendors' conventions and for the attestor's own `proxySessionId`
|
|
38
|
-
* constraint: smallcase alphanumeric, 8-14 chars). Only a suggestion — the
|
|
39
|
-
* caller's own `proxySessionId`, when given, is used instead. */
|
|
40
|
-
export function generateProxySessionId() {
|
|
41
|
-
let out = '';
|
|
42
|
-
for (let i = 0; i < PROXY_SESSION_ID_LENGTH; i++) {
|
|
43
|
-
out += PROXY_SESSION_ID_CHARS[randomInt(PROXY_SESSION_ID_CHARS.length)];
|
|
44
|
-
}
|
|
45
|
-
return out;
|
|
46
|
-
}
|
|
47
|
-
/** Parse a `PROXY_SERVER`-style `host:port` string. Throws on a malformed
|
|
48
|
-
* value — a bad env var should fail loudly, not silently proxy nowhere. */
|
|
49
|
-
export function parseProxyServer(server) {
|
|
50
|
-
const match = /^([^:/]+):(\d+)$/.exec(server.trim());
|
|
51
|
-
if (!match) {
|
|
52
|
-
throw new Error(`PROXY_SERVER must be "host:port", got "${server}"`);
|
|
53
|
-
}
|
|
54
|
-
return { host: match[1], port: Number(match[2]) };
|
|
55
|
-
}
|
|
56
|
-
const PCN_READY_TIMEOUT_MS = 5000;
|
|
57
|
-
const PCN_READY_POLL_MS = 100;
|
|
58
|
-
/** Chrome caches a proxy credential per host:port after the first successful
|
|
59
|
-
* challenge, so this only needs to cover the FIRST request. If no challenge
|
|
60
|
-
* ever arrives (proxy doesn't require auth), don't leave `Fetch` enabled —
|
|
61
|
-
* it would otherwise hold every request pending on this handler forever. */
|
|
62
|
-
const PROXY_AUTH_SAFETY_TIMEOUT_MS = 15000;
|
|
63
|
-
async function isExtensionReady(sender) {
|
|
64
|
-
const { result } = await sender.send('Runtime.evaluate', {
|
|
65
|
-
expression: 'Boolean(window.__pcn && window.__pcn.ready)',
|
|
66
|
-
returnByValue: true,
|
|
67
|
-
});
|
|
68
|
-
return result?.value === true;
|
|
69
|
-
}
|
|
70
|
-
async function waitForExtensionReady(sender) {
|
|
71
|
-
const deadline = Date.now() + PCN_READY_TIMEOUT_MS;
|
|
72
|
-
while (Date.now() < deadline) {
|
|
73
|
-
if (await isExtensionReady(sender)) {
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
await delay(PCN_READY_POLL_MS);
|
|
77
|
-
}
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Route the browser's network traffic through `host:port` via the Popcorn
|
|
82
|
-
* extension's page API. Never throws — a `false` return means the extension
|
|
83
|
-
* couldn't be reached (e.g. this isn't a Popcorn browser); callers still get
|
|
84
|
-
* proxy auth wired up by {@link setupProxyAuthViaCdp} independently, since a
|
|
85
|
-
* caller-supplied proxy might already be configured another way.
|
|
86
|
-
*/
|
|
87
|
-
export async function configureBrowserProxyViaExtension(sender, config) {
|
|
88
|
-
try {
|
|
89
|
-
if (!await isExtensionReady(sender)) {
|
|
90
|
-
// Force the content script to (re-)run so `window.__pcn` appears.
|
|
91
|
-
await sender.send('Page.navigate', { url: 'about:blank' });
|
|
92
|
-
if (!await waitForExtensionReady(sender)) {
|
|
93
|
-
LOGGER.warn('popcorn proxy extension not ready; skipping __pcn.set');
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
const { result } = await sender.send('Runtime.evaluate', {
|
|
98
|
-
expression: `(async () => {
|
|
99
|
-
try { await window.__pcn.set(${JSON.stringify(config)}); return true }
|
|
100
|
-
catch(e) { return false }
|
|
101
|
-
})()`,
|
|
102
|
-
awaitPromise: true,
|
|
103
|
-
returnByValue: true,
|
|
104
|
-
});
|
|
105
|
-
return result?.value === true;
|
|
106
|
-
}
|
|
107
|
-
catch (err) {
|
|
108
|
-
LOGGER.warn({ err }, 'configureBrowserProxyViaExtension failed');
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
/** Clear the extension-configured proxy, routing the browser direct again. */
|
|
113
|
-
export async function clearBrowserProxyViaExtension(sender) {
|
|
114
|
-
try {
|
|
115
|
-
const { result } = await sender.send('Runtime.evaluate', {
|
|
116
|
-
expression: `(async () => {
|
|
117
|
-
try { await window.__pcn.clear(); return true }
|
|
118
|
-
catch(e) { return false }
|
|
119
|
-
})()`,
|
|
120
|
-
awaitPromise: true,
|
|
121
|
-
returnByValue: true,
|
|
122
|
-
});
|
|
123
|
-
return result?.value === true;
|
|
124
|
-
}
|
|
125
|
-
catch (err) {
|
|
126
|
-
LOGGER.warn({ err }, 'clearBrowserProxyViaExtension failed');
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Answer the proxy's Basic-auth challenge over CDP `Fetch`, since Popcorn's
|
|
132
|
-
* extension only sets host/port routing (not credentials — the proxy vendor
|
|
133
|
-
* still challenges the browser for them the normal way). Self-disables once
|
|
134
|
-
* the first challenge is answered (or after a timeout with none), rather
|
|
135
|
-
* than intercepting every request for the session's whole lifetime.
|
|
136
|
-
*/
|
|
137
|
-
export async function setupProxyAuthViaCdp(sender, credentials) {
|
|
138
|
-
await sender.send('Fetch.enable', { handleAuthRequests: true });
|
|
139
|
-
let settled = false;
|
|
140
|
-
const finish = () => {
|
|
141
|
-
if (settled) {
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
settled = true;
|
|
145
|
-
unsubAuth();
|
|
146
|
-
unsubPaused();
|
|
147
|
-
sender.send('Fetch.disable').catch(() => { });
|
|
148
|
-
};
|
|
149
|
-
const unsubAuth = sender.subscribe('Fetch.authRequired', (params) => {
|
|
150
|
-
sender.send('Fetch.continueWithAuth', {
|
|
151
|
-
requestId: params.requestId,
|
|
152
|
-
authChallengeResponse: {
|
|
153
|
-
response: 'ProvideCredentials',
|
|
154
|
-
username: credentials.username,
|
|
155
|
-
password: credentials.password,
|
|
156
|
-
},
|
|
157
|
-
}).then(finish).catch(() => { });
|
|
158
|
-
});
|
|
159
|
-
// Requests still need releasing while `Fetch` is enabled, whether or not
|
|
160
|
-
// they end up challenged. Fire-and-forget: serializing behind auth
|
|
161
|
-
// negotiation would stall unrelated requests.
|
|
162
|
-
const unsubPaused = sender.subscribe('Fetch.requestPaused', (params) => {
|
|
163
|
-
sender.send('Fetch.continueRequest', { requestId: params.requestId })
|
|
164
|
-
.catch(() => { });
|
|
165
|
-
});
|
|
166
|
-
setTimeout(finish, PROXY_AUTH_SAFETY_TIMEOUT_MS);
|
|
167
|
-
}
|
|
168
|
-
/** Configure routing + auth + reload, in the right order: routing must be
|
|
169
|
-
* live before the reload navigates, and auth must be armed before that
|
|
170
|
-
* navigation's requests go out. */
|
|
171
|
-
export async function applyBrowserProxy(sender, opts) {
|
|
172
|
-
const appliedViaExtension = await configureBrowserProxyViaExtension(sender, opts);
|
|
173
|
-
await setupProxyAuthViaCdp(sender, opts);
|
|
174
|
-
if (opts.reload !== false) {
|
|
175
|
-
await sender.send('Page.reload', {});
|
|
176
|
-
}
|
|
177
|
-
return { appliedViaExtension };
|
|
178
|
-
}
|
|
179
|
-
/** Clear proxy routing and reload so subsequent requests go direct again. */
|
|
180
|
-
export async function clearBrowserProxy(sender, opts = {}) {
|
|
181
|
-
const cleared = await clearBrowserProxyViaExtension(sender);
|
|
182
|
-
if (opts.reload !== false) {
|
|
183
|
-
await sender.send('Page.reload', {});
|
|
184
|
-
}
|
|
185
|
-
return { cleared };
|
|
186
|
-
}
|
package/lib/live/page.d.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The live-view page served to whoever opens the tunnel URL.
|
|
3
|
-
*
|
|
4
|
-
* Deliberately dependency-free and inline: it is delivered through a throwaway
|
|
5
|
-
* tunnel, so it cannot rely on a CDN, a bundler, or anything cached.
|
|
6
|
-
*
|
|
7
|
-
* Frames arrive as base64 JPEG over Server-Sent Events rather than a
|
|
8
|
-
* WebSocket. SSE is one plain GET, so it survives tunnels and proxies that
|
|
9
|
-
* mishandle upgrades, and it reconnects on its own. Input goes the other way
|
|
10
|
-
* as small POSTs.
|
|
11
|
-
*/
|
|
12
|
-
export interface PageOptions {
|
|
13
|
-
/** Path prefix that carries the access token, for example `/v/<token>`. */
|
|
14
|
-
base: string;
|
|
15
|
-
/** Whether to wire up mouse and keyboard forwarding. */
|
|
16
|
-
interactive: boolean;
|
|
17
|
-
/** Shown in the header so a viewer knows which tab they are watching. */
|
|
18
|
-
title: string;
|
|
19
|
-
}
|
|
20
|
-
export declare function renderViewerPage(opts: PageOptions): string;
|