@xevy/heny-connect 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -34
- package/bin/heny-connect.mjs +61 -54
- package/lib/browser-controller.mjs +377 -0
- package/lib/cdp.mjs +95 -0
- package/lib/computer-runtime.mjs +68 -0
- package/lib/network-policy.mjs +102 -0
- package/lib/process-runner.mjs +62 -0
- package/lib/state.mjs +54 -0
- package/lib/validating-proxy.mjs +70 -0
- package/lib/work-folder.mjs +129 -0
- package/lib/worker.mjs +251 -0
- package/package.json +4 -3
- package/windows/heny-connect-tray.ps1 +112 -154
package/README.md
CHANGED
|
@@ -1,60 +1,74 @@
|
|
|
1
1
|
# @xevy/heny-connect
|
|
2
2
|
|
|
3
|
-
Heny Connect
|
|
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
|
-
|
|
5
|
+
Assigned capabilities cover:
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
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
|
|
9
10
|
|
|
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.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Windows 10 or 11
|
|
16
|
+
- Microsoft Edge or Google Chrome
|
|
17
|
+
- Node.js 22 or newer
|
|
18
|
+
- A six-digit code from Heny **Computers**
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
## Install and pair
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
Open PowerShell once:
|
|
19
23
|
|
|
20
|
-
```
|
|
21
|
-
|
|
24
|
+
```powershell
|
|
25
|
+
npm.cmd install -g @xevy/heny-connect@latest
|
|
26
|
+
heny-connect.cmd pair --code 123456 --server https://heny.vyte.dev
|
|
27
|
+
heny-connect.cmd install
|
|
22
28
|
```
|
|
23
29
|
|
|
24
|
-
The
|
|
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.
|
|
25
31
|
|
|
26
|
-
|
|
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.
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
## System tray
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
heny-connect install
|
|
32
|
-
```
|
|
36
|
+
The tray reports:
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
- Browser starting
|
|
39
|
+
- Available
|
|
40
|
+
- Working, including the current action
|
|
41
|
+
- Paused
|
|
42
|
+
- Error
|
|
43
|
+
- Offline
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
- Pause or resume the connection
|
|
38
|
-
- Reconnect now
|
|
39
|
-
- Turn start-at-sign-in on or off
|
|
40
|
-
- Quit until the next sign-in
|
|
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.
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
## Agent runtimes
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
50
|
+
|
|
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.
|
|
47
52
|
|
|
48
|
-
|
|
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.
|
|
54
|
+
|
|
55
|
+
## Commands
|
|
49
56
|
|
|
50
57
|
```powershell
|
|
51
|
-
heny-connect
|
|
58
|
+
heny-connect.cmd status
|
|
59
|
+
heny-connect.cmd pause
|
|
60
|
+
heny-connect.cmd resume
|
|
61
|
+
heny-connect.cmd tray
|
|
62
|
+
heny-connect.cmd install
|
|
63
|
+
heny-connect.cmd uninstall
|
|
52
64
|
```
|
|
53
65
|
|
|
54
|
-
|
|
66
|
+
`status` shows server registration plus content-free local worker status. Device tokens, browsing content, URLs, page titles, screenshots, and browser debugger details stay out of console output.
|
|
67
|
+
|
|
68
|
+
## Remove start-at-sign-in
|
|
55
69
|
|
|
56
|
-
```
|
|
57
|
-
|
|
70
|
+
```powershell
|
|
71
|
+
heny-connect.cmd uninstall
|
|
58
72
|
```
|
|
59
73
|
|
|
60
|
-
|
|
74
|
+
Use **Quit** from the tray to end the current worker session.
|
package/bin/heny-connect.mjs
CHANGED
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Heny Connect — pairs
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* npx @xevy/heny-connect pair --code 123456 --server https://heny.example
|
|
7
|
-
* npx @xevy/heny-connect run
|
|
8
|
-
* npx @xevy/heny-connect status
|
|
9
|
-
*
|
|
10
|
-
* Pairing state is stored in ~/.heny-connect.json (mode 600).
|
|
3
|
+
* Heny Connect — pairs a Windows computer, owns a dedicated browser profile,
|
|
4
|
+
* and runs bounded browser commands without an open terminal.
|
|
11
5
|
*/
|
|
12
6
|
import { spawn, spawnSync } from "node:child_process";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
7
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
8
|
+
import { hostname, platform, release } from "node:os";
|
|
15
9
|
import { dirname, join } from "node:path";
|
|
16
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { CAPABILITIES, PROTOCOL_VERSION, DeviceApi, runWorker } from "../lib/worker.mjs";
|
|
12
|
+
import { CONNECT_HOME, PAUSE_FILE, STATUS_FILE, loadState, protectConnectHome, saveState, writeStatus } from "../lib/state.mjs";
|
|
17
13
|
|
|
18
|
-
const STATE_FILE = join(process.env.HENY_CONNECT_HOME || homedir(), ".heny-connect.json");
|
|
19
14
|
const CLI_FILE = fileURLToPath(import.meta.url);
|
|
20
15
|
const TRAY_SCRIPT = join(dirname(CLI_FILE), "..", "windows", "heny-connect-tray.ps1");
|
|
21
16
|
const args = process.argv.slice(2);
|
|
@@ -29,63 +24,73 @@ function systemLabel() {
|
|
|
29
24
|
return `${os} ${release()}`;
|
|
30
25
|
}
|
|
31
26
|
|
|
32
|
-
async function loadState() { try { return JSON.parse(await readFile(STATE_FILE, "utf8")); } catch { return null; } }
|
|
33
|
-
async function saveState(state) { await writeFile(STATE_FILE, JSON.stringify(state, null, 2)); await chmod(STATE_FILE, 0o600).catch(() => undefined); }
|
|
34
|
-
|
|
35
27
|
async function call(server, path, body, token) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
28
|
+
const response = await fetch(new URL(path, server), {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
signal: AbortSignal.timeout(20_000),
|
|
33
|
+
});
|
|
34
|
+
let parsed = null;
|
|
35
|
+
try { parsed = await response.json(); } catch {}
|
|
36
|
+
if (!response.ok) throw Object.assign(new Error(parsed?.error?.message || `Server answered HTTP ${response.status}`), { status: response.status, code: parsed?.error?.code || "server_error" });
|
|
40
37
|
return parsed;
|
|
41
38
|
}
|
|
42
39
|
|
|
43
40
|
async function pair() {
|
|
44
|
-
const code = opt("code");
|
|
41
|
+
const code = opt("code");
|
|
42
|
+
const server = opt("server", process.env.HENY_SERVER);
|
|
45
43
|
if (!code || !server) { console.error("Usage: heny-connect pair --code 123456 --server https://heny.example"); process.exitCode = 2; return; }
|
|
46
|
-
|
|
44
|
+
await protectConnectHome();
|
|
45
|
+
const result = await call(server, "/api/devices/pair", { code, system: systemLabel(), hostname: hostname(), capabilities: CAPABILITIES, protocolVersion: PROTOCOL_VERSION });
|
|
47
46
|
await saveState({ server, token: result.token, deviceId: result.deviceId, workspace: result.workspace, pairedAt: new Date().toISOString() });
|
|
47
|
+
await unlink(PAUSE_FILE).catch(() => undefined);
|
|
48
48
|
console.log(`Paired with ${result.workspace?.name ?? "workspace"} as device ${result.deviceId}.`);
|
|
49
|
-
if (
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function heartbeat(state, detail) {
|
|
53
|
-
return call(state.server, "/api/devices/heartbeat", { state: detail.state, detail: detail.message }, state.token);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function sendHeartbeat(stateName = "available", message = `${hostname()}: browser ready`) {
|
|
57
|
-
const state = await loadState();
|
|
58
|
-
if (!state) { console.error("Not paired. Run: heny-connect pair --code … --server …"); process.exitCode = 2; return false; }
|
|
59
|
-
if (!["online", "available", "offline"].includes(stateName)) { console.error("State must be online, available, or offline."); process.exitCode = 2; return false; }
|
|
60
|
-
await heartbeat(state, { state: stateName, message });
|
|
61
|
-
return true;
|
|
49
|
+
if (args.includes("--run")) await run();
|
|
62
50
|
}
|
|
63
51
|
|
|
64
52
|
async function run() {
|
|
65
53
|
const state = await loadState();
|
|
66
|
-
if (!state) { console.error("
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (!beats || count < beats) setInterval(tick, every);
|
|
54
|
+
if (!state) { console.error("This computer needs pairing. Open Heny Desktop to get a code."); process.exitCode = 2; return; }
|
|
55
|
+
try {
|
|
56
|
+
await runWorker(state);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const detailCode = error?.status === 401 ? "re_pair_required" : (/^[a-z0-9_]{1,40}$/.test(error?.code || "") ? error.code : "worker_error");
|
|
59
|
+
await writeStatus({ state: "error", detailCode, currentCommandId: null, currentAction: null }).catch(() => undefined);
|
|
60
|
+
console.error(error?.code === "device_revoked" ? "Registration was revoked. Pair this computer again." : error.message);
|
|
61
|
+
process.exitCode = error?.status === 401 ? 3 : 1;
|
|
62
|
+
}
|
|
76
63
|
}
|
|
77
64
|
|
|
78
65
|
async function heartbeatOnce() {
|
|
66
|
+
const state = await loadState();
|
|
67
|
+
if (!state) { console.error("This computer needs pairing. Open Heny Desktop to get a code."); process.exitCode = 2; return; }
|
|
79
68
|
const stateName = opt("state", "available");
|
|
80
|
-
|
|
81
|
-
|
|
69
|
+
if (!["available", "paused", "offline", "browser_starting", "error"].includes(stateName)) { console.error("Choose a valid worker state."); process.exitCode = 2; return; }
|
|
70
|
+
const browserReady = stateName === "available";
|
|
71
|
+
await new DeviceApi(state).heartbeat(stateName, browserReady);
|
|
72
|
+
console.log(`Heartbeat reported ${stateName}.`);
|
|
82
73
|
}
|
|
83
74
|
|
|
84
75
|
async function status() {
|
|
85
76
|
const state = await loadState();
|
|
86
77
|
if (!state) { console.log("Not paired."); return; }
|
|
87
|
-
const
|
|
88
|
-
|
|
78
|
+
const response = await fetch(new URL("/api/devices/me", state.server), { headers: { authorization: `Bearer ${state.token}` }, signal: AbortSignal.timeout(20_000) });
|
|
79
|
+
let local = null;
|
|
80
|
+
try { local = JSON.parse(await readFile(STATUS_FILE, "utf8")); } catch {}
|
|
81
|
+
if (!response.ok) { console.log(`Server answered HTTP ${response.status}`); return; }
|
|
82
|
+
console.log(JSON.stringify({ ...(await response.json()), local }, null, 2));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function pause() {
|
|
86
|
+
await protectConnectHome();
|
|
87
|
+
await writeFile(PAUSE_FILE, "paused\n", { mode: 0o600 });
|
|
88
|
+
console.log("Heny Connect is pausing.");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resume() {
|
|
92
|
+
await unlink(PAUSE_FILE).catch(() => undefined);
|
|
93
|
+
console.log("Heny Connect is resuming.");
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
function requireWindows() {
|
|
@@ -96,7 +101,7 @@ function requireWindows() {
|
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
function trayArgs(mode) {
|
|
99
|
-
const values = ["-NoLogo", "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", TRAY_SCRIPT, "-NodePath", process.execPath, "-CliPath", CLI_FILE];
|
|
104
|
+
const values = ["-NoLogo", "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", TRAY_SCRIPT, "-NodePath", process.execPath, "-CliPath", CLI_FILE, "-HomePath", CONNECT_HOME];
|
|
100
105
|
if (mode === "install") values.push("-Install");
|
|
101
106
|
if (mode === "uninstall") values.push("-Uninstall");
|
|
102
107
|
return values;
|
|
@@ -104,7 +109,7 @@ function trayArgs(mode) {
|
|
|
104
109
|
|
|
105
110
|
async function tray() {
|
|
106
111
|
if (!requireWindows()) return;
|
|
107
|
-
if (!await loadState()) { console.error("
|
|
112
|
+
if (!await loadState()) { console.error("Pair this computer before starting the tray."); process.exitCode = 2; return; }
|
|
108
113
|
const child = spawn("powershell.exe", trayArgs("tray"), { detached: true, stdio: "ignore", windowsHide: true });
|
|
109
114
|
child.unref();
|
|
110
115
|
console.log("Heny Connect is running in the system tray.");
|
|
@@ -112,7 +117,8 @@ async function tray() {
|
|
|
112
117
|
|
|
113
118
|
async function install() {
|
|
114
119
|
if (!requireWindows()) return;
|
|
115
|
-
if (!await loadState()) { console.error("
|
|
120
|
+
if (!await loadState()) { console.error("Pair this computer before installing the tray."); process.exitCode = 2; return; }
|
|
121
|
+
if (CLI_FILE.includes("\\_npx\\")) { console.error("Install the package globally before enabling start at sign-in: npm.cmd install -g @xevy/heny-connect"); process.exitCode = 2; return; }
|
|
116
122
|
const result = spawnSync("powershell.exe", trayArgs("install"), { encoding: "utf8", windowsHide: true });
|
|
117
123
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
118
124
|
if (result.status !== 0) { if (result.stderr) process.stderr.write(result.stderr); process.exitCode = result.status || 1; }
|
|
@@ -125,10 +131,11 @@ async function uninstall() {
|
|
|
125
131
|
if (result.status !== 0) { if (result.stderr) process.stderr.write(result.stderr); process.exitCode = result.status || 1; }
|
|
126
132
|
}
|
|
127
133
|
|
|
128
|
-
const
|
|
134
|
+
const help = async () => console.log("Commands: pair --code <6 digits> --server <url> [--run] | run | status | pause | resume | heartbeat --state <state> | tray | install | uninstall");
|
|
135
|
+
const commands = { pair, run, status, pause, resume, heartbeat: heartbeatOnce, tray, install, uninstall, help };
|
|
129
136
|
try {
|
|
130
|
-
await (commands[command] ||
|
|
131
|
-
} catch (
|
|
132
|
-
console.error(
|
|
137
|
+
await (commands[command] || help)();
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error(error.message);
|
|
133
140
|
process.exitCode = 1;
|
|
134
141
|
}
|