@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 CHANGED
@@ -1,17 +1,21 @@
1
1
  # @xevy/heny-connect
2
2
 
3
- Heny Connect runs a dedicated browser worker on a paired Windows PC. It stays in the system tray, starts at sign-in, and executes three explicit commands from Heny Desktop:
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
- - Navigate to an approved public HTTP(S) URL
6
- - Read up to 50,000 characters of visible page text
7
- - Capture a viewport PNG up to 2 MiB
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 **Desktop**
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 dedicated browser profile under `%LOCALAPPDATA%\Heny Connect`. Windows ACLs grant access to the current user and SYSTEM.
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
- ## Dedicated browser
47
+ ## Agent runtimes
44
48
 
45
- The companion owns a separate persistent Chromium profile. Logins created in that window remain available after worker and browser restarts.
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, popups, external protocols, and browser permission grants are blocked.
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 excludes click, type, submit, upload, shell execution, command-supplied JavaScript, and personal-profile attachment.
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
 
@@ -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 operation = action === "browser.navigate" ? this.navigate(payload.url, timeoutMs, signal)
156
- : action === "browser.read" ? this.read()
157
- : action === "browser.screenshot" ? this.screenshot()
158
- : Promise.reject(Object.assign(new Error("Unsupported command."), { code: "unsupported_action" }));
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 { BrowserController } from "./browser-controller.mjs";
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 CAPABILITIES = ["browser.navigate", "browser.read", "browser.screenshot"];
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 browser = options.browser || new BrowserController({ home: CONNECT_HOME });
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 browser.stop().catch(() => undefined); };
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 browser.stop().catch(() => undefined);
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 browser.stop();
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 browser.start();
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 browser.execute(command.action, command.payload, { signal: controller.signal, timeoutMs: deadlineMs });
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 browser action failed." };
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 browser.stop().catch(() => undefined);
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 browser.stop().catch(() => undefined);
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 browser.stop().catch(() => undefined);
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.3.0",
4
- "description": "Run Heny's isolated, permission-bounded desktop browser worker.",
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"