@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
@@ -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
- }>;
@@ -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
- }
@@ -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;