@reclaimprotocol/agent 0.1.24 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/lib/cdp/claims.d.ts +21 -0
- package/lib/cdp/claims.js +129 -0
- package/lib/cdp/client.d.ts +0 -10
- package/lib/cdp/container.d.ts +85 -0
- package/lib/cdp/container.js +481 -0
- package/lib/live/tunnel.d.ts +6 -3
- package/lib/live/tunnel.js +57 -17
- package/lib/mcp/instructions.js +2 -1
- package/lib/mcp/start.js +58 -3
- package/lib/mcp/tools/agent/attach.d.ts +22 -2
- package/lib/mcp/tools/agent/attach.js +197 -55
- package/lib/mcp/tools/agent/build.d.ts +5 -0
- package/lib/mcp/tools/agent/build.js +9 -2
- package/lib/mcp/tools/agent/index.d.ts +1 -2
- package/lib/mcp/tools/agent/index.js +1 -1
- package/lib/mcp/tools/agent/live-view.d.ts +3 -7
- package/lib/mcp/tools/agent/live-view.js +74 -81
- package/lib/mcp/tools/agent/session.d.ts +14 -0
- package/lib/mcp/tools/agent/session.js +14 -8
- package/package.json +2 -2
- package/skill/SKILL.common.md +115 -38
- package/skill/builder-mode.md +9 -7
- package/skill/interception.md +3 -2
- package/skill/old-mode.md +10 -8
- package/src/cdp/claims.ts +153 -0
- package/src/cdp/client.ts +0 -8
- package/src/cdp/container.ts +593 -0
- package/src/live/tunnel.ts +71 -20
- package/src/mcp/instructions.ts +2 -1
- package/src/mcp/start.ts +65 -2
- package/src/mcp/tools/agent/attach.ts +229 -60
- package/src/mcp/tools/agent/build.ts +10 -2
- package/src/mcp/tools/agent/index.ts +6 -2
- package/src/mcp/tools/agent/live-view.ts +77 -93
- package/src/mcp/tools/agent/session.ts +14 -8
- package/lib/cdp/browser-proxy.d.ts +0 -78
- package/lib/cdp/browser-proxy.js +0 -186
- package/lib/live/page.d.ts +0 -20
- package/lib/live/page.js +0 -134
- package/lib/live/viewer.d.ts +0 -28
- package/lib/live/viewer.js +0 -232
- package/lib/mcp/tools/agent/proxy.d.ts +0 -3
- package/lib/mcp/tools/agent/proxy.js +0 -199
- package/src/live/page.ts +0 -145
- package/src/live/viewer.ts +0 -293
|
@@ -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
|
|
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
|
-
|
|
51
|
-
|
|
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<
|
|
55
|
+
defineTool<Record<string, never>>(
|
|
66
56
|
{
|
|
67
57
|
name: 'share_browser_view',
|
|
68
58
|
description:
|
|
69
|
-
'Put the attached
|
|
70
|
-
+ 'quick tunnel) so someone elsewhere can watch it
|
|
71
|
-
+ '
|
|
72
|
-
+ '
|
|
73
|
-
+ '
|
|
74
|
-
+ '
|
|
75
|
-
+ 'ONLY for
|
|
76
|
-
+ '
|
|
77
|
-
+ '
|
|
78
|
-
+ '`
|
|
79
|
-
+ '
|
|
80
|
-
+ 'and
|
|
81
|
-
+ '
|
|
82
|
-
+ '
|
|
83
|
-
+ '
|
|
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(
|
|
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
|
-
+
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
121
|
+
tunnel, url,
|
|
152
122
|
}
|
|
153
123
|
shareRef.current = share
|
|
154
|
-
LOGGER.info({ mode: attached.mode,
|
|
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
|
|
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
|
|
173
|
-
+ '
|
|
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
|
-
+ '
|
|
177
|
-
|
|
178
|
-
'
|
|
179
|
-
|
|
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
|
|
189
|
-
+ '
|
|
190
|
-
+ '
|
|
191
|
-
+ '
|
|
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
|
-
|
|
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:
|
|
83
|
-
//
|
|
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)
|
|
@@ -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;
|