@melaya/runner 1.1.67 → 1.1.69
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/dist/assistantHost.py +5 -0
- package/dist/browserBridge.js +178 -0
- package/dist/browserFast.d.ts +144 -0
- package/dist/browserFast.js +363 -0
- package/dist/browserProvisioner.d.ts +39 -0
- package/dist/browserProvisioner.js +96 -0
- package/dist/cli.js +180 -66
- package/dist/computerHelperManifest.d.ts +5 -0
- package/dist/computerHelperManifest.js +9 -0
- package/dist/computerHostCli.d.ts +233 -0
- package/dist/computerHostCli.js +808 -0
- package/dist/computerRelay.d.ts +73 -0
- package/dist/computerRelay.js +280 -0
- package/dist/connection.d.ts +12 -2
- package/dist/connection.js +299 -82
- package/dist/detect.d.ts +40 -1
- package/dist/detect.js +194 -36
- package/dist/hostChannel.d.ts +122 -0
- package/dist/hostChannel.js +117 -0
- package/dist/hostFiles.d.ts +25 -0
- package/dist/hostFiles.js +90 -0
- package/dist/linkedinLogin.js +13 -17
- package/dist/lumaLogin.js +11 -17
- package/dist/processTree.d.ts +65 -0
- package/dist/processTree.js +237 -0
- package/dist/publishGate.js +14 -4
- package/dist/pythonEnv.js +6 -7
- package/dist/sharedVendor.js +3 -1
- package/dist/singleInstance.js +10 -1
- package/dist/startupCache.d.ts +22 -0
- package/dist/startupCache.js +45 -0
- package/dist/startupTimings.d.ts +34 -0
- package/dist/startupTimings.js +81 -0
- package/dist/uvBootstrap.js +32 -24
- package/dist/vaults.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computer control hosted by the runner CLI: `npx @melaya/runner --computer`.
|
|
3
|
+
*
|
|
4
|
+
* On a headless Linux machine or VM (and on any computer without the Melaya
|
|
5
|
+
* desktop app) the runner itself hosts the computer executor: it starts the
|
|
6
|
+
* native helper for this platform (desktop/native/computer-linux on Linux; the
|
|
7
|
+
* Windows / macOS helpers speak the same protocol) and drives it over stdio
|
|
8
|
+
* exactly as the desktop app's helperProcess.ts does. The protocol is
|
|
9
|
+
* desktop/native/PROTOCOL.md (v1):
|
|
10
|
+
*
|
|
11
|
+
* helper -> {"event":"ready",...} first line
|
|
12
|
+
* host -> {"id","op":"init","denyPatterns","selfPids"} <- {"id","ok":true}
|
|
13
|
+
* host -> {"id","op":"capabilities"} <- {"id","capabilities"}
|
|
14
|
+
* host -> {"id","op":"ping"} <- {"id","pong":true,"busy","current"}
|
|
15
|
+
* host -> {"id","op":"command","action","allowedAppIds","allowedApps","deadlineMs"} <- {"id","result"}
|
|
16
|
+
* host -> {"op":"cancel","target?"} / {"op":"shutdown"}
|
|
17
|
+
* helper -> events: target, user_input, log
|
|
18
|
+
*
|
|
19
|
+
* Like the desktop app, this host runs `batch` (step by step, per-step
|
|
20
|
+
* `expect`), `keep_awake` (systemd-inhibit / caffeinate) and `handoff` (no one
|
|
21
|
+
* is at a headless screen: unsupported) itself, and never sends them to the
|
|
22
|
+
* helper.
|
|
23
|
+
*
|
|
24
|
+
* computerRelay.ts (server socket <-> executor) loads this module lazily and
|
|
25
|
+
* calls createCliComputerHost({ send, verbose }) when the runner is not inside
|
|
26
|
+
* the desktop app. It returns null unless the CLI was started with --computer
|
|
27
|
+
* (enableCliComputerHost) or MELAYA_COMPUTER=1.
|
|
28
|
+
*
|
|
29
|
+
* Before anything reaches the helper this file enforces what the desktop app's
|
|
30
|
+
* bridge does (the runner cannot import desktop/): known kinds, batch limits,
|
|
31
|
+
* the allowlist, per-app tiers (the server's requiredTierOf,
|
|
32
|
+
* server/src/services/computer/computerEffects.ts) and a hard-deny list of apps
|
|
33
|
+
* no agent may touch (Melaya itself, authentication prompts, lock screens). The
|
|
34
|
+
* helper enforces the allowlist and tiers again per window.
|
|
35
|
+
*
|
|
36
|
+
* Helper binary: --computer-helper <path> or MELAYA_COMPUTER_HELPER, else
|
|
37
|
+
* <package>/native/<platform>-<arch>/, else the per-user cache
|
|
38
|
+
* ~/.melaya/computer/<version>/<platform>-<arch>/ where a pinned release (url +
|
|
39
|
+
* sha256 in HELPER_MANIFEST) is downloaded and verified. Types MIRROR
|
|
40
|
+
* packages/desktop-contract/src/computer.ts (this package cannot import it).
|
|
41
|
+
*/
|
|
42
|
+
import { spawn as rawNodeSpawn } from "child_process";
|
|
43
|
+
import { trackChild } from "./processTree.js";
|
|
44
|
+
// Tracked so runner shutdown stops the helper / inhibitor even if dispose()'s delayed kill never fires.
|
|
45
|
+
const nodeSpawn = ((cmd, args, opts) => trackChild(rawNodeSpawn(cmd, args, opts)));
|
|
46
|
+
import { createHash } from "crypto";
|
|
47
|
+
import { chmodSync, existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
|
|
48
|
+
import { homedir } from "os";
|
|
49
|
+
import { delimiter, dirname, join } from "path";
|
|
50
|
+
import { fileURLToPath } from "url";
|
|
51
|
+
import { COMPUTER_HELPER_MANIFEST, COMPUTER_HELPER_VERSION } from "./computerHelperManifest.js";
|
|
52
|
+
export const COMPUTER_ACTION_KINDS = [
|
|
53
|
+
"screen", "screenshot", "find", "get_text", "current_app", "list_windows", "list_apps", "list_displays",
|
|
54
|
+
"clipboard_read", "ocr", "wait", "hover", "move", "scroll",
|
|
55
|
+
"focus_window", "open_app", "open_url", "open_file", "click", "type", "press", "drag", "select", "set_value",
|
|
56
|
+
"toggle", "window", "clipboard_write", "file_dialog", "batch",
|
|
57
|
+
"zoom", "cursor_position", "read_table", "expect", "mouse", "hold_key", "select_text", "quit_app",
|
|
58
|
+
"save_dialog", "handoff", "keep_awake",
|
|
59
|
+
];
|
|
60
|
+
const READ_KINDS = new Set([
|
|
61
|
+
"screen", "screenshot", "find", "get_text", "current_app", "list_windows", "list_apps",
|
|
62
|
+
"list_displays", "clipboard_read", "ocr", "wait", "hover", "move", "scroll",
|
|
63
|
+
"zoom", "cursor_position", "read_table", "expect", "handoff", "keep_awake",
|
|
64
|
+
]);
|
|
65
|
+
const KIND_SET = new Set(COMPUTER_ACTION_KINDS);
|
|
66
|
+
/** Kinds that do not act on an app window: an empty allowlist does not block them. */
|
|
67
|
+
export const APP_INDEPENDENT_KINDS = new Set(["list_apps", "list_displays", "cursor_position", "keep_awake", "handoff"]);
|
|
68
|
+
/** File handles need the desktop app's handle store (it sends `resolved.path`); the CLI has none. */
|
|
69
|
+
const NEEDS_FILE_HANDLES = new Set(["open_file", "file_dialog", "save_dialog"]);
|
|
70
|
+
export const COMPUTER_MAX_BATCH_STEPS = 100;
|
|
71
|
+
const DEFAULT_DEADLINE_MS = 15_000;
|
|
72
|
+
const MAX_DEADLINE_MS = 31 * 60_000;
|
|
73
|
+
const PROTOCOL_VERSION = 1;
|
|
74
|
+
// ── tiers (port of the server's requiredTierOf) ─────────────────────────────
|
|
75
|
+
const TIER_RANK = { view: 0, click: 1, full: 2 };
|
|
76
|
+
const NAVIGATION_KEYS = new Set([
|
|
77
|
+
"tab", "escape", "esc", "up", "down", "left", "right", "arrowup", "arrowdown", "arrowleft", "arrowright",
|
|
78
|
+
"home", "end", "pageup", "pagedown", "space",
|
|
79
|
+
]);
|
|
80
|
+
const MODIFIER_KEYS = new Set(["shift", "ctrl", "control", "alt", "option", "cmd", "command", "meta", "win", "super"]);
|
|
81
|
+
function splitChord(keys) {
|
|
82
|
+
const parts = keys.split("+").map((p) => p.trim()).filter(Boolean);
|
|
83
|
+
const key = (parts.pop() || "").toLowerCase();
|
|
84
|
+
const mods = new Set(parts.map((m) => {
|
|
85
|
+
const l = m.toLowerCase();
|
|
86
|
+
if (l === "control")
|
|
87
|
+
return "ctrl";
|
|
88
|
+
if (l === "command" || l === "meta" || l === "super" || l === "win")
|
|
89
|
+
return "cmd";
|
|
90
|
+
if (l === "option")
|
|
91
|
+
return "alt";
|
|
92
|
+
return l;
|
|
93
|
+
}));
|
|
94
|
+
return { mods, key };
|
|
95
|
+
}
|
|
96
|
+
/** The lowest app tier that allows this action (same rule as the server and the helpers). */
|
|
97
|
+
export function requiredTierOf(action) {
|
|
98
|
+
const kind = String(action.kind || "");
|
|
99
|
+
if (READ_KINDS.has(kind))
|
|
100
|
+
return "view";
|
|
101
|
+
switch (kind) {
|
|
102
|
+
case "focus_window":
|
|
103
|
+
return "view";
|
|
104
|
+
case "open_app":
|
|
105
|
+
return Array.isArray(action.args) && action.args.length > 0 ? "full" : "view";
|
|
106
|
+
case "click":
|
|
107
|
+
case "select":
|
|
108
|
+
case "toggle":
|
|
109
|
+
case "mouse":
|
|
110
|
+
case "select_text":
|
|
111
|
+
return "click";
|
|
112
|
+
case "window":
|
|
113
|
+
return action.op === "close" ? "full" : "click";
|
|
114
|
+
case "hold_key":
|
|
115
|
+
return MODIFIER_KEYS.has(String(action.key || "").toLowerCase()) ? "click" : "full";
|
|
116
|
+
case "press": {
|
|
117
|
+
const { mods, key } = splitChord(String(action.keys || ""));
|
|
118
|
+
const onlyShift = [...mods].every((m) => m === "shift");
|
|
119
|
+
return NAVIGATION_KEYS.has(key) && onlyShift ? "click" : "full";
|
|
120
|
+
}
|
|
121
|
+
case "batch": {
|
|
122
|
+
let worst = "view";
|
|
123
|
+
for (const s of (Array.isArray(action.steps) ? action.steps : [])) {
|
|
124
|
+
const t = requiredTierOf(s && typeof s === "object" ? s : {});
|
|
125
|
+
if (TIER_RANK[t] > TIER_RANK[worst])
|
|
126
|
+
worst = t;
|
|
127
|
+
}
|
|
128
|
+
return worst;
|
|
129
|
+
}
|
|
130
|
+
default:
|
|
131
|
+
return "full";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// ── hard deny ───────────────────────────────────────────────────────────────
|
|
135
|
+
/**
|
|
136
|
+
* Apps no agent may read or drive, whatever the allowlist says. Sent to the
|
|
137
|
+
* helper at init (it adds them to its own built-in list) and checked here for
|
|
138
|
+
* actions that name an app and for app / window lists coming back. Plain
|
|
139
|
+
* regexes valid in JS, .NET and Rust.
|
|
140
|
+
*/
|
|
141
|
+
export const HARD_DENY_SOURCES = [
|
|
142
|
+
"melaya",
|
|
143
|
+
// authentication prompts and privilege elevation
|
|
144
|
+
"polkit|pkexec|gksu|kdesu|lxsudo",
|
|
145
|
+
"pinentry|gcr-prompter|gnome-keyring|ssh-askpass|ksshaskpass|kwalletd",
|
|
146
|
+
"consent\\.exe|credentialuibroker|logonui|securityagent|coreautha|loginwindow",
|
|
147
|
+
// lock screens and greeters
|
|
148
|
+
"screensaver|light-locker|i3lock|swaylock|xsecurelock|slock|kscreenlocker|lockapp",
|
|
149
|
+
"^gdm|greeter|lightdm|sddm",
|
|
150
|
+
];
|
|
151
|
+
const HARD_DENY = HARD_DENY_SOURCES.map((s) => new RegExp(s, "i"));
|
|
152
|
+
export function isHardDenied(appId) {
|
|
153
|
+
const s = String(appId || "").trim();
|
|
154
|
+
return !!s && HARD_DENY.some((re) => re.test(s));
|
|
155
|
+
}
|
|
156
|
+
// ── command policy (pure; tested) ───────────────────────────────────────────
|
|
157
|
+
function errorResult(kind, code, message, elapsedMs = 0) {
|
|
158
|
+
return { ok: false, kind: kind || "screen", error: { code, message }, elapsedMs };
|
|
159
|
+
}
|
|
160
|
+
function asTier(v) {
|
|
161
|
+
return v === "view" || v === "click" || v === "full" ? v : null;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Everything the CLI host checks before a command reaches the helper: shape,
|
|
165
|
+
* kinds, batch limits, allowlist (minus hard-denied apps), the tier ceiling and
|
|
166
|
+
* the app an action names. Returns the sanitized allowlist to forward.
|
|
167
|
+
*/
|
|
168
|
+
export function checkCommand(msg) {
|
|
169
|
+
const action = (msg.action && typeof msg.action === "object" ? msg.action : null);
|
|
170
|
+
const kind = typeof action?.kind === "string" ? action.kind : "";
|
|
171
|
+
if (!action || !KIND_SET.has(kind))
|
|
172
|
+
return { ok: false, result: errorResult(kind || "screen", "invalid_action", `Unknown action kind "${kind}".`) };
|
|
173
|
+
if (kind === "batch") {
|
|
174
|
+
const steps = action.steps;
|
|
175
|
+
if (!Array.isArray(steps) || steps.length === 0)
|
|
176
|
+
return { ok: false, result: errorResult(kind, "invalid_action", "batch needs at least one step.") };
|
|
177
|
+
if (steps.length > COMPUTER_MAX_BATCH_STEPS)
|
|
178
|
+
return { ok: false, result: errorResult(kind, "invalid_action", `batch takes at most ${COMPUTER_MAX_BATCH_STEPS} steps.`) };
|
|
179
|
+
for (const s of steps) {
|
|
180
|
+
const sk = s && typeof s === "object" ? String(s.kind || "") : "";
|
|
181
|
+
if (sk === "batch")
|
|
182
|
+
return { ok: false, result: errorResult(kind, "invalid_action", "A batch cannot contain another batch.") };
|
|
183
|
+
if (!KIND_SET.has(sk))
|
|
184
|
+
return { ok: false, result: errorResult(kind, "invalid_action", `Unknown step kind "${sk}".`) };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Allowlist: tiers from allowedApps win; bare ids are "full" (PROTOCOL.md §3). Hard-denied ids are dropped.
|
|
188
|
+
const tiers = new Map();
|
|
189
|
+
for (const id of Array.isArray(msg.allowedAppIds) ? msg.allowedAppIds : []) {
|
|
190
|
+
if (typeof id !== "string" || !id.trim())
|
|
191
|
+
continue;
|
|
192
|
+
tiers.set(id.trim().toLowerCase(), { appId: id.trim(), tier: "full" });
|
|
193
|
+
}
|
|
194
|
+
for (const a of Array.isArray(msg.allowedApps) ? msg.allowedApps : []) {
|
|
195
|
+
const appId = typeof a?.appId === "string" ? a.appId.trim() : "";
|
|
196
|
+
if (!appId)
|
|
197
|
+
continue;
|
|
198
|
+
const tier = asTier(a.tier);
|
|
199
|
+
if (tier)
|
|
200
|
+
tiers.set(appId.toLowerCase(), { appId, tier });
|
|
201
|
+
else
|
|
202
|
+
tiers.delete(appId.toLowerCase()); // an unknown tier never widens access
|
|
203
|
+
}
|
|
204
|
+
for (const [k, v] of [...tiers])
|
|
205
|
+
if (isHardDenied(v.appId))
|
|
206
|
+
tiers.delete(k);
|
|
207
|
+
const allowedApps = [...tiers.values()];
|
|
208
|
+
const allowedAppIds = allowedApps.map((a) => a.appId);
|
|
209
|
+
if (allowedApps.length === 0 && !APP_INDEPENDENT_KINDS.has(kind)) {
|
|
210
|
+
return { ok: false, result: errorResult(kind, "not_allowed", "No app is allowed on this computer yet. Ask the user to allow apps on the Computer Control page.") };
|
|
211
|
+
}
|
|
212
|
+
const need = requiredTierOf(action);
|
|
213
|
+
const named = (kind === "open_app" || kind === "quit_app") && typeof action.appId === "string" ? action.appId.trim() : "";
|
|
214
|
+
if (named) {
|
|
215
|
+
if (isHardDenied(named))
|
|
216
|
+
return { ok: false, result: errorResult(kind, "not_allowed", "Agents never open or quit that app.") };
|
|
217
|
+
const t = tiers.get(named.toLowerCase());
|
|
218
|
+
if (!t)
|
|
219
|
+
return { ok: false, result: errorResult(kind, "not_allowed", `${named} is not on the user's allowlist. Allowed apps: ${allowedAppIds.join(", ") || "none"}.`) };
|
|
220
|
+
const needNamed = kind === "quit_app" ? "full" : need;
|
|
221
|
+
if (TIER_RANK[t.tier] < TIER_RANK[needNamed]) {
|
|
222
|
+
return { ok: false, result: errorResult(kind, "tier_denied", `${named} is allowed at the "${t.tier}" tier, and ${kind} needs "${needNamed}".`) };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (allowedApps.length > 0) {
|
|
226
|
+
const max = allowedApps.reduce((m, a) => (TIER_RANK[a.tier] > TIER_RANK[m] ? a.tier : m), "view");
|
|
227
|
+
if (TIER_RANK[need] > TIER_RANK[max]) {
|
|
228
|
+
return { ok: false, result: errorResult(kind, "tier_denied", `No allowed app has the "${need}" tier that ${kind} needs. Ask the user to raise an app's tier on the Computer Control page.`) };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const deadlineMs = Math.min(MAX_DEADLINE_MS, Math.max(1_000, Math.floor(Number(msg.deadlineMs) || DEFAULT_DEADLINE_MS)));
|
|
232
|
+
return { ok: true, action, allowedApps, allowedAppIds, deadlineMs };
|
|
233
|
+
}
|
|
234
|
+
// ── helper binary ───────────────────────────────────────────────────────────
|
|
235
|
+
/**
|
|
236
|
+
* Pinned helper releases, one per platform-arch, from computerHelperManifest.ts:
|
|
237
|
+
* generated by the release job (desktop-release.yml,
|
|
238
|
+
* .github/scripts/write-computer-helper-manifest.mjs) with the published URL
|
|
239
|
+
* and its sha256, and committed with the release. A download is only ever
|
|
240
|
+
* accepted when its sha256 matches. Empty until the first release: use
|
|
241
|
+
* MELAYA_COMPUTER_HELPER or --computer-helper meanwhile.
|
|
242
|
+
*/
|
|
243
|
+
export const HELPER_VERSION = COMPUTER_HELPER_VERSION;
|
|
244
|
+
export const HELPER_MANIFEST = { ...COMPUTER_HELPER_MANIFEST };
|
|
245
|
+
export function platformKey(platform = process.platform, arch = process.arch) {
|
|
246
|
+
return `${platform}-${arch}`;
|
|
247
|
+
}
|
|
248
|
+
function binaryNames(platform) {
|
|
249
|
+
return platform === "win32" ? ["melaya-computer.exe"] : ["melaya-computer", "MelayaComputer"];
|
|
250
|
+
}
|
|
251
|
+
function packageDir() {
|
|
252
|
+
try {
|
|
253
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return process.cwd();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
export function helperCacheDir(env = process.env) {
|
|
260
|
+
return join(env.MELAYA_HOME || join(homedir(), ".melaya"), "computer", HELPER_VERSION, platformKey());
|
|
261
|
+
}
|
|
262
|
+
/** Finds the helper binary on disk (no download). */
|
|
263
|
+
export function resolveHelperPath(opts = {}) {
|
|
264
|
+
const env = opts.env ?? process.env;
|
|
265
|
+
const exists = opts.exists ?? existsSync;
|
|
266
|
+
const explicit = opts.helperPath || env.MELAYA_COMPUTER_HELPER;
|
|
267
|
+
if (explicit)
|
|
268
|
+
return exists(explicit) ? explicit : null;
|
|
269
|
+
const dirs = [join(packageDir(), "native", platformKey()), helperCacheDir(env)];
|
|
270
|
+
for (const d of dirs) {
|
|
271
|
+
for (const n of binaryNames(process.platform)) {
|
|
272
|
+
const p = join(d, n);
|
|
273
|
+
if (exists(p))
|
|
274
|
+
return p;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
/** Downloads the pinned helper for this platform into the per-user cache and verifies its sha256. */
|
|
280
|
+
export async function downloadHelper(opts = {}) {
|
|
281
|
+
const entry = (opts.manifest ?? HELPER_MANIFEST)[platformKey()];
|
|
282
|
+
if (!entry)
|
|
283
|
+
return null;
|
|
284
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
285
|
+
const res = await fetchFn(entry.url);
|
|
286
|
+
if (!res.ok)
|
|
287
|
+
throw new Error(`helper download failed: HTTP ${res.status}`);
|
|
288
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
289
|
+
const sum = createHash("sha256").update(buf).digest("hex");
|
|
290
|
+
if (sum !== entry.sha256.toLowerCase())
|
|
291
|
+
throw new Error("helper download failed: sha256 mismatch");
|
|
292
|
+
const dir = helperCacheDir(opts.env);
|
|
293
|
+
mkdirSync(dir, { recursive: true });
|
|
294
|
+
const target = join(dir, binaryNames(process.platform)[0]);
|
|
295
|
+
const tmp = `${target}.part`;
|
|
296
|
+
writeFileSync(tmp, buf);
|
|
297
|
+
if (process.platform !== "win32")
|
|
298
|
+
chmodSync(tmp, 0o755);
|
|
299
|
+
renameSync(tmp, target);
|
|
300
|
+
opts.log?.(` [computer] helper ${HELPER_VERSION} downloaded and verified`);
|
|
301
|
+
return target;
|
|
302
|
+
}
|
|
303
|
+
export const HELPER_MISSING_MESSAGE = "The computer helper is not installed on this machine. Build it (desktop/native/computer-linux: ./build.sh) and start the runner with MELAYA_COMPUTER_HELPER=/path/to/melaya-computer, or pass --computer-helper.";
|
|
304
|
+
/** Splits a byte stream into JSON objects, one per line; tolerant of partial chunks, CRLF and noise. */
|
|
305
|
+
export class LineDecoder {
|
|
306
|
+
buf = "";
|
|
307
|
+
push(chunk, onObject, onNoise) {
|
|
308
|
+
this.buf += chunk;
|
|
309
|
+
let i;
|
|
310
|
+
while ((i = this.buf.indexOf("\n")) >= 0) {
|
|
311
|
+
const line = this.buf.slice(0, i).replace(/\r$/, "").trim();
|
|
312
|
+
this.buf = this.buf.slice(i + 1);
|
|
313
|
+
if (!line)
|
|
314
|
+
continue;
|
|
315
|
+
try {
|
|
316
|
+
const o = JSON.parse(line);
|
|
317
|
+
if (o && typeof o === "object" && !Array.isArray(o))
|
|
318
|
+
onObject(o);
|
|
319
|
+
else
|
|
320
|
+
onNoise?.(line);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
onNoise?.(line);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// The host accepts lines up to 64 MiB (a 4K screenshot is ~10 MB); drop garbage beyond that.
|
|
327
|
+
if (this.buf.length > 64 * 1024 * 1024)
|
|
328
|
+
this.buf = "";
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
export class HelperProcess {
|
|
332
|
+
path;
|
|
333
|
+
opts;
|
|
334
|
+
child = null;
|
|
335
|
+
waiters = new Map();
|
|
336
|
+
aborted = new Map();
|
|
337
|
+
seq = 0;
|
|
338
|
+
restarts = [];
|
|
339
|
+
disposed = false;
|
|
340
|
+
starting = null;
|
|
341
|
+
readyResolve = null;
|
|
342
|
+
ready = null;
|
|
343
|
+
constructor(path, opts = {}) {
|
|
344
|
+
this.path = path;
|
|
345
|
+
this.opts = opts;
|
|
346
|
+
}
|
|
347
|
+
log(l) { this.opts.log?.(l); }
|
|
348
|
+
async start() {
|
|
349
|
+
if (this.child)
|
|
350
|
+
return;
|
|
351
|
+
if (this.disposed)
|
|
352
|
+
throw new Error("helper disposed");
|
|
353
|
+
this.starting ??= (async () => {
|
|
354
|
+
const spawnFn = this.opts.spawn ?? nodeSpawn;
|
|
355
|
+
const child = spawnFn(this.path, [], { stdio: ["pipe", "pipe", "pipe"], env: this.opts.env ?? process.env });
|
|
356
|
+
this.child = child;
|
|
357
|
+
const ready = new Promise((r) => { this.readyResolve = r; });
|
|
358
|
+
const dec = new LineDecoder();
|
|
359
|
+
child.stdout?.setEncoding("utf8");
|
|
360
|
+
child.stdout?.on("data", (d) => dec.push(d, (o) => this.onMessage(o), (l) => this.log(` [computer] helper: ${l.slice(0, 200)}`)));
|
|
361
|
+
child.stderr?.setEncoding("utf8");
|
|
362
|
+
child.stderr?.on("data", (d) => { for (const l of String(d).split("\n"))
|
|
363
|
+
if (l.trim())
|
|
364
|
+
this.log(` [computer] helper: ${l.slice(0, 300)}`); });
|
|
365
|
+
child.stdin?.on("error", () => { });
|
|
366
|
+
child.on("exit", (code, signal) => this.onExit(child, code, signal));
|
|
367
|
+
child.on("error", (e) => { this.log(` [computer] helper failed to start: ${e.message}`); this.onExit(child, null, null); });
|
|
368
|
+
// The helper announces itself before reading anything; older builds may not: do not wait forever.
|
|
369
|
+
await Promise.race([ready, new Promise((r) => setTimeout(r, 3_000).unref?.())]);
|
|
370
|
+
if (!this.child)
|
|
371
|
+
throw new Error("helper exited at start");
|
|
372
|
+
// Deny list + our own pid: the helper never treats Melaya or auth prompts as targets.
|
|
373
|
+
await this.request({ op: "init", protocolVersion: PROTOCOL_VERSION, denyPatterns: HARD_DENY_SOURCES, selfPids: [process.pid] }, 5_000).catch(() => undefined);
|
|
374
|
+
})().finally(() => { this.starting = null; });
|
|
375
|
+
return this.starting;
|
|
376
|
+
}
|
|
377
|
+
onMessage(o) {
|
|
378
|
+
// Events first: a `target` event carries the command id too and must never be taken for its reply.
|
|
379
|
+
if (typeof o.event === "string") {
|
|
380
|
+
if (o.event === "ready") {
|
|
381
|
+
this.ready = o;
|
|
382
|
+
this.readyResolve?.();
|
|
383
|
+
}
|
|
384
|
+
this.opts.onEvent?.(o);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const id = typeof o.id === "string" ? o.id : (typeof o.id === "number" ? String(o.id) : "");
|
|
388
|
+
if (!id)
|
|
389
|
+
return;
|
|
390
|
+
const late = this.aborted.get(id);
|
|
391
|
+
if (late) {
|
|
392
|
+
clearTimeout(late);
|
|
393
|
+
this.aborted.delete(id);
|
|
394
|
+
return;
|
|
395
|
+
} // a cancelled command freed the lane
|
|
396
|
+
const w = this.waiters.get(id);
|
|
397
|
+
if (!w)
|
|
398
|
+
return;
|
|
399
|
+
this.waiters.delete(id);
|
|
400
|
+
clearTimeout(w.timer);
|
|
401
|
+
w.resolve(o);
|
|
402
|
+
}
|
|
403
|
+
onExit(child, code, signal) {
|
|
404
|
+
if (this.child !== child)
|
|
405
|
+
return;
|
|
406
|
+
this.child = null;
|
|
407
|
+
this.readyResolve?.();
|
|
408
|
+
for (const [id, w] of this.waiters) {
|
|
409
|
+
clearTimeout(w.timer);
|
|
410
|
+
w.reject(new Error(`helper exited (${signal || code})`));
|
|
411
|
+
this.waiters.delete(id);
|
|
412
|
+
}
|
|
413
|
+
for (const t of this.aborted.values())
|
|
414
|
+
clearTimeout(t);
|
|
415
|
+
this.aborted.clear();
|
|
416
|
+
if (!this.disposed) {
|
|
417
|
+
const now = Date.now();
|
|
418
|
+
this.restarts = this.restarts.filter((t) => now - t < 60_000);
|
|
419
|
+
this.restarts.push(now);
|
|
420
|
+
this.log(` [computer] helper exited (${signal || code}); it restarts on the next command${this.restarts.length >= 3 ? " (crashing repeatedly)" : ""}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
get crashLooping() {
|
|
424
|
+
return this.restarts.length >= 3;
|
|
425
|
+
}
|
|
426
|
+
/** Writes one request and waits for the line with the same id. */
|
|
427
|
+
async request(msg, timeoutMs) {
|
|
428
|
+
if (!this.child)
|
|
429
|
+
await this.start();
|
|
430
|
+
const child = this.child;
|
|
431
|
+
if (!child?.stdin)
|
|
432
|
+
throw new Error("helper not running");
|
|
433
|
+
const id = typeof msg.id === "string" && msg.id ? msg.id : `h${++this.seq}`;
|
|
434
|
+
return new Promise((resolve, reject) => {
|
|
435
|
+
const timer = setTimeout(() => {
|
|
436
|
+
this.waiters.delete(id);
|
|
437
|
+
reject(new Error("helper timeout"));
|
|
438
|
+
}, timeoutMs);
|
|
439
|
+
this.waiters.set(id, { resolve, reject, timer });
|
|
440
|
+
try {
|
|
441
|
+
child.stdin.write(JSON.stringify({ ...msg, id }) + "\n");
|
|
442
|
+
}
|
|
443
|
+
catch (e) {
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
this.waiters.delete(id);
|
|
446
|
+
reject(e);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
/** Fire-and-forget line. */
|
|
451
|
+
send(msg) {
|
|
452
|
+
try {
|
|
453
|
+
this.child?.stdin?.write(JSON.stringify(msg) + "\n");
|
|
454
|
+
}
|
|
455
|
+
catch { /* helper gone */ }
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Aborts one command (or every one). A command that has not answered `stopped`
|
|
459
|
+
* within the grace period gets the helper killed; it restarts on the next command.
|
|
460
|
+
*/
|
|
461
|
+
cancel(target) {
|
|
462
|
+
this.send(target ? { op: "cancel", target } : { op: "cancel" });
|
|
463
|
+
if (!target || !this.child)
|
|
464
|
+
return;
|
|
465
|
+
const child = this.child;
|
|
466
|
+
this.aborted.set(target, setTimeout(() => {
|
|
467
|
+
this.aborted.delete(target);
|
|
468
|
+
if (this.child === child) {
|
|
469
|
+
this.log(" [computer] the helper did not stop in time: restarting it");
|
|
470
|
+
try {
|
|
471
|
+
child.kill("SIGKILL");
|
|
472
|
+
}
|
|
473
|
+
catch { /* ignore */ }
|
|
474
|
+
}
|
|
475
|
+
}, this.opts.abortGraceMs ?? 3_000));
|
|
476
|
+
}
|
|
477
|
+
get running() {
|
|
478
|
+
return this.child !== null;
|
|
479
|
+
}
|
|
480
|
+
dispose() {
|
|
481
|
+
this.disposed = true;
|
|
482
|
+
for (const [, w] of this.waiters) {
|
|
483
|
+
clearTimeout(w.timer);
|
|
484
|
+
w.reject(new Error("helper disposed"));
|
|
485
|
+
}
|
|
486
|
+
this.waiters.clear();
|
|
487
|
+
for (const t of this.aborted.values())
|
|
488
|
+
clearTimeout(t);
|
|
489
|
+
this.aborted.clear();
|
|
490
|
+
const c = this.child;
|
|
491
|
+
this.child = null;
|
|
492
|
+
if (c) {
|
|
493
|
+
try {
|
|
494
|
+
c.stdin?.write(JSON.stringify({ op: "shutdown" }) + "\n");
|
|
495
|
+
c.stdin?.end();
|
|
496
|
+
}
|
|
497
|
+
catch { /* ignore */ }
|
|
498
|
+
setTimeout(() => { try {
|
|
499
|
+
c.kill();
|
|
500
|
+
}
|
|
501
|
+
catch { /* ignore */ } }, 1_000).unref?.();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// ── keep_awake (run by the host, PROTOCOL.md §7) ────────────────────────────
|
|
506
|
+
function onPath(cmd, env) {
|
|
507
|
+
for (const d of String(env.PATH || "").split(delimiter)) {
|
|
508
|
+
if (d && existsSync(join(d, cmd)))
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
/** Holds a sleep inhibitor child process: systemd-inhibit (Linux) or caffeinate (macOS). */
|
|
514
|
+
export class KeepAwake {
|
|
515
|
+
opts;
|
|
516
|
+
child = null;
|
|
517
|
+
constructor(opts = {}) {
|
|
518
|
+
this.opts = opts;
|
|
519
|
+
}
|
|
520
|
+
available() {
|
|
521
|
+
const env = this.opts.env ?? process.env;
|
|
522
|
+
const platform = this.opts.platform ?? process.platform;
|
|
523
|
+
if (platform === "linux" && onPath("systemd-inhibit", env))
|
|
524
|
+
return "systemd-inhibit";
|
|
525
|
+
if (platform === "darwin" && onPath("caffeinate", env))
|
|
526
|
+
return "caffeinate";
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
get active() {
|
|
530
|
+
return this.child !== null;
|
|
531
|
+
}
|
|
532
|
+
acquire() {
|
|
533
|
+
if (this.child)
|
|
534
|
+
return "already";
|
|
535
|
+
const tool = this.available();
|
|
536
|
+
if (!tool)
|
|
537
|
+
return null;
|
|
538
|
+
const spawnFn = this.opts.spawn ?? nodeSpawn;
|
|
539
|
+
const args = tool === "systemd-inhibit"
|
|
540
|
+
? ["--what=idle:sleep", "--who=Melaya", "--why=An agent is using this computer", "--mode=block", "sleep", "infinity"]
|
|
541
|
+
: ["-dimsu"];
|
|
542
|
+
const child = spawnFn(tool, args, { stdio: ["pipe", "pipe", "pipe"], env: this.opts.env ?? process.env });
|
|
543
|
+
child.on("exit", () => { if (this.child === child)
|
|
544
|
+
this.child = null; });
|
|
545
|
+
child.on("error", () => { if (this.child === child)
|
|
546
|
+
this.child = null; });
|
|
547
|
+
this.child = child;
|
|
548
|
+
return tool;
|
|
549
|
+
}
|
|
550
|
+
release() {
|
|
551
|
+
const c = this.child;
|
|
552
|
+
this.child = null;
|
|
553
|
+
if (c) {
|
|
554
|
+
try {
|
|
555
|
+
c.kill();
|
|
556
|
+
}
|
|
557
|
+
catch { /* ignore */ }
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
let cliEnabled = false;
|
|
562
|
+
let cliDefaults = {};
|
|
563
|
+
/** Called by cli.ts for `--computer`: later createCliComputerHost() calls (from the relay) are enabled. */
|
|
564
|
+
export function enableCliComputerHost(opts = {}) {
|
|
565
|
+
cliEnabled = true;
|
|
566
|
+
cliDefaults = { ...cliDefaults, ...opts };
|
|
567
|
+
}
|
|
568
|
+
export function isCliComputerEnabled(env = process.env) {
|
|
569
|
+
return cliEnabled || env.MELAYA_COMPUTER === "1" || env.MELAYA_COMPUTER === "true";
|
|
570
|
+
}
|
|
571
|
+
/** Test seam: forget enableCliComputerHost(). */
|
|
572
|
+
export function _resetCliComputerHost() {
|
|
573
|
+
cliEnabled = false;
|
|
574
|
+
cliDefaults = {};
|
|
575
|
+
}
|
|
576
|
+
/** Real user input seen this recently pauses a batch (`user_interrupted`). */
|
|
577
|
+
const USER_INPUT_PAUSE_MS = 1_500;
|
|
578
|
+
export function createCliComputerHost(options = {}) {
|
|
579
|
+
const opts = { ...cliDefaults, ...options };
|
|
580
|
+
const env = opts.env ?? process.env;
|
|
581
|
+
const enabled = opts.enabled ?? isCliComputerEnabled(env);
|
|
582
|
+
if (!enabled)
|
|
583
|
+
return null;
|
|
584
|
+
const log = (l) => { if (opts.verbose || opts.log)
|
|
585
|
+
(opts.log ?? console.log)(l); };
|
|
586
|
+
let helper = null;
|
|
587
|
+
let helperPath = resolveHelperPath({ helperPath: opts.helperPath, env });
|
|
588
|
+
let resolving = null;
|
|
589
|
+
let caps;
|
|
590
|
+
let capsAt = 0;
|
|
591
|
+
let disposed = false;
|
|
592
|
+
let lastUserInput = 0;
|
|
593
|
+
const keepAwake = opts.keepAwake ?? new KeepAwake({ spawn: opts.spawn, env });
|
|
594
|
+
const ensureHelper = async () => {
|
|
595
|
+
if (disposed)
|
|
596
|
+
return null;
|
|
597
|
+
if (helper)
|
|
598
|
+
return helper;
|
|
599
|
+
if (!helperPath && opts.download !== false && !opts.helperPath && !env.MELAYA_COMPUTER_HELPER) {
|
|
600
|
+
resolving ??= downloadHelper({ env, log }).catch((e) => { log(` [computer] ${e.message}`); return null; });
|
|
601
|
+
helperPath = await resolving;
|
|
602
|
+
}
|
|
603
|
+
if (!helperPath)
|
|
604
|
+
return null;
|
|
605
|
+
helper = new HelperProcess(helperPath, {
|
|
606
|
+
spawn: opts.spawn,
|
|
607
|
+
env,
|
|
608
|
+
log,
|
|
609
|
+
abortGraceMs: opts.abortGraceMs,
|
|
610
|
+
onEvent: (e) => {
|
|
611
|
+
if (e.event === "user_input") {
|
|
612
|
+
lastUserInput = Date.now();
|
|
613
|
+
log(" [computer] someone is using this computer: runs pause");
|
|
614
|
+
}
|
|
615
|
+
else if (e.event === "log") {
|
|
616
|
+
log(` [computer] ${String(e.message || "").slice(0, 300)}`);
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
return helper;
|
|
621
|
+
};
|
|
622
|
+
const capabilities = async () => {
|
|
623
|
+
if (caps !== undefined && Date.now() - capsAt < 60_000)
|
|
624
|
+
return caps;
|
|
625
|
+
const h = await ensureHelper();
|
|
626
|
+
if (!h) {
|
|
627
|
+
log(` [computer] ${HELPER_MISSING_MESSAGE}`);
|
|
628
|
+
caps = null;
|
|
629
|
+
capsAt = Date.now();
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
const reply = await h.request({ op: "capabilities" }, 10_000);
|
|
634
|
+
const c = reply.capabilities && typeof reply.capabilities === "object" ? reply.capabilities : null;
|
|
635
|
+
if (c) {
|
|
636
|
+
// What this host adds (batch, keep_awake) and cannot do (file handles, the hand-off overlay).
|
|
637
|
+
const kinds = new Set((Array.isArray(c.kinds) ? c.kinds : []).filter((k) => !NEEDS_FILE_HANDLES.has(k) && k !== "handoff"));
|
|
638
|
+
kinds.add("batch");
|
|
639
|
+
if (keepAwake.available())
|
|
640
|
+
kinds.add("keep_awake");
|
|
641
|
+
else
|
|
642
|
+
kinds.delete("keep_awake");
|
|
643
|
+
caps = { ...c, kinds: COMPUTER_ACTION_KINDS.filter((k) => kinds.has(k)), host: "cli" };
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
caps = null;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
catch (e) {
|
|
650
|
+
log(` [computer] helper did not answer: ${e.message}`);
|
|
651
|
+
caps = null;
|
|
652
|
+
}
|
|
653
|
+
capsAt = Date.now();
|
|
654
|
+
return caps;
|
|
655
|
+
};
|
|
656
|
+
/** One action through the helper (never batch / keep_awake / handoff). */
|
|
657
|
+
const runOnHelper = async (id, action, allowedAppIds, allowedApps, deadlineMs) => {
|
|
658
|
+
const kind = String(action.kind || "screen");
|
|
659
|
+
const started = Date.now();
|
|
660
|
+
if (NEEDS_FILE_HANDLES.has(kind)) {
|
|
661
|
+
return errorResult(kind, "unsupported", "File handles need the Melaya desktop app; this computer runs the runner only. Ask the user for the file, or type a path they gave you into the app's own dialog.");
|
|
662
|
+
}
|
|
663
|
+
const h = await ensureHelper();
|
|
664
|
+
if (!h)
|
|
665
|
+
return errorResult(kind, "helper_unavailable", HELPER_MISSING_MESSAGE);
|
|
666
|
+
if (h.crashLooping && !h.running)
|
|
667
|
+
return errorResult(kind, "helper_unavailable", "The computer helper keeps crashing on this machine. Check the runner log (--verbose).");
|
|
668
|
+
try {
|
|
669
|
+
// The helper keeps its own deadline; the extra second covers the line round trip.
|
|
670
|
+
const reply = await h.request({ id, op: "command", action, allowedAppIds, allowedApps, deadlineMs }, deadlineMs + 1_000);
|
|
671
|
+
const result = reply.result;
|
|
672
|
+
if (!result || typeof result !== "object" || typeof result.ok !== "boolean") {
|
|
673
|
+
return errorResult(kind, "internal", "The computer helper answered without a result.", Date.now() - started);
|
|
674
|
+
}
|
|
675
|
+
return filterResult(result);
|
|
676
|
+
}
|
|
677
|
+
catch (e) {
|
|
678
|
+
const m = e.message || String(e);
|
|
679
|
+
if (m === "helper timeout") {
|
|
680
|
+
h.cancel(id);
|
|
681
|
+
return errorResult(kind, "timeout", "The computer did not finish before the deadline. Read the screen before retrying: the action may or may not have happened.", Date.now() - started);
|
|
682
|
+
}
|
|
683
|
+
return errorResult(kind, "helper_unavailable", `The computer helper stopped: ${m}`, Date.now() - started);
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
/** batch, run here step by step (PROTOCOL.md §7): per-step expect, onError, user-input pause, shared deadline. */
|
|
687
|
+
const runBatch = async (id, action, allowedAppIds, allowedApps, deadlineMs) => {
|
|
688
|
+
const started = Date.now();
|
|
689
|
+
const steps = action.steps || [];
|
|
690
|
+
const stopDefault = action.stopOnError !== false;
|
|
691
|
+
const results = [];
|
|
692
|
+
let failed = 0;
|
|
693
|
+
const left = () => deadlineMs - (Date.now() - started);
|
|
694
|
+
for (let i = 0; i < steps.length; i++) {
|
|
695
|
+
const step = steps[i];
|
|
696
|
+
const kind = String(step.kind || "");
|
|
697
|
+
let r;
|
|
698
|
+
let expectFailed = false;
|
|
699
|
+
if (i > 0 && Date.now() - lastUserInput < USER_INPUT_PAUSE_MS) {
|
|
700
|
+
r = errorResult(kind, "user_interrupted", "Someone started using the mouse or keyboard, so the batch paused here. Read the screen before continuing.");
|
|
701
|
+
}
|
|
702
|
+
else if (left() < 200) {
|
|
703
|
+
r = errorResult(kind, "timeout", "The batch ran out of time before this step.");
|
|
704
|
+
}
|
|
705
|
+
else if (kind === "keep_awake" || kind === "handoff") {
|
|
706
|
+
r = await runLocal(kind, step);
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
const { expect: cond, onError: _onError, expectTimeoutMs, ...plain } = step;
|
|
710
|
+
r = await runOnHelper(`${id}.${i + 1}`, plain, allowedAppIds, allowedApps, Math.max(500, left()));
|
|
711
|
+
if (r.ok && cond && typeof cond === "object") {
|
|
712
|
+
const timeoutMs = Math.min(Math.max(100, Number(expectTimeoutMs) || 2_000), Math.max(100, left() - 100));
|
|
713
|
+
const ex = await runOnHelper(`${id}.${i + 1}e`, { kind: "expect", condition: cond, timeoutMs }, allowedAppIds, allowedApps, timeoutMs + 500);
|
|
714
|
+
if (!ex.ok) {
|
|
715
|
+
expectFailed = true;
|
|
716
|
+
const em = ex.error?.message || "the expectation failed";
|
|
717
|
+
r = { ok: false, kind, error: { code: ex.error?.code || "timeout", message: `The step ran but its expectation failed: ${em}` }, elapsedMs: r.elapsedMs };
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
results.push(r);
|
|
722
|
+
if (!r.ok) {
|
|
723
|
+
failed++;
|
|
724
|
+
const code = String(r.error?.code || "internal");
|
|
725
|
+
const onError = step.onError === "continue" ? false : step.onError === "stop" ? true : stopDefault;
|
|
726
|
+
// A failed expectation follows onError; a stop, a timeout or a person at the keyboard always ends the batch.
|
|
727
|
+
const forced = !expectFailed && ["stopped", "timeout", "user_interrupted", "screen_locked", "secure_desktop"].includes(code);
|
|
728
|
+
if (onError || forced) {
|
|
729
|
+
const msg = r.error?.message || "";
|
|
730
|
+
return { ok: false, kind: "batch", error: { code, message: `Step ${i + 1} failed: ${msg}` }, steps: results, elapsedMs: Date.now() - started };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const text = failed === 0 ? `All ${results.length} steps done.` : `${results.length} steps run, ${failed} failed (onError continue).`;
|
|
735
|
+
return { ok: true, kind: "batch", text, steps: results, elapsedMs: Date.now() - started };
|
|
736
|
+
};
|
|
737
|
+
/** keep_awake and handoff, run by the host. */
|
|
738
|
+
const runLocal = async (kind, action) => {
|
|
739
|
+
if (kind === "handoff") {
|
|
740
|
+
return errorResult(kind, "unsupported", "No one is at this computer's screen to take over (runner-hosted computer). Ask the user in chat instead.");
|
|
741
|
+
}
|
|
742
|
+
if (action.on === false) {
|
|
743
|
+
keepAwake.release();
|
|
744
|
+
return { ok: true, kind, text: "The computer may sleep again.", elapsedMs: 0 };
|
|
745
|
+
}
|
|
746
|
+
const tool = keepAwake.acquire();
|
|
747
|
+
if (!tool)
|
|
748
|
+
return errorResult(kind, "unsupported", "No sleep inhibitor is available here (systemd-inhibit on Linux, caffeinate on macOS). Headless VMs usually never sleep; check the VM's power settings.");
|
|
749
|
+
return { ok: true, kind, text: `Keeping the computer awake (${tool}). Released on stop.`, elapsedMs: 0 };
|
|
750
|
+
};
|
|
751
|
+
const run = async (msg) => {
|
|
752
|
+
const check = checkCommand(msg);
|
|
753
|
+
if (!check.ok)
|
|
754
|
+
return check.result;
|
|
755
|
+
const kind = String(check.action.kind);
|
|
756
|
+
const id = typeof msg.id === "string" && msg.id ? msg.id : `cli${Date.now()}`;
|
|
757
|
+
if (kind === "keep_awake" || kind === "handoff")
|
|
758
|
+
return runLocal(kind, check.action);
|
|
759
|
+
if (kind === "batch")
|
|
760
|
+
return runBatch(id, check.action, check.allowedAppIds, check.allowedApps, check.deadlineMs);
|
|
761
|
+
return runOnHelper(id, check.action, check.allowedAppIds, check.allowedApps, check.deadlineMs);
|
|
762
|
+
};
|
|
763
|
+
const api = {
|
|
764
|
+
async handle(msg) {
|
|
765
|
+
if (!msg || typeof msg !== "object")
|
|
766
|
+
return;
|
|
767
|
+
if (msg.type === "computer.capabilities.request") {
|
|
768
|
+
const c = await capabilities();
|
|
769
|
+
const out = { type: "computer.capabilities", capabilities: c };
|
|
770
|
+
opts.send?.(out);
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
if (msg.type === "computer.stop") {
|
|
774
|
+
api.stopAll(msg.reason);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (msg.type === "computer.command") {
|
|
778
|
+
const id = typeof msg.id === "string" ? msg.id : "";
|
|
779
|
+
const result = await run(msg);
|
|
780
|
+
return { type: "computer.result", id, result };
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
capabilities,
|
|
784
|
+
stopAll(reason) {
|
|
785
|
+
log(` [computer] stop (${String(reason || "stop").slice(0, 200)})`);
|
|
786
|
+
helper?.cancel();
|
|
787
|
+
keepAwake.release();
|
|
788
|
+
},
|
|
789
|
+
dispose() {
|
|
790
|
+
disposed = true;
|
|
791
|
+
keepAwake.release();
|
|
792
|
+
helper?.dispose();
|
|
793
|
+
helper = null;
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
return api;
|
|
797
|
+
}
|
|
798
|
+
/** Defense in depth on what goes back to the server: no hard-denied app in app / window lists. */
|
|
799
|
+
function filterResult(r) {
|
|
800
|
+
const out = { ...r };
|
|
801
|
+
if (Array.isArray(out.apps))
|
|
802
|
+
out.apps = out.apps.filter((a) => !isHardDenied(String(a?.appId || "")));
|
|
803
|
+
if (Array.isArray(out.windows))
|
|
804
|
+
out.windows = out.windows.filter((w) => !isHardDenied(String(w?.appId || "")));
|
|
805
|
+
if (Array.isArray(out.steps))
|
|
806
|
+
out.steps = out.steps.map((s) => (s && typeof s === "object" ? filterResult(s) : s));
|
|
807
|
+
return out;
|
|
808
|
+
}
|