@xevy/heny-connect 0.3.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 +14 -10
- package/bin/heny-connect.mjs +2 -2
- package/lib/browser-controller.mjs +141 -6
- package/lib/computer-runtime.mjs +68 -0
- package/lib/process-runner.mjs +62 -0
- package/lib/work-folder.mjs +129 -0
- package/lib/worker.mjs +23 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
# @xevy/heny-connect
|
|
2
2
|
|
|
3
|
-
Heny Connect runs a
|
|
3
|
+
Heny Connect runs a bounded Computer worker on a paired Windows PC. It stays in the system tray, starts at sign-in, and gives each Agent–Computer assignment a separate browser profile and work folder.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
5
|
+
Assigned capabilities cover:
|
|
6
|
+
|
|
7
|
+
- Browser navigation, semantic snapshots, clicks, form fields, selection, keyboard input, scrolling, history, uploads, downloads, visible text, and screenshots
|
|
8
|
+
- Work-folder listing, UTF-8 reads and atomic writes, directory creation, and deletion
|
|
9
|
+
- Argument-vector process execution for Node.js, npm, and approved local programs
|
|
10
|
+
|
|
11
|
+
Browser references expire after navigation. Files remain inside the assignment work folder with a 1 MiB per-file limit and 100 MiB folder quota. Process execution uses an argument vector with shell expansion disabled, a scrubbed environment, bounded output, cancellation, and a 120-second ceiling.
|
|
8
12
|
|
|
9
13
|
## Requirements
|
|
10
14
|
|
|
11
15
|
- Windows 10 or 11
|
|
12
16
|
- Microsoft Edge or Google Chrome
|
|
13
17
|
- Node.js 22 or newer
|
|
14
|
-
- A six-digit code from Heny **
|
|
18
|
+
- A six-digit code from Heny **Computers**
|
|
15
19
|
|
|
16
20
|
## Install and pair
|
|
17
21
|
|
|
@@ -25,7 +29,7 @@ heny-connect.cmd install
|
|
|
25
29
|
|
|
26
30
|
The code works once and expires after 15 minutes. `install` starts the tray immediately and registers **HenyConnect** for the current user's Windows sign-in.
|
|
27
31
|
|
|
28
|
-
Heny Connect stores its registration and
|
|
32
|
+
Heny Connect stores its registration and assignment runtimes under `%LOCALAPPDATA%\Heny Connect`. Windows ACLs grant access to the current user and SYSTEM. Retiring an assignment stops its browser and removes its local runtime directory on the next successful heartbeat.
|
|
29
33
|
|
|
30
34
|
## System tray
|
|
31
35
|
|
|
@@ -40,13 +44,13 @@ The tray reports:
|
|
|
40
44
|
|
|
41
45
|
The menu provides **Open Heny**, **Pause/Resume**, **Reconnect now**, **Start at sign-in**, and **Quit**. Pause aborts current work, closes the dedicated browser, and reaches the local Paused state within five seconds during a network interruption.
|
|
42
46
|
|
|
43
|
-
##
|
|
47
|
+
## Agent runtimes
|
|
44
48
|
|
|
45
|
-
|
|
49
|
+
Every assignment owns a persistent Chromium profile. Logins created in that assignment window remain available after worker and browser restarts and stay separate from other Agents.
|
|
46
50
|
|
|
47
|
-
Browser traffic uses a loopback validating proxy. Each HTTP request and HTTPS tunnel resolves the destination, rejects local/private/reserved addresses, and connects directly to the validated address. Chromium retains the destination Host and TLS certificate checks. Downloads
|
|
51
|
+
Browser traffic uses a loopback validating proxy. Each HTTP request and HTTPS tunnel resolves the destination, rejects local/private/reserved addresses, and connects directly to the validated address. Chromium retains the destination Host and TLS certificate checks. Downloads land in the assignment work folder. Popups, external protocols, and browser permission grants are blocked.
|
|
48
52
|
|
|
49
|
-
The command protocol
|
|
53
|
+
The command protocol accepts structured actions with strict payload schemas. Browser commands use snapshot references. Process commands carry an executable and argument array. Heny requires an owner or admin to grant process authority on the assignment, then applies the Agent’s exact-action approval policy before execution.
|
|
50
54
|
|
|
51
55
|
## Commands
|
|
52
56
|
|
package/bin/heny-connect.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
|
8
8
|
import { hostname, platform, release } from "node:os";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { CAPABILITIES, DeviceApi, runWorker } from "../lib/worker.mjs";
|
|
11
|
+
import { CAPABILITIES, PROTOCOL_VERSION, DeviceApi, runWorker } from "../lib/worker.mjs";
|
|
12
12
|
import { CONNECT_HOME, PAUSE_FILE, STATUS_FILE, loadState, protectConnectHome, saveState, writeStatus } from "../lib/state.mjs";
|
|
13
13
|
|
|
14
14
|
const CLI_FILE = fileURLToPath(import.meta.url);
|
|
@@ -42,7 +42,7 @@ async function pair() {
|
|
|
42
42
|
const server = opt("server", process.env.HENY_SERVER);
|
|
43
43
|
if (!code || !server) { console.error("Usage: heny-connect pair --code 123456 --server https://heny.example"); process.exitCode = 2; return; }
|
|
44
44
|
await protectConnectHome();
|
|
45
|
-
const result = await call(server, "/api/devices/pair", { code, system: systemLabel(), hostname: hostname(), capabilities: CAPABILITIES });
|
|
45
|
+
const result = await call(server, "/api/devices/pair", { code, system: systemLabel(), hostname: hostname(), capabilities: CAPABILITIES, protocolVersion: PROTOCOL_VERSION });
|
|
46
46
|
await saveState({ server, token: result.token, deviceId: result.deviceId, workspace: result.workspace, pairedAt: new Date().toISOString() });
|
|
47
47
|
await unlink(PAUSE_FILE).catch(() => undefined);
|
|
48
48
|
console.log(`Paired with ${result.workspace?.name ?? "workspace"} as device ${result.deviceId}.`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
4
4
|
import net from "node:net";
|
|
5
5
|
import { platform } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
@@ -41,8 +41,9 @@ function findBrowser() {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export class BrowserController {
|
|
44
|
-
constructor({ home, browserPath = findBrowser(), lookup } = {}) {
|
|
44
|
+
constructor({ home, workRoot = join(home, "work"), browserPath = findBrowser(), lookup } = {}) {
|
|
45
45
|
this.home = home;
|
|
46
|
+
this.workRoot = workRoot;
|
|
46
47
|
this.browserPath = browserPath;
|
|
47
48
|
this.lookup = lookup;
|
|
48
49
|
this.process = null;
|
|
@@ -50,6 +51,8 @@ export class BrowserController {
|
|
|
50
51
|
this.session = null;
|
|
51
52
|
this.targetId = null;
|
|
52
53
|
this.mainFrameId = null;
|
|
54
|
+
this.generation = 0;
|
|
55
|
+
this.references = new Map();
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
async start() {
|
|
@@ -140,6 +143,9 @@ export class BrowserController {
|
|
|
140
143
|
void this.session?.send("Page.stopLoading").catch(() => undefined);
|
|
141
144
|
}
|
|
142
145
|
});
|
|
146
|
+
this.session.on("Page.frameNavigated", ({ frame }) => {
|
|
147
|
+
if (frame?.id === this.mainFrameId) void this.clearReferences();
|
|
148
|
+
});
|
|
143
149
|
this.session.on("Target.targetCreated", ({ targetInfo }) => {
|
|
144
150
|
if (targetInfo?.type === "page" && targetInfo.targetId !== this.targetId) void this.session?.send("Target.closeTarget", { targetId: targetInfo.targetId }).catch(() => undefined);
|
|
145
151
|
});
|
|
@@ -152,10 +158,22 @@ export class BrowserController {
|
|
|
152
158
|
const onAbort = () => { void this.session?.send("Page.stopLoading").catch(() => undefined); };
|
|
153
159
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
154
160
|
try {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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" }));
|
|
159
177
|
const cancelled = new Promise((_, reject) => signal?.addEventListener("abort", () => reject(Object.assign(new Error("Command cancelled."), { code: "command_cancelled" })), { once: true }));
|
|
160
178
|
return await Promise.race([operation, cancelled]);
|
|
161
179
|
} finally {
|
|
@@ -198,6 +216,122 @@ export class BrowserController {
|
|
|
198
216
|
}
|
|
199
217
|
}
|
|
200
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
|
+
|
|
201
335
|
async read() {
|
|
202
336
|
const result = await this.session.send("Runtime.evaluate", {
|
|
203
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}})()`,
|
|
@@ -221,6 +355,7 @@ export class BrowserController {
|
|
|
221
355
|
const session = this.session;
|
|
222
356
|
this.session = null;
|
|
223
357
|
this.mainFrameId = null;
|
|
358
|
+
await this.clearReferences();
|
|
224
359
|
const browserProcess = this.process;
|
|
225
360
|
this.process = null;
|
|
226
361
|
if (browserProcess) {
|
|
@@ -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,62 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
|
|
4
|
+
const OUTPUT_LIMIT = 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
function cleanEnvironment() {
|
|
7
|
+
const allowed = ["SystemRoot", "ComSpec", "PATH", "PATHEXT", "TEMP", "TMP", "WINDIR", "LOCALAPPDATA", "APPDATA"];
|
|
8
|
+
return Object.fromEntries(allowed.flatMap((key) => typeof process.env[key] === "string" ? [[key, process.env[key]]] : []));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function stopTree(child) {
|
|
12
|
+
if (!child.pid) return;
|
|
13
|
+
if (platform() === "win32") spawnSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
14
|
+
else {
|
|
15
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch {} }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runProcess({ executable, args = [], timeoutMs = 120_000, cwd, signal }) {
|
|
20
|
+
if (typeof executable !== "string" || !executable.trim() || executable.includes("\0") || /[\r\n]/.test(executable)) throw Object.assign(new Error("Choose a valid executable."), { code: "invalid_executable" });
|
|
21
|
+
if (!Array.isArray(args) || args.length > 100 || args.some((arg) => typeof arg !== "string" || arg.includes("\0") || arg.length > 8192)) throw Object.assign(new Error("Process arguments are invalid."), { code: "invalid_process_args" });
|
|
22
|
+
const started = Date.now();
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const child = spawn(executable, args, { cwd, env: cleanEnvironment(), windowsHide: true, shell: false, detached: platform() !== "win32", stdio: ["ignore", "pipe", "pipe"] });
|
|
25
|
+
let stdout = "";
|
|
26
|
+
let stderr = "";
|
|
27
|
+
let truncated = false;
|
|
28
|
+
let settled = false;
|
|
29
|
+
const collect = (kind, chunk) => {
|
|
30
|
+
const text = chunk.toString("utf8");
|
|
31
|
+
if (kind === "stdout") {
|
|
32
|
+
const remaining = Math.max(0, OUTPUT_LIMIT - Buffer.byteLength(stdout));
|
|
33
|
+
stdout += Buffer.from(text).subarray(0, remaining).toString("utf8");
|
|
34
|
+
if (Buffer.byteLength(text) > remaining) truncated = true;
|
|
35
|
+
} else {
|
|
36
|
+
const remaining = Math.max(0, OUTPUT_LIMIT - Buffer.byteLength(stderr));
|
|
37
|
+
stderr += Buffer.from(text).subarray(0, remaining).toString("utf8");
|
|
38
|
+
if (Buffer.byteLength(text) > remaining) truncated = true;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
child.stdout.on("data", (chunk) => collect("stdout", chunk));
|
|
42
|
+
child.stderr.on("data", (chunk) => collect("stderr", chunk));
|
|
43
|
+
const fail = (error) => {
|
|
44
|
+
if (settled) return;
|
|
45
|
+
settled = true;
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
signal?.removeEventListener("abort", abort);
|
|
48
|
+
reject(error);
|
|
49
|
+
};
|
|
50
|
+
const abort = () => { stopTree(child); fail(Object.assign(new Error("Process cancelled."), { code: "command_cancelled" })); };
|
|
51
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
52
|
+
const timer = setTimeout(() => { stopTree(child); fail(Object.assign(new Error("Process exceeded its time limit."), { code: "process_timeout" })); }, Math.min(120_000, Math.max(100, timeoutMs)));
|
|
53
|
+
child.once("error", (error) => { fail(Object.assign(new Error("The process could not start."), { code: error.code === "ENOENT" ? "executable_not_found" : "process_start_failed" })); });
|
|
54
|
+
child.once("close", (exitCode, exitSignal) => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
signal?.removeEventListener("abort", abort);
|
|
59
|
+
resolve({ executable, exitCode, signal: exitSignal, stdout, stderr, truncated, durationMs: Date.now() - started });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
5
|
+
const MAX_FOLDER_BYTES = 100 * 1024 * 1024;
|
|
6
|
+
const MAX_ENTRIES = 1000;
|
|
7
|
+
|
|
8
|
+
function invalidPath(message = "Use a relative path inside the Agent work folder.") {
|
|
9
|
+
return Object.assign(new Error(message), { code: "invalid_work_path" });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normaliseRelative(value, allowEmpty = false) {
|
|
13
|
+
if (typeof value !== "string" || value.includes("\0") || isAbsolute(value) || /^[a-zA-Z]:[\\/]/.test(value)) throw invalidPath();
|
|
14
|
+
const parts = value.split(/[\\/]+/).filter(Boolean);
|
|
15
|
+
if (parts.some((part) => part === "." || part === "..")) throw invalidPath();
|
|
16
|
+
const output = parts.join(sep);
|
|
17
|
+
if (!allowEmpty && !output) throw invalidPath();
|
|
18
|
+
if (output.length > 500) throw invalidPath("The Agent work path is too long.");
|
|
19
|
+
return output;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function rejectLinks(root, target, allowMissingLeaf = false) {
|
|
23
|
+
const rel = relative(root, target);
|
|
24
|
+
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) throw invalidPath();
|
|
25
|
+
const parts = rel ? rel.split(sep) : [];
|
|
26
|
+
let current = root;
|
|
27
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
28
|
+
current = join(current, parts[index]);
|
|
29
|
+
try {
|
|
30
|
+
const info = await lstat(current);
|
|
31
|
+
if (info.isSymbolicLink()) throw Object.assign(new Error("Links and Windows reparse points are unavailable in Agent work folders."), { code: "work_link_denied" });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code === "ENOENT" && allowMissingLeaf && index === parts.length - 1) return;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function folderBytes(path, ceiling = MAX_FOLDER_BYTES) {
|
|
40
|
+
let total = 0;
|
|
41
|
+
const walk = async (dir) => {
|
|
42
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
43
|
+
const target = join(dir, entry.name);
|
|
44
|
+
if (entry.isSymbolicLink()) throw Object.assign(new Error("Links are unavailable in Agent work folders."), { code: "work_link_denied" });
|
|
45
|
+
if (entry.isDirectory()) await walk(target);
|
|
46
|
+
else if (entry.isFile()) total += (await stat(target)).size;
|
|
47
|
+
if (total > ceiling) return;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
await walk(path);
|
|
51
|
+
return total;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class WorkFolder {
|
|
55
|
+
constructor(root) { this.root = resolve(root); }
|
|
56
|
+
|
|
57
|
+
async start() {
|
|
58
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
59
|
+
const canonical = await realpath(this.root);
|
|
60
|
+
if (canonical !== this.root && canonical.toLowerCase() !== this.root.toLowerCase()) throw invalidPath("The Agent work folder must resolve to its configured directory.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async resolvePath(value, { allowEmpty = false, allowMissingLeaf = false } = {}) {
|
|
64
|
+
await this.start();
|
|
65
|
+
const rel = normaliseRelative(value, allowEmpty);
|
|
66
|
+
const target = resolve(this.root, rel);
|
|
67
|
+
await rejectLinks(this.root, target, allowMissingLeaf);
|
|
68
|
+
return { target, relative: rel.replaceAll(sep, "/") };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async list(value = "") {
|
|
72
|
+
const { target, relative: rel } = await this.resolvePath(value, { allowEmpty: true });
|
|
73
|
+
const info = await stat(target);
|
|
74
|
+
if (!info.isDirectory()) throw Object.assign(new Error("The requested work path is not a directory."), { code: "work_not_directory" });
|
|
75
|
+
const all = await readdir(target, { withFileTypes: true });
|
|
76
|
+
const entries = [];
|
|
77
|
+
for (const entry of all.slice(0, MAX_ENTRIES)) {
|
|
78
|
+
if (entry.isSymbolicLink()) continue;
|
|
79
|
+
const item = { name: entry.name, type: entry.isDirectory() ? "directory" : "file" };
|
|
80
|
+
if (entry.isFile()) item.bytes = (await stat(join(target, entry.name))).size;
|
|
81
|
+
entries.push(item);
|
|
82
|
+
}
|
|
83
|
+
return { path: rel, entries, truncated: all.length > MAX_ENTRIES };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async read(value) {
|
|
87
|
+
const { target, relative: rel } = await this.resolvePath(value);
|
|
88
|
+
const info = await stat(target);
|
|
89
|
+
if (!info.isFile()) throw Object.assign(new Error("The requested work path is not a file."), { code: "work_not_file" });
|
|
90
|
+
if (info.size > MAX_FILE_BYTES) throw Object.assign(new Error("The requested file exceeds 1 MiB."), { code: "work_file_too_large" });
|
|
91
|
+
const content = await readFile(target, "utf8");
|
|
92
|
+
return { path: rel, content, bytes: Buffer.byteLength(content), truncated: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async write(value, content) {
|
|
96
|
+
if (typeof content !== "string" || Buffer.byteLength(content) > MAX_FILE_BYTES) throw Object.assign(new Error("Work-folder writes are limited to 1 MiB."), { code: "work_file_too_large" });
|
|
97
|
+
const { target, relative: rel } = await this.resolvePath(value, { allowMissingLeaf: true });
|
|
98
|
+
const parent = dirname(target);
|
|
99
|
+
await rejectLinks(this.root, parent);
|
|
100
|
+
const existingBytes = await folderBytes(this.root);
|
|
101
|
+
let replacedBytes = 0;
|
|
102
|
+
try { replacedBytes = (await stat(target)).size; } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
103
|
+
if (existingBytes - replacedBytes + Buffer.byteLength(content) > MAX_FOLDER_BYTES) throw Object.assign(new Error("The Agent work folder exceeds its 100 MiB quota."), { code: "work_quota_exceeded" });
|
|
104
|
+
const temporary = join(parent, `.${basename(target)}.${process.pid}.${Date.now()}.tmp`);
|
|
105
|
+
const file = await open(temporary, "wx", 0o600);
|
|
106
|
+
try {
|
|
107
|
+
await file.writeFile(content, "utf8");
|
|
108
|
+
await file.close();
|
|
109
|
+
await rename(temporary, target);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
await file.close().catch(() => undefined);
|
|
112
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
return { path: rel, ok: true, bytes: Buffer.byteLength(content) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async mkdir(value) {
|
|
119
|
+
const { target, relative: rel } = await this.resolvePath(value, { allowMissingLeaf: true });
|
|
120
|
+
await mkdir(target, { recursive: false, mode: 0o700 });
|
|
121
|
+
return { path: rel, ok: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async delete(value) {
|
|
125
|
+
const { target, relative: rel } = await this.resolvePath(value);
|
|
126
|
+
await rm(target, { recursive: false, force: false });
|
|
127
|
+
return { path: rel, ok: true };
|
|
128
|
+
}
|
|
129
|
+
}
|
package/lib/worker.mjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { open, readFile, unlink } from "node:fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import { ComputerRuntime } from "./computer-runtime.mjs";
|
|
3
3
|
import { clearState, CONNECT_HOME, LOCK_FILE, PAUSE_FILE, STOP_FILE, writeStatus } from "./state.mjs";
|
|
4
4
|
|
|
5
|
-
export const
|
|
5
|
+
export const PROTOCOL_VERSION = 2;
|
|
6
|
+
export const CAPABILITIES = [
|
|
7
|
+
"browser.navigate", "browser.snapshot", "browser.read", "browser.screenshot", "browser.click", "browser.fill", "browser.select", "browser.press", "browser.scroll", "browser.wait", "browser.back", "browser.upload", "browser.download",
|
|
8
|
+
"file.list", "file.read", "file.write", "file.mkdir", "file.delete", "process.run",
|
|
9
|
+
];
|
|
6
10
|
const LEASE_MS = 10_000;
|
|
7
11
|
const POLL_MS = 2_000;
|
|
8
12
|
const HEARTBEAT_MS = 30_000;
|
|
@@ -89,6 +93,7 @@ export class DeviceApi {
|
|
|
89
93
|
state,
|
|
90
94
|
browserReady,
|
|
91
95
|
capabilities: CAPABILITIES,
|
|
96
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
92
97
|
...(current ? { currentCommandId: current.id, currentAction: current.action } : {}),
|
|
93
98
|
detailCode: current ? "command_running" : state,
|
|
94
99
|
});
|
|
@@ -111,12 +116,16 @@ export async function runWorker(state, options = {}) {
|
|
|
111
116
|
await unlink(STOP_FILE).catch(() => undefined);
|
|
112
117
|
const releaseLock = await acquireLock();
|
|
113
118
|
const api = options.api || new DeviceApi(state);
|
|
114
|
-
const
|
|
119
|
+
const runtime = options.runtime || (options.browser ? {
|
|
120
|
+
start: () => options.browser.start(),
|
|
121
|
+
stop: () => options.browser.stop(),
|
|
122
|
+
execute: (command, executeOptions) => options.browser.execute(command.action, command.payload, executeOptions),
|
|
123
|
+
} : new ComputerRuntime({ home: CONNECT_HOME }));
|
|
115
124
|
let active = null;
|
|
116
125
|
let stopped = false;
|
|
117
126
|
let nextHeartbeat = 0;
|
|
118
127
|
let pauseDeadline = 0;
|
|
119
|
-
const stop = () => { stopped = true; active?.controller.abort(); void
|
|
128
|
+
const stop = () => { stopped = true; active?.controller.abort(); void runtime.stop().catch(() => undefined); };
|
|
120
129
|
process.once("SIGINT", stop);
|
|
121
130
|
process.once("SIGTERM", stop);
|
|
122
131
|
options.signal?.addEventListener("abort", stop, { once: true });
|
|
@@ -131,7 +140,7 @@ export async function runWorker(state, options = {}) {
|
|
|
131
140
|
active?.controller.abort();
|
|
132
141
|
if (!pauseHandled) {
|
|
133
142
|
pauseHandled = true;
|
|
134
|
-
await
|
|
143
|
+
await runtime.stop().catch(() => undefined);
|
|
135
144
|
await writeStatus({ state: isPaused ? "paused" : "offline", currentCommandId: null, currentAction: null }).catch(() => undefined);
|
|
136
145
|
}
|
|
137
146
|
} else {
|
|
@@ -142,7 +151,8 @@ export async function runWorker(state, options = {}) {
|
|
|
142
151
|
|
|
143
152
|
async function report(workerState, browserReady, current) {
|
|
144
153
|
await writeStatus({ state: workerState, currentCommandId: current?.id ?? null, currentAction: current?.action ?? null });
|
|
145
|
-
await api.heartbeat(workerState, browserReady, current);
|
|
154
|
+
const heartbeat = await api.heartbeat(workerState, browserReady, current);
|
|
155
|
+
if (Array.isArray(heartbeat?.assignments) && typeof runtime.synchronise === "function") await runtime.synchronise(heartbeat.assignments);
|
|
146
156
|
nextHeartbeat = Date.now() + HEARTBEAT_MS;
|
|
147
157
|
}
|
|
148
158
|
|
|
@@ -153,7 +163,7 @@ export async function runWorker(state, options = {}) {
|
|
|
153
163
|
if (await shutdownRequested()) { stopped = true; break; }
|
|
154
164
|
if (await paused()) {
|
|
155
165
|
active?.controller.abort();
|
|
156
|
-
await
|
|
166
|
+
await runtime.stop();
|
|
157
167
|
const reportBudget = pauseDeadline ? Math.max(0, pauseDeadline - Date.now()) : 5_000;
|
|
158
168
|
await settleWithin(report("paused", false), reportBudget);
|
|
159
169
|
pauseDeadline = 0;
|
|
@@ -166,7 +176,7 @@ export async function runWorker(state, options = {}) {
|
|
|
166
176
|
await api.heartbeat("browser_starting", false).catch(() => undefined);
|
|
167
177
|
}
|
|
168
178
|
try {
|
|
169
|
-
await
|
|
179
|
+
await runtime.start();
|
|
170
180
|
if (stopped) break;
|
|
171
181
|
if (await paused()) continue;
|
|
172
182
|
if (Date.now() >= nextHeartbeat) await report("available", true);
|
|
@@ -190,16 +200,16 @@ export async function runWorker(state, options = {}) {
|
|
|
190
200
|
let result;
|
|
191
201
|
let completionError;
|
|
192
202
|
try {
|
|
193
|
-
result = await
|
|
203
|
+
result = await runtime.execute(command, { signal: controller.signal, timeoutMs: deadlineMs });
|
|
194
204
|
} catch (error) {
|
|
195
205
|
const cancelled = controller.signal.aborted;
|
|
196
206
|
status = cancelled ? "cancelled" : "failed";
|
|
197
|
-
completionError = { code: cancelled ? "command_cancelled" : sanitizedCode(error), message: cancelled ? "Command cancelled." : "The
|
|
207
|
+
completionError = { code: cancelled ? "command_cancelled" : sanitizedCode(error), message: cancelled ? "Command cancelled." : "The Computer action failed." };
|
|
198
208
|
}
|
|
199
209
|
const locallyPaused = status === "cancelled" && await paused();
|
|
200
210
|
if (locallyPaused) {
|
|
201
211
|
pauseDeadline = Date.now() + (await shutdownRequested() ? 3_000 : 5_000);
|
|
202
|
-
await
|
|
212
|
+
await runtime.stop().catch(() => undefined);
|
|
203
213
|
await writeStatus({ state: "paused", currentCommandId: null, currentAction: null }).catch(() => undefined);
|
|
204
214
|
}
|
|
205
215
|
const commandDeadline = new Date(command.deadlineAt).getTime();
|
|
@@ -219,7 +229,7 @@ export async function runWorker(state, options = {}) {
|
|
|
219
229
|
if (error.status === 401) throw error;
|
|
220
230
|
if (stopped) break;
|
|
221
231
|
if (await paused()) continue;
|
|
222
|
-
await
|
|
232
|
+
await runtime.stop().catch(() => undefined);
|
|
223
233
|
await writeStatus({ state: "error", detailCode: sanitizedCode(error), currentCommandId: null, currentAction: null });
|
|
224
234
|
await api.heartbeat("error", false).catch(() => undefined);
|
|
225
235
|
console.error(`${new Date().toISOString()} worker error ${sanitizedCode(error)}`);
|
|
@@ -229,7 +239,7 @@ export async function runWorker(state, options = {}) {
|
|
|
229
239
|
} finally {
|
|
230
240
|
clearInterval(localWatch);
|
|
231
241
|
active?.controller.abort();
|
|
232
|
-
await
|
|
242
|
+
await runtime.stop().catch(() => undefined);
|
|
233
243
|
await settleWithin(api.heartbeat("offline", false), 5_000);
|
|
234
244
|
await writeStatus({ state: "offline", currentCommandId: null, currentAction: null }).catch(() => undefined);
|
|
235
245
|
await unlink(STOP_FILE).catch(() => undefined);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xevy/heny-connect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run Heny's isolated, permission-bounded
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Run Heny's isolated, permission-bounded Windows Computer worker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"heny-connect": "bin/heny-connect.mjs"
|