@mmmbuto/nexuscrew 0.8.12 → 0.8.14
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 +90 -36
- package/frontend/dist/assets/index-BR2Qfqi2.js +91 -0
- package/frontend/dist/assets/{index-PkKeNfT7.css → index-CopPQTMk.css} +2 -2
- 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 +14 -0
- package/lib/fleet/builtin.js +243 -38
- package/lib/fleet/cell-exec.js +81 -0
- package/lib/fleet/credentials.js +135 -0
- package/lib/fleet/env-key.js +12 -0
- package/lib/fleet/launch-broker.js +136 -0
- package/lib/fleet/managed.js +139 -19
- package/lib/fleet/routes.js +16 -1
- package/lib/mcp/server.js +131 -3
- package/lib/proxy/federation.js +47 -7
- package/lib/pty/attach.js +3 -2
- package/lib/runtime/env.js +50 -0
- package/lib/server.js +26 -2
- package/lib/settings/routes.js +4 -3
- package/lib/tmux/actions.js +96 -1
- package/lib/tmux/list.js +22 -3
- 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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const net = require('node:net');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
|
|
9
|
+
const MAX_PAYLOAD = 512 * 1024;
|
|
10
|
+
const REQUEST_LIMIT = 256;
|
|
11
|
+
|
|
12
|
+
function runtimeDir(cfg = {}) {
|
|
13
|
+
const home = cfg.home || os.homedir();
|
|
14
|
+
return cfg.launchRuntimeDir || path.join(home, '.nexuscrew', 'run');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureRuntimeDir(dir) {
|
|
18
|
+
const parent = path.dirname(dir);
|
|
19
|
+
try {
|
|
20
|
+
const parentSt = fs.lstatSync(parent);
|
|
21
|
+
const owned = typeof process.getuid !== 'function' || parentSt.uid === process.getuid();
|
|
22
|
+
if (!parentSt.isSymbolicLink() && parentSt.isDirectory() && owned && (parentSt.mode & 0o077)) {
|
|
23
|
+
fs.chmodSync(parent, 0o700);
|
|
24
|
+
}
|
|
25
|
+
const checked = fs.lstatSync(parent);
|
|
26
|
+
if (checked.isSymbolicLink() || !checked.isDirectory()
|
|
27
|
+
|| (typeof process.getuid === 'function' && checked.uid !== process.getuid()) || (checked.mode & 0o077)) {
|
|
28
|
+
throw new Error('unsafe launch broker parent directory');
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code !== 'ENOENT') throw error;
|
|
32
|
+
const grand = fs.lstatSync(path.dirname(parent));
|
|
33
|
+
if (grand.isSymbolicLink() || !grand.isDirectory()
|
|
34
|
+
|| (typeof process.getuid === 'function' && grand.uid !== process.getuid()) || (grand.mode & 0o022)) {
|
|
35
|
+
throw new Error('unsafe launch broker parent root');
|
|
36
|
+
}
|
|
37
|
+
fs.mkdirSync(parent, { mode: 0o700 });
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const st = fs.lstatSync(dir);
|
|
41
|
+
if (st.isSymbolicLink() || !st.isDirectory()
|
|
42
|
+
|| (typeof process.getuid === 'function' && st.uid !== process.getuid()) || (st.mode & 0o077)) {
|
|
43
|
+
throw new Error('unsafe launch broker directory');
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.code !== 'ENOENT') throw error;
|
|
47
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function encodePayload(payload) {
|
|
52
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
53
|
+
if (!body.length || body.length > MAX_PAYLOAD) throw new Error('launch payload too large');
|
|
54
|
+
const head = Buffer.allocUnsafe(4); head.writeUInt32BE(body.length, 0);
|
|
55
|
+
return Buffer.concat([head, body]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createLaunchBroker(cfg = {}) {
|
|
59
|
+
const dir = runtimeDir(cfg);
|
|
60
|
+
let server = null;
|
|
61
|
+
let socketPath = '';
|
|
62
|
+
let starting = null;
|
|
63
|
+
let closed = false;
|
|
64
|
+
const pending = new Map();
|
|
65
|
+
const ttlMs = Math.max(1000, Number(cfg.launchTokenTtlMs) || 15000);
|
|
66
|
+
|
|
67
|
+
function expire(nonce) {
|
|
68
|
+
const entry = pending.get(nonce);
|
|
69
|
+
if (!entry) return;
|
|
70
|
+
pending.delete(nonce);
|
|
71
|
+
clearTimeout(entry.timer);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function start() {
|
|
75
|
+
if (closed) throw new Error('launch broker closed');
|
|
76
|
+
if (server) return socketPath;
|
|
77
|
+
if (starting) return starting;
|
|
78
|
+
starting = new Promise((resolve, reject) => {
|
|
79
|
+
try {
|
|
80
|
+
ensureRuntimeDir(dir);
|
|
81
|
+
socketPath = path.join(dir, `launch-${process.pid}-${crypto.randomBytes(5).toString('hex')}.sock`);
|
|
82
|
+
} catch (error) { reject(error); return; }
|
|
83
|
+
server = net.createServer((socket) => {
|
|
84
|
+
let raw = '';
|
|
85
|
+
socket.setEncoding('utf8');
|
|
86
|
+
socket.setTimeout(3000, () => socket.destroy());
|
|
87
|
+
socket.on('data', (chunk) => {
|
|
88
|
+
raw += chunk;
|
|
89
|
+
if (raw.length > REQUEST_LIMIT) return socket.destroy();
|
|
90
|
+
const nl = raw.indexOf('\n');
|
|
91
|
+
if (nl === -1) return;
|
|
92
|
+
let nonce = '';
|
|
93
|
+
try { nonce = JSON.parse(raw.slice(0, nl)).nonce; } catch (_) {}
|
|
94
|
+
if (typeof nonce !== 'string' || !/^[a-f0-9]{64}$/.test(nonce)) return socket.destroy();
|
|
95
|
+
const entry = pending.get(nonce);
|
|
96
|
+
if (!entry || entry.expires < Date.now()) { expire(nonce); return socket.destroy(); }
|
|
97
|
+
// Single-use before any bytes leave this process. A second client can
|
|
98
|
+
// never claim the same payload, even while the first socket drains.
|
|
99
|
+
pending.delete(nonce); clearTimeout(entry.timer);
|
|
100
|
+
try { socket.end(entry.encoded); } catch (_) { socket.destroy(); }
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
server.once('error', (error) => {
|
|
104
|
+
if (!server?.listening) { server = null; starting = null; reject(error); }
|
|
105
|
+
});
|
|
106
|
+
server.listen(socketPath, () => {
|
|
107
|
+
try { fs.chmodSync(socketPath, 0o600); } catch (_) {}
|
|
108
|
+
server.unref(); starting = null; resolve(socketPath);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
return starting;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function issue(payload) {
|
|
115
|
+
const target = await start();
|
|
116
|
+
const nonce = crypto.randomBytes(32).toString('hex');
|
|
117
|
+
const entry = { encoded: encodePayload(payload), expires: Date.now() + ttlMs, timer: null };
|
|
118
|
+
entry.timer = setTimeout(() => expire(nonce), ttlMs);
|
|
119
|
+
entry.timer.unref?.();
|
|
120
|
+
pending.set(nonce, entry);
|
|
121
|
+
return { socketPath: target, nonce };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function close() {
|
|
125
|
+
if (closed) return;
|
|
126
|
+
closed = true;
|
|
127
|
+
for (const [nonce] of pending) expire(nonce);
|
|
128
|
+
const active = server; server = null;
|
|
129
|
+
if (active) await new Promise((resolve) => active.close(() => resolve()));
|
|
130
|
+
try { if (socketPath) fs.unlinkSync(socketPath); } catch (_) {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { issue, close, pendingCount: () => pending.size };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { createLaunchBroker, runtimeDir, ensureRuntimeDir, encodePayload, MAX_PAYLOAD };
|
package/lib/fleet/managed.js
CHANGED
|
@@ -6,6 +6,8 @@ const fs = require('node:fs');
|
|
|
6
6
|
const path = require('node:path');
|
|
7
7
|
const crypto = require('node:crypto');
|
|
8
8
|
const { execFile } = require('node:child_process');
|
|
9
|
+
const { ENV_KEY_RE } = require('./env-key.js');
|
|
10
|
+
const { readCredentialStore } = require('./credentials.js');
|
|
9
11
|
|
|
10
12
|
const OLLAMA_CLOUD_MODELS = Object.freeze([
|
|
11
13
|
'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
|
|
@@ -20,7 +22,6 @@ const OLLAMA_CONTEXT = Object.freeze({
|
|
|
20
22
|
const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
|
|
21
23
|
const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
|
|
22
24
|
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi' });
|
|
23
|
-
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
24
25
|
const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
25
26
|
|
|
26
27
|
function validBaseUrl(value) {
|
|
@@ -140,24 +141,65 @@ function defaultDefinitions() {
|
|
|
140
141
|
};
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
function
|
|
144
|
+
function parseAssignments(raw) {
|
|
144
145
|
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
146
|
for (const line of raw.split(/\r?\n/)) {
|
|
152
147
|
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
153
148
|
if (!m) continue;
|
|
154
149
|
let v = m[2];
|
|
155
150
|
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) v = v.slice(1, -1);
|
|
151
|
+
// This is a data parser, not a shell. Reject syntax that would have a
|
|
152
|
+
// different meaning if sourced instead of treating it as a credential.
|
|
153
|
+
if (/\$\(|`|\x00|\r|\n|\$(?:\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)/.test(v)) continue;
|
|
156
154
|
out[m[1]] = v;
|
|
157
155
|
}
|
|
158
156
|
return out;
|
|
159
157
|
}
|
|
160
158
|
|
|
159
|
+
function insideRoot(root, target) {
|
|
160
|
+
const relative = path.relative(root, target);
|
|
161
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function safeAllowedRoots(roots = []) {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const root of roots) {
|
|
167
|
+
try {
|
|
168
|
+
const st = fs.lstatSync(root);
|
|
169
|
+
if (st.isSymbolicLink() || !st.isDirectory() || (st.mode & 0o022)) continue;
|
|
170
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) continue;
|
|
171
|
+
out.push(fs.realpathSync(root));
|
|
172
|
+
} catch (_) {}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseEnvFile(file, opts = {}) {
|
|
178
|
+
try {
|
|
179
|
+
const lst = fs.lstatSync(file);
|
|
180
|
+
let target = file;
|
|
181
|
+
if (lst.isSymbolicLink()) {
|
|
182
|
+
const roots = safeAllowedRoots(opts.allowSymlinkRoots);
|
|
183
|
+
if (!roots.length) return {};
|
|
184
|
+
target = fs.realpathSync(file);
|
|
185
|
+
if (!roots.some((root) => insideRoot(root, target))) return {};
|
|
186
|
+
}
|
|
187
|
+
const st = fs.lstatSync(target);
|
|
188
|
+
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) || st.size > 256 * 1024) return {};
|
|
189
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
|
|
190
|
+
return parseAssignments(fs.readFileSync(target, 'utf8'));
|
|
191
|
+
} catch (_) { return {}; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseProviderShellFile(file) {
|
|
195
|
+
try {
|
|
196
|
+
const st = fs.lstatSync(file);
|
|
197
|
+
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o022) || st.size > 256 * 1024) return {};
|
|
198
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
|
|
199
|
+
return parseAssignments(fs.readFileSync(file, 'utf8'));
|
|
200
|
+
} catch (_) { return {}; }
|
|
201
|
+
}
|
|
202
|
+
|
|
161
203
|
function binaryCandidates(client, home) {
|
|
162
204
|
const prefix = process.env.PREFIX || '';
|
|
163
205
|
const bin = client;
|
|
@@ -180,18 +222,82 @@ function findBinary(client, home) {
|
|
|
180
222
|
return null;
|
|
181
223
|
}
|
|
182
224
|
|
|
225
|
+
// Termux reports process.platform === 'android' and deliberately has no
|
|
226
|
+
// /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
|
|
227
|
+
// `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
|
|
228
|
+
// client starts. Detect only that explicit Node shebang and invoke it through
|
|
229
|
+
// the already-running trusted Node executable. Native and shell binaries keep
|
|
230
|
+
// their original direct-exec path.
|
|
231
|
+
function needsExplicitNode(binary, platform = process.platform) {
|
|
232
|
+
if (platform !== 'android') return false;
|
|
233
|
+
try {
|
|
234
|
+
const fd = fs.openSync(binary, 'r');
|
|
235
|
+
try {
|
|
236
|
+
const buffer = Buffer.alloc(160);
|
|
237
|
+
const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
238
|
+
const first = buffer.subarray(0, length).toString('utf8').split(/\r?\n/, 1)[0];
|
|
239
|
+
return /^#!\s*\/usr\/bin\/env(?:\s+-S)?\s+node(?:\s|$)/.test(first);
|
|
240
|
+
} finally { fs.closeSync(fd); }
|
|
241
|
+
} catch (_) { return false; }
|
|
242
|
+
}
|
|
243
|
+
|
|
183
244
|
function secretsPath(cfg, home) {
|
|
184
245
|
return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS || path.join(home, '.nexuscrew', 'providers.env');
|
|
185
246
|
}
|
|
186
247
|
|
|
248
|
+
function shellProvidersPath(cfg, home) {
|
|
249
|
+
return cfg.providerShellPath || process.env.NEXUSCREW_PROVIDER_SHELL
|
|
250
|
+
|| path.join(home, '.config', 'ai-shell', 'providers.zsh');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function providerKeyPaths(cfg, home) {
|
|
254
|
+
const paths = [
|
|
255
|
+
cfg.providerKeysPath || process.env.NEXUSCREW_PROVIDER_KEYS
|
|
256
|
+
|| path.join(home, '.config', 'keys', 'ai.env'),
|
|
257
|
+
cfg.providerSecurePath || process.env.NEXUSCREW_PROVIDER_SECURE
|
|
258
|
+
|| path.join(home, '.config', 'secure', '.env'),
|
|
259
|
+
];
|
|
260
|
+
return [...new Set(paths.filter((file) => typeof file === 'string' && file))];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseProviderKeyFiles(cfg, home) {
|
|
264
|
+
const values = {};
|
|
265
|
+
// Match providers.zsh ordering: a later secure file may intentionally
|
|
266
|
+
// override the canonical ai.env value. Files remain data-only and must be
|
|
267
|
+
// private regular files owned by the NexusCrew user.
|
|
268
|
+
const files = providerKeyPaths(cfg, home);
|
|
269
|
+
const roots = [...new Set(files.map((file) => path.dirname(path.resolve(file))))];
|
|
270
|
+
for (const file of files) Object.assign(values, parseEnvFile(file, { allowSymlinkRoots: roots }));
|
|
271
|
+
return values;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function credentialSources(cfg, home) {
|
|
275
|
+
let local = {};
|
|
276
|
+
try { local = readCredentialStore(cfg, home); } catch (_) { /* unsafe/corrupt store is ignored, never trusted */ }
|
|
277
|
+
return {
|
|
278
|
+
runtime: cfg.env || process.env,
|
|
279
|
+
local,
|
|
280
|
+
shell: parseProviderShellFile(shellProvidersPath(cfg, home)),
|
|
281
|
+
keys: parseProviderKeyFiles(cfg, home),
|
|
282
|
+
legacy: parseEnvFile(secretsPath(cfg, home)),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
187
286
|
function credential(profile, spec, cfg, home) {
|
|
188
|
-
if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '' };
|
|
287
|
+
if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '', source: profile.auth };
|
|
189
288
|
const envKey = profile.auth === 'dynamic' ? spec.envKey : profile.auth;
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
return { envKey, value };
|
|
289
|
+
const sources = credentialSources(cfg, home);
|
|
290
|
+
// The fixed shell file is already the user's environment source. Values are
|
|
291
|
+
// consumed only in memory and passed to the selected child; never persisted
|
|
292
|
+
// in fleet.json, service files, API responses or logs.
|
|
293
|
+
if (sources.runtime[envKey]) return { envKey, value: sources.runtime[envKey], source: 'environment' };
|
|
294
|
+
if (sources.local[envKey]) return { envKey, value: sources.local[envKey], source: 'local' };
|
|
295
|
+
if (sources.shell[envKey]) return { envKey, value: sources.shell[envKey], source: 'compatibility' };
|
|
296
|
+
if (sources.keys[envKey]) return { envKey, value: sources.keys[envKey], source: 'compatibility' };
|
|
297
|
+
if (profile.legacySecrets && sources.legacy[envKey]) {
|
|
298
|
+
return { envKey, value: sources.legacy[envKey], source: 'compatibility' };
|
|
299
|
+
}
|
|
300
|
+
return { envKey, value: '', source: 'missing' };
|
|
195
301
|
}
|
|
196
302
|
|
|
197
303
|
let ollamaCache = { at: 0, models: [] };
|
|
@@ -202,7 +308,10 @@ async function discoverOllamaModels(opts = {}) {
|
|
|
202
308
|
if (typeof fetchImpl !== 'function') return [...OLLAMA_CLOUD_MODELS];
|
|
203
309
|
try {
|
|
204
310
|
const home = opts.home || require('node:os').homedir();
|
|
205
|
-
const
|
|
311
|
+
const sources = credentialSources(opts, home);
|
|
312
|
+
const apiKey = opts.apiKey || sources.runtime.OLLAMA_API_KEY
|
|
313
|
+
|| sources.local.OLLAMA_API_KEY || sources.shell.OLLAMA_API_KEY || sources.keys.OLLAMA_API_KEY
|
|
314
|
+
|| sources.legacy.OLLAMA_API_KEY;
|
|
206
315
|
if (!apiKey) throw new Error('OLLAMA_API_KEY missing');
|
|
207
316
|
const response = await fetchImpl('https://ollama.com/api/tags', { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout?.(2500) });
|
|
208
317
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
@@ -270,9 +379,12 @@ function describeManaged(spec, cfg = {}) {
|
|
|
270
379
|
credentialProfile: normalized.credentialProfile || '', model: normalized.model,
|
|
271
380
|
permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
|
|
272
381
|
endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
|
|
382
|
+
credentialSource: authConfigured ? cred.source : 'missing',
|
|
273
383
|
configured: !!binary && authConfigured, models: [...(profile.models || [])], defaultModel: profile.model || '',
|
|
274
384
|
binary: binary || '', displayName: normalized.displayName || profile.label,
|
|
275
|
-
reason: !binary ? `client ${profile.client} not found` : (!authConfigured
|
|
385
|
+
reason: !binary ? `client ${profile.client} not found` : (!authConfigured
|
|
386
|
+
? `credential ${cred.envKey} missing — set it on this device`
|
|
387
|
+
: 'ready'),
|
|
276
388
|
};
|
|
277
389
|
}
|
|
278
390
|
|
|
@@ -393,7 +505,14 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
393
505
|
if (model) args.push('--model', model);
|
|
394
506
|
}
|
|
395
507
|
if (cell?.prompt) args.push(cell.prompt);
|
|
396
|
-
|
|
508
|
+
let command = info.binary;
|
|
509
|
+
if (needsExplicitNode(info.binary, cfg.platform || process.platform)) {
|
|
510
|
+
command = cfg.nodeExecPath || process.execPath;
|
|
511
|
+
args.unshift(info.binary);
|
|
512
|
+
}
|
|
513
|
+
return { ok: true, info, engine: {
|
|
514
|
+
...engine, command, args, env, promptMode: 'managed-argv', clientBinary: info.binary,
|
|
515
|
+
} };
|
|
397
516
|
}
|
|
398
517
|
|
|
399
518
|
function publicCatalog() {
|
|
@@ -410,6 +529,7 @@ function publicCatalog() {
|
|
|
410
529
|
|
|
411
530
|
module.exports = {
|
|
412
531
|
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, CLIENT_LABELS, normalizeManagedSpec,
|
|
413
|
-
defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine,
|
|
414
|
-
discoverPiModels, parseEnvFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
532
|
+
defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
533
|
+
discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
534
|
+
providerKeyPaths, parseProviderKeyFiles, credentialSources, credential, ENV_KEY_RE,
|
|
415
535
|
};
|
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
|
|
|
@@ -106,6 +106,16 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
106
106
|
// Ogni route negozia la capability del provider: mancante → 501 (§9c).
|
|
107
107
|
r.get('/schema', guard((f) => { requireCap(f, 'schema'); return f.schema(); }));
|
|
108
108
|
r.get('/definitions', guard((f) => { requireCap(f, 'definitions'); return f.definitions(); }));
|
|
109
|
+
r.get('/credentials/status', guard((f) => {
|
|
110
|
+
requireCap(f, 'credentials'); return f.credentialStatus();
|
|
111
|
+
}));
|
|
112
|
+
r.post('/credentials/set', guard((f, b) => {
|
|
113
|
+
requireCap(f, 'credentials');
|
|
114
|
+
return f.setLocalCredential(String(b.envKey || ''), typeof b.value === 'string' ? b.value : '');
|
|
115
|
+
}, { mutate: true }));
|
|
116
|
+
r.post('/credentials/remove', guard((f, b) => {
|
|
117
|
+
requireCap(f, 'credentials'); return f.removeLocalCredential(String(b.envKey || ''));
|
|
118
|
+
}, { mutate: true }));
|
|
109
119
|
r.post('/define-engine', guard((f, b) => { requireCap(f, 'define'); return f.defineEngine(b.def); }, { mutate: true }));
|
|
110
120
|
r.post('/edit-engine', guard((f, b) => { requireCap(f, 'edit'); return f.editEngine(b.id, b.patch, b.envChanges); }, { mutate: true }));
|
|
111
121
|
r.post('/remove-engine', guard((f, b) => { requireCap(f, 'remove'); return f.removeEngine(b.id); }, { mutate: true }));
|
|
@@ -113,6 +123,11 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
113
123
|
r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
|
|
114
124
|
r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
|
|
115
125
|
r.post('/restore-cells', guard((f, b) => { requireCap(f, 'restore'); return f.restoreCells(b.cells); }, { mutate: true }));
|
|
126
|
+
r.post('/restore-engines', guard((f, b) => {
|
|
127
|
+
requireCap(f, 'restore');
|
|
128
|
+
if (typeof f.restoreEngines !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
|
|
129
|
+
return f.restoreEngines(b.engines, { overwrite: b.overwrite === true });
|
|
130
|
+
}, { mutate: true }));
|
|
116
131
|
// Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
|
|
117
132
|
// gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
|
|
118
133
|
r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
|
package/lib/mcp/server.js
CHANGED
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Porta NexusCrew DENTRO le sessioni AI (Claude Code / codex-vl) come server
|
|
5
5
|
// MCP: notifiche umane, richieste di attenzione (ask), consegna file, stato
|
|
6
|
-
// read-only
|
|
7
|
-
// con l'HTTP API locale di NexusCrew (loopback + Bearer)
|
|
6
|
+
// read-only e directory/invio autenticato tra celle Fleet attive. Il bridge
|
|
7
|
+
// parla SOLO con l'HTTP API locale di NexusCrew (loopback + Bearer); le route
|
|
8
|
+
// federate applicano ACL e identita' owner-qualified lato server.
|
|
8
9
|
//
|
|
9
10
|
// Protocollo: JSON-RPC 2.0, UN messaggio JSON per riga (stdio framing MCP).
|
|
10
11
|
// Hand-rolled minimale, zero dipendenze SDK (stile del repo). Fail-closed:
|
|
11
12
|
// garbage in input non crasha MAI il processo — risponde un errore JSON-RPC.
|
|
12
13
|
// Niente log su stdout (corromperebbe il canale): diagnostica su stderr.
|
|
13
14
|
const readline = require('node:readline');
|
|
15
|
+
const crypto = require('node:crypto');
|
|
14
16
|
const os = require('node:os');
|
|
15
17
|
const path = require('node:path');
|
|
16
18
|
const { execFile } = require('node:child_process');
|
|
@@ -95,6 +97,7 @@ function orderedDeckMembers(deck) {
|
|
|
95
97
|
|
|
96
98
|
const NODE_PART_RE = /^[a-z0-9-]{1,32}$/;
|
|
97
99
|
const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
100
|
+
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
98
101
|
function fleetStatusPath(node) {
|
|
99
102
|
if (!node) return '/api/fleet/status';
|
|
100
103
|
const parts = String(node).split('/');
|
|
@@ -144,6 +147,69 @@ function memberOwnerId(member, deckOwner, ownerTopology) {
|
|
|
144
147
|
return found ? found.instanceId : null;
|
|
145
148
|
}
|
|
146
149
|
|
|
150
|
+
function parseCellTarget(value) {
|
|
151
|
+
if (typeof value !== 'string') return null;
|
|
152
|
+
const split = value.indexOf(':');
|
|
153
|
+
if (split < 16) return null;
|
|
154
|
+
const instanceId = value.slice(0, split);
|
|
155
|
+
const cell = value.slice(split + 1);
|
|
156
|
+
return NODE_ID_RE.test(instanceId) && CELL_ID_RE.test(cell) ? { instanceId, cell } : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeCellPayload(payload, owner, callerSession = null) {
|
|
160
|
+
if (!payload || payload.instanceId !== owner.instanceId || !Array.isArray(payload.cells)) return [];
|
|
161
|
+
const route = owner.route.length ? owner.route.join('/') : 'local';
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
const out = [];
|
|
164
|
+
for (const raw of payload.cells) {
|
|
165
|
+
if (!raw || raw.instanceId !== owner.instanceId || !CELL_ID_RE.test(String(raw.cell || ''))
|
|
166
|
+
|| typeof raw.tmuxSession !== 'string' || !isValidSession(raw.tmuxSession)
|
|
167
|
+
|| seen.has(raw.cell)) continue;
|
|
168
|
+
seen.add(raw.cell);
|
|
169
|
+
out.push({
|
|
170
|
+
id: `${owner.instanceId}:${raw.cell}`,
|
|
171
|
+
instanceId: owner.instanceId,
|
|
172
|
+
owner: owner.label,
|
|
173
|
+
route,
|
|
174
|
+
cell: raw.cell,
|
|
175
|
+
tmuxSession: raw.tmuxSession,
|
|
176
|
+
engine: typeof raw.engine === 'string' ? raw.engine : '',
|
|
177
|
+
model: typeof raw.model === 'string' ? raw.model : '',
|
|
178
|
+
active: raw.active === true,
|
|
179
|
+
canReceive: raw.canReceive === true,
|
|
180
|
+
lastSeen: Number.isFinite(raw.lastSeen) ? raw.lastSeen : null,
|
|
181
|
+
self: owner.route.length === 0 && callerSession === raw.tmuxSession,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function readCellDirectory(ctx, callerSession = null) {
|
|
188
|
+
const [config, topology] = await Promise.all([
|
|
189
|
+
ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'),
|
|
190
|
+
]);
|
|
191
|
+
const localId = String(config && config.instanceId || '');
|
|
192
|
+
if (!NODE_ID_RE.test(localId)) throw new Error('instanceId locale non disponibile');
|
|
193
|
+
const owners = [{ instanceId: localId, route: [], label: 'Local', stale: false },
|
|
194
|
+
...topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localId)];
|
|
195
|
+
const cells = [];
|
|
196
|
+
const unavailable = [];
|
|
197
|
+
await Promise.all(owners.map(async (owner) => {
|
|
198
|
+
const apiPath = owner.route.length ? routePath(owner.route, 'cells') : '/api/cells';
|
|
199
|
+
if (!apiPath) return;
|
|
200
|
+
try {
|
|
201
|
+
cells.push(...normalizeCellPayload(await ctx.api('GET', apiPath), owner, callerSession));
|
|
202
|
+
} catch (_) {
|
|
203
|
+
unavailable.push({ instanceId: owner.instanceId, owner: owner.label,
|
|
204
|
+
route: owner.route.length ? owner.route.join('/') : 'local' });
|
|
205
|
+
}
|
|
206
|
+
}));
|
|
207
|
+
cells.sort((a, b) => (a.route === 'local' ? -1 : b.route === 'local' ? 1
|
|
208
|
+
: a.route.localeCompare(b.route)) || a.cell.localeCompare(b.cell));
|
|
209
|
+
unavailable.sort((a, b) => a.route.localeCompare(b.route));
|
|
210
|
+
return { nodeId: localId, cells, unavailable };
|
|
211
|
+
}
|
|
212
|
+
|
|
147
213
|
// --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
|
|
148
214
|
// annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
|
|
149
215
|
const TOOLS = [
|
|
@@ -337,6 +403,63 @@ const TOOLS = [
|
|
|
337
403
|
};
|
|
338
404
|
},
|
|
339
405
|
},
|
|
406
|
+
{
|
|
407
|
+
name: 'nc_cells',
|
|
408
|
+
description: 'Directory read-only di tutte le celle Fleet autorizzate nella rete NexusCrew. Usa l\'id owner-qualified restituito per evitare nomi ambigui.',
|
|
409
|
+
inputSchema: { type: 'object', properties: {} },
|
|
410
|
+
annotations: { readOnlyHint: true },
|
|
411
|
+
async handler(_args, ctx) {
|
|
412
|
+
const callerSession = await ctx.session();
|
|
413
|
+
return readCellDirectory(ctx, callerSession);
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: 'nc_send_cell',
|
|
418
|
+
description: 'Invia e sottopone un messaggio a una cella Fleet attiva autorizzata. target deve essere l\'id esatto restituito da nc_cells; submitted non significa lavoro completato.',
|
|
419
|
+
inputSchema: {
|
|
420
|
+
type: 'object',
|
|
421
|
+
properties: {
|
|
422
|
+
target: { type: 'string', description: 'id owner-qualified restituito da nc_cells: <instanceId>:<cell>' },
|
|
423
|
+
message: { type: 'string', description: 'messaggio o task da sottoporre (max 8000 caratteri)' },
|
|
424
|
+
},
|
|
425
|
+
required: ['target', 'message'],
|
|
426
|
+
},
|
|
427
|
+
async handler(args, ctx) {
|
|
428
|
+
const targetRef = parseCellTarget(argString(args, 'target', { required: true, max: 128 }));
|
|
429
|
+
if (!targetRef) throw new Error('target non valido: usa l\'id esatto restituito da nc_cells');
|
|
430
|
+
const message = argString(args, 'message', { required: true, max: 8000 });
|
|
431
|
+
for (let i = 0; i < message.length; i += 1) {
|
|
432
|
+
const code = message.charCodeAt(i);
|
|
433
|
+
if (code === 9 || code === 10 || code === 13) continue;
|
|
434
|
+
if (code < 32 || code === 127) throw new Error('message contiene caratteri di controllo non ammessi');
|
|
435
|
+
}
|
|
436
|
+
const callerSession = requireSession(await ctx.session(), 'nc_send_cell');
|
|
437
|
+
const directory = await readCellDirectory(ctx, callerSession);
|
|
438
|
+
const sender = directory.cells.find((cell) => cell.self && cell.active);
|
|
439
|
+
if (!sender) throw new Error('nc_send_cell: la sessione chiamante non e\' una cella Fleet attiva locale');
|
|
440
|
+
const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
|
|
441
|
+
&& cell.cell === targetRef.cell);
|
|
442
|
+
if (!target) throw new Error('cella destinataria non trovata nella rete autorizzata');
|
|
443
|
+
if (!target.canReceive) throw new Error('cella destinataria non attiva; nessun messaggio accodato');
|
|
444
|
+
const apiPath = target.route === 'local'
|
|
445
|
+
? '/api/cells/send' : routePath(target.route.split('/'), 'cells/send');
|
|
446
|
+
if (!apiPath) throw new Error('route destinataria non valida');
|
|
447
|
+
const id = ctx.messageId();
|
|
448
|
+
const receipt = await ctx.api('POST', apiPath, {
|
|
449
|
+
id,
|
|
450
|
+
from: { instanceId: sender.instanceId, cell: sender.cell, tmuxSession: sender.tmuxSession },
|
|
451
|
+
to: { instanceId: target.instanceId, cell: target.cell, tmuxSession: target.tmuxSession },
|
|
452
|
+
message,
|
|
453
|
+
});
|
|
454
|
+
return {
|
|
455
|
+
id: receipt.id,
|
|
456
|
+
status: receipt.status,
|
|
457
|
+
at: receipt.at,
|
|
458
|
+
to: receipt.to,
|
|
459
|
+
note: receipt.note || 'submitted conferma il trasporto, non il completamento del task',
|
|
460
|
+
};
|
|
461
|
+
},
|
|
462
|
+
},
|
|
340
463
|
{
|
|
341
464
|
name: 'nc_inbox',
|
|
342
465
|
description: 'Elenca i file ricevuti nell\'inbox NexusCrew di questa sessione (read-only).',
|
|
@@ -357,6 +480,7 @@ function createMcpServer(opts = {}) {
|
|
|
357
480
|
const env = opts.env || process.env;
|
|
358
481
|
const execFileImpl = opts.execFileImpl || execFile;
|
|
359
482
|
const fetchImpl = opts.fetchImpl || fetch;
|
|
483
|
+
const idFactory = opts.idFactory || (() => crypto.randomUUID());
|
|
360
484
|
const errlog = opts.errlog || ((s) => { try { process.stderr.write(`${s}\n`); } catch (_) {} });
|
|
361
485
|
// Config UNICA fonte per porta/token path: stessa risoluzione del server
|
|
362
486
|
// (config.json + env NEXUSCREW_CONFIG_FILE/PORT/TOKEN_FILE). opts.config per test.
|
|
@@ -405,6 +529,7 @@ function createMcpServer(opts = {}) {
|
|
|
405
529
|
api,
|
|
406
530
|
home: () => env.HOME || os.homedir(),
|
|
407
531
|
fileExists: (p) => { try { return require('node:fs').statSync(p).isFile(); } catch (_) { return false; } },
|
|
532
|
+
messageId: () => String(idFactory()).toLowerCase(),
|
|
408
533
|
};
|
|
409
534
|
|
|
410
535
|
function write(msg) {
|
|
@@ -539,4 +664,7 @@ function startMcp(opts = {}) {
|
|
|
539
664
|
return srv;
|
|
540
665
|
}
|
|
541
666
|
|
|
542
|
-
module.exports = {
|
|
667
|
+
module.exports = {
|
|
668
|
+
createMcpServer, startMcp, resolveSession, TOOLS,
|
|
669
|
+
parseCellTarget, normalizeCellPayload, readCellDirectory,
|
|
670
|
+
};
|