@mmmbuto/nexuscrew 0.7.6 → 0.8.0
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 +173 -18
- package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
- package/frontend/dist/assets/index-DQrDBogT.js +83 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +29 -0
- package/frontend/dist/version.json +1 -0
- package/lib/auth/middleware.js +7 -1
- package/lib/auth/token.js +31 -1
- package/lib/cli/commands.js +462 -31
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +319 -0
- package/lib/cli/init.js +107 -7
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -3
- package/lib/cli/url.js +63 -0
- package/lib/config.js +5 -0
- package/lib/decks/routes.js +81 -0
- package/lib/decks/store.js +117 -0
- package/lib/files/routes.js +59 -2
- package/lib/files/store.js +16 -1
- package/lib/fleet/boot.js +62 -0
- package/lib/fleet/builtin.js +594 -0
- package/lib/fleet/definitions.js +410 -0
- package/lib/fleet/index.js +49 -10
- package/lib/fleet/managed.js +211 -0
- package/lib/fleet/provider.js +102 -0
- package/lib/fleet/routes.js +78 -8
- package/lib/fs/routes.js +56 -0
- package/lib/mcp/server.js +362 -0
- package/lib/nodes/commands.js +396 -0
- package/lib/nodes/store.js +358 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +300 -0
- package/lib/notify/asks.js +150 -0
- package/lib/notify/events.js +59 -0
- package/lib/notify/notifier.js +37 -0
- package/lib/notify/persist.js +73 -0
- package/lib/notify/push.js +289 -0
- package/lib/notify/routes.js +260 -0
- package/lib/proxy/node-proxy.js +292 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +214 -15
- package/lib/settings/routes.js +473 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +8 -2
- package/skills/nexuscrew-agent/SKILL.md +83 -0
- package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
- package/skills/nexuscrew-agent/bin/nc-send +48 -0
- package/frontend/dist/assets/index-BWhSqR3J.css +0 -32
- package/frontend/dist/assets/index-Bx8vdLZY.css +0 -32
- package/frontend/dist/assets/index-CLb2xmyu.js +0 -81
- package/frontend/dist/assets/index-CgxfkmRo.js +0 -81
- package/frontend/dist/assets/index-DE67kR0M.js +0 -81
- package/frontend/dist/assets/index-DQMx2p4x.js +0 -81
- package/frontend/dist/assets/index-DWhfVwKW.css +0 -32
- package/frontend/dist/assets/index-DoPZ_3-h.js +0 -81
- package/frontend/dist/assets/index-o9l7pPAf.css +0 -32
- package/frontend/dist/assets/index-q9PUrGO0.js +0 -81
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Profili engine gestiti da NexusCrew. La configurazione descrive client e
|
|
3
|
+
// provider; command/argv/env effettivi vengono composti qui, senza shell e senza
|
|
4
|
+
// salvare segreti in fleet.json. I profili custom v1 restano supportati.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const OLLAMA_CLOUD_MODELS = Object.freeze([
|
|
9
|
+
'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
|
|
10
|
+
'qwen3.5:397b', 'deepseek-v4-flash', 'mistral-large-3:675b', 'gemma4:31b',
|
|
11
|
+
]);
|
|
12
|
+
const OLLAMA_CONTEXT = Object.freeze({
|
|
13
|
+
'glm-5.2': 1000000,
|
|
14
|
+
'kimi-k2.7-code': 262144,
|
|
15
|
+
'deepseek-v4-pro': 524288,
|
|
16
|
+
'minimax-m3': 524288,
|
|
17
|
+
'qwen3.5:397b': 262144,
|
|
18
|
+
'deepseek-v4-flash': 1048576,
|
|
19
|
+
'mistral-large-3:675b': 262144,
|
|
20
|
+
'gemma4:31b': 262144,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const CATALOG = Object.freeze([
|
|
24
|
+
{ id: 'claude.native', client: 'claude', provider: 'native', label: 'Claude · Native', auth: 'login', endpoint: 'Anthropic account', rc: true, default: true },
|
|
25
|
+
{ id: 'codex-vl.native', client: 'codex-vl', provider: 'native', label: 'Codex-VL · Native', auth: 'login', endpoint: 'OpenAI account', rc: false, default: true },
|
|
26
|
+
{ id: 'claude.ollama-cloud', client: 'claude', provider: 'ollama-cloud', label: 'Claude · Ollama Cloud Direct', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, rc: false, default: false },
|
|
27
|
+
{ id: 'codex-vl.ollama-cloud', client: 'codex-vl', provider: 'ollama-cloud', label: 'Codex-VL · Ollama Cloud Direct', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, rc: false, default: false },
|
|
28
|
+
{ id: 'claude.zai-a', client: 'claude', provider: 'zai-a', label: 'Claude · Z.AI A', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/anthropic', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], rc: false, default: false },
|
|
29
|
+
{ id: 'claude.zai-p', client: 'claude', provider: 'zai-p', label: 'Claude · Z.AI P', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/anthropic', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], rc: false, default: false },
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const byPair = (client, provider) => CATALOG.find((p) => p.client === client && p.provider === provider) || null;
|
|
33
|
+
|
|
34
|
+
function normalizeManagedSpec(value) {
|
|
35
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
36
|
+
const keys = Object.keys(value);
|
|
37
|
+
if (keys.some((k) => !['client', 'provider', 'model'].includes(k))) return null;
|
|
38
|
+
const profile = byPair(value.client, value.provider);
|
|
39
|
+
if (!profile) return null;
|
|
40
|
+
const model = value.model === undefined ? (profile.model || '') : value.model;
|
|
41
|
+
if (typeof model !== 'string' || model.length > 128) return null;
|
|
42
|
+
return { client: profile.client, provider: profile.provider, model };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function defaultDefinitions() {
|
|
46
|
+
return {
|
|
47
|
+
schemaVersion: 1,
|
|
48
|
+
engines: CATALOG.filter((p) => p.default).map((p) => ({
|
|
49
|
+
id: p.id, label: p.label, rc: p.rc,
|
|
50
|
+
managed: { client: p.client, provider: p.provider, model: p.model || '' },
|
|
51
|
+
})),
|
|
52
|
+
cells: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseEnvFile(file) {
|
|
57
|
+
const out = {};
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
const st = fs.lstatSync(file);
|
|
61
|
+
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077)) return out;
|
|
62
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
63
|
+
} catch (_) { return out; }
|
|
64
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
65
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
66
|
+
if (!m) continue;
|
|
67
|
+
let value = m[2];
|
|
68
|
+
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) value = value.slice(1, -1);
|
|
69
|
+
out[m[1]] = value;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function binaryCandidates(client, home) {
|
|
75
|
+
const prefix = process.env.PREFIX || '';
|
|
76
|
+
if (client === 'claude') return [path.join(home, '.local', 'bin', 'claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude', prefix && path.join(prefix, 'bin', 'claude')].filter(Boolean);
|
|
77
|
+
return [path.join(home, '.local', 'bin', 'codex-vl'), '/usr/local/bin/codex-vl', '/opt/homebrew/bin/codex-vl', prefix && path.join(prefix, 'bin', 'codex-vl')].filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findBinary(client, home) {
|
|
81
|
+
for (const candidate of binaryCandidates(client, home)) {
|
|
82
|
+
try {
|
|
83
|
+
const real = fs.realpathSync(candidate);
|
|
84
|
+
const st = fs.lstatSync(real);
|
|
85
|
+
if (!st.isFile() || !(st.mode & 0o100) || (st.mode & 0o002)) continue;
|
|
86
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid() && st.uid !== 0) continue;
|
|
87
|
+
return real;
|
|
88
|
+
} catch (_) { /* next */ }
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function secretsPath(cfg, home) {
|
|
94
|
+
return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS
|
|
95
|
+
|| path.join(home, '.nexuscrew', 'providers.env');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let ollamaCache = { at: 0, models: [] };
|
|
99
|
+
async function discoverOllamaModels(opts = {}) {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
|
|
102
|
+
if (!opts.noCache && ollamaCache.models.length && now - ollamaCache.at < ttl) return [...ollamaCache.models];
|
|
103
|
+
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
104
|
+
if (typeof fetchImpl !== 'function') return [...OLLAMA_CLOUD_MODELS];
|
|
105
|
+
try {
|
|
106
|
+
const home = opts.home || require('node:os').homedir();
|
|
107
|
+
const apiKey = opts.apiKey || parseEnvFile(secretsPath(opts, home)).OLLAMA_API_KEY;
|
|
108
|
+
if (!apiKey) throw new Error('OLLAMA_API_KEY mancante');
|
|
109
|
+
const response = await fetchImpl('https://ollama.com/api/tags', {
|
|
110
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
111
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined,
|
|
112
|
+
});
|
|
113
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
114
|
+
const body = await response.json();
|
|
115
|
+
const available = new Set();
|
|
116
|
+
for (const item of Array.isArray(body.models) ? body.models : []) {
|
|
117
|
+
const name = typeof item?.name === 'string' ? item.name : '';
|
|
118
|
+
if (!/^[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?$/.test(name) || name.length > 128) continue;
|
|
119
|
+
available.add(name);
|
|
120
|
+
}
|
|
121
|
+
// La API diretta espone molti modelli, inclusi modelli in deprecazione. La UI
|
|
122
|
+
// mantiene la shortlist TOP curata, ma solo se ancora realmente disponibile.
|
|
123
|
+
const models = OLLAMA_CLOUD_MODELS.filter((name) => available.has(name));
|
|
124
|
+
if (!models.length) throw new Error('nessun modello cloud');
|
|
125
|
+
ollamaCache = { at: now, models };
|
|
126
|
+
return [...models];
|
|
127
|
+
} catch (_) {
|
|
128
|
+
ollamaCache = { at: now, models: [...OLLAMA_CLOUD_MODELS] };
|
|
129
|
+
return [...OLLAMA_CLOUD_MODELS];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function describeManaged(spec, cfg = {}) {
|
|
134
|
+
const normalized = normalizeManagedSpec(spec);
|
|
135
|
+
if (!normalized) return { configured: false, reason: 'profilo managed non valido' };
|
|
136
|
+
const home = cfg.home || require('node:os').homedir();
|
|
137
|
+
const profile = byPair(normalized.client, normalized.provider);
|
|
138
|
+
const binary = findBinary(normalized.client, home);
|
|
139
|
+
const secretFile = secretsPath(cfg, home);
|
|
140
|
+
const secrets = profile.auth === 'login' || profile.auth === 'none' ? {} : parseEnvFile(secretFile);
|
|
141
|
+
const authConfigured = profile.auth === 'login' || profile.auth === 'none' || !!secrets[profile.auth];
|
|
142
|
+
const configured = !!binary && authConfigured;
|
|
143
|
+
return {
|
|
144
|
+
client: profile.client, provider: profile.provider, model: normalized.model,
|
|
145
|
+
endpoint: profile.endpoint, auth: profile.auth, authConfigured, configured,
|
|
146
|
+
models: [...(profile.models || [])], defaultModel: profile.model || '',
|
|
147
|
+
binary: binary || '',
|
|
148
|
+
reason: !binary ? `client ${profile.client} non trovato` : (!authConfigured ? `credenziale ${profile.auth} mancante` : 'ready'),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
153
|
+
const info = describeManaged(engine.managed, cfg);
|
|
154
|
+
if (!info.configured) return { ok: false, reason: info.reason, info };
|
|
155
|
+
const home = cfg.home || require('node:os').homedir();
|
|
156
|
+
const env = {};
|
|
157
|
+
const args = [];
|
|
158
|
+
const model = (cell && cell.model) || info.model;
|
|
159
|
+
if (info.client === 'claude') {
|
|
160
|
+
args.push('--dangerously-skip-permissions');
|
|
161
|
+
if (info.provider === 'native') {
|
|
162
|
+
if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
|
|
163
|
+
} else if (info.provider === 'ollama-cloud') {
|
|
164
|
+
const secrets = parseEnvFile(secretsPath(cfg, home));
|
|
165
|
+
Object.assign(env, {
|
|
166
|
+
ANTHROPIC_BASE_URL: 'https://ollama.com', ANTHROPIC_AUTH_TOKEN: secrets.OLLAMA_API_KEY,
|
|
167
|
+
ANTHROPIC_API_KEY: '',
|
|
168
|
+
ANTHROPIC_MODEL: model, ANTHROPIC_SMALL_FAST_MODEL: model, API_TIMEOUT_MS: '3000000',
|
|
169
|
+
CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(OLLAMA_CONTEXT[model] || 200000),
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
const profile = byPair(info.client, info.provider);
|
|
173
|
+
const secrets = parseEnvFile(secretsPath(cfg, home));
|
|
174
|
+
Object.assign(env, {
|
|
175
|
+
ANTHROPIC_BASE_URL: profile.endpoint, ANTHROPIC_AUTH_TOKEN: secrets[profile.auth],
|
|
176
|
+
ANTHROPIC_MODEL: model, ANTHROPIC_SMALL_FAST_MODEL: model,
|
|
177
|
+
API_TIMEOUT_MS: '3000000', CLAUDE_CODE_AUTO_COMPACT_WINDOW: '1000000',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (model) args.push('--model', model);
|
|
181
|
+
} else {
|
|
182
|
+
args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
183
|
+
if (info.provider === 'ollama-cloud') {
|
|
184
|
+
const secrets = parseEnvFile(secretsPath(cfg, home));
|
|
185
|
+
env.OPENAI_API_KEY = secrets.OLLAMA_API_KEY;
|
|
186
|
+
if (model) args.push('-m', model);
|
|
187
|
+
const localCatalog = path.join(home, '.codex', 'ollama_cloud_model_catalog.json');
|
|
188
|
+
args.push(
|
|
189
|
+
'-c', 'model_provider="ollama_cloud"',
|
|
190
|
+
'-c', 'model_providers.ollama_cloud.name="Ollama Cloud"',
|
|
191
|
+
'-c', 'model_providers.ollama_cloud.base_url="https://ollama.com/v1"',
|
|
192
|
+
'-c', 'model_providers.ollama_cloud.wire_api="responses"',
|
|
193
|
+
'-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000',
|
|
194
|
+
'-c', `model_context_window=${OLLAMA_CONTEXT[model] || 200000}`,
|
|
195
|
+
);
|
|
196
|
+
// Codex carica il catalogo solo all'avvio. Il file e' locale e opzionale:
|
|
197
|
+
// sui device che non lo hanno resta valido il fallback context-window sopra.
|
|
198
|
+
if (fs.existsSync(localCatalog)) args.push('-c', `model_catalog_json="${localCatalog}"`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Entrambi i client accettano il bootstrap come argomento posizionale. Questo
|
|
202
|
+
// evita di digitare nella TUI prima che sia pronta; argv e' diretto, mai shell.
|
|
203
|
+
if (cell && cell.prompt) args.push(cell.prompt);
|
|
204
|
+
return { ok: true, info, engine: { ...engine, command: info.binary, args, env, promptMode: 'managed-argv' } };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, normalizeManagedSpec, defaultDefinitions, describeManaged,
|
|
209
|
+
discoverOllamaModels,
|
|
210
|
+
resolveManagedEngine, parseEnvFile, findBinary,
|
|
211
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// B4.2 — Provider selection. Sceglie UNA volta, a startup, quale fleet governa il
|
|
3
|
+
// runtime (design §4b/§9b/§9g). Ritorna { mode, reason, fleet }.
|
|
4
|
+
//
|
|
5
|
+
// mode ∈ 'external' | 'builtin' | 'disabled'
|
|
6
|
+
//
|
|
7
|
+
// Regole (design §9g):
|
|
8
|
+
// - forced (cfg.fleetProvider | NEXUSCREW_FLEET_PROVIDER): onorato e FAIL-CLOSED
|
|
9
|
+
// se indisponibile. NIENTE auto-fallback silenzioso al built-in.
|
|
10
|
+
// - auto: 'external' vince solo se fidato (binTrusted, riusato da index.js) E
|
|
11
|
+
// risponde al contratto (createFleet → available). Altrimenti 'builtin' se
|
|
12
|
+
// abilitato e fleet.json valido. Altrimenti 'disabled'.
|
|
13
|
+
//
|
|
14
|
+
// Il drift a runtime NON si risolve qui: e' una scelta one-shot. Se l'external
|
|
15
|
+
// diventa invalido DOPO lo startup, lo status risultante sara' degraded/unavailable
|
|
16
|
+
// (mai fall-through silenzioso al built-in) — gestito dal layer di status.
|
|
17
|
+
const { createFleet, binTrusted } = require('./index.js');
|
|
18
|
+
const { createBuiltinFleet } = require('./builtin.js');
|
|
19
|
+
|
|
20
|
+
const DISABLED_FLEET = Object.freeze({
|
|
21
|
+
available: false, provider: 'disabled', isCellSession: () => false, capabilities: () => [],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function failClosed(mode) {
|
|
25
|
+
return {
|
|
26
|
+
mode: 'disabled',
|
|
27
|
+
reason: `fail-closed: provider forzato "${mode}" non disponibile (nessun auto-fallback, §9g)`,
|
|
28
|
+
fleet: DISABLED_FLEET,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Costruisce (una volta) i candidati e ne valuta la disponibilita'.
|
|
33
|
+
async function selectProvider(cfg = {}) {
|
|
34
|
+
if (cfg.fleetEnabled === false) {
|
|
35
|
+
return { mode: 'disabled', reason: 'fleet disabilitato (fleetEnabled=false)', fleet: DISABLED_FLEET };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const forced = (cfg.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER || '').toLowerCase() || null;
|
|
39
|
+
|
|
40
|
+
// External: fidato (regular file, exec, no symlink, no world-writable) E contratto ok.
|
|
41
|
+
let extFleet = null;
|
|
42
|
+
let extReason = null;
|
|
43
|
+
if (cfg.fleetBin) {
|
|
44
|
+
if (binTrusted(cfg.fleetBin)) {
|
|
45
|
+
const f = await createFleet(cfg); // fa status --json al boot
|
|
46
|
+
if (f.available) { extFleet = f; extReason = 'fidato + risponde al contratto'; }
|
|
47
|
+
else extReason = 'binario fidato ma non risponde al contratto (status invalido)';
|
|
48
|
+
} else {
|
|
49
|
+
extReason = 'binario non fidato (symlink/world-writable/non exec)';
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
extReason = 'nessun fleetBin esterno configurato';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Builtin: abilitato + fleet.json valido.
|
|
56
|
+
let biFleet = null;
|
|
57
|
+
let biReason = null;
|
|
58
|
+
if (cfg.builtinEnabled !== false) {
|
|
59
|
+
const f = await createBuiltinFleet({ ...cfg, fleetProviderReason: 'fleet.json definitions' });
|
|
60
|
+
if (f.available) { biFleet = f; biReason = 'fleet.json valido'; }
|
|
61
|
+
else biReason = 'fleet.json mancante o invalido (fail-closed)';
|
|
62
|
+
} else {
|
|
63
|
+
biReason = 'builtin disabilitato (builtinEnabled=false)';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Mode FORZATO: fail-closed se il richiesto non e' disponibile ---
|
|
67
|
+
if (forced === 'external') {
|
|
68
|
+
return extFleet
|
|
69
|
+
? { mode: 'external', reason: `forced external (${extReason})`, fleet: extFleet }
|
|
70
|
+
: failClosed('external');
|
|
71
|
+
}
|
|
72
|
+
if (forced === 'builtin') {
|
|
73
|
+
return biFleet
|
|
74
|
+
? { mode: 'builtin', reason: `forced builtin (${biReason})`, fleet: biFleet }
|
|
75
|
+
: failClosed('builtin');
|
|
76
|
+
}
|
|
77
|
+
if (forced === 'disabled') {
|
|
78
|
+
return { mode: 'disabled', reason: 'forced disabled', fleet: DISABLED_FLEET };
|
|
79
|
+
}
|
|
80
|
+
if (forced) {
|
|
81
|
+
return failClosed(forced); // valore forzato non riconosciuto
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- AUTO: external fidato+contratto vince, poi builtin, poi disabled ---
|
|
85
|
+
if (extFleet) {
|
|
86
|
+
return { mode: 'external', reason: `auto: external ${extReason}`, fleet: extFleet };
|
|
87
|
+
}
|
|
88
|
+
if (biFleet) {
|
|
89
|
+
return {
|
|
90
|
+
mode: 'builtin',
|
|
91
|
+
reason: `auto: external scartato (${extReason}) → builtin (${biReason})`,
|
|
92
|
+
fleet: biFleet,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
mode: 'disabled',
|
|
97
|
+
reason: `auto: nessun provider disponibile — external: ${extReason}; builtin: ${biReason}`,
|
|
98
|
+
fleet: DISABLED_FLEET,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { selectProvider, DISABLED_FLEET };
|
package/lib/fleet/routes.js
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const express = require('express');
|
|
3
3
|
|
|
4
|
+
// Capability minime del contratto storico per i fleet external/legacy che NON
|
|
5
|
+
// dichiarano capabilities() (design §9c): status/up/down/engine/boot.
|
|
6
|
+
// Il built-in B4 aggiunge define/edit/remove/schema.
|
|
7
|
+
const DEFAULT_CAPS = ['status', 'up', 'down', 'engine', 'boot'];
|
|
8
|
+
|
|
9
|
+
function capList(fleet) {
|
|
10
|
+
return typeof fleet.capabilities === 'function' ? fleet.capabilities() : DEFAULT_CAPS;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Design §9c: una route per un metodo non supportato dal provider ritorna 501
|
|
14
|
+
// (mai 404/502 ambigui). Gli errori nativi del builtin (400/403/409) passano
|
|
15
|
+
// invece nel guard con il loro status.
|
|
16
|
+
function requireCap(fleet, cap) {
|
|
17
|
+
if (!capList(fleet).includes(cap)) {
|
|
18
|
+
const e = new Error('not supported by this fleet provider');
|
|
19
|
+
e.status = 501;
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
4
24
|
// Router /api/fleet — fleetP è una Promise<Fleet> (createServer non diventa
|
|
5
25
|
// async): ogni handler attende la resolve; unavailable → 404 sui comandi.
|
|
6
|
-
function fleetRoutes(fleetP) {
|
|
26
|
+
function fleetRoutes(fleetP, cfg = {}) {
|
|
7
27
|
const r = express.Router();
|
|
8
28
|
r.use(express.json({ limit: '4kb' }));
|
|
9
29
|
|
|
10
|
-
const
|
|
30
|
+
const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
31
|
+
|
|
32
|
+
const guard = (fn, opts = {}) => async (req, res) => {
|
|
11
33
|
try {
|
|
34
|
+
// READONLY e' un gate di prodotto, non solo del provider built-in: deve
|
|
35
|
+
// bloccare anche il fleet esterno legacy prima che tocchi systemd/tmux reali.
|
|
36
|
+
if (opts.mutate && readonly()) return res.status(403).json({ error: 'READONLY: mutazione fleet bloccata' });
|
|
12
37
|
const fleet = await fleetP;
|
|
13
38
|
if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
|
|
14
39
|
res.json(await fn(fleet, req.body || {}));
|
|
@@ -17,17 +42,62 @@ function fleetRoutes(fleetP) {
|
|
|
17
42
|
}
|
|
18
43
|
};
|
|
19
44
|
|
|
45
|
+
// /status espone anche `provider` e `capabilities` (design §9b/§9c), oltre ai
|
|
46
|
+
// campi storici (available/cells/engines). Provider senza capabilities()
|
|
47
|
+
// (external legacy) → DEFAULT_CAPS; external senza campo provider → 'external'.
|
|
20
48
|
r.get('/status', async (_req, res) => {
|
|
21
49
|
try {
|
|
22
50
|
const fleet = await fleetP;
|
|
23
|
-
if (!fleet.available)
|
|
24
|
-
|
|
51
|
+
if (!fleet.available) {
|
|
52
|
+
return res.json({
|
|
53
|
+
available: false,
|
|
54
|
+
provider: fleet.provider || 'disabled',
|
|
55
|
+
bootOwner: 'none', // §9b: provider non disponibile -> nessun boot owner
|
|
56
|
+
capabilities: capList(fleet),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const st = await fleet.status();
|
|
60
|
+
const provider = st.provider || fleet.provider || 'external';
|
|
61
|
+
// bootOwner (§9b): preferisce quello dichiarato dal provider (st.bootOwner);
|
|
62
|
+
// altrimenti deriva dal mode: builtin -> 'builtin', disabled -> 'none',
|
|
63
|
+
// external -> 'external'. Contratto valori: 'builtin' | 'external' | 'none'.
|
|
64
|
+
const bootOwner = st.bootOwner || (provider === 'builtin' ? 'builtin' : provider === 'disabled' ? 'none' : 'external');
|
|
65
|
+
res.json({
|
|
66
|
+
...st,
|
|
67
|
+
provider,
|
|
68
|
+
bootOwner,
|
|
69
|
+
capabilities: capList(fleet),
|
|
70
|
+
});
|
|
25
71
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
26
72
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
73
|
+
|
|
74
|
+
// /up: il built-in (capability 'edit') è definitions-driven — se il body porta
|
|
75
|
+
// un engine lo persiste sulla cella (f.engine) PRIMA di up, perché l'engine
|
|
76
|
+
// dichiarato vince sull'override runtime (ignored dal builtin.up).
|
|
77
|
+
// External legacy: passthrough up(cell,{engine,boot}) invariato.
|
|
78
|
+
r.post('/up', guard((f, b) => {
|
|
79
|
+
const cell = String(b.cell || '');
|
|
80
|
+
if (b.engine && capList(f).includes('edit')) {
|
|
81
|
+
return f.engine(cell, String(b.engine), { model: typeof b.model === 'string' ? b.model : '' }).then(() => f.up(cell));
|
|
82
|
+
}
|
|
83
|
+
return f.up(cell, { engine: b.engine, boot: !!b.boot });
|
|
84
|
+
}, { mutate: true }));
|
|
85
|
+
r.post('/down', guard((f, b) => f.down(String(b.cell || ''), { boot: !!b.boot }), { mutate: true }));
|
|
86
|
+
r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true }));
|
|
87
|
+
r.post('/engine', guard((f, b) => f.engine(String(b.cell || ''), String(b.engine || ''), { model: typeof b.model === 'string' ? b.model : '' }), { mutate: true }));
|
|
88
|
+
r.post('/boot', guard((f, b) => f.boot(String(b.cell || ''), b.enabled === true), { mutate: true }));
|
|
89
|
+
|
|
90
|
+
// --- Estensione B4.2: schema + define/edit/remove (engine e cell) ---
|
|
91
|
+
// Ogni route negozia la capability del provider: mancante → 501 (§9c).
|
|
92
|
+
r.get('/schema', guard((f) => { requireCap(f, 'schema'); return f.schema(); }));
|
|
93
|
+
r.get('/definitions', guard((f) => { requireCap(f, 'definitions'); return f.definitions(); }));
|
|
94
|
+
r.post('/define-engine', guard((f, b) => { requireCap(f, 'define'); return f.defineEngine(b.def); }, { mutate: true }));
|
|
95
|
+
r.post('/edit-engine', guard((f, b) => { requireCap(f, 'edit'); return f.editEngine(b.id, b.patch, b.envChanges); }, { mutate: true }));
|
|
96
|
+
r.post('/remove-engine', guard((f, b) => { requireCap(f, 'remove'); return f.removeEngine(b.id); }, { mutate: true }));
|
|
97
|
+
r.post('/define-cell', guard((f, b) => { requireCap(f, 'define'); return f.defineCell(b.def); }, { mutate: true }));
|
|
98
|
+
r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
|
|
99
|
+
r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
|
|
100
|
+
|
|
31
101
|
return r;
|
|
32
102
|
}
|
|
33
103
|
|
package/lib/fs/routes.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fsp = require('node:fs/promises');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const express = require('express');
|
|
5
|
+
|
|
6
|
+
const MAX_ENTRIES = 500;
|
|
7
|
+
|
|
8
|
+
// Router /api/fs — sfoglia SOLO directory, confinato alla home (o root passata).
|
|
9
|
+
// Serve al folder-picker del dialog "new session": il browser non può sfogliare
|
|
10
|
+
// il filesystem del server, quindi lo fa il server, dietro lo stesso Bearer di
|
|
11
|
+
// tutte le /api. Policy symlink: realpath; se la risoluzione esce dalla root
|
|
12
|
+
// (symlink verso fuori) → 403. Niente file, niente contenuti: solo nomi di dir.
|
|
13
|
+
function fsRoutes({ home }) {
|
|
14
|
+
const r = express.Router();
|
|
15
|
+
|
|
16
|
+
r.get('/dirs', async (req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
const root = await fsp.realpath(home);
|
|
19
|
+
const q = typeof req.query.path === 'string' && req.query.path ? req.query.path : root;
|
|
20
|
+
if (q.includes('\0')) return res.status(400).json({ error: 'path non valido' });
|
|
21
|
+
let real;
|
|
22
|
+
try { real = await fsp.realpath(path.resolve(root, q)); } catch (_) {
|
|
23
|
+
return res.status(404).json({ error: 'directory inesistente' });
|
|
24
|
+
}
|
|
25
|
+
if (real !== root && !real.startsWith(root + path.sep)) {
|
|
26
|
+
return res.status(403).json({ error: 'fuori dalla home' });
|
|
27
|
+
}
|
|
28
|
+
let entries;
|
|
29
|
+
try { entries = await fsp.readdir(real, { withFileTypes: true }); } catch (e) {
|
|
30
|
+
if (e.code === 'ENOTDIR') return res.status(404).json({ error: 'non è una directory' });
|
|
31
|
+
if (e.code === 'EACCES') return res.status(403).json({ error: 'permesso negato' });
|
|
32
|
+
throw e;
|
|
33
|
+
}
|
|
34
|
+
const showHidden = req.query.hidden === '1';
|
|
35
|
+
const names = [];
|
|
36
|
+
for (const d of entries) {
|
|
37
|
+
if (!showHidden && d.name.startsWith('.')) continue;
|
|
38
|
+
if (d.isDirectory()) { names.push(d.name); continue; }
|
|
39
|
+
if (d.isSymbolicLink()) { // symlink: solo se risolve a una dir
|
|
40
|
+
try { if ((await fsp.stat(path.join(real, d.name))).isDirectory()) names.push(d.name); } catch (_) { /* rotto: skip */ }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const dirs = names.sort((a, b) => a.localeCompare(b)).slice(0, MAX_ENTRIES);
|
|
44
|
+
res.json({
|
|
45
|
+
path: real,
|
|
46
|
+
parent: real === root ? null : path.dirname(real),
|
|
47
|
+
home: root,
|
|
48
|
+
dirs,
|
|
49
|
+
});
|
|
50
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return r;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { fsRoutes };
|