@imenam/mcp-ssh-gate 0.0.0-stage → 1.0.1
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/README.md +128 -2
- package/dist/shared/model.d.ts +76 -0
- package/dist/shared/model.js +7 -0
- package/dist/src/cli/setup-mcp.d.ts +21 -0
- package/dist/src/cli/setup-mcp.js +137 -0
- package/dist/src/core/config.d.ts +14 -0
- package/dist/src/core/config.js +36 -0
- package/dist/src/core/errors.d.ts +4 -0
- package/dist/src/core/errors.js +4 -0
- package/dist/src/core/executor.d.ts +46 -0
- package/dist/src/core/executor.js +328 -0
- package/dist/src/core/files.d.ts +9 -0
- package/dist/src/core/files.js +63 -0
- package/dist/src/core/heartbeat.d.ts +5 -0
- package/dist/src/core/heartbeat.js +26 -0
- package/dist/src/core/request-store.d.ts +26 -0
- package/dist/src/core/request-store.js +115 -0
- package/dist/src/core/server-store.d.ts +42 -0
- package/dist/src/core/server-store.js +177 -0
- package/dist/src/core/ssh-session.d.ts +72 -0
- package/dist/src/core/ssh-session.js +243 -0
- package/dist/src/core/vault.d.ts +8 -0
- package/dist/src/core/vault.js +45 -0
- package/dist/src/gui-worker.d.ts +1 -0
- package/dist/src/gui-worker.js +212 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +149 -0
- package/dist/src/tools/format.d.ts +4 -0
- package/dist/src/tools/format.js +66 -0
- package/dist/src/tools/tools.d.ts +14 -0
- package/dist/src/tools/tools.js +247 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +16 -0
- package/gui/dist/assets/index-B2ykxkbY.css +1 -0
- package/gui/dist/assets/index-D1Fg5r8H.js +10 -0
- package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/gui/dist/index.html +13 -0
- package/package.json +58 -4
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { GateError } from "./errors.js";
|
|
2
|
+
import { toPublic } from "./server-store.js";
|
|
3
|
+
import { ShellSession } from "./ssh-session.js";
|
|
4
|
+
/** Sortie conservée par commande ; au-delà, le début est écarté. */
|
|
5
|
+
export const MAX_STORED_OUTPUT = 512 * 1024;
|
|
6
|
+
const PERSIST_INTERVAL_MS = 400;
|
|
7
|
+
function now() {
|
|
8
|
+
return new Date().toISOString();
|
|
9
|
+
}
|
|
10
|
+
function cleanNote(note) {
|
|
11
|
+
const text = typeof note === "string" ? note.trim() : "";
|
|
12
|
+
return text ? text : null;
|
|
13
|
+
}
|
|
14
|
+
function skipRemaining(draft, from) {
|
|
15
|
+
for (let i = from; i < draft.commands.length; i++) {
|
|
16
|
+
if (draft.commands[i].status === "pending")
|
|
17
|
+
draft.commands[i].status = "skipped";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Exécution des demandes validées. Une session SSH par serveur, ouverte à la
|
|
22
|
+
* première exécution, et une seule demande en cours par serveur.
|
|
23
|
+
*/
|
|
24
|
+
export class Executor {
|
|
25
|
+
servers;
|
|
26
|
+
requests;
|
|
27
|
+
log;
|
|
28
|
+
sessions = new Map();
|
|
29
|
+
running = new Map();
|
|
30
|
+
constructor(servers, requests, log) {
|
|
31
|
+
this.servers = servers;
|
|
32
|
+
this.requests = requests;
|
|
33
|
+
this.log = log;
|
|
34
|
+
}
|
|
35
|
+
/** Marque en échec les demandes restées « en cours » après l'arrêt du worker. */
|
|
36
|
+
recoverAbandoned() {
|
|
37
|
+
for (const request of this.requests.listOpen()) {
|
|
38
|
+
if (request.status !== "running")
|
|
39
|
+
continue;
|
|
40
|
+
this.requests.update(request.id, (draft) => {
|
|
41
|
+
for (const command of draft.commands) {
|
|
42
|
+
if (command.status === "running") {
|
|
43
|
+
command.status = "interrupted";
|
|
44
|
+
command.endedAt = now();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
skipRemaining(draft, 0);
|
|
48
|
+
draft.status = "failed";
|
|
49
|
+
draft.error = "L'interface s'est arrêtée pendant l'exécution : le résultat des commandes en cours est inconnu.";
|
|
50
|
+
draft.finishedAt = now();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
views() {
|
|
55
|
+
const open = this.requests.listOpen();
|
|
56
|
+
return this.servers.list().map((server) => this.view(server, open));
|
|
57
|
+
}
|
|
58
|
+
view(server, open = this.requests.listOpen()) {
|
|
59
|
+
const session = this.sessions.get(server.id);
|
|
60
|
+
return {
|
|
61
|
+
...toPublic(server),
|
|
62
|
+
session: session && !session.closed ? "connected" : "closed",
|
|
63
|
+
cwd: session && !session.closed ? session.cwd : null,
|
|
64
|
+
pendingCount: open.filter((r) => r.serverId === server.id && r.status === "pending").length,
|
|
65
|
+
busy: this.running.has(server.id),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
addServer(input) {
|
|
69
|
+
return this.view(this.servers.create(input));
|
|
70
|
+
}
|
|
71
|
+
updateServer(id, input) {
|
|
72
|
+
const before = this.servers.get(id);
|
|
73
|
+
const after = this.servers.update(before.id, input);
|
|
74
|
+
if (before.host !== after.host || before.port !== after.port || before.username !== after.username)
|
|
75
|
+
this.disconnect(after.id);
|
|
76
|
+
return this.view(after);
|
|
77
|
+
}
|
|
78
|
+
deleteServer(id) {
|
|
79
|
+
const server = this.servers.get(id);
|
|
80
|
+
if (this.running.has(server.id))
|
|
81
|
+
throw new GateError("Une exécution est en cours sur ce serveur : attends sa fin ou interromps-la.");
|
|
82
|
+
this.disconnect(server.id);
|
|
83
|
+
this.servers.delete(server.id);
|
|
84
|
+
this.requests.deleteServer(server.id);
|
|
85
|
+
}
|
|
86
|
+
/** Ouvre une nouvelle session ; enregistre l'empreinte de l'hôte au premier test. */
|
|
87
|
+
async testConnection(id) {
|
|
88
|
+
const server = this.servers.get(id);
|
|
89
|
+
if (this.running.has(server.id))
|
|
90
|
+
throw new GateError("Une exécution est en cours sur ce serveur.");
|
|
91
|
+
this.disconnect(server.id);
|
|
92
|
+
const session = await this.openSession(server);
|
|
93
|
+
if (!server.hostKeyFingerprint)
|
|
94
|
+
this.servers.setFingerprint(server.id, session.fingerprint);
|
|
95
|
+
return { server: this.view(this.servers.get(server.id)), fingerprint: session.fingerprint, cwd: session.cwd };
|
|
96
|
+
}
|
|
97
|
+
disconnect(serverId) {
|
|
98
|
+
const session = this.sessions.get(serverId);
|
|
99
|
+
this.sessions.delete(serverId);
|
|
100
|
+
session?.close();
|
|
101
|
+
}
|
|
102
|
+
approve(requestId, note) {
|
|
103
|
+
const request = this.requests.get(requestId);
|
|
104
|
+
this.assertIdle(request.serverId);
|
|
105
|
+
const updated = this.requests.update(requestId, (draft) => {
|
|
106
|
+
if (draft.status !== "pending")
|
|
107
|
+
throw new GateError(`Cette demande n'est plus en attente (${draft.status}).`);
|
|
108
|
+
draft.status = "running";
|
|
109
|
+
draft.note = cleanNote(note);
|
|
110
|
+
draft.decidedAt = now();
|
|
111
|
+
});
|
|
112
|
+
this.start(updated);
|
|
113
|
+
return updated;
|
|
114
|
+
}
|
|
115
|
+
refuse(requestId, note) {
|
|
116
|
+
return this.requests.update(requestId, (draft) => {
|
|
117
|
+
if (draft.status !== "pending")
|
|
118
|
+
throw new GateError(`Cette demande n'est plus en attente (${draft.status}).`);
|
|
119
|
+
draft.status = "refused";
|
|
120
|
+
draft.note = cleanNote(note);
|
|
121
|
+
draft.decidedAt = now();
|
|
122
|
+
draft.finishedAt = draft.decidedAt;
|
|
123
|
+
skipRemaining(draft, 0);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
editCommand(requestId, index, command) {
|
|
127
|
+
const text = typeof command === "string" ? command.trim() : "";
|
|
128
|
+
if (!text)
|
|
129
|
+
throw new GateError("La commande ne peut pas être vide.");
|
|
130
|
+
return this.requests.update(requestId, (draft) => {
|
|
131
|
+
if (draft.status !== "pending")
|
|
132
|
+
throw new GateError(`Cette demande n'est plus en attente (${draft.status}).`);
|
|
133
|
+
const entry = draft.commands[index];
|
|
134
|
+
if (!entry)
|
|
135
|
+
throw new GateError(`Commande ${index + 1} introuvable.`);
|
|
136
|
+
const proposed = entry.original ?? entry.command;
|
|
137
|
+
entry.command = text;
|
|
138
|
+
entry.original = text === proposed ? null : proposed;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
removeCommand(requestId, index) {
|
|
142
|
+
return this.requests.update(requestId, (draft) => {
|
|
143
|
+
if (draft.status !== "pending")
|
|
144
|
+
throw new GateError(`Cette demande n'est plus en attente (${draft.status}).`);
|
|
145
|
+
if (!draft.commands[index])
|
|
146
|
+
throw new GateError(`Commande ${index + 1} introuvable.`);
|
|
147
|
+
if (draft.commands.length === 1)
|
|
148
|
+
throw new GateError("Une demande garde au moins une commande : refuse-la plutôt.");
|
|
149
|
+
draft.commands.splice(index, 1);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/** Commande saisie par l'utilisateur : exécutée aussitôt, sans validation. */
|
|
153
|
+
runManual(serverId, command) {
|
|
154
|
+
const server = this.servers.get(serverId);
|
|
155
|
+
this.assertIdle(server.id);
|
|
156
|
+
const created = this.requests.create({ serverId: server.id, source: "user", explanation: "", commands: [String(command ?? "")] });
|
|
157
|
+
const updated = this.requests.update(created.id, (draft) => {
|
|
158
|
+
draft.status = "running";
|
|
159
|
+
draft.decidedAt = now();
|
|
160
|
+
});
|
|
161
|
+
this.start(updated);
|
|
162
|
+
return updated;
|
|
163
|
+
}
|
|
164
|
+
async interrupt(requestId) {
|
|
165
|
+
const request = this.requests.get(requestId);
|
|
166
|
+
const entry = this.running.get(request.serverId);
|
|
167
|
+
if (!entry || entry.requestId !== request.id)
|
|
168
|
+
throw new GateError("Cette demande n'est pas en cours d'exécution.");
|
|
169
|
+
const session = this.sessions.get(request.serverId);
|
|
170
|
+
if (!session || session.closed)
|
|
171
|
+
throw new GateError("Le shell de ce serveur est fermé.");
|
|
172
|
+
entry.interruptRequested = true;
|
|
173
|
+
try {
|
|
174
|
+
await session.interrupt();
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
entry.interruptRequested = false;
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
assertIdle(serverId) {
|
|
182
|
+
if (this.running.has(serverId))
|
|
183
|
+
throw new GateError("Une exécution est déjà en cours sur ce serveur.");
|
|
184
|
+
}
|
|
185
|
+
async openSession(server) {
|
|
186
|
+
const session = await ShellSession.open({
|
|
187
|
+
host: server.host,
|
|
188
|
+
port: server.port,
|
|
189
|
+
username: server.username,
|
|
190
|
+
privateKey: this.servers.privateKey(server.id),
|
|
191
|
+
expectedFingerprint: server.hostKeyFingerprint,
|
|
192
|
+
}, (reason) => {
|
|
193
|
+
if (this.sessions.get(server.id) === session)
|
|
194
|
+
this.sessions.delete(server.id);
|
|
195
|
+
this.log(`Session ${server.id} closed: ${reason}`);
|
|
196
|
+
});
|
|
197
|
+
this.sessions.set(server.id, session);
|
|
198
|
+
return session;
|
|
199
|
+
}
|
|
200
|
+
async sessionFor(serverId) {
|
|
201
|
+
const existing = this.sessions.get(serverId);
|
|
202
|
+
if (existing && !existing.closed)
|
|
203
|
+
return existing;
|
|
204
|
+
const server = this.servers.get(serverId);
|
|
205
|
+
if (!server.hostKeyFingerprint) {
|
|
206
|
+
throw new GateError("La connexion à ce serveur n'a jamais été testée : lance « Tester la connexion » dans l'interface.");
|
|
207
|
+
}
|
|
208
|
+
return this.openSession(server);
|
|
209
|
+
}
|
|
210
|
+
start(request) {
|
|
211
|
+
const entry = { requestId: request.id, interruptRequested: false };
|
|
212
|
+
this.running.set(request.serverId, entry);
|
|
213
|
+
this.execute(request, entry)
|
|
214
|
+
.catch((err) => this.log(`Execution of ${request.id} failed: ${err?.stack ?? err}`))
|
|
215
|
+
.finally(() => {
|
|
216
|
+
if (this.running.get(request.serverId) === entry)
|
|
217
|
+
this.running.delete(request.serverId);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
finish(requestId, status, error, from) {
|
|
221
|
+
this.requests.update(requestId, (draft) => {
|
|
222
|
+
skipRemaining(draft, from);
|
|
223
|
+
draft.status = status;
|
|
224
|
+
draft.error = error;
|
|
225
|
+
draft.finishedAt = now();
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async execute(request, entry) {
|
|
229
|
+
let session;
|
|
230
|
+
try {
|
|
231
|
+
session = await this.sessionFor(request.serverId);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
this.finish(request.id, "failed", err.message, 0);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const total = this.requests.get(request.id).commands.length;
|
|
238
|
+
for (let index = 0; index < total; index++) {
|
|
239
|
+
if (entry.interruptRequested) {
|
|
240
|
+
this.finish(request.id, "interrupted", null, index);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const command = this.requests.get(request.id).commands[index].command;
|
|
244
|
+
const stopped = await this.runOne(request.id, index, command, session, entry);
|
|
245
|
+
if (stopped) {
|
|
246
|
+
this.finish(request.id, stopped.status, stopped.error, index + 1);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
this.finish(request.id, "completed", null, total);
|
|
251
|
+
}
|
|
252
|
+
/** Exécute une commande ; renvoie de quoi clore la demande si l'exécution doit s'arrêter là. */
|
|
253
|
+
async runOne(requestId, index, command, session, entry) {
|
|
254
|
+
let syntaxError;
|
|
255
|
+
try {
|
|
256
|
+
syntaxError = await session.checkSyntax(command);
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
this.requests.update(requestId, (draft) => {
|
|
260
|
+
draft.commands[index].status = "failed";
|
|
261
|
+
draft.commands[index].endedAt = now();
|
|
262
|
+
});
|
|
263
|
+
return { status: "failed", error: err.message };
|
|
264
|
+
}
|
|
265
|
+
if (syntaxError) {
|
|
266
|
+
this.requests.update(requestId, (draft) => {
|
|
267
|
+
const target = draft.commands[index];
|
|
268
|
+
target.status = "failed";
|
|
269
|
+
target.output = `Erreur de syntaxe, commande non exécutée :\n${syntaxError}`;
|
|
270
|
+
target.startedAt = now();
|
|
271
|
+
target.endedAt = target.startedAt;
|
|
272
|
+
});
|
|
273
|
+
return { status: "failed", error: null };
|
|
274
|
+
}
|
|
275
|
+
this.requests.update(requestId, (draft) => {
|
|
276
|
+
draft.commands[index].status = "running";
|
|
277
|
+
draft.commands[index].startedAt = now();
|
|
278
|
+
});
|
|
279
|
+
let output = "";
|
|
280
|
+
let truncated = false;
|
|
281
|
+
let dirty = false;
|
|
282
|
+
const flush = () => {
|
|
283
|
+
if (!dirty)
|
|
284
|
+
return;
|
|
285
|
+
dirty = false;
|
|
286
|
+
this.requests.update(requestId, (draft) => {
|
|
287
|
+
draft.commands[index].output = output;
|
|
288
|
+
draft.commands[index].outputTruncated = truncated;
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
const timer = setInterval(flush, PERSIST_INTERVAL_MS);
|
|
292
|
+
try {
|
|
293
|
+
const result = await session.run(command, (text) => {
|
|
294
|
+
output += text;
|
|
295
|
+
if (output.length > MAX_STORED_OUTPUT) {
|
|
296
|
+
output = output.slice(-MAX_STORED_OUTPUT);
|
|
297
|
+
truncated = true;
|
|
298
|
+
}
|
|
299
|
+
dirty = true;
|
|
300
|
+
});
|
|
301
|
+
clearInterval(timer);
|
|
302
|
+
const status = entry.interruptRequested ? "interrupted" : result.exitCode === 0 ? "succeeded" : "failed";
|
|
303
|
+
this.requests.update(requestId, (draft) => {
|
|
304
|
+
const target = draft.commands[index];
|
|
305
|
+
target.output = output;
|
|
306
|
+
target.outputTruncated = truncated;
|
|
307
|
+
target.exitCode = result.exitCode;
|
|
308
|
+
target.status = status;
|
|
309
|
+
target.endedAt = now();
|
|
310
|
+
draft.cwd = result.cwd;
|
|
311
|
+
});
|
|
312
|
+
if (status === "succeeded")
|
|
313
|
+
return null;
|
|
314
|
+
return { status: status === "interrupted" ? "interrupted" : "failed", error: null };
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
clearInterval(timer);
|
|
318
|
+
this.requests.update(requestId, (draft) => {
|
|
319
|
+
const target = draft.commands[index];
|
|
320
|
+
target.output = output;
|
|
321
|
+
target.outputTruncated = truncated;
|
|
322
|
+
target.status = entry.interruptRequested ? "interrupted" : "failed";
|
|
323
|
+
target.endedAt = now();
|
|
324
|
+
});
|
|
325
|
+
return { status: entry.interruptRequested ? "interrupted" : "failed", error: err.message };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sérialise un cycle lire-modifier-écrire entre processus : `wx` échoue si le
|
|
3
|
+
* verrou existe déjà. Un verrou plus vieux que LOCK_STALE_MS est cassé.
|
|
4
|
+
* Non réentrant.
|
|
5
|
+
*/
|
|
6
|
+
export declare function withFileLock<T>(lockPath: string, subject: string, fn: () => T): T;
|
|
7
|
+
/** Écrit un JSON par renommage atomique : un lecteur voit l'ancien contenu ou le nouveau, jamais un fichier tronqué. */
|
|
8
|
+
export declare function writeJsonAtomic(file: string, value: unknown, mode?: number): void;
|
|
9
|
+
export declare function readJson<T>(file: string): T;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { GateError } from "./errors.js";
|
|
4
|
+
const LOCK_STALE_MS = 10_000;
|
|
5
|
+
const LOCK_RETRY_MS = 25;
|
|
6
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
7
|
+
function sleepSync(ms) {
|
|
8
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Sérialise un cycle lire-modifier-écrire entre processus : `wx` échoue si le
|
|
12
|
+
* verrou existe déjà. Un verrou plus vieux que LOCK_STALE_MS est cassé.
|
|
13
|
+
* Non réentrant.
|
|
14
|
+
*/
|
|
15
|
+
export function withFileLock(lockPath, subject, fn) {
|
|
16
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
17
|
+
let fd;
|
|
18
|
+
for (;;) {
|
|
19
|
+
try {
|
|
20
|
+
fd = fs.openSync(lockPath, "wx");
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err.code !== "EEXIST")
|
|
25
|
+
throw err;
|
|
26
|
+
try {
|
|
27
|
+
if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
28
|
+
fs.unlinkSync(lockPath);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (Date.now() > deadline) {
|
|
36
|
+
throw new GateError(`${subject} is being written by another process and did not free up in ${LOCK_TIMEOUT_MS} ms. Try again in a moment.`);
|
|
37
|
+
}
|
|
38
|
+
sleepSync(LOCK_RETRY_MS);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return fn();
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
fs.closeSync(fd);
|
|
46
|
+
try {
|
|
47
|
+
fs.unlinkSync(lockPath);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Verrou déjà cassé pour obsolescence par un autre processus.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Écrit un JSON par renommage atomique : un lecteur voit l'ancien contenu ou le nouveau, jamais un fichier tronqué. */
|
|
55
|
+
export function writeJsonAtomic(file, value, mode) {
|
|
56
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
57
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
58
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { encoding: "utf8", mode });
|
|
59
|
+
fs.renameSync(tmp, file);
|
|
60
|
+
}
|
|
61
|
+
export function readJson(file) {
|
|
62
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
63
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const HEARTBEAT_INTERVAL_MS = 2000;
|
|
2
|
+
/** Écrit le battement du worker : son URL publique et l'heure courante. */
|
|
3
|
+
export declare function writeHeartbeat(dataDir: string, url: string): void;
|
|
4
|
+
/** URL du worker vivant qui sert l'interface, ou null s'il n'y en a pas. */
|
|
5
|
+
export declare function liveGuiUrl(dataDir: string): string | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readJson, writeJsonAtomic } from "./files.js";
|
|
4
|
+
/** Au-delà de ce délai sans battement, le worker de l'interface est réputé arrêté. */
|
|
5
|
+
const STALE_MS = 10_000;
|
|
6
|
+
export const HEARTBEAT_INTERVAL_MS = 2_000;
|
|
7
|
+
function fileOf(dataDir) {
|
|
8
|
+
return path.join(dataDir, "gui.json");
|
|
9
|
+
}
|
|
10
|
+
/** Écrit le battement du worker : son URL publique et l'heure courante. */
|
|
11
|
+
export function writeHeartbeat(dataDir, url) {
|
|
12
|
+
writeJsonAtomic(fileOf(dataDir), { pid: process.pid, url, updatedAt: Date.now() });
|
|
13
|
+
}
|
|
14
|
+
/** URL du worker vivant qui sert l'interface, ou null s'il n'y en a pas. */
|
|
15
|
+
export function liveGuiUrl(dataDir) {
|
|
16
|
+
const file = fileOf(dataDir);
|
|
17
|
+
if (!fs.existsSync(file))
|
|
18
|
+
return null;
|
|
19
|
+
try {
|
|
20
|
+
const beat = readJson(file);
|
|
21
|
+
return Date.now() - beat.updatedAt < STALE_MS ? beat.url : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type GateRequest, type RequestSource } from "../../shared/model.js";
|
|
2
|
+
export interface NewRequest {
|
|
3
|
+
serverId: string;
|
|
4
|
+
source: RequestSource;
|
|
5
|
+
explanation: string;
|
|
6
|
+
commands: string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Demandes d'exécution : un fichier JSON par demande dans
|
|
10
|
+
* `<DATA_DIR>/requests/<serveur>/`. Les processus MCP créent les demandes,
|
|
11
|
+
* le worker de l'interface les fait avancer.
|
|
12
|
+
*/
|
|
13
|
+
export declare class RequestStore {
|
|
14
|
+
private readonly root;
|
|
15
|
+
constructor(dataDir: string);
|
|
16
|
+
create(input: NewRequest): GateRequest;
|
|
17
|
+
get(id: string): GateRequest;
|
|
18
|
+
/** Demandes d'un serveur, les plus anciennes d'abord ; `limit` garde les plus récentes. */
|
|
19
|
+
listForServer(serverId: string, limit?: number): GateRequest[];
|
|
20
|
+
/** Demandes non terminées de tous les serveurs. */
|
|
21
|
+
listOpen(): GateRequest[];
|
|
22
|
+
update(id: string, fn: (draft: GateRequest) => void): GateRequest;
|
|
23
|
+
deleteServer(serverId: string): void;
|
|
24
|
+
private fileOf;
|
|
25
|
+
private locate;
|
|
26
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { MAX_COMMANDS_PER_REQUEST } from "../../shared/model.js";
|
|
5
|
+
import { GateError } from "./errors.js";
|
|
6
|
+
import { readJson, withFileLock, writeJsonAtomic } from "./files.js";
|
|
7
|
+
const ID_PATTERN = /^[0-9a-z]{9}-[0-9a-f]{6}$/;
|
|
8
|
+
/** Identifiant triable par date de création : horodatage base 36 sur 9 caractères, puis 6 caractères aléatoires. */
|
|
9
|
+
function newId() {
|
|
10
|
+
return `${Date.now().toString(36).padStart(9, "0")}-${randomBytes(3).toString("hex")}`;
|
|
11
|
+
}
|
|
12
|
+
function normalizeCommands(commands) {
|
|
13
|
+
if (!Array.isArray(commands) || commands.length === 0)
|
|
14
|
+
throw new GateError("At least one command is required.");
|
|
15
|
+
if (commands.length > MAX_COMMANDS_PER_REQUEST) {
|
|
16
|
+
throw new GateError(`A request holds at most ${MAX_COMMANDS_PER_REQUEST} commands; split the work into several requests.`);
|
|
17
|
+
}
|
|
18
|
+
return commands.map((raw, index) => {
|
|
19
|
+
const command = String(raw ?? "").trim();
|
|
20
|
+
if (!command)
|
|
21
|
+
throw new GateError(`Command ${index + 1} is empty.`);
|
|
22
|
+
return command;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Demandes d'exécution : un fichier JSON par demande dans
|
|
27
|
+
* `<DATA_DIR>/requests/<serveur>/`. Les processus MCP créent les demandes,
|
|
28
|
+
* le worker de l'interface les fait avancer.
|
|
29
|
+
*/
|
|
30
|
+
export class RequestStore {
|
|
31
|
+
root;
|
|
32
|
+
constructor(dataDir) {
|
|
33
|
+
this.root = path.join(dataDir, "requests");
|
|
34
|
+
fs.mkdirSync(this.root, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
create(input) {
|
|
37
|
+
const commands = normalizeCommands(input.commands);
|
|
38
|
+
const request = {
|
|
39
|
+
id: newId(),
|
|
40
|
+
serverId: input.serverId,
|
|
41
|
+
source: input.source,
|
|
42
|
+
explanation: String(input.explanation ?? "").trim(),
|
|
43
|
+
createdAt: new Date().toISOString(),
|
|
44
|
+
status: "pending",
|
|
45
|
+
note: null,
|
|
46
|
+
decidedAt: null,
|
|
47
|
+
finishedAt: null,
|
|
48
|
+
error: null,
|
|
49
|
+
cwd: null,
|
|
50
|
+
commands: commands.map((command) => ({
|
|
51
|
+
command,
|
|
52
|
+
original: null,
|
|
53
|
+
status: "pending",
|
|
54
|
+
exitCode: null,
|
|
55
|
+
output: "",
|
|
56
|
+
outputTruncated: false,
|
|
57
|
+
startedAt: null,
|
|
58
|
+
endedAt: null,
|
|
59
|
+
})),
|
|
60
|
+
};
|
|
61
|
+
writeJsonAtomic(this.fileOf(request.serverId, request.id), request);
|
|
62
|
+
return request;
|
|
63
|
+
}
|
|
64
|
+
get(id) {
|
|
65
|
+
return readJson(this.locate(id));
|
|
66
|
+
}
|
|
67
|
+
/** Demandes d'un serveur, les plus anciennes d'abord ; `limit` garde les plus récentes. */
|
|
68
|
+
listForServer(serverId, limit) {
|
|
69
|
+
const dir = path.join(this.root, serverId);
|
|
70
|
+
if (!fs.existsSync(dir))
|
|
71
|
+
return [];
|
|
72
|
+
let names = fs
|
|
73
|
+
.readdirSync(dir)
|
|
74
|
+
.filter((name) => name.endsWith(".json"))
|
|
75
|
+
.sort();
|
|
76
|
+
if (limit !== undefined)
|
|
77
|
+
names = names.slice(-limit);
|
|
78
|
+
return names.map((name) => readJson(path.join(dir, name)));
|
|
79
|
+
}
|
|
80
|
+
/** Demandes non terminées de tous les serveurs. */
|
|
81
|
+
listOpen() {
|
|
82
|
+
if (!fs.existsSync(this.root))
|
|
83
|
+
return [];
|
|
84
|
+
return fs
|
|
85
|
+
.readdirSync(this.root)
|
|
86
|
+
.flatMap((serverId) => this.listForServer(serverId))
|
|
87
|
+
.filter((request) => request.status === "pending" || request.status === "running");
|
|
88
|
+
}
|
|
89
|
+
update(id, fn) {
|
|
90
|
+
const file = this.locate(id);
|
|
91
|
+
return withFileLock(`${file}.lock`, `Request ${id}`, () => {
|
|
92
|
+
const draft = readJson(file);
|
|
93
|
+
fn(draft);
|
|
94
|
+
writeJsonAtomic(file, draft);
|
|
95
|
+
return draft;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
deleteServer(serverId) {
|
|
99
|
+
fs.rmSync(path.join(this.root, serverId), { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
fileOf(serverId, id) {
|
|
102
|
+
return path.join(this.root, serverId, `${id}.json`);
|
|
103
|
+
}
|
|
104
|
+
locate(id) {
|
|
105
|
+
const value = String(id ?? "").trim();
|
|
106
|
+
if (!ID_PATTERN.test(value))
|
|
107
|
+
throw new GateError(`Invalid request id "${id}".`);
|
|
108
|
+
for (const serverId of fs.readdirSync(this.root)) {
|
|
109
|
+
const file = this.fileOf(serverId, value);
|
|
110
|
+
if (fs.existsSync(file))
|
|
111
|
+
return file;
|
|
112
|
+
}
|
|
113
|
+
throw new GateError(`Unknown request "${id}".`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type NotesAuthor, type ServerPublic } from "../../shared/model.js";
|
|
2
|
+
export interface StoredServer extends ServerPublic {
|
|
3
|
+
/** Clé privée OpenSSH chiffrée avec VAULT_KEY. */
|
|
4
|
+
privateKeyEncrypted: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ServerInput {
|
|
7
|
+
name: string;
|
|
8
|
+
host: string;
|
|
9
|
+
port: number;
|
|
10
|
+
username: string;
|
|
11
|
+
}
|
|
12
|
+
/** Contrôle et normalise les champs saisis pour un serveur. */
|
|
13
|
+
export declare function validateServerInput(input: ServerInput): ServerInput;
|
|
14
|
+
export declare function toPublic(server: StoredServer): ServerPublic;
|
|
15
|
+
/**
|
|
16
|
+
* Serveurs SSH : `<DATA_DIR>/servers.json`. La paire de clés est générée à la
|
|
17
|
+
* création ; seule la clé publique est lisible, la clé privée est chiffrée.
|
|
18
|
+
*/
|
|
19
|
+
export declare class ServerStore {
|
|
20
|
+
private readonly vaultKey;
|
|
21
|
+
private readonly file;
|
|
22
|
+
private readonly lockFile;
|
|
23
|
+
constructor(dataDir: string, vaultKey: Buffer);
|
|
24
|
+
list(): StoredServer[];
|
|
25
|
+
/** Retrouve un serveur par identifiant ou par nom (sans tenir compte de la casse). */
|
|
26
|
+
get(idOrName: string): StoredServer;
|
|
27
|
+
create(input: ServerInput): StoredServer;
|
|
28
|
+
/** Modifie les champs saisis. Un changement d'hôte ou de port oublie l'empreinte : c'est une autre machine. */
|
|
29
|
+
update(id: string, input: ServerInput): StoredServer;
|
|
30
|
+
setFingerprint(id: string, fingerprint: string): StoredServer;
|
|
31
|
+
/**
|
|
32
|
+
* Remplace les notes. Avec `expectedUpdatedAt`, l'écriture est refusée si les
|
|
33
|
+
* notes ont changé depuis cette date : une saisie ne doit pas écraser une
|
|
34
|
+
* mise à jour faite entre-temps par l'agent ou par un autre onglet.
|
|
35
|
+
*/
|
|
36
|
+
setNotes(id: string, text: string, by: NotesAuthor, expectedUpdatedAt?: string | null): StoredServer;
|
|
37
|
+
delete(id: string): void;
|
|
38
|
+
privateKey(id: string): string;
|
|
39
|
+
/** Vérifie que VAULT_KEY déchiffre toutes les clés enregistrées. */
|
|
40
|
+
verifyVault(): void;
|
|
41
|
+
private mutate;
|
|
42
|
+
}
|