@mmmbuto/nexuscrew 0.9.24 → 0.9.25

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.
@@ -15,6 +15,7 @@ const { readTokenSafe } = require('../auth/token.js');
15
15
  const { cellIdFromTmuxSession, tmuxSessionForCell } = require('../fleet/definitions.js');
16
16
  const {
17
17
  identityContextError, IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
18
+ IDENTITY_CONTEXT_VERIFIED_ENV_INVALID,
18
19
  } = require('./identity-schema.js');
19
20
 
20
21
  const INTROSPECT_TIMEOUT_MS = 8000;
@@ -27,6 +28,40 @@ function identityChannelPath({ tokenPath, session }) {
27
28
  return path.join(identityChannelDir(tokenPath), `${session}.json`);
28
29
  }
29
30
 
31
+ // i metadati verified consegnati dal launcher VL al figlio MCP
32
+ // (solo su sessione bound). La fonte e' PRESENTE se la version key esiste:
33
+ // presence parziale o valori incompleti = fonte invalida (fail-closed), mai
34
+ // assenza. Nessun valore torna al chiamante: solo coerenza booleana.
35
+ const VERIFIED_ENV_VERSION_KEY = 'NEXUSCREW_VERIFIED_ENV_VERSION';
36
+ const VERIFIED_ENV_FIELDS = Object.freeze({
37
+ ownerInstanceId: 'NEXUSCREW_VERIFIED_OWNER_INSTANCE_ID',
38
+ cellId: 'NEXUSCREW_VERIFIED_CELL_ID',
39
+ incarnationId: 'NEXUSCREW_VERIFIED_INCARNATION_ID',
40
+ bindingId: 'NEXUSCREW_VERIFIED_BINDING_ID',
41
+ origin: 'NEXUSCREW_VERIFIED_ORIGIN',
42
+ threadId: 'NEXUSCREW_VERIFIED_THREAD_ID',
43
+ });
44
+
45
+ function readVerifiedEnv(env) {
46
+ const raw = env && env[VERIFIED_ENV_VERSION_KEY];
47
+ if (typeof raw !== 'string') return null; // del tutto assente -> percorso legacy
48
+ const fields = {};
49
+ for (const [key, name] of Object.entries(VERIFIED_ENV_FIELDS)) {
50
+ const value = env[name];
51
+ fields[key] = typeof value === 'string' && value.trim() ? value.trim() : null;
52
+ }
53
+ return { present: true, version: raw.trim(), ...fields };
54
+ }
55
+
56
+ function verifiedEnvFault(verified, { localInstanceId } = {}) {
57
+ if (verified.version !== '1') return 'version';
58
+ if (!verified.ownerInstanceId) return 'owner';
59
+ if (localInstanceId && verified.ownerInstanceId !== localInstanceId) return 'owner';
60
+ if (!verified.cellId) return 'cell';
61
+ if (!verified.incarnationId) return 'incarnation';
62
+ return null;
63
+ }
64
+
30
65
  function readIdentityChannel(filePath) {
31
66
  let raw;
32
67
  try { raw = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (_) { return null; }
@@ -75,13 +110,100 @@ function createMcpIdentityProvider({ config, env = process.env, fetchImpl = fetc
75
110
  const cfg = config || require('../config.js').loadConfig();
76
111
  const tokenPath = cfg && cfg.tokenPath;
77
112
  const port = cfg && cfg.port;
78
- const session = env && env.NEXUSCREW_MCP_SESSION;
79
- if (!tokenPath || !Number.isSafeInteger(port) || !session) return null;
80
- const channelPath = identityChannelPath({ tokenPath, session });
81
- if (!readIdentityChannel(channelPath)) return null;
113
+ const verified = readVerifiedEnv(env);
114
+ const legacySession = env && env.NEXUSCREW_MCP_SESSION;
115
+
116
+ // la fonte verified ha PRECEDENZA ASSOLUTA. Se i metadati verified
117
+ // sono presenti il provider esiste SEMPRE (anche con metadati incompleti):
118
+ // la validazione completa e' per-richiesta e fallisce closed con
119
+ // VERIFIED_ENV_INVALID. Mai un ritorno a null che riaprirebbe il percorso
120
+ // legacy accanto a metadati verified di provenienza non dimostrata.
121
+ let mode = null;
122
+ let channelSession = null;
123
+ if (verified && verified.present) {
124
+ mode = 'verified-env';
125
+ // (c) cellId -> tmuxSession con la stessa mappatura deterministica del
126
+ // launcher (definitions.tmuxSessionForCell): il canale identity di quella
127
+ // sessione e' l'unico archivo condiviso della cella su questo nodo.
128
+ channelSession = verified.cellId ? tmuxSessionForCell(verified.cellId) : null;
129
+ } else if (legacySession) {
130
+ mode = 'legacy-session';
131
+ channelSession = legacySession;
132
+ }
133
+ if (!tokenPath || !Number.isSafeInteger(port) || !mode) return null;
134
+ if (mode === 'legacy-session' && !channelSession) return null;
135
+ // In verified mode un canale sessione non derivabile NON degrada a null:
136
+ // il provider resta in piedi e fallisce closed per-richiesta.
137
+ const channelPath = identityChannelPath({ tokenPath, session: channelSession || '__unverified__' });
138
+ if (mode === 'legacy-session' && !readIdentityChannel(channelPath)) return null;
82
139
  const baseUrl = `http://127.0.0.1:${port}`;
83
140
 
84
- async function introspectChannel() {
141
+ // l'ownerInstanceId dichiarato deve coincidere con l'instanceId
142
+ // locale del nodo. Fonte: config.injectabile (test) oppure nodes.json.
143
+ function localInstanceId() {
144
+ if (typeof cfg.identityOwnerInstanceId === 'string' && cfg.identityOwnerInstanceId) {
145
+ return cfg.identityOwnerInstanceId;
146
+ }
147
+ try {
148
+ const nodesStore = require('../nodes/store.js');
149
+ const nodesPath = cfg.nodesPath
150
+ || nodesStore.defaultNodesPath(cfg.home || require('node:os').homedir());
151
+ const store = nodesStore.loadStore(nodesPath);
152
+ return (store && store.nodeId) || null;
153
+ } catch (_) {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ function verifiedFault() {
159
+ const fault = verifiedEnvFault(verified, { localInstanceId: localInstanceId() });
160
+ if (fault) return fault;
161
+ if (!channelSession) return 'cell';
162
+ if (!readIdentityChannel(channelPath)) return 'channel';
163
+ return null;
164
+ }
165
+
166
+ async function introspectVerified() {
167
+ const fault = verifiedFault();
168
+ if (fault) {
169
+ throw identityContextError(IDENTITY_CONTEXT_VERIFIED_ENV_INVALID,
170
+ `NEXUSCREW_MCP_IDENTITY_VERIFIED_ENV_INVALID: verified env incoerente (${fault})`);
171
+ }
172
+ let response;
173
+ try {
174
+ const token = readTokenSafe(tokenPath);
175
+ response = await fetchImpl(`${baseUrl}/api/lease/introspect`, {
176
+ method: 'POST',
177
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
178
+ body: JSON.stringify({ proof: readIdentityChannel(channelPath).proof }),
179
+ signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS),
180
+ });
181
+ } catch (_) {
182
+ throw identityContextError(IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
183
+ 'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE: authority non raggiungibile');
184
+ }
185
+ if (response.status >= 500) {
186
+ throw identityContextError(IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
187
+ 'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE: authority non raggiungibile');
188
+ }
189
+ if (!response.ok) {
190
+ throw identityContextError(IDENTITY_CONTEXT_VERIFIED_ENV_INVALID,
191
+ 'NEXUSCREW_MCP_IDENTITY_VERIFIED_ENV_INVALID: introspezione rifiutata dall\'authority');
192
+ }
193
+ const out = await response.json().catch(() => null);
194
+ const coherent = out && out.status === 'live' && out.identityMode === 'authority'
195
+ && out.cellId === verified.cellId && out.tmuxSession === channelSession
196
+ && typeof out.instanceId === 'string' && out.instanceId === verified.ownerInstanceId
197
+ && Number.isSafeInteger(Number(out.expiresAt)) && Number(out.expiresAt) > now()
198
+ && typeof out.incarnationId === 'string' && out.incarnationId === verified.incarnationId;
199
+ if (!coherent) {
200
+ throw identityContextError(IDENTITY_CONTEXT_VERIFIED_ENV_INVALID,
201
+ 'NEXUSCREW_MCP_IDENTITY_VERIFIED_ENV_INVALID: introspezione non coerente coi metadati verified');
202
+ }
203
+ return { context: sharedContextFromIntrospection({ ...out, expiresAt: Number(out.expiresAt) }, now()), proof: readIdentityChannel(channelPath).proof };
204
+ }
205
+
206
+ async function introspectLegacy() {
85
207
  const channel = readIdentityChannel(channelPath);
86
208
  if (!channel) return { verified: false, mode: 'shared' };
87
209
  let response;
@@ -113,11 +235,16 @@ function createMcpIdentityProvider({ config, env = process.env, fetchImpl = fetc
113
235
  return { context: sharedContextFromIntrospection({ ...out, expiresAt: Number(out.expiresAt) }, now()), proof: channel.proof };
114
236
  }
115
237
 
238
+ const introspectChannel = mode === 'verified-env' ? introspectVerified : introspectLegacy;
239
+
116
240
  async function provider({ tool } = {}) {
117
241
  void tool; // il canale lease è per-connessione cella, non per-tool
118
242
  const out = await introspectChannel();
119
243
  return out.context ? out.context : out;
120
244
  }
245
+ // il server espone `source: 'verified-env'` in nc_identity quando la
246
+ // fonte dei metadati e' il launcher verified e non la sessione legacy.
247
+ provider.identitySource = mode === 'verified-env' ? 'verified-env' : 'online';
121
248
  provider.currentProof = () => {
122
249
  const channel = readIdentityChannel(channelPath);
123
250
  return channel ? channel.proof : null;
@@ -129,4 +256,5 @@ module.exports = {
129
256
  createMcpIdentityProvider,
130
257
  persistIdentityChannel,
131
258
  identityChannelPath,
259
+ readVerifiedEnv,
132
260
  };
@@ -9,6 +9,7 @@ const IDENTITY_CONTEXT_MISSING = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_MISSING';
9
9
  const IDENTITY_CONTEXT_UNVERIFIED = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_UNVERIFIED';
10
10
  const IDENTITY_CONTEXT_FROM_MISMATCH = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_FROM_MISMATCH';
11
11
  const IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE = 'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE';
12
+ const IDENTITY_CONTEXT_VERIFIED_ENV_INVALID = 'NEXUSCREW_MCP_IDENTITY_VERIFIED_ENV_INVALID';
12
13
 
13
14
  function identityContextError(code, message) {
14
15
  const error = new Error(message);
@@ -163,6 +164,7 @@ module.exports = {
163
164
  IDENTITY_CONTEXT_UNVERIFIED,
164
165
  IDENTITY_CONTEXT_FROM_MISMATCH,
165
166
  IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
167
+ IDENTITY_CONTEXT_VERIFIED_ENV_INVALID,
166
168
  identityContextError,
167
169
  normalizeIdentityContext,
168
170
  };
package/lib/mcp/server.js CHANGED
@@ -32,6 +32,7 @@ const { TOOLS, IDENTITY_CODE, IDENTITY_REMEDIATION } = require('./tools.js');
32
32
  const cells = require('./cells.js');
33
33
  const {
34
34
  normalizeIdentityContext, IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
35
+ IDENTITY_CONTEXT_VERIFIED_ENV_INVALID,
35
36
  } = require('./identity-schema.js');
36
37
  const { persistIdentityChannel } = require('./identity-provider.js');
37
38
 
@@ -99,18 +100,55 @@ function companionInstructions() {
99
100
  // senza cambiare la precedenza e senza esporre valori/segreti. `resolveSession`
100
101
  // resta il wrapper pubblico Promise<string|null> invariato (compatibilita').
101
102
  const IDENTITY_REQUIRED_ENV_VARS = Object.freeze(['TMUX', 'TMUX_PANE', 'NEXUSCREW_MCP_SESSION']);
103
+ // i nomi dei metadati verified consegnati dal launcher VL (solo su
104
+ // sessione bound). Presence only: mai i valori.
105
+ const IDENTITY_VERIFIED_ENV_VARS = Object.freeze([
106
+ 'NEXUSCREW_VERIFIED_ENV_VERSION',
107
+ 'NEXUSCREW_VERIFIED_OWNER_INSTANCE_ID',
108
+ 'NEXUSCREW_VERIFIED_CELL_ID',
109
+ 'NEXUSCREW_VERIFIED_INCARNATION_ID',
110
+ 'NEXUSCREW_VERIFIED_BINDING_ID',
111
+ 'NEXUSCREW_VERIFIED_ORIGIN',
112
+ 'NEXUSCREW_VERIFIED_THREAD_ID',
113
+ ]);
114
+ const IDENTITY_ALL_REQUIRED_ENV_VARS = Object.freeze([
115
+ ...IDENTITY_REQUIRED_ENV_VARS, ...IDENTITY_VERIFIED_ENV_VARS,
116
+ ]);
102
117
 
103
118
  function envPresenceOf(env) {
104
- return {
119
+ const presence = {
105
120
  TMUX: !!env.TMUX,
106
121
  TMUX_PANE: !!env.TMUX_PANE,
107
122
  NEXUSCREW_MCP_SESSION: !!(typeof env.NEXUSCREW_MCP_SESSION === 'string' && env.NEXUSCREW_MCP_SESSION.trim()),
108
123
  };
124
+ for (const name of IDENTITY_VERIFIED_ENV_VARS) {
125
+ presence[name] = !!(typeof env[name] === 'string' && env[name].trim());
126
+ }
127
+ return presence;
128
+ }
129
+
130
+ // la fonte verified esiste se ANY dei nomi verified e' presente.
131
+ // Presence parziale = fonte presente ma invalida (fail-closed), non assenza.
132
+ function verifiedEnvPresent(envPresence) {
133
+ return IDENTITY_VERIFIED_ENV_VARS.some((name) => envPresence[name] === true);
109
134
  }
110
135
 
111
136
  function resolveIdentity({ env, tmuxBin, execFileImpl }) {
112
137
  const e = env || {};
113
138
  const envPresence = envPresenceOf(e);
139
+ // se una qualunque variabile verified e' presente, il percorso
140
+ // legacy e' VETATO (non e' un fallback, e' un percorso alternativo usato
141
+ // solo in assenza totale della verified). La verifica completa (introspezione
142
+ // live/coerente) vive nel provider online; qui il chiamante senza provider
143
+ // riceve comunque un fail-closed nominato invece di un'identita' legacy
144
+ // fabbricata accanto a metadati verified di cui non si puo' garantire la
145
+ // coerenza.
146
+ if (verifiedEnvPresent(envPresence)) {
147
+ return {
148
+ session: null, source: 'verified-env', code: IDENTITY_CODE.VERIFIED_ENV_INVALID,
149
+ envPresence, requiredEnvVars: IDENTITY_ALL_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
150
+ };
151
+ }
114
152
  const fallbackRaw = e.NEXUSCREW_MCP_SESSION;
115
153
  const fallbackPresent = typeof fallbackRaw === 'string' && fallbackRaw.trim().length > 0;
116
154
  const tmuxPresent = !!e.TMUX;
@@ -130,24 +168,24 @@ function resolveIdentity({ env, tmuxBin, execFileImpl }) {
130
168
 
131
169
  const ok = (session, source) => ({
132
170
  session, source, code: IDENTITY_CODE.OK,
133
- envPresence, requiredEnvVars: IDENTITY_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
171
+ envPresence, requiredEnvVars: IDENTITY_ALL_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
134
172
  });
135
173
  const missing = () => ({
136
174
  session: null, source: 'missing', code: codeWhenMissing(),
137
- envPresence, requiredEnvVars: IDENTITY_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
175
+ envPresence, requiredEnvVars: IDENTITY_ALL_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
138
176
  });
139
177
  // P0: pane stantio o non verificabile -> NON attribuire. source 'stale-pane'
140
178
  // nomina il problema; code STALE_PANE (tools.js).
141
179
  const stalePane = () => ({
142
180
  session: null, source: 'stale-pane', code: IDENTITY_CODE.STALE_PANE,
143
- envPresence, requiredEnvVars: IDENTITY_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
181
+ envPresence, requiredEnvVars: IDENTITY_ALL_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
144
182
  });
145
183
  // P0/R2: tmux e fallback env dicono sessioni DIVERSE entrambe valide:
146
184
  // identità ambigua -> NON attribuire (nemmeno il fallback: è parte del
147
185
  // conflitto). Il code nomina il mismatch, che INVALID non direbbe.
148
186
  const sessionMismatch = () => ({
149
187
  session: null, source: 'session-mismatch', code: IDENTITY_CODE.SESSION_MISMATCH,
150
- envPresence, requiredEnvVars: IDENTITY_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
188
+ envPresence, requiredEnvVars: IDENTITY_ALL_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
151
189
  });
152
190
 
153
191
  return new Promise((resolve) => {
@@ -273,7 +311,8 @@ function createMcpServer(opts = {}) {
273
311
  try {
274
312
  raw = await identityContextProvider({ tool });
275
313
  } catch (e) {
276
- if (e && e.code === IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE) throw e;
314
+ if (e && (e.code === IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE
315
+ || e.code === IDENTITY_CONTEXT_VERIFIED_ENV_INVALID)) throw e;
277
316
  throw identityContextError(IDENTITY_CONTEXT_MISSING,
278
317
  'contesto identita online non disponibile: binding condiviso rifiutato');
279
318
  }
@@ -284,7 +323,7 @@ function createMcpServer(opts = {}) {
284
323
  if (!identityContextProvider) return localIdentity();
285
324
  return identityContext({ tool, sharedRequired: true }).then((context) => ({
286
325
  session: context.tmuxSession,
287
- source: 'online',
326
+ source: identityContextProvider.identitySource === 'verified-env' ? 'verified-env' : 'online',
288
327
  code: IDENTITY_CODE.OK,
289
328
  envPresence: envPresenceOf(env),
290
329
  requiredEnvVars: IDENTITY_REQUIRED_ENV_VARS,
@@ -442,9 +481,11 @@ function createMcpServer(opts = {}) {
442
481
  ? identityContextProvider.currentProof() : null;
443
482
  const sharedIdentity = sharedContext ? Object.freeze({
444
483
  session: sharedContext.tmuxSession,
445
- source: 'online', code: IDENTITY_CODE.OK,
484
+ source: identityContextProvider.identitySource === 'verified-env'
485
+ ? 'verified-env' : 'online',
486
+ code: IDENTITY_CODE.OK,
446
487
  envPresence: envPresenceOf(env),
447
- requiredEnvVars: IDENTITY_REQUIRED_ENV_VARS,
488
+ requiredEnvVars: IDENTITY_ALL_REQUIRED_ENV_VARS,
448
489
  remediation: IDENTITY_REMEDIATION,
449
490
  context: sharedContext,
450
491
  ...(sharedProof ? { proof: sharedProof } : {}),
package/lib/mcp/tools.js CHANGED
@@ -44,15 +44,26 @@ const IDENTITY_CODE = Object.freeze({
44
44
  // P0/R2: tmux e NEXUSCREW_MCP_SESSION sono entrambi validi ma dicono sessioni
45
45
  // diverse: identità ambigua, non si attribuisce nessuna delle due.
46
46
  SESSION_MISMATCH: 'NEXUSCREW_MCP_IDENTITY_SESSION_MISMATCH',
47
+ // una fonte verified (NEXUSCREW_VERIFIED_*) presente ma non
48
+ // coerente (versione, owner, cella, canale, introspezione) e' fail-closed:
49
+ // MAI fallback alle variabili legacy quando la fonte verified esiste.
50
+ VERIFIED_ENV_INVALID: 'NEXUSCREW_MCP_IDENTITY_VERIFIED_ENV_INVALID',
47
51
  });
48
52
 
49
53
  // Remediation senza segreti/valori: nomi soltanto, compatibile con
50
54
  // `codex-vl mcp add --env-var` (allowlist di nomi, nessun valore persistito).
55
+ // la remediation NON consiglia piu' di allowlistare TMUX/TMUX_PANE/
56
+ // NEXUSCREW_MCP_SESSION in env_vars: sul percorso shared verified quel permesso
57
+ // fa fallire lo spawn (rmcp-client utils: nomi riservati rifiutati). Le strade
58
+ // sono due: sessione verified (codex-vl >= 0.154.0-vl.3 + NexusCrew >= 0.9.25
59
+ // con authority mode provisionato) oppure sessione legacy con le tre variabili
60
+ // ereditate dal client MCP.
51
61
  const IDENTITY_REMEDIATION =
52
- 'Nei client che ripuliscono l\'ambiente, allowlista i nomi delle variabili di identita nel server MCP stdio '
53
- + '(codex-vl mcp add ... --env-var NEXUSCREW_MCP_SESSION --env-var TMUX --env-var TMUX_PANE) '
54
- + 'oppure assicurati che il client MCP inoltri il contesto tmux al processo child: nessun valore '
55
- + 'viene copiato nella CLI o nel file di configurazione.';
62
+ 'Due percorsi validi: (1) sessione verified — serve codex-vl >= 0.154.0-vl.3 e NexusCrew >= 0.9.25 '
63
+ + 'con authority mode provisionato (nexuscrew identity provision): il launcher consegna i metadati '
64
+ + 'NEXUSCREW_VERIFIED_* al figlio MCP, non serve allowlistare nulla; (2) sessione legacy — il client '
65
+ + 'MCP deve ereditare TMUX/TMUX_PANE/NEXUSCREW_MCP_SESSION (niente allowlist in env_vars del server '
66
+ + 'MCP: con codex-vl >= 0.154.0-vl.3 i nomi sono riservati e lo spawn fallisce).';
56
67
 
57
68
  // Perche' una ricerca fallita non e' una sola cosa.
58
69
  //
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.24",
4
- "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
3
+ "version": "0.9.25",
4
+ "description": "Faithful browser tmux client \u2014 attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {
7
7
  "nexuscrew": "bin/nexuscrew.js"