@fkom13/mcp-sftp-orchestrator 6.0.0 → 11.8.0
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/.env.example +27 -15
- package/CHANGELOG.md +174 -0
- package/README.fr.md +356 -0
- package/README.md +314 -60
- package/apis.js +20 -64
- package/atomicJsonStore.js +117 -0
- package/compareEngine.js +132 -0
- package/config.js +40 -4
- package/diagnose.js +89 -0
- package/diffEngine.js +141 -0
- package/diffFormatter.js +114 -0
- package/fileOps.js +275 -0
- package/fleet.js +170 -0
- package/groups.js +25 -0
- package/guide.js +236 -0
- package/history.js +5 -44
- package/infraTopology.js +336 -0
- package/inventory.js +151 -0
- package/lhm.plugin.json +7 -0
- package/notes.js +3 -0
- package/package.json +11 -26
- package/policies.js +8 -0
- package/projects.js +108 -0
- package/queue.js +180 -125
- package/server.js +2552 -469
- package/servers.js +30 -33
- package/sftp.js +284 -86
- package/shellSessions.js +265 -0
- package/snapshotManager.js +320 -0
- package/sourceAdapter.js +410 -0
- package/ssh.js +184 -86
- package/sshPool.js +161 -148
- package/sshTrust.js +214 -0
- package/toolMetadata.js +76 -0
- package/tunnels.js +243 -0
- package/utils.js +215 -0
- package/workSession.js +139 -0
- package/publish1github.md +0 -216
- package/test_features.js +0 -112
package/toolMetadata.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata MCP agentique centralisée.
|
|
3
|
+
*
|
|
4
|
+
* L'objectif n'est pas de remplacer les garde-fous serveur par des hints :
|
|
5
|
+
* les annotations MCP aident le client/modèle à choisir et confirmer le bon
|
|
6
|
+
* outil, tandis que les policies/guards restent l'autorité réelle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const READ_ONLY = new Set([
|
|
10
|
+
'system_diagnostics','server_list','server_group_list','fleet_status','infra_audit',
|
|
11
|
+
'project_list','project_get','project_resolve','project_diff','work_list','server_inventory',
|
|
12
|
+
'policy_blocklist_list','api_list','api_check','get_system_resources','get_services_status',
|
|
13
|
+
'check_api_health','get_fail2ban_status','task_queue','task_status','task_history','pool_stats',
|
|
14
|
+
'queue_stats','task_logs','get_pm2_logs','get_docker_logs','tail_file','help','task_wait',
|
|
15
|
+
'file_read','diff_files','diff_folders','compare_all_sources','shell_list','snapshot_list',
|
|
16
|
+
'snapshot_diff','server_note_get','server_note_list','infra_overview','tmux_read','tmux_list',
|
|
17
|
+
'tunnel_list','guide'
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const DESTRUCTIVE = new Set([
|
|
21
|
+
'server_remove','server_group_remove','project_remove','work_end','policy_blocklist_remove',
|
|
22
|
+
'api_remove','task_exec','task_transfer','task_transfer_multi','task_exec_interactive',
|
|
23
|
+
'task_exec_sequence','task_retry','task_retry_all','task_purge','file_write','file_edit',
|
|
24
|
+
'shell_exec','shell_close','snapshot_restore','snapshot_delete','server_note_remove',
|
|
25
|
+
'tmux_exec','tmux_kill','tunnel_close','tunnel_allowlist_remove'
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const IDEMPOTENT_MUTATIONS = new Set([
|
|
29
|
+
'server_group_set','project_set','server_note_set','policy_blocklist_add',
|
|
30
|
+
'tunnel_allowlist_add','tunnel_allowlist_remove'
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const CLOSED_WORLD = new Set([
|
|
34
|
+
'help','guide','policy_blocklist_list','project_list','project_get','project_resolve',
|
|
35
|
+
'server_group_list','server_note_get','server_note_list','task_queue','task_status',
|
|
36
|
+
'task_history','queue_stats','task_logs','snapshot_list','snapshot_diff'
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const DESCRIPTION_OVERRIDES = {
|
|
40
|
+
system_diagnostics: 'Diagnostique le processus MCP lui-même (queue, pool SSH, registres, erreurs). Pour l’état réel d’une machine utilisez server_inventory; pour la topologie domaines→ports→services utilisez infra_overview avec alias.',
|
|
41
|
+
infra_audit: 'Contexte de contrôle-plane en un appel: version Orchestrator, registres, groupes, projets, notes, jobs crashés et pool. N’interroge pas profondément les machines. Pour une machine live et ses routes réseau utilisez infra_overview avec alias.',
|
|
42
|
+
infra_overview: 'Cartographie d’infrastructure. Sans alias: vue légère de tout le parc depuis les registres/notes. Avec alias (ou group:/all): découverte live mise en cache des listeners, Docker/Compose, PM2, systemd et Nginx, puis corrélation domaine → proxy → port → conteneur/service.',
|
|
43
|
+
fleet_status: 'Teste en parallèle la joignabilité SSH et quelques ressources de plusieurs serveurs. Choisir cet outil pour “qui est online ?”. Pour le détail d’une machine utilisez server_inventory; pour ses domaines/services exposés utilisez infra_overview.',
|
|
44
|
+
server_inventory: 'Inventaire live léger d’une machine avec cache TTL: ressources, Docker, PM2, HOME et Tailscale. Ne reconstruit pas les routes reverse-proxy; utilisez infra_overview pour domaine → port → service.',
|
|
45
|
+
task_exec: 'Commande distante one-shot. Pour une commande longue utilisez timeout:0 puis task_wait/task_status. Pour conserver cd/export entre appels utilisez shell_create/shell_exec; pour une session terminal survivant au MCP utilisez tmux_*.',
|
|
46
|
+
task_exec_sequence: 'Exécute plusieurs commandes ordonnées sur le même serveur avec résultat par étape. Préférer à plusieurs task_exec lorsque les étapes sont déterministes mais n’ont pas besoin d’un shell persistant.',
|
|
47
|
+
task_exec_interactive: 'Commande distante avec gestion de prompts interactifs connus. Utiliser seulement si la commande attend réellement yes/no/password/choix; sinon préférer task_exec.',
|
|
48
|
+
task_transfer: 'Transfert fichier ou dossier. server_to_server copie directement source_alias → alias. force:false protège une destination existante. Pour une réplication bit-à-bit avec métadonnées/symlinks, vérifier les warnings du résultat.',
|
|
49
|
+
task_transfer_multi: 'Lot de transferts avec patterns glob. À préférer pour plusieurs fichiers indépendants; task_transfer est plus clair pour un fichier/dossier ou un transfert server_to_server.',
|
|
50
|
+
file_edit: 'Édition chirurgicale locale/remote avec protection expectedHash, dryRun et backup. Préférer à file_write pour modifier un fichier existant sans écraser involontairement des changements concurrents.',
|
|
51
|
+
file_write: 'Crée ou remplace un fichier local/remote. Pour un fichier existant, préférer file_edit + expectedHash quand une édition chirurgicale est possible.',
|
|
52
|
+
shell_create: 'Ouvre un shell SSH persistant: cwd et variables survivent entre shell_exec. Pour une tâche détachée survivant à une déconnexion du MCP, préférer tmux_create/tmux_exec.',
|
|
53
|
+
tmux_create: 'Crée une session tmux distante durable. Adapté aux agents/serveurs de dev et tâches longues que l’on doit reprendre après déconnexion.',
|
|
54
|
+
snapshot_create: 'Snapshot CAS dédupliqué de fichiers/dossiers local ou remote pour rollback infra. Ce n’est pas de la mémoire sémantique projet; GenCodeDoc reste la brique Project Intelligence.',
|
|
55
|
+
help: 'Référence compacte des tools et variables Orchestrator. Pour des workflows agentiques guidés, utiliser guide.',
|
|
56
|
+
guide: 'Playbooks agentiques Orchestrator (audit, déploiement, recovery, transferts). Utiliser help pour le schéma/description d’un tool précis.'
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function annotationsFor(name) {
|
|
60
|
+
const readOnlyHint = READ_ONLY.has(name);
|
|
61
|
+
return {
|
|
62
|
+
readOnlyHint,
|
|
63
|
+
destructiveHint: readOnlyHint ? false : DESTRUCTIVE.has(name),
|
|
64
|
+
idempotentHint: readOnlyHint || IDEMPOTENT_MUTATIONS.has(name),
|
|
65
|
+
openWorldHint: !CLOSED_WORLD.has(name)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function enhanceToolConfig(name, config = {}) {
|
|
70
|
+
const annotations = { ...annotationsFor(name), ...(config.annotations || {}) };
|
|
71
|
+
const description = DESCRIPTION_OVERRIDES[name] || config.description;
|
|
72
|
+
return { ...config, description, annotations };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { annotationsFor, enhanceToolConfig, DESCRIPTION_OVERRIDES };
|
|
76
|
+
export default { annotationsFor, enhanceToolConfig, DESCRIPTION_OVERRIDES };
|
package/tunnels.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import config from './config.js';
|
|
5
|
+
import queue from './queue.js';
|
|
6
|
+
import history from './history.js';
|
|
7
|
+
import ssh from './ssh.js';
|
|
8
|
+
import utils from './utils.js';
|
|
9
|
+
import jsonStore from './atomicJsonStore.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const TUNNELS_PATH = path.join(config.dataDir, 'tunnels.json');
|
|
13
|
+
const ALLOWLIST_PATH = path.join(config.dataDir, 'tunnel_allowlist.json');
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ALLOWLIST = [80, 443, 3000, 8080, 8443, 9090, 3002, 3100, 3102, 4520, 5002, 4001, 51821, 5678, 8081, 8083, 8090];
|
|
16
|
+
|
|
17
|
+
const LOCAL_TUNNELS = new Map();
|
|
18
|
+
|
|
19
|
+
async function loadJson(p, def) {
|
|
20
|
+
return jsonStore.readJson(p, def);
|
|
21
|
+
}
|
|
22
|
+
async function saveJson(p, data) {
|
|
23
|
+
await jsonStore.writeJsonAtomic(p, data);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function loadAllowlist() {
|
|
27
|
+
return loadJson(ALLOWLIST_PATH, DEFAULT_ALLOWLIST);
|
|
28
|
+
}
|
|
29
|
+
async function saveAllowlist(list) {
|
|
30
|
+
await saveJson(ALLOWLIST_PATH, list);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function loadRegistry() {
|
|
34
|
+
return loadJson(TUNNELS_PATH, {});
|
|
35
|
+
}
|
|
36
|
+
async function saveRegistry(reg) {
|
|
37
|
+
await saveJson(TUNNELS_PATH, reg);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tunnelLocks = new Map();
|
|
41
|
+
async function withTunnelLock(name, fn) {
|
|
42
|
+
const prev = tunnelLocks.get(name) || Promise.resolve();
|
|
43
|
+
let release;
|
|
44
|
+
const gate = new Promise(r => { release = r; });
|
|
45
|
+
const tail = prev.catch(() => {}).then(() => gate);
|
|
46
|
+
tunnelLocks.set(name, tail);
|
|
47
|
+
await prev.catch(() => {});
|
|
48
|
+
try { return await fn(); }
|
|
49
|
+
finally { release(); if (tunnelLocks.get(name) === tail) tunnelLocks.delete(name); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function getSshTarget(alias, serverManager) {
|
|
53
|
+
const cfg = await serverManager.getServer(alias);
|
|
54
|
+
return { host: cfg.host, port: cfg.port || 22, user: cfg.user, keyPath: cfg.keyPath };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function spawnLocalSsh(args, name, info) {
|
|
58
|
+
const proc = spawn('ssh', args, { stdio: 'ignore', detached: false });
|
|
59
|
+
LOCAL_TUNNELS.set(name, { process: proc, info });
|
|
60
|
+
proc.on('exit', (code) => {
|
|
61
|
+
LOCAL_TUNNELS.delete(name);
|
|
62
|
+
});
|
|
63
|
+
proc.on('error', () => LOCAL_TUNNELS.delete(name));
|
|
64
|
+
return proc.pid;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildArgs(type, listenPort, target, via, remoteKeyPath) {
|
|
68
|
+
const args = ['-N', '-o', 'ServerAliveInterval=30', '-o', 'StrictHostKeyChecking=no'];
|
|
69
|
+
if (type !== 'remote') args.push('-o', 'ExitOnForwardFailure=yes');
|
|
70
|
+
switch (type) {
|
|
71
|
+
case 'local': args.push('-L', `${listenPort}:${target}`); break;
|
|
72
|
+
case 'remote': args.push('-R', `${listenPort}:${target}`); break;
|
|
73
|
+
case 'socks': args.push('-D', `${listenPort}`); break;
|
|
74
|
+
}
|
|
75
|
+
args.push('-p', String(via.port), `${via.user}@${via.host}`);
|
|
76
|
+
const keyToUse = remoteKeyPath || via.keyPath;
|
|
77
|
+
if (keyToUse) args.push('-i', keyToUse);
|
|
78
|
+
return args;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default {
|
|
82
|
+
async create(params, serverManager) {
|
|
83
|
+
return withTunnelLock(params.name, async () => {
|
|
84
|
+
const { name, type, listen_port, target, via, source, key_path } = params;
|
|
85
|
+
if (!name) throw new Error("Le paramètre 'name' est requis.");
|
|
86
|
+
if (!['local', 'remote', 'socks'].includes(type)) throw new Error("type doit être 'local', 'remote' ou 'socks'");
|
|
87
|
+
if (!listen_port || listen_port < 1 || listen_port > 65535) throw new Error("listen_port invalide");
|
|
88
|
+
if (type !== 'socks' && !target) throw new Error("target requis pour les tunnels local/remote");
|
|
89
|
+
if (listen_port < 1024) throw new Error("Les ports < 1024 nécessitent root. Utilisez un port > 1023.");
|
|
90
|
+
|
|
91
|
+
const allowlist = await loadAllowlist();
|
|
92
|
+
if (!allowlist.includes(listen_port)) {
|
|
93
|
+
throw new Error(`Port ${listen_port} non autorisé. Autorisez-le: tunnel_allowlist_add ${listen_port}. Ports autorisés: ${allowlist.join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const registry = await loadRegistry();
|
|
97
|
+
if (registry[name]) throw new Error(`Un tunnel nommé '${name}' existe déjà. Fermez-le d'abord.`);
|
|
98
|
+
|
|
99
|
+
const viaTarget = await getSshTarget(via, serverManager);
|
|
100
|
+
|
|
101
|
+
// Pour les tunnels sur serveur distant, le key_path doit exister SUR ce serveur
|
|
102
|
+
const remoteKeyPath = source ? (key_path || null) : null;
|
|
103
|
+
const args = buildArgs(type, listen_port, target || '', viaTarget, source ? (key_path || null) : null);
|
|
104
|
+
|
|
105
|
+
if (!source) {
|
|
106
|
+
const pid = spawnLocalSsh(args, name, { type, listen_port, target, via, source: null });
|
|
107
|
+
registry[name] = { type, listen_port, target, via, source: null, pid, created_at: new Date().toISOString() };
|
|
108
|
+
await saveRegistry(registry);
|
|
109
|
+
const desc = type === 'socks'
|
|
110
|
+
? `Proxy SOCKS5 sur 127.0.0.1:${listen_port} (via ${via})`
|
|
111
|
+
: `http://127.0.0.1:${listen_port} → ${via}:${target}`;
|
|
112
|
+
return `Tunnel '${name}' créé localement (PID ${pid}). ${desc}\n⚠️ Ne survit pas au redémarrage du MCP.`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sessionName = `tunnel-${name}`;
|
|
116
|
+
const sshCmd = ['ssh', ...args].map(utils.escapeShellArg).join(' ');
|
|
117
|
+
const createCmd = `tmux new-session -d -s ${utils.escapeShellArg(sessionName)} -- sh -lc ${utils.escapeShellArg(sshCmd)}`;
|
|
118
|
+
|
|
119
|
+
const job = queue.addJob({ type: 'ssh', alias: source, cmd: createCmd, pty: true, timeout: 20, skip_policy: true, status: 'pending' });
|
|
120
|
+
history.logTask(job);
|
|
121
|
+
ssh.executeCommand(job.id);
|
|
122
|
+
|
|
123
|
+
const result = await new Promise(resolve => {
|
|
124
|
+
const start = Date.now();
|
|
125
|
+
const poll = () => {
|
|
126
|
+
const j = queue.getJob(job.id);
|
|
127
|
+
if (!j || j.status === 'completed') resolve(j);
|
|
128
|
+
else if (j.status === 'failed') resolve(j);
|
|
129
|
+
else if (Date.now() - start > 30000) resolve({ status: 'failed', error: 'timeout 30s' });
|
|
130
|
+
else setTimeout(poll, 200);
|
|
131
|
+
};
|
|
132
|
+
poll();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!result || result.status === 'failed') {
|
|
136
|
+
const detail = result ? (result.stderr || result.output || result.error || '') : 'job introuvable';
|
|
137
|
+
throw new Error(`Échec création tunnel sur ${source}: ${detail.substring(0, 500)}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
registry[name] = { type, listen_port, target, via, source, tmux_session: sessionName, key_path, created_at: new Date().toISOString() };
|
|
141
|
+
await saveRegistry(registry);
|
|
142
|
+
|
|
143
|
+
const desc = type === 'socks'
|
|
144
|
+
? `Proxy SOCKS5 sur ${source}:${listen_port} (via ${via})`
|
|
145
|
+
: `${source}:${listen_port} → ${via}:${target}`;
|
|
146
|
+
return `Tunnel '${name}' créé sur ${source} (session tmux: ${sessionName}). ${desc}\n✅ Persistant (survit au redémarrage du MCP).`;
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async get(name) {
|
|
151
|
+
const registry = await loadRegistry();
|
|
152
|
+
return registry[name] || null;
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async list() {
|
|
156
|
+
const registry = await loadRegistry();
|
|
157
|
+
const alive = [];
|
|
158
|
+
|
|
159
|
+
for (const [name, info] of Object.entries(registry)) {
|
|
160
|
+
let status;
|
|
161
|
+
if (!info.source) {
|
|
162
|
+
const local = LOCAL_TUNNELS.get(name);
|
|
163
|
+
status = local && local.process && !local.process.killed ? 'actif' : 'mort (MCP relancé, relancez le tunnel)';
|
|
164
|
+
} else {
|
|
165
|
+
const ckJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux has-session -t ${utils.escapeShellArg(info.tmux_session)} 2>/dev/null && echo actif || echo mort`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
|
|
166
|
+
ssh.executeCommand(ckJob.id);
|
|
167
|
+
await new Promise(r => { const p = () => { const j = queue.getJob(ckJob.id); if (!j || j.status === 'completed' || j.status === 'failed') r(j); else setTimeout(p, 200); }; p(); });
|
|
168
|
+
const j = queue.getJob(ckJob.id);
|
|
169
|
+
status = j && j.output && j.output.trim() === 'actif' ? 'actif' : 'mort';
|
|
170
|
+
}
|
|
171
|
+
alive.push({ name, ...info, status });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const allowlist = await loadAllowlist();
|
|
175
|
+
return { tunnels: alive, allowlist };
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async close(name, serverManager) {
|
|
179
|
+
return withTunnelLock(name, async () => {
|
|
180
|
+
const registry = await loadRegistry();
|
|
181
|
+
const info = registry[name];
|
|
182
|
+
if (!info) throw new Error(`Tunnel '${name}' introuvable.`);
|
|
183
|
+
|
|
184
|
+
if (!info.source) {
|
|
185
|
+
const local = LOCAL_TUNNELS.get(name);
|
|
186
|
+
if (local && local.process && !local.process.killed) {
|
|
187
|
+
local.process.kill('SIGTERM');
|
|
188
|
+
setTimeout(() => { if (!local.process.killed) local.process.kill('SIGKILL'); }, 2000);
|
|
189
|
+
}
|
|
190
|
+
LOCAL_TUNNELS.delete(name);
|
|
191
|
+
} else {
|
|
192
|
+
const killJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux kill-session -t ${utils.escapeShellArg(info.tmux_session)} 2>/dev/null; echo OK`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
|
|
193
|
+
ssh.executeCommand(killJob.id);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
delete registry[name];
|
|
197
|
+
await saveRegistry(registry);
|
|
198
|
+
return `Tunnel '${name}' fermé.`;
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
async allowlistList() {
|
|
203
|
+
return await loadAllowlist();
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
async allowlistAdd(port) {
|
|
207
|
+
return jsonStore.updateJson(ALLOWLIST_PATH, DEFAULT_ALLOWLIST, list => {
|
|
208
|
+
if (!list.includes(port)) list.push(port);
|
|
209
|
+
return list;
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
async allowlistRemove(port) {
|
|
214
|
+
return jsonStore.updateJson(ALLOWLIST_PATH, DEFAULT_ALLOWLIST, list => list.filter(p => p !== port));
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async restore(servers) {
|
|
218
|
+
const registry = await loadRegistry();
|
|
219
|
+
let restored = 0;
|
|
220
|
+
for (const [name, info] of Object.entries(registry)) {
|
|
221
|
+
if (!info.source) {
|
|
222
|
+
try {
|
|
223
|
+
const viaTarget = await getSshTarget(info.via, servers);
|
|
224
|
+
const args = buildArgs(info.type, info.listen_port, info.target || '', viaTarget, false);
|
|
225
|
+
const pid = spawnLocalSsh(args, name, info);
|
|
226
|
+
registry[name].pid = pid;
|
|
227
|
+
restored++;
|
|
228
|
+
} catch { /* silencieux, tunnel perdu */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await saveRegistry(registry);
|
|
232
|
+
return restored;
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
shutdown() {
|
|
236
|
+
for (const [name, entry] of LOCAL_TUNNELS) {
|
|
237
|
+
if (entry.process && !entry.process.killed) {
|
|
238
|
+
entry.process.kill('SIGTERM');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
LOCAL_TUNNELS.clear();
|
|
242
|
+
}
|
|
243
|
+
};
|
package/utils.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilitaires partagés (échappement shell, masquage secrets, timeouts, statuts).
|
|
3
|
+
* Extrait pour tests unitaires sans charger le serveur MCP.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SENSITIVE_KEYS = new Set([
|
|
7
|
+
'password',
|
|
8
|
+
'pass',
|
|
9
|
+
'api_key',
|
|
10
|
+
'apikey',
|
|
11
|
+
'apiKey',
|
|
12
|
+
'htpasswd_pass',
|
|
13
|
+
'htpasswdPass',
|
|
14
|
+
'secret',
|
|
15
|
+
'token',
|
|
16
|
+
'access_token',
|
|
17
|
+
'privateKey',
|
|
18
|
+
'private_key',
|
|
19
|
+
'authorization'
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function escapeShellArg(arg) {
|
|
23
|
+
if (typeof arg !== 'string') return String(arg);
|
|
24
|
+
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Masque un secret : garde les `visible` derniers caractères.
|
|
29
|
+
* null/undefined/non-string → inchangé.
|
|
30
|
+
*/
|
|
31
|
+
function maskSecret(value, visible = 4) {
|
|
32
|
+
if (value == null) return value;
|
|
33
|
+
const s = String(value);
|
|
34
|
+
if (s.length === 0) return s;
|
|
35
|
+
if (s.length <= visible) return '***';
|
|
36
|
+
return '***' + s.slice(-visible);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Clone profond léger et masque les champs sensibles (récursif).
|
|
41
|
+
* Ne mute pas l'objet d'origine.
|
|
42
|
+
*/
|
|
43
|
+
function redactSensitiveObject(obj, options = {}) {
|
|
44
|
+
const visible = options.visible ?? 4;
|
|
45
|
+
const extraKeys = options.extraKeys || [];
|
|
46
|
+
const keys = new Set([...SENSITIVE_KEYS, ...extraKeys.map(k => k.toLowerCase())]);
|
|
47
|
+
|
|
48
|
+
function walk(value, keyName) {
|
|
49
|
+
if (value == null) return value;
|
|
50
|
+
if (Array.isArray(value)) return value.map(v => walk(v, null));
|
|
51
|
+
if (typeof value === 'object') {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [k, v] of Object.entries(value)) {
|
|
54
|
+
const lower = k.toLowerCase();
|
|
55
|
+
if (keys.has(lower) || keys.has(k)) {
|
|
56
|
+
out[k] = typeof v === 'string' || typeof v === 'number'
|
|
57
|
+
? maskSecret(v, visible)
|
|
58
|
+
: (v == null ? v : '***');
|
|
59
|
+
} else {
|
|
60
|
+
out[k] = walk(v, k);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return walk(obj, null);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convertit un timeout "secondes utilisateur" en ms pour waitForJobCompletion.
|
|
73
|
+
* - undefined/null → fallbackMs (déjà en ms, ex: config.syncTimeout)
|
|
74
|
+
* - 0 → 0 (attente infinie côté wait)
|
|
75
|
+
* - n > 0 → n * 1000
|
|
76
|
+
*/
|
|
77
|
+
function toWaitTimeoutMs(timeoutSeconds, fallbackMs) {
|
|
78
|
+
if (timeoutSeconds === 0) return 0;
|
|
79
|
+
if (timeoutSeconds == null || timeoutSeconds === undefined || timeoutSeconds === '') {
|
|
80
|
+
return fallbackMs;
|
|
81
|
+
}
|
|
82
|
+
const n = Number(timeoutSeconds);
|
|
83
|
+
if (!Number.isFinite(n) || n < 0) return fallbackMs;
|
|
84
|
+
return n * 1000;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Statuts pour lesquels un job n'évoluera plus (fin d'attente hybride). */
|
|
88
|
+
function isTerminalJobStatus(status) {
|
|
89
|
+
return status === 'completed'
|
|
90
|
+
|| status === 'failed'
|
|
91
|
+
|| status === 'partial'
|
|
92
|
+
|| status === 'crashed';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Résout le port SSH depuis une config serveur.
|
|
97
|
+
*/
|
|
98
|
+
function resolveSshPort(serverConfig) {
|
|
99
|
+
if (!serverConfig) return 22;
|
|
100
|
+
const p = serverConfig.port;
|
|
101
|
+
if (p == null || p === '') return 22;
|
|
102
|
+
const n = Number(p);
|
|
103
|
+
return Number.isFinite(n) && n > 0 && n <= 65535 ? n : 22;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Patterns heuristiques de commandes destructives (avertissement / dry-run). */
|
|
107
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
108
|
+
/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b/i,
|
|
109
|
+
/\brm\s+-rf\b/i,
|
|
110
|
+
/\bmkfs\b/i,
|
|
111
|
+
/\bdd\s+if=/i,
|
|
112
|
+
/\bdrop\s+(database|table)\b/i,
|
|
113
|
+
/\btruncate\s+table\b/i,
|
|
114
|
+
/\bshutdown\b/i,
|
|
115
|
+
/\breboot\b/i,
|
|
116
|
+
/\buserdel\b/i,
|
|
117
|
+
/\bpasswd\b/i,
|
|
118
|
+
/\bchmod\s+-R\s+000\b/i,
|
|
119
|
+
/\b>\s*\/dev\/sd/i,
|
|
120
|
+
/\biptables\s+-F\b/i,
|
|
121
|
+
/\bsystemctl\s+(stop|disable|mask)\b/i,
|
|
122
|
+
/\bdocker\s+(system\s+prune|rmi|rm\s+-f)\b/i,
|
|
123
|
+
/\bpm2\s+(delete|del|kill)\b/i
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
function isDestructiveCommand(cmd) {
|
|
127
|
+
if (!cmd || typeof cmd !== 'string') return false;
|
|
128
|
+
return DESTRUCTIVE_PATTERNS.some(re => re.test(cmd));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Refuse l'opération si MCP_READONLY=true.
|
|
133
|
+
* @throws Error
|
|
134
|
+
*/
|
|
135
|
+
function assertWritable(actionLabel = 'cette opération') {
|
|
136
|
+
// Lazy import-free: caller passes config.readOnly or we read env
|
|
137
|
+
const ro = process.env.MCP_READONLY === 'true' || process.env.MCP_READONLY === '1';
|
|
138
|
+
if (ro) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Mode lecture seule (MCP_READONLY=1) : ${actionLabel} est refusé. ` +
|
|
141
|
+
`Relancez le MCP sans MCP_READONLY pour écrire.`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Compacte un objet pour l'agent (tronque gros champs).
|
|
148
|
+
* options = { maxString=500, maxArray=30, dropKeys=[] }
|
|
149
|
+
*/
|
|
150
|
+
function compactResult(obj, options = {}) {
|
|
151
|
+
const maxString = options.maxString ?? 500;
|
|
152
|
+
const maxArray = options.maxArray ?? 30;
|
|
153
|
+
const dropKeys = new Set(options.dropKeys || ['diff', 'raw_output', 'content']);
|
|
154
|
+
|
|
155
|
+
function walk(v, key) {
|
|
156
|
+
if (v == null) return v;
|
|
157
|
+
if (typeof v === 'string') {
|
|
158
|
+
if (dropKeys.has(key)) return v.length > 80 ? `[omitted ${v.length} chars]` : v;
|
|
159
|
+
return v.length > maxString ? v.slice(0, maxString) + `…[+${v.length - maxString}]` : v;
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(v)) {
|
|
162
|
+
const sliced = v.slice(0, maxArray).map((item) => walk(item, null));
|
|
163
|
+
if (v.length > maxArray) sliced.push(`…+${v.length - maxArray} items`);
|
|
164
|
+
return sliced;
|
|
165
|
+
}
|
|
166
|
+
if (typeof v === 'object') {
|
|
167
|
+
const out = {};
|
|
168
|
+
for (const [k, val] of Object.entries(v)) {
|
|
169
|
+
if (dropKeys.has(k) && typeof val === 'string' && val.length > 100) {
|
|
170
|
+
out[k] = `[omitted ${val.length} chars]`;
|
|
171
|
+
} else {
|
|
172
|
+
out[k] = walk(val, k);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
return v;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return walk(obj, null);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function wantsCompact(params = {}) {
|
|
184
|
+
if (params && params.compact === true) return true;
|
|
185
|
+
if (params && params.compact === false) return false;
|
|
186
|
+
return process.env.MCP_COMPACT === 'true' || process.env.MCP_COMPACT === '1';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export {
|
|
190
|
+
escapeShellArg,
|
|
191
|
+
maskSecret,
|
|
192
|
+
redactSensitiveObject,
|
|
193
|
+
toWaitTimeoutMs,
|
|
194
|
+
isTerminalJobStatus,
|
|
195
|
+
resolveSshPort,
|
|
196
|
+
isDestructiveCommand,
|
|
197
|
+
assertWritable,
|
|
198
|
+
compactResult,
|
|
199
|
+
wantsCompact,
|
|
200
|
+
DESTRUCTIVE_PATTERNS,
|
|
201
|
+
SENSITIVE_KEYS
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export default {
|
|
205
|
+
escapeShellArg,
|
|
206
|
+
maskSecret,
|
|
207
|
+
redactSensitiveObject,
|
|
208
|
+
toWaitTimeoutMs,
|
|
209
|
+
isTerminalJobStatus,
|
|
210
|
+
resolveSshPort,
|
|
211
|
+
isDestructiveCommand,
|
|
212
|
+
assertWritable,
|
|
213
|
+
compactResult,
|
|
214
|
+
wantsCompact
|
|
215
|
+
};
|
package/workSession.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workSession.js — Sessions de travail (journal d'intervention).
|
|
3
|
+
* Stockage atomique: <dataDir>/work_sessions.json
|
|
4
|
+
*/
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import config from './config.js';
|
|
8
|
+
import notes from './notes.js';
|
|
9
|
+
import snapshotManager from './snapshotManager.js';
|
|
10
|
+
import jsonStore from './atomicJsonStore.js';
|
|
11
|
+
|
|
12
|
+
const SESSIONS_PATH = path.join(config.dataDir, 'work_sessions.json');
|
|
13
|
+
const EMPTY = { active: {}, history: [] };
|
|
14
|
+
|
|
15
|
+
const load = () => jsonStore.readJson(SESSIONS_PATH, EMPTY);
|
|
16
|
+
|
|
17
|
+
function trimHistory(data) {
|
|
18
|
+
data.active ||= {};
|
|
19
|
+
data.history ||= [];
|
|
20
|
+
if (data.history.length > 100) data.history = data.history.slice(-100);
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
async start({ alias = null, project = null, tag = null, message = null, snapshot = false, paths = [] } = {}) {
|
|
26
|
+
const id = `ws_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
|
|
27
|
+
const session = {
|
|
28
|
+
id,
|
|
29
|
+
alias,
|
|
30
|
+
project,
|
|
31
|
+
tag: tag || `work-${new Date().toISOString().slice(0, 10)}`,
|
|
32
|
+
message: message || null,
|
|
33
|
+
startedAt: new Date().toISOString(),
|
|
34
|
+
events: [],
|
|
35
|
+
snapshotId: null,
|
|
36
|
+
status: 'active'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Effet externe avant publication de la session. S'il échoue, on garde
|
|
40
|
+
// l'information dans les events mais on ne laisse aucun état partiel.
|
|
41
|
+
if (snapshot && alias && paths.length) {
|
|
42
|
+
try {
|
|
43
|
+
const snap = await snapshotManager.createSnapshot(
|
|
44
|
+
{ type: 'remote', alias }, paths,
|
|
45
|
+
{ tag: session.tag, message: message || `work_start ${id}` }
|
|
46
|
+
);
|
|
47
|
+
session.snapshotId = snap.snapshotId;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
session.events.push({ at: new Date().toISOString(), type: 'snapshot_error', detail: e.message });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
|
|
54
|
+
trimHistory(data);
|
|
55
|
+
data.active[id] = session;
|
|
56
|
+
return data;
|
|
57
|
+
});
|
|
58
|
+
return session;
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async log(id, event) {
|
|
62
|
+
let count = 0;
|
|
63
|
+
await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
|
|
64
|
+
trimHistory(data);
|
|
65
|
+
const session = data.active[id];
|
|
66
|
+
if (!session) throw new Error(`Session '${id}' introuvable ou déjà fermée.`);
|
|
67
|
+
session.events.push({ at: new Date().toISOString(), ...event });
|
|
68
|
+
count = session.events.length;
|
|
69
|
+
return data;
|
|
70
|
+
});
|
|
71
|
+
return { id, events: count };
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async get(id) {
|
|
75
|
+
const data = trimHistory(await load());
|
|
76
|
+
if (data.active[id]) return data.active[id];
|
|
77
|
+
const hist = data.history.find(s => s.id === id);
|
|
78
|
+
if (hist) return hist;
|
|
79
|
+
throw new Error(`Session '${id}' introuvable.`);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async list({ includeHistory = false } = {}) {
|
|
83
|
+
const data = trimHistory(await load());
|
|
84
|
+
const active = Object.values(data.active);
|
|
85
|
+
if (!includeHistory) return { active, historyCount: data.history.length };
|
|
86
|
+
return { active, history: data.history };
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async end(id, { summary = null, note = true } = {}) {
|
|
90
|
+
let closed;
|
|
91
|
+
// Commit principal d'abord : une panne de notes ne doit jamais laisser
|
|
92
|
+
// la session active alors que l'appel end a été accepté.
|
|
93
|
+
await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
|
|
94
|
+
trimHistory(data);
|
|
95
|
+
const session = data.active[id];
|
|
96
|
+
if (!session) throw new Error(`Session '${id}' introuvable ou déjà fermée.`);
|
|
97
|
+
|
|
98
|
+
session.status = 'closed';
|
|
99
|
+
session.endedAt = new Date().toISOString();
|
|
100
|
+
session.summary = summary || session.message || `Session ${id} fermée`;
|
|
101
|
+
session.events.push({ at: session.endedAt, type: 'end', detail: session.summary });
|
|
102
|
+
if (note && session.alias) session.noteUpdated = null; // pending
|
|
103
|
+
|
|
104
|
+
delete data.active[id];
|
|
105
|
+
data.history.push(session);
|
|
106
|
+
trimHistory(data);
|
|
107
|
+
closed = structuredClone(session);
|
|
108
|
+
return data;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (note && closed.alias) {
|
|
112
|
+
let noteUpdated = true;
|
|
113
|
+
let noteError = null;
|
|
114
|
+
try {
|
|
115
|
+
await notes.set(closed.alias, {
|
|
116
|
+
intervention: `[${closed.tag}] ${closed.summary} (${closed.events.length} events)`
|
|
117
|
+
});
|
|
118
|
+
} catch (e) {
|
|
119
|
+
noteUpdated = false;
|
|
120
|
+
noteError = e.message;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Compensation: enrichit l'historique sans rouvrir la session.
|
|
124
|
+
await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
|
|
125
|
+
trimHistory(data);
|
|
126
|
+
const hist = data.history.find(s => s.id === id);
|
|
127
|
+
if (hist) {
|
|
128
|
+
hist.noteUpdated = noteUpdated;
|
|
129
|
+
if (noteError) hist.noteError = noteError;
|
|
130
|
+
else delete hist.noteError;
|
|
131
|
+
closed = structuredClone(hist);
|
|
132
|
+
}
|
|
133
|
+
return data;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return closed;
|
|
138
|
+
}
|
|
139
|
+
};
|