@xevy/heny-connect 0.2.0 → 0.4.0
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 +48 -34
- package/bin/heny-connect.mjs +61 -54
- package/lib/browser-controller.mjs +377 -0
- package/lib/cdp.mjs +95 -0
- package/lib/computer-runtime.mjs +68 -0
- package/lib/network-policy.mjs +102 -0
- package/lib/process-runner.mjs +62 -0
- package/lib/state.mjs +54 -0
- package/lib/validating-proxy.mjs +70 -0
- package/lib/work-folder.mjs +129 -0
- package/lib/worker.mjs +251 -0
- package/package.json +4 -3
- package/windows/heny-connect-tray.ps1 +112 -154
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { platform } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { CdpSession } from "./cdp.mjs";
|
|
8
|
+
import { parsePublicUrl, resolvePublicUrl } from "./network-policy.mjs";
|
|
9
|
+
import { createValidatingProxy } from "./validating-proxy.mjs";
|
|
10
|
+
|
|
11
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
const DENIED_PERMISSIONS = ["geolocation", "notifications", "midi", "midiSysex", "camera", "microphone", "backgroundSync", "sensors", "clipboardReadWrite", "clipboardSanitizedWrite", "paymentHandler", "idleDetection", "localFonts", "windowManagement"];
|
|
13
|
+
|
|
14
|
+
async function randomPort() {
|
|
15
|
+
const server = net.createServer();
|
|
16
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
|
17
|
+
const port = server.address().port;
|
|
18
|
+
await new Promise((resolve) => server.close(resolve));
|
|
19
|
+
return port;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function browserCandidates() {
|
|
23
|
+
const local = process.env.LOCALAPPDATA;
|
|
24
|
+
const programs = process.env.PROGRAMFILES;
|
|
25
|
+
const programs86 = process.env["PROGRAMFILES(X86)"];
|
|
26
|
+
return [
|
|
27
|
+
process.env.HENY_BROWSER_PATH,
|
|
28
|
+
local && join(local, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
29
|
+
programs && join(programs, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
30
|
+
programs86 && join(programs86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
31
|
+
local && join(local, "Google", "Chrome", "Application", "chrome.exe"),
|
|
32
|
+
programs && join(programs, "Google", "Chrome", "Application", "chrome.exe"),
|
|
33
|
+
programs86 && join(programs86, "Google", "Chrome", "Application", "chrome.exe"),
|
|
34
|
+
].filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findBrowser() {
|
|
38
|
+
const browser = browserCandidates().find((candidate) => existsSync(candidate));
|
|
39
|
+
if (!browser) throw Object.assign(new Error("Install Microsoft Edge or Google Chrome."), { code: "browser_missing" });
|
|
40
|
+
return browser;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class BrowserController {
|
|
44
|
+
constructor({ home, workRoot = join(home, "work"), browserPath = findBrowser(), lookup } = {}) {
|
|
45
|
+
this.home = home;
|
|
46
|
+
this.workRoot = workRoot;
|
|
47
|
+
this.browserPath = browserPath;
|
|
48
|
+
this.lookup = lookup;
|
|
49
|
+
this.process = null;
|
|
50
|
+
this.proxy = null;
|
|
51
|
+
this.session = null;
|
|
52
|
+
this.targetId = null;
|
|
53
|
+
this.mainFrameId = null;
|
|
54
|
+
this.generation = 0;
|
|
55
|
+
this.references = new Map();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async start() {
|
|
59
|
+
if (this.session) return;
|
|
60
|
+
const profile = join(this.home, "browser-profile");
|
|
61
|
+
await mkdir(profile, { recursive: true });
|
|
62
|
+
this.proxy = await createValidatingProxy({ lookup: this.lookup });
|
|
63
|
+
const port = await randomPort();
|
|
64
|
+
const args = [
|
|
65
|
+
`--remote-debugging-address=127.0.0.1`,
|
|
66
|
+
`--remote-debugging-port=${port}`,
|
|
67
|
+
`--user-data-dir=${profile}`,
|
|
68
|
+
`--proxy-server=http://127.0.0.1:${this.proxy.port}`,
|
|
69
|
+
"--proxy-bypass-list=<-loopback>",
|
|
70
|
+
"--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1",
|
|
71
|
+
"--disable-quic",
|
|
72
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
73
|
+
"--no-first-run",
|
|
74
|
+
"--no-default-browser-check",
|
|
75
|
+
"--disable-sync",
|
|
76
|
+
"--disable-background-networking",
|
|
77
|
+
"--disable-component-update",
|
|
78
|
+
"--disable-features=ExternalProtocolDialog",
|
|
79
|
+
"--disable-notifications",
|
|
80
|
+
"--disable-extensions",
|
|
81
|
+
"--disable-session-crashed-bubble",
|
|
82
|
+
"--no-service-autorun",
|
|
83
|
+
"--new-window",
|
|
84
|
+
"about:blank",
|
|
85
|
+
];
|
|
86
|
+
this.process = spawn(this.browserPath, args, { stdio: "ignore", windowsHide: false });
|
|
87
|
+
const browserProcess = this.process;
|
|
88
|
+
this.process.once("exit", () => {
|
|
89
|
+
if (this.process === browserProcess) this.process = null;
|
|
90
|
+
if (this.session) { const session = this.session; this.session = null; session.close(); }
|
|
91
|
+
if (this.proxy) { const proxy = this.proxy; this.proxy = null; void proxy.close().catch(() => undefined); }
|
|
92
|
+
});
|
|
93
|
+
let version;
|
|
94
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(500) });
|
|
97
|
+
if (response.ok) { version = await response.json(); break; }
|
|
98
|
+
} catch {}
|
|
99
|
+
await sleep(250);
|
|
100
|
+
}
|
|
101
|
+
if (!version) { await this.stop(); throw Object.assign(new Error("The dedicated browser did not start."), { code: "browser_start_failed" }); }
|
|
102
|
+
let targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
|
|
103
|
+
let target = targets.find((entry) => entry.type === "page");
|
|
104
|
+
if (!target) target = await (await fetch(`http://127.0.0.1:${port}/json/new?about%3Ablank`, { method: "PUT" })).json();
|
|
105
|
+
this.targetId = target.id;
|
|
106
|
+
this.session = await CdpSession.open(target.webSocketDebuggerUrl);
|
|
107
|
+
const connectedSession = this.session;
|
|
108
|
+
this.session.onClose(() => {
|
|
109
|
+
if (this.session !== connectedSession) return;
|
|
110
|
+
this.session = null;
|
|
111
|
+
this.targetId = null;
|
|
112
|
+
this.mainFrameId = null;
|
|
113
|
+
void this.stop();
|
|
114
|
+
});
|
|
115
|
+
await Promise.all([
|
|
116
|
+
this.session.send("Page.enable"),
|
|
117
|
+
this.session.send("Runtime.enable"),
|
|
118
|
+
this.session.send("Fetch.enable", { patterns: [{ urlPattern: "*", requestStage: "Request" }] }),
|
|
119
|
+
this.session.send("Target.setDiscoverTargets", { discover: true }),
|
|
120
|
+
this.session.send("Browser.setDownloadBehavior", { behavior: "deny" }).catch(() => undefined),
|
|
121
|
+
this.session.send("Browser.resetPermissions").catch(() => undefined),
|
|
122
|
+
this.session.send("Page.setInterceptFileChooserDialog", { enabled: true }).catch(() => undefined),
|
|
123
|
+
...DENIED_PERMISSIONS.map((name) => this.session.send("Browser.setPermission", { permission: { name }, setting: "denied" }).catch(() => undefined)),
|
|
124
|
+
]);
|
|
125
|
+
const frameTree = await this.session.send("Page.getFrameTree");
|
|
126
|
+
this.mainFrameId = frameTree.frameTree.frame.id;
|
|
127
|
+
this.session.on("Fetch.requestPaused", async ({ requestId, request }) => {
|
|
128
|
+
try {
|
|
129
|
+
await resolvePublicUrl(request.url, this.lookup);
|
|
130
|
+
await this.session?.send("Fetch.continueRequest", { requestId });
|
|
131
|
+
} catch {
|
|
132
|
+
await this.session?.send("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
this.session.on("Page.javascriptDialogOpening", () => this.session?.send("Page.handleJavaScriptDialog", { accept: false }).catch(() => undefined));
|
|
136
|
+
this.session.on("Page.fileChooserOpened", () => undefined);
|
|
137
|
+
this.session.on("Page.frameRequestedNavigation", ({ frameId, url }) => {
|
|
138
|
+
let protocol = "";
|
|
139
|
+
try { protocol = new URL(url).protocol; } catch {}
|
|
140
|
+
const external = !["http:", "https:", "about:", "data:", "blob:"].includes(protocol);
|
|
141
|
+
const blockedMainFrame = frameId === this.mainFrameId && !["http:", "https:"].includes(protocol);
|
|
142
|
+
if (external || blockedMainFrame) {
|
|
143
|
+
void this.session?.send("Page.stopLoading").catch(() => undefined);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
this.session.on("Page.frameNavigated", ({ frame }) => {
|
|
147
|
+
if (frame?.id === this.mainFrameId) void this.clearReferences();
|
|
148
|
+
});
|
|
149
|
+
this.session.on("Target.targetCreated", ({ targetInfo }) => {
|
|
150
|
+
if (targetInfo?.type === "page" && targetInfo.targetId !== this.targetId) void this.session?.send("Target.closeTarget", { targetId: targetInfo.targetId }).catch(() => undefined);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async execute(action, payload, { signal, timeoutMs }) {
|
|
155
|
+
if (signal?.aborted) throw Object.assign(new Error("Command cancelled."), { code: "command_cancelled" });
|
|
156
|
+
await this.start();
|
|
157
|
+
if (signal?.aborted) throw Object.assign(new Error("Command cancelled."), { code: "command_cancelled" });
|
|
158
|
+
const onAbort = () => { void this.session?.send("Page.stopLoading").catch(() => undefined); };
|
|
159
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
160
|
+
try {
|
|
161
|
+
const operations = {
|
|
162
|
+
"browser.navigate": () => this.navigate(payload.url, timeoutMs, signal),
|
|
163
|
+
"browser.snapshot": () => this.snapshot(),
|
|
164
|
+
"browser.read": () => this.read(),
|
|
165
|
+
"browser.screenshot": () => this.screenshot(),
|
|
166
|
+
"browser.click": () => this.click(payload.ref),
|
|
167
|
+
"browser.fill": () => this.fill(payload.ref, payload.value),
|
|
168
|
+
"browser.select": () => this.select(payload.ref, payload.value),
|
|
169
|
+
"browser.press": () => this.press(payload.key),
|
|
170
|
+
"browser.scroll": () => this.scroll(payload.direction, payload.amount),
|
|
171
|
+
"browser.wait": () => this.wait(payload.milliseconds, signal),
|
|
172
|
+
"browser.back": () => this.back(timeoutMs, signal),
|
|
173
|
+
"browser.upload": () => this.upload(payload.ref, payload.filePath),
|
|
174
|
+
"browser.download": () => this.download(payload.ref, timeoutMs, signal),
|
|
175
|
+
};
|
|
176
|
+
const operation = operations[action]?.() ?? Promise.reject(Object.assign(new Error("Unsupported command."), { code: "unsupported_action" }));
|
|
177
|
+
const cancelled = new Promise((_, reject) => signal?.addEventListener("abort", () => reject(Object.assign(new Error("Command cancelled."), { code: "command_cancelled" })), { once: true }));
|
|
178
|
+
return await Promise.race([operation, cancelled]);
|
|
179
|
+
} finally {
|
|
180
|
+
signal?.removeEventListener("abort", onAbort);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async pageIdentity() {
|
|
185
|
+
const result = await this.session.send("Runtime.evaluate", {
|
|
186
|
+
expression: "({title:String(document.title||'').slice(0,1000),url:String(location.href)})",
|
|
187
|
+
returnByValue: true,
|
|
188
|
+
});
|
|
189
|
+
return result.result.value;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async navigate(value, timeoutMs, signal) {
|
|
193
|
+
const url = parsePublicUrl(value);
|
|
194
|
+
await resolvePublicUrl(url.href, this.lookup);
|
|
195
|
+
const navigationAbort = new AbortController();
|
|
196
|
+
const abortNavigation = () => navigationAbort.abort();
|
|
197
|
+
signal?.addEventListener("abort", abortNavigation, { once: true });
|
|
198
|
+
const loaded = this.session.waitFor("Page.loadEventFired", timeoutMs, navigationAbort.signal).then(
|
|
199
|
+
() => ({ error: null }),
|
|
200
|
+
(error) => ({ error }),
|
|
201
|
+
);
|
|
202
|
+
try {
|
|
203
|
+
const result = await this.session.send("Page.navigate", { url: url.href }, timeoutMs);
|
|
204
|
+
if (result.errorText) throw Object.assign(new Error("Navigation failed."), { code: "navigation_failed" });
|
|
205
|
+
const loadOutcome = await loaded;
|
|
206
|
+
if (loadOutcome.error) throw loadOutcome.error;
|
|
207
|
+
const identity = await this.pageIdentity();
|
|
208
|
+
parsePublicUrl(identity.url);
|
|
209
|
+
return identity;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
navigationAbort.abort();
|
|
212
|
+
await loaded;
|
|
213
|
+
throw error;
|
|
214
|
+
} finally {
|
|
215
|
+
signal?.removeEventListener("abort", abortNavigation);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async clearReferences() {
|
|
220
|
+
for (const objectId of this.references.values()) await this.session?.send("Runtime.releaseObject", { objectId }).catch(() => undefined);
|
|
221
|
+
this.references.clear();
|
|
222
|
+
this.generation += 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async snapshot() {
|
|
226
|
+
await this.clearReferences();
|
|
227
|
+
const evaluated = await this.session.send("Runtime.evaluate", {
|
|
228
|
+
expression: `Array.from(document.querySelectorAll('a[href],button,input,textarea,select,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="textbox"],[tabindex]')).filter(el=>{const r=el.getBoundingClientRect();const s=getComputedStyle(el);return r.width>0&&r.height>0&&s.visibility!=="hidden"&&s.display!=="none"&&!el.disabled}).slice(0,500)`,
|
|
229
|
+
returnByValue: false,
|
|
230
|
+
});
|
|
231
|
+
const arrayId = evaluated.result.objectId;
|
|
232
|
+
if (!arrayId) throw Object.assign(new Error("The page did not return an interactive snapshot."), { code: "snapshot_failed" });
|
|
233
|
+
const properties = await this.session.send("Runtime.getProperties", { objectId: arrayId, ownProperties: true });
|
|
234
|
+
const elements = [];
|
|
235
|
+
for (const property of properties.result ?? []) {
|
|
236
|
+
if (!/^\d+$/.test(property.name) || !property.value?.objectId) continue;
|
|
237
|
+
const objectId = property.value.objectId;
|
|
238
|
+
const details = await this.session.send("Runtime.callFunctionOn", {
|
|
239
|
+
objectId,
|
|
240
|
+
functionDeclaration: `function(){const role=this.getAttribute('role')||({A:'link',BUTTON:'button',INPUT:(this.type||'textbox'),TEXTAREA:'textbox',SELECT:'combobox'}[this.tagName]||this.tagName.toLowerCase());const name=(this.getAttribute('aria-label')||this.innerText||this.value||this.getAttribute('title')||this.getAttribute('placeholder')||'').replace(/\s+/g,' ').trim().slice(0,500);return {role:String(role).slice(0,80),name,value:('value'in this?String(this.value).slice(0,2000):undefined)}}`,
|
|
241
|
+
returnByValue: true,
|
|
242
|
+
});
|
|
243
|
+
const ref = `e${elements.length + 1}`;
|
|
244
|
+
this.references.set(ref, objectId);
|
|
245
|
+
elements.push({ ref, ...details.result.value });
|
|
246
|
+
}
|
|
247
|
+
await this.session.send("Runtime.releaseObject", { objectId: arrayId }).catch(() => undefined);
|
|
248
|
+
const identity = await this.pageIdentity();
|
|
249
|
+
parsePublicUrl(identity.url);
|
|
250
|
+
return { ...identity, generation: this.generation, elements, truncated: (properties.result ?? []).filter((property) => /^\d+$/.test(property.name)).length >= 500 };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
element(ref) {
|
|
254
|
+
const objectId = this.references.get(ref);
|
|
255
|
+
if (!objectId) throw Object.assign(new Error("This element reference is stale. Take a new browser snapshot."), { code: "stale_element_ref" });
|
|
256
|
+
return objectId;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async callElement(ref, functionDeclaration, arguments_ = []) {
|
|
260
|
+
const result = await this.session.send("Runtime.callFunctionOn", { objectId: this.element(ref), functionDeclaration, arguments: arguments_.map((value) => ({ value })), awaitPromise: true, returnByValue: true });
|
|
261
|
+
if (result.exceptionDetails) throw Object.assign(new Error("The page interaction failed."), { code: "interaction_failed" });
|
|
262
|
+
await sleep(250);
|
|
263
|
+
return this.pageIdentity();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
click(ref) {
|
|
267
|
+
return this.callElement(ref, "function(){this.scrollIntoView({block:'center'});this.focus();this.click();return true}");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
fill(ref, value) {
|
|
271
|
+
return this.callElement(ref, "function(value){this.scrollIntoView({block:'center'});this.focus();const p=this instanceof HTMLTextAreaElement?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;const setter=Object.getOwnPropertyDescriptor(p,'value')?.set;if(!setter)throw new Error('not fillable');setter.call(this,value);this.dispatchEvent(new Event('input',{bubbles:true}));this.dispatchEvent(new Event('change',{bubbles:true}));return true}", [value]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
select(ref, value) {
|
|
275
|
+
return this.callElement(ref, "function(value){if(!(this instanceof HTMLSelectElement))throw new Error('not selectable');this.focus();this.value=value;this.dispatchEvent(new Event('input',{bubbles:true}));this.dispatchEvent(new Event('change',{bubbles:true}));return true}", [value]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async press(key) {
|
|
279
|
+
const text = key.length === 1 ? key : "";
|
|
280
|
+
const code = key.length === 1 ? `Key${key.toUpperCase()}` : key;
|
|
281
|
+
await this.session.send("Input.dispatchKeyEvent", { type: "keyDown", key, code, text });
|
|
282
|
+
await this.session.send("Input.dispatchKeyEvent", { type: "keyUp", key, code });
|
|
283
|
+
await sleep(250);
|
|
284
|
+
return this.pageIdentity();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async scroll(direction, amount = 700) {
|
|
288
|
+
await this.session.send("Runtime.evaluate", { expression: `window.scrollBy({top:${direction === "up" ? -Math.abs(amount) : Math.abs(amount)},behavior:'auto'});true`, returnByValue: true });
|
|
289
|
+
return this.pageIdentity();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async wait(milliseconds, signal) {
|
|
293
|
+
await Promise.race([
|
|
294
|
+
sleep(milliseconds),
|
|
295
|
+
new Promise((_, reject) => signal?.addEventListener("abort", () => reject(Object.assign(new Error("Command cancelled."), { code: "command_cancelled" })), { once: true })),
|
|
296
|
+
]);
|
|
297
|
+
return this.pageIdentity();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async back(timeoutMs, signal) {
|
|
301
|
+
const history = await this.session.send("Page.getNavigationHistory");
|
|
302
|
+
const entry = history.entries?.[history.currentIndex - 1];
|
|
303
|
+
if (!entry) return this.pageIdentity();
|
|
304
|
+
const loaded = this.session.waitFor("Page.loadEventFired", timeoutMs, signal).catch(() => undefined);
|
|
305
|
+
await this.session.send("Page.navigateToHistoryEntry", { entryId: entry.id });
|
|
306
|
+
await loaded;
|
|
307
|
+
return this.pageIdentity();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async upload(ref, filePath) {
|
|
311
|
+
if (typeof filePath !== "string") throw Object.assign(new Error("Choose a file from the Agent work folder."), { code: "invalid_upload_path" });
|
|
312
|
+
await this.session.send("DOM.setFileInputFiles", { objectId: this.element(ref), files: [filePath] });
|
|
313
|
+
return this.pageIdentity();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async download(ref, timeoutMs, signal) {
|
|
317
|
+
const downloadRoot = join(this.workRoot, "downloads");
|
|
318
|
+
await mkdir(downloadRoot, { recursive: true, mode: 0o700 });
|
|
319
|
+
await this.session.send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: downloadRoot, eventsEnabled: true });
|
|
320
|
+
try {
|
|
321
|
+
const begin = this.session.waitFor("Browser.downloadWillBegin", timeoutMs, signal);
|
|
322
|
+
await this.click(ref);
|
|
323
|
+
const started = await begin;
|
|
324
|
+
let progress;
|
|
325
|
+
do { progress = await this.session.waitFor("Browser.downloadProgress", timeoutMs, signal); } while (progress.guid !== started.guid || progress.state === "inProgress");
|
|
326
|
+
if (progress.state !== "completed") throw Object.assign(new Error("The browser download did not complete."), { code: "download_failed" });
|
|
327
|
+
const target = join(downloadRoot, started.suggestedFilename);
|
|
328
|
+
const info = await stat(target);
|
|
329
|
+
return { ...(await this.pageIdentity()), download: { path: `downloads/${started.suggestedFilename}`, bytes: info.size } };
|
|
330
|
+
} finally {
|
|
331
|
+
await this.session?.send("Browser.setDownloadBehavior", { behavior: "deny" }).catch(() => undefined);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async read() {
|
|
336
|
+
const result = await this.session.send("Runtime.evaluate", {
|
|
337
|
+
expression: `(()=>{const raw=(document.body?.innerText||"").replace(/\\r/g,"").replace(/[ \\t]+/g," ").replace(/\\n{3,}/g,"\\n\\n").trim();return {title:String(document.title||"").slice(0,1000),url:String(location.href),text:raw.slice(0,50000),truncated:raw.length>50000}})()`,
|
|
338
|
+
returnByValue: true,
|
|
339
|
+
});
|
|
340
|
+
parsePublicUrl(result.result.value.url);
|
|
341
|
+
return result.result.value;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async screenshot() {
|
|
345
|
+
const identity = await this.pageIdentity();
|
|
346
|
+
parsePublicUrl(identity.url);
|
|
347
|
+
const metrics = await this.session.send("Page.getLayoutMetrics");
|
|
348
|
+
const viewport = metrics.cssVisualViewport || metrics.visualViewport;
|
|
349
|
+
const result = await this.session.send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false });
|
|
350
|
+
if (Buffer.byteLength(result.data, "base64") > 2 * 1024 * 1024) throw Object.assign(new Error("Screenshot is too large."), { code: "screenshot_too_large" });
|
|
351
|
+
return { ...identity, mimeType: "image/png", dataBase64: result.data, width: Math.round(viewport.clientWidth), height: Math.round(viewport.clientHeight) };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async stop() {
|
|
355
|
+
const session = this.session;
|
|
356
|
+
this.session = null;
|
|
357
|
+
this.mainFrameId = null;
|
|
358
|
+
await this.clearReferences();
|
|
359
|
+
const browserProcess = this.process;
|
|
360
|
+
this.process = null;
|
|
361
|
+
if (browserProcess) {
|
|
362
|
+
const exited = browserProcess.exitCode !== null;
|
|
363
|
+
const exit = exited ? Promise.resolve(true) : new Promise((resolve) => browserProcess.once("exit", () => resolve(true)));
|
|
364
|
+
if (session) await session.send("Browser.close", {}, 2_000).catch(() => undefined);
|
|
365
|
+
const closed = exited || await Promise.race([exit, sleep(3_000).then(() => false)]);
|
|
366
|
+
if (!closed) {
|
|
367
|
+
if (platform() === "win32" && browserProcess.pid) spawnSync("taskkill.exe", ["/PID", String(browserProcess.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
368
|
+
else browserProcess.kill();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
session?.close();
|
|
372
|
+
if (this.proxy) {
|
|
373
|
+
await this.proxy.close().catch(() => undefined);
|
|
374
|
+
this.proxy = null;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
package/lib/cdp.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export class CdpSession {
|
|
2
|
+
constructor(socket) {
|
|
3
|
+
this.socket = socket;
|
|
4
|
+
this.id = 0;
|
|
5
|
+
this.pending = new Map();
|
|
6
|
+
this.listeners = new Map();
|
|
7
|
+
this.closeListeners = new Set();
|
|
8
|
+
this.closed = false;
|
|
9
|
+
socket.onmessage = (event) => this.onMessage(JSON.parse(typeof event.data === "string" ? event.data : event.data.toString()));
|
|
10
|
+
socket.onerror = () => this.onClosed();
|
|
11
|
+
socket.onclose = () => this.onClosed();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
onClosed() {
|
|
15
|
+
if (this.closed) return;
|
|
16
|
+
this.closed = true;
|
|
17
|
+
const error = Object.assign(new Error("Dedicated browser closed."), { code: "browser_closed" });
|
|
18
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
19
|
+
this.pending.clear();
|
|
20
|
+
for (const listener of this.closeListeners) void listener(error);
|
|
21
|
+
this.closeListeners.clear();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static async open(url) {
|
|
25
|
+
if (typeof WebSocket !== "function") throw Object.assign(new Error("Heny Connect requires Node 22 or newer."), { code: "node_unsupported" });
|
|
26
|
+
const socket = new WebSocket(url);
|
|
27
|
+
await new Promise((resolve, reject) => {
|
|
28
|
+
socket.onopen = resolve;
|
|
29
|
+
socket.onerror = () => reject(Object.assign(new Error("Dedicated browser connection failed."), { code: "browser_connection_failed" }));
|
|
30
|
+
});
|
|
31
|
+
return new CdpSession(socket);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onMessage(message) {
|
|
35
|
+
if (message.id && this.pending.has(message.id)) {
|
|
36
|
+
const pending = this.pending.get(message.id);
|
|
37
|
+
this.pending.delete(message.id);
|
|
38
|
+
if (message.error) pending.reject(Object.assign(new Error(message.error.message), { code: "browser_protocol_error" }));
|
|
39
|
+
else pending.resolve(message.result);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (message.method) {
|
|
43
|
+
for (const listener of this.listeners.get(message.method) || []) void listener(message.params || {});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
on(method, listener) {
|
|
48
|
+
const entries = this.listeners.get(method) || [];
|
|
49
|
+
entries.push(listener);
|
|
50
|
+
this.listeners.set(method, entries);
|
|
51
|
+
return () => this.listeners.set(method, entries.filter((entry) => entry !== listener));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onClose(listener) {
|
|
55
|
+
if (this.closed) { void listener(Object.assign(new Error("Dedicated browser closed."), { code: "browser_closed" })); return () => undefined; }
|
|
56
|
+
this.closeListeners.add(listener);
|
|
57
|
+
return () => this.closeListeners.delete(listener);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
send(method, params = {}, timeoutMs = 20_000) {
|
|
61
|
+
if (this.closed) return Promise.reject(Object.assign(new Error("Dedicated browser closed."), { code: "browser_closed" }));
|
|
62
|
+
const id = ++this.id;
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
this.pending.delete(id);
|
|
66
|
+
reject(Object.assign(new Error("Dedicated browser timed out."), { code: "browser_timeout" }));
|
|
67
|
+
}, timeoutMs);
|
|
68
|
+
this.pending.set(id, {
|
|
69
|
+
resolve: (value) => { clearTimeout(timer); resolve(value); },
|
|
70
|
+
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
71
|
+
});
|
|
72
|
+
try { this.socket.send(JSON.stringify({ id, method, params })); } catch { this.onClosed(); }
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
waitFor(method, timeoutMs, signal) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const done = (error, value) => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
remove();
|
|
81
|
+
signal?.removeEventListener("abort", aborted);
|
|
82
|
+
error ? reject(error) : resolve(value);
|
|
83
|
+
};
|
|
84
|
+
const remove = this.on(method, (params) => done(null, params));
|
|
85
|
+
const timer = setTimeout(() => done(Object.assign(new Error("Dedicated browser timed out."), { code: "browser_timeout" })), timeoutMs);
|
|
86
|
+
const aborted = () => done(Object.assign(new Error("Command cancelled."), { code: "command_cancelled" }));
|
|
87
|
+
if (signal?.aborted) queueMicrotask(aborted);
|
|
88
|
+
else signal?.addEventListener("abort", aborted, { once: true });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
close() {
|
|
93
|
+
try { this.socket.close(); } finally { this.onClosed(); }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { BrowserController } from "./browser-controller.mjs";
|
|
4
|
+
import { runProcess } from "./process-runner.mjs";
|
|
5
|
+
import { WorkFolder } from "./work-folder.mjs";
|
|
6
|
+
|
|
7
|
+
function safeRuntimeKey(value) {
|
|
8
|
+
if (value === "workspace") return value;
|
|
9
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{20,80}$/.test(value)) throw Object.assign(new Error("The Computer assignment runtime is invalid."), { code: "runtime_key_invalid" });
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ComputerRuntime {
|
|
14
|
+
constructor({ home, browserFactory = (options) => new BrowserController(options) }) {
|
|
15
|
+
this.home = home;
|
|
16
|
+
this.browserFactory = browserFactory;
|
|
17
|
+
this.runtimes = new Map();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async runtime(key) {
|
|
21
|
+
const safeKey = safeRuntimeKey(key);
|
|
22
|
+
if (this.runtimes.has(safeKey)) return this.runtimes.get(safeKey);
|
|
23
|
+
const root = join(this.home, "agents", safeKey);
|
|
24
|
+
const workRoot = join(root, "work");
|
|
25
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
26
|
+
const work = new WorkFolder(workRoot);
|
|
27
|
+
await work.start();
|
|
28
|
+
const browser = this.browserFactory({ home: root, workRoot });
|
|
29
|
+
const runtime = { root, work, browser };
|
|
30
|
+
this.runtimes.set(safeKey, runtime);
|
|
31
|
+
return runtime;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async start() {
|
|
35
|
+
const runtime = await this.runtime("workspace");
|
|
36
|
+
await runtime.browser.start();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async execute(command, { signal, timeoutMs }) {
|
|
40
|
+
const runtime = await this.runtime(command.runtime?.key ?? "workspace");
|
|
41
|
+
if (command.action === "browser.upload") {
|
|
42
|
+
const resolved = await runtime.work.resolvePath(command.payload.path);
|
|
43
|
+
return runtime.browser.execute(command.action, { ref: command.payload.ref, filePath: resolved.target }, { signal, timeoutMs });
|
|
44
|
+
}
|
|
45
|
+
if (command.action.startsWith("browser.")) return runtime.browser.execute(command.action, command.payload, { signal, timeoutMs });
|
|
46
|
+
if (command.action === "file.list") return runtime.work.list(command.payload.path);
|
|
47
|
+
if (command.action === "file.read") return runtime.work.read(command.payload.path);
|
|
48
|
+
if (command.action === "file.write") return runtime.work.write(command.payload.path, command.payload.content);
|
|
49
|
+
if (command.action === "file.mkdir") return runtime.work.mkdir(command.payload.path);
|
|
50
|
+
if (command.action === "file.delete") return runtime.work.delete(command.payload.path);
|
|
51
|
+
if (command.action === "process.run") return runProcess({ ...command.payload, cwd: runtime.work.root, signal });
|
|
52
|
+
throw Object.assign(new Error("Unsupported Computer command."), { code: "unsupported_action" });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async synchronise(assignments) {
|
|
56
|
+
const active = new Set((assignments ?? []).map((assignment) => safeRuntimeKey(assignment.key)));
|
|
57
|
+
for (const [key, runtime] of this.runtimes) {
|
|
58
|
+
if (key === "workspace" || active.has(key)) continue;
|
|
59
|
+
await runtime.browser.stop().catch(() => undefined);
|
|
60
|
+
this.runtimes.delete(key);
|
|
61
|
+
await rm(runtime.root, { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async stop() {
|
|
66
|
+
await Promise.all([...this.runtimes.values()].map((runtime) => runtime.browser.stop().catch(() => undefined)));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
|
|
4
|
+
function ipv4Number(address) {
|
|
5
|
+
const parts = address.split(".").map(Number);
|
|
6
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
|
|
7
|
+
return parts.reduce((value, part) => (value * 256) + part, 0) >>> 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function ipv4In(address, base, bits) {
|
|
11
|
+
const value = ipv4Number(address);
|
|
12
|
+
const start = ipv4Number(base);
|
|
13
|
+
if (value === null || start === null) return false;
|
|
14
|
+
if (bits === 0) return true;
|
|
15
|
+
const mask = (0xffffffff << (32 - bits)) >>> 0;
|
|
16
|
+
return (value & mask) === (start & mask);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function expandIpv6(address) {
|
|
20
|
+
let value = address.toLowerCase().split("%")[0];
|
|
21
|
+
if (value.includes(".")) {
|
|
22
|
+
const lastColon = value.lastIndexOf(":");
|
|
23
|
+
const v4 = ipv4Number(value.slice(lastColon + 1));
|
|
24
|
+
if (v4 === null) return null;
|
|
25
|
+
value = `${value.slice(0, lastColon)}:${((v4 >>> 16) & 0xffff).toString(16)}:${(v4 & 0xffff).toString(16)}`;
|
|
26
|
+
}
|
|
27
|
+
const halves = value.split("::");
|
|
28
|
+
if (halves.length > 2) return null;
|
|
29
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
30
|
+
const right = halves[1] ? halves[1].split(":") : [];
|
|
31
|
+
const fill = 8 - left.length - right.length;
|
|
32
|
+
if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
|
|
33
|
+
const words = [...left, ...Array(fill).fill("0"), ...right];
|
|
34
|
+
if (words.length !== 8 || words.some((word) => !/^[0-9a-f]{1,4}$/.test(word))) return null;
|
|
35
|
+
return words.reduce((result, word) => (result << 16n) | BigInt(parseInt(word, 16)), 0n);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ipv6In(address, base, bits) {
|
|
39
|
+
const value = expandIpv6(address);
|
|
40
|
+
const start = expandIpv6(base);
|
|
41
|
+
if (value === null || start === null) return false;
|
|
42
|
+
if (bits === 0) return true;
|
|
43
|
+
const shift = 128n - BigInt(bits);
|
|
44
|
+
return (value >> shift) === (start >> shift);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isBlockedAddress(address) {
|
|
48
|
+
const version = isIP(address);
|
|
49
|
+
if (version === 4) {
|
|
50
|
+
return [
|
|
51
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
|
|
52
|
+
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
|
|
53
|
+
["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24],
|
|
54
|
+
["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
55
|
+
].some(([base, bits]) => ipv4In(address, base, bits));
|
|
56
|
+
}
|
|
57
|
+
if (version === 6) {
|
|
58
|
+
const mapped = address.toLowerCase().match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
59
|
+
if (mapped) return isBlockedAddress(mapped[1]);
|
|
60
|
+
if (ipv6In(address, "::ffff:0:0", 96)) {
|
|
61
|
+
const value = expandIpv6(address);
|
|
62
|
+
const low = Number(value & 0xffffffffn) >>> 0;
|
|
63
|
+
return isBlockedAddress(`${low >>> 24}.${(low >>> 16) & 255}.${(low >>> 8) & 255}.${low & 255}`);
|
|
64
|
+
}
|
|
65
|
+
return [
|
|
66
|
+
["::", 96], ["::1", 128], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["100::", 64],
|
|
67
|
+
["2001::", 32], ["2001:db8::", 32], ["2002::", 16], ["3fff::", 20], ["5f00::", 16],
|
|
68
|
+
["fc00::", 7], ["fec0::", 10], ["fe80::", 10], ["ff00::", 8],
|
|
69
|
+
].some(([base, bits]) => ipv6In(address, base, bits));
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parsePublicUrl(value) {
|
|
75
|
+
let url;
|
|
76
|
+
try { url = new URL(value); } catch { throw Object.assign(new Error("Destination URL is invalid."), { code: "destination_invalid" }); }
|
|
77
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
|
|
78
|
+
throw Object.assign(new Error("Destination must use HTTP(S) without credentials."), { code: "destination_blocked" });
|
|
79
|
+
}
|
|
80
|
+
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
81
|
+
if (![80, 443].includes(port)) throw Object.assign(new Error("Destination port is blocked."), { code: "destination_blocked" });
|
|
82
|
+
return url;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function resolvePublicHost(hostname, lookup = dnsLookup) {
|
|
86
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
87
|
+
if (!host || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host === "metadata.google.internal") {
|
|
88
|
+
throw Object.assign(new Error("Destination is blocked."), { code: "destination_blocked" });
|
|
89
|
+
}
|
|
90
|
+
const literal = isIP(host);
|
|
91
|
+
const answers = literal ? [{ address: host, family: literal }] : await lookup(host, { all: true, verbatim: true });
|
|
92
|
+
if (!answers.length || answers.some(({ address }) => isBlockedAddress(address))) {
|
|
93
|
+
throw Object.assign(new Error("Destination is blocked."), { code: "destination_blocked" });
|
|
94
|
+
}
|
|
95
|
+
return answers[0];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function resolvePublicUrl(value, lookup = dnsLookup) {
|
|
99
|
+
const url = parsePublicUrl(value);
|
|
100
|
+
const answer = await resolvePublicHost(url.hostname, lookup);
|
|
101
|
+
return { url, ...answer };
|
|
102
|
+
}
|