@melaya/runner 1.1.69 → 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/cli.js +11 -0
- package/dist/codeWorker.js +33 -17
- package/dist/computerHelperManifest.d.ts +1 -1
- package/dist/computerHelperManifest.js +11 -2
- package/dist/computerHostCli.d.ts +1 -1
- package/dist/computerHostCli.js +7 -0
- package/dist/connection.d.ts +6 -0
- package/dist/connection.js +24 -1
- package/dist/hostChannel.d.ts +4 -0
- package/dist/modelRescan.d.ts +36 -0
- package/dist/modelRescan.js +80 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -128,6 +128,17 @@ async function main() {
|
|
|
128
128
|
verbose: opts.verbose,
|
|
129
129
|
});
|
|
130
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
|
+
});
|
|
141
|
+
}
|
|
131
142
|
// Fresh discovery: print it, publish it, remember it for the next start.
|
|
132
143
|
void discovery.then((scan) => {
|
|
133
144
|
reportModels(scan.models, (l) => handle.print(l));
|
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).
|
|
@@ -5,5 +5,14 @@
|
|
|
5
5
|
// Pinned computer helper releases for the runner CLI host (computerHostCli.ts),
|
|
6
6
|
// keyed by `${process.platform}-${process.arch}`. A download is only accepted
|
|
7
7
|
// when its sha256 matches.
|
|
8
|
-
export const COMPUTER_HELPER_VERSION = "1.0.
|
|
9
|
-
export const COMPUTER_HELPER_MANIFEST = {
|
|
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
|
+
};
|
|
@@ -120,7 +120,7 @@ export declare function checkCommand(msg: {
|
|
|
120
120
|
* accepted when its sha256 matches. Empty until the first release: use
|
|
121
121
|
* MELAYA_COMPUTER_HELPER or --computer-helper meanwhile.
|
|
122
122
|
*/
|
|
123
|
-
export declare const HELPER_VERSION = "1.0.
|
|
123
|
+
export declare const HELPER_VERSION = "1.0.1";
|
|
124
124
|
export declare const HELPER_MANIFEST: Record<string, {
|
|
125
125
|
url: string;
|
|
126
126
|
sha256: string;
|
package/dist/computerHostCli.js
CHANGED
|
@@ -140,6 +140,13 @@ export function requiredTierOf(action) {
|
|
|
140
140
|
*/
|
|
141
141
|
export const HARD_DENY_SOURCES = [
|
|
142
142
|
"melaya",
|
|
143
|
+
// password managers and one-time-code apps (same list as the desktop app's HARD_DENY_PATTERNS,
|
|
144
|
+
// desktop/src/main/computer/allowlist.ts: the Windows helper has no built-in list of its own)
|
|
145
|
+
"1password|agilebits|bitwarden|keepass|lastpass|dashlane|nordpass|roboform|enpass|keepersecurity",
|
|
146
|
+
"\\bkeeper (password|desktop)|proton ?pass|\\bauthy\\b|authenticator|yubico",
|
|
147
|
+
"keychain ?access|com\\.apple\\.keychainaccess|com\\.apple\\.passwords|^passwords$",
|
|
148
|
+
// Windows Security
|
|
149
|
+
"sechealthui|securityhealth",
|
|
143
150
|
// authentication prompts and privilege elevation
|
|
144
151
|
"polkit|pkexec|gksu|kdesu|lxsudo",
|
|
145
152
|
"pinentry|gcr-prompter|gnome-keyring|ssh-askpass|ksshaskpass|kwalletd",
|
package/dist/connection.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ interface ConnectOpts {
|
|
|
25
25
|
export interface RunnerConnection {
|
|
26
26
|
/** Replace the model list and push it through `runner:models` when it changed. */
|
|
27
27
|
publishModels(models: DetectedModel[]): void;
|
|
28
|
+
/**
|
|
29
|
+
* Forced rescan now (desktop app "rescan_models"): scanProviders({ force: true }),
|
|
30
|
+
* then publish like the periodic rescan. Coalesced: requests share a scan in
|
|
31
|
+
* flight, at most one forced scan per 5 s (modelRescan.ts). Resolves with the list.
|
|
32
|
+
*/
|
|
33
|
+
rescanModels(): Promise<DetectedModel[]>;
|
|
28
34
|
/** console.log that keeps the connect spinner intact. */
|
|
29
35
|
print(line: string): void;
|
|
30
36
|
}
|
package/dist/connection.js
CHANGED
|
@@ -29,6 +29,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
29
29
|
const __dirname = dirname(__filename);
|
|
30
30
|
import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
|
|
31
31
|
import { scanProviders, providerFingerprint } from "./detect.js";
|
|
32
|
+
import { createForcedRescan } from "./modelRescan.js";
|
|
32
33
|
import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
|
|
33
34
|
// Melaya Browser (plan Sections 3/4/7): the governed browser control
|
|
34
35
|
// bridge + its runner-side grant verifier and engine discovery.
|
|
@@ -542,6 +543,27 @@ export async function connect(opts) {
|
|
|
542
543
|
}, MODEL_REFRESH_MS);
|
|
543
544
|
modelRefresh.unref?.();
|
|
544
545
|
};
|
|
546
|
+
// Forced rescan on request (desktop app after an engine launch / sign-in).
|
|
547
|
+
// Same publish path as the periodic rescan; also resets its change detection.
|
|
548
|
+
const forcedRescan = createForcedRescan({
|
|
549
|
+
scan: async () => {
|
|
550
|
+
const fp = await providerFingerprint().catch(() => "");
|
|
551
|
+
const scan = await scanProviders({ force: true });
|
|
552
|
+
if (fp)
|
|
553
|
+
lastFingerprint = fp;
|
|
554
|
+
lastScanAt = Date.now();
|
|
555
|
+
return scan.models;
|
|
556
|
+
},
|
|
557
|
+
publish: (models) => {
|
|
558
|
+
if (opts.verbose)
|
|
559
|
+
print(chalk.gray(` [models] forced rescan: ${models.length} model(s)`));
|
|
560
|
+
publishModels(models);
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
const rescanModels = async () => {
|
|
564
|
+
await forcedRescan.request();
|
|
565
|
+
return opts.models;
|
|
566
|
+
};
|
|
545
567
|
socket.on("connect", async () => {
|
|
546
568
|
spinner.succeed(chalk.green("Connected to Melaya"));
|
|
547
569
|
markStage("socket_connected");
|
|
@@ -3341,6 +3363,7 @@ export async function connect(opts) {
|
|
|
3341
3363
|
clearInterval(modelRefresh);
|
|
3342
3364
|
modelRefresh = null;
|
|
3343
3365
|
}
|
|
3366
|
+
forcedRescan.dispose();
|
|
3344
3367
|
if (bgTimer) {
|
|
3345
3368
|
clearInterval(bgTimer);
|
|
3346
3369
|
bgTimer = null;
|
|
@@ -3389,7 +3412,7 @@ export async function connect(opts) {
|
|
|
3389
3412
|
claimGracefulShutdown();
|
|
3390
3413
|
process.on("SIGINT", cleanup);
|
|
3391
3414
|
process.on("SIGTERM", cleanup);
|
|
3392
|
-
return { publishModels, print };
|
|
3415
|
+
return { publishModels, rescanModels, print };
|
|
3393
3416
|
}
|
|
3394
3417
|
export function verifySignature(content, signature, runnerToken) {
|
|
3395
3418
|
if (!content || !signature || !runnerToken)
|
package/dist/hostChannel.d.ts
CHANGED
|
@@ -86,6 +86,10 @@ export type HostToRunnerMessage = {
|
|
|
86
86
|
} | {
|
|
87
87
|
type: "start";
|
|
88
88
|
token: string;
|
|
89
|
+
}
|
|
90
|
+
/** Additive: rescan model providers now and publish the list through runner:models. */
|
|
91
|
+
| {
|
|
92
|
+
type: "rescan_models";
|
|
89
93
|
} | {
|
|
90
94
|
type: "computer.result";
|
|
91
95
|
id: string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forced model rescans on request.
|
|
3
|
+
*
|
|
4
|
+
* The runner rescans its model providers every 15 min (connection.ts). When
|
|
5
|
+
* the desktop app launches an engine (Ollama / LM Studio started, a Claude
|
|
6
|
+
* Code / Codex / Copilot sign-in finished) it sends { type: "rescan_models" }
|
|
7
|
+
* (hostChannel.ts), so the new models reach Melaya in seconds instead.
|
|
8
|
+
*
|
|
9
|
+
* Requests are cheap to send and may arrive in bursts (the app after a launch,
|
|
10
|
+
* the web page every 10 s while it waits), so:
|
|
11
|
+
* - a request while a forced scan runs shares that scan;
|
|
12
|
+
* - at most one forced scan starts per `minIntervalMs` (5 s);
|
|
13
|
+
* - a request inside that window is not dropped: it schedules one trailing
|
|
14
|
+
* scan at the end of the window, shared by every request until then.
|
|
15
|
+
*/
|
|
16
|
+
export interface ForcedRescanOptions<T> {
|
|
17
|
+
/** One forced provider scan (scanProviders({ force: true })). */
|
|
18
|
+
scan: () => Promise<T>;
|
|
19
|
+
/** Publish the result the way the periodic rescan does (runner:models). */
|
|
20
|
+
publish: (result: T) => void;
|
|
21
|
+
/** Minimum gap between two forced scan starts. Default 5 s. */
|
|
22
|
+
minIntervalMs?: number;
|
|
23
|
+
/** Called when a scan or publish throws; the last good list stays. */
|
|
24
|
+
onError?: (e: unknown) => void;
|
|
25
|
+
/** Test seams. */
|
|
26
|
+
now?: () => number;
|
|
27
|
+
setTimer?: (fn: () => void, ms: number) => () => void;
|
|
28
|
+
}
|
|
29
|
+
export interface ForcedRescan {
|
|
30
|
+
/** Ask for a forced rescan. Resolves once the scan that serves it has published (or failed). */
|
|
31
|
+
request(): Promise<void>;
|
|
32
|
+
/** Cancel a pending trailing scan (shutdown). */
|
|
33
|
+
dispose(): void;
|
|
34
|
+
}
|
|
35
|
+
export declare const FORCED_RESCAN_MIN_INTERVAL_MS = 5000;
|
|
36
|
+
export declare function createForcedRescan<T>(opts: ForcedRescanOptions<T>): ForcedRescan;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forced model rescans on request.
|
|
3
|
+
*
|
|
4
|
+
* The runner rescans its model providers every 15 min (connection.ts). When
|
|
5
|
+
* the desktop app launches an engine (Ollama / LM Studio started, a Claude
|
|
6
|
+
* Code / Codex / Copilot sign-in finished) it sends { type: "rescan_models" }
|
|
7
|
+
* (hostChannel.ts), so the new models reach Melaya in seconds instead.
|
|
8
|
+
*
|
|
9
|
+
* Requests are cheap to send and may arrive in bursts (the app after a launch,
|
|
10
|
+
* the web page every 10 s while it waits), so:
|
|
11
|
+
* - a request while a forced scan runs shares that scan;
|
|
12
|
+
* - at most one forced scan starts per `minIntervalMs` (5 s);
|
|
13
|
+
* - a request inside that window is not dropped: it schedules one trailing
|
|
14
|
+
* scan at the end of the window, shared by every request until then.
|
|
15
|
+
*/
|
|
16
|
+
export const FORCED_RESCAN_MIN_INTERVAL_MS = 5_000;
|
|
17
|
+
function defaultTimer(fn, ms) {
|
|
18
|
+
const t = setTimeout(fn, ms);
|
|
19
|
+
t.unref?.();
|
|
20
|
+
return () => clearTimeout(t);
|
|
21
|
+
}
|
|
22
|
+
export function createForcedRescan(opts) {
|
|
23
|
+
const minGap = opts.minIntervalMs ?? FORCED_RESCAN_MIN_INTERVAL_MS;
|
|
24
|
+
const now = opts.now ?? Date.now;
|
|
25
|
+
const setTimer = opts.setTimer ?? defaultTimer;
|
|
26
|
+
let inFlight = null;
|
|
27
|
+
let lastStart = Number.NEGATIVE_INFINITY;
|
|
28
|
+
let trailing = null;
|
|
29
|
+
let disposed = false;
|
|
30
|
+
const run = () => {
|
|
31
|
+
lastStart = now();
|
|
32
|
+
const p = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
opts.publish(await opts.scan());
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
try {
|
|
38
|
+
opts.onError?.(e);
|
|
39
|
+
}
|
|
40
|
+
catch { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
})().finally(() => { if (inFlight === p)
|
|
43
|
+
inFlight = null; });
|
|
44
|
+
inFlight = p;
|
|
45
|
+
return p;
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
request() {
|
|
49
|
+
if (disposed)
|
|
50
|
+
return Promise.resolve();
|
|
51
|
+
if (inFlight)
|
|
52
|
+
return inFlight;
|
|
53
|
+
if (trailing)
|
|
54
|
+
return trailing.promise;
|
|
55
|
+
const wait = lastStart + minGap - now();
|
|
56
|
+
if (wait <= 0)
|
|
57
|
+
return run();
|
|
58
|
+
let resolve;
|
|
59
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
60
|
+
const cancel = setTimer(() => {
|
|
61
|
+
trailing = null;
|
|
62
|
+
if (disposed) {
|
|
63
|
+
resolve();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
(inFlight ?? run()).then(resolve, resolve);
|
|
67
|
+
}, wait);
|
|
68
|
+
trailing = { promise, cancel, resolve };
|
|
69
|
+
return promise;
|
|
70
|
+
},
|
|
71
|
+
dispose() {
|
|
72
|
+
disposed = true;
|
|
73
|
+
if (trailing) {
|
|
74
|
+
trailing.cancel();
|
|
75
|
+
trailing.resolve();
|
|
76
|
+
trailing = null;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|