@mmmbuto/nexuscrew 0.9.20 → 0.9.22
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 -0
- package/frontend/dist/assets/{index-VdGL6XJ8.js → index-D5snoCdN.js} +1 -1
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/audio/routes.js +27 -0
- package/lib/cells/routes.js +30 -2
- package/lib/cli/commands.js +11 -1
- package/lib/files/routes.js +31 -4
- package/lib/fleet/builtin.js +26 -4
- package/lib/fleet/cell-exec.js +251 -7
- package/lib/fleet/cell-lease-server.js +161 -6
- package/lib/fleet/definitions.js +16 -1
- package/lib/fleet/identity-authority.js +367 -0
- package/lib/fleet/identity-transport.js +68 -0
- package/lib/fleet/launch-broker.js +26 -1
- package/lib/fleet/lease-client.js +168 -1
- package/lib/fleet/lease-routes.js +96 -24
- package/lib/fleet/managed.js +52 -3
- package/lib/fleet/runtime.js +24 -1
- package/lib/identity/binding-guard.js +128 -0
- package/lib/live-host/bridge.js +69 -0
- package/lib/live-host/routes.js +9 -5
- package/lib/mcp/identity-provider.js +132 -0
- package/lib/mcp/identity-schema.js +168 -0
- package/lib/mcp/server.js +123 -6
- package/lib/mcp/tools.js +38 -6
- package/lib/notify/asks.js +1 -0
- package/lib/notify/routes.js +29 -1
- package/lib/server.js +24 -4
- package/package.json +1 -3
- package/docs/img/fleet-deck-desktop.png +0 -0
- package/docs/img/session-mobile.png +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function issueIdentityChallenge({
|
|
4
|
+
identityAuthority,
|
|
5
|
+
daemonCredential,
|
|
6
|
+
audience,
|
|
7
|
+
daemonBootId,
|
|
8
|
+
connectionId,
|
|
9
|
+
} = {}) {
|
|
10
|
+
if (!identityAuthority || typeof identityAuthority.registerDaemonChallenge !== 'function') {
|
|
11
|
+
return { ok: false, reason: 'identity-authority' };
|
|
12
|
+
}
|
|
13
|
+
const out = identityAuthority.registerDaemonChallenge({
|
|
14
|
+
daemonCredential,
|
|
15
|
+
audience,
|
|
16
|
+
daemonBootId,
|
|
17
|
+
connectionId,
|
|
18
|
+
});
|
|
19
|
+
if (!out.ok) return out;
|
|
20
|
+
return { challenge: out.challenge, issuedAt: out.issuedAt, expiresAt: out.expiresAt };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function issueLaunchIdentity({
|
|
24
|
+
identityAuthority,
|
|
25
|
+
daemonCredential,
|
|
26
|
+
launcherCredential,
|
|
27
|
+
audience,
|
|
28
|
+
daemonBootId,
|
|
29
|
+
connectionId,
|
|
30
|
+
subject,
|
|
31
|
+
} = {}) {
|
|
32
|
+
const challenge = issueIdentityChallenge({
|
|
33
|
+
identityAuthority,
|
|
34
|
+
daemonCredential,
|
|
35
|
+
audience,
|
|
36
|
+
daemonBootId,
|
|
37
|
+
connectionId,
|
|
38
|
+
});
|
|
39
|
+
if (!challenge || !challenge.challenge) {
|
|
40
|
+
return { ok: false, reason: challenge && challenge.reason ? challenge.reason : 'challenge' };
|
|
41
|
+
}
|
|
42
|
+
const grant = identityAuthority.issueLaunchGrant({
|
|
43
|
+
launcherCredential,
|
|
44
|
+
challenge: challenge.challenge,
|
|
45
|
+
subject,
|
|
46
|
+
});
|
|
47
|
+
if (!grant || !grant.ok) {
|
|
48
|
+
return { ok: false, reason: grant && grant.reason ? grant.reason : 'grant' };
|
|
49
|
+
}
|
|
50
|
+
const proof = identityAuthority.issueChallengeProof({
|
|
51
|
+
launchGrant: grant.grant,
|
|
52
|
+
challenge: challenge.challenge,
|
|
53
|
+
});
|
|
54
|
+
if (!proof || !proof.ok) {
|
|
55
|
+
return { ok: false, reason: proof && proof.reason ? proof.reason : 'proof' };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
audience,
|
|
60
|
+
daemonBootId,
|
|
61
|
+
connectionId,
|
|
62
|
+
challenge: challenge.challenge,
|
|
63
|
+
grant: grant.grant,
|
|
64
|
+
proof: proof.proof,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { issueIdentityChallenge, issueLaunchIdentity };
|
|
@@ -5,6 +5,7 @@ const net = require('node:net');
|
|
|
5
5
|
const os = require('node:os');
|
|
6
6
|
const path = require('node:path');
|
|
7
7
|
const crypto = require('node:crypto');
|
|
8
|
+
const { issueLaunchIdentity } = require('./identity-transport.js');
|
|
8
9
|
|
|
9
10
|
const MAX_PAYLOAD = 512 * 1024;
|
|
10
11
|
const REQUEST_LIMIT = 256;
|
|
@@ -82,6 +83,10 @@ function createLaunchBroker(cfg = {}) {
|
|
|
82
83
|
// onLease o senza lease il comportamento e' invariato (socket.end, one-shot):
|
|
83
84
|
// le celle non-ospite non sono toccate.
|
|
84
85
|
const onLease = typeof cfg.onLease === 'function' ? cfg.onLease : null;
|
|
86
|
+
const identityMode = cfg.identityMode || 'legacy';
|
|
87
|
+
const identityAuthority = cfg.identityAuthority || null;
|
|
88
|
+
const identityDaemonCredential = cfg.identityDaemonCredential || null;
|
|
89
|
+
const identityLauncherCredential = cfg.identityLauncherCredential || null;
|
|
85
90
|
|
|
86
91
|
function expire(nonce) {
|
|
87
92
|
const entry = pending.get(nonce);
|
|
@@ -226,7 +231,27 @@ function createLaunchBroker(cfg = {}) {
|
|
|
226
231
|
async function issue(payload) {
|
|
227
232
|
const target = await start();
|
|
228
233
|
const nonce = crypto.randomBytes(32).toString('hex');
|
|
229
|
-
|
|
234
|
+
let encodedPayload = payload;
|
|
235
|
+
if (payload && payload.identity) {
|
|
236
|
+
if (identityMode !== 'authority' || !identityAuthority || !identityDaemonCredential) {
|
|
237
|
+
throw new Error('identity authority non disponibile');
|
|
238
|
+
}
|
|
239
|
+
const launched = issueLaunchIdentity({
|
|
240
|
+
identityAuthority,
|
|
241
|
+
daemonCredential: identityDaemonCredential,
|
|
242
|
+
launcherCredential: identityLauncherCredential,
|
|
243
|
+
audience: payload.identity.audience,
|
|
244
|
+
daemonBootId: payload.identity.daemonBootId,
|
|
245
|
+
connectionId: payload.identity.connectionId,
|
|
246
|
+
subject: payload.identity.subject,
|
|
247
|
+
});
|
|
248
|
+
if (!launched || !launched.ok) {
|
|
249
|
+
throw new Error(`identity launch ${launched && launched.reason ? launched.reason : 'malformed'}`);
|
|
250
|
+
}
|
|
251
|
+
const { ok, ...identity } = launched;
|
|
252
|
+
encodedPayload = { ...payload, identity };
|
|
253
|
+
}
|
|
254
|
+
const entry = { encoded: encodePayload(encodedPayload), expires: Date.now() + ttlMs, timer: null, lease: payload && payload.lease ? payload.lease : null };
|
|
230
255
|
entry.timer = setTimeout(() => expire(nonce), ttlMs);
|
|
231
256
|
entry.timer.unref?.();
|
|
232
257
|
pending.set(nonce, entry);
|
|
@@ -18,6 +18,48 @@
|
|
|
18
18
|
const net = require('node:net');
|
|
19
19
|
const L = require('./cell-lease.js');
|
|
20
20
|
|
|
21
|
+
const IDENTITY_FRAME_LIMIT = 8 * 1024;
|
|
22
|
+
const IDENTITY_TIMEOUT_MS = 4000;
|
|
23
|
+
const IDENTITY_CHALLENGE_KEYS = Object.freeze([
|
|
24
|
+
'version', 'audience', 'daemonBootId', 'connectionId', 'nonce', 'issuedAt', 'expiresAt',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function nonEmptyString(value, max = 256) {
|
|
28
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validDaemonChallenge(value) {
|
|
32
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
33
|
+
const keys = Object.keys(value);
|
|
34
|
+
if (keys.length !== IDENTITY_CHALLENGE_KEYS.length
|
|
35
|
+
|| IDENTITY_CHALLENGE_KEYS.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) return false;
|
|
36
|
+
// R4: tipi JSON originari, niente coercizioni (Number()/regex su non-stringhe).
|
|
37
|
+
const issuedAt = value.issuedAt;
|
|
38
|
+
const expiresAt = value.expiresAt;
|
|
39
|
+
return value.version === 1
|
|
40
|
+
&& nonEmptyString(value.audience) && nonEmptyString(value.daemonBootId)
|
|
41
|
+
&& nonEmptyString(value.connectionId)
|
|
42
|
+
&& typeof value.nonce === 'string' && /^[a-f0-9]{64}$/.test(value.nonce)
|
|
43
|
+
&& Number.isSafeInteger(issuedAt) && Number.isSafeInteger(expiresAt)
|
|
44
|
+
&& issuedAt >= 0 && expiresAt > issuedAt;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function identityErrorCode(reason) {
|
|
48
|
+
switch (reason) {
|
|
49
|
+
case 'expired': return 'EXPIRED';
|
|
50
|
+
case 'replay': case 'challenge-replay': return 'REPLAY';
|
|
51
|
+
case 'audience': case 'daemonBootId': case 'connectionId': return 'AUDIENCE_MISMATCH';
|
|
52
|
+
case 'lease-down': case 'revoked': case 'generation': return 'REVOKED';
|
|
53
|
+
case 'timeout': case 'authority-unavailable': case 'authority': return 'AUTHORITY_UNAVAILABLE';
|
|
54
|
+
default: return 'IDENTITY_UNVERIFIED';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validIdentityRequestId(value) {
|
|
59
|
+
return (typeof value === 'string' && value.length > 0 && value.length <= 128)
|
|
60
|
+
|| Number.isSafeInteger(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
21
63
|
function startLeaseClient(initialSocket, info, seams = {}) {
|
|
22
64
|
if (!initialSocket || !info || !info.stablePath || !info.launchEpoch) return null;
|
|
23
65
|
const setTimer = seams.setTimeout || ((fn, ms) => setTimeout(fn, ms));
|
|
@@ -34,12 +76,45 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
34
76
|
let heldProof = null;
|
|
35
77
|
// R3.2: bound di grace per i reconnect (eofAt + GRACE_MS). Oltre non si ritenta.
|
|
36
78
|
let reconnectDeadline = null;
|
|
79
|
+
const identityPending = new Map();
|
|
80
|
+
// R1: una sola transizione di generazione in volo (il supervisore e' sequenziale).
|
|
81
|
+
let generationWaiter = null;
|
|
37
82
|
|
|
38
83
|
function send(obj) {
|
|
39
84
|
if (!current || current.destroyed || !current.writable) return false;
|
|
40
85
|
try { current.write(`${JSON.stringify(obj)}\n`); return true; } catch (_) { return false; }
|
|
41
86
|
}
|
|
42
87
|
|
|
88
|
+
function rejectIdentity(reason) {
|
|
89
|
+
for (const pending of identityPending.values()) {
|
|
90
|
+
clearTimer(pending.timer);
|
|
91
|
+
pending.reject(Object.assign(new Error(reason), { code: reason }));
|
|
92
|
+
}
|
|
93
|
+
identityPending.clear();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function identityDown() {
|
|
97
|
+
rejectIdentity('lease-down');
|
|
98
|
+
if (generationWaiter) {
|
|
99
|
+
const waiter = generationWaiter;
|
|
100
|
+
generationWaiter = null;
|
|
101
|
+
clearTimer(waiter.timer);
|
|
102
|
+
waiter.reject(Object.assign(new Error('lease-down'), { code: 'lease-down' }));
|
|
103
|
+
}
|
|
104
|
+
if (typeof info.onIdentityDown === 'function') {
|
|
105
|
+
try { info.onIdentityDown(); } catch (_) {}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function cancelIdentity(requestId) {
|
|
110
|
+
const pending = identityPending.get(requestId);
|
|
111
|
+
if (!pending) return false;
|
|
112
|
+
identityPending.delete(requestId);
|
|
113
|
+
clearTimer(pending.timer);
|
|
114
|
+
pending.reject(Object.assign(new Error('cancelled'), { code: 'cancelled' }));
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
43
118
|
function armRefresh() {
|
|
44
119
|
clearTimer(refreshTimer);
|
|
45
120
|
refreshTimer = setTimer(() => {
|
|
@@ -52,6 +127,7 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
52
127
|
|
|
53
128
|
function onEOF() {
|
|
54
129
|
if (stopped) return;
|
|
130
|
+
identityDown();
|
|
55
131
|
if (current) { try { current.removeAllListeners('data'); current.removeAllListeners('close'); current.removeAllListeners('end'); } catch (_) {} }
|
|
56
132
|
current = null;
|
|
57
133
|
clearTimer(refreshTimer);
|
|
@@ -157,6 +233,31 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
157
233
|
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
158
234
|
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
159
235
|
let msg; try { msg = JSON.parse(line); } catch (_) { continue; }
|
|
236
|
+
if (msg.type === 'challengeProofResult' && validIdentityRequestId(msg.requestId)) {
|
|
237
|
+
const pending = identityPending.get(msg.requestId);
|
|
238
|
+
if (pending) {
|
|
239
|
+
identityPending.delete(msg.requestId);
|
|
240
|
+
clearTimer(pending.timer);
|
|
241
|
+
if (msg.ok === true && msg.proof && typeof msg.proof === 'object') {
|
|
242
|
+
pending.resolve({ ok: true, proof: msg.proof });
|
|
243
|
+
} else {
|
|
244
|
+
const reason = msg.ok === false && typeof msg.reason === 'string' ? msg.reason : 'identity-unverified';
|
|
245
|
+
pending.reject(Object.assign(new Error(reason), { code: reason }));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (msg.type === 'generationAck' && generationWaiter && msg.generation === generationWaiter.generation) {
|
|
250
|
+
const waiter = generationWaiter;
|
|
251
|
+
generationWaiter = null;
|
|
252
|
+
clearTimer(waiter.timer);
|
|
253
|
+
waiter.resolve({ ok: true, generation: msg.generation });
|
|
254
|
+
}
|
|
255
|
+
if (msg.type === 'generationDeny' && generationWaiter) {
|
|
256
|
+
const waiter = generationWaiter;
|
|
257
|
+
generationWaiter = null;
|
|
258
|
+
clearTimer(waiter.timer);
|
|
259
|
+
waiter.reject(Object.assign(new Error('revoked'), { code: 'revoked' }));
|
|
260
|
+
}
|
|
160
261
|
if ((msg.type === 'ack' || msg.type === 'lease') && msg.proof && typeof msg.proof === 'object') {
|
|
161
262
|
heldProof = msg.proof;
|
|
162
263
|
}
|
|
@@ -175,17 +276,83 @@ function startLeaseClient(initialSocket, info, seams = {}) {
|
|
|
175
276
|
armRefresh();
|
|
176
277
|
send({ type: 'refresh' }); // primo refresh immediato
|
|
177
278
|
|
|
279
|
+
function challengeProof({ requestId, generation, challenge } = {}) {
|
|
280
|
+
if (!validIdentityRequestId(requestId) || !Number.isSafeInteger(generation) || generation < 0) {
|
|
281
|
+
return Promise.resolve({ ok: false, reason: 'identity-unverified' });
|
|
282
|
+
}
|
|
283
|
+
if (!validDaemonChallenge(challenge)) return Promise.resolve({ ok: false, reason: 'challenge' });
|
|
284
|
+
if (stopped || !current || current.destroyed || !current.writable || identityPending.has(requestId)) {
|
|
285
|
+
return Promise.resolve({ ok: false, reason: 'authority-unavailable' });
|
|
286
|
+
}
|
|
287
|
+
return new Promise((resolve, reject) => {
|
|
288
|
+
const pending = { resolve, reject, timer: null };
|
|
289
|
+
pending.timer = setTimer(() => {
|
|
290
|
+
if (!identityPending.has(requestId)) return;
|
|
291
|
+
identityPending.delete(requestId);
|
|
292
|
+
reject(Object.assign(new Error('timeout'), { code: 'timeout' }));
|
|
293
|
+
}, IDENTITY_TIMEOUT_MS);
|
|
294
|
+
if (pending.timer && typeof pending.timer.unref === 'function') pending.timer.unref();
|
|
295
|
+
identityPending.set(requestId, pending);
|
|
296
|
+
const sent = send({ type: 'challengeProof', requestId, generation, challenge });
|
|
297
|
+
if (!sent) {
|
|
298
|
+
identityPending.delete(requestId);
|
|
299
|
+
clearTimer(pending.timer);
|
|
300
|
+
reject(Object.assign(new Error('authority-unavailable'), { code: 'authority-unavailable' }));
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// R1: annuncia la transizione di generazione sulla connessione viva PRIMA
|
|
306
|
+
// che il supervisore apra il canale identita' della generazione nuova.
|
|
307
|
+
function announceGeneration(generation) {
|
|
308
|
+
if (!Number.isSafeInteger(generation) || generation < 0) {
|
|
309
|
+
return Promise.resolve({ ok: false, reason: 'identity-unverified' });
|
|
310
|
+
}
|
|
311
|
+
if (stopped || !current || current.destroyed || !current.writable) {
|
|
312
|
+
return Promise.resolve({ ok: false, reason: 'lease-down' });
|
|
313
|
+
}
|
|
314
|
+
return new Promise((resolve, reject) => {
|
|
315
|
+
const waiter = { resolve, reject, timer: null, generation };
|
|
316
|
+
generationWaiter = waiter;
|
|
317
|
+
waiter.timer = setTimer(() => {
|
|
318
|
+
if (generationWaiter !== waiter) return;
|
|
319
|
+
generationWaiter = null;
|
|
320
|
+
reject(Object.assign(new Error('timeout'), { code: 'timeout' }));
|
|
321
|
+
}, IDENTITY_TIMEOUT_MS);
|
|
322
|
+
if (waiter.timer && typeof waiter.timer.unref === 'function') waiter.timer.unref();
|
|
323
|
+
if (!send({ type: 'generation', generation })) {
|
|
324
|
+
if (generationWaiter === waiter) generationWaiter = null;
|
|
325
|
+
clearTimer(waiter.timer);
|
|
326
|
+
reject(Object.assign(new Error('lease-down'), { code: 'lease-down' }));
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
178
331
|
return {
|
|
179
332
|
stop() {
|
|
180
333
|
stopped = true;
|
|
334
|
+
rejectIdentity('lease-down');
|
|
335
|
+
if (generationWaiter) {
|
|
336
|
+
const waiter = generationWaiter;
|
|
337
|
+
generationWaiter = null;
|
|
338
|
+
clearTimer(waiter.timer);
|
|
339
|
+
waiter.reject(Object.assign(new Error('lease-down'), { code: 'lease-down' }));
|
|
340
|
+
}
|
|
181
341
|
clearTimer(refreshTimer);
|
|
182
342
|
clearTimer(reconnectTimer);
|
|
183
343
|
try { current && current.destroy(); } catch (_) {}
|
|
184
344
|
current = null;
|
|
185
345
|
},
|
|
346
|
+
challengeProof,
|
|
347
|
+
announceGeneration,
|
|
348
|
+
cancelIdentityChallenge: cancelIdentity,
|
|
186
349
|
_isConnected: () => !!current && !current.destroyed,
|
|
187
350
|
_heldProof: () => heldProof,
|
|
351
|
+
_identityPendingCount: () => identityPending.size,
|
|
188
352
|
};
|
|
189
353
|
}
|
|
190
354
|
|
|
191
|
-
module.exports = {
|
|
355
|
+
module.exports = {
|
|
356
|
+
startLeaseClient, validDaemonChallenge, identityErrorCode,
|
|
357
|
+
IDENTITY_FRAME_LIMIT, IDENTITY_TIMEOUT_MS,
|
|
358
|
+
};
|
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Il bridge MCP di una cella (`nexuscrew mcp`) parla con l'HTTP API locale dietro
|
|
6
6
|
// Bearer (canale nativo del bridge): queste route sono quel collegamento.
|
|
7
|
-
// La CELLA e' derivata
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// autorizzazione, non il trasporto).
|
|
7
|
+
// La CELLA di register e' derivata dal proof identity verificato dall'authority;
|
|
8
|
+
// refresh/recovery la derivano dal proof child firmato dal lease manager. La
|
|
9
|
+
// sessione tmux del body non e' mai un authorizer.
|
|
11
10
|
//
|
|
12
11
|
// Semantica degli status (tutti 200 salvo errori di protocollo):
|
|
13
12
|
// registered | live | pending | no-registration | expired | denied
|
|
@@ -15,11 +14,38 @@
|
|
|
15
14
|
// richiesta era malformata o il servizio non c'e' — non e' un esito di lease.
|
|
16
15
|
|
|
17
16
|
const express = require('express');
|
|
18
|
-
const { cellIdFromTmuxSession } = require('./definitions.js');
|
|
17
|
+
const { cellIdFromTmuxSession, tmuxSessionForCell } = require('./definitions.js');
|
|
18
|
+
const { createIdentityBindingGuard } = require('../identity/binding-guard.js');
|
|
19
19
|
|
|
20
|
-
function leaseRoutes({
|
|
20
|
+
function leaseRoutes({
|
|
21
|
+
fleetP, readonly = () => false, log = () => {}, identityAuthority = null,
|
|
22
|
+
identityAudience = 'nexuscrew-lease', identityMode = 'legacy', instanceId = null,
|
|
23
|
+
}) {
|
|
24
|
+
const bindingGuard = createIdentityBindingGuard({
|
|
25
|
+
fleetP, instanceId, now: () => Date.now(),
|
|
26
|
+
// refresh/recovery hanno gia il proof child come authorizer nel body. Il
|
|
27
|
+
// binding MCP, quando presentato, viene verificato fail-closed; l'assenza
|
|
28
|
+
// preserva il canale nativo lease (D) che esisteva prima di G3.
|
|
29
|
+
sharedRequired: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
async function guardBinding(req, cell) {
|
|
33
|
+
try {
|
|
34
|
+
return await bindingGuard.verify(req, { expected: { cell }, localOnly: true });
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function bindingRejected(res, error) {
|
|
41
|
+
return res.status(403).json({ error: error.message, code: error.code });
|
|
42
|
+
}
|
|
21
43
|
const r = express.Router();
|
|
22
44
|
const smallJson = express.json({ limit: '8kb' });
|
|
45
|
+
if (identityMode !== 'legacy' && identityMode !== 'authority') {
|
|
46
|
+
throw new Error('fleet.identity.mode non valido');
|
|
47
|
+
}
|
|
48
|
+
const mode = identityMode;
|
|
23
49
|
|
|
24
50
|
const guard = (fn) => async (req, res) => {
|
|
25
51
|
try {
|
|
@@ -30,19 +56,25 @@ function leaseRoutes({ fleetP, readonly = () => false, log = () => {} }) {
|
|
|
30
56
|
if (!fleet.lease || typeof fleet.lease.childRegister !== 'function') {
|
|
31
57
|
return res.status(501).json({ error: 'lease non disponibile su questo nodo' });
|
|
32
58
|
}
|
|
33
|
-
return await fn(fleet.lease, req, res);
|
|
59
|
+
return await fn(fleet.lease, req, res, fleet);
|
|
34
60
|
} catch (e) {
|
|
35
61
|
res.status(500).json({ error: String((e && e.message) || e) });
|
|
36
62
|
}
|
|
37
63
|
};
|
|
38
64
|
|
|
39
|
-
// La sessione
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
65
|
+
// La sessione nel body NON è una credenziale. Per register il subject viene
|
|
66
|
+
// dal proof identity emesso dall'authority; per refresh/recovery dal proof
|
|
67
|
+
// child già firmato dal lease manager. Se un client legacy invia session la
|
|
68
|
+
// confrontiamo solo come guardia anti-confusione, mai per scegliere la cella.
|
|
69
|
+
const cellFromProof = (req, res) => {
|
|
70
|
+
const cell = req.body && req.body.proof && req.body.proof.cellId;
|
|
71
|
+
if (!tmuxSessionForCell(cell)) {
|
|
72
|
+
res.status(400).json({ error: 'proof senza cella valida' });
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const declared = req.body && req.body.session;
|
|
76
|
+
if (declared !== undefined && cellIdFromTmuxSession(declared) !== cell) {
|
|
77
|
+
res.status(400).json({ error: 'sessione body discordante dal proof' });
|
|
46
78
|
return null;
|
|
47
79
|
}
|
|
48
80
|
return cell;
|
|
@@ -56,31 +88,71 @@ function leaseRoutes({ fleetP, readonly = () => false, log = () => {} }) {
|
|
|
56
88
|
return proof;
|
|
57
89
|
};
|
|
58
90
|
|
|
59
|
-
r.post('/register', smallJson, guard((lease, req, res) => {
|
|
91
|
+
r.post('/register', smallJson, guard((lease, req, res, fleet) => {
|
|
60
92
|
if (readonly()) return res.status(403).json({ error: 'READONLY: lease child bloccato' });
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
93
|
+
let cell;
|
|
94
|
+
if (mode === 'authority') {
|
|
95
|
+
const proof = requireProof(req, res);
|
|
96
|
+
if (!proof) return undefined;
|
|
97
|
+
const authority = identityAuthority || fleet.identityAuthority;
|
|
98
|
+
if (!authority || typeof authority.verifyChallengeProof !== 'function') {
|
|
99
|
+
return res.status(501).json({ error: 'identity authority non disponibile' });
|
|
100
|
+
}
|
|
101
|
+
const checked = authority.verifyChallengeProof(proof, { audience: identityAudience });
|
|
102
|
+
if (!checked.ok) return res.json({ status: 'denied', reason: checked.reason });
|
|
103
|
+
cell = cellFromProof(req, res);
|
|
104
|
+
if (!cell) return undefined;
|
|
105
|
+
} else {
|
|
106
|
+
const declared = req.body && req.body.session;
|
|
107
|
+
cell = cellIdFromTmuxSession(declared);
|
|
108
|
+
if (!cell) return res.status(400).json({ error: 'sessione mancante o non valida' });
|
|
109
|
+
}
|
|
110
|
+
const out = lease.childRegister(cell, { authority: mode === 'authority' });
|
|
64
111
|
log(`lease-route: register ${cell} -> ${out.status}`);
|
|
65
112
|
return res.json(out);
|
|
66
113
|
}));
|
|
67
114
|
|
|
68
|
-
|
|
115
|
+
// Introspezione del proof child per il contesto shared del bridge MCP:
|
|
116
|
+
// READ-ONLY (non consuma, non rinnova), attiva soltanto in authority mode.
|
|
117
|
+
// In legacy risponde 501: nessun binding B+C puo nascere dal percorso D.
|
|
118
|
+
r.post('/introspect', smallJson, guard((lease, req, res) => {
|
|
119
|
+
if (mode !== 'authority') {
|
|
120
|
+
return res.status(501).json({ error: 'identity introspection richiede fleet.identity.mode authority' });
|
|
121
|
+
}
|
|
122
|
+
const proof = requireProof(req, res);
|
|
123
|
+
if (!proof) return undefined;
|
|
124
|
+
const out = lease.childIntrospect(proof);
|
|
125
|
+
if (out.status === 'live') {
|
|
126
|
+
const node = typeof instanceId === 'function' ? instanceId() : instanceId;
|
|
127
|
+
return res.json({
|
|
128
|
+
...out,
|
|
129
|
+
tmuxSession: tmuxSessionForCell(out.cellId),
|
|
130
|
+
...(typeof node === 'string' && node ? { instanceId: node } : {}),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return res.json(out);
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
r.post('/refresh', smallJson, guard(async (lease, req, res) => {
|
|
69
137
|
if (readonly()) return res.status(403).json({ error: 'READONLY: lease child bloccato' });
|
|
70
|
-
const cell = requireCell(req, res);
|
|
71
|
-
if (!cell) return undefined;
|
|
72
138
|
const proof = requireProof(req, res);
|
|
73
139
|
if (!proof) return undefined;
|
|
140
|
+
const cell = cellFromProof(req, res);
|
|
141
|
+
if (!cell) return undefined;
|
|
142
|
+
const binding = await guardBinding(req, cell);
|
|
143
|
+
if (binding instanceof Error) return bindingRejected(res, binding);
|
|
74
144
|
const out = lease.childRefresh(cell, proof);
|
|
75
145
|
return res.json(out);
|
|
76
146
|
}));
|
|
77
147
|
|
|
78
|
-
r.post('/recovery', smallJson, guard((lease, req, res) => {
|
|
148
|
+
r.post('/recovery', smallJson, guard(async (lease, req, res) => {
|
|
79
149
|
if (readonly()) return res.status(403).json({ error: 'READONLY: lease child bloccato' });
|
|
80
|
-
const cell = requireCell(req, res);
|
|
81
|
-
if (!cell) return undefined;
|
|
82
150
|
const proof = requireProof(req, res);
|
|
83
151
|
if (!proof) return undefined;
|
|
152
|
+
const cell = cellFromProof(req, res);
|
|
153
|
+
if (!cell) return undefined;
|
|
154
|
+
const binding = await guardBinding(req, cell);
|
|
155
|
+
if (binding instanceof Error) return bindingRejected(res, binding);
|
|
84
156
|
const out = lease.childRecovery(cell, proof);
|
|
85
157
|
log(`lease-route: recovery ${cell} -> ${out.status}`);
|
|
86
158
|
return res.json(out);
|
package/lib/fleet/managed.js
CHANGED
|
@@ -20,6 +20,15 @@ const OLLAMA_CLOUD_MODELS = Object.freeze([
|
|
|
20
20
|
// glm-5.3: disponibile su ollama.com/library (2026-09-05). La scheda
|
|
21
21
|
// dichiara Context 1M tokens, Input Text e le badge tools/thinking/cloud.
|
|
22
22
|
'glm-5.3',
|
|
23
|
+
// kimi-k3: disponibile su ollama.com/library (2026-09-06). La scheda
|
|
24
|
+
// dichiara Context 1M tokens, Input Text, Image e le badge vision/tools/thinking/cloud.
|
|
25
|
+
// DICHIARATO 2026-09-06 https://ollama.com/library/kimi-k3
|
|
26
|
+
'kimi-k3',
|
|
27
|
+
// deepseek-v4.1-flash: disponibile su ollama.com/library (2026-09-11). La
|
|
28
|
+
// scheda dichiara Context 1M tokens, Input Text, Image e le badge
|
|
29
|
+
// tools/thinking/vision/cloud; 552B MoE (8B attivi).
|
|
30
|
+
// DICHIARATO 2026-09-11 https://ollama.com/library/deepseek-v4.1-flash
|
|
31
|
+
'deepseek-v4.1-flash',
|
|
23
32
|
]);
|
|
24
33
|
// Autorita' per OLLAMA_CONTEXT = IL CAMPO STRUTTURATO «Context» della scheda
|
|
25
34
|
// canale (ollama.com/library, fetch 2026-08-27) — NON la descrizione, che
|
|
@@ -48,6 +57,12 @@ const OLLAMA_CONTEXT = Object.freeze({
|
|
|
48
57
|
'glm-5.3-flash': 1000000,
|
|
49
58
|
// glm-5.3: scheda «Context / 1M tokens» (2026-09-05).
|
|
50
59
|
'glm-5.3': 1000000,
|
|
60
|
+
// kimi-k3: scheda «Context / 1M tokens», Input Text, Image (2026-09-06).
|
|
61
|
+
// DICHIARATO 2026-09-06 https://ollama.com/library/kimi-k3
|
|
62
|
+
'kimi-k3': 1000000,
|
|
63
|
+
// deepseek-v4.1-flash: scheda «Context / 1M tokens» (2026-09-11); come gli
|
|
64
|
+
// altri deepseek vale la convenzione binaria del repo (1M -> 1048576).
|
|
65
|
+
'deepseek-v4.1-flash': 1048576,
|
|
51
66
|
});
|
|
52
67
|
// Capacita' per modello OSSERVATE dalla scheda canale (ollama.com/library,
|
|
53
68
|
// 2026-08-27). Assenza = default conservativo (comportamento odierno, nessuna
|
|
@@ -62,6 +77,14 @@ const OLLAMA_MODEL_CAPABILITIES = Object.freeze({
|
|
|
62
77
|
// glm-5.3: scheda tools/thinking/cloud, Input Text (2026-09-05). PARALLEL
|
|
63
78
|
// NON dichiarato: default conservativo false finche' non misurato su device.
|
|
64
79
|
'glm-5.3': Object.freeze({ input: ['text'], reasoning: true, supportsParallelToolCalls: false }),
|
|
80
|
+
// kimi-k3: scheda vision/tools/thinking/cloud, Input Text, Image (2026-09-06).
|
|
81
|
+
// PARALLEL NON dichiarato: default conservativo false finche' non misurato su device.
|
|
82
|
+
// DICHIARATO 2026-09-06 https://ollama.com/library/kimi-k3
|
|
83
|
+
'kimi-k3': Object.freeze({ input: ['text', 'image'], reasoning: true, supportsParallelToolCalls: false }),
|
|
84
|
+
// deepseek-v4.1-flash: scheda tools/thinking/vision/cloud, Input Text, Image
|
|
85
|
+
// (2026-09-11). PARALLEL NON dichiarato: default conservativo false finche'
|
|
86
|
+
// non misurato su device.
|
|
87
|
+
'deepseek-v4.1-flash': Object.freeze({ input: ['text', 'image'], reasoning: true, supportsParallelToolCalls: false }),
|
|
65
88
|
});
|
|
66
89
|
// Descrittori dell'engine ollama-cloud per la generazione del catalogo client:
|
|
67
90
|
// stessa forma dei descrittori custom/PI (id, label, contextWindow, input,
|
|
@@ -335,6 +358,8 @@ const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model'
|
|
|
335
358
|
// D3: massimo numero di nomi in `envPassthrough`. L'allowlist e' opt-in e per
|
|
336
359
|
// nome, mai un passthrough in blocco: un tetto basso ferma una lista incontrollata.
|
|
337
360
|
const MAX_ENV_PASSTHROUGH = 32;
|
|
361
|
+
const CODEX_APP_SERVER_IDENTITY_REQUIRED = 'CODEX_APP_SERVER_IDENTITY_REQUIRED';
|
|
362
|
+
const CODEX_APP_SERVER_IDENTITY_REQUIRED_DEFAULT = '1';
|
|
338
363
|
// Explicit credential source policy. Default 'auto' preserves the legacy
|
|
339
364
|
// resolution order (runtime -> store -> shell -> key files -> legacy) so a
|
|
340
365
|
// pre-WP1 fleet.json migrates no-op: no existing cell changes resolution.
|
|
@@ -389,7 +414,7 @@ const CATALOG = Object.freeze([
|
|
|
389
414
|
// launch valorizza ANTHROPIC_API_KEY, non ANTHROPIC_AUTH_TOKEN come Z.AI.
|
|
390
415
|
{ id: 'claude.opencode-go', client: 'claude', provider: 'opencode-go', label: 'OpenCode Go', auth: 'OPENCODE_API_KEY', endpoint: OPENCODE_GO_ANTHROPIC_ROOT, protocol: 'anthropic_messages', model: 'deepseek-v4-flash', models: OPENCODE_GO_MESSAGES_MODELS, strictModels: true, core: true },
|
|
391
416
|
{ id: 'claude.openrouter', client: 'claude', provider: 'openrouter', label: 'OpenRouter', auth: 'OPENROUTER_API_KEY', endpoint: 'https://openrouter.ai/api', protocol: 'anthropic_messages', requiresModel: true, core: true, notice: 'claude-openrouter' },
|
|
392
|
-
{ id: 'claude.ollama-cloud', client: 'claude', provider: 'ollama-cloud', label: 'Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com', protocol: 'anthropic_messages', model: '
|
|
417
|
+
{ id: 'claude.ollama-cloud', client: 'claude', provider: 'ollama-cloud', label: 'Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com', protocol: 'anthropic_messages', model: 'deepseek-v4.1-flash', models: OLLAMA_CLOUD_MODELS, legacySecrets: true, core: true },
|
|
393
418
|
{ id: 'claude.bedrock', client: 'claude', provider: 'bedrock', label: 'Amazon Bedrock', auth: 'login', endpoint: 'AWS Bedrock', protocol: 'anthropic_messages', core: true, providerEnv: { CLAUDE_CODE_USE_BEDROCK: '1' } },
|
|
394
419
|
{ id: 'claude.vertex', client: 'claude', provider: 'vertex', label: 'Google Vertex AI', auth: 'login', endpoint: 'Google Vertex AI', protocol: 'anthropic_messages', core: true, providerEnv: { CLAUDE_CODE_USE_VERTEX: '1' } },
|
|
395
420
|
{ id: 'claude.foundry', client: 'claude', provider: 'foundry', label: 'Microsoft Foundry', auth: 'login', endpoint: 'Microsoft Foundry', protocol: 'anthropic_messages', core: true, providerEnv: { CLAUDE_CODE_USE_FOUNDRY: '1' } },
|
|
@@ -403,7 +428,7 @@ const CATALOG = Object.freeze([
|
|
|
403
428
|
{ id: 'codex-vl.alibaba-token-plan', client: 'codex-vl', provider: 'alibaba-token-plan', label: 'Alibaba Token Plan Personal', auth: 'ALIBABA_CODE_API_KEY', endpoint: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', protocol: 'openai_responses', model: 'qwen3.8-max', models: ALIBABA_CODEX_MODELS, strictModels: true, core: true, notice: 'alibaba-token-plan' },
|
|
404
429
|
{ id: 'codex-vl.opencode-go', client: 'codex-vl', provider: 'opencode-go', label: 'OpenCode Go', auth: 'OPENCODE_API_KEY', endpoint: OPENCODE_GO_API_BASE, protocol: 'openai_responses', model: 'deepseek-v4-flash', models: OPENCODE_GO_RESPONSES_MODELS, strictModels: true, core: true },
|
|
405
430
|
{ id: 'codex-vl.openrouter', client: 'codex-vl', provider: 'openrouter', label: 'OpenRouter', auth: 'OPENROUTER_API_KEY', endpoint: 'https://openrouter.ai/api/v1', protocol: 'openai_responses', requiresModel: true, core: true, notice: 'codex-openrouter' },
|
|
406
|
-
{ id: 'codex-vl.ollama-cloud', client: 'codex-vl', provider: 'ollama-cloud', label: 'Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: '
|
|
431
|
+
{ id: 'codex-vl.ollama-cloud', client: 'codex-vl', provider: 'ollama-cloud', label: 'Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: 'deepseek-v4.1-flash', models: OLLAMA_CLOUD_MODELS, legacySecrets: true, core: true },
|
|
407
432
|
{ id: 'codex-vl.zai-a', client: 'codex-vl', provider: 'zai-a', label: 'Z.AI A (Responses nativo)', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/v1', protocol: 'openai_responses', model: 'glm-5.3', models: ZAI_RESPONSES_MODELS, strictModels: true, core: true },
|
|
408
433
|
{ id: 'codex-vl.zai-p', client: 'codex-vl', provider: 'zai-p', label: 'Z.AI P (Responses nativo)', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/v1', protocol: 'openai_responses', model: 'glm-5.3', models: ZAI_RESPONSES_MODELS, strictModels: true, core: true },
|
|
409
434
|
{ id: 'codex-vl.openai-api', client: 'codex-vl', provider: 'openai-api', label: 'OpenAI API', auth: 'OPENAI_API_KEY', endpoint: 'https://api.openai.com/v1', protocol: 'openai_responses', core: true },
|
|
@@ -414,7 +439,7 @@ const CATALOG = Object.freeze([
|
|
|
414
439
|
// Codex (upstream OpenAI, Responses).
|
|
415
440
|
{ id: 'codex.native', client: 'codex', provider: 'native', label: 'OpenAI / ChatGPT account', auth: 'login', endpoint: 'OpenAI account', protocol: 'openai_responses', default: true, core: true },
|
|
416
441
|
{ id: 'codex.openai-api', client: 'codex', provider: 'openai-api', label: 'OpenAI API', auth: 'OPENAI_API_KEY', endpoint: 'https://api.openai.com/v1', protocol: 'openai_responses', core: true },
|
|
417
|
-
{ id: 'codex.ollama-cloud', client: 'codex', provider: 'ollama-cloud', label: 'Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: '
|
|
442
|
+
{ id: 'codex.ollama-cloud', client: 'codex', provider: 'ollama-cloud', label: 'Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: 'deepseek-v4.1-flash', models: OLLAMA_CLOUD_MODELS, legacySecrets: true, core: true },
|
|
418
443
|
{ id: 'codex.ollama', client: 'codex', provider: 'ollama', label: 'Ollama local', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'ollama', core: true },
|
|
419
444
|
{ id: 'codex.lmstudio', client: 'codex', provider: 'lmstudio', label: 'LM Studio', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'lmstudio', core: true },
|
|
420
445
|
{ id: 'codex.custom', client: 'codex', provider: 'custom', label: 'Custom Responses endpoint', auth: 'dynamic', protocol: 'openai_responses', protocols: ['openai_responses'], custom: true, core: true },
|
|
@@ -1835,6 +1860,8 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
1835
1860
|
else if (spec.provider === 'ollama-cloud') {
|
|
1836
1861
|
env.OPENAI_API_KEY = cred.value;
|
|
1837
1862
|
args.push(...codexProviderArgs('ollama_cloud', 'Ollama Cloud', profile.endpoint, 'OPENAI_API_KEY'));
|
|
1863
|
+
// Ollama Responses rejects the built-in web_search tool before the turn starts.
|
|
1864
|
+
args.push('-c', 'web_search="disabled"');
|
|
1838
1865
|
args.push('-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000', '-c', `model_context_window=${ollamaContextFor(model) ?? 200000}`);
|
|
1839
1866
|
// Catalogo generato dagli id DICHIARATI DELL'ENGINE: senza entry il
|
|
1840
1867
|
// client cade sul descrittore fallback (272000 fisso, parallel tools
|
|
@@ -2011,6 +2038,28 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
2011
2038
|
// TUI interattivo nella cwd della cella.
|
|
2012
2039
|
if (model) args.push('--model', model);
|
|
2013
2040
|
}
|
|
2041
|
+
// The codex-vl TUI starts the app-server daemon itself, so this
|
|
2042
|
+
// launcher-owned switch must be in the child environment (not argv). A
|
|
2043
|
+
// definition-level value is an explicit user override; cfg.env is retained
|
|
2044
|
+
// as the runtime compatibility override for callers that do not carry a
|
|
2045
|
+
// parsed engine definition. Other managed clients never receive this key.
|
|
2046
|
+
if (spec.client === 'codex-vl') {
|
|
2047
|
+
const definitionEnv = engine && engine.env;
|
|
2048
|
+
const runtimeEnv = cfg && cfg.env;
|
|
2049
|
+
const hasDefinitionOverride = definitionEnv && Object.prototype.hasOwnProperty.call(
|
|
2050
|
+
definitionEnv, CODEX_APP_SERVER_IDENTITY_REQUIRED,
|
|
2051
|
+
);
|
|
2052
|
+
const hasRuntimeOverride = runtimeEnv && Object.prototype.hasOwnProperty.call(
|
|
2053
|
+
runtimeEnv, CODEX_APP_SERVER_IDENTITY_REQUIRED,
|
|
2054
|
+
);
|
|
2055
|
+
if (hasDefinitionOverride && typeof definitionEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED] === 'string') {
|
|
2056
|
+
env[CODEX_APP_SERVER_IDENTITY_REQUIRED] = definitionEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED];
|
|
2057
|
+
} else if (hasRuntimeOverride && typeof runtimeEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED] === 'string') {
|
|
2058
|
+
env[CODEX_APP_SERVER_IDENTITY_REQUIRED] = runtimeEnv[CODEX_APP_SERVER_IDENTITY_REQUIRED];
|
|
2059
|
+
} else {
|
|
2060
|
+
env[CODEX_APP_SERVER_IDENTITY_REQUIRED] = CODEX_APP_SERVER_IDENTITY_REQUIRED_DEFAULT;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2014
2063
|
// Prompt su argv (0.8.47): SOLO i client che non hanno un percorso classified
|
|
2015
2064
|
// delivery. kimi.native e claude.kimi-code usano promptMode 'send-keys' con
|
|
2016
2065
|
// deliverBootstrapPrompt (readiness classificata + at-most-once): il prompt
|