@melaya/runner 1.1.69 → 1.1.71

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 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));
@@ -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
- // Memory and CPU caps via a shell `ulimit` wrapper around sandbox-exec.
287
- // ulimit -v: virtual address space in KB (4x heap for runtime headroom).
288
- // ulimit -t: hard CPU time in seconds.
289
- // We use /bin/sh -c "ulimit ...; exec sandbox-exec ..." so both limits
290
- // apply before the Node process starts. This is a soft-then-hard pair;
291
- // SIGKILL from the parent wall-clock timer is the backstop.
292
- const asKb = memMb * 4 * 1024;
293
- const shellCmd = `ulimit -v ${asKb} -t ${cpuSec}; exec /usr/bin/sandbox-exec -p '${profile.replace(/'/g, "'\\''")}' ${JSON.stringify(nodeBin)} ${nodeArgs.map((a) => JSON.stringify(a)).join(" ")}`;
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
- const asKb = memMb * 4 * 1024;
302
- const shellCmd = `ulimit -v ${asKb} -t ${cpuSec}; exec ${JSON.stringify(nodeBin)} ${nodeArgs.map((a) => JSON.stringify(a)).join(" ")}`;
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
- const entryPath = join(opts.scratchDir, "melaya-code-worker.cjs");
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(opts.scratchDir, entryPath, limits);
369
+ const { cmd, args, sandbox } = buildSandboxedCommand(scratchDir, entryPath, limits);
354
370
  let child;
355
371
  try {
356
372
  child = spawn(cmd, args, {
357
- cwd: opts.scratchDir,
358
- env: sanitizedEnv(opts.scratchDir),
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).
@@ -1,4 +1,4 @@
1
- export declare const COMPUTER_HELPER_VERSION = "1.0.0";
1
+ export declare const COMPUTER_HELPER_VERSION = "1.0.1";
2
2
  export declare const COMPUTER_HELPER_MANIFEST: Record<string, {
3
3
  url: string;
4
4
  sha256: string;
@@ -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.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.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;
@@ -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",
@@ -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
  }
@@ -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/detect.js CHANGED
@@ -111,6 +111,16 @@ async function detectClaudeCode(force = false) {
111
111
  const names = await fetchClaudeFamilies(oauth.accessToken, force);
112
112
  return names.map((name) => ({ provider: "claude_code", name }));
113
113
  }
114
+ // The short-lived access token expired but the sign-in is still valid: the
115
+ // CLI only renews it when `claude` runs, and the runtime renews it through
116
+ // the CLI on first use (model.py _refresh_claude_oauth_via_cli). Report the
117
+ // user as signed in (known aliases; no API call with an expired token)
118
+ // instead of "not signed in". Never consume the refresh token here.
119
+ const refreshExpiresAt = Number(oauth.refreshTokenExpiresAt ?? 0);
120
+ if (oauth.accessToken && oauth.refreshToken && (!refreshExpiresAt || Date.now() < refreshExpiresAt)) {
121
+ const cached = networkCacheGet(`claude:${tokenKey(oauth.accessToken)}`, false);
122
+ return (cached ?? CLAUDE_ALIASES_FALLBACK).map((name) => ({ provider: "claude_code", name }));
123
+ }
114
124
  }
115
125
  catch { /* malformed creds → treat as logged out */ }
116
126
  return []; // not logged in / expired → don't advertise models
@@ -209,8 +219,10 @@ async function detectGithubCopilotWith(gh) {
209
219
  },
210
220
  signal: AbortSignal.timeout(6000),
211
221
  });
222
+ // No Copilot seat / revoked token → don't advertise (cached). Anything else
223
+ // (429, 5xx) is transient: null so it is not cached as "not signed in".
212
224
  if (!ex.ok)
213
- return []; // no Copilot seat / revoked → don't advertise
225
+ return ex.status === 401 || ex.status === 403 || ex.status === 404 ? [] : null;
214
226
  const exd = (await ex.json());
215
227
  if (!exd.token)
216
228
  return [];
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.69",
3
+ "version": "1.1.71",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Melaya Labs LLC",