@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
package/README.md
CHANGED
|
@@ -1,3 +1,129 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @imenam/mcp-ssh-gate
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Un agent propose des commandes à exécuter sur un serveur de production ; tu les lis, les modifies ou les refuses dans une interface web, et seules celles que tu valides partent sur le serveur, en SSH. Le résultat revient à l'agent sans copier-coller.
|
|
4
|
+
|
|
5
|
+
L'agent ne détient aucun accès au serveur. La paire de clés SSH est générée par le MCP à l'ajout d'un serveur ; tu poses la clé publique sur le serveur, la clé privée reste chiffrée dans le MCP et n'est jamais affichée.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Agent ──submit_commands──▶ file d'attente ──▶ Interface : tu valides, modifies ou refuses
|
|
9
|
+
▲ │
|
|
10
|
+
└──── wait_for_result ◀── sortie, code de retour ◀── SSH ─┘ (shell bash persistant)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g @imenam/mcp-ssh-gate
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Ou directement via `npx`, sans installation.
|
|
22
|
+
|
|
23
|
+
### Déclaration dans un projet
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx -y @imenam/mcp-ssh-gate --claude-setup-mcp
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Crée ou complète le `.mcp.json` du dossier courant avec une entrée `ssh-gate` et une `VAULT_KEY` générée. Relancé avec `--force`, il remplace l'entrée en **conservant** la `VAULT_KEY` existante.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Interface derrière le proxy central
|
|
33
|
+
npx -y @imenam/mcp-ssh-gate --claude-setup-mcp --proxy_url=http://localhost:3000
|
|
34
|
+
|
|
35
|
+
# Interface en accès direct, sans proxy : http://localhost:4600
|
|
36
|
+
npx -y @imenam/mcp-ssh-gate --claude-setup-mcp --app_port=4600
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Derrière le gateway manager
|
|
40
|
+
|
|
41
|
+
Déclare le MCP comme les autres, et ajoute `VAULT_KEY` en cochant **secret** : le manager la stocke chiffrée avec sa clé maître et la réinjecte au lancement. Une clé se génère avec :
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx -y @imenam/mcp-ssh-gate --generate-vault-key
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Variables d'environnement
|
|
48
|
+
|
|
49
|
+
| Variable | Défaut | Rôle |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `DATA_DIR` | — | **Requis.** Serveurs, clés chiffrées et historique des commandes. Créé s'il n'existe pas. |
|
|
52
|
+
| `VAULT_KEY` | — | **Requis.** 32 octets en base64. Chiffre les clés privées SSH (AES-256-GCM). La perdre ou la changer rend les clés existantes illisibles : le MCP refuse alors de démarrer. |
|
|
53
|
+
| `PROXY_URL` | — | URL du proxy qui expose l'interface. |
|
|
54
|
+
| `APP_PORT` | — | Port local de l'interface quand il n'y a pas de proxy. `PROXY_URL` ou `APP_PORT` est **requis** : sans interface, rien ne peut être validé. `PROXY_URL` prime si les deux sont renseignés. |
|
|
55
|
+
| `APP_GROUP` | — | Groupe d'affichage dans le dashboard du proxy. |
|
|
56
|
+
| `APP_NAME` | `SSH Gate` | Nom affiché dans le dashboard du proxy. |
|
|
57
|
+
| `APP_PATH` | `/ssh-gate` | Chemin de montage derrière le proxy. |
|
|
58
|
+
| `MCP_LOG_DIR` | `<DATA_DIR>/logs` | Répertoire des logs. |
|
|
59
|
+
|
|
60
|
+
Un réglage requis absent ou invalide arrête le lancement avec un message qui dit quoi corriger.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Ajouter un serveur
|
|
65
|
+
|
|
66
|
+
1. **Ajouter un serveur** dans l'interface : nom, hôte, port, utilisateur.
|
|
67
|
+
2. Le MCP génère une paire `ed25519`. La clé privée est chiffrée avec `VAULT_KEY` dans `DATA_DIR/servers.json`.
|
|
68
|
+
3. Copie la clé publique affichée dans `~/.ssh/authorized_keys` de l'utilisateur, sur le serveur (une commande prête à coller est proposée).
|
|
69
|
+
4. **Tester la connexion** : l'empreinte de la clé d'hôte est enregistrée. Si elle change plus tard, la connexion est refusée. Changer l'hôte ou le port du serveur efface l'empreinte : il faut tester à nouveau.
|
|
70
|
+
|
|
71
|
+
Tant que la connexion n'a pas été testée, l'agent ne peut pas soumettre de commandes à ce serveur.
|
|
72
|
+
|
|
73
|
+
## Les outils
|
|
74
|
+
|
|
75
|
+
| Outil | Rôle |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `list_servers` | Serveurs configurés, si leur connexion a été testée, et leurs notes. |
|
|
78
|
+
| `update_server_notes` | Complète (`append`, par défaut) ou réécrit (`replace`) les notes d'un serveur. |
|
|
79
|
+
| `submit_commands` | Soumet 1 à 20 commandes avec une explication. Rien ne s'exécute avant ta validation. Renvoie un `request_id`. |
|
|
80
|
+
| `wait_for_result` | Attend ta décision et la fin de l'exécution, puis renvoie le refus et son motif, ou chaque commande avec son code de retour et sa sortie, et ta note. Rend la main au bout de `timeout_seconds` (300 par défaut, 1800 au plus) : il suffit de le rappeler. |
|
|
81
|
+
| `cancel_request` | Retire une demande pas encore validée. |
|
|
82
|
+
| `get_history` | Dernières demandes d'un serveur, y compris les commandes que tu as tapées toi-même. |
|
|
83
|
+
| `reconnect_gui` | Relance l'interface si elle ne tourne plus, et donne son URL. |
|
|
84
|
+
|
|
85
|
+
## Notes du serveur
|
|
86
|
+
|
|
87
|
+
Chaque serveur porte des notes en texte libre : dossier des sites, gestion des applis (PM2, systemd…), emplacement des configurations, règles à respecter. L'agent les reçoit avec `list_servers` avant d'intervenir et les complète avec `update_server_notes` quand il découvre quelque chose d'utile ; tu les lis et les modifies dans l'onglet **Notes** du serveur.
|
|
88
|
+
|
|
89
|
+
Les notes sont limitées à 20 000 caractères. Une modification faite dans l'interface est refusée si l'agent a changé les notes pendant la saisie, pour ne pas écraser sa mise à jour.
|
|
90
|
+
|
|
91
|
+
## Exécution
|
|
92
|
+
|
|
93
|
+
- Les commandes validées s'exécutent **dans l'ordre**, dans un shell `bash -l` **persistant** par serveur : un `cd` ou un `export` reste actif pour les commandes et les demandes suivantes.
|
|
94
|
+
- **Pas de terminal** : l'entrée standard est `/dev/null` (une commande qui attend une saisie reçoit une fin de fichier), stderr est fusionné dans stdout. `sudo` doit être configuré sans mot de passe pour l'utilisateur SSH.
|
|
95
|
+
- Chaque commande est d'abord vérifiée avec `bash -n` : une erreur de syntaxe l'arrête sans rien exécuter.
|
|
96
|
+
- Le lot **s'arrête à la première commande** dont le code de retour n'est pas 0 ; les suivantes sont marquées « non exécutée ».
|
|
97
|
+
- **Interrompre (Ctrl+C)** envoie `SIGINT` aux processus lancés par le shell (`pkill -INT -P`, à installer sur le serveur s'il manque). Une boucle exécutée par le shell lui-même n'a pas de processus à interrompre : il faut fermer le shell.
|
|
98
|
+
- Chaque commande garde au plus 512 Ko de sortie (la fin) ; l'agent en reçoit les 20 000 derniers caractères.
|
|
99
|
+
|
|
100
|
+
## L'interface
|
|
101
|
+
|
|
102
|
+
- **Barre latérale** : les serveurs, l'état de leur shell, une pastille par demande en attente.
|
|
103
|
+
- **Journal** : chaque commande exécutée, son auteur (Agent ou Toi), son heure, son code de retour ; un clic déplie sa sortie.
|
|
104
|
+
- **Demande en attente** : l'explication de l'agent, les commandes numérotées. Au survol d'une ligne, le crayon la modifie (Entrée pour valider, Échap pour annuler) et la croix la retire. La note pour l'agent est jointe au résultat, ou sert de motif au refus.
|
|
105
|
+
- **Exécution en cours** : l'avancement commande par commande et la sortie en direct.
|
|
106
|
+
- **Onglets Session / Notes** dans l'en-tête du serveur ; la pastille de demande en attente reste visible sur « Session ».
|
|
107
|
+
- **Saisie manuelle** en bas d'écran : une commande tapée là s'exécute aussitôt dans le même shell, et l'agent la voit dans l'historique.
|
|
108
|
+
|
|
109
|
+
L'interface se met à jour par rafraîchissement périodique.
|
|
110
|
+
|
|
111
|
+
## Architecture
|
|
112
|
+
|
|
113
|
+
- Chaque session d'agent lance son processus MCP ; **un seul worker** sert l'interface et exécute les commandes pour toutes. Il écrit un battement dans `DATA_DIR/gui.json` ; un processus MCP qui ne le voit plus en démarre un autre.
|
|
114
|
+
- Les demandes sont des fichiers JSON dans `DATA_DIR/requests/<serveur>/`, écrits par renommage atomique et modifiés sous verrou de fichier.
|
|
115
|
+
- Les sessions SSH vivent dans le worker. S'il s'arrête pendant une exécution, la demande est marquée en échec au démarrage suivant : le résultat des commandes en cours est inconnu.
|
|
116
|
+
|
|
117
|
+
## Développement
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npm install
|
|
121
|
+
npm run build # serveur et interface
|
|
122
|
+
npm test # dont un test de bout en bout contre un faux serveur SSH qui exécute bash
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Publication
|
|
126
|
+
|
|
127
|
+
Chaque push sur `master`, merge de pull request compris, publie une nouvelle version sur npm (`.github/workflows/publish.yml`) : `npm ci`, build, tests, puis `npm version patch`, `npm publish`, et enfin le push sur `master` du commit et du tag de version. La publication passe par le trusted publishing de npm (OIDC, sans token), avec attestation de provenance. Un échec du build ou des tests arrête le workflow avant la montée de version ; un échec de `npm publish` l'arrête avant le push, si bien que relancer le run reprend la même version.
|
|
128
|
+
|
|
129
|
+
Sur npmjs.com, le paquet déclare ce workflow comme publieur de confiance (Settings → Trusted publishing → GitHub Actions : `Matthieu-Pesnot-Pin`, `mcp-ssh-gate`, `publish.yml`, avec `npm publish` dans les actions autorisées). Le commit de version est poussé avec `GITHUB_TOKEN` : si `master` est protégée, la règle doit autoriser GitHub Actions à y pousser.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Auteur d'une demande : l'agent via les outils MCP, ou l'utilisateur via la saisie manuelle. */
|
|
2
|
+
export type RequestSource = "agent" | "user";
|
|
3
|
+
export type RequestStatus = "pending" | "running" | "completed" | "failed" | "interrupted" | "refused" | "cancelled";
|
|
4
|
+
export type CommandStatus = "pending" | "running" | "succeeded" | "failed" | "interrupted" | "skipped";
|
|
5
|
+
/** Statuts après lesquels une demande ne change plus. */
|
|
6
|
+
export declare const FINAL_STATUSES: readonly RequestStatus[];
|
|
7
|
+
export declare function isFinalStatus(status: RequestStatus): boolean;
|
|
8
|
+
export interface CommandRun {
|
|
9
|
+
/** Commande exécutée, ou à exécuter. */
|
|
10
|
+
command: string;
|
|
11
|
+
/** Commande proposée par l'agent quand l'utilisateur l'a modifiée, sinon null. */
|
|
12
|
+
original: string | null;
|
|
13
|
+
status: CommandStatus;
|
|
14
|
+
exitCode: number | null;
|
|
15
|
+
/** Sortie standard et d'erreur, fusionnées. */
|
|
16
|
+
output: string;
|
|
17
|
+
/** Vrai quand le début de la sortie a été écarté pour rester sous la limite de stockage. */
|
|
18
|
+
outputTruncated: boolean;
|
|
19
|
+
startedAt: string | null;
|
|
20
|
+
endedAt: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface GateRequest {
|
|
23
|
+
id: string;
|
|
24
|
+
serverId: string;
|
|
25
|
+
source: RequestSource;
|
|
26
|
+
/** Explication de l'agent ; vide pour une commande saisie à la main. */
|
|
27
|
+
explanation: string;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
status: RequestStatus;
|
|
30
|
+
/** Note de l'utilisateur : jointe au résultat, ou motif du refus. */
|
|
31
|
+
note: string | null;
|
|
32
|
+
decidedAt: string | null;
|
|
33
|
+
finishedAt: string | null;
|
|
34
|
+
/** Erreur qui a arrêté l'exécution hors du code de retour d'une commande (connexion perdue…). */
|
|
35
|
+
error: string | null;
|
|
36
|
+
/** Répertoire courant du shell à la fin de l'exécution. */
|
|
37
|
+
cwd: string | null;
|
|
38
|
+
commands: CommandRun[];
|
|
39
|
+
}
|
|
40
|
+
export interface ServerPublic {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
host: string;
|
|
44
|
+
port: number;
|
|
45
|
+
username: string;
|
|
46
|
+
publicKey: string;
|
|
47
|
+
/** Empreinte SHA256 de la clé d'hôte, enregistrée au premier test de connexion. */
|
|
48
|
+
hostKeyFingerprint: string | null;
|
|
49
|
+
createdAt: string;
|
|
50
|
+
/** Contexte du serveur en texte libre, lu par l'agent avant d'intervenir. */
|
|
51
|
+
notes: string;
|
|
52
|
+
notesUpdatedAt: string | null;
|
|
53
|
+
notesUpdatedBy: NotesAuthor | null;
|
|
54
|
+
}
|
|
55
|
+
export type NotesAuthor = "agent" | "user";
|
|
56
|
+
export declare const MAX_NOTES_LENGTH = 20000;
|
|
57
|
+
export type SessionState = "connected" | "closed";
|
|
58
|
+
export interface ServerView extends ServerPublic {
|
|
59
|
+
session: SessionState;
|
|
60
|
+
cwd: string | null;
|
|
61
|
+
pendingCount: number;
|
|
62
|
+
busy: boolean;
|
|
63
|
+
}
|
|
64
|
+
export interface StateResponse {
|
|
65
|
+
version: string;
|
|
66
|
+
servers: ServerView[];
|
|
67
|
+
}
|
|
68
|
+
export interface RequestsResponse {
|
|
69
|
+
requests: GateRequest[];
|
|
70
|
+
}
|
|
71
|
+
export interface TestConnectionResponse {
|
|
72
|
+
server: ServerView;
|
|
73
|
+
fingerprint: string;
|
|
74
|
+
cwd: string;
|
|
75
|
+
}
|
|
76
|
+
export declare const MAX_COMMANDS_PER_REQUEST = 20;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Statuts après lesquels une demande ne change plus. */
|
|
2
|
+
export const FINAL_STATUSES = ["completed", "failed", "interrupted", "refused", "cancelled"];
|
|
3
|
+
export function isFinalStatus(status) {
|
|
4
|
+
return FINAL_STATUSES.includes(status);
|
|
5
|
+
}
|
|
6
|
+
export const MAX_NOTES_LENGTH = 20_000;
|
|
7
|
+
export const MAX_COMMANDS_PER_REQUEST = 20;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface GuiExposure {
|
|
2
|
+
proxyUrl?: string;
|
|
3
|
+
appPort?: number;
|
|
4
|
+
}
|
|
5
|
+
/** Traduit `--proxy_url=` / `--app_port=` en mode d'exposition de l'interface ; les deux ensemble sont refusés. */
|
|
6
|
+
export declare function parseGuiExposure(argv: string[]): {
|
|
7
|
+
exposure: GuiExposure;
|
|
8
|
+
} | {
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
export interface SetupMcpOptions {
|
|
12
|
+
cwd: string;
|
|
13
|
+
force: boolean;
|
|
14
|
+
exposure?: GuiExposure;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Écrit l'entrée du serveur dans le .mcp.json du dossier courant. Une
|
|
18
|
+
* VAULT_KEY déjà présente est conservée : la remplacer rendrait illisibles les
|
|
19
|
+
* clés des serveurs enregistrés.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runSetupMcp({ cwd, force, exposure }: SetupMcpOptions): number;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { generateVaultKey } from "../core/vault.js";
|
|
4
|
+
const SERVER_NAME = "ssh-gate";
|
|
5
|
+
const PACKAGE_NAME = "@imenam/mcp-ssh-gate";
|
|
6
|
+
/** Slashes POSIX : pas de backslashes échappés dans le JSON, et Node les accepte sous Windows. */
|
|
7
|
+
function toPosixPath(dir) {
|
|
8
|
+
return dir.replace(/\\/g, "/");
|
|
9
|
+
}
|
|
10
|
+
function capitalize(value) {
|
|
11
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
12
|
+
}
|
|
13
|
+
function buildServerEntry(codeRoot, exposure, vaultKey) {
|
|
14
|
+
const folder = path.basename(codeRoot) || "ssh-gate";
|
|
15
|
+
const proxyUrl = exposure.appPort !== undefined ? "" : exposure.proxyUrl ?? "http://localhost:";
|
|
16
|
+
return {
|
|
17
|
+
command: "npx",
|
|
18
|
+
args: ["-y", PACKAGE_NAME],
|
|
19
|
+
env: {
|
|
20
|
+
PROXY_URL: proxyUrl,
|
|
21
|
+
APP_PORT: exposure.appPort !== undefined ? String(exposure.appPort) : "",
|
|
22
|
+
APP_GROUP: capitalize(folder),
|
|
23
|
+
APP_NAME: "SSH Gate",
|
|
24
|
+
APP_PATH: "/ssh-gate",
|
|
25
|
+
DATA_DIR: toPosixPath(path.join(codeRoot, ".ssh-gate-data")),
|
|
26
|
+
VAULT_KEY: vaultKey,
|
|
27
|
+
MCP_LOG_DIR: "",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const GENERATED_VARS = [
|
|
32
|
+
["PROXY_URL", "URL du proxy pour exposer l'interface (compléter le port)"],
|
|
33
|
+
["APP_PORT", "port local si aucun proxy — l'interface est servie sur http://localhost:<port>"],
|
|
34
|
+
["APP_GROUP", "groupe d'affichage dans le dashboard du proxy"],
|
|
35
|
+
["APP_NAME", "nom affiché de l'interface dans le dashboard du proxy"],
|
|
36
|
+
["APP_PATH", "chemin de montage de l'interface derrière le proxy"],
|
|
37
|
+
["DATA_DIR", "serveurs, clés chiffrées et historique des commandes (pré-rempli)"],
|
|
38
|
+
["VAULT_KEY", "clé de chiffrement des clés SSH (générée) — à conserver, la perdre rend les clés illisibles"],
|
|
39
|
+
["MCP_LOG_DIR", "répertoire des logs (défaut : <DATA_DIR>/logs)"],
|
|
40
|
+
];
|
|
41
|
+
function readFlag(argv, name) {
|
|
42
|
+
const prefixes = [`--${name}=`, `--${name.replace(/_/g, "-")}=`];
|
|
43
|
+
for (const arg of argv) {
|
|
44
|
+
const prefix = prefixes.find((p) => arg.startsWith(p));
|
|
45
|
+
if (prefix)
|
|
46
|
+
return arg.slice(prefix.length).trim();
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
/** Traduit `--proxy_url=` / `--app_port=` en mode d'exposition de l'interface ; les deux ensemble sont refusés. */
|
|
51
|
+
export function parseGuiExposure(argv) {
|
|
52
|
+
const rawProxyUrl = readFlag(argv, "proxy_url");
|
|
53
|
+
const rawAppPort = readFlag(argv, "app_port");
|
|
54
|
+
if (rawProxyUrl !== undefined && rawAppPort !== undefined) {
|
|
55
|
+
return { error: "--proxy_url et --app_port sont exclusifs : l'interface passe par le proxy, ou écoute sur un port local." };
|
|
56
|
+
}
|
|
57
|
+
if (rawProxyUrl !== undefined) {
|
|
58
|
+
if (rawProxyUrl === "")
|
|
59
|
+
return { error: "--proxy_url attend une URL, ex : --proxy_url=http://localhost:3000" };
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = new URL(rawProxyUrl);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { error: `--proxy_url invalide : "${rawProxyUrl}" n'est pas une URL absolue (ex : http://localhost:3000).` };
|
|
66
|
+
}
|
|
67
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
68
|
+
return { error: `--proxy_url invalide : protocole "${parsed.protocol}" non supporté (http ou https attendu).` };
|
|
69
|
+
}
|
|
70
|
+
return { exposure: { proxyUrl: rawProxyUrl.replace(/\/+$/, "") } };
|
|
71
|
+
}
|
|
72
|
+
if (rawAppPort !== undefined) {
|
|
73
|
+
const port = Number(rawAppPort);
|
|
74
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
75
|
+
return { error: `--app_port invalide : "${rawAppPort}" n'est pas un entier entre 1 et 65535.` };
|
|
76
|
+
}
|
|
77
|
+
return { exposure: { appPort: port } };
|
|
78
|
+
}
|
|
79
|
+
return { exposure: {} };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Écrit l'entrée du serveur dans le .mcp.json du dossier courant. Une
|
|
83
|
+
* VAULT_KEY déjà présente est conservée : la remplacer rendrait illisibles les
|
|
84
|
+
* clés des serveurs enregistrés.
|
|
85
|
+
*/
|
|
86
|
+
export function runSetupMcp({ cwd, force, exposure = {} }) {
|
|
87
|
+
const target = path.join(cwd, ".mcp.json");
|
|
88
|
+
let config = {};
|
|
89
|
+
const exists = fs.existsSync(target);
|
|
90
|
+
if (exists) {
|
|
91
|
+
const raw = fs.readFileSync(target, "utf-8").trim();
|
|
92
|
+
if (raw !== "") {
|
|
93
|
+
try {
|
|
94
|
+
config = JSON.parse(raw);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
console.error(`Erreur : ${target} contient du JSON invalide (${err.message}).`);
|
|
98
|
+
console.error("Corrigez le fichier ou supprimez-le, puis relancez la commande.");
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (typeof config !== "object" || config === null || Array.isArray(config)) {
|
|
103
|
+
console.error(`Erreur : ${target} ne contient pas un objet JSON.`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (typeof config.mcpServers !== "object" || config.mcpServers === null || Array.isArray(config.mcpServers)) {
|
|
108
|
+
config.mcpServers = {};
|
|
109
|
+
}
|
|
110
|
+
const previous = config.mcpServers[SERVER_NAME];
|
|
111
|
+
if (previous && !force) {
|
|
112
|
+
console.error(`Le serveur "${SERVER_NAME}" est déjà configuré dans ${target}.`);
|
|
113
|
+
console.error("Relancez avec --force pour écraser cette entrée (les autres serveurs et la VAULT_KEY sont préservés).");
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
const previousKey = typeof previous?.env?.VAULT_KEY === "string" && previous.env.VAULT_KEY.trim() ? previous.env.VAULT_KEY.trim() : null;
|
|
117
|
+
config.mcpServers[SERVER_NAME] = buildServerEntry(cwd, exposure, previousKey ?? generateVaultKey());
|
|
118
|
+
fs.writeFileSync(target, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
119
|
+
const action = previous ? "remplacée" : exists ? "ajoutée" : "créée";
|
|
120
|
+
console.log(`Configuration ${SERVER_NAME} ${action} dans ${target}`);
|
|
121
|
+
console.log(previousKey ? "VAULT_KEY existante conservée." : "Nouvelle VAULT_KEY générée.");
|
|
122
|
+
console.log("");
|
|
123
|
+
if (exposure.appPort !== undefined) {
|
|
124
|
+
console.log(`Interface en accès direct : http://localhost:${exposure.appPort} (sans proxy).`);
|
|
125
|
+
}
|
|
126
|
+
else if (exposure.proxyUrl !== undefined) {
|
|
127
|
+
console.log(`Interface exposée via le proxy ${exposure.proxyUrl}.`);
|
|
128
|
+
}
|
|
129
|
+
console.log('Variables du champ "env" :');
|
|
130
|
+
for (const [name, description] of GENERATED_VARS) {
|
|
131
|
+
console.log(` ${name.padEnd(14)} ${description}`);
|
|
132
|
+
}
|
|
133
|
+
console.log("");
|
|
134
|
+
console.log("PROXY_URL ou APP_PORT doit être renseigné : sans interface, aucune commande ne peut être validée.");
|
|
135
|
+
console.log("PROXY_URL et APP_PORT s'excluent : PROXY_URL prime si les deux sont renseignés.");
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface GateConfig {
|
|
2
|
+
dataDir: string;
|
|
3
|
+
vaultKey: Buffer;
|
|
4
|
+
logDir: string;
|
|
5
|
+
}
|
|
6
|
+
/** Racine du paquet : premier dossier contenant un package.json en remontant depuis `startDir`. */
|
|
7
|
+
export declare function findProjectRoot(startDir: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Lit la configuration dans l'environnement. DATA_DIR et VAULT_KEY sont
|
|
10
|
+
* obligatoires ; un DATA_DIR qui n'existe pas encore est créé.
|
|
11
|
+
*/
|
|
12
|
+
export declare function loadConfig(env?: NodeJS.ProcessEnv): GateConfig;
|
|
13
|
+
/** L'interface est indispensable : sans elle, aucune commande ne peut être validée. */
|
|
14
|
+
export declare function assertGuiConfigured(env?: NodeJS.ProcessEnv): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { GateError } from "./errors.js";
|
|
4
|
+
import { parseVaultKey } from "./vault.js";
|
|
5
|
+
/** Racine du paquet : premier dossier contenant un package.json en remontant depuis `startDir`. */
|
|
6
|
+
export function findProjectRoot(startDir) {
|
|
7
|
+
let current = startDir;
|
|
8
|
+
while (current !== path.dirname(current)) {
|
|
9
|
+
if (fs.existsSync(path.join(current, "package.json")))
|
|
10
|
+
return current;
|
|
11
|
+
current = path.dirname(current);
|
|
12
|
+
}
|
|
13
|
+
return startDir;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Lit la configuration dans l'environnement. DATA_DIR et VAULT_KEY sont
|
|
17
|
+
* obligatoires ; un DATA_DIR qui n'existe pas encore est créé.
|
|
18
|
+
*/
|
|
19
|
+
export function loadConfig(env = process.env) {
|
|
20
|
+
const rawDataDir = env.DATA_DIR?.trim();
|
|
21
|
+
if (!rawDataDir) {
|
|
22
|
+
throw new GateError("DATA_DIR is not set. It is the folder where the servers and the command history are stored.");
|
|
23
|
+
}
|
|
24
|
+
const dataDir = path.resolve(rawDataDir);
|
|
25
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
26
|
+
const vaultKey = parseVaultKey(env.VAULT_KEY);
|
|
27
|
+
const configuredLogDir = env.MCP_LOG_DIR?.trim();
|
|
28
|
+
const logDir = configuredLogDir ? path.resolve(configuredLogDir) : path.join(dataDir, "logs");
|
|
29
|
+
return { dataDir, vaultKey, logDir };
|
|
30
|
+
}
|
|
31
|
+
/** L'interface est indispensable : sans elle, aucune commande ne peut être validée. */
|
|
32
|
+
export function assertGuiConfigured(env = process.env) {
|
|
33
|
+
if (env.PROXY_URL?.trim() || env.APP_PORT?.trim())
|
|
34
|
+
return;
|
|
35
|
+
throw new GateError("Neither PROXY_URL nor APP_PORT is set. The web GUI is where commands are approved: set PROXY_URL to expose it through the proxy, or APP_PORT to serve it on http://localhost:<port>.");
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { GateRequest, ServerView } from "../../shared/model.js";
|
|
2
|
+
import type { RequestStore } from "./request-store.js";
|
|
3
|
+
import { type ServerInput, type ServerStore, type StoredServer } from "./server-store.js";
|
|
4
|
+
/** Sortie conservée par commande ; au-delà, le début est écarté. */
|
|
5
|
+
export declare const MAX_STORED_OUTPUT: number;
|
|
6
|
+
/**
|
|
7
|
+
* Exécution des demandes validées. Une session SSH par serveur, ouverte à la
|
|
8
|
+
* première exécution, et une seule demande en cours par serveur.
|
|
9
|
+
*/
|
|
10
|
+
export declare class Executor {
|
|
11
|
+
private readonly servers;
|
|
12
|
+
private readonly requests;
|
|
13
|
+
private readonly log;
|
|
14
|
+
private readonly sessions;
|
|
15
|
+
private readonly running;
|
|
16
|
+
constructor(servers: ServerStore, requests: RequestStore, log: (message: string) => void);
|
|
17
|
+
/** Marque en échec les demandes restées « en cours » après l'arrêt du worker. */
|
|
18
|
+
recoverAbandoned(): void;
|
|
19
|
+
views(): ServerView[];
|
|
20
|
+
view(server: StoredServer, open?: GateRequest[]): ServerView;
|
|
21
|
+
addServer(input: ServerInput): ServerView;
|
|
22
|
+
updateServer(id: string, input: ServerInput): ServerView;
|
|
23
|
+
deleteServer(id: string): void;
|
|
24
|
+
/** Ouvre une nouvelle session ; enregistre l'empreinte de l'hôte au premier test. */
|
|
25
|
+
testConnection(id: string): Promise<{
|
|
26
|
+
server: ServerView;
|
|
27
|
+
fingerprint: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
}>;
|
|
30
|
+
disconnect(serverId: string): void;
|
|
31
|
+
approve(requestId: string, note: unknown): GateRequest;
|
|
32
|
+
refuse(requestId: string, note: unknown): GateRequest;
|
|
33
|
+
editCommand(requestId: string, index: number, command: unknown): GateRequest;
|
|
34
|
+
removeCommand(requestId: string, index: number): GateRequest;
|
|
35
|
+
/** Commande saisie par l'utilisateur : exécutée aussitôt, sans validation. */
|
|
36
|
+
runManual(serverId: string, command: unknown): GateRequest;
|
|
37
|
+
interrupt(requestId: string): Promise<void>;
|
|
38
|
+
private assertIdle;
|
|
39
|
+
private openSession;
|
|
40
|
+
private sessionFor;
|
|
41
|
+
private start;
|
|
42
|
+
private finish;
|
|
43
|
+
private execute;
|
|
44
|
+
/** Exécute une commande ; renvoie de quoi clore la demande si l'exécution doit s'arrêter là. */
|
|
45
|
+
private runOne;
|
|
46
|
+
}
|