@mmmbuto/nexuscrew 0.8.39 → 0.8.40
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/CHANGELOG.md +33 -1
- package/README.md +11 -1
- package/frontend/dist/assets/index-CwZySkm2.js +93 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/audio/acl.js +65 -0
- package/lib/audio/adapters.js +233 -0
- package/lib/audio/bridge-auth.js +176 -0
- package/lib/audio/capability.js +40 -0
- package/lib/audio/consent.js +64 -0
- package/lib/audio/dispatch.js +154 -0
- package/lib/audio/group-receipt.js +119 -0
- package/lib/audio/group-speak.js +146 -0
- package/lib/audio/groups.js +121 -0
- package/lib/audio/origin.js +128 -0
- package/lib/audio/queue.js +191 -0
- package/lib/audio/rate-limit.js +71 -0
- package/lib/audio/receipt.js +146 -0
- package/lib/audio/routes.js +374 -0
- package/lib/audio/speak.js +125 -0
- package/lib/fleet/managed.js +53 -2
- package/lib/fleet/provider.js +6 -0
- package/lib/mcp/server.js +27 -3
- package/lib/mcp/tools.js +142 -0
- package/lib/nodes/tunnel-supervisor.js +33 -7
- package/lib/nodes/tunnel.js +21 -0
- package/lib/proxy/federation.js +41 -11
- package/lib/proxy/hop-proof.js +50 -0
- package/lib/server.js +93 -2
- package/lib/settings/routes.js +131 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-D0MBXlAl.js +0 -93
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/routes.js — route audio locali: /speak, /stop, /capability, /status.
|
|
3
|
+
//
|
|
4
|
+
// Due invarianti reggono tutto il resto.
|
|
5
|
+
//
|
|
6
|
+
// 1) L'origine e' verificata, non dichiarata. `originResolver` e' obbligatorio e
|
|
7
|
+
// autoritativo: nessun header, campo del body o parametro di query concorre a
|
|
8
|
+
// stabilire chi sta parlando. Senza un'origine verificabile la richiesta
|
|
9
|
+
// muore con 401, non con un default permissivo.
|
|
10
|
+
//
|
|
11
|
+
// 2) Ogni decisione di suonare la prende il nodo che possiede l'altoparlante. Se
|
|
12
|
+
// il target e' remoto questo server non decide per lui: instrada e riporta
|
|
13
|
+
// l'esito per endpoint cosi' com'e', incluso `unknown`.
|
|
14
|
+
//
|
|
15
|
+
// Il corpo grezzo viene conservato (`req.rawBody`) perche' la firma del bridge
|
|
16
|
+
// copre i BYTE trasmessi: ri-serializzare il JSON per verificarlo produrrebbe
|
|
17
|
+
// una stringa diversa a parita' di significato.
|
|
18
|
+
const express = require('express');
|
|
19
|
+
const { handleSpeak, handleStop, isValidInstance } = require('./speak.js');
|
|
20
|
+
const { describeCapability } = require('./capability.js');
|
|
21
|
+
const { createReceiptStore } = require('./receipt.js');
|
|
22
|
+
const { createSpeakRateLimiter } = require('./rate-limit.js');
|
|
23
|
+
const { createGroupReceiptStore } = require('./group-receipt.js');
|
|
24
|
+
const { createGroupSpeaker } = require('./group-speak.js');
|
|
25
|
+
|
|
26
|
+
const UTTERANCE_ID_RE = /^[A-Za-z0-9._:-]{8,128}$/;
|
|
27
|
+
const LANG_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,34})$/;
|
|
28
|
+
const VOICE_RE = /^[^\u0000-\u001f\u007f]{1,64}$/;
|
|
29
|
+
const URGENCIES = new Set(['normal', 'high']);
|
|
30
|
+
|
|
31
|
+
function plainObject(value) {
|
|
32
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function onlyKeys(value, allowed) {
|
|
36
|
+
return plainObject(value) && Object.keys(value).every((key) => allowed.has(key));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function optionalBounded(value, re) {
|
|
40
|
+
return value === undefined || (typeof value === 'string' && re.test(value));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Il server non deve diventare tollerante verso campi futuri/non documentati:
|
|
44
|
+
// testo, route o identita' inattesi non vengono mai ignorati in silenzio.
|
|
45
|
+
// `originCell`/`originNode` sono gli unici campi aggiunti dal dispatcher sul
|
|
46
|
+
// percorso federato; il target li ricontrolla nel resolver, non li usa mai
|
|
47
|
+
// come fonte primaria dell'identita'.
|
|
48
|
+
function parseSpeakBody(body, { federated = false } = {}) {
|
|
49
|
+
const allowed = new Set(['target', 'text', 'lang', 'voice', 'urgency', 'utteranceId']);
|
|
50
|
+
if (federated) { allowed.add('originCell'); allowed.add('originNode'); }
|
|
51
|
+
if (!onlyKeys(body, allowed)) return { error: 'invalid-request' };
|
|
52
|
+
const target = typeof body.target === 'string' ? body.target : '';
|
|
53
|
+
if (!isValidInstance(target)) return { error: 'invalid-target' };
|
|
54
|
+
if (typeof body.text !== 'string' || body.text.length < 1 || body.text.length > 320) return { error: 'invalid-text' };
|
|
55
|
+
if (!optionalBounded(body.lang, LANG_RE) || !optionalBounded(body.voice, VOICE_RE)) return { error: 'invalid-request' };
|
|
56
|
+
if (body.urgency !== undefined && !URGENCIES.has(body.urgency)) return { error: 'invalid-request' };
|
|
57
|
+
if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
|
|
58
|
+
return { error: 'invalid-utterance' };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
target, text: body.text,
|
|
62
|
+
...(body.lang !== undefined ? { lang: body.lang } : {}),
|
|
63
|
+
...(body.voice !== undefined ? { voice: body.voice } : {}),
|
|
64
|
+
urgency: body.urgency || 'normal',
|
|
65
|
+
...(body.utteranceId ? { utteranceId: body.utteranceId } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseStopBody(body, { federated = false } = {}) {
|
|
70
|
+
const allowed = new Set(['target', 'utteranceId']);
|
|
71
|
+
if (federated) { allowed.add('originCell'); allowed.add('originNode'); }
|
|
72
|
+
if (!onlyKeys(body, allowed)) return { error: 'invalid-request' };
|
|
73
|
+
const target = typeof body.target === 'string' ? body.target : '';
|
|
74
|
+
if (!isValidInstance(target)) return { error: 'invalid-target' };
|
|
75
|
+
if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
|
|
76
|
+
return { error: 'invalid-utterance' };
|
|
77
|
+
}
|
|
78
|
+
return { target, ...(body.utteranceId ? { utteranceId: body.utteranceId } : {}) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseRemoteStatusBody(body) {
|
|
82
|
+
if (!onlyKeys(body, new Set(['target', 'utteranceId', 'originCell', 'originNode']))) return { error: 'invalid-request' };
|
|
83
|
+
if (!isValidInstance(body.target)) return { error: 'invalid-target' };
|
|
84
|
+
if (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId)) return { error: 'invalid-utterance' };
|
|
85
|
+
return { target: body.target, utteranceId: body.utteranceId };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseGroupSpeakBody(body) {
|
|
89
|
+
if (!onlyKeys(body, new Set(['group', 'text', 'lang', 'voice', 'urgency', 'utteranceId']))) return { error: 'invalid-request' };
|
|
90
|
+
if (typeof body.group !== 'string') return { error: 'invalid-group' };
|
|
91
|
+
if (typeof body.text !== 'string' || body.text.length < 1 || body.text.length > 320) return { error: 'invalid-text' };
|
|
92
|
+
if (!optionalBounded(body.lang, LANG_RE) || !optionalBounded(body.voice, VOICE_RE)) return { error: 'invalid-request' };
|
|
93
|
+
if (body.urgency !== undefined && !URGENCIES.has(body.urgency)) return { error: 'invalid-request' };
|
|
94
|
+
if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
|
|
95
|
+
return { error: 'invalid-utterance' };
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
group: body.group, text: body.text, urgency: body.urgency || 'normal',
|
|
99
|
+
...(body.lang !== undefined ? { lang: body.lang } : {}),
|
|
100
|
+
...(body.voice !== undefined ? { voice: body.voice } : {}),
|
|
101
|
+
...(body.utteranceId ? { utteranceId: body.utteranceId } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function audioRoutes(deps = {}) {
|
|
106
|
+
if (!deps.originResolver || typeof deps.originResolver.resolve !== 'function') {
|
|
107
|
+
throw new Error('audioRoutes: originResolver e obbligatorio (origine verificata)');
|
|
108
|
+
}
|
|
109
|
+
const r = express.Router();
|
|
110
|
+
r.use(express.json({
|
|
111
|
+
limit: '16kb',
|
|
112
|
+
verify: (req, _res, buf) => { req.rawBody = Buffer.from(buf); },
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
const originResolver = deps.originResolver;
|
|
116
|
+
const receiptStore = deps.receiptStore || createReceiptStore();
|
|
117
|
+
const rateLimiter = deps.rateLimiter || createSpeakRateLimiter();
|
|
118
|
+
const readonly = deps.readonly || (() => false);
|
|
119
|
+
const localNodeId = deps.localNodeId || (() => null);
|
|
120
|
+
const consent = deps.consent || (() => false);
|
|
121
|
+
const adapter = deps.adapter || null;
|
|
122
|
+
const queue = deps.queue || null;
|
|
123
|
+
const acl = deps.acl || null;
|
|
124
|
+
const dispatcher = deps.dispatcher || null;
|
|
125
|
+
const getGroup = deps.getGroup || (() => null);
|
|
126
|
+
const groupReceipts = deps.groupReceipts || createGroupReceiptStore();
|
|
127
|
+
const now = deps.now || Date.now;
|
|
128
|
+
|
|
129
|
+
const send = (res, code, body) => res.status(code).json(body);
|
|
130
|
+
|
|
131
|
+
// Un solo messaggio per ogni fallimento di origine. Distinguere "firma
|
|
132
|
+
// scaduta" da "nonce riusato" da "cella non attiva" darebbe a chi prova a
|
|
133
|
+
// forgiare un oracolo gratuito su cosa manca per riuscire.
|
|
134
|
+
async function originOr401(req, res, opts = {}) {
|
|
135
|
+
const resolved = await originResolver.resolve(req, opts);
|
|
136
|
+
if (!resolved || resolved.ok !== true) {
|
|
137
|
+
send(res, 401, { error: 'origine non verificabile' });
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return resolved;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function gateDeps(origin, attested) {
|
|
144
|
+
return {
|
|
145
|
+
readonly, rateLimiter, queue, localNodeId, consent, now,
|
|
146
|
+
acl: acl ? acl.allows : null,
|
|
147
|
+
receipt: (status, o) => receiptStore.record({
|
|
148
|
+
origin: o.origin, target: o.target, status, reason: o.reason,
|
|
149
|
+
utteranceId: o.utteranceId, attested,
|
|
150
|
+
}),
|
|
151
|
+
updateReceipt: (id, status, reason) => receiptStore.update(id, status, reason),
|
|
152
|
+
readReceipt: (id, o) => receiptStore.get(o, id),
|
|
153
|
+
findReceipt: (id, o, target) => receiptStore.find(id, o, target),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function localCapability(target) {
|
|
158
|
+
const self = localNodeId();
|
|
159
|
+
if (!self || target !== self) return { status: 'refused', reason: 'invalid-target' };
|
|
160
|
+
const described = describeCapability({ adapter, consent: consent() === true, nodeId: self });
|
|
161
|
+
if (described.consent !== true) return { status: 'refused', reason: 'consent' };
|
|
162
|
+
if (described.installed !== true) return { status: 'refused', reason: 'no-adapter' };
|
|
163
|
+
if (described.liveness !== 'ready') return { status: 'unknown', reason: 'liveness-unknown' };
|
|
164
|
+
return { status: 'ready', capability: described };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function capabilityForGroup({ target }) {
|
|
168
|
+
if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
|
|
169
|
+
if (target === localNodeId()) return localCapability(target);
|
|
170
|
+
if (!dispatcher || typeof dispatcher.probeCapability !== 'function') return { status: 'unreachable', reason: 'dispatch-unavailable' };
|
|
171
|
+
return dispatcher.probeCapability(target);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function speakForGroup({ target, origin, utteranceId, text, lang, voice, urgency }) {
|
|
175
|
+
if (target === localNodeId()) {
|
|
176
|
+
return handleSpeak({
|
|
177
|
+
target, text, lang, voice, urgency, utteranceId, origin, trust: 'local-bridge', visited: [],
|
|
178
|
+
}, gateDeps(origin, false));
|
|
179
|
+
}
|
|
180
|
+
if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unreachable', reason: 'dispatch-unavailable' };
|
|
181
|
+
// Un gruppo non e' una scorciatoia attorno al budget `nc_speak`: prima del
|
|
182
|
+
// dispatch remoto consuma gli stessi tre bucket sul nodo origine. Il target
|
|
183
|
+
// li applichera' di nuovo in modo indipendente, perche' resta l'autorita'
|
|
184
|
+
// finale sul proprio altoparlante.
|
|
185
|
+
const rl = rateLimiter.check({ origin, target, urgency });
|
|
186
|
+
if (!rl.allowed) return { status: 'refused', reason: 'rate-limit' };
|
|
187
|
+
return dispatcher.dispatch({
|
|
188
|
+
resource: '/audio/speak', target, origin,
|
|
189
|
+
payload: { text, ...(lang ? { lang } : {}), ...(voice ? { voice } : {}), urgency, utteranceId },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function statusForGroup({ target, origin, utteranceId }) {
|
|
194
|
+
if (target === localNodeId()) return receiptStore.get(origin, utteranceId) || { status: 'unknown', reason: 'not-found' };
|
|
195
|
+
if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unknown', reason: 'dispatch-unavailable' };
|
|
196
|
+
return dispatcher.dispatch({ resource: '/audio/speak/status', target, origin, payload: { utteranceId } });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function stopForGroup({ target, origin, utteranceId }) {
|
|
200
|
+
if (target === localNodeId()) return handleStop({ target, utteranceId, origin }, gateDeps(origin, false));
|
|
201
|
+
if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unknown', reason: 'dispatch-unavailable' };
|
|
202
|
+
return dispatcher.dispatch({ resource: '/audio/stop', target, origin, payload: { utteranceId } });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const groupSpeaker = deps.groupSpeaker || createGroupSpeaker({
|
|
206
|
+
getGroup, receipts: groupReceipts,
|
|
207
|
+
capability: capabilityForGroup, speak: speakForGroup,
|
|
208
|
+
status: statusForGroup, stop: stopForGroup,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
r.post('/speak', async (req, res) => {
|
|
212
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
213
|
+
const { origin, trust, visited } = resolved;
|
|
214
|
+
const parsed = parseSpeakBody(req.body, { federated: trust === 'federated' });
|
|
215
|
+
if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
|
|
216
|
+
const { target, utteranceId } = parsed;
|
|
217
|
+
// Gruppo e speak singolo condividono il namespace dell'utteranceId per
|
|
218
|
+
// origine. Senza questo gate un id riusato potrebbe far sembrare `spoken`
|
|
219
|
+
// un endpoint che aveva pronunciato una richiesta precedente, diversa.
|
|
220
|
+
if (utteranceId && groupReceipts.get(origin, utteranceId)) {
|
|
221
|
+
return send(res, 422, { status: 'refused', reason: 'utterance-collision' });
|
|
222
|
+
}
|
|
223
|
+
// Target remoto: questo nodo non decide e non parla. Instrada sulla
|
|
224
|
+
// topologia autorizzata e restituisce l'esito del target senza addolcirlo.
|
|
225
|
+
if (dispatcher && isValidInstance(target) && !dispatcher.isLocal(target)) {
|
|
226
|
+
if (readonly()) return send(res, 403, { status: 'refused', reason: 'readonly' });
|
|
227
|
+
const rl = rateLimiter.check({ origin, target, urgency: parsed.urgency });
|
|
228
|
+
if (!rl.allowed) return send(res, 429, { status: 'refused', reason: 'rate-limit' });
|
|
229
|
+
const local = receiptStore.record({
|
|
230
|
+
origin, target, status: 'accepted', utteranceId, attested: trust === 'federated',
|
|
231
|
+
});
|
|
232
|
+
const out = await dispatcher.dispatch({
|
|
233
|
+
resource: '/audio/speak', target, origin,
|
|
234
|
+
payload: {
|
|
235
|
+
text: parsed.text, lang: parsed.lang, voice: parsed.voice, urgency: parsed.urgency,
|
|
236
|
+
utteranceId: local.utteranceId,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
const merged = receiptStore.update(local.utteranceId, out.status, out.reason) || {
|
|
240
|
+
...local, status: out.status, ...(out.reason ? { reason: out.reason } : {}),
|
|
241
|
+
};
|
|
242
|
+
const code = out.status === 'spoken' || out.status === 'accepted' || out.status === 'unknown' ? 200
|
|
243
|
+
: out.status === 'unreachable' ? 503 : 422;
|
|
244
|
+
return send(res, code, merged);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const out = handleSpeak({
|
|
248
|
+
target, text: parsed.text, lang: parsed.lang, voice: parsed.voice, urgency: parsed.urgency,
|
|
249
|
+
utteranceId, origin, trust, visited,
|
|
250
|
+
}, gateDeps(origin, trust === 'federated'));
|
|
251
|
+
const code = out.status === 'spoken' || out.status === 'accepted' || out.status === 'unknown' ? 200
|
|
252
|
+
: (out.reason === 'invalid-text' || out.reason === 'invalid-target' || out.reason === 'not-local-target') ? 400
|
|
253
|
+
: out.status === 'unreachable' ? 503 : 422;
|
|
254
|
+
return send(res, code, out);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
r.post('/stop', async (req, res) => {
|
|
258
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
259
|
+
const { origin } = resolved;
|
|
260
|
+
const parsed = parseStopBody(req.body, { federated: resolved.trust === 'federated' });
|
|
261
|
+
if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
|
|
262
|
+
const { target } = parsed;
|
|
263
|
+
if (dispatcher && isValidInstance(target) && !dispatcher.isLocal(target)) {
|
|
264
|
+
const out = await dispatcher.dispatch({
|
|
265
|
+
resource: '/audio/stop', target, origin,
|
|
266
|
+
payload: { ...(parsed.utteranceId ? { utteranceId: parsed.utteranceId } : {}) },
|
|
267
|
+
});
|
|
268
|
+
return send(res, out.status === 'accepted' ? 200 : out.status === 'unreachable' ? 503 : 422, out);
|
|
269
|
+
}
|
|
270
|
+
const out = handleStop({ target, utteranceId: parsed.utteranceId, origin }, gateDeps(origin, false));
|
|
271
|
+
return send(res, out.status === 'accepted' || out.status === 'unknown' ? 200 : 422, out);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Capability del SOLO nodo locale. Non e' una directory: un chiamante non puo'
|
|
275
|
+
// usare questo endpoint per sondare la capability di un terzo nodo, che va
|
|
276
|
+
// interrogato sulla propria route federata con la credenziale giusta.
|
|
277
|
+
r.get('/capability', async (req, res) => {
|
|
278
|
+
const resolved = await originOr401(req, res, { requireCell: false }); if (!resolved) return undefined;
|
|
279
|
+
if (resolved.trust === 'federated' && acl && typeof acl.allows === 'function') {
|
|
280
|
+
const verdict = acl.allows({ trust: resolved.trust, origin: resolved.origin, visited: resolved.visited });
|
|
281
|
+
if (!verdict || verdict.allowed !== true) return send(res, 403, { error: 'capability non autorizzata' });
|
|
282
|
+
}
|
|
283
|
+
if (Object.keys(req.query || {}).some((key) => key !== 'target') || Array.isArray(req.query && req.query.target)) {
|
|
284
|
+
return send(res, 400, { error: 'query non valida' });
|
|
285
|
+
}
|
|
286
|
+
const self = localNodeId();
|
|
287
|
+
const target = String(req.query.target || self || '');
|
|
288
|
+
if (!isValidInstance(target)) return send(res, 400, { error: 'target non valido (instanceId 32 hex)' });
|
|
289
|
+
if (!self || target !== self) return send(res, 403, { error: 'capability descrive solo il nodo locale' });
|
|
290
|
+
return send(res, 200, describeCapability({ adapter, consent: consent() === true, nodeId: self }));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Status per utteranceId, leggibile SOLO dalla stessa origine (nodo+cella).
|
|
294
|
+
// Per chiunque altro l'enunciato non esiste: distinguere 404 da 403
|
|
295
|
+
// permetterebbe di enumerare gli id altrui.
|
|
296
|
+
r.get('/speak/status/:utteranceId', async (req, res) => {
|
|
297
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
298
|
+
const rc = receiptStore.get(resolved.origin, String(req.params.utteranceId || ''));
|
|
299
|
+
if (!rc) return send(res, 404, { status: 'unknown', reason: 'not-found' });
|
|
300
|
+
return send(res, 200, rc);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Status federato: POST e non GET perche' la cella attestata del chiamante
|
|
304
|
+
// viaggia nel body costruito dal dispatcher. Non e' una API per browser: la
|
|
305
|
+
// whitelist Hydra e la prova hop rendono il percorso raggiungibile solo da un
|
|
306
|
+
// origin node gia' autenticato. Il target riapplica l'ACL prima di rivelare
|
|
307
|
+
// anche il receipt redatto.
|
|
308
|
+
r.post('/speak/status', async (req, res) => {
|
|
309
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
310
|
+
if (resolved.trust !== 'federated') return send(res, 403, { status: 'refused', reason: 'federated-only' });
|
|
311
|
+
const parsed = parseRemoteStatusBody(req.body);
|
|
312
|
+
if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
|
|
313
|
+
if (parsed.target !== localNodeId()) return send(res, 403, { status: 'refused', reason: 'not-local-target' });
|
|
314
|
+
if (acl && typeof acl.allows === 'function') {
|
|
315
|
+
const verdict = acl.allows({ trust: resolved.trust, origin: resolved.origin, visited: resolved.visited });
|
|
316
|
+
if (!verdict || verdict.allowed !== true) return send(res, 422, { status: 'refused', reason: 'acl' });
|
|
317
|
+
}
|
|
318
|
+
const rc = receiptStore.get(resolved.origin, parsed.utteranceId);
|
|
319
|
+
if (!rc) return send(res, 404, { status: 'unknown', reason: 'not-found' });
|
|
320
|
+
return send(res, 200, rc);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// I gruppi sono locali all'origine e non fanno parte della whitelist
|
|
324
|
+
// federata: la loro espansione avviene qui, quindi ogni endpoint riceve un
|
|
325
|
+
// comando /audio/speak esatto e applica autonomamente consenso/ACL/READONLY.
|
|
326
|
+
r.post('/groups/speak', async (req, res) => {
|
|
327
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
328
|
+
if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
|
|
329
|
+
const parsed = parseGroupSpeakBody(req.body);
|
|
330
|
+
if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
|
|
331
|
+
if (readonly()) return send(res, 422, { status: 'refused', reason: 'readonly' });
|
|
332
|
+
if (parsed.utteranceId && receiptStore.get(resolved.origin, parsed.utteranceId)) {
|
|
333
|
+
return send(res, 422, { status: 'refused', reason: 'utterance-collision' });
|
|
334
|
+
}
|
|
335
|
+
const out = await groupSpeaker.speakGroup({ origin: resolved.origin, ...parsed });
|
|
336
|
+
const code = out.status === 'refused' ? 422 : 200;
|
|
337
|
+
return send(res, code, out);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
r.get('/groups/status/:utteranceId', async (req, res) => {
|
|
341
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
342
|
+
if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
|
|
343
|
+
const utteranceId = String(req.params.utteranceId || '');
|
|
344
|
+
if (!UTTERANCE_ID_RE.test(utteranceId)) return send(res, 400, { status: 'refused', reason: 'invalid-utterance' });
|
|
345
|
+
const receipt = groupSpeaker.getStatus({ origin: resolved.origin, utteranceId });
|
|
346
|
+
if (!receipt) return send(res, 404, { status: 'unknown', reason: 'not-found' });
|
|
347
|
+
return send(res, 200, receipt);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
r.post('/groups/stop', async (req, res) => {
|
|
351
|
+
const resolved = await originOr401(req, res); if (!resolved) return undefined;
|
|
352
|
+
if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
|
|
353
|
+
if (!onlyKeys(req.body, new Set(['utteranceId'])) || typeof req.body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(req.body.utteranceId)) {
|
|
354
|
+
return send(res, 400, { status: 'refused', reason: 'invalid-utterance' });
|
|
355
|
+
}
|
|
356
|
+
const out = await groupSpeaker.stopGroup({ origin: resolved.origin, utteranceId: req.body.utteranceId });
|
|
357
|
+
return send(res, out.status === 'refused' ? 422 : 200, out);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
r.use((err, _req, res, _next) => {
|
|
361
|
+
if (err && (err.type === 'entity.too.large' || err.status === 413)) {
|
|
362
|
+
return res.status(413).json({ error: 'body troppo grande' });
|
|
363
|
+
}
|
|
364
|
+
if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
|
|
365
|
+
return res.status(err.status || 400).json({ error: 'richiesta audio non valida' });
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
return r;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
module.exports = {
|
|
372
|
+
audioRoutes, UTTERANCE_ID_RE, LANG_RE, VOICE_RE,
|
|
373
|
+
parseSpeakBody, parseStopBody, parseGroupSpeakBody,
|
|
374
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/audio/speak.js — catena di gate LATO TARGET: la decisione di emettere
|
|
3
|
+
// suono la prende il nodo che possiede l'altoparlante, sempre, anche quando la
|
|
4
|
+
// richiesta e' gia' passata da una route federata autorizzata.
|
|
5
|
+
//
|
|
6
|
+
// Ordine dei gate, deliberato:
|
|
7
|
+
// 0. testo valido (1..320) — nessun effetto collaterale
|
|
8
|
+
// 1. target esatto e uguale a QUESTO nodo — un nodo parla solo per se'
|
|
9
|
+
// 2. READONLY — `speak` e' una mutazione: ha
|
|
10
|
+
// un effetto fisico osservabile, quindi in sola lettura si rifiuta
|
|
11
|
+
// 3. ACL sull'origine
|
|
12
|
+
// 4. consenso audio locale (default OFF)
|
|
13
|
+
// 5. idempotenza per utteranceId — PRIMA del rate limit, cosi' un
|
|
14
|
+
// retry identico non consuma budget e non produce una seconda voce
|
|
15
|
+
// 6. rate limit dedicato
|
|
16
|
+
// 7. adapter/coda presenti — senza adapter si dice no, non
|
|
17
|
+
// si finge un `accepted`
|
|
18
|
+
// 8. accodamento
|
|
19
|
+
//
|
|
20
|
+
// I gate 3 e 4 stanno prima dell'idempotenza di proposito: un rifiuto per ACL o
|
|
21
|
+
// consenso deve restare un rifiuto anche al secondo tentativo, non trasformarsi
|
|
22
|
+
// in un receipt riusato.
|
|
23
|
+
const INSTANCE_ID_RE = /^[a-f0-9]{32}$/i;
|
|
24
|
+
const TEXT_MAX = 320;
|
|
25
|
+
|
|
26
|
+
function isValidInstance(v) {
|
|
27
|
+
return typeof v === 'string' && INSTANCE_ID_RE.test(v);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// handleSpeak(): ritorna SEMPRE uno stato per endpoint, mai un booleano.
|
|
31
|
+
// `deps.receipt(status, opts)` registra e ritorna il receipt redatto.
|
|
32
|
+
function handleSpeak(input, deps) {
|
|
33
|
+
const opts = input || {};
|
|
34
|
+
const d = deps || {};
|
|
35
|
+
const { target, origin } = opts;
|
|
36
|
+
|
|
37
|
+
if (typeof opts.text !== 'string' || opts.text.length < 1 || opts.text.length > TEXT_MAX) {
|
|
38
|
+
return { status: 'refused', reason: 'invalid-text' };
|
|
39
|
+
}
|
|
40
|
+
if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
|
|
41
|
+
// Un target diverso da questo nodo non e' affar suo: il nodo non ritrasmette
|
|
42
|
+
// e non fa da relay applicativo per la voce di un altro.
|
|
43
|
+
if (typeof d.localNodeId === 'function' && target !== d.localNodeId()) {
|
|
44
|
+
return { status: 'refused', reason: 'not-local-target' };
|
|
45
|
+
}
|
|
46
|
+
if (typeof d.readonly === 'function' && d.readonly()) {
|
|
47
|
+
return d.receipt('refused', { reason: 'readonly', target, origin, utteranceId: opts.utteranceId });
|
|
48
|
+
}
|
|
49
|
+
if (typeof d.acl === 'function') {
|
|
50
|
+
const verdict = d.acl({ trust: opts.trust, origin, visited: opts.visited });
|
|
51
|
+
if (!verdict || verdict.allowed !== true) {
|
|
52
|
+
// La reason resta generica verso l'esterno: la ragione precisa del rifiuto
|
|
53
|
+
// descriverebbe la topologia del nodo a chi non e' autorizzato a vederla.
|
|
54
|
+
return d.receipt('refused', { reason: 'acl', target, origin, utteranceId: opts.utteranceId });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (typeof d.consent === 'function' && d.consent() !== true) {
|
|
58
|
+
return d.receipt('refused', { reason: 'consent', target, origin, utteranceId: opts.utteranceId });
|
|
59
|
+
}
|
|
60
|
+
if (opts.utteranceId && typeof d.findReceipt === 'function') {
|
|
61
|
+
const found = d.findReceipt(opts.utteranceId, origin, target);
|
|
62
|
+
if (found === 'collision') {
|
|
63
|
+
const ts = typeof d.now === 'function' ? d.now() : Date.now();
|
|
64
|
+
return {
|
|
65
|
+
status: 'refused', reason: 'utterance-collision', utteranceId: opts.utteranceId,
|
|
66
|
+
origin: { node: origin && origin.node, cell: origin && origin.cell }, target, timestamp: ts,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (found) return found;
|
|
70
|
+
}
|
|
71
|
+
if (d.rateLimiter) {
|
|
72
|
+
const rl = d.rateLimiter.check({ origin, target, urgency: opts.urgency });
|
|
73
|
+
if (!rl.allowed) {
|
|
74
|
+
return d.receipt('refused', { reason: 'rate-limit', target, origin, utteranceId: opts.utteranceId });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!d.queue || typeof d.queue.enqueue !== 'function') {
|
|
78
|
+
return d.receipt('refused', { reason: 'no-adapter', target, origin, utteranceId: opts.utteranceId });
|
|
79
|
+
}
|
|
80
|
+
// Il receipt nasce PRIMA dell'accodamento: la coda aggiornera' questo stesso
|
|
81
|
+
// utteranceId quando l'adapter conferma l'avvio, fallisce o va in timeout.
|
|
82
|
+
const receipt = d.receipt('accepted', { target, origin, utteranceId: opts.utteranceId });
|
|
83
|
+
const admitted = d.queue.enqueue({
|
|
84
|
+
utteranceId: receipt.utteranceId,
|
|
85
|
+
text: opts.text, lang: opts.lang, voice: opts.voice, urgency: opts.urgency,
|
|
86
|
+
});
|
|
87
|
+
if (admitted.status !== 'accepted') {
|
|
88
|
+
return (d.updateReceipt && d.updateReceipt(receipt.utteranceId, admitted.status, admitted.reason))
|
|
89
|
+
|| { ...receipt, status: admitted.status, ...(admitted.reason ? { reason: admitted.reason } : {}) };
|
|
90
|
+
}
|
|
91
|
+
// La coda puo' aver gia' concluso in modo sincrono (adapter fake nei test,
|
|
92
|
+
// fallimento immediato di spawn): si rilegge il receipt invece di riportare
|
|
93
|
+
// uno stato ormai superato.
|
|
94
|
+
return (d.readReceipt && d.readReceipt(receipt.utteranceId, origin)) || receipt;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// handleStop(): comando di controllo, non sintesi. Lo stop LOCALE e' sovrano e
|
|
98
|
+
// non dipende dalla rete: un endpoint deve poter tacere anche offline. Uno stop
|
|
99
|
+
// remoto per utteranceId vale solo per gli enunciati del chiamante.
|
|
100
|
+
function handleStop(input, deps) {
|
|
101
|
+
const opts = input || {};
|
|
102
|
+
const d = deps || {};
|
|
103
|
+
const { target, origin } = opts;
|
|
104
|
+
if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
|
|
105
|
+
if (typeof d.localNodeId === 'function' && target !== d.localNodeId()) {
|
|
106
|
+
return { status: 'refused', reason: 'not-local-target' };
|
|
107
|
+
}
|
|
108
|
+
// READONLY non blocca lo stop: fermare una voce e' sempre permesso. Impedirlo
|
|
109
|
+
// significherebbe che un nodo in sola lettura non puo' zittirsi.
|
|
110
|
+
if (opts.utteranceId) {
|
|
111
|
+
if (typeof d.findReceipt === 'function') {
|
|
112
|
+
const found = d.findReceipt(opts.utteranceId, origin, target);
|
|
113
|
+
if (!found || found === 'collision') {
|
|
114
|
+
return { status: 'unknown', reason: 'utterance-not-found', utteranceId: opts.utteranceId };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const acted = d.queue && typeof d.queue.stop === 'function' ? d.queue.stop(opts.utteranceId) : false;
|
|
118
|
+
if (d.updateReceipt && acted) d.updateReceipt(opts.utteranceId, 'refused', 'stopped');
|
|
119
|
+
return { status: 'accepted', utteranceId: opts.utteranceId, stopped: acted === true };
|
|
120
|
+
}
|
|
121
|
+
const acted = d.queue && typeof d.queue.stopAll === 'function' ? d.queue.stopAll() : false;
|
|
122
|
+
return { status: 'accepted', stopped: acted === true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { handleSpeak, handleStop, isValidInstance, INSTANCE_ID_RE, TEXT_MAX };
|
package/lib/fleet/managed.js
CHANGED
|
@@ -60,7 +60,11 @@ const ALIBABA_PI_MODELS = Object.freeze([
|
|
|
60
60
|
]);
|
|
61
61
|
|
|
62
62
|
const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
|
|
63
|
-
const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
|
|
63
|
+
const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', 'credentialSourcePolicy', ...CUSTOM_KEYS]);
|
|
64
|
+
// Explicit credential source policy. Default 'auto' preserves the legacy
|
|
65
|
+
// resolution order (runtime -> store -> shell -> key files -> legacy) so a
|
|
66
|
+
// pre-WP1 fleet.json migrates no-op: no existing cell changes resolution.
|
|
67
|
+
const CREDENTIAL_SOURCES = Object.freeze(['environment', 'nexuscrew-store', 'auto']);
|
|
64
68
|
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', agy: 'Agy', shell: 'Shell' });
|
|
65
69
|
const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
66
70
|
|
|
@@ -167,7 +171,13 @@ function normalizeManagedSpec(value) {
|
|
|
167
171
|
const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
|
|
168
172
|
if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
|
|
169
173
|
if ((value.client === 'pi' || value.client === 'shell') && permissionPolicy !== 'standard') return null;
|
|
174
|
+
const credentialSourcePolicy = value.credentialSourcePolicy === undefined ? 'auto' : value.credentialSourcePolicy;
|
|
175
|
+
if (!CREDENTIAL_SOURCES.includes(credentialSourcePolicy)) return null;
|
|
170
176
|
const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
|
|
177
|
+
// 'auto' is the default and stays ABSENT from the normalized spec, so a legacy
|
|
178
|
+
// fleet.json (no credentialSourcePolicy) migrates truly no-op: no added field,
|
|
179
|
+
// no change in resolution. Explicit environment|nexuscrew-store are recorded.
|
|
180
|
+
if (credentialSourcePolicy !== 'auto') out.credentialSourcePolicy = credentialSourcePolicy;
|
|
171
181
|
if (profile.credentialProfile) out.credentialProfile = profile.credentialProfile;
|
|
172
182
|
if (profile.credentialEnv) {
|
|
173
183
|
const envKey = typeof value.envKey === 'string' && value.envKey.trim()
|
|
@@ -416,10 +426,20 @@ function credentialSources(cfg, home) {
|
|
|
416
426
|
function credential(profile, spec, cfg, home) {
|
|
417
427
|
if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '', source: profile.auth };
|
|
418
428
|
const envKey = profile.auth === 'dynamic' ? spec.envKey : profile.auth;
|
|
429
|
+
const policy = spec && CREDENTIAL_SOURCES.includes(spec.credentialSourcePolicy) ? spec.credentialSourcePolicy : 'auto';
|
|
419
430
|
const sources = credentialSources(cfg, home);
|
|
420
431
|
// The fixed shell file is already the user's environment source. Values are
|
|
421
432
|
// consumed only in memory and passed to the selected child; never persisted
|
|
422
433
|
// in fleet.json, service files, API responses or logs.
|
|
434
|
+
if (policy === 'environment') {
|
|
435
|
+
if (sources.runtime[envKey]) return { envKey, value: sources.runtime[envKey], source: 'environment' };
|
|
436
|
+
return { envKey, value: '', source: 'missing' };
|
|
437
|
+
}
|
|
438
|
+
if (policy === 'nexuscrew-store') {
|
|
439
|
+
if (sources.local[envKey]) return { envKey, value: sources.local[envKey], source: 'nexuscrew-store' };
|
|
440
|
+
return { envKey, value: '', source: 'missing' };
|
|
441
|
+
}
|
|
442
|
+
// auto: legacy resolution order (runtime -> store -> shell -> keys -> legacy).
|
|
423
443
|
if (sources.runtime[envKey]) return { envKey, value: sources.runtime[envKey], source: 'environment' };
|
|
424
444
|
if (sources.local[envKey]) return { envKey, value: sources.local[envKey], source: 'local' };
|
|
425
445
|
if (sources.shell[envKey]) return { envKey, value: sources.shell[envKey], source: 'compatibility' };
|
|
@@ -430,6 +450,31 @@ function credential(profile, spec, cfg, home) {
|
|
|
430
450
|
return { envKey, value: '', source: 'missing' };
|
|
431
451
|
}
|
|
432
452
|
|
|
453
|
+
// The profile's "owned" env set. When the credential source is the local store,
|
|
454
|
+
// the child environment must not inherit any of these from the runtime: the
|
|
455
|
+
// entire set is enumerated and neutralized (unset), never just envKey. For
|
|
456
|
+
// Anthropic-compatible (claude.*) clients the set is the ANTHROPIC_* triple;
|
|
457
|
+
// every other client keeps the single profile envKey.
|
|
458
|
+
function credentialEnvNeutralizeSet(profile) {
|
|
459
|
+
if (!profile) return [];
|
|
460
|
+
if (profile.client === 'claude') return ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
|
461
|
+
const auth = profile.auth;
|
|
462
|
+
const envKey = auth && auth !== 'dynamic' && auth !== 'login' && auth !== 'none'
|
|
463
|
+
? auth : (profile.defaultEnvKey || '');
|
|
464
|
+
return envKey ? [envKey] : [];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Apply nexuscrew-store neutralization to a composed child env: keys of the
|
|
468
|
+
// profile set that carry no NexusCrew-injected value are UNSET (deleted), never
|
|
469
|
+
// left as an empty string. 'auto'/'environment' are unchanged (legacy behavior).
|
|
470
|
+
function applyStoreNeutralization(env, spec, profile) {
|
|
471
|
+
if (!env || !spec || spec.credentialSourcePolicy !== 'nexuscrew-store') return env;
|
|
472
|
+
for (const k of credentialEnvNeutralizeSet(profile)) {
|
|
473
|
+
if (env[k] === undefined || env[k] === '') delete env[k];
|
|
474
|
+
}
|
|
475
|
+
return env;
|
|
476
|
+
}
|
|
477
|
+
|
|
433
478
|
let ollamaCache = { at: 0, models: [] };
|
|
434
479
|
async function discoverOllamaModels(opts = {}) {
|
|
435
480
|
const now = Date.now(); const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
|
|
@@ -527,6 +572,7 @@ function describeManaged(spec, cfg = {}) {
|
|
|
527
572
|
credentialProfile: normalized.credentialProfile || '', model: normalized.model,
|
|
528
573
|
permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
|
|
529
574
|
endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
|
|
575
|
+
credentialSourcePolicy: normalized.credentialSourcePolicy || 'auto',
|
|
530
576
|
credentialSource: authConfigured ? cred.source : 'missing',
|
|
531
577
|
configured, models: [...(profile.models || [])], defaultModel: profile.model || '',
|
|
532
578
|
binary: binary || '', displayName: normalized.displayName || profile.label,
|
|
@@ -814,6 +860,10 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
814
860
|
if (cell?.prompt) args.push('--prompt-interactive');
|
|
815
861
|
}
|
|
816
862
|
if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
|
|
863
|
+
// nexuscrew-store source: neutralize the profile's env set in the composed
|
|
864
|
+
// child env (unset, never empty), so the runtime cannot leak credentials that
|
|
865
|
+
// the local store is meant to own.
|
|
866
|
+
applyStoreNeutralization(env, spec, profile);
|
|
817
867
|
let command = info.binary;
|
|
818
868
|
if (needsExplicitNode(info.binary, cfg.platform || process.platform, cfg.env || process.env)) {
|
|
819
869
|
command = cfg.nodeExecPath || process.execPath;
|
|
@@ -841,10 +891,11 @@ function publicCatalog() {
|
|
|
841
891
|
module.exports = {
|
|
842
892
|
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
|
|
843
893
|
ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
|
|
844
|
-
CLIENT_LABELS, normalizeManagedSpec,
|
|
894
|
+
CLIENT_LABELS, normalizeManagedSpec, profileFor,
|
|
845
895
|
defaultDefinitions, defaultShellEngine, defaultAgyEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
846
896
|
discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
847
897
|
providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
|
|
898
|
+
credentialEnvNeutralizeSet, applyStoreNeutralization,
|
|
848
899
|
ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
|
|
849
900
|
shellLoginArgs, shellConfiguredCommandArgs, ENV_KEY_RE,
|
|
850
901
|
};
|
package/lib/fleet/provider.js
CHANGED
|
@@ -13,6 +13,12 @@ function disabled(reason) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
async function selectProvider(cfg = {}) {
|
|
16
|
+
// Seam esplicito per i test: permette di far girare il server REALE con uno
|
|
17
|
+
// stato Fleet controllato, senza tmux ne' processi. Esiste perche' i confini
|
|
18
|
+
// che dipendono dalle celle attive (l'origine di Audio Share) vanno provati
|
|
19
|
+
// sul server vero: un test che inietta le dipendenze nel router non si
|
|
20
|
+
// accorgerebbe mai di un cablaggio sbagliato.
|
|
21
|
+
if (cfg.fleetSeam) return { mode: 'seam', reason: 'fleet iniettata (seam di test)', fleet: cfg.fleetSeam };
|
|
16
22
|
if (cfg.fleetEnabled === false) return disabled('fleet disabilitata (fleetEnabled=false)');
|
|
17
23
|
if (cfg.builtinEnabled === false) return disabled('fleet builtin disabilitata (builtinEnabled=false)');
|
|
18
24
|
const fleet = await createBuiltinFleet({ ...cfg, fleetProviderReason: 'NexusCrew builtin fleet' });
|