@mmmbuto/nexuscrew 0.8.12 → 0.8.13
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/README.md +74 -34
- package/frontend/dist/assets/{index-PkKeNfT7.css → index-4rNd0SwZ.css} +2 -2
- package/frontend/dist/assets/index-DuIR61l-.js +91 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +137 -0
- package/lib/cli/service.js +18 -6
- package/lib/config.js +4 -0
- package/lib/fleet/builtin.js +146 -22
- package/lib/fleet/managed.js +72 -15
- package/lib/fleet/routes.js +6 -1
- package/lib/mcp/server.js +131 -3
- package/lib/proxy/federation.js +44 -5
- package/lib/pty/attach.js +3 -2
- package/lib/runtime/env.js +50 -0
- package/lib/server.js +10 -1
- package/lib/settings/routes.js +4 -3
- package/lib/tmux/actions.js +96 -1
- package/lib/tmux/list.js +2 -1
- package/lib/update/core.js +35 -8
- package/lib/update/manager.js +6 -3
- package/lib/update/runner.js +7 -3
- package/package.json +1 -1
- package/skills/nexuscrew-agent/SKILL.md +14 -3
- package/frontend/dist/assets/index-DrroT7uq.js +0 -91
package/frontend/dist/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DuIR61l-.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-4rNd0SwZ.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.13"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const { isValidSession } = require('../files/store.js');
|
|
5
|
+
const { submitTextOk } = require('../tmux/actions.js');
|
|
6
|
+
|
|
7
|
+
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
8
|
+
const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
9
|
+
const MESSAGE_ID_RE = /^[a-f0-9-]{16,64}$/;
|
|
10
|
+
|
|
11
|
+
function publicCells(status, instanceId, now = Date.now()) {
|
|
12
|
+
if (!NODE_ID_RE.test(String(instanceId || '')) || !status || status.available !== true
|
|
13
|
+
|| !Array.isArray(status.cells)) return [];
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
const cells = [];
|
|
16
|
+
for (const raw of status.cells) {
|
|
17
|
+
if (!raw || !CELL_ID_RE.test(String(raw.cell || ''))
|
|
18
|
+
|| !isValidSession(raw.tmuxSession) || seen.has(raw.cell)) continue;
|
|
19
|
+
seen.add(raw.cell);
|
|
20
|
+
// Provider external legacy may not expose `tmux`; an explicit false still
|
|
21
|
+
// wins, while the historical `active:true` contract remains compatible.
|
|
22
|
+
const active = raw.active === true && raw.tmux !== false;
|
|
23
|
+
cells.push({
|
|
24
|
+
id: `${instanceId}:${raw.cell}`,
|
|
25
|
+
instanceId,
|
|
26
|
+
cell: raw.cell,
|
|
27
|
+
tmuxSession: raw.tmuxSession,
|
|
28
|
+
engine: typeof raw.engine === 'string' ? raw.engine : '',
|
|
29
|
+
model: typeof raw.model === 'string' ? raw.model : '',
|
|
30
|
+
active,
|
|
31
|
+
canReceive: active,
|
|
32
|
+
lastSeen: active ? now : null,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return cells;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseVisited(req) {
|
|
39
|
+
const raw = String(req.headers['x-nexuscrew-visited'] || '');
|
|
40
|
+
if (!raw) return [];
|
|
41
|
+
const ids = raw.split(',');
|
|
42
|
+
if (!ids.length || ids.length > 5 || ids.some((id) => !NODE_ID_RE.test(id))
|
|
43
|
+
|| new Set(ids).size !== ids.length) return null;
|
|
44
|
+
return ids;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validIdentity(value) {
|
|
48
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
49
|
+
&& NODE_ID_RE.test(String(value.instanceId || ''))
|
|
50
|
+
&& CELL_ID_RE.test(String(value.cell || ''))
|
|
51
|
+
&& isValidSession(value.tmuxSession);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now = () => Date.now() }) {
|
|
55
|
+
const r = express.Router();
|
|
56
|
+
|
|
57
|
+
async function status() {
|
|
58
|
+
const fleet = await fleetP;
|
|
59
|
+
if (!fleet || fleet.available !== true || typeof fleet.status !== 'function') {
|
|
60
|
+
return { available: false, cells: [] };
|
|
61
|
+
}
|
|
62
|
+
return fleet.status();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
r.get('/', async (_req, res) => {
|
|
66
|
+
try {
|
|
67
|
+
const nodeId = instanceId();
|
|
68
|
+
const st = await status();
|
|
69
|
+
res.json({
|
|
70
|
+
instanceId: NODE_ID_RE.test(String(nodeId || '')) ? nodeId : null,
|
|
71
|
+
available: st.available === true,
|
|
72
|
+
at: now(),
|
|
73
|
+
cells: publicCells(st, nodeId, now()),
|
|
74
|
+
});
|
|
75
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
r.post('/send', express.json({ limit: '16kb' }), async (req, res) => {
|
|
79
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: invio cella bloccato' });
|
|
80
|
+
const body = req.body || {};
|
|
81
|
+
const keys = Object.keys(body);
|
|
82
|
+
if (keys.some((key) => !['id', 'from', 'to', 'message'].includes(key))
|
|
83
|
+
|| !MESSAGE_ID_RE.test(String(body.id || ''))
|
|
84
|
+
|| !validIdentity(body.from) || !validIdentity(body.to)
|
|
85
|
+
|| !submitTextOk(body.message)) {
|
|
86
|
+
return res.status(400).json({ error: 'messaggio cella non valido' });
|
|
87
|
+
}
|
|
88
|
+
const localId = instanceId();
|
|
89
|
+
if (!NODE_ID_RE.test(String(localId || '')) || body.to.instanceId !== localId) {
|
|
90
|
+
return res.status(409).json({ error: 'destinazione non appartiene a questo nodo' });
|
|
91
|
+
}
|
|
92
|
+
const visited = parseVisited(req);
|
|
93
|
+
if (visited === null || (visited.length && (visited.at(-1) !== localId
|
|
94
|
+
|| body.from.instanceId !== visited[0]))) {
|
|
95
|
+
return res.status(403).json({ error: 'identita mittente non verificata' });
|
|
96
|
+
}
|
|
97
|
+
if (!visited.length && body.from.instanceId !== localId) {
|
|
98
|
+
return res.status(403).json({ error: 'mittente remoto senza route autenticata' });
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const cells = publicCells(await status(), localId, now());
|
|
102
|
+
const target = cells.find((cell) => cell.cell === body.to.cell
|
|
103
|
+
&& cell.tmuxSession === body.to.tmuxSession);
|
|
104
|
+
if (!target) return res.status(404).json({ error: 'cella destinataria sconosciuta' });
|
|
105
|
+
if (!target.canReceive) return res.status(409).json({ error: 'cella destinataria non attiva' });
|
|
106
|
+
const label = `${body.from.cell}@${body.from.instanceId.slice(0, 8)}`;
|
|
107
|
+
// End on printable text even when the source message ends in a newline:
|
|
108
|
+
// Pi may auto-submit a bracketed paste that ends with LF. NexusCrew owns
|
|
109
|
+
// the single explicit Enter used by the transport.
|
|
110
|
+
const envelope = `[NexusCrew message ${body.id} from ${label}]\n${body.message}\n[End NexusCrew message]`;
|
|
111
|
+
const outcome = await submit(target.tmuxSession, envelope, { engine: target.engine });
|
|
112
|
+
if (!outcome || outcome.submitted !== true) {
|
|
113
|
+
return res.status(409).json({ error: outcome?.reason || 'consegna non riuscita' });
|
|
114
|
+
}
|
|
115
|
+
const at = now();
|
|
116
|
+
return res.json({
|
|
117
|
+
id: body.id,
|
|
118
|
+
status: 'submitted',
|
|
119
|
+
at,
|
|
120
|
+
to: { instanceId: localId, cell: target.cell, tmuxSession: target.tmuxSession },
|
|
121
|
+
note: 'submitted conferma solo paste+Enter nel TUI, non elaborazione o completamento',
|
|
122
|
+
});
|
|
123
|
+
} catch (e) { return res.status(500).json({ error: String(e.message || e) }); }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
r.use((err, _req, res, _next) => {
|
|
127
|
+
if (err && (err.type === 'entity.too.large' || err.status === 413)) {
|
|
128
|
+
return res.status(413).json({ error: 'body troppo grande' });
|
|
129
|
+
}
|
|
130
|
+
if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
|
|
131
|
+
return res.status(err.status || 400).json({ error: String(err.message || err) });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return r;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { cellsRoutes, publicCells, parseVisited, validIdentity };
|
package/lib/cli/service.js
CHANGED
|
@@ -59,7 +59,9 @@ function generateService(platform, ctx) {
|
|
|
59
59
|
function generateLinux(ctx) {
|
|
60
60
|
assertSystemdSafe('repoRoot', ctx.repoRoot); // [M3] reject char non gestibili
|
|
61
61
|
assertSystemdSafe('nodeBin', ctx.nodeBin);
|
|
62
|
-
const
|
|
62
|
+
const workDir = ctx.runtimeDir || path.join(ctx.home, '.nexuscrew');
|
|
63
|
+
assertSystemdSafe('runtimeDir', workDir);
|
|
64
|
+
const runtime = escapeSystemdPath(workDir);
|
|
63
65
|
const node = escapeSystemdExec(ctx.nodeBin);
|
|
64
66
|
const repoBin = escapeSystemdExec(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
|
|
65
67
|
const nodeDir = escapeSystemdPath(path.dirname(ctx.nodeBin));
|
|
@@ -74,7 +76,7 @@ Type=simple
|
|
|
74
76
|
# The HTTP service may be the process that creates the shared tmux server.
|
|
75
77
|
# Restart only NexusCrew itself; tmux sessions are independent user workloads.
|
|
76
78
|
KillMode=process
|
|
77
|
-
WorkingDirectory=${
|
|
79
|
+
WorkingDirectory=${runtime}
|
|
78
80
|
Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
|
|
79
81
|
ExecStart=${node} ${repoBin} serve
|
|
80
82
|
Restart=on-failure
|
|
@@ -88,7 +90,7 @@ WantedBy=default.target
|
|
|
88
90
|
function generateMac(ctx) {
|
|
89
91
|
const nodeXml = escapeXml(ctx.nodeBin);
|
|
90
92
|
const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
|
|
91
|
-
const
|
|
93
|
+
const runtimeXml = escapeXml(ctx.runtimeDir || path.join(ctx.home, '.nexuscrew'));
|
|
92
94
|
const homeXml = escapeXml(ctx.home);
|
|
93
95
|
const launchPath = [...new Set([
|
|
94
96
|
path.dirname(ctx.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
|
|
@@ -107,11 +109,15 @@ function generateMac(ctx) {
|
|
|
107
109
|
<string>serve</string>
|
|
108
110
|
</array>
|
|
109
111
|
<key>WorkingDirectory</key>
|
|
110
|
-
<string>${
|
|
112
|
+
<string>${runtimeXml}</string>
|
|
111
113
|
<key>EnvironmentVariables</key>
|
|
112
114
|
<dict>
|
|
113
115
|
<key>PATH</key>
|
|
114
116
|
<string>${launchPathXml}</string>
|
|
117
|
+
<key>LANG</key>
|
|
118
|
+
<string>en_US.UTF-8</string>
|
|
119
|
+
<key>LC_CTYPE</key>
|
|
120
|
+
<string>UTF-8</string>
|
|
115
121
|
</dict>
|
|
116
122
|
<key>RunAtLoad</key>
|
|
117
123
|
<true/>
|
|
@@ -132,14 +138,15 @@ function generateMac(ctx) {
|
|
|
132
138
|
function generateTermux(ctx) {
|
|
133
139
|
const nodeQ = shellQuote(ctx.nodeBin);
|
|
134
140
|
const repoBinQ = shellQuote(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
|
|
135
|
-
const repoQ = shellQuote(ctx.repoRoot);
|
|
136
141
|
return `#!/data/data/com.termux/files/usr/bin/sh
|
|
137
142
|
# NexusCrew boot (Termux) - loopback, localhost del telefono
|
|
138
143
|
export PATH=/data/data/com.termux/files/usr/bin:$PATH
|
|
139
144
|
export HOME=/data/data/com.termux/files/home
|
|
140
|
-
|
|
145
|
+
export LANG=\${LANG:-en_US.UTF-8}
|
|
146
|
+
export LC_CTYPE=\${LC_CTYPE:-en_US.UTF-8}
|
|
141
147
|
termux-wake-lock 2>/dev/null || true
|
|
142
148
|
mkdir -p "$HOME/.nexuscrew"
|
|
149
|
+
cd -- "$HOME/.nexuscrew"
|
|
143
150
|
exec ${nodeQ} ${repoBinQ} serve --pidfile >> "$HOME/.nexuscrew/nexuscrew.log" 2>&1
|
|
144
151
|
`;
|
|
145
152
|
}
|
|
@@ -229,6 +236,11 @@ function installService(platform, content, ctx, { dryRun = false, execImpl = exe
|
|
|
229
236
|
return { target, mode, written: false, failures: [], note: 'dry-run: nessuna scrittura' };
|
|
230
237
|
}
|
|
231
238
|
|
|
239
|
+
const runtimeDir = ctx.runtimeDir || path.join(home, '.nexuscrew');
|
|
240
|
+
// A real install always has an existing home. Test/dry relocation may pass a
|
|
241
|
+
// synthetic home together with a custom installPath; do not escape that
|
|
242
|
+
// relocation merely to create a WorkingDirectory that will never be used.
|
|
243
|
+
if (fs.existsSync(home) || ctx.runtimeDir) fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
|
|
232
244
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
233
245
|
// temp file stessa dir (per atomic rename su stesso filesystem)
|
|
234
246
|
const tmp = target + '.tmp.' + process.pid;
|
package/lib/config.js
CHANGED
|
@@ -30,6 +30,9 @@ function baseDefaults() {
|
|
|
30
30
|
fleetEnabled: true,
|
|
31
31
|
fleetBin: path.join(os.homedir(), '.local', 'bin', 'fleet'),
|
|
32
32
|
providerSecretsPath: path.join(os.homedir(), '.nexuscrew', 'providers.env'),
|
|
33
|
+
// Existing user-owned shell exports. NexusCrew parses simple assignments
|
|
34
|
+
// as data; it never executes/sources this file and never copies values.
|
|
35
|
+
providerShellPath: path.join(os.homedir(), '.config', 'ai-shell', 'providers.zsh'),
|
|
33
36
|
// Installazioni npm globali controllano periodicamente il dist-tag latest.
|
|
34
37
|
// Il manager aggiorna solo verso una semver superiore: mai downgrade.
|
|
35
38
|
autoUpdate: true,
|
|
@@ -66,6 +69,7 @@ function envOverrides() {
|
|
|
66
69
|
if (process.env.NEXUSCREW_FLEET) e.fleetEnabled = process.env.NEXUSCREW_FLEET !== '0';
|
|
67
70
|
if (process.env.NEXUSCREW_FLEET_BIN) e.fleetBin = process.env.NEXUSCREW_FLEET_BIN;
|
|
68
71
|
if (process.env.NEXUSCREW_PROVIDER_SECRETS) e.providerSecretsPath = process.env.NEXUSCREW_PROVIDER_SECRETS;
|
|
72
|
+
if (process.env.NEXUSCREW_PROVIDER_SHELL) e.providerShellPath = process.env.NEXUSCREW_PROVIDER_SHELL;
|
|
69
73
|
if (process.env.NEXUSCREW_AUTO_UPDATE !== undefined) {
|
|
70
74
|
e.autoUpdate = !['', '0', 'false', 'no', 'off'].includes(String(process.env.NEXUSCREW_AUTO_UPDATE).toLowerCase());
|
|
71
75
|
}
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -31,6 +31,7 @@ const {
|
|
|
31
31
|
const {
|
|
32
32
|
publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
|
|
33
33
|
} = require('./managed.js');
|
|
34
|
+
const { MINIMAL_ENV_KEYS, minimalRuntimeEnv } = require('../runtime/env.js');
|
|
34
35
|
|
|
35
36
|
const STATUS_TTL_MS = 2000;
|
|
36
37
|
|
|
@@ -40,20 +41,8 @@ const STATUS_TTL_MS = 2000;
|
|
|
40
41
|
// Nota: se un server tmux e' gia' in esecuzione (avviato fuori dal service), i comandi
|
|
41
42
|
// ereditano l'env di quel server; la garanzia dura resta: le definizioni non possono
|
|
42
43
|
// iniettare loader-key, e engine.env arriva al pane SOLO tramite chiavi validate.
|
|
43
|
-
const MINIMAL_ENV_KEYS = [
|
|
44
|
-
'PATH', 'HOME', 'SHELL', 'TERM', 'LANG', 'LANGUAGE',
|
|
45
|
-
'LC_ALL', 'LC_CTYPE', 'USER', 'LOGNAME',
|
|
46
|
-
'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS',
|
|
47
|
-
];
|
|
48
44
|
function minimalEnv() {
|
|
49
|
-
|
|
50
|
-
for (const k of MINIMAL_ENV_KEYS) {
|
|
51
|
-
if (process.env[k] !== undefined && process.env[k] !== '') env[k] = process.env[k];
|
|
52
|
-
}
|
|
53
|
-
if (!env.PATH) env.PATH = '/usr/local/bin:/usr/bin:/bin';
|
|
54
|
-
if (!env.HOME) env.HOME = os.homedir();
|
|
55
|
-
if (!env.TERM) env.TERM = 'xterm-256color';
|
|
56
|
-
return env;
|
|
45
|
+
return minimalRuntimeEnv(process.env, { home: os.homedir() });
|
|
57
46
|
}
|
|
58
47
|
|
|
59
48
|
function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
|
|
@@ -88,6 +77,32 @@ function redactSecrets(text, engine, cell) {
|
|
|
88
77
|
return out;
|
|
89
78
|
}
|
|
90
79
|
|
|
80
|
+
const MAX_EARLY_DIAGNOSTIC = 1200;
|
|
81
|
+
|
|
82
|
+
function sanitizeEarlyDiagnostic(text, engine, cell, home) {
|
|
83
|
+
let out = redactSecrets(String(text || ''), engine, cell);
|
|
84
|
+
// ANSI CSI/OSC e byte di controllo non devono arrivare nell'errore JSON/UI.
|
|
85
|
+
out = out.replace(/\x1b\][^\x07]*(?:\x07|$)/g, '')
|
|
86
|
+
.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '');
|
|
87
|
+
let clean = '';
|
|
88
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
89
|
+
const code = out.charCodeAt(i);
|
|
90
|
+
if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)) clean += out[i];
|
|
91
|
+
}
|
|
92
|
+
out = clean;
|
|
93
|
+
if (typeof home === 'string' && home) out = out.split(home).join('~');
|
|
94
|
+
out = out
|
|
95
|
+
.replace(/\bBearer\s+\S+/gi, `Bearer ${REDACTED}`)
|
|
96
|
+
.replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|AUTH)[A-Z0-9_]*)(\s*[:=]\s*)\S+/g,
|
|
97
|
+
(_m, key, sep) => `${key}${sep}${REDACTED}`)
|
|
98
|
+
.replace(/\b(?:sk|fw|fpk|hf|zai)-[A-Za-z0-9._-]{8,}\b/gi, REDACTED);
|
|
99
|
+
const lines = out.split(/\r?\n/).map((line) => line.trimEnd())
|
|
100
|
+
.filter((line) => line.trim() && !/^Pane is dead \(status /i.test(line.trim()));
|
|
101
|
+
out = lines.join('\n').trim();
|
|
102
|
+
if (out.length > MAX_EARLY_DIAGNOSTIC) out = `…${out.slice(-(MAX_EARLY_DIAGNOSTIC - 1))}`;
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
91
106
|
// Esecutore tmux: argv diretto (MAI shell). Risolve sempre {err,stdout,stderr,code}
|
|
92
107
|
// cosi' il chiamante distingue "sessione assente" (code!==0 atteso) da errori reali.
|
|
93
108
|
function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
|
|
@@ -152,6 +167,25 @@ async function waitAlive(tmuxBin, session, { env, readyMs }) {
|
|
|
152
167
|
}
|
|
153
168
|
}
|
|
154
169
|
|
|
170
|
+
async function waitStablePane(tmuxBin, target, { env, readyMs }) {
|
|
171
|
+
const deadline = Date.now() + Math.max(0, readyMs | 0);
|
|
172
|
+
for (;;) {
|
|
173
|
+
const state = await tmuxExec(tmuxBin,
|
|
174
|
+
['display-message', '-p', '-t', target, '#{pane_dead}\t#{pane_dead_status}\t#{pane_id}'],
|
|
175
|
+
{ env, timeoutMs: 2000 });
|
|
176
|
+
if (state.err) return { alive: false, status: null, target: null };
|
|
177
|
+
const [dead, rawStatus, paneId] = state.stdout.trim().split('\t');
|
|
178
|
+
if (!/^%[0-9]+$/.test(paneId || '')) return { alive: false, status: null, target: null };
|
|
179
|
+
if (dead === '1') {
|
|
180
|
+
const status = /^-?[0-9]+$/.test(rawStatus || '') ? Number(rawStatus) : null;
|
|
181
|
+
return { alive: false, status, target: paneId };
|
|
182
|
+
}
|
|
183
|
+
if (dead !== '0') return { alive: false, status: null, target: null };
|
|
184
|
+
if (Date.now() >= deadline) return { alive: true, status: null, target: paneId };
|
|
185
|
+
await sleep(60);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
155
189
|
// Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
|
|
156
190
|
// load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
|
|
157
191
|
// poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
|
|
@@ -257,12 +291,14 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
257
291
|
function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
|
|
258
292
|
|
|
259
293
|
async function refreshSessions() {
|
|
260
|
-
const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}'], { env: minimalEnv() });
|
|
294
|
+
const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}\t#{session_windows}'], { env: minimalEnv() });
|
|
261
295
|
if (r.err) return new Set(); // nessun server / nessuna sessione
|
|
262
296
|
const set = new Set();
|
|
263
297
|
for (const line of r.stdout.split('\n')) {
|
|
264
|
-
const
|
|
265
|
-
|
|
298
|
+
const [rawName, rawWindows] = line.split('\t');
|
|
299
|
+
const n = String(rawName || '').trim();
|
|
300
|
+
const windows = rawWindows === undefined ? 1 : Number(rawWindows);
|
|
301
|
+
if (n && Number.isFinite(windows) && windows > 0) set.add(n);
|
|
266
302
|
}
|
|
267
303
|
return set;
|
|
268
304
|
}
|
|
@@ -362,6 +398,10 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
362
398
|
// #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
|
|
363
399
|
const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: launchEngine, cell });
|
|
364
400
|
argv.splice(2, 0, '-P', '-F', '#{pane_id}');
|
|
401
|
+
// Mantieni il pane morto solo durante la finestra di readiness: permette di
|
|
402
|
+
// catturare un errore reale del client senza lasciare una sessione fantasma.
|
|
403
|
+
// Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
|
|
404
|
+
argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
|
|
365
405
|
const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
|
|
366
406
|
if (launch.err) {
|
|
367
407
|
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
@@ -376,17 +416,34 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
376
416
|
// this readiness gate the PWA reported success and then showed nothing.
|
|
377
417
|
// Always verify liveness, including cells without a system prompt.
|
|
378
418
|
const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
|
|
379
|
-
const
|
|
380
|
-
|
|
419
|
+
const paneId = launch.stdout.trim().split('\n')[0] || '';
|
|
420
|
+
const readiness = paneId.startsWith('%')
|
|
421
|
+
? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
|
|
422
|
+
: { alive: await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs }), status: null, target: null };
|
|
423
|
+
if (!readiness.alive) {
|
|
424
|
+
let diagnostic = '';
|
|
425
|
+
if (readiness.target) {
|
|
426
|
+
const captured = await tmuxExec(tmuxBin,
|
|
427
|
+
['capture-pane', '-p', '-S', '-80', '-t', readiness.target], { env: minimalEnv(), timeoutMs: 2000 });
|
|
428
|
+
if (!captured.err) diagnostic = sanitizeEarlyDiagnostic(captured.stdout, launchEngine, cell, home);
|
|
429
|
+
}
|
|
430
|
+
// remain-on-exit era soltanto diagnostico: nessun pane morto deve restare
|
|
431
|
+
// nella Fleet o nella lista tmux dopo aver raccolto l'errore.
|
|
432
|
+
await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv(), timeoutMs: 2000 });
|
|
381
433
|
cache = { ...cache, at: 0 };
|
|
382
|
-
|
|
434
|
+
const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
|
|
435
|
+
const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
|
|
436
|
+
throw httpError(500, `client ${client} terminato subito${status}: ${diagnostic || 'verifica login, provider, modello e argomenti dell\'engine'}`);
|
|
437
|
+
}
|
|
438
|
+
if (readiness.target) {
|
|
439
|
+
await tmuxExec(tmuxBin,
|
|
440
|
+
['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
|
|
383
441
|
}
|
|
384
442
|
|
|
385
443
|
// (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
|
|
386
444
|
// (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
|
|
387
445
|
let prompt = null;
|
|
388
446
|
if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
|
|
389
|
-
const paneId = launch.stdout.trim().split('\n')[0] || '';
|
|
390
447
|
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
391
448
|
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
392
449
|
env: minimalEnv(),
|
|
@@ -605,6 +662,68 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
605
662
|
.filter((cell) => cell && sessions.has(cell.tmuxSession)).map((cell) => cell.id),
|
|
606
663
|
};
|
|
607
664
|
}
|
|
665
|
+
|
|
666
|
+
// Ripristino engine separato e atomico. Il formato portatile non accetta mai
|
|
667
|
+
// `env` values: per un engine custom nuovo parte da env vuoto; in overwrite
|
|
668
|
+
// conserva gli eventuali valori write-only già configurati sul target.
|
|
669
|
+
async function restoreEngines(engines, opts = {}) {
|
|
670
|
+
if (readonly()) throw httpError(403, 'READONLY: restore-engines bloccato');
|
|
671
|
+
if (!Array.isArray(engines) || engines.length < 1 || engines.length > 24) {
|
|
672
|
+
throw httpError(400, 'engines deve contenere 1..24 definizioni');
|
|
673
|
+
}
|
|
674
|
+
const allowed = new Set(['id', 'label', 'rc', 'managed', 'command', 'args', 'envKeys', 'model', 'promptMode', 'promptFlag']);
|
|
675
|
+
const seen = new Set();
|
|
676
|
+
for (const engine of engines) {
|
|
677
|
+
if (!engine || typeof engine !== 'object' || Array.isArray(engine)) throw httpError(400, 'definizione engine non valida');
|
|
678
|
+
for (const key of Object.keys(engine)) if (!allowed.has(key)) throw httpError(400, `campo engine backup non ammesso: ${key}`);
|
|
679
|
+
if (typeof engine.id !== 'string' || seen.has(engine.id)) throw httpError(400, `id engine duplicato o mancante: ${engine.id || '?'}`);
|
|
680
|
+
if (engine.managed && engine.envKeys !== undefined) throw httpError(400, `envKeys non ammesso per engine managed: ${engine.id}`);
|
|
681
|
+
if (!engine.managed && (!Array.isArray(engine.envKeys)
|
|
682
|
+
|| engine.envKeys.length > 32
|
|
683
|
+
|| engine.envKeys.some((key) => typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key))
|
|
684
|
+
|| new Set(engine.envKeys).size !== engine.envKeys.length)) {
|
|
685
|
+
throw httpError(400, `envKeys non valido per engine: ${engine.id}`);
|
|
686
|
+
}
|
|
687
|
+
if (!engine.managed && Array.isArray(engine.args) && engine.args.some((arg) => {
|
|
688
|
+
const text = String(arg || '');
|
|
689
|
+
return /(?:bearer\s+|authorization\s*[:=]|(?:api[_-]?key|secret|token)\s*[:=])/i.test(text)
|
|
690
|
+
|| /^-{1,2}(?:api[-_]?key|access[-_]?key|auth(?:orization)?[-_]?token|token|secret|password|credential)(?:$|=)/i.test(text)
|
|
691
|
+
|| /\b(?:sk|fw|fpk|hf|zai)[-_][A-Za-z0-9._-]{8,}\b/i.test(text)
|
|
692
|
+
|| /https?:\/\/[^\s/@:]+:[^\s/@]+@/i.test(text);
|
|
693
|
+
})) {
|
|
694
|
+
throw httpError(400, `argomento potenzialmente segreto non ammesso per engine: ${engine.id}`);
|
|
695
|
+
}
|
|
696
|
+
seen.add(engine.id);
|
|
697
|
+
}
|
|
698
|
+
const defs = reloadDefs();
|
|
699
|
+
const conflicts = engines.filter((engine) => !!findEngine(defs, engine.id)).map((engine) => engine.id);
|
|
700
|
+
if (conflicts.length && opts.overwrite !== true) {
|
|
701
|
+
throw httpError(409, `engine già esistenti: ${conflicts.join(', ')}`, { code: 'engine-conflicts', conflicts });
|
|
702
|
+
}
|
|
703
|
+
const sessions = await refreshSessions();
|
|
704
|
+
const affected = defs.cells.filter((cell) => conflicts.includes(cell.engine) && sessions.has(cell.tmuxSession)).map((cell) => cell.id);
|
|
705
|
+
await mutate(defs, (draft) => {
|
|
706
|
+
for (const portable of engines) {
|
|
707
|
+
const index = draft.engines.findIndex((current) => current.id === portable.id);
|
|
708
|
+
const current = index >= 0 ? draft.engines[index] : null;
|
|
709
|
+
const next = { ...portable };
|
|
710
|
+
if (!next.managed) {
|
|
711
|
+
const keys = next.envKeys;
|
|
712
|
+
delete next.envKeys;
|
|
713
|
+
next.env = Object.fromEntries(keys.map((key) => [key,
|
|
714
|
+
current && !current.managed && Object.prototype.hasOwnProperty.call(current.env || {}, key)
|
|
715
|
+
? current.env[key] : '',
|
|
716
|
+
]));
|
|
717
|
+
}
|
|
718
|
+
if (index >= 0) draft.engines[index] = next; else draft.engines.push(next);
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
return {
|
|
722
|
+
ok: true, count: engines.length,
|
|
723
|
+
created: engines.filter((engine) => !conflicts.includes(engine.id)).map((engine) => engine.id),
|
|
724
|
+
replaced: conflicts, needsRestart: affected,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
608
727
|
async function removeCell(id, opts = {}) {
|
|
609
728
|
if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
|
|
610
729
|
let defs = reloadDefs();
|
|
@@ -694,7 +813,10 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
694
813
|
...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
|
|
695
814
|
})),
|
|
696
815
|
managedCatalog: publicCatalog(),
|
|
697
|
-
managedConfig: {
|
|
816
|
+
managedConfig: {
|
|
817
|
+
providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env'),
|
|
818
|
+
providerShellPath: cfg.providerShellPath || path.join(home, '.config', 'ai-shell', 'providers.zsh'),
|
|
819
|
+
},
|
|
698
820
|
};
|
|
699
821
|
}
|
|
700
822
|
|
|
@@ -776,7 +898,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
776
898
|
provider: 'builtin',
|
|
777
899
|
status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
|
|
778
900
|
defineEngine, editEngine, removeEngine,
|
|
779
|
-
defineCell, editCell, removeCell, importCell, restoreCells,
|
|
901
|
+
defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
|
|
780
902
|
schema, definitions, capabilities,
|
|
781
903
|
};
|
|
782
904
|
}
|
|
@@ -787,5 +909,7 @@ module.exports = {
|
|
|
787
909
|
minimalEnv,
|
|
788
910
|
promptCharsOk,
|
|
789
911
|
redactSecrets,
|
|
912
|
+
sanitizeEarlyDiagnostic,
|
|
913
|
+
waitStablePane,
|
|
790
914
|
MINIMAL_ENV_KEYS,
|
|
791
915
|
};
|
package/lib/fleet/managed.js
CHANGED
|
@@ -140,24 +140,38 @@ function defaultDefinitions() {
|
|
|
140
140
|
};
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
function
|
|
143
|
+
function parseAssignments(raw) {
|
|
144
144
|
const out = {};
|
|
145
|
-
let raw;
|
|
146
|
-
try {
|
|
147
|
-
const st = fs.lstatSync(file);
|
|
148
|
-
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077)) return out;
|
|
149
|
-
raw = fs.readFileSync(file, 'utf8');
|
|
150
|
-
} catch (_) { return out; }
|
|
151
145
|
for (const line of raw.split(/\r?\n/)) {
|
|
152
146
|
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
153
147
|
if (!m) continue;
|
|
154
148
|
let v = m[2];
|
|
155
149
|
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) v = v.slice(1, -1);
|
|
150
|
+
// This is a data parser, not a shell. Reject syntax that would have a
|
|
151
|
+
// different meaning if sourced instead of treating it as a credential.
|
|
152
|
+
if (/\$\(|`|\x00|\r|\n/.test(v)) continue;
|
|
156
153
|
out[m[1]] = v;
|
|
157
154
|
}
|
|
158
155
|
return out;
|
|
159
156
|
}
|
|
160
157
|
|
|
158
|
+
function parseEnvFile(file) {
|
|
159
|
+
try {
|
|
160
|
+
const st = fs.lstatSync(file);
|
|
161
|
+
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) || st.size > 256 * 1024) return {};
|
|
162
|
+
return parseAssignments(fs.readFileSync(file, 'utf8'));
|
|
163
|
+
} catch (_) { return {}; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseProviderShellFile(file) {
|
|
167
|
+
try {
|
|
168
|
+
const st = fs.lstatSync(file);
|
|
169
|
+
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o022) || st.size > 256 * 1024) return {};
|
|
170
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
|
|
171
|
+
return parseAssignments(fs.readFileSync(file, 'utf8'));
|
|
172
|
+
} catch (_) { return {}; }
|
|
173
|
+
}
|
|
174
|
+
|
|
161
175
|
function binaryCandidates(client, home) {
|
|
162
176
|
const prefix = process.env.PREFIX || '';
|
|
163
177
|
const bin = client;
|
|
@@ -180,17 +194,51 @@ function findBinary(client, home) {
|
|
|
180
194
|
return null;
|
|
181
195
|
}
|
|
182
196
|
|
|
197
|
+
// Termux reports process.platform === 'android' and deliberately has no
|
|
198
|
+
// /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
|
|
199
|
+
// `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
|
|
200
|
+
// client starts. Detect only that explicit Node shebang and invoke it through
|
|
201
|
+
// the already-running trusted Node executable. Native and shell binaries keep
|
|
202
|
+
// their original direct-exec path.
|
|
203
|
+
function needsExplicitNode(binary, platform = process.platform) {
|
|
204
|
+
if (platform !== 'android') return false;
|
|
205
|
+
try {
|
|
206
|
+
const fd = fs.openSync(binary, 'r');
|
|
207
|
+
try {
|
|
208
|
+
const buffer = Buffer.alloc(160);
|
|
209
|
+
const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
210
|
+
const first = buffer.subarray(0, length).toString('utf8').split(/\r?\n/, 1)[0];
|
|
211
|
+
return /^#!\s*\/usr\/bin\/env(?:\s+-S)?\s+node(?:\s|$)/.test(first);
|
|
212
|
+
} finally { fs.closeSync(fd); }
|
|
213
|
+
} catch (_) { return false; }
|
|
214
|
+
}
|
|
215
|
+
|
|
183
216
|
function secretsPath(cfg, home) {
|
|
184
217
|
return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS || path.join(home, '.nexuscrew', 'providers.env');
|
|
185
218
|
}
|
|
186
219
|
|
|
220
|
+
function shellProvidersPath(cfg, home) {
|
|
221
|
+
return cfg.providerShellPath || process.env.NEXUSCREW_PROVIDER_SHELL
|
|
222
|
+
|| path.join(home, '.config', 'ai-shell', 'providers.zsh');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function credentialSources(cfg, home) {
|
|
226
|
+
return {
|
|
227
|
+
runtime: cfg.env || process.env,
|
|
228
|
+
shell: parseProviderShellFile(shellProvidersPath(cfg, home)),
|
|
229
|
+
legacy: parseEnvFile(secretsPath(cfg, home)),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
187
233
|
function credential(profile, spec, cfg, home) {
|
|
188
234
|
if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '' };
|
|
189
235
|
const envKey = profile.auth === 'dynamic' ? spec.envKey : profile.auth;
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
|
|
236
|
+
const sources = credentialSources(cfg, home);
|
|
237
|
+
// The fixed shell file is already the user's environment source. Values are
|
|
238
|
+
// consumed only in memory and passed to the selected child; never persisted
|
|
239
|
+
// in fleet.json, service files, API responses or logs.
|
|
240
|
+
let value = sources.runtime[envKey] || sources.shell[envKey] || '';
|
|
241
|
+
if (!value && profile.legacySecrets) value = sources.legacy[envKey] || '';
|
|
194
242
|
return { envKey, value };
|
|
195
243
|
}
|
|
196
244
|
|
|
@@ -202,7 +250,9 @@ async function discoverOllamaModels(opts = {}) {
|
|
|
202
250
|
if (typeof fetchImpl !== 'function') return [...OLLAMA_CLOUD_MODELS];
|
|
203
251
|
try {
|
|
204
252
|
const home = opts.home || require('node:os').homedir();
|
|
205
|
-
const
|
|
253
|
+
const sources = credentialSources(opts, home);
|
|
254
|
+
const apiKey = opts.apiKey || sources.runtime.OLLAMA_API_KEY
|
|
255
|
+
|| sources.shell.OLLAMA_API_KEY || sources.legacy.OLLAMA_API_KEY;
|
|
206
256
|
if (!apiKey) throw new Error('OLLAMA_API_KEY missing');
|
|
207
257
|
const response = await fetchImpl('https://ollama.com/api/tags', { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout?.(2500) });
|
|
208
258
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
@@ -393,7 +443,14 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
393
443
|
if (model) args.push('--model', model);
|
|
394
444
|
}
|
|
395
445
|
if (cell?.prompt) args.push(cell.prompt);
|
|
396
|
-
|
|
446
|
+
let command = info.binary;
|
|
447
|
+
if (needsExplicitNode(info.binary, cfg.platform || process.platform)) {
|
|
448
|
+
command = cfg.nodeExecPath || process.execPath;
|
|
449
|
+
args.unshift(info.binary);
|
|
450
|
+
}
|
|
451
|
+
return { ok: true, info, engine: {
|
|
452
|
+
...engine, command, args, env, promptMode: 'managed-argv', clientBinary: info.binary,
|
|
453
|
+
} };
|
|
397
454
|
}
|
|
398
455
|
|
|
399
456
|
function publicCatalog() {
|
|
@@ -410,6 +467,6 @@ function publicCatalog() {
|
|
|
410
467
|
|
|
411
468
|
module.exports = {
|
|
412
469
|
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, CLIENT_LABELS, normalizeManagedSpec,
|
|
413
|
-
defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine,
|
|
414
|
-
discoverPiModels, parseEnvFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
470
|
+
defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
471
|
+
discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
415
472
|
};
|
package/lib/fleet/routes.js
CHANGED
|
@@ -27,7 +27,7 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
27
27
|
const r = express.Router();
|
|
28
28
|
const smallJson = express.json({ limit: '4kb' });
|
|
29
29
|
const restoreJson = express.json({ limit: '256kb' });
|
|
30
|
-
r.use((req, res, next) => (req.path === '/restore-cells' ? restoreJson : smallJson)(req, res, next));
|
|
30
|
+
r.use((req, res, next) => (req.path === '/restore-cells' || req.path === '/restore-engines' ? restoreJson : smallJson)(req, res, next));
|
|
31
31
|
|
|
32
32
|
const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
33
33
|
|
|
@@ -113,6 +113,11 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
113
113
|
r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
|
|
114
114
|
r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
|
|
115
115
|
r.post('/restore-cells', guard((f, b) => { requireCap(f, 'restore'); return f.restoreCells(b.cells); }, { mutate: true }));
|
|
116
|
+
r.post('/restore-engines', guard((f, b) => {
|
|
117
|
+
requireCap(f, 'restore');
|
|
118
|
+
if (typeof f.restoreEngines !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
|
|
119
|
+
return f.restoreEngines(b.engines, { overwrite: b.overwrite === true });
|
|
120
|
+
}, { mutate: true }));
|
|
116
121
|
// Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
|
|
117
122
|
// gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
|
|
118
123
|
r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
|