@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.
@@ -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,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: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true, core: true },
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: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true, core: true },
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: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true, core: true },
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