@mmmbuto/nexuscrew 0.9.20 → 0.9.21

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.
@@ -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
- const entry = { encoded: encodePayload(payload), expires: Date.now() + ttlMs, timer: null, lease: payload && payload.lease ? payload.lease : null };
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 = { startLeaseClient };
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 dalla sessione tmux dichiarata dal chiamante lo stesso
8
- // modello degli altri tool nc_*; il PROOF firmato dal verifier per-installazione
9
- // e' l'authorizer di refresh/recovery (PREMESSA 2b: cambia il modello di
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({ fleetP, readonly = () => false, log = () => {} }) {
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 dichiarata determina la cella. Se non risolve in una cella
40
- // valida la richiesta non ha soggetto: 400, senza cadere in un default.
41
- const cellOf = (req) => cellIdFromTmuxSession(req.body && req.body.session);
42
- const requireCell = (req, res) => {
43
- const cell = cellOf(req);
44
- if (!cell) {
45
- res.status(400).json({ error: 'sessione non valida: impossibile derivare la cella' });
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
- const cell = requireCell(req, res);
62
- if (!cell) return undefined;
63
- const out = lease.childRegister(cell);
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
- r.post('/refresh', smallJson, guard((lease, req, res) => {
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);
@@ -20,6 +20,10 @@ 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',
23
27
  ]);
24
28
  // Autorita' per OLLAMA_CONTEXT = IL CAMPO STRUTTURATO «Context» della scheda
25
29
  // canale (ollama.com/library, fetch 2026-08-27) — NON la descrizione, che
@@ -48,6 +52,9 @@ const OLLAMA_CONTEXT = Object.freeze({
48
52
  'glm-5.3-flash': 1000000,
49
53
  // glm-5.3: scheda «Context / 1M tokens» (2026-09-05).
50
54
  'glm-5.3': 1000000,
55
+ // kimi-k3: scheda «Context / 1M tokens», Input Text, Image (2026-09-06).
56
+ // DICHIARATO 2026-09-06 https://ollama.com/library/kimi-k3
57
+ 'kimi-k3': 1000000,
51
58
  });
52
59
  // Capacita' per modello OSSERVATE dalla scheda canale (ollama.com/library,
53
60
  // 2026-08-27). Assenza = default conservativo (comportamento odierno, nessuna
@@ -62,6 +69,10 @@ const OLLAMA_MODEL_CAPABILITIES = Object.freeze({
62
69
  // glm-5.3: scheda tools/thinking/cloud, Input Text (2026-09-05). PARALLEL
63
70
  // NON dichiarato: default conservativo false finche' non misurato su device.
64
71
  'glm-5.3': Object.freeze({ input: ['text'], reasoning: true, supportsParallelToolCalls: false }),
72
+ // kimi-k3: scheda vision/tools/thinking/cloud, Input Text, Image (2026-09-06).
73
+ // PARALLEL NON dichiarato: default conservativo false finche' non misurato su device.
74
+ // DICHIARATO 2026-09-06 https://ollama.com/library/kimi-k3
75
+ 'kimi-k3': Object.freeze({ input: ['text', 'image'], reasoning: true, supportsParallelToolCalls: false }),
65
76
  });
66
77
  // Descrittori dell'engine ollama-cloud per la generazione del catalogo client:
67
78
  // stessa forma dei descrittori custom/PI (id, label, contextWindow, input,
@@ -1835,6 +1846,8 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
1835
1846
  else if (spec.provider === 'ollama-cloud') {
1836
1847
  env.OPENAI_API_KEY = cred.value;
1837
1848
  args.push(...codexProviderArgs('ollama_cloud', 'Ollama Cloud', profile.endpoint, 'OPENAI_API_KEY'));
1849
+ // Ollama Responses rejects the built-in web_search tool before the turn starts.
1850
+ args.push('-c', 'web_search="disabled"');
1838
1851
  args.push('-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000', '-c', `model_context_window=${ollamaContextFor(model) ?? 200000}`);
1839
1852
  // Catalogo generato dagli id DICHIARATI DELL'ENGINE: senza entry il
1840
1853
  // client cade sul descrittore fallback (272000 fisso, parallel tools
@@ -13,6 +13,7 @@
13
13
  // policy, la redazione e il testo degli errori sono INVARIATI rispetto a
14
14
  // builtin.js prima dell'estrazione.
15
15
  const path = require('node:path');
16
+ const crypto = require('node:crypto');
16
17
  const {
17
18
  loadDefinitions, validateCommandTrust, resolveCwd,
18
19
  } = require('./definitions.js');
@@ -41,7 +42,11 @@ function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) ||
41
42
  // e' tornato unavailable su garbage).
42
43
  // ---------------------------------------------------------------------------
43
44
  function createBuiltinRuntime(ctx) {
44
- const { cfg, home, defsPath, tmuxBin, readonly, launchBroker, leaseManager, boot, ensureProtection } = ctx;
45
+ const {
46
+ cfg, home, defsPath, tmuxBin, readonly, launchBroker, leaseManager, boot, ensureProtection,
47
+ identityAuthority = null, identityMode = 'legacy', identityOwnerInstanceId = null,
48
+ } = ctx;
49
+ const identityDaemonBootId = cfg.identityDaemonBootId || crypto.randomBytes(16).toString('hex');
45
50
  let cache = { at: 0, defs: boot, sessions: new Set() };
46
51
 
47
52
  function reloadDefs() {
@@ -232,6 +237,23 @@ function createBuiltinRuntime(ctx) {
232
237
  if (leaseManager) {
233
238
  try { leaseInfo = await leaseManager.track(cell.id); } catch (_) { leaseInfo = null; }
234
239
  }
240
+ const identity = identityMode === 'authority' && identityAuthority ? {
241
+ audience: cfg.identityAudience || cfg.fleet?.identity?.audience || 'nexuscrew-lease',
242
+ daemonBootId: identityDaemonBootId,
243
+ connectionId: `${cell.id}-${crypto.randomBytes(8).toString('hex')}`,
244
+ subject: {
245
+ ownerInstanceId: identityOwnerInstanceId,
246
+ cellId: cell.id,
247
+ incarnationId: crypto.randomBytes(16).toString('hex'),
248
+ launchEpoch: leaseInfo ? leaseInfo.launchEpoch : crypto.randomBytes(8).toString('hex'),
249
+ },
250
+ } : undefined;
251
+ if (identity && leaseManager && typeof leaseManager.setLaunchSubject === 'function'
252
+ && leaseManager.setLaunchSubject(cell.id, identity.subject) !== true) {
253
+ throw httpError(500, 'identity launch subject non registrabile', null, {
254
+ phase: 'launch-broker', code: 'IDENTITY_SUBJECT_UNAVAILABLE',
255
+ });
256
+ }
235
257
  ticket = await launchBroker.issue({
236
258
  command: child.command,
237
259
  args: child.args,
@@ -241,6 +263,7 @@ function createBuiltinRuntime(ctx) {
241
263
  MCP_DEVICE: `${String(cell.id).toLowerCase()}-agent`,
242
264
  NEXUSCREW_MCP_SESSION: cell.tmuxSession,
243
265
  },
266
+ ...(identity ? { identity } : {}),
244
267
  ...(leaseInfo ? { lease: { cellId: cell.id, launchEpoch: leaseInfo.launchEpoch, stablePath: leaseInfo.stablePath } } : {}),
245
268
  supervise: {
246
269
  enabled: !launchEngine.shellOneShot,