@mmmbuto/nexuscrew 0.9.40 → 0.9.42
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 +21 -1
- package/bin/nc-activity-hook.js +55 -0
- package/frontend/dist/assets/index-CPO7bv6-.css +32 -0
- package/frontend/dist/assets/index-DpFbq_tt.js +169 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/files/activity.js +407 -0
- package/lib/fleet/ai-desktop.js +123 -0
- package/lib/fleet/cell-exec.js +152 -4
- package/lib/fleet/codex-hooks.js +253 -0
- package/lib/fleet/managed.js +274 -30
- package/lib/fleet/mcp-cache-readiness.js +154 -0
- package/lib/fleet/prompt-delivery.js +65 -12
- package/lib/fleet/runtime.js +158 -10
- package/lib/notify/ask-relay.js +104 -19
- package/lib/proxy/panel-proxy.js +16 -2
- package/lib/server.js +41 -1
- package/lib/settings/routes.js +55 -3
- package/lib/tmux/list.js +19 -1
- package/lib/tmux/supervisor-pane.js +81 -0
- package/package.json +4 -3
- package/frontend/dist/assets/index-BJBurY16.js +0 -169
- package/frontend/dist/assets/index-Csz8VaFz.css +0 -32
package/lib/fleet/cell-exec.js
CHANGED
|
@@ -8,6 +8,7 @@ const net = require('node:net');
|
|
|
8
8
|
const path = require('node:path');
|
|
9
9
|
const { spawn } = require('node:child_process');
|
|
10
10
|
const { MAX_PAYLOAD } = require('./launch-broker.js');
|
|
11
|
+
const { scriviStato, scriviGenerazione, AVVIO, USCITA } = require('../files/activity.js');
|
|
11
12
|
const {
|
|
12
13
|
validDaemonChallenge, validVerifyExpected, identityErrorCode,
|
|
13
14
|
IDENTITY_FRAME_LIMIT, IDENTITY_TIMEOUT_MS,
|
|
@@ -71,17 +72,37 @@ function promptCharsOk(prompt) {
|
|
|
71
72
|
return true;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
// Gate readiness MCP nel payload. I nomi attesi sono quelli del file
|
|
76
|
+
// cell-mcp materializzato (qualsiasi stringa safe <=64: la charset stretta
|
|
77
|
+
// vale solo sui valori che finiscono nell'opzione pane @nc_mcp_list).
|
|
78
|
+
function validMcpReadiness(value) {
|
|
79
|
+
if (value === undefined) return true;
|
|
80
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
81
|
+
if (Object.keys(value).some((key) => !['cwd', 'expectedServers', 'budgetMs', 'pollMs', 'cacheRoot'].includes(key))) return false;
|
|
82
|
+
return typeof value.cwd === 'string' && value.cwd.length > 0 && value.cwd.length <= 4096
|
|
83
|
+
&& !/[\0\r\n]/.test(value.cwd)
|
|
84
|
+
&& Array.isArray(value.expectedServers) && value.expectedServers.length > 0
|
|
85
|
+
&& value.expectedServers.length <= 64
|
|
86
|
+
&& value.expectedServers.every((n) => typeof n === 'string' && n.length >= 1
|
|
87
|
+
&& n.length <= 64 && !/[\0\r\n]/.test(n))
|
|
88
|
+
&& (value.budgetMs === undefined || validInteger(value.budgetMs, 0, 120000))
|
|
89
|
+
&& (value.pollMs === undefined || validInteger(value.pollMs, 50, 5000))
|
|
90
|
+
&& (value.cacheRoot === undefined || (typeof value.cacheRoot === 'string'
|
|
91
|
+
&& value.cacheRoot.length > 0 && value.cacheRoot.length <= 4096 && !/[\0\r\n]/.test(value.cacheRoot)));
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
function validRestartPrompt(value) {
|
|
75
95
|
if (value === undefined) return true;
|
|
76
96
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
77
|
-
if (Object.keys(value).some((key) => !['tmuxBin', 'tmuxSession', 'prompt', 'readyMs', 'client', 'readyWaitMs'].includes(key))) return false;
|
|
97
|
+
if (Object.keys(value).some((key) => !['tmuxBin', 'tmuxSession', 'prompt', 'readyMs', 'client', 'readyWaitMs', 'mcpReadiness'].includes(key))) return false;
|
|
78
98
|
return typeof value.tmuxBin === 'string' && value.tmuxBin.length > 0 && value.tmuxBin.length <= 4096
|
|
79
99
|
&& !/[\0\r\n]/.test(value.tmuxBin)
|
|
80
100
|
&& typeof value.tmuxSession === 'string' && /^[\w.@%:+-]{1,128}$/.test(value.tmuxSession)
|
|
81
101
|
&& promptCharsOk(value.prompt)
|
|
82
102
|
&& (value.readyMs === undefined || validInteger(value.readyMs, 0, 30000))
|
|
83
103
|
&& (value.client === undefined || value.client === '' || value.client === 'kimi' || value.client === 'claude')
|
|
84
|
-
&& (value.readyWaitMs === undefined || validInteger(value.readyWaitMs, 0, 120000))
|
|
104
|
+
&& (value.readyWaitMs === undefined || validInteger(value.readyWaitMs, 0, 120000))
|
|
105
|
+
&& (value.mcpReadiness === undefined || validMcpReadiness(value.mcpReadiness));
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
function validIdentity(value) {
|
|
@@ -109,9 +130,24 @@ function validLease(value) {
|
|
|
109
130
|
&& typeof value.stablePath === 'string' && value.stablePath.length > 0 && value.stablePath.length <= 4096;
|
|
110
131
|
}
|
|
111
132
|
|
|
133
|
+
// Canale di attivita' della cella: dove il supervisore deve dichiarare l'uscita
|
|
134
|
+
// del client, e con quale generazione. Presente SOLO quando il launcher ha
|
|
135
|
+
// davvero iniettato gli hook (managed.js pubblica la generazione prima di
|
|
136
|
+
// costruire gli argv): senza canale non c'e' niente da invalidare, e un payload
|
|
137
|
+
// senza il campo conserva il comportamento di prima.
|
|
138
|
+
function validActivity(value) {
|
|
139
|
+
if (value === undefined) return true;
|
|
140
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
141
|
+
if (Object.keys(value).some((key) => !['dir', 'generation'].includes(key))) return false;
|
|
142
|
+
return typeof value.dir === 'string' && value.dir.length > 0 && value.dir.length <= 4096
|
|
143
|
+
&& !/[\0\r\n]/.test(value.dir)
|
|
144
|
+
&& typeof value.generation === 'string' && value.generation.length > 0 && value.generation.length <= 128
|
|
145
|
+
&& !/[\0\r\n]/.test(value.generation);
|
|
146
|
+
}
|
|
147
|
+
|
|
112
148
|
function validPayload(payload) {
|
|
113
149
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
|
|
114
|
-
if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease', 'identity', 'identityChannel'].includes(key))) return false;
|
|
150
|
+
if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease', 'identity', 'identityChannel', 'activity'].includes(key))) return false;
|
|
115
151
|
if (typeof payload.command !== 'string' || !payload.command || !Array.isArray(payload.args)) return false;
|
|
116
152
|
if (!payload.env || typeof payload.env !== 'object' || Array.isArray(payload.env)) return false;
|
|
117
153
|
// Il launcher decide se il canale identita' esiste (managed.js) e lo dichiara
|
|
@@ -124,6 +160,7 @@ function validPayload(payload) {
|
|
|
124
160
|
&& validSupervise(payload.supervise)
|
|
125
161
|
&& validRestartPrompt(payload.restartPrompt)
|
|
126
162
|
&& validLease(payload.lease)
|
|
163
|
+
&& validActivity(payload.activity)
|
|
127
164
|
&& validIdentity(payload.identity);
|
|
128
165
|
}
|
|
129
166
|
|
|
@@ -428,10 +465,36 @@ function waitChild(child) {
|
|
|
428
465
|
// post-paste incerto (delivery-unknown / staged-not-submitted) — il main loop
|
|
429
466
|
// NON auto-restarta in quel caso (leftover bytes potenzialmente residui nel PTY del
|
|
430
467
|
// pane riusato; fermo bounded + restart operatore).
|
|
468
|
+
// Gate readiness MCP per le claude.* (config.mcpReadiness dal payload).
|
|
469
|
+
// La deadline e' COMPOSTA sullo stesso orologio del launch (notBeforeMs =
|
|
470
|
+
// avvio della GENERAZIONE corrente, mai handshake di una generazione
|
|
471
|
+
// precedente; budgetMs dal tetto approvato) e la cancellazione ferma il polling
|
|
472
|
+
// prima del respawn (isCancelled valutato ad ogni poll dalla deliver).
|
|
473
|
+
function buildMcpWait(config, seams) {
|
|
474
|
+
const mr = config && config.mcpReadiness;
|
|
475
|
+
if (!mr || !Array.isArray(mr.expectedServers) || mr.expectedServers.length === 0) return null;
|
|
476
|
+
const now = seams.nowImpl || Date.now;
|
|
477
|
+
const startedAt = now();
|
|
478
|
+
const budget = Math.max(0, Number(mr.budgetMs) || 20000);
|
|
479
|
+
const waitImpl = seams.waitMcpReadiness || require('./mcp-cache-readiness.js').waitMcpReadiness;
|
|
480
|
+
return async ({ isCancelled }) => waitImpl({
|
|
481
|
+
params: { cacheRoot: mr.cacheRoot, cwd: mr.cwd, expectedServers: mr.expectedServers, notBeforeMs: startedAt },
|
|
482
|
+
deadlineMs: startedAt + budget,
|
|
483
|
+
pollMs: mr.pollMs || 500,
|
|
484
|
+
sleepImpl: seams.sleepImpl,
|
|
485
|
+
nowImpl: seams.nowImpl,
|
|
486
|
+
isCancelled,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const MCP_DELIVERY_STATES = ['ready', 'degraded'];
|
|
491
|
+
const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
492
|
+
|
|
431
493
|
function startGenerationPrompt(config, generation, childState, seams = {}) {
|
|
432
494
|
if (!config) return null;
|
|
433
495
|
const classified = config.client === 'kimi' || config.client === 'claude';
|
|
434
496
|
if (!classified && generation === 0) return null; // legacy: gen0 resta al runtime
|
|
497
|
+
const mcpWait = buildMcpWait(config, seams);
|
|
435
498
|
const setTimer = seams.setTimeout || setTimeout;
|
|
436
499
|
const clearTimer = seams.clearTimeout || clearTimeout;
|
|
437
500
|
const runTmux = seams.tmuxExec || ((bin, args, opts = {}) => new Promise((resolve) => {
|
|
@@ -444,6 +507,19 @@ function startGenerationPrompt(config, generation, childState, seams = {}) {
|
|
|
444
507
|
try { await runTmux(config.tmuxBin, ['set-option', '-p', '-t', paneTarget, '@nc_delivery', value], {}); }
|
|
445
508
|
catch (_) { /* best-effort: il report timeout di up() resta onesto */ }
|
|
446
509
|
};
|
|
510
|
+
// Elenco bounded dei server non pronti su @nc_mcp_list (solo nomi
|
|
511
|
+
// validati e cap 16 per classe; muore col pane come @nc_delivery).
|
|
512
|
+
const markMcpList = async (mcp) => {
|
|
513
|
+
if (!classified || !mcp) return;
|
|
514
|
+
const valida = (arr) => (Array.isArray(arr) ? arr : [])
|
|
515
|
+
.filter((n) => typeof n === 'string' && MCP_NAME_RE.test(n)).slice(0, 16).sort();
|
|
516
|
+
const f = valida(mcp.failed);
|
|
517
|
+
const p = valida(mcp.pending);
|
|
518
|
+
if (!f.length && !p.length) return;
|
|
519
|
+
const value = `${f.length ? `failed=${f.join(',')}` : ''}${f.length && p.length ? '|' : ''}${p.length ? `pending=${p.join(',')}` : ''}`;
|
|
520
|
+
try { await runTmux(config.tmuxBin, ['set-option', '-p', '-t', paneTarget, '@nc_mcp_list', value], {}); }
|
|
521
|
+
catch (_) { /* best-effort come @nc_delivery */ }
|
|
522
|
+
};
|
|
447
523
|
let timer = null; let cancelled = false;
|
|
448
524
|
let settledDone = false; let settleResolve = null;
|
|
449
525
|
const settled = new Promise((resolve) => { settleResolve = resolve; });
|
|
@@ -470,6 +546,7 @@ function startGenerationPrompt(config, generation, childState, seams = {}) {
|
|
|
470
546
|
paneTarget: process.env.TMUX_PANE || undefined,
|
|
471
547
|
readyWaitMs: config.readyWaitMs,
|
|
472
548
|
isCancelled,
|
|
549
|
+
...(mcpWait ? { mcpWait } : {}),
|
|
473
550
|
});
|
|
474
551
|
const state = result && typeof result.state === 'string' ? result.state : '';
|
|
475
552
|
const uncertain = state === 'delivery-unknown' || state === 'staged-not-submitted';
|
|
@@ -480,7 +557,10 @@ function startGenerationPrompt(config, generation, childState, seams = {}) {
|
|
|
480
557
|
}
|
|
481
558
|
if (state) {
|
|
482
559
|
const kind = state === 'skipped-not-ready' && result.notReady ? `:${result.notReady}` : '';
|
|
483
|
-
|
|
560
|
+
const mcpState = result && result.mcp && MCP_DELIVERY_STATES.includes(result.mcp.state)
|
|
561
|
+
? `:mcp${result.mcp.state}` : '';
|
|
562
|
+
await markDelivery(`${state}${kind}${mcpState}`);
|
|
563
|
+
if (mcpState) await markMcpList(result.mcp);
|
|
484
564
|
}
|
|
485
565
|
// L'esito post-paste incerto va conservato ANCHE se il child
|
|
486
566
|
// era vivo al ritorno di deliver: i byte possono restare nel PTY del
|
|
@@ -527,6 +607,20 @@ async function main(argv = process.argv.slice(2), seams = {}) {
|
|
|
527
607
|
const sleep = seams.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
528
608
|
const proc = seams.process || process;
|
|
529
609
|
const writeError = seams.writeError || seams.stderrWrite || ((message) => process.stderr.write(message));
|
|
610
|
+
const activity = payload.activity || null;
|
|
611
|
+
// Uscita del client: il supervisore DICHIARA che il processo che pubblicava lo
|
|
612
|
+
// stato non c'e' piu'. Serve perche' il riavvio avviene DENTRO lo stesso
|
|
613
|
+
// lancio (loop sotto): `activity.gen` non cambia, quindi la generazione non
|
|
614
|
+
// puo' accorgersi che il client precedente e' morto. Senza questa riga uno
|
|
615
|
+
// `Stop` dell'ultimo turno resterebbe «ferma» per tutto il backoff — e per
|
|
616
|
+
// sempre, ora che «ferma» non scade — mentre il client e' morto e il prossimo
|
|
617
|
+
// non e' pronto. Il lettore mappa l'evento a null: «non verificato».
|
|
618
|
+
const dichiaraUscita = () => {
|
|
619
|
+
if (!activity) return;
|
|
620
|
+
if (scriviStato(activity.dir, { evento: USCITA, generazione: activity.generation, ora: now() }) !== true) {
|
|
621
|
+
writeError('nexuscrew cell: uscita del client non registrata nello stato di attivita\n');
|
|
622
|
+
}
|
|
623
|
+
};
|
|
530
624
|
// Il canale identita' nasce SOLO se il launcher lo ha deciso: la stessa
|
|
531
625
|
// risoluzione che decide CODEX_APP_SERVER_IDENTITY_REQUIRED in managed.js
|
|
532
626
|
// viaggia nel payload come `identityChannel`. Derivarlo qui (aggiungere
|
|
@@ -616,6 +710,55 @@ async function main(argv = process.argv.slice(2), seams = {}) {
|
|
|
616
710
|
for (const [signal, handler] of handlers) proc.off?.(signal, handler);
|
|
617
711
|
};
|
|
618
712
|
|
|
713
|
+
// LA GENERAZIONE SI SCRIVE QUI, E SOLO QUI.
|
|
714
|
+
//
|
|
715
|
+
// Questo processo gira DENTRO la sessione tmux appena creata: chi perde la
|
|
716
|
+
// corsa sulla new-session non arriva mai a un cell-exec e quindi non scrive
|
|
717
|
+
// niente. Scriverla prima (nella risoluzione) significava scriverla anche per
|
|
718
|
+
// il perdente: sul disco restava la generazione di chi non aveva lanciato
|
|
719
|
+
// nulla, mentre gli hook del vincitore portavano la loro — e ogni suo evento
|
|
720
|
+
// veniva scartato al lettore, cioe' «non verificato» su una cella che
|
|
721
|
+
// lavorava.
|
|
722
|
+
//
|
|
723
|
+
// Prima del primo spawn, non dentro il ciclo: la generazione identifica il
|
|
724
|
+
// LANCIO, e i riavvii interni del client (il ciclo sotto) restano lo stesso
|
|
725
|
+
// lancio. Il client parte DOPO questa scrittura: mai un hook che scrive una
|
|
726
|
+
// generazione che non e' ancora su disco.
|
|
727
|
+
if (activity) {
|
|
728
|
+
// (1) INVALIDA lo stato lasciato dal client precedente, PRIMA di pubblicare
|
|
729
|
+
// la generazione. Un evento di lancio porta la generazione nuova e il
|
|
730
|
+
// lettore lo mappa a null: senza, un «ferma» dell'ultimo turno del client
|
|
731
|
+
// precedente sopravviverebbe al lancio — e con «ferma» che non scade
|
|
732
|
+
// resterebbe vero finche' un hook non lo rinnova, cioe' per sempre su una
|
|
733
|
+
// cella appena partita. Scrivo prima della generazione di proposito:
|
|
734
|
+
// l'invalidazione non deve dipendere dalla pubblicazione riuscita.
|
|
735
|
+
if (scriviStato(activity.dir, {
|
|
736
|
+
evento: AVVIO, generazione: activity.generation, ora: now(),
|
|
737
|
+
}) !== true) {
|
|
738
|
+
// LIMITE DICHIARATO (accettato come tale, non un percorso normale): se la
|
|
739
|
+
// directory e' diventata non scrivibile fra due lanci, QUI non si scrive
|
|
740
|
+
// niente e lo stato che la UI mostra puo' essere QUELLO DEL LANCIO
|
|
741
|
+
// PRECEDENTE — un «ferma» dell'ultimo turno di un client che non c'e'
|
|
742
|
+
// piu'. Il lettore non puo' accorgersene: la generazione su disco e'
|
|
743
|
+
// ancora la vecchia, e gli eventi nuovi non arrivano finche' un hook non
|
|
744
|
+
// scrive. E' un guasto del filesystem durante la vita della cella (la
|
|
745
|
+
// directory e' stata resa non scrivibile), non un esito che il codice
|
|
746
|
+
// possa riparare da qui: si dichiara nel log, con la conseguenza.
|
|
747
|
+
writeError("nexuscrew cell: stato di attivita' precedente non invalidato (directory non scrivibile); "
|
|
748
|
+
+ "lo stato mostrato puo' essere quello del lancio precedente\n");
|
|
749
|
+
}
|
|
750
|
+
// (2) PUBBLICA la generazione, e con essa il segno che questo supervisore
|
|
751
|
+
// garantisce l'evento di uscita: e' quel segno a permettere la non scadenza
|
|
752
|
+
// di «ferma»/«interrotta» nel lettore.
|
|
753
|
+
if (scriviGenerazione(activity.dir, activity.generation, { uscitaGarantita: true }) !== true) {
|
|
754
|
+
// Scrittura fallita: si avvia lo stesso, e lo si dichiara. Il client
|
|
755
|
+
// scrivera' eventi con una generazione che non e' quella su disco, quindi
|
|
756
|
+
// verranno scartati e la cella risultera' «non verificato» — l'esito
|
|
757
|
+
// sicuro, mai «ferma» senza fondamento.
|
|
758
|
+
writeError("nexuscrew cell: generazione di attivita' non pubblicata, stato non verificabile\n");
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
619
762
|
let delayMs = supervise.restartDelayMs;
|
|
620
763
|
let rapid = [];
|
|
621
764
|
try {
|
|
@@ -719,6 +862,11 @@ async function main(argv = process.argv.slice(2), seams = {}) {
|
|
|
719
862
|
childState.exited = true;
|
|
720
863
|
closeIdentityChannel();
|
|
721
864
|
current = null;
|
|
865
|
+
// Il client non c'e' piu': QUALUNQUE sia la causa (uscita normale, segnale,
|
|
866
|
+
// errore di spawn, stop dell'operatore) lo stato pubblicato non e' piu'
|
|
867
|
+
// verificabile. Prima di ogni return del supervisore, cosi' nessuna delle
|
|
868
|
+
// uscite resta con lo stato del client morto.
|
|
869
|
+
dichiaraUscita();
|
|
722
870
|
// : la generazione e' finita. Cancella la delivery in volo e ATTESA
|
|
723
871
|
// Del suo termine PRIMA di qualunque nuovo spawn.: se l'esito e' un
|
|
724
872
|
// post-paste incerto (delivery-unknown / staged-not-submitted) i byte del
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Canale di attivita' per le celle codex e codex-vl.
|
|
3
|
+
//
|
|
4
|
+
// Perche' esiste: le celle Claude pubblicano il proprio stato di turno con gli
|
|
5
|
+
// hook di Claude Code (bin/nc-activity-hook.js, iniettato via --settings). Gli
|
|
6
|
+
// engine codex non hanno quel meccanismo, quindi fino a oggi la UI le mostrava
|
|
7
|
+
// «non verificate» — che era la verita', ma anche un'informazione che si poteva
|
|
8
|
+
// avere. Codex ha un sistema di hook proprio, con una differenza che conta:
|
|
9
|
+
// un hook iniettato da riga di comando NON e' fidato finche' il suo hash non
|
|
10
|
+
// compare in `hooks.state`, altrimenti il client apre un dialogo di revisione e
|
|
11
|
+
// non lo esegue.
|
|
12
|
+
//
|
|
13
|
+
// Questo modulo costruisce i due pezzi che servono, e nient'altro:
|
|
14
|
+
// 1. gli argomenti `-c hooks.<Evento>=[...]` che DEFINISCONO l'hook;
|
|
15
|
+
// 2. gli argomenti `-c hooks.state={...}` che lo FIDANO, con l'hash che il
|
|
16
|
+
// client si aspetta di trovare.
|
|
17
|
+
//
|
|
18
|
+
// L'hash non e' lo sha256 del testo del comando: e' lo sha256 del JSON CANONICO
|
|
19
|
+
// di un'identita' normalizzata (evento + handler normalizzato). Replicato da
|
|
20
|
+
// codex-rs/hooks/src/engine/discovery.rs (`hook_hash`) e
|
|
21
|
+
// codex-rs/config/src/fingerprint.rs (`version_for_toml`).
|
|
22
|
+
//
|
|
23
|
+
// La forma `-c hooks.state.<chiave>.trusted_hash=...` NON funziona: il parser
|
|
24
|
+
// di `-c` spezza il path sui punti (codex-rs/config/src/overrides.rs) e la
|
|
25
|
+
// chiave dell'hook ne contiene. Percio' `hooks.state` si passa come TABELLA
|
|
26
|
+
// inline. Verificato in una prova isolata su TUI reale, su entrambe le
|
|
27
|
+
// versioni in uso.
|
|
28
|
+
|
|
29
|
+
const crypto = require('node:crypto');
|
|
30
|
+
const { termuxRuntimePaths } = require('../runtime/env.js');
|
|
31
|
+
|
|
32
|
+
// Etichette usate nelle chiavi di stato — codex-rs/hooks/src/lib.rs,
|
|
33
|
+
// `hook_event_key_label`. Solo gli eventi che iniettiamo.
|
|
34
|
+
const ETICHETTA_EVENTO = Object.freeze({
|
|
35
|
+
PreToolUse: 'pre_tool_use',
|
|
36
|
+
PostToolUse: 'post_tool_use',
|
|
37
|
+
SessionStart: 'session_start',
|
|
38
|
+
SessionEnd: 'session_end',
|
|
39
|
+
UserPromptSubmit: 'user_prompt_submit',
|
|
40
|
+
Stop: 'stop',
|
|
41
|
+
Interrupt: 'interrupt',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Timeout normalizzato — codex-rs/hooks/src/engine/discovery.rs,
|
|
45
|
+
// `normalize_command_hook`: dieci minuti per tutti, UN SECONDO per SessionEnd e
|
|
46
|
+
// Interrupt (che hanno anche un tetto di tre). Il valore entra nell'hash,
|
|
47
|
+
// quindi sbagliarlo qui farebbe ricomparire il dialogo di revisione.
|
|
48
|
+
const TIMEOUT_STANDARD_SEC = 600;
|
|
49
|
+
const TIMEOUT_FINE_SEC = 1;
|
|
50
|
+
|
|
51
|
+
// La fonte sintetica da cui il client crede che l'hook provenga: e' il layer
|
|
52
|
+
// `SessionFlags`, cioe' proprio cio' che passiamo con `-c`
|
|
53
|
+
// (discovery.rs:402-422).
|
|
54
|
+
const KEY_SOURCE_SESSION_FLAGS = '/<session-flags>/config.toml';
|
|
55
|
+
|
|
56
|
+
function etichetta(evento) {
|
|
57
|
+
const label = ETICHETTA_EVENTO[evento];
|
|
58
|
+
if (!label) throw new Error(`evento hook non gestito: ${evento}`);
|
|
59
|
+
return label;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function timeoutPerEvento(evento) {
|
|
63
|
+
return evento === 'SessionEnd' || evento === 'Interrupt'
|
|
64
|
+
? TIMEOUT_FINE_SEC
|
|
65
|
+
: TIMEOUT_STANDARD_SEC;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Riordina le chiavi in modo ricorsivo: `version_for_toml` serializza il JSON
|
|
69
|
+
// CANONICO, e due identita' con le stesse chiavi in ordine diverso devono dare
|
|
70
|
+
// lo stesso hash.
|
|
71
|
+
function ordinaChiavi(valore) {
|
|
72
|
+
if (Array.isArray(valore)) return valore.map(ordinaChiavi);
|
|
73
|
+
if (valore && typeof valore === 'object') {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const chiave of Object.keys(valore).sort()) out[chiave] = ordinaChiavi(valore[chiave]);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
return valore;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function jsonCanonico(valore) {
|
|
82
|
+
return JSON.stringify(ordinaChiavi(valore));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Hash atteso da codex per questo handler su questo evento.
|
|
87
|
+
* Replica `hook_hash` -> `version_for_toml`.
|
|
88
|
+
*/
|
|
89
|
+
function hashHook(evento, comando) {
|
|
90
|
+
const identita = {
|
|
91
|
+
event_name: etichetta(evento),
|
|
92
|
+
hooks: [{
|
|
93
|
+
async: false,
|
|
94
|
+
command: comando,
|
|
95
|
+
timeout: timeoutPerEvento(evento),
|
|
96
|
+
type: 'command',
|
|
97
|
+
}],
|
|
98
|
+
};
|
|
99
|
+
const digest = crypto.createHash('sha256').update(jsonCanonico(identita), 'utf8').digest('hex');
|
|
100
|
+
return `sha256:${digest}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Chiave dello stato per un handler: `hook_key` (hooks/src/lib.rs). */
|
|
104
|
+
function chiaveHook(evento, gruppo = 0, handler = 0) {
|
|
105
|
+
return `${KEY_SOURCE_SESSION_FLAGS}:${etichetta(evento)}:${gruppo}:${handler}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Stringa TOML. I valori qui sono percorsi e comandi unix, senza backslash:
|
|
109
|
+
// JSON.stringify produce una basic string corretta per questi contenuti.
|
|
110
|
+
function tomlString(valore) {
|
|
111
|
+
return JSON.stringify(String(valore));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Definizione di UN hook: `hooks.<Evento>=[{hooks=[{type,command}]}]`. */
|
|
115
|
+
function definizioneHook(evento, comando) {
|
|
116
|
+
return `hooks.${evento}=[{hooks=[{type="command",command=${tomlString(comando)}}]}]`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `hooks.state` come tabella inline con un trusted_hash per chiave. */
|
|
120
|
+
function tabellaStato(voci) {
|
|
121
|
+
const parti = voci.map(({ chiave, hash }) => `${tomlString(chiave)}={trusted_hash=${tomlString(hash)}}`);
|
|
122
|
+
return `hooks.state={${parti.join(',')}}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Argomenti da appendere alla riga di lancio di una cella codex/codex-vl.
|
|
127
|
+
* `comandoPerEvento(evento)` ritorna il comando dell'hook per quell'evento.
|
|
128
|
+
* Ritorna [] se non c'e' niente da iniettare.
|
|
129
|
+
*/
|
|
130
|
+
function argomentiHookCodex(eventi, comandoPerEvento) {
|
|
131
|
+
if (!Array.isArray(eventi) || eventi.length === 0) return [];
|
|
132
|
+
const args = [];
|
|
133
|
+
const voci = [];
|
|
134
|
+
for (const evento of eventi) {
|
|
135
|
+
const comando = comandoPerEvento(evento);
|
|
136
|
+
if (!comando) continue;
|
|
137
|
+
args.push('-c', definizioneHook(evento, comando));
|
|
138
|
+
voci.push({ chiave: chiaveHook(evento), hash: hashHook(evento, comando) });
|
|
139
|
+
}
|
|
140
|
+
if (voci.length === 0) return [];
|
|
141
|
+
args.push('-c', tabellaStato(voci));
|
|
142
|
+
return args;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Guardia di versione ────────────────────────────────────────────────
|
|
146
|
+
//
|
|
147
|
+
// L'hash dipende dalla normalizzazione, dalla serializzazione e dal source
|
|
148
|
+
// path sintetico di UNA versione di codex. Se il binario cambia quelle regole,
|
|
149
|
+
// l'hash non combacia piu', il client apre il dialogo «Hooks need review» e la
|
|
150
|
+
// cella resta BLOCCATA su una domanda che nessuno vede. Il costo di sbagliare
|
|
151
|
+
// non e' «hook che non parte»: e' una cella che non lavora.
|
|
152
|
+
//
|
|
153
|
+
// Quindi si inietta SOLO su versioni effettivamente provate, e su ogni altra
|
|
154
|
+
// non si inietta niente: la cella torna «non verificato» come oggi, che e' il
|
|
155
|
+
// comportamento di prima — un degrado dichiarato, non un guasto introdotto.
|
|
156
|
+
const VERSIONI_PROVATE = Object.freeze({
|
|
157
|
+
codex: ['0.156.1'],
|
|
158
|
+
'codex-vl': ['0.155.1'],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Probe della versione, con lo stesso seam del ramo vl (cfg.vlVersionProbe):
|
|
162
|
+
// i test non devono dipendere da un binario vero. Ritorna l'output GREZZO.
|
|
163
|
+
// `binary` e' il percorso RISOLTO del binario della cella, non il nome del
|
|
164
|
+
// client: si esegue quello, senza shell e con un tetto di tempo breve.
|
|
165
|
+
function versionOutput(binary, cfg) {
|
|
166
|
+
const probe = cfg && typeof cfg.codexVersionProbe === 'function' ? cfg.codexVersionProbe : null;
|
|
167
|
+
if (probe) {
|
|
168
|
+
const out = probe(binary);
|
|
169
|
+
return out === null || out === undefined ? null : String(out);
|
|
170
|
+
}
|
|
171
|
+
const { spawnSync } = require('node:child_process');
|
|
172
|
+
const r = spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: 3000, shell: false });
|
|
173
|
+
if (r.error || r.status !== 0) return null;
|
|
174
|
+
return `${r.stdout || ''}${r.stderr || ''}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Si puo' iniettare su questo binario? Ritorna { ok, versione } oppure
|
|
179
|
+
* { ok: false, reason } — il motivo e' per la riga di log, mai per l'utente.
|
|
180
|
+
* `client` sceglie l'elenco delle versioni provate; `binary` e' cio' che si
|
|
181
|
+
* esegue davvero. Sono due cose diverse: se coincidono e' una coincidenza.
|
|
182
|
+
*/
|
|
183
|
+
function gateVersione(client, binary, cfg) {
|
|
184
|
+
const ammesse = VERSIONI_PROVATE[client];
|
|
185
|
+
if (!ammesse) return { ok: false, reason: `client ${client} non gestito dal canale hook` };
|
|
186
|
+
if (!binary) {
|
|
187
|
+
return { ok: false, reason: `binario di ${client} non risolto sul nodo: nessun hook iniettato` };
|
|
188
|
+
}
|
|
189
|
+
const out = versionOutput(binary, cfg);
|
|
190
|
+
if (out === null) {
|
|
191
|
+
return { ok: false, reason: `versione di ${client} non determinabile (${binary} --version non risponde): nessun hook iniettato` };
|
|
192
|
+
}
|
|
193
|
+
const m = out.match(/(\d+\.\d+\.\d+)/);
|
|
194
|
+
if (!m) {
|
|
195
|
+
return { ok: false, reason: `versione di ${client} non riconosciuta da "${String(out).trim().slice(0, 60)}": nessun hook iniettato` };
|
|
196
|
+
}
|
|
197
|
+
if (!ammesse.includes(m[1])) {
|
|
198
|
+
return { ok: false, reason: `versione ${client} ${m[1]} non provata con gli hook (provate: ${ammesse.join(', ')}): nessun hook iniettato` };
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, versione: m[1] };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// La chiave di fiducia di questi hook e' Unix: `key_source` e'
|
|
204
|
+
// `/<session-flags>/config.toml` e il comando e' quotato in stile POSIX. Il
|
|
205
|
+
// sorgente di codex sintetizza quella fonte con `C:\` su Windows
|
|
206
|
+
// (`hooks/src/engine/discovery.rs:402-435`), quindi la' la chiave che il
|
|
207
|
+
// client calcola NON coincide con quella che generiamo: si inietterebbe un
|
|
208
|
+
// hook non fidato, cioe' esattamente il dialogo che la guardia esiste per
|
|
209
|
+
// evitare. Finche' non c'e' una prova su Windows, li' non si inietta.
|
|
210
|
+
//
|
|
211
|
+
// Termux e' un caso diverso e piu' stretto: il probe esegue il binario
|
|
212
|
+
// direttamente, mentre il launcher lo lancia tramite Node quando lo shim ha
|
|
213
|
+
// uno shebang senza `/usr/bin/env` (`managed.js:1006-1028`). Il gate
|
|
214
|
+
// fallirebbe chiuso, e una cella «non verificata» senza motivo e' un limite
|
|
215
|
+
// che si dichiara qui invece di lasciarlo scoprire.
|
|
216
|
+
function gatePiattaforma(cfg) {
|
|
217
|
+
const platform = (cfg && cfg.platform) || process.platform;
|
|
218
|
+
const env = (cfg && cfg.env) || process.env;
|
|
219
|
+
const termux = platform === 'android'
|
|
220
|
+
|| termuxRuntimePaths(env, { platform, home: cfg && cfg.home }) !== null;
|
|
221
|
+
if (termux) {
|
|
222
|
+
return { ok: false, reason: 'termux: il probe della versione non segue il percorso di lancio' };
|
|
223
|
+
}
|
|
224
|
+
if (platform !== 'linux' && platform !== 'darwin') {
|
|
225
|
+
return { ok: false, reason: `piattaforma ${platform}: chiave di fiducia e quoting degli hook non provati qui` };
|
|
226
|
+
}
|
|
227
|
+
return { ok: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Eventi iniettati per una cella codex, nell'ordine in cui il client li emette.
|
|
231
|
+
const EVENTI_CODEX = Object.freeze([
|
|
232
|
+
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Interrupt', 'SessionEnd',
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
module.exports = {
|
|
236
|
+
ETICHETTA_EVENTO,
|
|
237
|
+
TIMEOUT_STANDARD_SEC,
|
|
238
|
+
TIMEOUT_FINE_SEC,
|
|
239
|
+
KEY_SOURCE_SESSION_FLAGS,
|
|
240
|
+
VERSIONI_PROVATE,
|
|
241
|
+
EVENTI_CODEX,
|
|
242
|
+
etichetta,
|
|
243
|
+
timeoutPerEvento,
|
|
244
|
+
jsonCanonico,
|
|
245
|
+
hashHook,
|
|
246
|
+
chiaveHook,
|
|
247
|
+
definizioneHook,
|
|
248
|
+
tabellaStato,
|
|
249
|
+
argomentiHookCodex,
|
|
250
|
+
versionOutput,
|
|
251
|
+
gateVersione,
|
|
252
|
+
gatePiattaforma,
|
|
253
|
+
};
|