@mmmbuto/nexuscrew 0.8.11 → 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 +82 -36
- package/frontend/dist/assets/index-4rNd0SwZ.css +32 -0
- 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/doctor.js +26 -2
- package/lib/cli/service.js +19 -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/nodes/tunnel-supervisor.js +26 -4
- package/lib/nodes/tunnel.js +22 -6
- 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 +2 -2
- package/skills/nexuscrew-agent/SKILL.md +14 -3
- package/frontend/dist/assets/index-DoUIJ4GM.js +0 -91
- package/frontend/dist/assets/index-DrrRAIg6.css +0 -32
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/doctor.js
CHANGED
|
@@ -67,7 +67,12 @@ function checkBoot(platform, home, execImpl) {
|
|
|
67
67
|
if (platform === 'termux') {
|
|
68
68
|
const p = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
|
|
69
69
|
const ok = fs.existsSync(p);
|
|
70
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
name: 'boot script', ok, warn: ok,
|
|
72
|
+
detail: ok
|
|
73
|
+
? `${p} · app Termux:Boot non verificabile da CLI: installala e avviala una volta`
|
|
74
|
+
: 'nessun Termux:boot script',
|
|
75
|
+
};
|
|
71
76
|
}
|
|
72
77
|
if (platform === 'linux') {
|
|
73
78
|
try {
|
|
@@ -84,6 +89,24 @@ function checkBoot(platform, home, execImpl) {
|
|
|
84
89
|
return { name: 'boot (launchd RunAtLoad)', ok: true, warn: !ok, detail: ok ? 'plist installato' : 'plist non installato' };
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
function checkUserLinger(platform, execImpl, uidVal) {
|
|
93
|
+
if (platform !== 'linux') {
|
|
94
|
+
return { name: 'user linger', ok: true, detail: `${platform}: non applicabile` };
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const value = String(execImpl('loginctl', [
|
|
98
|
+
'show-user', String(uidVal), '--property=Linger', '--value',
|
|
99
|
+
], { encoding: 'utf8' }) || '').trim().toLowerCase();
|
|
100
|
+
const enabled = value === 'yes';
|
|
101
|
+
return {
|
|
102
|
+
name: 'user linger', ok: true, warn: !enabled,
|
|
103
|
+
detail: enabled ? 'enabled · il servizio user puo partire senza login' : 'disabled · abilita con loginctl enable-linger per il boot senza login',
|
|
104
|
+
};
|
|
105
|
+
} catch (_) {
|
|
106
|
+
return { name: 'user linger', ok: true, warn: true, detail: 'non verificabile; il boot senza login potrebbe non partire' };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
function checkTmuxSurvival(platform, execImpl) {
|
|
88
111
|
if (platform !== 'linux') {
|
|
89
112
|
return { name: 'tmux survival on service restart', ok: true, detail: `${platform}: systemd cgroup non applicabile` };
|
|
@@ -159,6 +182,7 @@ function doctor(opts = {}) {
|
|
|
159
182
|
checkPty(ptyLoad),
|
|
160
183
|
checkService(platform, home, execImpl, uidVal, opts.installPath),
|
|
161
184
|
checkBoot(platform, home, execImpl),
|
|
185
|
+
checkUserLinger(platform, execImpl, uidVal),
|
|
162
186
|
checkTmuxSurvival(platform, execImpl),
|
|
163
187
|
checkTokenPerms(tokenPath),
|
|
164
188
|
checkSshClient(existsImpl),
|
|
@@ -178,6 +202,6 @@ function doctor(opts = {}) {
|
|
|
178
202
|
module.exports = {
|
|
179
203
|
doctor, nodeMajor,
|
|
180
204
|
checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
|
|
181
|
-
checkTmuxSurvival,
|
|
205
|
+
checkTmuxSurvival, checkUserLinger,
|
|
182
206
|
checkSshClient, checkAutossh, checkSshPermitlisten,
|
|
183
207
|
};
|
package/lib/cli/service.js
CHANGED
|
@@ -59,13 +59,16 @@ 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));
|
|
66
68
|
return `# NexusCrew service (systemd --user, loopback, solo tunnel SSH/VPN)
|
|
67
69
|
[Unit]
|
|
68
70
|
Description=NexusCrew - browser tmux client (loopback, solo tunnel SSH/VPN)
|
|
71
|
+
Wants=network-online.target
|
|
69
72
|
After=network-online.target
|
|
70
73
|
|
|
71
74
|
[Service]
|
|
@@ -73,7 +76,7 @@ Type=simple
|
|
|
73
76
|
# The HTTP service may be the process that creates the shared tmux server.
|
|
74
77
|
# Restart only NexusCrew itself; tmux sessions are independent user workloads.
|
|
75
78
|
KillMode=process
|
|
76
|
-
WorkingDirectory=${
|
|
79
|
+
WorkingDirectory=${runtime}
|
|
77
80
|
Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
|
|
78
81
|
ExecStart=${node} ${repoBin} serve
|
|
79
82
|
Restart=on-failure
|
|
@@ -87,7 +90,7 @@ WantedBy=default.target
|
|
|
87
90
|
function generateMac(ctx) {
|
|
88
91
|
const nodeXml = escapeXml(ctx.nodeBin);
|
|
89
92
|
const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
|
|
90
|
-
const
|
|
93
|
+
const runtimeXml = escapeXml(ctx.runtimeDir || path.join(ctx.home, '.nexuscrew'));
|
|
91
94
|
const homeXml = escapeXml(ctx.home);
|
|
92
95
|
const launchPath = [...new Set([
|
|
93
96
|
path.dirname(ctx.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
|
|
@@ -106,11 +109,15 @@ function generateMac(ctx) {
|
|
|
106
109
|
<string>serve</string>
|
|
107
110
|
</array>
|
|
108
111
|
<key>WorkingDirectory</key>
|
|
109
|
-
<string>${
|
|
112
|
+
<string>${runtimeXml}</string>
|
|
110
113
|
<key>EnvironmentVariables</key>
|
|
111
114
|
<dict>
|
|
112
115
|
<key>PATH</key>
|
|
113
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>
|
|
114
121
|
</dict>
|
|
115
122
|
<key>RunAtLoad</key>
|
|
116
123
|
<true/>
|
|
@@ -131,14 +138,15 @@ function generateMac(ctx) {
|
|
|
131
138
|
function generateTermux(ctx) {
|
|
132
139
|
const nodeQ = shellQuote(ctx.nodeBin);
|
|
133
140
|
const repoBinQ = shellQuote(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
|
|
134
|
-
const repoQ = shellQuote(ctx.repoRoot);
|
|
135
141
|
return `#!/data/data/com.termux/files/usr/bin/sh
|
|
136
142
|
# NexusCrew boot (Termux) - loopback, localhost del telefono
|
|
137
143
|
export PATH=/data/data/com.termux/files/usr/bin:$PATH
|
|
138
144
|
export HOME=/data/data/com.termux/files/home
|
|
139
|
-
|
|
145
|
+
export LANG=\${LANG:-en_US.UTF-8}
|
|
146
|
+
export LC_CTYPE=\${LC_CTYPE:-en_US.UTF-8}
|
|
140
147
|
termux-wake-lock 2>/dev/null || true
|
|
141
148
|
mkdir -p "$HOME/.nexuscrew"
|
|
149
|
+
cd -- "$HOME/.nexuscrew"
|
|
142
150
|
exec ${nodeQ} ${repoBinQ} serve --pidfile >> "$HOME/.nexuscrew/nexuscrew.log" 2>&1
|
|
143
151
|
`;
|
|
144
152
|
}
|
|
@@ -228,6 +236,11 @@ function installService(platform, content, ctx, { dryRun = false, execImpl = exe
|
|
|
228
236
|
return { target, mode, written: false, failures: [], note: 'dry-run: nessuna scrittura' };
|
|
229
237
|
}
|
|
230
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 });
|
|
231
244
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
232
245
|
// temp file stessa dir (per atomic rename su stesso filesystem)
|
|
233
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
|
};
|