@melaya/runner 1.1.68 → 1.1.70
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/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 +190 -65
- package/dist/codeWorker.js +33 -17
- package/dist/computerHelperManifest.d.ts +5 -0
- package/dist/computerHelperManifest.js +18 -0
- package/dist/computerHostCli.d.ts +233 -0
- package/dist/computerHostCli.js +815 -0
- package/dist/computerRelay.d.ts +73 -0
- package/dist/computerRelay.js +280 -0
- package/dist/connection.d.ts +18 -2
- package/dist/connection.js +322 -82
- package/dist/detect.d.ts +40 -1
- package/dist/detect.js +194 -36
- package/dist/hostChannel.d.ts +126 -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/modelRescan.d.ts +36 -0
- package/dist/modelRescan.js +80 -0
- 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,139 @@ 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
|
-
|
|
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
|
+
// Desktop app: "rescan_models" after an engine launch or sign-in, so its models
|
|
132
|
+
// show up in seconds instead of at the next 15 min rescan.
|
|
133
|
+
if (hosted) {
|
|
134
|
+
onHostMessage((msg) => {
|
|
135
|
+
if (msg.type !== "rescan_models")
|
|
136
|
+
return;
|
|
137
|
+
void handle.rescanModels()
|
|
138
|
+
.then((models) => writeStartupCache({ models, modelsAt: Date.now() }))
|
|
139
|
+
.catch(() => { });
|
|
140
|
+
});
|
|
80
141
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
142
|
+
// Fresh discovery: print it, publish it, remember it for the next start.
|
|
143
|
+
void discovery.then((scan) => {
|
|
144
|
+
reportModels(scan.models, (l) => handle.print(l));
|
|
145
|
+
if (opts.verbose || opts.diagnostics) {
|
|
146
|
+
const slow = scan.timedOut.length ? `; timed out: ${scan.timedOut.join(", ")}` : "";
|
|
147
|
+
handle.print(chalk.gray(` model discovery ${scan.ms}ms (${Object.entries(scan.probeMs).map(([k, v]) => `${k} ${v}ms`).join(", ")}${slow})`));
|
|
148
|
+
}
|
|
149
|
+
handle.publishModels(scan.models);
|
|
150
|
+
writeStartupCache({ models: scan.models, modelsAt: Date.now() });
|
|
151
|
+
markStage("discovery_done");
|
|
152
|
+
}).catch(() => { markStage("discovery_done"); });
|
|
153
|
+
void pythonReady.then(async (python) => {
|
|
154
|
+
if (python) {
|
|
155
|
+
markStage("python_ready");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// Same terminal failure as before the reorder: no usable Python, no runner.
|
|
159
|
+
console.log(chalk.red(` ✗ Could not auto-provision Python 3.${PY_MAX_MINOR}`));
|
|
160
|
+
console.log(chalk.gray(" Install a supported Python manually, then restart the runner:"));
|
|
161
|
+
console.log(chalk.gray(" macOS: brew install python@3.12"));
|
|
162
|
+
console.log(chalk.gray(" Linux: sudo apt install python3.12 python3.12-venv (or: pyenv install 3.12)"));
|
|
163
|
+
console.log(chalk.gray(" Windows: winget install Python.Python.3.12"));
|
|
164
|
+
await reportInstallFailure("python_missing", {
|
|
165
|
+
token: opts.token, server: opts.server,
|
|
166
|
+
detail: lastPythonFound ? `host=${lastPythonFound}` : "none",
|
|
167
|
+
});
|
|
168
|
+
hostStatus("error", `Could not set up Python 3.${PY_MAX_MINOR} for local AI runs. Check your internet connection, then resume the runner.`);
|
|
169
|
+
await flushHost();
|
|
170
|
+
process.exit(1);
|
|
171
|
+
});
|
|
172
|
+
// --diagnostics (and --verbose): the stage timeline once every stage is in, or after 2 min.
|
|
173
|
+
if (opts.diagnostics || opts.verbose) {
|
|
174
|
+
let printed = false;
|
|
175
|
+
const print = () => {
|
|
176
|
+
if (printed)
|
|
177
|
+
return;
|
|
178
|
+
printed = true;
|
|
179
|
+
const lines = formatStartupTimings().split("\n").map((l) => ` ${l}`).join("\n");
|
|
180
|
+
handle.print(chalk.gray(`\n Startup timings (from process start):\n${lines}`));
|
|
181
|
+
if (opts.diagnostics)
|
|
182
|
+
handle.print(`startup-timings ${JSON.stringify(startupTimings().sinceStartMs)}`);
|
|
183
|
+
};
|
|
184
|
+
if (startupComplete())
|
|
185
|
+
print();
|
|
186
|
+
else {
|
|
187
|
+
const off = onStage(() => { if (startupComplete()) {
|
|
188
|
+
off();
|
|
189
|
+
print();
|
|
190
|
+
} });
|
|
191
|
+
setTimeout(() => { off(); print(); }, 120_000).unref?.();
|
|
97
192
|
}
|
|
98
|
-
python = provisioned;
|
|
99
|
-
console.log(chalk.green(` ✓ Python: ${python} (auto-provisioned 3.${PY_MAX_MINOR})`));
|
|
100
193
|
}
|
|
101
|
-
|
|
102
|
-
|
|
194
|
+
}
|
|
195
|
+
function reportModels(models, print) {
|
|
103
196
|
if (models.length === 0) {
|
|
104
|
-
|
|
105
|
-
|
|
197
|
+
print(chalk.yellow(" ⚠ No local models detected (LM Studio / Ollama not running; Claude Code / Codex / GitHub Copilot not logged in)"));
|
|
198
|
+
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"));
|
|
199
|
+
return;
|
|
106
200
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
201
|
+
for (const m of models)
|
|
202
|
+
print(chalk.green(` ✓ ${m.provider}: ${m.name}`));
|
|
203
|
+
}
|
|
204
|
+
// The unsupported interpreter found during the last scan (for the failure report).
|
|
205
|
+
let lastPythonFound;
|
|
206
|
+
// Lines printed while the connect spinner may be spinning go through the
|
|
207
|
+
// connection's printer once it exists (keeps the spinner line intact).
|
|
208
|
+
let say = (l) => console.log(l);
|
|
209
|
+
// Detect Python. Fast path: the interpreter that worked last time (one probe).
|
|
210
|
+
// Then every candidate is probed IN PARALLEL (async, so the socket keeps
|
|
211
|
+
// running) and the best one by preference order wins. If the host only has an
|
|
212
|
+
// unsupported/newer Python (Homebrew now ships 3.13/3.14 as `python3`), or
|
|
213
|
+
// none at all, we PROVISION a managed CPython 3.12 via uv, so the runner needs
|
|
214
|
+
// zero Python setup on the box. `venv` can't do this (it only wraps an
|
|
215
|
+
// existing interpreter); uv downloads a standalone one.
|
|
216
|
+
async function resolvePython(cached) {
|
|
217
|
+
if (cached?.cmd) {
|
|
218
|
+
const v = await probePyVersion(cached.cmd);
|
|
219
|
+
if (v && v.major === 3 && v.minor >= PY_MIN_MINOR && v.minor <= PY_MAX_MINOR) {
|
|
220
|
+
say(chalk.green(` ✓ Python: ${cached.cmd} (3.${v.minor}${cached.provisioned ? ", auto-provisioned" : ""})`));
|
|
221
|
+
return cached.cmd;
|
|
110
222
|
}
|
|
111
223
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
224
|
+
const py = await findPython();
|
|
225
|
+
if (py.ok) {
|
|
226
|
+
say(chalk.green(` ✓ Python: ${py.cmd} (3.${py.minor})`));
|
|
227
|
+
writeStartupCache({ python: { cmd: py.cmd, minor: py.minor } });
|
|
228
|
+
return py.cmd;
|
|
229
|
+
}
|
|
230
|
+
lastPythonFound = py.foundVersion;
|
|
231
|
+
say(chalk.yellow(` ⚠ No supported host Python (need 3.${PY_MIN_MINOR}–3.${PY_MAX_MINOR}` +
|
|
232
|
+
`${py.foundVersion ? `; host has ${py.foundVersion}` : ""}) — provisioning one automatically`));
|
|
233
|
+
const { provisionPython } = await import("./uvBootstrap.js");
|
|
234
|
+
const provisioned = await provisionPython(`3.${PY_MAX_MINOR}`, (m) => say(chalk.gray(` ${m}`)));
|
|
235
|
+
if (!provisioned)
|
|
236
|
+
return null;
|
|
237
|
+
say(chalk.green(` ✓ Python: ${provisioned} (auto-provisioned 3.${PY_MAX_MINOR})`));
|
|
238
|
+
writeStartupCache({ python: { cmd: provisioned, minor: PY_MAX_MINOR, provisioned: true } });
|
|
239
|
+
return provisioned;
|
|
121
240
|
}
|
|
122
241
|
// Supported CPython range for the runner's venv. agentscope and its native
|
|
123
242
|
// deps (tiktoken, numpy, pydantic-core, curl_cffi via scrapling, sentence-
|
|
@@ -132,17 +251,21 @@ async function main() {
|
|
|
132
251
|
const PY_MIN_MINOR = 10;
|
|
133
252
|
const PY_MAX_MINOR = 12;
|
|
134
253
|
async function probePyVersion(cmd) {
|
|
135
|
-
const {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
254
|
+
const { execFile } = await import("child_process");
|
|
255
|
+
// Async (never blocks the socket) and no shell: the command is a fixed name or a cached path.
|
|
256
|
+
return new Promise((resolve) => {
|
|
257
|
+
try {
|
|
258
|
+
execFile(cmd, ["--version"], { encoding: "utf-8", timeout: 5000, windowsHide: true }, (err, stdout, stderr) => {
|
|
259
|
+
if (err)
|
|
260
|
+
return resolve(null);
|
|
261
|
+
const m = `${stdout || ""}${stderr || ""}`.trim().match(/Python (\d+)\.(\d+)/);
|
|
262
|
+
resolve(m ? { major: Number(m[1]), minor: Number(m[2]) } : null);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
resolve(null);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
146
269
|
}
|
|
147
270
|
async function findPython() {
|
|
148
271
|
const candidates = [];
|
|
@@ -165,17 +288,16 @@ async function findPython() {
|
|
|
165
288
|
}
|
|
166
289
|
// 3. Generic names last — accepted only if they resolve into the supported range.
|
|
167
290
|
candidates.push("python3", "python");
|
|
291
|
+
// Probe all candidates at once; the preference order above still decides.
|
|
292
|
+
const unique = [...new Set(candidates)];
|
|
293
|
+
const versions = await Promise.all(unique.map((cmd) => probePyVersion(cmd)));
|
|
168
294
|
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);
|
|
295
|
+
for (let i = 0; i < unique.length; i++) {
|
|
296
|
+
const v = versions[i];
|
|
175
297
|
if (!v || v.major !== 3)
|
|
176
298
|
continue;
|
|
177
299
|
if (v.minor >= PY_MIN_MINOR && v.minor <= PY_MAX_MINOR)
|
|
178
|
-
return { ok: true, cmd, minor: v.minor };
|
|
300
|
+
return { ok: true, cmd: unique[i], minor: v.minor };
|
|
179
301
|
if (!fallback)
|
|
180
302
|
fallback = `Python ${v.major}.${v.minor}`;
|
|
181
303
|
}
|
|
@@ -200,6 +322,8 @@ async function bootstrap() {
|
|
|
200
322
|
}
|
|
201
323
|
bootstrap().catch(async (e) => {
|
|
202
324
|
console.error(chalk.red(`\n Fatal: ${e.message}\n`));
|
|
325
|
+
// The full error goes to the app's log (stderr above); the status line stays generic.
|
|
326
|
+
hostStatus("error", "The runner stopped unexpectedly. Copy diagnostics for details.");
|
|
203
327
|
// Token is parsed inside main(); recover it from argv so a crash BEFORE the
|
|
204
328
|
// socket still reports. Only the STAGE is sent — never the error text, which
|
|
205
329
|
// routinely carries file paths and usernames.
|
|
@@ -209,5 +333,6 @@ bootstrap().catch(async (e) => {
|
|
|
209
333
|
token: tokArg ? tokArg.replace(/^--token[= ]?/, "") : undefined,
|
|
210
334
|
server: srvArg ? srvArg.replace(/^--server[= ]?/, "") : undefined,
|
|
211
335
|
});
|
|
336
|
+
await flushHost();
|
|
212
337
|
process.exit(1);
|
|
213
338
|
});
|
package/dist/codeWorker.js
CHANGED
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
// kill verification (poll for exit; escalate; report kill_unverified if
|
|
64
64
|
// the OS will not confirm death).
|
|
65
65
|
import { spawn } from "node:child_process";
|
|
66
|
-
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
67
|
-
import { join } from "node:path";
|
|
66
|
+
import { writeFileSync, mkdirSync, existsSync, realpathSync } from "node:fs";
|
|
67
|
+
import { join, dirname } from "node:path";
|
|
68
68
|
import { createInterface } from "node:readline";
|
|
69
69
|
export const DEFAULT_LIMITS = {
|
|
70
70
|
wallClockMs: 60_000,
|
|
@@ -253,6 +253,14 @@ function buildSandboxedCommand(scratchDir, entryPath, limits) {
|
|
|
253
253
|
// The node dyld shared cache path varies by macOS version; the
|
|
254
254
|
// /System/Volumes/Preboot allowance covers macOS 13+.
|
|
255
255
|
const nodeDir = nodeBin.replace(/\/[^/]+$/, "");
|
|
256
|
+
// Node resolves the entry script with realpathSync, which lstat()s
|
|
257
|
+
// every ancestor directory: allow metadata (stat only, no contents)
|
|
258
|
+
// on exactly those ancestors, nothing else. scratchDir is already a
|
|
259
|
+
// real path here (see runSandboxedScript).
|
|
260
|
+
const ancestorMeta = [];
|
|
261
|
+
for (let p = dirname(scratchDir); p !== "/"; p = dirname(p)) {
|
|
262
|
+
ancestorMeta.push(`(allow file-read-metadata (literal "${p}"))`);
|
|
263
|
+
}
|
|
256
264
|
const profile = [
|
|
257
265
|
"(version 1)",
|
|
258
266
|
"(deny default)",
|
|
@@ -263,6 +271,10 @@ function buildSandboxedCommand(scratchDir, entryPath, limits) {
|
|
|
263
271
|
// Node's own scratch dir: full read/write.
|
|
264
272
|
`(allow file-read* (subpath "${scratchDir}"))`,
|
|
265
273
|
`(allow file-write* (subpath "${scratchDir}"))`,
|
|
274
|
+
...ancestorMeta,
|
|
275
|
+
// Node aborts during process init (SIGABRT) unless it can read
|
|
276
|
+
// the root directory itself (not its subtree).
|
|
277
|
+
"(allow file-read-data (literal \"/\"))",
|
|
266
278
|
// macOS system libraries and dyld shared cache.
|
|
267
279
|
"(allow file-read* (subpath \"/System/Library\"))",
|
|
268
280
|
"(allow file-read* (subpath \"/System/Volumes\"))",
|
|
@@ -283,23 +295,23 @@ function buildSandboxedCommand(scratchDir, entryPath, limits) {
|
|
|
283
295
|
"(allow ipc-posix-shm-read-data)",
|
|
284
296
|
"(allow ipc-posix-shm-write-data)",
|
|
285
297
|
].join("\n");
|
|
286
|
-
//
|
|
287
|
-
// ulimit -
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
const shellCmd = `ulimit -
|
|
298
|
+
// CPU cap via a shell `ulimit` wrapper around sandbox-exec.
|
|
299
|
+
// ulimit -t: hard CPU time in seconds, applied before Node starts;
|
|
300
|
+
// `&&` refuses to start the worker if the cap cannot be set.
|
|
301
|
+
// HONEST LIMIT: macOS does not support RLIMIT_AS (`ulimit -v` fails
|
|
302
|
+
// with EINVAL and, chained with -t, used to skip the CPU cap too), so
|
|
303
|
+
// memory is capped by --max-old-space-size only. SIGKILL from the
|
|
304
|
+
// parent wall-clock timer is the backstop.
|
|
305
|
+
const shellCmd = `ulimit -t ${cpuSec} && exec /usr/bin/sandbox-exec -p '${profile.replace(/'/g, "'\\''")}' ${JSON.stringify(nodeBin)} ${nodeArgs.map((a) => JSON.stringify(a)).join(" ")}`;
|
|
294
306
|
return {
|
|
295
307
|
cmd: "/bin/sh",
|
|
296
308
|
args: ["-c", shellCmd],
|
|
297
309
|
sandbox: "seatbelt",
|
|
298
310
|
};
|
|
299
311
|
}
|
|
300
|
-
// No sandbox-exec: fall back to permission model + ulimit wrapper
|
|
301
|
-
|
|
302
|
-
const shellCmd = `ulimit -
|
|
312
|
+
// No sandbox-exec: fall back to permission model + ulimit wrapper
|
|
313
|
+
// (CPU cap only, see the RLIMIT_AS note above).
|
|
314
|
+
const shellCmd = `ulimit -t ${cpuSec} && exec ${JSON.stringify(nodeBin)} ${nodeArgs.map((a) => JSON.stringify(a)).join(" ")}`;
|
|
303
315
|
return {
|
|
304
316
|
cmd: "/bin/sh",
|
|
305
317
|
args: ["-c", shellCmd],
|
|
@@ -348,14 +360,18 @@ export async function runSandboxedScript(opts) {
|
|
|
348
360
|
const consoleLines = [];
|
|
349
361
|
const traces = [];
|
|
350
362
|
mkdirSync(opts.scratchDir, { recursive: true });
|
|
351
|
-
|
|
363
|
+
// macOS: os.tmpdir() is /var/folders/..., a symlink to /private/var/...
|
|
364
|
+
// Seatbelt and the Node permission model both check resolved paths, so
|
|
365
|
+
// the worker is given the real path everywhere.
|
|
366
|
+
const scratchDir = process.platform === "darwin" ? realpathSync(opts.scratchDir) : opts.scratchDir;
|
|
367
|
+
const entryPath = join(scratchDir, "melaya-code-worker.cjs");
|
|
352
368
|
writeFileSync(entryPath, WORKER_ENTRY_SOURCE, "utf-8");
|
|
353
|
-
const { cmd, args, sandbox } = buildSandboxedCommand(
|
|
369
|
+
const { cmd, args, sandbox } = buildSandboxedCommand(scratchDir, entryPath, limits);
|
|
354
370
|
let child;
|
|
355
371
|
try {
|
|
356
372
|
child = spawn(cmd, args, {
|
|
357
|
-
cwd:
|
|
358
|
-
env: sanitizedEnv(
|
|
373
|
+
cwd: scratchDir,
|
|
374
|
+
env: sanitizedEnv(scratchDir),
|
|
359
375
|
// ONLY stdio pipes are inherited; Node does not pass extra fds
|
|
360
376
|
// unless they are listed here, which closes the inherited-fd
|
|
361
377
|
// channel (plan 0.7).
|
|
@@ -0,0 +1,18 @@
|
|
|
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.1";
|
|
9
|
+
export const COMPUTER_HELPER_MANIFEST = {
|
|
10
|
+
"linux-arm64": {
|
|
11
|
+
"url": "https://api.melaya.org/desktop/helpers/1.0.1/melaya-computer-linux-aarch64",
|
|
12
|
+
"sha256": "f5e6825fc00b589178b0540ed6f2e350dc8ac77bb97e08ae17395282465fec36"
|
|
13
|
+
},
|
|
14
|
+
"linux-x64": {
|
|
15
|
+
"url": "https://api.melaya.org/desktop/helpers/1.0.1/melaya-computer-linux-x86_64",
|
|
16
|
+
"sha256": "795933f10803f2bc25c95207e5a196ff6413e2e55f038ccd54c3f6f19e7c764c"
|
|
17
|
+
}
|
|
18
|
+
};
|