neuralos 3.9.1 → 3.9.3

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/package.json CHANGED
@@ -50,6 +50,6 @@
50
50
  "url": "git+https://github.com/DrOlu/RTerm.git"
51
51
  },
52
52
  "name": "neuralos",
53
- "version": "3.9.1",
53
+ "version": "3.9.3",
54
54
  "description": "Standalone neuralOS backend (rterm-backend): AI-native terminal & agentic-AI operations platform. Ships the neuralOS plugin — on-device data agents (verified probe menus over real data sources, engine + weights auto-provisioned on first use)."
55
55
  }
@@ -12,7 +12,7 @@
12
12
  * Run: npx tsx plugins/neuralos/neuralos.extreme.spec.mts
13
13
  */
14
14
  import { spawnSync } from 'node:child_process'
15
- import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync, chmodSync } from 'node:fs'
15
+ import { mkdtempSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs'
16
16
  import { homedir, tmpdir } from 'node:os'
17
17
  import { join } from 'node:path'
18
18
 
@@ -290,6 +290,94 @@ else { console.log(JSON.stringify({ function_calls: [{ name: 'transactions_count
290
290
  ok(plain.code === 0 && plain.stdout.trim() === 'true', 'defaultExec keeps the parent environment for plain calls')
291
291
  }
292
292
 
293
+ // ---------------------------------------------------------------------------
294
+ // 12. The lost-exec-bit family (v3.9.2). curl -o / electron-builder
295
+ // extraResources ship engines with mode 0644; spawning them dies with
296
+ // EACCES and an EMPTY stderr. Resolution must check executability, not
297
+ // existence — a broken bundle must never shadow a runnable engine.
298
+ // ---------------------------------------------------------------------------
299
+ {
300
+ // 12a. A 0644 bundled engine is skipped, and the next candidate wins.
301
+ const root = mkdtempSync(join(tmpdir(), 'neuralos-execbit-'))
302
+ const resources = join(root, 'resources')
303
+ const cache = join(root, 'cache')
304
+ const instances = join(root, 'instances')
305
+ mkdirSync(join(resources, 'neuralos'), { recursive: true })
306
+ mkdirSync(cache, { recursive: true })
307
+ mkdirSync(join(instances, 'engine'), { recursive: true })
308
+ // bundled copy: present but NOT executable (the v3.9.1 bug)
309
+ writeFileSync(join(resources, 'neuralos', 'engine-macos-arm64'), 'bundled-broken')
310
+ writeFileSync(join(resources, 'neuralos', 'needle3.cact'), 'bundled-weights')
311
+ chmodSync(join(resources, 'neuralos', 'engine-macos-arm64'), 0o644)
312
+ // fleet-convention copy: executable
313
+ writeFileSync(join(instances, 'engine', 'needle'), '#!/bin/sh\necho ok')
314
+ writeFileSync(join(instances, 'engine', 'needle3.cact'), 'fleet-weights')
315
+ chmodSync(join(instances, 'engine', 'needle'), 0o755)
316
+
317
+ const cfg = resolveConfig({ getSettings: () => ({}) }, { NEURALOS_INSTANCES_DIR: instances, NEURALOS_CACHE_DIR: cache, NEURALOS_AUTO_DOWNLOAD: '0' })
318
+ const resolved = await resolveEngine(cfg, { platform: 'darwin', arch: 'arm64', resourcesPath: resources })
319
+ ok(resolved.engineBin === join(instances, 'engine', 'needle'), 'a 0644 bundled engine is skipped for the runnable fleet engine', JSON.stringify(resolved))
320
+
321
+ // 12b. chmod recovery: a 0644 engine we OWN is fixed in place, not skipped.
322
+ const ownedDir = mkdtempSync(join(tmpdir(), 'neuralos-owned-'))
323
+ writeFileSync(join(ownedDir, 'engine-macos-arm64'), 'owned')
324
+ writeFileSync(join(ownedDir, 'needle3.cact'), 'owned-weights')
325
+ chmodSync(join(ownedDir, 'engine-macos-arm64'), 0o644)
326
+ const cfgOwned = resolveConfig({ getSettings: () => ({}) }, { NEURALOS_INSTANCES_DIR: join(root, 'nope'), NEURALOS_CACHE_DIR: ownedDir, NEURALOS_AUTO_DOWNLOAD: '0' })
327
+ const recovered = await resolveEngine(cfgOwned, { platform: 'darwin', arch: 'arm64' })
328
+ ok(recovered.engineBin === join(ownedDir, 'engine-macos-arm64'), 'a 0644 engine we own is chmod-recovered instead of skipped', JSON.stringify(recovered))
329
+
330
+ // 12c. EACCES at spawn time is named, with the fix in the message.
331
+ const denied = await engineSelect(
332
+ () => Promise.resolve({ ok: false, code: 'EACCES', stdout: '', stderr: '' }),
333
+ { engineBin: '/broken/engine', engineWeights: '/w' },
334
+ '/m',
335
+ 'q',
336
+ )
337
+ ok(
338
+ (denied as { error?: string }).error?.includes('engine not executable') && (denied as { error?: string }).error?.includes('chmod +x'),
339
+ 'EACCES spawn failure names the fix (chmod +x / engineBin override)',
340
+ JSON.stringify(denied),
341
+ )
342
+
343
+ // 12d. A non-EACCES crash keeps the original shape.
344
+ const crash = await engineSelect(
345
+ () => Promise.resolve({ ok: false, code: 1, stdout: '', stderr: 'boom' }),
346
+ { engineBin: '/e', engineWeights: '/w' },
347
+ '/m',
348
+ 'q',
349
+ )
350
+ ok((crash as { error?: string }).error === 'engine exited 1: boom', 'non-EACCES crashes keep the engine exited N: stderr shape')
351
+
352
+ // 12e. defaultExec preserves a real EACCES code from the OS.
353
+ const realExec = defaultExec()
354
+ const noExecFile = join(root, 'not-executable')
355
+ writeFileSync(noExecFile, 'x')
356
+ chmodSync(noExecFile, 0o644)
357
+ const eacc = await realExec(noExecFile, [])
358
+ ok(eacc.code === 'EACCES', 'defaultExec surfaces EACCES as the code (not a generic 1)', JSON.stringify(eacc.code))
359
+
360
+ // 12f. defaultExec reports timeout kills honestly.
361
+ const slow = await realExec(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { timeoutMs: 300 })
362
+ ok(String(slow.code).includes('killed') || String(slow.code).includes('ETIMEDOUT'), 'a timeout kill is reported as killed/ETIMEDOUT, not exit 1', JSON.stringify(slow.code))
363
+
364
+ // 12g. truncate(undefined) must not throw (JSON.stringify(undefined) is undefined).
365
+ const undef = truncate(undefined as never)
366
+ ok(undef === 'null', 'truncate(undefined) renders honestly instead of crashing', String(undef))
367
+
368
+ // 12h. A corrupt menu is data for graphProbe, never an exception.
369
+ const corruptDir = mkdtempSync(join(tmpdir(), 'neuralos-corrupt-'))
370
+ writeFileSync(join(corruptDir, 'needle_menu.json'), 'NOT JSON {{{')
371
+ const corrupt = await graphProbe(() => Promise.resolve({ ok: true, code: 0, stdout: '{}', stderr: '' }), resolveConfig({}, {}), corruptDir, { op: 'overview' })
372
+ ok((corrupt as { error?: string }).error?.includes('instance menu unreadable'), 'graphProbe reports a corrupt menu as data')
373
+
374
+ // 12i. A menu that is valid JSON but not an array is also rejected as data.
375
+ const nonArrayDir = mkdtempSync(join(tmpdir(), 'neuralos-nonarray-'))
376
+ writeFileSync(join(nonArrayDir, 'needle_menu.json'), '{"not":"an array"}')
377
+ const nonArray = await graphProbe(() => Promise.resolve({ ok: true, code: 0, stdout: '{}', stderr: '' }), resolveConfig({}, {}), nonArrayDir, { op: 'overview' })
378
+ ok((nonArray as { error?: string }).error?.includes('instance menu unreadable'), 'a non-array menu is rejected as data')
379
+ }
380
+
293
381
  console.log(`\n${pass} passed, ${fail} failed`)
294
382
  if (fail > 0) {
295
383
  console.log(`FAILURES:\n${failures.map((f) => ` - ${f}`).join('\n')}`)
@@ -79,7 +79,14 @@ export function defaultExec() {
79
79
  new Promise((resolve) => {
80
80
  const execEnv = opts.env ? { ...process.env, ...opts.env } : process.env
81
81
  const child = cpExecFile(cmd, args, { timeout: opts.timeoutMs ?? 120000, maxBuffer: 16 * 1024 * 1024, env: execEnv }, (err, stdout, stderr) => {
82
- resolve({ ok: !err, code: err ? err.code ?? 1 : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })
82
+ // err.code: 'EACCES'/'ENOENT'/'ETIMEDOUT' style codes, undefined for
83
+ // signal kills (e.g. our own timeout SIGTERM). Preserve the real
84
+ // code; a timeout must not masquerade as a generic exit 1.
85
+ let code = 0
86
+ if (err) {
87
+ code = typeof err.code === 'string' || typeof err.code === 'number' ? err.code : err.signal ? `killed (${err.signal})` : 1
88
+ }
89
+ resolve({ ok: !err, code, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })
83
90
  })
84
91
  void child
85
92
  })
@@ -94,6 +101,33 @@ async function exists(p) {
94
101
  }
95
102
  }
96
103
 
104
+ /**
105
+ * A candidate is USABLE only if the binary is actually EXECUTABLE.
106
+ * Existence alone is not enough: electron-builder's extraResources copy and
107
+ * curl-downloaded files routinely land with mode 0644 — spawning them dies
108
+ * with EACCES ("engine exited EACCES:" with an empty stderr, the classic
109
+ * symptom). We probe X_OK; where the path is OURS to manage (the shared
110
+ * cache, the fleet-convention dir, an explicit override) we attempt a
111
+ * one-shot chmod recovery and re-probe. The app bundle is NOT ours to
112
+ * mutate — a 0644 bundled engine is skipped, never chmod'd, so a broken
113
+ * bundle falls through to the next runnable engine instead of shadowing it.
114
+ */
115
+ async function executable(p, { recover = true } = {}) {
116
+ try {
117
+ await fs.access(p, fsConstants.X_OK)
118
+ return true
119
+ } catch {
120
+ if (!recover) return false
121
+ try {
122
+ await fs.chmod(p, 0o755)
123
+ await fs.access(p, fsConstants.X_OK)
124
+ return true
125
+ } catch {
126
+ return false
127
+ }
128
+ }
129
+ }
130
+
97
131
  /** Download one file over HTTPS into dir with its basename (idempotent). */
98
132
  async function download(deps, url, dir) {
99
133
  await fs.mkdir(dir, { recursive: true })
@@ -149,19 +183,36 @@ export async function resolveEngine(cfg, deps = {}) {
149
183
  const resources = (deps.resourcesPath ?? process.resourcesPath) ?? undefined
150
184
  const bundled = bundledEngineName(platform, arch)
151
185
  if (resources && bundled) {
152
- candidates.push({ bin: path.join(resources, 'neuralos', bundled), weights: path.join(resources, 'neuralos', WEIGHTS_NAME) })
186
+ // the app bundle is not ours to chmod — probe, skip, never recover
187
+ candidates.push({ bin: path.join(resources, 'neuralos', bundled), weights: path.join(resources, 'neuralos', WEIGHTS_NAME), recover: false })
153
188
  }
154
189
  if (bundled) {
155
190
  candidates.push({ bin: path.join(cfg.cacheDir, bundled), weights: path.join(cfg.cacheDir, WEIGHTS_NAME) })
156
191
  }
157
192
  candidates.push({ bin: path.join(cfg.instancesDir, 'engine', 'needle'), weights: path.join(cfg.instancesDir, 'engine', WEIGHTS_NAME) })
193
+ const skipped = []
158
194
  for (const c of candidates) {
159
- if ((await exists(c.bin)) && (await exists(c.weights))) return { engineBin: c.bin, engineWeights: c.weights }
195
+ if (!(await exists(c.bin)) || !(await exists(c.weights))) continue
196
+ if (!(await executable(c.bin, { recover: c.recover !== false }))) {
197
+ // A present-but-unexecutable engine is a broken candidate, never a
198
+ // hard stop: record it and fall through to the next candidate.
199
+ skipped.push(c.bin)
200
+ continue
201
+ }
202
+ return { engineBin: c.bin, engineWeights: c.weights }
160
203
  }
161
204
  if (cfg.autoDownload) {
162
- return ensureEngine({ ...deps, cacheDir: cfg.cacheDir, platform, arch })
205
+ const ensured = await ensureEngine({ ...deps, cacheDir: cfg.cacheDir, platform, arch })
206
+ if (!ensured.error) return ensured
207
+ // autoDownload could not provision a usable engine either — surface
208
+ // BOTH failures so the operator can see why every path was exhausted.
209
+ return {
210
+ error: `neuralOS engine not usable: ${ensured.error}${skipped.length ? `; also found but not executable: ${skipped.join(', ')} (chmod +x them or set neuralos.engineBin/engineWeights)` : ''}`,
211
+ }
212
+ }
213
+ return {
214
+ error: `neuralOS engine not found or not executable${skipped.length ? ` (found but not executable: ${skipped.join(', ')} — chmod +x them or set neuralos.engineBin/engineWeights)` : ' — set neuralos.engineBin/engineWeights (settings), install the desktop bundle, or enable autoDownload'}`,
163
215
  }
164
- return { error: 'neuralOS engine not found — set neuralos.engineBin/engineWeights (settings), install the desktop bundle, or enable autoDownload' }
165
216
  }
166
217
 
167
218
  export function parseJsonObject(text) {
@@ -220,7 +271,16 @@ export async function engineSelect(exec, engine, menuPath, question, opts = {})
220
271
  } catch (e) {
221
272
  return { error: `engine execution failed: ${String(e?.message ?? e)}` }
222
273
  }
223
- if (out.code !== 0 && !out.stdout.trim()) return { error: `engine exited ${out.code}: ${out.stderr.slice(0, 200)}` }
274
+ if (out.code !== 0 && !out.stdout.trim()) {
275
+ // EACCES on spawn surfaces as code 'EACCES' with EMPTY stderr — the
276
+ // classic lost-exec-bit symptom. Name the fix instead of a bare code.
277
+ if (out.code === 'EACCES' || /EACCES|permission denied/i.test(String(out.stderr))) {
278
+ return {
279
+ error: `engine not executable (${engine.engineBin}) — restore the exec bit (chmod +x) or set neuralos.engineBin/engineWeights to a runnable binary`,
280
+ }
281
+ }
282
+ return { error: `engine exited ${out.code}: ${out.stderr.slice(0, 200)}` }
283
+ }
224
284
  const parsed = parseJsonObject(out.stdout)
225
285
  if (!parsed) return { error: `engine output not JSON: ${out.stdout.slice(0, 120)}` }
226
286
  const calls = parsed.function_calls
@@ -266,8 +326,16 @@ export async function executeProbe(exec, cfg, instanceDir, probe, args, opts = {
266
326
  */
267
327
  export async function graphProbe(exec, cfg, instanceDir, input) {
268
328
  if (!['overview', 'neighbors', 'connect'].includes(input.op)) return { error: `unknown graph op: ${input.op}` }
269
- const raw = await fs.readFile(path.join(instanceDir, 'needle_menu.json'), 'utf-8')
270
- const menu = JSON.parse(raw)
329
+ // A corrupt/unreadable menu is an answer, never a thrown exception —
330
+ // every other read path in this file already reports errors as data.
331
+ let menu
332
+ try {
333
+ const raw = await fs.readFile(path.join(instanceDir, 'needle_menu.json'), 'utf-8')
334
+ menu = JSON.parse(raw)
335
+ if (!Array.isArray(menu)) throw new Error('menu is not an array')
336
+ } catch (e) {
337
+ return { error: `instance menu unreadable: ${String(e?.message ?? e)}` }
338
+ }
271
339
  const graphNames = menu.map((t) => t.name).filter((n) => n.includes('_graph_'))
272
340
  const probe = graphNames.find((n) => n.endsWith(`_graph_${input.op}`))
273
341
  if (!probe) return { error: `this instance has no ${input.op} graph probe`, available_graph_probes: graphNames }
@@ -288,7 +356,9 @@ export async function adminProbe(exec, cfg, instanceDir, probe, args) {
288
356
  }
289
357
 
290
358
  export function truncate(value, max = MAX_RESULT_CHARS) {
291
- const text = typeof value === 'string' ? value : JSON.stringify(value)
359
+ // JSON.stringify(undefined) is undefined (not a string) — a probe that
360
+ // returned nothing must not crash the tool; render it honestly instead.
361
+ const text = typeof value === 'string' ? value : JSON.stringify(value) ?? 'null'
292
362
  if (text.length <= max) return text
293
363
  return `${text.slice(0, max)}\n… (truncated at ${max} chars)`
294
364
  }
@@ -47,8 +47,9 @@ export function resolveConfig(ctx = {}, env = process.env) {
47
47
  auth: block.auth || undefined,
48
48
  enabled: block.enabled !== false,
49
49
  autoServe: block.autoServe !== false,
50
- /** dispatch timeout in ms (default 600s = 10min for LLM-backed agents). */
51
- dispatchTimeout: block.dispatchTimeout ?? 600000,
50
+ /** dispatch timeout in ms (default 180s = the edge invoke floor; a real
51
+ * LLM agent turn takes 60s+, and callers time out at 30s without it). */
52
+ dispatchTimeout: block.dispatchTimeout ?? 180000,
52
53
  /** multiple meshes (v3.2.0). Each: {name, url/servers, auth, prefix?}. */
53
54
  meshes: Array.isArray(block.meshes) ? block.meshes : undefined,
54
55
  }
@@ -167,7 +168,7 @@ export async function discoverAgents(ctx, filter = {}) {
167
168
 
168
169
  export async function dispatchTask(ctx, target, skill, input = {}, opts = {}) {
169
170
  const cfg = resolveConfig(ctx)
170
- const timeout = opts.timeout ?? cfg.dispatchTimeout ?? 600000
171
+ const timeout = opts.timeout ?? cfg.dispatchTimeout ?? 180000
171
172
  const nc = await connectMesh(ctx)
172
173
  const reqEnv = envelope('request', { skill, input }, cfg, { to: target, task_id: randomUUID() })
173
174
  const inbox = `${cfg.prefix}.agent.${target}.inbox`
@@ -276,7 +277,7 @@ export function register(ctx) {
276
277
  target: { type: 'string', description: 'Target agent id (e.g. grip-cli-001)' },
277
278
  skill: { type: 'string', description: 'Skill id from the target manifest' },
278
279
  input: { type: 'object', description: 'Input payload for the skill', optional: true },
279
- timeout: { type: 'number', description: 'Reply timeout ms (default 30000)', optional: true },
280
+ timeout: { type: 'number', description: 'Reply timeout ms (default 180000 — the edge invoke floor; a real LLM agent turn takes 60s+)', optional: true },
280
281
  },
281
282
  handler: async (p) => guarded(async () => {
282
283
  if (!p?.target || !p?.skill) return { error: 'synapse_dispatch needs target and skill' }
@@ -86,9 +86,9 @@ function mkCtx(settings = {}, conn) {
86
86
 
87
87
  // ─── config ─────────────────────────────────────────────────────────────────
88
88
 
89
- test('resolveConfig includes dispatchTimeout (default 600000) + meshes', () => {
89
+ test('resolveConfig includes dispatchTimeout (default 180000 = the edge invoke floor) + meshes', () => {
90
90
  const c = resolveConfig({ settings: {} }, {})
91
- eq(c.dispatchTimeout, 600000, 'default dispatchTimeout 600s')
91
+ eq(c.dispatchTimeout, 180000, 'default dispatchTimeout 180s (edge invoke floor; LLM turns take 60s+)')
92
92
  eq(c.meshes, undefined, 'no meshes by default')
93
93
  })
94
94