rterm-backend 3.9.1 → 3.9.2
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": "rterm-backend",
|
|
53
|
-
"version": "3.9.
|
|
53
|
+
"version": "3.9.2",
|
|
54
54
|
"description": "AI-native terminal & agentic-AI operations platform for Forward Deployed Engineers & SREs: AIOps closed-loop remediation, AI SRE, self-healing infrastructure, runbook automation, ChatOps; executes over SSH/WinRM/serial under policy with tamper-evident audit."
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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())
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
}
|