@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
package/lib/live/page.js
DELETED
|
@@ -1,134 +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
|
-
/** Escape a value for embedding in a single-quoted JS string literal. */
|
|
13
|
-
function js(value) {
|
|
14
|
-
return value
|
|
15
|
-
.replace(/\\/g, '\\\\')
|
|
16
|
-
.replace(/'/g, "\\'")
|
|
17
|
-
.replace(/</g, '\\x3c');
|
|
18
|
-
}
|
|
19
|
-
export function renderViewerPage(opts) {
|
|
20
|
-
return `<!doctype html>
|
|
21
|
-
<html lang="en">
|
|
22
|
-
<head>
|
|
23
|
-
<meta charset="utf-8">
|
|
24
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
25
|
-
<meta name="robots" content="noindex, nofollow">
|
|
26
|
-
<title>Reclaim live view</title>
|
|
27
|
-
<style>
|
|
28
|
-
:root { color-scheme: dark; }
|
|
29
|
-
* { box-sizing: border-box; }
|
|
30
|
-
body { margin: 0; background: #0b0b0c; color: #e7e7ea; display: flex;
|
|
31
|
-
flex-direction: column; height: 100vh;
|
|
32
|
-
font: 14px/1.4 ui-sans-serif, system-ui, sans-serif; }
|
|
33
|
-
header { display: flex; align-items: center; gap: .75rem;
|
|
34
|
-
padding: .5rem .75rem; border-bottom: 1px solid #26262b; }
|
|
35
|
-
.dot { width: .5rem; height: .5rem; border-radius: 50%; background: #f5a524; }
|
|
36
|
-
.dot.live { background: #17c964; }
|
|
37
|
-
.title { color: #9a9aa4; overflow: hidden; text-overflow: ellipsis;
|
|
38
|
-
white-space: nowrap; }
|
|
39
|
-
.badge { margin-left: auto; font-size: 12px; padding: .15rem .4rem;
|
|
40
|
-
border: 1px solid #26262b; border-radius: .25rem; color: #9a9aa4; }
|
|
41
|
-
main { flex: 1; min-height: 0; display: grid; place-items: center;
|
|
42
|
-
padding: .75rem; }
|
|
43
|
-
img { max-width: 100%; max-height: 100%; display: block;
|
|
44
|
-
background: #fff; border-radius: .25rem; }
|
|
45
|
-
img.interactive { cursor: crosshair; }
|
|
46
|
-
.msg { color: #9a9aa4; }
|
|
47
|
-
</style>
|
|
48
|
-
</head>
|
|
49
|
-
<body>
|
|
50
|
-
<header>
|
|
51
|
-
<span class="dot" id="dot"></span>
|
|
52
|
-
<span class="title" id="title">${js(opts.title)}</span>
|
|
53
|
-
<span class="badge">${opts.interactive ? 'interactive' : 'view only'}</span>
|
|
54
|
-
</header>
|
|
55
|
-
<main><img id="screen" alt="Live view of the browser tab"><p class="msg"
|
|
56
|
-
id="msg">Connecting…</p></main>
|
|
57
|
-
<script>
|
|
58
|
-
(function () {
|
|
59
|
-
var BASE = '${js(opts.base)}';
|
|
60
|
-
var INTERACTIVE = ${opts.interactive ? 'true' : 'false'};
|
|
61
|
-
var screen = document.getElementById('screen');
|
|
62
|
-
var msg = document.getElementById('msg');
|
|
63
|
-
var dot = document.getElementById('dot');
|
|
64
|
-
var title = document.getElementById('title');
|
|
65
|
-
|
|
66
|
-
var es = new EventSource(BASE + '/stream');
|
|
67
|
-
es.addEventListener('frame', function (e) {
|
|
68
|
-
var payload = JSON.parse(e.data);
|
|
69
|
-
screen.src = 'data:image/jpeg;base64,' + payload.data;
|
|
70
|
-
if (payload.title) { title.textContent = payload.title; }
|
|
71
|
-
msg.style.display = 'none';
|
|
72
|
-
dot.className = 'dot live';
|
|
73
|
-
});
|
|
74
|
-
es.onerror = function () {
|
|
75
|
-
dot.className = 'dot';
|
|
76
|
-
msg.style.display = '';
|
|
77
|
-
msg.textContent = 'Reconnecting…';
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
if (!INTERACTIVE) { return; }
|
|
81
|
-
screen.className = 'interactive';
|
|
82
|
-
|
|
83
|
-
// The <img> is letterboxed by object-fit, so a click at some point in the
|
|
84
|
-
// element maps to page coordinates by the ratio of natural to rendered size.
|
|
85
|
-
function toPage(ev) {
|
|
86
|
-
var r = screen.getBoundingClientRect();
|
|
87
|
-
if (!screen.naturalWidth || !r.width) { return null; }
|
|
88
|
-
return {
|
|
89
|
-
x: Math.round((ev.clientX - r.left) * (screen.naturalWidth / r.width)),
|
|
90
|
-
y: Math.round((ev.clientY - r.top) * (screen.naturalHeight / r.height))
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function post(body) {
|
|
95
|
-
return fetch(BASE + '/input', {
|
|
96
|
-
method: 'POST',
|
|
97
|
-
headers: { 'content-type': 'application/json' },
|
|
98
|
-
body: JSON.stringify(body)
|
|
99
|
-
}).catch(function () {});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
screen.addEventListener('mousedown', function (ev) {
|
|
103
|
-
var p = toPage(ev); if (!p) { return; }
|
|
104
|
-
ev.preventDefault();
|
|
105
|
-
post({ kind: 'mouse', type: 'mousePressed', x: p.x, y: p.y,
|
|
106
|
-
button: 'left', clickCount: 1 });
|
|
107
|
-
});
|
|
108
|
-
screen.addEventListener('mouseup', function (ev) {
|
|
109
|
-
var p = toPage(ev); if (!p) { return; }
|
|
110
|
-
post({ kind: 'mouse', type: 'mouseReleased', x: p.x, y: p.y,
|
|
111
|
-
button: 'left', clickCount: 1 });
|
|
112
|
-
});
|
|
113
|
-
screen.addEventListener('wheel', function (ev) {
|
|
114
|
-
var p = toPage(ev); if (!p) { return; }
|
|
115
|
-
ev.preventDefault();
|
|
116
|
-
post({ kind: 'mouse', type: 'mouseWheel', x: p.x, y: p.y,
|
|
117
|
-
deltaX: ev.deltaX, deltaY: ev.deltaY });
|
|
118
|
-
}, { passive: false });
|
|
119
|
-
|
|
120
|
-
// Typing goes to the page, so the viewer never needs to focus anything.
|
|
121
|
-
window.addEventListener('keydown', function (ev) {
|
|
122
|
-
if (ev.metaKey || ev.ctrlKey) { return; }
|
|
123
|
-
ev.preventDefault();
|
|
124
|
-
post({ kind: 'key', type: 'keyDown', key: ev.key, code: ev.code });
|
|
125
|
-
});
|
|
126
|
-
window.addEventListener('keyup', function (ev) {
|
|
127
|
-
if (ev.metaKey || ev.ctrlKey) { return; }
|
|
128
|
-
post({ kind: 'key', type: 'keyUp', key: ev.key, code: ev.code });
|
|
129
|
-
});
|
|
130
|
-
})();
|
|
131
|
-
</script>
|
|
132
|
-
</body>
|
|
133
|
-
</html>`;
|
|
134
|
-
}
|
package/lib/live/viewer.d.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import type { CdpTabClient } from '../cdp/client.ts';
|
|
2
|
-
export interface LiveViewOptions {
|
|
3
|
-
/** The tab to stream. */
|
|
4
|
-
tab: CdpTabClient;
|
|
5
|
-
/** Forward viewer input into the page. */
|
|
6
|
-
interactive: boolean;
|
|
7
|
-
/** Shown in the viewer's header. */
|
|
8
|
-
title?: string;
|
|
9
|
-
}
|
|
10
|
-
export interface LiveView {
|
|
11
|
-
/** Loopback origin the tunnel should point at. */
|
|
12
|
-
localUrl: string;
|
|
13
|
-
/** Port the server bound to. */
|
|
14
|
-
port: number;
|
|
15
|
-
/** Unguessable path segment guarding every route. */
|
|
16
|
-
token: string;
|
|
17
|
-
/** Path prefix, `/v/<token>`. Append this to the public origin. */
|
|
18
|
-
path: string;
|
|
19
|
-
/** Number of viewers currently streaming. */
|
|
20
|
-
viewers: () => number;
|
|
21
|
-
stop: () => Promise<void>;
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Start the loopback live-view server for `tab`. Resolves once it is
|
|
25
|
-
* listening; the screencast starts with the first viewer and stops with the
|
|
26
|
-
* last, so an idle share costs the page nothing.
|
|
27
|
-
*/
|
|
28
|
-
export declare function startLiveView(opts: LiveViewOptions): Promise<LiveView>;
|
package/lib/live/viewer.js
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Interactive live view of one attached tab, over a public URL.
|
|
3
|
-
*
|
|
4
|
-
* Two halves:
|
|
5
|
-
* - OUT: CDP `Page.startScreencast` pushes JPEG frames; each is forwarded to
|
|
6
|
-
* every connected browser as a Server-Sent Event and acked back to CDP.
|
|
7
|
-
* - IN: the viewer POSTs pointer and key events, which are replayed into the
|
|
8
|
-
* page with `Input.dispatch*`.
|
|
9
|
-
*
|
|
10
|
-
* Only ever bound to 127.0.0.1. Reaching it from outside is the tunnel's job
|
|
11
|
-
* (see `tunnel.ts`), and because a quick tunnel is unauthenticated, EVERY
|
|
12
|
-
* route sits behind an unguessable token in the path. A request without it
|
|
13
|
-
* gets a flat 404 — not a 401, which would confirm the server exists.
|
|
14
|
-
*
|
|
15
|
-
* Interactivity is the caller's choice. `interactive: false` still streams,
|
|
16
|
-
* but the input route refuses, which is the right setting for showing someone
|
|
17
|
-
* a run rather than handing them the wheel.
|
|
18
|
-
*/
|
|
19
|
-
import { randomBytes } from 'node:crypto';
|
|
20
|
-
import { createServer, } from 'node:http';
|
|
21
|
-
import { LOGGER } from "../logger.js";
|
|
22
|
-
import { renderViewerPage } from "./page.js";
|
|
23
|
-
/** JPEG quality for streamed frames. 60 keeps a text-heavy page readable
|
|
24
|
-
* while staying small enough for a tunnel's uplink. */
|
|
25
|
-
const FRAME_QUALITY = 60;
|
|
26
|
-
/** Cap the streamed frame size. Screencast scales to fit, so this bounds
|
|
27
|
-
* bandwidth without changing the page's own layout viewport. */
|
|
28
|
-
const MAX_FRAME_WIDTH = 1280;
|
|
29
|
-
const MAX_FRAME_HEIGHT = 800;
|
|
30
|
-
/** Bytes accepted on an input POST. Events are tiny; anything larger is
|
|
31
|
-
* either a bug or someone probing. */
|
|
32
|
-
const MAX_INPUT_BYTES = 4096;
|
|
33
|
-
/**
|
|
34
|
-
* Start the loopback live-view server for `tab`. Resolves once it is
|
|
35
|
-
* listening; the screencast starts with the first viewer and stops with the
|
|
36
|
-
* last, so an idle share costs the page nothing.
|
|
37
|
-
*/
|
|
38
|
-
export async function startLiveView(opts) {
|
|
39
|
-
const token = randomBytes(24).toString('base64url');
|
|
40
|
-
const path = `/v/${token}`;
|
|
41
|
-
const clients = new Set();
|
|
42
|
-
let streaming = false;
|
|
43
|
-
let unsubscribe;
|
|
44
|
-
const startScreencast = async () => {
|
|
45
|
-
if (streaming) {
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
streaming = true;
|
|
49
|
-
unsubscribe = opts.tab.subscribe('Page.screencastFrame', (frame) => {
|
|
50
|
-
// Ack first: CDP sends at most one un-acked frame, so a slow or failed
|
|
51
|
-
// write must not stall the stream.
|
|
52
|
-
void opts.tab
|
|
53
|
-
.send('Page.screencastFrameAck', { sessionId: frame.sessionId })
|
|
54
|
-
.catch(() => { });
|
|
55
|
-
const payload = JSON.stringify({ data: frame.data });
|
|
56
|
-
for (const res of clients) {
|
|
57
|
-
res.write(`event: frame\ndata: ${payload}\n\n`);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
await opts.tab.send('Page.enable');
|
|
61
|
-
await opts.tab.send('Page.startScreencast', {
|
|
62
|
-
format: 'jpeg',
|
|
63
|
-
quality: FRAME_QUALITY,
|
|
64
|
-
maxWidth: MAX_FRAME_WIDTH,
|
|
65
|
-
maxHeight: MAX_FRAME_HEIGHT,
|
|
66
|
-
everyNthFrame: 1,
|
|
67
|
-
});
|
|
68
|
-
};
|
|
69
|
-
const stopScreencast = async () => {
|
|
70
|
-
if (!streaming) {
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
streaming = false;
|
|
74
|
-
unsubscribe?.();
|
|
75
|
-
unsubscribe = undefined;
|
|
76
|
-
await opts.tab.send('Page.stopScreencast').catch(() => { });
|
|
77
|
-
};
|
|
78
|
-
const server = createServer((req, res) => {
|
|
79
|
-
const url = req.url ?? '/';
|
|
80
|
-
// Constant prefix check: everything is scoped under the token, so an
|
|
81
|
-
// unmatched path is indistinguishable from a server that isn't there.
|
|
82
|
-
if (!url.startsWith(path)) {
|
|
83
|
-
res.writeHead(404).end('not found');
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const route = url.slice(path.length) || '/';
|
|
87
|
-
if (req.method === 'GET' && (route === '/' || route.startsWith('/?'))) {
|
|
88
|
-
const body = renderViewerPage({
|
|
89
|
-
base: path,
|
|
90
|
-
interactive: opts.interactive,
|
|
91
|
-
title: opts.title ?? 'Attached tab',
|
|
92
|
-
});
|
|
93
|
-
res.writeHead(200, {
|
|
94
|
-
'content-type': 'text/html; charset=utf-8',
|
|
95
|
-
// A share URL must never be cached or indexed by anything between
|
|
96
|
-
// the viewer and here.
|
|
97
|
-
'cache-control': 'no-store',
|
|
98
|
-
'referrer-policy': 'no-referrer',
|
|
99
|
-
}).end(body);
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
if (req.method === 'GET' && route === '/stream') {
|
|
103
|
-
res.writeHead(200, {
|
|
104
|
-
'content-type': 'text/event-stream',
|
|
105
|
-
'cache-control': 'no-store',
|
|
106
|
-
connection: 'keep-alive',
|
|
107
|
-
// Defeat proxy buffering, which would otherwise hold frames back.
|
|
108
|
-
'x-accel-buffering': 'no',
|
|
109
|
-
});
|
|
110
|
-
res.write(': connected\n\n');
|
|
111
|
-
clients.add(res);
|
|
112
|
-
void startScreencast().catch((err) => {
|
|
113
|
-
LOGGER.warn({ err }, 'live view: screencast failed to start');
|
|
114
|
-
});
|
|
115
|
-
req.on('close', () => {
|
|
116
|
-
clients.delete(res);
|
|
117
|
-
if (!clients.size) {
|
|
118
|
-
void stopScreencast();
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (req.method === 'POST' && route === '/input') {
|
|
124
|
-
if (!opts.interactive) {
|
|
125
|
-
res.writeHead(403).end('view only');
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
readJson(req, MAX_INPUT_BYTES)
|
|
129
|
-
.then((body) => dispatchInput(opts.tab, body))
|
|
130
|
-
.then(() => res.writeHead(204).end())
|
|
131
|
-
.catch((err) => {
|
|
132
|
-
LOGGER.debug({ err }, 'live view: input rejected');
|
|
133
|
-
res.writeHead(400).end('bad input');
|
|
134
|
-
});
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
res.writeHead(404).end('not found');
|
|
138
|
-
});
|
|
139
|
-
await new Promise((resolve) => {
|
|
140
|
-
server.listen(0, '127.0.0.1', resolve);
|
|
141
|
-
});
|
|
142
|
-
const port = server.address().port;
|
|
143
|
-
return {
|
|
144
|
-
localUrl: `http://127.0.0.1:${port}`,
|
|
145
|
-
port,
|
|
146
|
-
token,
|
|
147
|
-
path,
|
|
148
|
-
viewers: () => clients.size,
|
|
149
|
-
stop: async () => {
|
|
150
|
-
await stopScreencast();
|
|
151
|
-
for (const res of clients) {
|
|
152
|
-
res.end();
|
|
153
|
-
}
|
|
154
|
-
clients.clear();
|
|
155
|
-
await closeServer(server);
|
|
156
|
-
},
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
function closeServer(server) {
|
|
160
|
-
return new Promise((resolve) => {
|
|
161
|
-
server.close(() => resolve());
|
|
162
|
-
// A held-open SSE response would otherwise keep the server alive.
|
|
163
|
-
server.closeAllConnections?.();
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
function readJson(req, limit) {
|
|
167
|
-
return new Promise((resolve, reject) => {
|
|
168
|
-
let size = 0;
|
|
169
|
-
const chunks = [];
|
|
170
|
-
req.on('data', (chunk) => {
|
|
171
|
-
size += chunk.length;
|
|
172
|
-
if (size > limit) {
|
|
173
|
-
reject(new Error('input too large'));
|
|
174
|
-
req.destroy();
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
chunks.push(chunk);
|
|
178
|
-
});
|
|
179
|
-
req.on('end', () => {
|
|
180
|
-
try {
|
|
181
|
-
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
182
|
-
}
|
|
183
|
-
catch (err) {
|
|
184
|
-
reject(err instanceof Error ? err : new Error('bad json'));
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
req.on('error', reject);
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
/** Replay one viewer event into the page. Unknown shapes throw, so the route
|
|
191
|
-
* answers 400 rather than forwarding something arbitrary to CDP. */
|
|
192
|
-
async function dispatchInput(tab, body) {
|
|
193
|
-
const num = (v) => (typeof v === 'number' && Number.isFinite(v)
|
|
194
|
-
? v
|
|
195
|
-
: 0);
|
|
196
|
-
if (body.kind === 'mouse') {
|
|
197
|
-
const type = String(body.type);
|
|
198
|
-
if (!['mousePressed', 'mouseReleased', 'mouseMoved', 'mouseWheel']
|
|
199
|
-
.includes(type)) {
|
|
200
|
-
throw new Error(`unsupported mouse type ${type}`);
|
|
201
|
-
}
|
|
202
|
-
await tab.send('Input.dispatchMouseEvent', {
|
|
203
|
-
type,
|
|
204
|
-
x: num(body.x),
|
|
205
|
-
y: num(body.y),
|
|
206
|
-
button: type === 'mouseWheel' ? 'none' : 'left',
|
|
207
|
-
clickCount: type === 'mouseWheel' ? 0 : 1,
|
|
208
|
-
...(type === 'mouseWheel'
|
|
209
|
-
? { deltaX: num(body.deltaX), deltaY: num(body.deltaY) }
|
|
210
|
-
: {}),
|
|
211
|
-
});
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
if (body.kind === 'key') {
|
|
215
|
-
const type = String(body.type);
|
|
216
|
-
if (type !== 'keyDown' && type !== 'keyUp') {
|
|
217
|
-
throw new Error(`unsupported key type ${type}`);
|
|
218
|
-
}
|
|
219
|
-
const key = String(body.key ?? '');
|
|
220
|
-
// A single printable character needs `text` as well, or the page sees the
|
|
221
|
-
// keypress but no character is inserted.
|
|
222
|
-
const text = key.length === 1 ? key : undefined;
|
|
223
|
-
await tab.send('Input.dispatchKeyEvent', {
|
|
224
|
-
type,
|
|
225
|
-
key,
|
|
226
|
-
code: String(body.code ?? ''),
|
|
227
|
-
...(text && type === 'keyDown' ? { text } : {}),
|
|
228
|
-
});
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
throw new Error('unknown event kind');
|
|
232
|
-
}
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Native browser-level network proxy for a capture's tab — separate from the
|
|
3
|
-
* attestor's own egress (`geoLocation`/`useProxy` on a published provider).
|
|
4
|
-
* Recommended for a Popcorn (`builder`-mode) remote browser session: applied
|
|
5
|
-
* via the Popcorn extension's `window.__pcn` page API so the browser stays
|
|
6
|
-
* in its existing kiosk-mode window instead of relaunching with a new proxy
|
|
7
|
-
* context — see `../../../cdp/browser-proxy.ts` for the mechanism.
|
|
8
|
-
*
|
|
9
|
-
* Username/password are ALWAYS explicit caller input — this module never
|
|
10
|
-
* reads `PROXY_PASSWORD` (there is no such getter) and never applies a
|
|
11
|
-
* proxy without the caller supplying credentials on the call itself. Env
|
|
12
|
-
* (`PROXY_SERVER`, `PROXY_USERNAME`) only supplies the vendor's routing
|
|
13
|
-
* endpoint and username TEMPLATE, for the "templated" convenience mode.
|
|
14
|
-
*/
|
|
15
|
-
import { badRequest } from '@hapi/boom';
|
|
16
|
-
import assert from 'node:assert';
|
|
17
|
-
import { applyBrowserProxy, buildTemplatedProxyUsername, clearBrowserProxy, generateProxySessionId, parseProxyServer, } from "../../../cdp/browser-proxy.js";
|
|
18
|
-
import { suggestCountryFromIp } from "../../../cdp/geo.js";
|
|
19
|
-
import { proxyServer, proxyUsernameTemplate } from "../../../consts.js";
|
|
20
|
-
import { defineTool } from "../../server.js";
|
|
21
|
-
/** `CdpTabClient.subscribe` is typed against a closed `CdpTabEventMap` of
|
|
22
|
-
* known capture-engine events; this proxy module deliberately listens for
|
|
23
|
-
* CDP `Fetch.*` events outside that map. Same shape of adapter as
|
|
24
|
-
* `client.ts`'s `flattenedSessionClient` sender — forwarding a dynamic
|
|
25
|
-
* method string the declared type doesn't literally cover. */
|
|
26
|
-
function tabProxySender(tabClient) {
|
|
27
|
-
return {
|
|
28
|
-
send: (method, params) => tabClient.send(method, params),
|
|
29
|
-
subscribe: (method, listener) => {
|
|
30
|
-
return tabClient.subscribe(method, listener);
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
const COUNTRY_CODE_PATTERN = '^[A-Za-z]{2}$';
|
|
35
|
-
export function proxyTools(captures) {
|
|
36
|
-
const requireCapture = (captureId) => {
|
|
37
|
-
const entry = captures.sessions.get(captureId);
|
|
38
|
-
assert(entry, badRequest(`no capture ${captureId} — call start_capture first `
|
|
39
|
-
+ '(it has been reset or never started).'));
|
|
40
|
-
return entry;
|
|
41
|
-
};
|
|
42
|
-
const applyTool = defineTool({
|
|
43
|
-
name: 'apply_browser_proxy',
|
|
44
|
-
description: 'Route this capture\'s browser tab through a native network proxy. '
|
|
45
|
-
+ 'Recommended for a Popcorn (builder-mode) remote browser session — '
|
|
46
|
-
+ 'applied via the Popcorn extension so the browser stays in its '
|
|
47
|
-
+ 'existing kiosk-mode window instead of relaunching. ALWAYS ask the '
|
|
48
|
-
+ 'developer for the proxy `password`, and for `username` when using '
|
|
49
|
-
+ '"full" mode — never invent or reuse a credential from elsewhere. '
|
|
50
|
-
+ 'Two username modes: "full" (caller supplies the exact string) or '
|
|
51
|
-
+ '"templated" (the server\'s PROXY_USERNAME env template combined '
|
|
52
|
-
+ 'with `countryCode` and `proxySessionId`). Missing `countryCode` '
|
|
53
|
-
+ '(templated mode only) and/or missing `proxySessionId` (always) '
|
|
54
|
-
+ 'come back bundled in ONE `needsChoice` response — a best-known/IP '
|
|
55
|
-
+ 'suggestion for country, and a random 10-character alphanumeric '
|
|
56
|
-
+ 'suggestion for the sticky-session id (deliberately NOT this '
|
|
57
|
-
+ 'capture\'s own id — vendors expect a short alphanumeric token, '
|
|
58
|
-
+ 'and the same value later becomes the attestor\'s proxySessionId '
|
|
59
|
-
+ 'too, so reusing it across calls keeps proofs on the same exit '
|
|
60
|
-
+ 'IP the browser used). Ask the developer to confirm or override '
|
|
61
|
-
+ 'each suggestion, then call again with them set. Reloads the '
|
|
62
|
-
+ 'page once applied, since proxy routing only affects new '
|
|
63
|
-
+ 'connections. Also arms proxy-auth handling over CDP `Fetch` '
|
|
64
|
-
+ '(Popcorn\'s extension only sets host/port routing, not '
|
|
65
|
-
+ 'credentials). Subsequent run_proof calls on this captureId '
|
|
66
|
-
+ 'automatically prove through the same proxy session/country.',
|
|
67
|
-
inputSchema: {
|
|
68
|
-
type: 'object',
|
|
69
|
-
properties: {
|
|
70
|
-
captureId: { type: 'string' },
|
|
71
|
-
password: { type: 'string', minLength: 1 },
|
|
72
|
-
username: { type: 'string', minLength: 1 },
|
|
73
|
-
usernameMode: { type: 'string', enum: ['full', 'templated'] },
|
|
74
|
-
countryCode: {
|
|
75
|
-
type: 'string',
|
|
76
|
-
pattern: COUNTRY_CODE_PATTERN,
|
|
77
|
-
description: '2-letter ISO country code, e.g. "US".',
|
|
78
|
-
},
|
|
79
|
-
proxySessionId: {
|
|
80
|
-
type: 'string',
|
|
81
|
-
minLength: 1,
|
|
82
|
-
maxLength: 64,
|
|
83
|
-
description: 'Sticky-session id — same value routes through the same '
|
|
84
|
-
+ 'exit IP, and later becomes the attestor\'s proxySessionId '
|
|
85
|
-
+ 'override too. Omit to get a random 10-character '
|
|
86
|
-
+ 'suggestion back instead of applying anything yet.',
|
|
87
|
-
},
|
|
88
|
-
host: {
|
|
89
|
-
type: 'string',
|
|
90
|
-
description: 'Override the PROXY_SERVER env default. Give both host and '
|
|
91
|
-
+ 'port, or neither.',
|
|
92
|
-
},
|
|
93
|
-
port: { type: 'number', minimum: 1, maximum: 65535 },
|
|
94
|
-
},
|
|
95
|
-
required: ['captureId', 'password'],
|
|
96
|
-
},
|
|
97
|
-
}, async (args) => {
|
|
98
|
-
const entry = requireCapture(args.captureId);
|
|
99
|
-
const mode = args.usernameMode ?? (args.username ? 'full' : 'templated');
|
|
100
|
-
let template;
|
|
101
|
-
if (mode === 'full') {
|
|
102
|
-
assert(args.username, badRequest('username is required when usernameMode is "full"'));
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
template = proxyUsernameTemplate();
|
|
106
|
-
assert(template, badRequest('usernameMode "templated" needs PROXY_USERNAME configured in '
|
|
107
|
-
+ 'the agent\'s environment. Use usernameMode "full" instead, or '
|
|
108
|
-
+ 'set PROXY_USERNAME.'));
|
|
109
|
-
}
|
|
110
|
-
const countryCode = args.countryCode;
|
|
111
|
-
const needsCountrySuggestion = mode === 'templated' && !countryCode;
|
|
112
|
-
const needsSessionIdSuggestion = !args.proxySessionId;
|
|
113
|
-
if (needsCountrySuggestion || needsSessionIdSuggestion) {
|
|
114
|
-
let suggestedCountryCode;
|
|
115
|
-
if (needsCountrySuggestion) {
|
|
116
|
-
const suggestion = await suggestCountryFromIp();
|
|
117
|
-
suggestedCountryCode = suggestion.countryCode;
|
|
118
|
-
}
|
|
119
|
-
return {
|
|
120
|
-
needsChoice: true,
|
|
121
|
-
message: 'Confirm or override the suggestion(s) below, then call '
|
|
122
|
-
+ 'apply_browser_proxy again with them set.',
|
|
123
|
-
...(suggestedCountryCode
|
|
124
|
-
? { suggestedCountryCode, suggestionSource: 'caller-ip' }
|
|
125
|
-
: {}),
|
|
126
|
-
...(needsSessionIdSuggestion
|
|
127
|
-
? { suggestedProxySessionId: generateProxySessionId() }
|
|
128
|
-
: {}),
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
// Non-null: the `needsChoice` branch above already returned for every
|
|
132
|
-
// case where `proxySessionId`/`username`/`template`/`countryCode`
|
|
133
|
-
// (for the applicable mode) could be missing — TS can't see that
|
|
134
|
-
// across the bundled `needsCountrySuggestion || needsSessionIdSuggestion`
|
|
135
|
-
// check, since it narrows through direct property tests, not through
|
|
136
|
-
// intermediate boolean variables.
|
|
137
|
-
const proxySessionId = args.proxySessionId;
|
|
138
|
-
const username = mode === 'full'
|
|
139
|
-
? args.username
|
|
140
|
-
: buildTemplatedProxyUsername({
|
|
141
|
-
template: template, countryCode: countryCode, proxySessionId,
|
|
142
|
-
});
|
|
143
|
-
let server;
|
|
144
|
-
if (args.host && args.port) {
|
|
145
|
-
server = { host: args.host, port: args.port };
|
|
146
|
-
}
|
|
147
|
-
else {
|
|
148
|
-
const raw = proxyServer();
|
|
149
|
-
assert(raw, badRequest('No host/port given and PROXY_SERVER is not configured in the '
|
|
150
|
-
+ 'agent\'s environment.'));
|
|
151
|
-
server = parseProxyServer(raw);
|
|
152
|
-
}
|
|
153
|
-
const { appliedViaExtension } = await applyBrowserProxy(tabProxySender(entry.tabClient), {
|
|
154
|
-
host: server.host,
|
|
155
|
-
port: server.port,
|
|
156
|
-
username,
|
|
157
|
-
password: args.password,
|
|
158
|
-
});
|
|
159
|
-
entry.browserProxy = {
|
|
160
|
-
proxySessionId,
|
|
161
|
-
...(countryCode ? { geoLocation: countryCode } : {}),
|
|
162
|
-
};
|
|
163
|
-
return {
|
|
164
|
-
applied: true,
|
|
165
|
-
appliedViaExtension,
|
|
166
|
-
host: server.host,
|
|
167
|
-
port: server.port,
|
|
168
|
-
maskedUsername: maskUsername(username),
|
|
169
|
-
...(countryCode ? { countryCode } : {}),
|
|
170
|
-
proxySessionId,
|
|
171
|
-
};
|
|
172
|
-
});
|
|
173
|
-
const clearTool = defineTool({
|
|
174
|
-
name: 'clear_browser_proxy',
|
|
175
|
-
description: 'Undo apply_browser_proxy for this capture: route its tab direct '
|
|
176
|
-
+ 'again, reload the page, and stop overriding run_proof\'s '
|
|
177
|
-
+ 'geoLocation/proxySessionId.',
|
|
178
|
-
inputSchema: {
|
|
179
|
-
type: 'object',
|
|
180
|
-
properties: { captureId: { type: 'string' } },
|
|
181
|
-
required: ['captureId'],
|
|
182
|
-
},
|
|
183
|
-
}, async ({ captureId }) => {
|
|
184
|
-
const entry = requireCapture(captureId);
|
|
185
|
-
const sender = tabProxySender(entry.tabClient);
|
|
186
|
-
const { cleared } = await clearBrowserProxy(sender);
|
|
187
|
-
delete entry.browserProxy;
|
|
188
|
-
return { cleared };
|
|
189
|
-
});
|
|
190
|
-
return [applyTool, clearTool];
|
|
191
|
-
}
|
|
192
|
-
/** Never echo a full proxy username verbatim in a tool result — it may embed
|
|
193
|
-
* the vendor account id. Short strings mask fully rather than leaking most
|
|
194
|
-
* of themselves through the "keep first/last 4" rule. */
|
|
195
|
-
function maskUsername(username) {
|
|
196
|
-
return username.length <= 8
|
|
197
|
-
? '*'.repeat(username.length)
|
|
198
|
-
: `${username.slice(0, 4)}…${username.slice(-4)}`;
|
|
199
|
-
}
|