@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.
@@ -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
+ }
package/lib/state.mjs ADDED
@@ -0,0 +1,54 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { homedir, platform } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export const CONNECT_HOME = process.env.HENY_CONNECT_HOME || (platform() === "win32" && process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "Heny Connect") : homedir());
7
+ export const STATE_FILE = join(CONNECT_HOME, ".heny-connect.json");
8
+ export const STATUS_FILE = join(CONNECT_HOME, "worker-status.json");
9
+ export const PAUSE_FILE = join(CONNECT_HOME, "paused");
10
+ export const STOP_FILE = join(CONNECT_HOME, "stopping");
11
+ export const LOCK_FILE = join(CONNECT_HOME, "worker.lock");
12
+
13
+ export async function protectConnectHome() {
14
+ await mkdir(CONNECT_HOME, { recursive: true });
15
+ if (platform() === "win32") {
16
+ const script = [
17
+ "$target=$env:HENY_ACL_TARGET",
18
+ "$acl=New-Object System.Security.AccessControl.DirectorySecurity",
19
+ "$acl.SetAccessRuleProtection($true,$false)",
20
+ "$inherit=[System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'",
21
+ "$prop=[System.Security.AccessControl.PropagationFlags]::None",
22
+ "$allow=[System.Security.AccessControl.AccessControlType]::Allow",
23
+ "$full=[System.Security.AccessControl.FileSystemRights]::FullControl",
24
+ "$user=[System.Security.Principal.WindowsIdentity]::GetCurrent().User",
25
+ "$system=[System.Security.Principal.SecurityIdentifier]::new('S-1-5-18')",
26
+ "$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($user,$full,$inherit,$prop,$allow))",
27
+ "$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($system,$full,$inherit,$prop,$allow))",
28
+ "Set-Acl -LiteralPath $target -AclObject $acl",
29
+ ].join(";");
30
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, stdio: "ignore", env: { ...process.env, HENY_ACL_TARGET: CONNECT_HOME } });
31
+ if (result.status !== 0) throw Object.assign(new Error("Heny Connect could not protect its local state."), { code: "acl_failed" });
32
+ }
33
+ }
34
+
35
+ export async function loadState() {
36
+ try { return JSON.parse(await readFile(STATE_FILE, "utf8")); } catch { return null; }
37
+ }
38
+
39
+ export async function saveState(state) {
40
+ await protectConnectHome();
41
+ const temporary = `${STATE_FILE}.tmp`;
42
+ await writeFile(temporary, JSON.stringify(state, null, 2), { mode: 0o600 });
43
+ await rename(temporary, STATE_FILE);
44
+ await chmod(STATE_FILE, 0o600).catch(() => undefined);
45
+ }
46
+
47
+ export async function clearState() {
48
+ await unlink(STATE_FILE).catch(() => undefined);
49
+ }
50
+
51
+ export async function writeStatus(status) {
52
+ await mkdir(CONNECT_HOME, { recursive: true });
53
+ await writeFile(STATUS_FILE, JSON.stringify({ ...status, updatedAt: new Date().toISOString() }), { mode: 0o600 });
54
+ }
@@ -0,0 +1,70 @@
1
+ import http from "node:http";
2
+ import net from "node:net";
3
+ import { resolvePublicHost, resolvePublicUrl } from "./network-policy.mjs";
4
+
5
+ function safeDestroy(stream) {
6
+ try { stream.destroy(); } catch {}
7
+ }
8
+
9
+ export async function createValidatingProxy(options = {}) {
10
+ const lookup = options.lookup;
11
+ const server = http.createServer(async (request, response) => {
12
+ try {
13
+ const { url, address, family } = await resolvePublicUrl(request.url, lookup);
14
+ const headers = { ...request.headers, host: url.host };
15
+ delete headers["proxy-connection"];
16
+ const upstream = http.request({
17
+ host: address,
18
+ family,
19
+ port: Number(url.port || 80),
20
+ method: request.method,
21
+ path: `${url.pathname}${url.search}`,
22
+ headers,
23
+ }, (incoming) => {
24
+ response.writeHead(incoming.statusCode || 502, incoming.statusMessage, incoming.headers);
25
+ incoming.pipe(response);
26
+ });
27
+ upstream.on("error", () => {
28
+ if (!response.headersSent) response.writeHead(502);
29
+ response.end();
30
+ });
31
+ request.pipe(upstream);
32
+ } catch {
33
+ response.writeHead(403, { "content-type": "text/plain", connection: "close" });
34
+ response.end("Destination blocked");
35
+ }
36
+ });
37
+
38
+ server.on("connect", async (request, client, head) => {
39
+ try {
40
+ const authority = request.url || "";
41
+ const match = authority.match(/^\[([^\]]+)]:(\d+)$/) || authority.match(/^([^:]+):(\d+)$/);
42
+ if (!match) throw new Error("Invalid authority");
43
+ const host = match[1];
44
+ const port = Number(match[2]);
45
+ if (port !== 443 && port !== 80) throw new Error("Port blocked");
46
+ const { address, family } = await resolvePublicHost(host, lookup);
47
+ const upstream = net.connect({ host: address, family, port }, () => {
48
+ client.write("HTTP/1.1 200 Connection Established\r\nProxy-Agent: Heny\r\n\r\n");
49
+ if (head.length) upstream.write(head);
50
+ client.pipe(upstream);
51
+ upstream.pipe(client);
52
+ });
53
+ upstream.on("error", () => safeDestroy(client));
54
+ client.on("error", () => safeDestroy(upstream));
55
+ } catch {
56
+ client.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
57
+ safeDestroy(client);
58
+ }
59
+ });
60
+
61
+ await new Promise((resolve, reject) => {
62
+ server.once("error", reject);
63
+ server.listen(0, "127.0.0.1", resolve);
64
+ });
65
+ const address = server.address();
66
+ return {
67
+ port: address.port,
68
+ close: () => new Promise((resolve) => server.close(resolve)),
69
+ };
70
+ }
@@ -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 ADDED
@@ -0,0 +1,251 @@
1
+ import { open, readFile, unlink } from "node:fs/promises";
2
+ import { ComputerRuntime } from "./computer-runtime.mjs";
3
+ import { clearState, CONNECT_HOME, LOCK_FILE, PAUSE_FILE, STOP_FILE, writeStatus } from "./state.mjs";
4
+
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
+ ];
10
+ const LEASE_MS = 10_000;
11
+ const POLL_MS = 2_000;
12
+ const HEARTBEAT_MS = 30_000;
13
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+ const settleWithin = (promise, milliseconds) => new Promise((resolve) => {
15
+ const timer = setTimeout(resolve, milliseconds);
16
+ timer.unref?.();
17
+ Promise.resolve(promise).then((value) => { clearTimeout(timer); resolve(value); }, () => { clearTimeout(timer); resolve(undefined); });
18
+ });
19
+
20
+ const attemptWithin = (promise, milliseconds) => new Promise((resolve) => {
21
+ const timer = setTimeout(() => resolve({ acknowledged: false }), milliseconds);
22
+ Promise.resolve(promise).then(
23
+ (value) => { clearTimeout(timer); resolve({ acknowledged: true, value }); },
24
+ (error) => { clearTimeout(timer); resolve({ acknowledged: false, error }); },
25
+ );
26
+ });
27
+
28
+ async function completeWithRetry(api, command, claimToken, status, result, error, cutoff) {
29
+ while (Date.now() < cutoff) {
30
+ const remaining = cutoff - Date.now();
31
+ const outcome = await attemptWithin(api.complete(command, claimToken, status, result, error), Math.min(2_000, remaining));
32
+ if (outcome.acknowledged) return true;
33
+ if (outcome.error?.status === 401) throw outcome.error;
34
+ if (outcome.error && [400, 409].includes(outcome.error.status)) return false;
35
+ const delay = Math.min(250, Math.max(0, cutoff - Date.now()));
36
+ if (delay) await sleep(delay);
37
+ }
38
+ return false;
39
+ }
40
+
41
+ function sanitizedCode(error) {
42
+ const value = typeof error?.code === "string" ? error.code : "worker_error";
43
+ return /^[a-z0-9_]{1,40}$/.test(value) ? value : "worker_error";
44
+ }
45
+
46
+ async function paused() {
47
+ try { await readFile(PAUSE_FILE); return true; } catch { return false; }
48
+ }
49
+
50
+ async function shutdownRequested() {
51
+ try { await readFile(STOP_FILE); return true; } catch { return false; }
52
+ }
53
+
54
+ async function acquireLock() {
55
+ try {
56
+ const file = await open(LOCK_FILE, "wx", 0o600);
57
+ await file.writeFile(String(process.pid));
58
+ return async () => { await file.close(); await unlink(LOCK_FILE).catch(() => undefined); };
59
+ } catch (error) {
60
+ if (error.code !== "EEXIST") throw error;
61
+ let pid = 0;
62
+ try { pid = Number(await readFile(LOCK_FILE, "utf8")); process.kill(pid, 0); } catch {
63
+ await unlink(LOCK_FILE).catch(() => undefined);
64
+ return acquireLock();
65
+ }
66
+ throw Object.assign(new Error("Heny Connect is already running."), { code: "worker_running" });
67
+ }
68
+ }
69
+
70
+ export class DeviceApi {
71
+ constructor(state) { this.state = state; }
72
+
73
+ async request(path, body = {}) {
74
+ const response = await fetch(new URL(path, this.state.server), {
75
+ method: "POST",
76
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.state.token}` },
77
+ body: JSON.stringify(body),
78
+ signal: AbortSignal.timeout(20_000),
79
+ });
80
+ if (response.status === 401) {
81
+ await clearState();
82
+ throw Object.assign(new Error("Registration was revoked. Pair this computer again."), { code: "device_revoked", status: 401 });
83
+ }
84
+ if (response.status === 204) return null;
85
+ let parsed = null;
86
+ try { parsed = await response.json(); } catch {}
87
+ if (!response.ok) throw Object.assign(new Error(parsed?.error?.message || `Server answered HTTP ${response.status}`), { code: parsed?.error?.code || "server_error", status: response.status });
88
+ return parsed;
89
+ }
90
+
91
+ heartbeat(state, browserReady, current) {
92
+ return this.request("/api/devices/heartbeat", {
93
+ state,
94
+ browserReady,
95
+ capabilities: CAPABILITIES,
96
+ protocolVersion: PROTOCOL_VERSION,
97
+ ...(current ? { currentCommandId: current.id, currentAction: current.action } : {}),
98
+ detailCode: current ? "command_running" : state,
99
+ });
100
+ }
101
+
102
+ claim() { return this.request("/api/devices/commands/claim"); }
103
+ lease(command, claimToken) { return this.request(`/api/devices/commands/${command.id}/lease`, { claimToken, attempt: command.attempt }); }
104
+ complete(command, claimToken, status, result, error) {
105
+ return this.request(`/api/devices/commands/${command.id}/complete`, {
106
+ claimToken,
107
+ attempt: command.attempt,
108
+ status,
109
+ ...(result !== undefined ? { result } : {}),
110
+ ...(error ? { error } : {}),
111
+ });
112
+ }
113
+ }
114
+
115
+ export async function runWorker(state, options = {}) {
116
+ await unlink(STOP_FILE).catch(() => undefined);
117
+ const releaseLock = await acquireLock();
118
+ const api = options.api || new DeviceApi(state);
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 }));
124
+ let active = null;
125
+ let stopped = false;
126
+ let nextHeartbeat = 0;
127
+ let pauseDeadline = 0;
128
+ const stop = () => { stopped = true; active?.controller.abort(); void runtime.stop().catch(() => undefined); };
129
+ process.once("SIGINT", stop);
130
+ process.once("SIGTERM", stop);
131
+ options.signal?.addEventListener("abort", stop, { once: true });
132
+ let watchBusy = false;
133
+ let pauseHandled = false;
134
+ const localWatch = setInterval(() => {
135
+ if (watchBusy) return;
136
+ watchBusy = true;
137
+ void Promise.all([paused(), shutdownRequested()]).then(async ([isPaused, shutdown]) => {
138
+ if (shutdown) stopped = true;
139
+ if (isPaused || shutdown) {
140
+ active?.controller.abort();
141
+ if (!pauseHandled) {
142
+ pauseHandled = true;
143
+ await runtime.stop().catch(() => undefined);
144
+ await writeStatus({ state: isPaused ? "paused" : "offline", currentCommandId: null, currentAction: null }).catch(() => undefined);
145
+ }
146
+ } else {
147
+ pauseHandled = false;
148
+ }
149
+ }).finally(() => { watchBusy = false; });
150
+ }, 250);
151
+
152
+ async function report(workerState, browserReady, current) {
153
+ await writeStatus({ state: workerState, currentCommandId: current?.id ?? null, currentAction: current?.action ?? null });
154
+ const heartbeat = await api.heartbeat(workerState, browserReady, current);
155
+ if (Array.isArray(heartbeat?.assignments) && typeof runtime.synchronise === "function") await runtime.synchronise(heartbeat.assignments);
156
+ nextHeartbeat = Date.now() + HEARTBEAT_MS;
157
+ }
158
+
159
+ try {
160
+ await writeStatus({ state: "browser_starting", currentCommandId: null, currentAction: null });
161
+ await api.heartbeat("browser_starting", false).catch(() => undefined);
162
+ while (!stopped) {
163
+ if (await shutdownRequested()) { stopped = true; break; }
164
+ if (await paused()) {
165
+ active?.controller.abort();
166
+ await runtime.stop();
167
+ const reportBudget = pauseDeadline ? Math.max(0, pauseDeadline - Date.now()) : 5_000;
168
+ await settleWithin(report("paused", false), reportBudget);
169
+ pauseDeadline = 0;
170
+ while (!stopped && await paused()) {
171
+ if (await shutdownRequested()) { stopped = true; break; }
172
+ await sleep(250);
173
+ }
174
+ if (stopped) break;
175
+ await writeStatus({ state: "browser_starting", currentCommandId: null, currentAction: null });
176
+ await api.heartbeat("browser_starting", false).catch(() => undefined);
177
+ }
178
+ try {
179
+ await runtime.start();
180
+ if (stopped) break;
181
+ if (await paused()) continue;
182
+ if (Date.now() >= nextHeartbeat) await report("available", true);
183
+ if (stopped) break;
184
+ if (await paused()) continue;
185
+ const claimed = await api.claim();
186
+ if (!claimed) { await sleep(options.pollMs ?? POLL_MS); continue; }
187
+ const { command, claimToken } = claimed;
188
+ const controller = new AbortController();
189
+ active = { command, claimToken, controller };
190
+ if (await paused()) controller.abort();
191
+ else await report("working", true, command);
192
+ const deadlineMs = Math.max(1, new Date(command.deadlineAt).getTime() - Date.now());
193
+ const deadline = setTimeout(() => controller.abort(), deadlineMs);
194
+ const pauseWatch = setInterval(() => { void Promise.all([paused(), shutdownRequested()]).then(([isPaused, shutdown]) => { if (shutdown) stopped = true; if (isPaused || shutdown) controller.abort(); }); }, 250);
195
+ const leaseWatch = setInterval(() => {
196
+ void api.lease(command, claimToken).then((lease) => { if (lease?.cancelRequested) controller.abort(); }).catch(() => controller.abort());
197
+ }, options.leaseMs ?? LEASE_MS);
198
+ try {
199
+ let status = "succeeded";
200
+ let result;
201
+ let completionError;
202
+ try {
203
+ result = await runtime.execute(command, { signal: controller.signal, timeoutMs: deadlineMs });
204
+ } catch (error) {
205
+ const cancelled = controller.signal.aborted;
206
+ status = cancelled ? "cancelled" : "failed";
207
+ completionError = { code: cancelled ? "command_cancelled" : sanitizedCode(error), message: cancelled ? "Command cancelled." : "The Computer action failed." };
208
+ }
209
+ const locallyPaused = status === "cancelled" && await paused();
210
+ if (locallyPaused) {
211
+ pauseDeadline = Date.now() + (await shutdownRequested() ? 3_000 : 5_000);
212
+ await runtime.stop().catch(() => undefined);
213
+ await writeStatus({ state: "paused", currentCommandId: null, currentAction: null }).catch(() => undefined);
214
+ }
215
+ const commandDeadline = new Date(command.deadlineAt).getTime();
216
+ const completionCutoff = locallyPaused ? Math.min(commandDeadline, pauseDeadline) : commandDeadline;
217
+ const acknowledged = await completeWithRetry(api, command, claimToken, status, result, completionError, completionCutoff);
218
+ if (!acknowledged && !locallyPaused) throw Object.assign(new Error("Command completion was not acknowledged."), { code: "completion_unconfirmed" });
219
+ const outcome = acknowledged ? status : "cancellation unconfirmed";
220
+ console.log(`${new Date().toISOString()} command ${command.id} ${command.action} ${outcome}`);
221
+ } finally {
222
+ clearTimeout(deadline);
223
+ clearInterval(pauseWatch);
224
+ clearInterval(leaseWatch);
225
+ active = null;
226
+ }
227
+ if (!await paused()) await report("available", true);
228
+ } catch (error) {
229
+ if (error.status === 401) throw error;
230
+ if (stopped) break;
231
+ if (await paused()) continue;
232
+ await runtime.stop().catch(() => undefined);
233
+ await writeStatus({ state: "error", detailCode: sanitizedCode(error), currentCommandId: null, currentAction: null });
234
+ await api.heartbeat("error", false).catch(() => undefined);
235
+ console.error(`${new Date().toISOString()} worker error ${sanitizedCode(error)}`);
236
+ await sleep(5_000);
237
+ }
238
+ }
239
+ } finally {
240
+ clearInterval(localWatch);
241
+ active?.controller.abort();
242
+ await runtime.stop().catch(() => undefined);
243
+ await settleWithin(api.heartbeat("offline", false), 5_000);
244
+ await writeStatus({ state: "offline", currentCommandId: null, currentAction: null }).catch(() => undefined);
245
+ await unlink(STOP_FILE).catch(() => undefined);
246
+ await releaseLock();
247
+ process.off("SIGINT", stop);
248
+ process.off("SIGTERM", stop);
249
+ options.signal?.removeEventListener("abort", stop);
250
+ }
251
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@xevy/heny-connect",
3
- "version": "0.2.0",
4
- "description": "Pair a computer with Heny and keep its device presence online.",
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"
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
+ "lib",
11
12
  "windows",
12
13
  "README.md"
13
14
  ],
@@ -15,7 +16,7 @@
15
16
  "test": "node --test test/*.node-test.mjs"
16
17
  },
17
18
  "engines": {
18
- "node": ">=20"
19
+ "node": ">=22"
19
20
  },
20
21
  "publishConfig": {
21
22
  "access": "public"