@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
package/dist/cli.js
CHANGED
|
@@ -17,7 +17,10 @@ import { Command } from "commander";
|
|
|
17
17
|
import chalk from "chalk";
|
|
18
18
|
import { reportInstallFailure } from "./installReport.js";
|
|
19
19
|
import { connect } from "./connection.js";
|
|
20
|
-
import {
|
|
20
|
+
import { scanProviders } from "./detect.js";
|
|
21
|
+
import { isHosted, hostStatus, onHostMessage, sendToHost, waitForHostToken, exitHostedSoon } from "./hostChannel.js";
|
|
22
|
+
import { markStage, onStage, startupComplete, startupTimings, formatStartupTimings } from "./startupTimings.js";
|
|
23
|
+
import { readStartupCache, writeStartupCache } from "./startupCache.js";
|
|
21
24
|
// Logo colors: cyan → blue → indigo gradient (matching melaya.org)
|
|
22
25
|
const c1 = chalk.hex("#22D3EE").bold;
|
|
23
26
|
const c2 = chalk.hex("#10B0F0").bold;
|
|
@@ -43,15 +46,55 @@ const BANNER = `
|
|
|
43
46
|
${br("▸")} ${dm("Local AI Pipeline Runner")} ${chalk.hex("#333")(`v${version}`)}
|
|
44
47
|
${br("─────────────────────────────────────────────────────")}
|
|
45
48
|
`;
|
|
49
|
+
// Inside the desktop app: give the last host message time to leave before exit.
|
|
50
|
+
const flushHost = () => (isHosted() ? new Promise((r) => setTimeout(r, 300)) : Promise.resolve());
|
|
51
|
+
// Desktop app "stop" (pause, disconnect, quit): run the graceful shutdown the
|
|
52
|
+
// CLI already has for Ctrl+C. Newest SIGTERM handler first, so connection.ts
|
|
53
|
+
// stops runs and assistant hosts before singleInstance.ts exits; the lock is
|
|
54
|
+
// released on "exit" either way. Hard exit if nothing is listening yet.
|
|
55
|
+
function stopHosted() {
|
|
56
|
+
const handlers = process.listeners("SIGTERM").slice().reverse();
|
|
57
|
+
exitHostedSoon(0, 5000);
|
|
58
|
+
for (const h of handlers) {
|
|
59
|
+
try {
|
|
60
|
+
h("SIGTERM");
|
|
61
|
+
}
|
|
62
|
+
catch { /* keep going */ }
|
|
63
|
+
}
|
|
64
|
+
if (handlers.length === 0)
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
46
67
|
async function main() {
|
|
68
|
+
const hosted = isHosted();
|
|
47
69
|
const program = new Command()
|
|
48
70
|
.name("melaya-runner")
|
|
49
|
-
.description("Run Melaya AI pipelines locally with your own models")
|
|
50
|
-
|
|
71
|
+
.description("Run Melaya AI pipelines locally with your own models");
|
|
72
|
+
// In the desktop app the token arrives over the host channel, never argv.
|
|
73
|
+
if (hosted)
|
|
74
|
+
program.option("--token <token>", "Runner token from the Melaya platform");
|
|
75
|
+
else
|
|
76
|
+
program.requiredOption("--token <token>", "Runner token from the Melaya platform");
|
|
77
|
+
program
|
|
51
78
|
.option("--server <url>", "Server URL", "wss://api.melaya.org")
|
|
52
79
|
.option("--verbose", "Show detailed logs", false)
|
|
80
|
+
.option("--computer", "Let Melaya agents operate allowed apps on this machine (headless Linux / VM)", false)
|
|
81
|
+
.option("--computer-helper <path>", "Path to the melaya-computer helper binary (default: MELAYA_COMPUTER_HELPER)")
|
|
82
|
+
.option("--diagnostics", "Print startup stage timings (config, socket, online, Python, model discovery)", false)
|
|
53
83
|
.parse(process.argv);
|
|
54
84
|
const opts = program.opts();
|
|
85
|
+
// --computer: this runner hosts the computer executor itself (computerHostCli.ts); the desktop app hosts its own.
|
|
86
|
+
if (opts.computer && !hosted) {
|
|
87
|
+
const { enableCliComputerHost } = await import("./computerHostCli.js");
|
|
88
|
+
enableCliComputerHost({ helperPath: opts.computerHelper, verbose: opts.verbose });
|
|
89
|
+
}
|
|
90
|
+
if (hosted) {
|
|
91
|
+
hostStatus("starting");
|
|
92
|
+
sendToHost({ type: "version", runnerVersion: version });
|
|
93
|
+
onHostMessage((msg) => { if (msg.type === "stop")
|
|
94
|
+
stopHosted(); });
|
|
95
|
+
opts.token = await waitForHostToken();
|
|
96
|
+
}
|
|
97
|
+
markStage("config");
|
|
55
98
|
console.log(BANNER);
|
|
56
99
|
// Single-instance guard: refuse to start if another runner is already running
|
|
57
100
|
// on this machine. Two runners for one user fight over a single server-side
|
|
@@ -61,63 +104,128 @@ async function main() {
|
|
|
61
104
|
const { acquireSingleInstanceLock } = await import("./singleInstance.js");
|
|
62
105
|
const lock = acquireSingleInstanceLock();
|
|
63
106
|
if (!lock.ok) {
|
|
107
|
+
hostStatus("blocked", `Another Melaya runner is already running on this computer (process ${lock.ownerPid}). Close it, then resume the runner.`);
|
|
64
108
|
console.log(chalk.red(` ✗ A Melaya runner is already running on this machine (pid ${lock.ownerPid}).`));
|
|
65
109
|
console.log(chalk.gray(" Only one runner can run per machine. Stop the other one first"));
|
|
66
110
|
console.log(chalk.gray(` (e.g. \`kill ${lock.ownerPid}\`, or close its terminal), then start again.`));
|
|
67
111
|
await reportInstallFailure("single_instance_locked", { token: opts.token, server: opts.server });
|
|
112
|
+
await flushHost();
|
|
68
113
|
process.exit(1);
|
|
69
114
|
}
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
115
|
+
// Readiness (audit P1.2): connect FIRST with the last good model list from
|
|
116
|
+
// disk (or none), then resolve Python and scan model providers in parallel
|
|
117
|
+
// and publish the fresh list through `runner:models`. Before, Python probing,
|
|
118
|
+
// provisioning and a serial provider scan (up to ~24 s of timeouts) all ran
|
|
119
|
+
// before the socket even opened. Requests that need Python wait for it.
|
|
120
|
+
const cache = readStartupCache();
|
|
121
|
+
const pythonReady = resolvePython(cache.python);
|
|
122
|
+
const discovery = scanProviders();
|
|
123
|
+
const handle = await connect({
|
|
124
|
+
token: opts.token,
|
|
125
|
+
serverUrl: opts.server,
|
|
126
|
+
pythonPath: pythonReady,
|
|
127
|
+
models: cache.models ?? [],
|
|
128
|
+
verbose: opts.verbose,
|
|
129
|
+
});
|
|
130
|
+
say = (l) => handle.print(l);
|
|
131
|
+
// Fresh discovery: print it, publish it, remember it for the next start.
|
|
132
|
+
void discovery.then((scan) => {
|
|
133
|
+
reportModels(scan.models, (l) => handle.print(l));
|
|
134
|
+
if (opts.verbose || opts.diagnostics) {
|
|
135
|
+
const slow = scan.timedOut.length ? `; timed out: ${scan.timedOut.join(", ")}` : "";
|
|
136
|
+
handle.print(chalk.gray(` model discovery ${scan.ms}ms (${Object.entries(scan.probeMs).map(([k, v]) => `${k} ${v}ms`).join(", ")}${slow})`));
|
|
137
|
+
}
|
|
138
|
+
handle.publishModels(scan.models);
|
|
139
|
+
writeStartupCache({ models: scan.models, modelsAt: Date.now() });
|
|
140
|
+
markStage("discovery_done");
|
|
141
|
+
}).catch(() => { markStage("discovery_done"); });
|
|
142
|
+
void pythonReady.then(async (python) => {
|
|
143
|
+
if (python) {
|
|
144
|
+
markStage("python_ready");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Same terminal failure as before the reorder: no usable Python, no runner.
|
|
148
|
+
console.log(chalk.red(` ✗ Could not auto-provision Python 3.${PY_MAX_MINOR}`));
|
|
149
|
+
console.log(chalk.gray(" Install a supported Python manually, then restart the runner:"));
|
|
150
|
+
console.log(chalk.gray(" macOS: brew install python@3.12"));
|
|
151
|
+
console.log(chalk.gray(" Linux: sudo apt install python3.12 python3.12-venv (or: pyenv install 3.12)"));
|
|
152
|
+
console.log(chalk.gray(" Windows: winget install Python.Python.3.12"));
|
|
153
|
+
await reportInstallFailure("python_missing", {
|
|
154
|
+
token: opts.token, server: opts.server,
|
|
155
|
+
detail: lastPythonFound ? `host=${lastPythonFound}` : "none",
|
|
156
|
+
});
|
|
157
|
+
hostStatus("error", `Could not set up Python 3.${PY_MAX_MINOR} for local AI runs. Check your internet connection, then resume the runner.`);
|
|
158
|
+
await flushHost();
|
|
159
|
+
process.exit(1);
|
|
160
|
+
});
|
|
161
|
+
// --diagnostics (and --verbose): the stage timeline once every stage is in, or after 2 min.
|
|
162
|
+
if (opts.diagnostics || opts.verbose) {
|
|
163
|
+
let printed = false;
|
|
164
|
+
const print = () => {
|
|
165
|
+
if (printed)
|
|
166
|
+
return;
|
|
167
|
+
printed = true;
|
|
168
|
+
const lines = formatStartupTimings().split("\n").map((l) => ` ${l}`).join("\n");
|
|
169
|
+
handle.print(chalk.gray(`\n Startup timings (from process start):\n${lines}`));
|
|
170
|
+
if (opts.diagnostics)
|
|
171
|
+
handle.print(`startup-timings ${JSON.stringify(startupTimings().sinceStartMs)}`);
|
|
172
|
+
};
|
|
173
|
+
if (startupComplete())
|
|
174
|
+
print();
|
|
175
|
+
else {
|
|
176
|
+
const off = onStage(() => { if (startupComplete()) {
|
|
177
|
+
off();
|
|
178
|
+
print();
|
|
179
|
+
} });
|
|
180
|
+
setTimeout(() => { off(); print(); }, 120_000).unref?.();
|
|
97
181
|
}
|
|
98
|
-
python = provisioned;
|
|
99
|
-
console.log(chalk.green(` ✓ Python: ${python} (auto-provisioned 3.${PY_MAX_MINOR})`));
|
|
100
182
|
}
|
|
101
|
-
|
|
102
|
-
|
|
183
|
+
}
|
|
184
|
+
function reportModels(models, print) {
|
|
103
185
|
if (models.length === 0) {
|
|
104
|
-
|
|
105
|
-
|
|
186
|
+
print(chalk.yellow(" ⚠ No local models detected (LM Studio / Ollama not running; Claude Code / Codex / GitHub Copilot not logged in)"));
|
|
187
|
+
print(chalk.gray(" Start LM Studio or Ollama, log in to Claude Code / Codex, or run `melaya-runner copilot login`; the runner picks them up automatically"));
|
|
188
|
+
return;
|
|
106
189
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
190
|
+
for (const m of models)
|
|
191
|
+
print(chalk.green(` ✓ ${m.provider}: ${m.name}`));
|
|
192
|
+
}
|
|
193
|
+
// The unsupported interpreter found during the last scan (for the failure report).
|
|
194
|
+
let lastPythonFound;
|
|
195
|
+
// Lines printed while the connect spinner may be spinning go through the
|
|
196
|
+
// connection's printer once it exists (keeps the spinner line intact).
|
|
197
|
+
let say = (l) => console.log(l);
|
|
198
|
+
// Detect Python. Fast path: the interpreter that worked last time (one probe).
|
|
199
|
+
// Then every candidate is probed IN PARALLEL (async, so the socket keeps
|
|
200
|
+
// running) and the best one by preference order wins. If the host only has an
|
|
201
|
+
// unsupported/newer Python (Homebrew now ships 3.13/3.14 as `python3`), or
|
|
202
|
+
// none at all, we PROVISION a managed CPython 3.12 via uv, so the runner needs
|
|
203
|
+
// zero Python setup on the box. `venv` can't do this (it only wraps an
|
|
204
|
+
// existing interpreter); uv downloads a standalone one.
|
|
205
|
+
async function resolvePython(cached) {
|
|
206
|
+
if (cached?.cmd) {
|
|
207
|
+
const v = await probePyVersion(cached.cmd);
|
|
208
|
+
if (v && v.major === 3 && v.minor >= PY_MIN_MINOR && v.minor <= PY_MAX_MINOR) {
|
|
209
|
+
say(chalk.green(` ✓ Python: ${cached.cmd} (3.${v.minor}${cached.provisioned ? ", auto-provisioned" : ""})`));
|
|
210
|
+
return cached.cmd;
|
|
110
211
|
}
|
|
111
212
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
213
|
+
const py = await findPython();
|
|
214
|
+
if (py.ok) {
|
|
215
|
+
say(chalk.green(` ✓ Python: ${py.cmd} (3.${py.minor})`));
|
|
216
|
+
writeStartupCache({ python: { cmd: py.cmd, minor: py.minor } });
|
|
217
|
+
return py.cmd;
|
|
218
|
+
}
|
|
219
|
+
lastPythonFound = py.foundVersion;
|
|
220
|
+
say(chalk.yellow(` ⚠ No supported host Python (need 3.${PY_MIN_MINOR}–3.${PY_MAX_MINOR}` +
|
|
221
|
+
`${py.foundVersion ? `; host has ${py.foundVersion}` : ""}) — provisioning one automatically`));
|
|
222
|
+
const { provisionPython } = await import("./uvBootstrap.js");
|
|
223
|
+
const provisioned = await provisionPython(`3.${PY_MAX_MINOR}`, (m) => say(chalk.gray(` ${m}`)));
|
|
224
|
+
if (!provisioned)
|
|
225
|
+
return null;
|
|
226
|
+
say(chalk.green(` ✓ Python: ${provisioned} (auto-provisioned 3.${PY_MAX_MINOR})`));
|
|
227
|
+
writeStartupCache({ python: { cmd: provisioned, minor: PY_MAX_MINOR, provisioned: true } });
|
|
228
|
+
return provisioned;
|
|
121
229
|
}
|
|
122
230
|
// Supported CPython range for the runner's venv. agentscope and its native
|
|
123
231
|
// deps (tiktoken, numpy, pydantic-core, curl_cffi via scrapling, sentence-
|
|
@@ -132,17 +240,21 @@ async function main() {
|
|
|
132
240
|
const PY_MIN_MINOR = 10;
|
|
133
241
|
const PY_MAX_MINOR = 12;
|
|
134
242
|
async function probePyVersion(cmd) {
|
|
135
|
-
const {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
243
|
+
const { execFile } = await import("child_process");
|
|
244
|
+
// Async (never blocks the socket) and no shell: the command is a fixed name or a cached path.
|
|
245
|
+
return new Promise((resolve) => {
|
|
246
|
+
try {
|
|
247
|
+
execFile(cmd, ["--version"], { encoding: "utf-8", timeout: 5000, windowsHide: true }, (err, stdout, stderr) => {
|
|
248
|
+
if (err)
|
|
249
|
+
return resolve(null);
|
|
250
|
+
const m = `${stdout || ""}${stderr || ""}`.trim().match(/Python (\d+)\.(\d+)/);
|
|
251
|
+
resolve(m ? { major: Number(m[1]), minor: Number(m[2]) } : null);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
resolve(null);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
146
258
|
}
|
|
147
259
|
async function findPython() {
|
|
148
260
|
const candidates = [];
|
|
@@ -165,17 +277,16 @@ async function findPython() {
|
|
|
165
277
|
}
|
|
166
278
|
// 3. Generic names last — accepted only if they resolve into the supported range.
|
|
167
279
|
candidates.push("python3", "python");
|
|
280
|
+
// Probe all candidates at once; the preference order above still decides.
|
|
281
|
+
const unique = [...new Set(candidates)];
|
|
282
|
+
const versions = await Promise.all(unique.map((cmd) => probePyVersion(cmd)));
|
|
168
283
|
let fallback; // a real 3.x that exists but is out of range
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (seen.has(cmd))
|
|
172
|
-
continue;
|
|
173
|
-
seen.add(cmd);
|
|
174
|
-
const v = await probePyVersion(cmd);
|
|
284
|
+
for (let i = 0; i < unique.length; i++) {
|
|
285
|
+
const v = versions[i];
|
|
175
286
|
if (!v || v.major !== 3)
|
|
176
287
|
continue;
|
|
177
288
|
if (v.minor >= PY_MIN_MINOR && v.minor <= PY_MAX_MINOR)
|
|
178
|
-
return { ok: true, cmd, minor: v.minor };
|
|
289
|
+
return { ok: true, cmd: unique[i], minor: v.minor };
|
|
179
290
|
if (!fallback)
|
|
180
291
|
fallback = `Python ${v.major}.${v.minor}`;
|
|
181
292
|
}
|
|
@@ -200,6 +311,8 @@ async function bootstrap() {
|
|
|
200
311
|
}
|
|
201
312
|
bootstrap().catch(async (e) => {
|
|
202
313
|
console.error(chalk.red(`\n Fatal: ${e.message}\n`));
|
|
314
|
+
// The full error goes to the app's log (stderr above); the status line stays generic.
|
|
315
|
+
hostStatus("error", "The runner stopped unexpectedly. Copy diagnostics for details.");
|
|
203
316
|
// Token is parsed inside main(); recover it from argv so a crash BEFORE the
|
|
204
317
|
// socket still reports. Only the STAGE is sent — never the error text, which
|
|
205
318
|
// routinely carries file paths and usernames.
|
|
@@ -209,5 +322,6 @@ bootstrap().catch(async (e) => {
|
|
|
209
322
|
token: tokArg ? tokArg.replace(/^--token[= ]?/, "") : undefined,
|
|
210
323
|
server: srvArg ? srvArg.replace(/^--server[= ]?/, "") : undefined,
|
|
211
324
|
});
|
|
325
|
+
await flushHost();
|
|
212
326
|
process.exit(1);
|
|
213
327
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// GENERATED by .github/scripts/write-computer-helper-manifest.mjs in desktop-release.yml.
|
|
2
|
+
// Do not edit by hand: commit the file the release job uploads (artifact
|
|
3
|
+
// "computer-helper-manifest") once the helpers are published at these URLs.
|
|
4
|
+
//
|
|
5
|
+
// Pinned computer helper releases for the runner CLI host (computerHostCli.ts),
|
|
6
|
+
// keyed by `${process.platform}-${process.arch}`. A download is only accepted
|
|
7
|
+
// when its sha256 matches.
|
|
8
|
+
export const COMPUTER_HELPER_VERSION = "1.0.0";
|
|
9
|
+
export const COMPUTER_HELPER_MANIFEST = {};
|
|
@@ -0,0 +1,233 @@
|
|
|
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 { type ChildProcess } from "child_process";
|
|
43
|
+
export type ComputerAppTier = "view" | "click" | "full";
|
|
44
|
+
export interface AllowedApp {
|
|
45
|
+
appId: string;
|
|
46
|
+
tier: ComputerAppTier;
|
|
47
|
+
}
|
|
48
|
+
export type ComputerRunnerToHost = {
|
|
49
|
+
type: "computer.command";
|
|
50
|
+
id: string;
|
|
51
|
+
action: unknown;
|
|
52
|
+
deadlineMs: number;
|
|
53
|
+
allowedAppIds: string[];
|
|
54
|
+
allowedApps?: AllowedApp[];
|
|
55
|
+
} | {
|
|
56
|
+
type: "computer.stop";
|
|
57
|
+
reason: string;
|
|
58
|
+
} | {
|
|
59
|
+
type: "computer.capabilities.request";
|
|
60
|
+
};
|
|
61
|
+
export type ComputerHostToRunner = {
|
|
62
|
+
type: "computer.result";
|
|
63
|
+
id: string;
|
|
64
|
+
result: ComputerResultLike;
|
|
65
|
+
} | {
|
|
66
|
+
type: "computer.capabilities";
|
|
67
|
+
capabilities: ComputerCapabilitiesLike | null;
|
|
68
|
+
};
|
|
69
|
+
export type ComputerResultLike = Record<string, unknown> & {
|
|
70
|
+
ok: boolean;
|
|
71
|
+
kind: string;
|
|
72
|
+
elapsedMs: number;
|
|
73
|
+
};
|
|
74
|
+
export type ComputerCapabilitiesLike = Record<string, unknown> & {
|
|
75
|
+
platform: string;
|
|
76
|
+
backend: string;
|
|
77
|
+
kinds: string[];
|
|
78
|
+
host?: "desktop" | "cli";
|
|
79
|
+
};
|
|
80
|
+
export declare const COMPUTER_ACTION_KINDS: readonly ["screen", "screenshot", "find", "get_text", "current_app", "list_windows", "list_apps", "list_displays", "clipboard_read", "ocr", "wait", "hover", "move", "scroll", "focus_window", "open_app", "open_url", "open_file", "click", "type", "press", "drag", "select", "set_value", "toggle", "window", "clipboard_write", "file_dialog", "batch", "zoom", "cursor_position", "read_table", "expect", "mouse", "hold_key", "select_text", "quit_app", "save_dialog", "handoff", "keep_awake"];
|
|
81
|
+
/** Kinds that do not act on an app window: an empty allowlist does not block them. */
|
|
82
|
+
export declare const APP_INDEPENDENT_KINDS: Set<string>;
|
|
83
|
+
export declare const COMPUTER_MAX_BATCH_STEPS = 100;
|
|
84
|
+
/** The lowest app tier that allows this action (same rule as the server and the helpers). */
|
|
85
|
+
export declare function requiredTierOf(action: Record<string, unknown>): ComputerAppTier;
|
|
86
|
+
/**
|
|
87
|
+
* Apps no agent may read or drive, whatever the allowlist says. Sent to the
|
|
88
|
+
* helper at init (it adds them to its own built-in list) and checked here for
|
|
89
|
+
* actions that name an app and for app / window lists coming back. Plain
|
|
90
|
+
* regexes valid in JS, .NET and Rust.
|
|
91
|
+
*/
|
|
92
|
+
export declare const HARD_DENY_SOURCES: string[];
|
|
93
|
+
export declare function isHardDenied(appId: string): boolean;
|
|
94
|
+
export type PolicyCheck = {
|
|
95
|
+
ok: true;
|
|
96
|
+
action: Record<string, unknown>;
|
|
97
|
+
allowedApps: AllowedApp[];
|
|
98
|
+
allowedAppIds: string[];
|
|
99
|
+
deadlineMs: number;
|
|
100
|
+
} | {
|
|
101
|
+
ok: false;
|
|
102
|
+
result: ComputerResultLike;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Everything the CLI host checks before a command reaches the helper: shape,
|
|
106
|
+
* kinds, batch limits, allowlist (minus hard-denied apps), the tier ceiling and
|
|
107
|
+
* the app an action names. Returns the sanitized allowlist to forward.
|
|
108
|
+
*/
|
|
109
|
+
export declare function checkCommand(msg: {
|
|
110
|
+
action?: unknown;
|
|
111
|
+
allowedAppIds?: unknown;
|
|
112
|
+
allowedApps?: unknown;
|
|
113
|
+
deadlineMs?: unknown;
|
|
114
|
+
}): PolicyCheck;
|
|
115
|
+
/**
|
|
116
|
+
* Pinned helper releases, one per platform-arch, from computerHelperManifest.ts:
|
|
117
|
+
* generated by the release job (desktop-release.yml,
|
|
118
|
+
* .github/scripts/write-computer-helper-manifest.mjs) with the published URL
|
|
119
|
+
* and its sha256, and committed with the release. A download is only ever
|
|
120
|
+
* accepted when its sha256 matches. Empty until the first release: use
|
|
121
|
+
* MELAYA_COMPUTER_HELPER or --computer-helper meanwhile.
|
|
122
|
+
*/
|
|
123
|
+
export declare const HELPER_VERSION = "1.0.0";
|
|
124
|
+
export declare const HELPER_MANIFEST: Record<string, {
|
|
125
|
+
url: string;
|
|
126
|
+
sha256: string;
|
|
127
|
+
} | undefined>;
|
|
128
|
+
export declare function platformKey(platform?: string, arch?: string): string;
|
|
129
|
+
export declare function helperCacheDir(env?: NodeJS.ProcessEnv): string;
|
|
130
|
+
/** Finds the helper binary on disk (no download). */
|
|
131
|
+
export declare function resolveHelperPath(opts?: {
|
|
132
|
+
helperPath?: string;
|
|
133
|
+
env?: NodeJS.ProcessEnv;
|
|
134
|
+
exists?: (p: string) => boolean;
|
|
135
|
+
}): string | null;
|
|
136
|
+
/** Downloads the pinned helper for this platform into the per-user cache and verifies its sha256. */
|
|
137
|
+
export declare function downloadHelper(opts?: {
|
|
138
|
+
env?: NodeJS.ProcessEnv;
|
|
139
|
+
fetchFn?: typeof fetch;
|
|
140
|
+
log?: (l: string) => void;
|
|
141
|
+
manifest?: typeof HELPER_MANIFEST;
|
|
142
|
+
}): Promise<string | null>;
|
|
143
|
+
export declare 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.";
|
|
144
|
+
export type SpawnFn = (command: string, args: string[], options: {
|
|
145
|
+
stdio: ["pipe", "pipe", "pipe"];
|
|
146
|
+
env?: NodeJS.ProcessEnv;
|
|
147
|
+
}) => ChildProcess;
|
|
148
|
+
/** Splits a byte stream into JSON objects, one per line; tolerant of partial chunks, CRLF and noise. */
|
|
149
|
+
export declare class LineDecoder {
|
|
150
|
+
private buf;
|
|
151
|
+
push(chunk: string, onObject: (o: Record<string, unknown>) => void, onNoise?: (line: string) => void): void;
|
|
152
|
+
}
|
|
153
|
+
export interface HelperOptions {
|
|
154
|
+
spawn?: SpawnFn;
|
|
155
|
+
env?: NodeJS.ProcessEnv;
|
|
156
|
+
log?: (l: string) => void;
|
|
157
|
+
onEvent?: (e: Record<string, unknown>) => void;
|
|
158
|
+
/** How long a cancelled command may take to answer before the helper is killed (PROTOCOL.md §5). */
|
|
159
|
+
abortGraceMs?: number;
|
|
160
|
+
}
|
|
161
|
+
export declare class HelperProcess {
|
|
162
|
+
private readonly path;
|
|
163
|
+
private readonly opts;
|
|
164
|
+
private child;
|
|
165
|
+
private waiters;
|
|
166
|
+
private aborted;
|
|
167
|
+
private seq;
|
|
168
|
+
private restarts;
|
|
169
|
+
private disposed;
|
|
170
|
+
private starting;
|
|
171
|
+
private readyResolve;
|
|
172
|
+
ready: Record<string, unknown> | null;
|
|
173
|
+
constructor(path: string, opts?: HelperOptions);
|
|
174
|
+
private log;
|
|
175
|
+
start(): Promise<void>;
|
|
176
|
+
private onMessage;
|
|
177
|
+
private onExit;
|
|
178
|
+
get crashLooping(): boolean;
|
|
179
|
+
/** Writes one request and waits for the line with the same id. */
|
|
180
|
+
request(msg: Record<string, unknown>, timeoutMs: number): Promise<Record<string, unknown>>;
|
|
181
|
+
/** Fire-and-forget line. */
|
|
182
|
+
send(msg: Record<string, unknown>): void;
|
|
183
|
+
/**
|
|
184
|
+
* Aborts one command (or every one). A command that has not answered `stopped`
|
|
185
|
+
* within the grace period gets the helper killed; it restarts on the next command.
|
|
186
|
+
*/
|
|
187
|
+
cancel(target?: string): void;
|
|
188
|
+
get running(): boolean;
|
|
189
|
+
dispose(): void;
|
|
190
|
+
}
|
|
191
|
+
/** Holds a sleep inhibitor child process: systemd-inhibit (Linux) or caffeinate (macOS). */
|
|
192
|
+
export declare class KeepAwake {
|
|
193
|
+
private readonly opts;
|
|
194
|
+
private child;
|
|
195
|
+
constructor(opts?: {
|
|
196
|
+
spawn?: SpawnFn;
|
|
197
|
+
env?: NodeJS.ProcessEnv;
|
|
198
|
+
platform?: string;
|
|
199
|
+
});
|
|
200
|
+
available(): string | null;
|
|
201
|
+
get active(): boolean;
|
|
202
|
+
acquire(): string | null;
|
|
203
|
+
release(): void;
|
|
204
|
+
}
|
|
205
|
+
export interface CliComputerHostOptions {
|
|
206
|
+
/** Results / capabilities pushed outside a handle() call (the relay's executor callback). */
|
|
207
|
+
send?: (msg: ComputerHostToRunner) => void;
|
|
208
|
+
verbose?: boolean;
|
|
209
|
+
/** Path to the helper binary (else MELAYA_COMPUTER_HELPER, the package's native/ folder, the per-user cache). */
|
|
210
|
+
helperPath?: string;
|
|
211
|
+
/** Force on / off (else enableCliComputerHost() or MELAYA_COMPUTER=1). */
|
|
212
|
+
enabled?: boolean;
|
|
213
|
+
/** Download the pinned helper when none is installed (default: true). */
|
|
214
|
+
download?: boolean;
|
|
215
|
+
env?: NodeJS.ProcessEnv;
|
|
216
|
+
spawn?: SpawnFn;
|
|
217
|
+
log?: (line: string) => void;
|
|
218
|
+
/** Test seam for keep_awake. */
|
|
219
|
+
keepAwake?: KeepAwake;
|
|
220
|
+
abortGraceMs?: number;
|
|
221
|
+
}
|
|
222
|
+
export interface CliComputerHostApi {
|
|
223
|
+
handle(msg: ComputerRunnerToHost): Promise<ComputerHostToRunner | void>;
|
|
224
|
+
capabilities(): Promise<ComputerCapabilitiesLike | null>;
|
|
225
|
+
stopAll(reason: string): void;
|
|
226
|
+
dispose(): void;
|
|
227
|
+
}
|
|
228
|
+
/** Called by cli.ts for `--computer`: later createCliComputerHost() calls (from the relay) are enabled. */
|
|
229
|
+
export declare function enableCliComputerHost(opts?: Partial<CliComputerHostOptions>): void;
|
|
230
|
+
export declare function isCliComputerEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
231
|
+
/** Test seam: forget enableCliComputerHost(). */
|
|
232
|
+
export declare function _resetCliComputerHost(): void;
|
|
233
|
+
export declare function createCliComputerHost(options?: CliComputerHostOptions): CliComputerHostApi | null;
|