v0.8.9 : piloter un autre PC depuis l'IA (poste distant ajean-remote)

This commit is contained in:
nathaninline committed 2026-08-11 14:58:15 +02:00
1 parent 6d36464971
commit 7fa5461136
27 files changed
+2432 -49

No files matched your search

+12
View File
@@ -114,6 +114,18 @@ jobs:
GOOS=$os GOARCH=$arch CGO_ENABLED=0 \
go build -trimpath -ldflags="$ldflags" -o "dist/$name" ./cmd/ajean
done
# Client léger « poste distant » ajean-remote : pas de CGO (ni UI ni
# systray), donc les 6 cibles se cross-compilent ici, macOS compris.
# Noms d'asset ajean-remote-<os>[-arm][.exe] — la page /download les
# présente à part (« piloter un autre PC »).
for t in linux/amd64:ajean-remote-linux linux/arm64:ajean-remote-linux-arm \
windows/amd64:ajean-remote-windows.exe windows/arm64:ajean-remote-windows-arm.exe \
darwin/amd64:ajean-remote-macos darwin/arm64:ajean-remote-macos-arm; do
target=${t%%:*}; name=${t#*:}
os=${target%/*}; arch=${target#*/}
GOOS=$os GOARCH=$arch CGO_ENABLED=0 \
go build -trimpath -ldflags="-s -w" -o "dist/$name" ./cmd/ajean-remote
done
- uses: actions/download-artifact@v4
with:
name: macos
+11 -31
View File
@@ -1,41 +1,21 @@
La limite d'envoi de fichiers passe de 24 Mo à 1 Go, et le téléchargement depuis ajean.link est réparé.
L'IA peut maintenant agir sur un autre PC que le serveur : un petit client à installer, et tu choisis la machine sur laquelle elle travaille.
## Des fichiers d'un gigaoctet
## Piloter un autre PC depuis l'IA
La 0.8.7 plafonnait à 24 Mo, et ce n'était pas un caprice : le fichier partait dans un seul message, encodé en base64, gardé entier en mémoire par le navigateur puis par le serveur. Un gigaoctet ainsi transmis, c'est près d'un gigaoctet et demi de mémoire de chaque côté — de quoi faire tomber la machine.
Parfois la puissance est sur le serveur, mais ce que tu veux faire est ailleurs — sur ton portable, un poste de travail, une autre machine. AJEAN sait maintenant s'y étendre.
Le fichier part maintenant par tranches de 8 Mo. Le navigateur n'en lit qu'une à la fois, le serveur l'écrit sur le disque et l'oublie aussitôt : plus personne ne détient le fichier entier. Mesuré sur un envoi de 120 Mo, la mémoire du serveur n'a pas bougé d'un mégaoctet.
Sur le PC à piloter, tu installes **ajean-remote** : un client minuscule et séparé (quelques mégaoctets, aucune interface ni moteur), qui ne fait qu'une chose — ouvrir une connexion **sortante** vers ton serveur et exécuter ce que l'IA lui demande. Aucun port à ouvrir, rien à configurer dans la box.
Au passage, la vignette affiche l'avancement en pourcentage : sur un gros fichier, l'anneau qui tournait sans rien dire n'était pas d'un grand secours.
Dans l'interface, une section **Postes distants** : tu génères un code d'appairage, tu choisis les capacités autorisées (shell, lecture, écriture, listing) et un dossier de travail. Sur l'autre PC :
Avant d'entamer un envoi, AJEAN vérifie qu'il reste de la place sur le disque et refuse franchement s'il n'y en a pas — remplir le volume de la machine qui fait tourner le modèle serait autrement plus ennuyeux qu'un envoi rejeté.
ajean-remote install https://ton-serveur --code TONCODE --allow shell,read,write,list
Un envoi interrompu — onglet fermé, réseau coupé, service redémarré — ne laisse rien derrière lui : le fichier partiel est effacé au bout de dix minutes, et au démarrage du service.
Le client se connecte, s'installe en service (invisible, au démarrage), et se reconnecte tout seul.
## Télécharger un fichier depuis ajean.link
## Tu choisis la machine cible
Depuis l'accès distant, cliquer sur un fichier proposé par l'IA téléchargeait un contenu JSON illisible à la place du fichier.
Pas de nouveaux outils à apprendre pour l'IA : elle garde son shell, son écriture et son édition de fichiers. Un sélecteur **« L'IA agit sur : Ce serveur / <ton poste> »** décide simplement *où* ces outils s'exécutent. Tu bascules d'un clic. Et si le poste choisi est hors ligne, l'action échoue proprement plutôt que de retomber par erreur sur le serveur.
La cause tient à la façon dont l'accès distant chiffre les échanges : toute réponse est réemballée en JSON avant de vous parvenir, et des données binaires n'y survivent pas. Le fichier était donc détruit en chemin, quel que soit son type.
## Pensé pour ne pas se retourner contre toi
Votre serveur signale maintenant à l'interface qu'elle est derrière le tunnel, et lui envoie le fichier sous une forme qui traverse intact, toujours en tranches pour ne pas le charger entier en mémoire. En local, rien ne change : le fichier est servi directement, sans détour.
Le même défaut abîmait l'export de conversation au format Markdown, qui revenait truffé de guillemets et d'échappements. Corrigé aussi.
## Aussi
- Plusieurs fichiers joints en même temps s'envoient désormais en parallèle. Ils s'attendaient les uns les autres, chaque envoi bloquant les suivants jusqu'à sa dernière tranche.
## Mise à jour
```bash
ajean update
```
Puis redémarrez l'interface :
```bash
ajean ui restart
```
Pensez à la place disque de la machine qui héberge AJEAN : les fichiers envoyés s'accumulent dans `uploads/`, à l'intérieur du dossier de travail de l'IA, et rien ne les efface pour l'instant.
Le poste se connecte en sortant, sa clé d'appareil est stockée hachée côté serveur, et c'est **toi** qui décides, machine par machine, ce que l'IA a le droit de faire et dans quel dossier. Lecture et écriture sont confinées à ce dossier ; les capacités effectives sont l'intersection de ce que tu autorises et de ce que le poste accepte localement.
+181
View File
@@ -0,0 +1,181 @@
// ajean-remote — client LÉGER « poste distant ». Binaire séparé et minuscule
// (protocole partagé + WebSocket, sans UI ni moteur) : on l'installe sur un PC
// pour que l'IA d'un serveur AJEAN puisse agir dessus.
//
// ajean-remote install <url-serveur> --code CODE [--allow shell,read,write,list] [--root DIR]
// appaire ce PC et l'installe en service (démarrage auto)
// ajean-remote connect <url> --code CODE [...] même chose mais en avant-plan (test)
// ajean-remote run boucle client (utilisé par le service)
// ajean-remote status | uninstall | logout
package main
import (
"context"
"fmt"
"os"
"runtime"
"strings"
"github.com/nathaninline/ajean/internal/nodeclient"
)
func main() {
args := os.Args[1:]
cmd := ""
if len(args) > 0 {
cmd = args[0]
args = args[1:]
}
var err error
switch cmd {
case "install":
err = doConnect(args, true)
case "connect":
err = doConnect(args, false)
case "run":
err = doRun()
case "uninstall":
err = nodeclient.Uninstall()
if err == nil {
fmt.Println("[ok] service retiré")
}
case "logout":
_ = nodeclient.Uninstall()
err = os.Remove(nodeclient.ConfigPath())
if err == nil || os.IsNotExist(err) {
err = nil
fmt.Println("[ok] configuration oubliée")
}
case "status":
err = doStatus()
default:
usage()
}
if err != nil {
fmt.Fprintln(os.Stderr, "[err]", err)
os.Exit(1)
}
}
func usage() {
fmt.Print(`ajean-remote — client léger « poste distant » pour AJEAN
ajean-remote install <url-serveur> --code CODE [options] appaire + installe en service (démarrage auto, invisible)
ajean-remote connect <url-serveur> --code CODE [options] idem en avant-plan (pour tester)
ajean-remote status état de la configuration
ajean-remote uninstall retire le service (garde la config)
ajean-remote logout retire le service ET oublie la clé
Options :
--code CODE code d'appairage (généré dans l'UI AJEAN → Postes distants)
--allow LISTE capacités acceptées : shell,read,write,list (défaut : ce que le serveur autorise)
--root DIR dossier autorisé (confine lecture/écriture)
--yes auto-approuve les actions (obligatoire pour le service, sans terminal)
`)
}
// doConnect appaire au besoin, sauvegarde, puis installe (service) ou lance
// (avant-plan) selon install.
func doConnect(args []string, install bool) error {
url, code, allow, root, yes := parseFlags(args)
cfg, lerr := nodeclient.LoadConfig()
needEnroll := lerr != nil || cfg.Key == ""
if url != "" {
cfg.ServerURL = url
}
if needEnroll {
if cfg.ServerURL == "" {
return fmt.Errorf("url du serveur requise (ex: http://192.168.1.127:8090)")
}
if code == "" {
return fmt.Errorf("code d'appairage requis : --code CODE")
}
if err := nodeclient.Enroll(&cfg, code); err != nil {
return err
}
fmt.Printf("[ok] appairé (id %s)\n", cfg.ID)
}
if len(allow) > 0 {
cfg.Caps = nodeclient.SanitizeCaps(allow)
}
if root != "" {
cfg.Root = root
}
if cfg.Root == "" {
cfg.Root = nodeclient.DataDir() + string(os.PathSeparator) + "workspace"
}
if yes || install {
cfg.AutoYes = true // un service n'a pas de terminal pour confirmer
}
if cfg.Name == "" {
cfg.Name, _ = os.Hostname()
}
if err := nodeclient.SaveConfig(cfg); err != nil {
return err
}
fmt.Printf("[ok] poste « %s » → %s\n", cfg.Name, cfg.ServerURL)
fmt.Printf("[ok] capacités : %s | dossier : %s\n", strings.Join(cfg.Caps, ", "), cfg.Root)
if install {
if err := nodeclient.Install(); err != nil {
return err
}
fmt.Println("[ok] service installé et démarré — il tournera en tâche de fond et au redémarrage")
return nil
}
nodeclient.Run(context.Background(), cfg, false)
return nil
}
func doRun() error {
cfg, err := nodeclient.LoadConfig()
if err != nil {
return fmt.Errorf("aucune configuration — lance d'abord « ajean-remote install … »")
}
return nodeclient.RunService(cfg)
}
func doStatus() error {
cfg, err := nodeclient.LoadConfig()
if err != nil {
fmt.Println("aucun poste configuré")
return nil
}
fmt.Printf("serveur : %s\n", cfg.ServerURL)
fmt.Printf("nom : %s\n", cfg.Name)
fmt.Printf("capacités : %s\n", strings.Join(cfg.Caps, ", "))
fmt.Printf("dossier : %s\n", cfg.Root)
fmt.Printf("auto-oui : %v\n", cfg.AutoYes)
fmt.Printf("os : %s/%s\n", runtime.GOOS, runtime.GOARCH)
return nil
}
func parseFlags(args []string) (url, code string, allow []string, root string, yes bool) {
for i := 0; i < len(args); i++ {
a := args[i]
next := func() string {
if i+1 < len(args) {
i++
return args[i]
}
return ""
}
switch {
case a == "--code":
code = next()
case a == "--allow":
for _, p := range strings.Split(next(), ",") {
if p = strings.TrimSpace(p); p != "" {
allow = append(allow, p)
}
}
case a == "--root":
root = next()
case a == "--yes" || a == "-y":
yes = true
case !strings.HasPrefix(a, "-") && url == "":
url = a
}
}
return
}
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 8,
"Patch": 8,
"Patch": 9,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 8,
"Patch": 8,
"Patch": 9,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "ajean.exe",
"ProductName": "AJEAN",
"ProductVersion": "0.8.8",
"ProductVersion": "0.8.9",
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
},
"VarFileInfo": {
+28 -1
View File
@@ -53,7 +53,9 @@ func baseSystemPrompt(caps Caps) string {
// consignes que les schémas ne portent pas — le shell utilisé, et les
// politiques d'usage.
if caps.Agent {
b.WriteString("\n\nThe shell is " + shellName() + ": use its syntax.\n")
// Le shell de la machine CIBLE (cmd.exe pour un poste Windows), pas celui du
// serveur : sinon le modèle écrit du bash là où tourne cmd.exe (et inversement).
b.WriteString("\n\nThe shell is " + agentTargetShellName() + ": use its syntax.\n")
} else {
b.WriteString("\n\n")
}
@@ -114,6 +116,31 @@ func machineSystemPrompt(caps Caps) string {
if !caps.Agent {
return ""
}
// Cible = un poste distant : bash/write/edit s'exécutent LÀ-BAS, pas sur ce
// serveur. Le modèle doit le savoir explicitement, sinon il croit agir sur
// l'hôte du serveur et se trompe de machine.
if tgt, ok := nodeTargetMetaGet(); ok {
var b strings.Builder
b.WriteString("Machine: you are operating on a REMOTE node named " + tgt.name)
if tgt.os != "" {
b.WriteString(" (" + tgt.os + ")")
}
b.WriteString(". Your bash, write and edit tools run on THAT machine — a different computer than this server.")
if strings.HasPrefix(strings.ToLower(tgt.os), "windows") {
b.WriteString(" Its shell is cmd.exe: use cmd syntax (never bash idioms like ls, 2>nul, single quotes, or 'cmd //c'). To create a file, use the write tool, never echo/type into it.")
}
if tgt.root != "" {
b.WriteString(" File paths in write/edit resolve inside its working folder " + tgt.root + "; read/write are confined there.")
}
// Le poste peut tourner en compte de service (Windows: LocalSystem) : les
// variables d'environnement personnelles (%USERPROFILE%, $HOME) ne désignent
// PAS forcément l'utilisateur interactif. Utiliser des chemins absolus.
b.WriteString(" It may run as a background service account, so %USERPROFILE%/$HOME may not point to the interactive user — prefer absolute paths (e.g. C:\\Users\\<name>\\...).")
if !tgt.connected {
b.WriteString(" ⚠ It is currently OFFLINE: those tools will fail until it reconnects. Tell the user instead of trying repeatedly.")
}
return b.String()
}
host, _ := os.Hostname()
if host == "" {
host = "unknown"
+30 -9
View File
@@ -167,7 +167,7 @@ func bashTool() Tool {
Type: "function",
Function: ToolFunction{
Name: "bash",
Description: "Run a shell command (" + shellName() + " syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
Description: "Run a shell command (" + agentTargetShellName() + " syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
@@ -264,6 +264,10 @@ func EnabledTools(caps Caps) []Tool {
if caps.Agent {
tools = append(tools, mcpTools()...)
}
// Postes distants : PAS de nouveaux outils. L'IA garde bash/write/edit ; c'est
// leur CIBLE D'EXÉCUTION qui change quand un poste est sélectionné (voir le
// routage dans la boucle d'outils et agentTargetSlug). Redonner des outils que
// le modèle a déjà (node__…__shell alors qu'il a bash) doublonnait le catalogue.
return tools
}
@@ -1004,17 +1008,27 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
}
case "write":
content, _ := args["content"].(string)
result = fileWrite(label, content)
if !strings.HasPrefix(result, "[erreur]") {
diff = addedDiff(content)
if tgt := agentTargetSlug(); tgt != "" {
// Cible = un poste distant : on écrit LÀ-BAS. Pas de diff (on
// n'a pas l'ancien contenu du fichier distant).
result = nodeCall(tgt, nodeCapWrite, map[string]any{"path": label, "content": content})
} else {
result = fileWrite(label, content)
if !strings.HasPrefix(result, "[erreur]") {
diff = addedDiff(content)
}
}
case "edit":
oldText, _ := args["old"].(string)
newText, _ := args["new"].(string)
result = fileEdit(label, oldText, newText)
// Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
if !strings.HasPrefix(result, "[erreur]") {
diff = lineDiff(oldText, newText)
if tgt := agentTargetSlug(); tgt != "" {
result = nodeEditRemote(tgt, label, oldText, newText)
} else {
result = fileEdit(label, oldText, newText)
// Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
if !strings.HasPrefix(result, "[erreur]") {
diff = lineDiff(oldText, newText)
}
}
case "bash":
to := 0
@@ -1024,7 +1038,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
case int:
to = v
}
result = runShell(ctx, label, to)
if tgt := agentTargetSlug(); tgt != "" {
// Cible = un poste distant : la commande s'exécute LÀ-BAS via le
// canal du poste (fail-closed : nodeCall renvoie une erreur si le
// poste est déconnecté, on n'exécute JAMAIS sur le serveur à sa place).
result = nodeCall(tgt, nodeCapShell, map[string]any{"command": label, "timeout": to})
} else {
result = runShell(ctx, label, to)
}
case "web_search":
result = capWebOutput(toolWebSearch(args))
case "web_open":
+225
View File
@@ -0,0 +1,225 @@
// node_api.go — endpoints HTTP du poste distant.
//
// Deux familles :
// - GESTION (propriétaire, derrière la clé de pilotage) : générer un code
// d'appairage, lister/configurer/révoquer les postes.
// - ENRÔLEMENT (public) : le poste échange un code d'appairage à usage unique
// contre sa clé d'appareil. Authentifié par le code, pas par la clé de
// pilotage — que le poste ne possède pas.
package ajean
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/coder/websocket"
)
// handleNodePair (authed) génère un code d'appairage à usage unique valable
// nodePairCodeTTL. Le propriétaire choisit les capacités accordées et le dossier
// racine ; le poste ne pourra jamais dépasser ça.
func handleNodePair(w http.ResponseWriter, r *http.Request) {
var req struct {
Caps []string `json:"caps"`
Root string `json:"root"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
caps := nodeSanitizeCaps(req.Caps)
if len(caps) == 0 {
// Défaut prudent : lecture seule. Le propriétaire élargit ensuite.
caps = []string{nodeCapRead, nodeCapList}
}
p := nodePairPending{
Code: strings.ToUpper(nodeRandHex(4)), // 8 caractères hex lisibles
Caps: caps,
Root: strings.TrimSpace(req.Root),
Expires: time.Now().Add(nodePairCodeTTL).Unix(),
}
if err := savePairPending(p); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{
"ok": true,
"code": p.Code,
"caps": p.Caps,
"root": p.Root,
"expires": p.Expires,
"ttl_min": int(nodePairCodeTTL.Minutes()),
})
}
// handleNodeEnroll (PUBLIC) échange un code d'appairage contre une clé
// d'appareil. Le code est à usage unique : consommé dès qu'il sert.
func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
Name string `json:"name"`
OS string `json:"os"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
pending, ok := loadPairPending()
if !ok {
sendJSON(w, 403, map[string]any{"error": "aucun code d'appairage actif — générez-en un dans l'interface"})
return
}
if !strings.EqualFold(strings.TrimSpace(req.Code), pending.Code) {
sendJSON(w, 403, map[string]any{"error": "code incorrect"})
return
}
// Consomme le code immédiatement (usage unique), même si la suite échoue.
clearPairPending()
key := nodeRandHex(24)
name := strings.TrimSpace(req.Name)
if name == "" {
name = "poste"
}
node := pairedNode{
ID: nodeRandHex(8),
Name: name,
OS: strings.TrimSpace(req.OS),
KeyHash: nodeHashKey(key),
Caps: pending.Caps,
Root: pending.Root,
CreatedAt: time.Now().Unix(),
}
nodes := append(loadNodes(), node)
if err := saveNodes(nodes); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
// La clé n'est renvoyée QU'ICI, une seule fois : le poste la stocke en local.
sendJSON(w, 200, map[string]any{
"ok": true,
"id": node.ID,
"key": key,
"caps": node.Caps,
"root": node.Root,
"name": node.Name,
})
}
// handleNodes (authed) liste les postes appairés avec leur état de connexion.
func handleNodes(w http.ResponseWriter, r *http.Request) {
connected := map[string]bool{}
for _, nc := range nodeConnected() {
connected[nc.id] = true
}
nodes := loadNodes()
out := make([]map[string]any, 0, len(nodes))
for _, n := range nodes {
out = append(out, map[string]any{
"id": n.ID,
"name": n.Name,
"slug": nodeSlug(n.Name),
"os": n.OS,
"caps": n.Caps,
"root": n.Root,
"created_at": n.CreatedAt,
"last_seen": n.LastSeen,
"connected": connected[n.ID],
})
}
_, pending := loadPairPending()
sendJSON(w, 200, map[string]any{"ok": true, "nodes": out, "all_caps": nodeAllCaps, "pairing": pending, "target": agentTargetSlug()})
}
// handleNodeTarget (authed) choisit la machine sur laquelle l'agent agit :
// slug d'un poste, ou "" pour le serveur local.
func handleNodeTarget(w http.ResponseWriter, r *http.Request) {
var req struct {
Slug string `json:"slug"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
if err := setAgentTargetSlug(strings.TrimSpace(req.Slug)); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "target": agentTargetSlug()})
}
// handleNodeCaps (authed) met à jour les capacités autorisées et/ou le dossier
// racine d'un poste. Prend effet à la prochaine (re)connexion du poste.
func handleNodeCaps(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Caps []string `json:"caps"`
Root *string `json:"root"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
nodes := loadNodes()
found := false
for i := range nodes {
if nodes[i].ID == req.ID {
nodes[i].Caps = nodeSanitizeCaps(req.Caps)
if req.Root != nil {
nodes[i].Root = strings.TrimSpace(*req.Root)
}
found = true
break
}
}
if !found {
sendJSON(w, 404, map[string]any{"error": "poste inconnu"})
return
}
if err := saveNodes(nodes); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
// Coupe la session en cours pour que les nouvelles capacités s'appliquent
// (le poste se reconnecte et re-négocie l'intersection).
for _, nc := range nodeConnected() {
if nc.id == req.ID {
_ = nc.conn.Close(websocket.StatusNormalClosure, "capacités mises à jour")
}
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleNodeRevoke (authed) révoque un poste : oublie sa clé et le déconnecte.
func handleNodeRevoke(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
nodes := loadNodes()
kept := nodes[:0]
removed := false
for _, n := range nodes {
if n.ID == req.ID {
removed = true
continue
}
kept = append(kept, n)
}
if !removed {
sendJSON(w, 404, map[string]any{"error": "poste inconnu"})
return
}
if err := saveNodes(kept); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
for _, nc := range nodeConnected() {
if nc.id == req.ID {
_ = nc.conn.Close(websocket.StatusPolicyViolation, "poste révoqué")
}
}
sendJSON(w, 200, map[string]any{"ok": true})
}
+69
View File
@@ -0,0 +1,69 @@
// node_proto.go — côté serveur, alias vers le protocole partagé (internal/nodewire)
// + les helpers de nommage d'outils propres à ajean.
//
// Le format du fil (Msg, capacités, confinement de chemin) vit dans nodewire,
// paquet SANS dépendance, pour que le client léger ajean-node le partage sans
// tirer tout ajean. Ici on ne fait qu'exposer ces symboles sous les noms
// historiques du paquet, pour ne pas toucher le reste du code serveur.
package ajean
import (
"strings"
"github.com/nathaninline/ajean/internal/nodewire"
)
// Capacités (alias des constantes partagées).
const (
nodeCapShell = nodewire.CapShell
nodeCapRead = nodewire.CapRead
nodeCapWrite = nodewire.CapWrite
nodeCapList = nodewire.CapList
)
// nodeAllCaps : ordre canonique partagé.
var nodeAllCaps = nodewire.AllCaps
// nodeMsg : enveloppe du fil (alias de type → interop garantie avec le client).
type nodeMsg = nodewire.Msg
func nodeCapLabel(cap string) string { return nodewire.CapLabel(cap) }
func nodeCapAllowed(allowed []string, cap string) bool { return nodewire.CapAllowed(allowed, cap) }
func nodeCapIntersect(allowed, declared []string) []string {
return nodewire.CapIntersect(allowed, declared)
}
func nodeSanitizeCaps(caps []string) []string { return nodewire.SanitizeCaps(caps) }
func nodeResolvePath(root, p string) (string, error) { return nodewire.ResolvePath(root, p) }
// ── Helpers de nommage d'outils, propres au serveur ────────────────────────
// (conservés pour les tests ; l'IA ne reçoit plus d'outils node__* — voir la
// refonte « cible d'exécution » dans llm_client.go / node_server.go.)
// nodeSlug transforme un nom de poste en identifiant sûr ([A-Za-z0-9_-]).
func nodeSlug(name string) string {
s := mcpSanitize(name)
if s == "" {
s = "poste"
}
return s
}
func nodeToolName(slug, cap string) string { return "node__" + slug + "__" + cap }
func isNodeTool(name string) bool { return strings.HasPrefix(name, "node__") }
func parseNodeToolName(name string) (slug, cap string, ok bool) {
rest, found := strings.CutPrefix(name, "node__")
if !found {
return "", "", false
}
i := strings.LastIndex(rest, "__")
if i < 0 {
return "", "", false
}
slug, cap = rest[:i], rest[i+2:]
if slug == "" || cap == "" {
return "", "", false
}
return slug, cap, true
}
+89
View File
@@ -0,0 +1,89 @@
package ajean
import (
"path/filepath"
"runtime"
"testing"
)
func TestNodeCapIntersect(t *testing.T) {
got := nodeCapIntersect(
[]string{nodeCapShell, nodeCapRead, nodeCapWrite}, // autorisées par le propriétaire
[]string{nodeCapRead, nodeCapList}, // déclarées par le poste
)
// Seul read est dans les deux ; l'ordre doit être canonique.
if len(got) != 1 || got[0] != nodeCapRead {
t.Fatalf("intersection attendue [read], obtenu %v", got)
}
}
func TestNodeSanitizeCapsDropsUnknown(t *testing.T) {
got := nodeSanitizeCaps([]string{"write", "danger", "read", "read"})
// Ordre canonique (read avant write), doublon et inconnu retirés.
want := []string{nodeCapRead, nodeCapWrite}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("attendu %v, obtenu %v", want, got)
}
}
func TestNodeToolNameRoundTrip(t *testing.T) {
name := nodeToolName("mon_poste", nodeCapShell)
if !isNodeTool(name) {
t.Fatalf("%s devrait être reconnu comme outil de poste", name)
}
slug, cap, ok := parseNodeToolName(name)
if !ok || slug != "mon_poste" || cap != nodeCapShell {
t.Fatalf("parse: ok=%v slug=%q cap=%q", ok, slug, cap)
}
}
func TestNodeToolNameSlugWithDoubleUnderscore(t *testing.T) {
// Un slug contenant « __ » ne doit pas casser la reconstitution : on coupe
// sur le DERNIER séparateur, et la capacité n'en contient jamais.
name := nodeToolName("a__b", nodeCapWrite)
slug, cap, ok := parseNodeToolName(name)
if !ok || slug != "a__b" || cap != nodeCapWrite {
t.Fatalf("parse: ok=%v slug=%q cap=%q", ok, slug, cap)
}
}
func TestNodeResolvePathConfinement(t *testing.T) {
root := t.TempDir()
// Chemin relatif normal : accepté, sous la racine.
got, err := nodeResolvePath(root, "sous/dossier/f.txt")
if err != nil {
t.Fatalf("chemin relatif refusé à tort: %v", err)
}
if want := filepath.Join(root, "sous", "dossier", "f.txt"); got != want {
t.Fatalf("attendu %q, obtenu %q", want, got)
}
// Traversée « .. » : refusée.
if _, err := nodeResolvePath(root, "../evade.txt"); err == nil {
t.Fatal("la traversée .. aurait dû être refusée")
}
if _, err := nodeResolvePath(root, "a/../../evade.txt"); err == nil {
t.Fatal("la traversée imbriquée aurait dû être refusée")
}
// Chemin absolu hors racine : refusé.
outside := filepath.Join(filepath.Dir(root), "ailleurs.txt")
if runtime.GOOS == "windows" {
outside = `C:\Windows\System32\evil.txt`
}
if _, err := nodeResolvePath(root, outside); err == nil {
t.Fatalf("un chemin absolu hors racine (%s) aurait dû être refusé", outside)
}
// Chemin absolu SOUS la racine : accepté.
inside := filepath.Join(root, "ok.txt")
if _, err := nodeResolvePath(root, inside); err != nil {
t.Fatalf("un chemin absolu sous la racine refusé à tort: %v", err)
}
// Racine vide : refusée (aucun dossier autorisé = aucun accès).
if _, err := nodeResolvePath("", "f.txt"); err == nil {
t.Fatal("une racine vide aurait dû refuser tout chemin")
}
}
+381
View File
@@ -0,0 +1,381 @@
// node_server.go — côté SERVEUR du poste distant : appairage, endpoint
// WebSocket, registre des postes connectés, et exposition de leurs outils à
// l'agent. Le serveur n'exécute JAMAIS rien lui-même ici : il route une demande
// d'outil vers le poste concerné et attend sa réponse.
package ajean
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
const (
nodePairCodeTTL = 10 * time.Minute
nodeCallTimeout = 5 * time.Minute // un shell distant peut être long
nodeHelloWait = 15 * time.Second
)
// pairedNode est un poste appairé, tel que persisté dans bkState["nodes"].
// On ne garde JAMAIS la clé en clair : seulement son SHA-256. Le poste, lui,
// conserve la clé en local (voir node_client.go).
type pairedNode struct {
ID string `json:"id"`
Name string `json:"name"`
OS string `json:"os"`
KeyHash string `json:"key_hash"`
Caps []string `json:"caps"` // capacités AUTORISÉES par le propriétaire
Root string `json:"root,omitempty"`
CreatedAt int64 `json:"created_at"`
LastSeen int64 `json:"last_seen,omitempty"`
}
func loadNodes() []pairedNode {
var l []pairedNode
getJSON(bkState, "nodes", &l)
return l
}
func saveNodes(l []pairedNode) error { return putJSON(bkState, "nodes", l) }
// findNodeByKey retrouve un poste appairé à partir de la clé d'appareil présentée
// (comparaison à temps constant sur le hash). Renvoie nil si aucune ne matche.
func findNodeByKey(key string) *pairedNode {
if key == "" {
return nil
}
want := nodeHashKey(key)
nodes := loadNodes()
for i := range nodes {
if subtle.ConstantTimeCompare([]byte(nodes[i].KeyHash), []byte(want)) == 1 {
return &nodes[i]
}
}
return nil
}
func nodeHashKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
// nodeRandHex renvoie n octets aléatoires en hexadécimal (2n caractères).
func nodeRandHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// ─── Code d'appairage (usage unique, TTL court) ────────────────────────────
type nodePairPending struct {
Code string `json:"code"`
Caps []string `json:"caps"` // capacités que ce code accordera
Root string `json:"root,omitempty"`
Expires int64 `json:"expires"`
}
func loadPairPending() (nodePairPending, bool) {
var p nodePairPending
if !getJSON(bkState, "node_pair", &p) || p.Code == "" {
return nodePairPending{}, false
}
if time.Now().Unix() > p.Expires {
return nodePairPending{}, false
}
return p, true
}
func savePairPending(p nodePairPending) error { return putJSON(bkState, "node_pair", p) }
func clearPairPending() { _ = putJSON(bkState, "node_pair", nodePairPending{}) }
// ─── Registre des postes CONNECTÉS (en mémoire) ────────────────────────────
type nodeConn struct {
id string
slug string
name string
os string
caps []string // capacités EFFECTIVES (autorisées ∩ déclarées)
root string
conn *websocket.Conn
ctx context.Context
writeMu sync.Mutex
mu sync.Mutex
pending map[string]chan string
}
func (nc *nodeConn) send(m nodeMsg) error {
nc.writeMu.Lock()
defer nc.writeMu.Unlock()
ctx, cancel := context.WithTimeout(nc.ctx, 15*time.Second)
defer cancel()
return wsjson.Write(ctx, nc.conn, m)
}
var (
nodeRegMu sync.Mutex
nodeReg = map[string]*nodeConn{} // slug → connexion vivante
)
func nodeRegister(nc *nodeConn) {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
// Un même slug déjà connecté (reconnexion, ou deux postes de même nom) : on
// ferme l'ancien pour éviter deux postes indiscernables sous le même outil.
if old, ok := nodeReg[nc.slug]; ok {
_ = old.conn.Close(websocket.StatusPolicyViolation, "remplacé par une nouvelle session")
}
nodeReg[nc.slug] = nc
}
func nodeUnregister(nc *nodeConn) {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
if cur, ok := nodeReg[nc.slug]; ok && cur == nc {
delete(nodeReg, nc.slug)
}
}
func nodeGet(slug string) *nodeConn {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
return nodeReg[slug]
}
func nodeConnected() []*nodeConn {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
out := make([]*nodeConn, 0, len(nodeReg))
for _, nc := range nodeReg {
out = append(out, nc)
}
sort.Slice(out, func(i, j int) bool { return out[i].slug < out[j].slug })
return out
}
// ─── Endpoint WebSocket : le poste se connecte ici ─────────────────────────
// handleNodeWS accueille la connexion sortante d'un poste. Route PUBLIQUE (pas
// derrière la clé de pilotage) : l'authentification se fait par la clé d'appareil
// remise à l'appairage, présentée en Bearer.
func handleNodeWS(w http.ResponseWriter, r *http.Request) {
key := bearerToken(r)
pn := findNodeByKey(key)
if pn == nil {
http.Error(w, "poste non appairé", http.StatusUnauthorized)
return
}
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
if err != nil {
return
}
c.SetReadLimit(-1)
ctx := r.Context()
// 1er message : le hello où le poste déclare son nom/os et ce qu'il sait faire.
helloCtx, cancel := context.WithTimeout(ctx, nodeHelloWait)
var hello nodeMsg
err = wsjson.Read(helloCtx, c, &hello)
cancel()
if err != nil || hello.Type != "hello" {
_ = c.Close(websocket.StatusProtocolError, "hello attendu")
return
}
name := strings.TrimSpace(hello.Name)
if name == "" {
name = pn.Name
}
caps := nodeCapIntersect(pn.Caps, hello.Caps)
nc := &nodeConn{
id: pn.ID,
slug: nodeSlug(name),
name: name,
os: strings.TrimSpace(hello.OS),
caps: caps,
root: pn.Root,
conn: c,
ctx: ctx,
pending: map[string]chan string{},
}
nodeRegister(nc)
defer nodeUnregister(nc)
touchNodeSeen(pn.ID)
// Boucle de lecture : les seules entrées attendues sont des « result » qui
// débloquent l'appel en attente correspondant. Le ping/keepalive est géré par
// coder/websocket.
for {
var m nodeMsg
if err := wsjson.Read(ctx, c, &m); err != nil {
_ = c.CloseNow()
return
}
if m.Type == "result" {
nc.mu.Lock()
ch := nc.pending[m.ID]
delete(nc.pending, m.ID)
nc.mu.Unlock()
if ch != nil {
ch <- m.Result
}
}
}
}
func touchNodeSeen(id string) {
nodes := loadNodes()
for i := range nodes {
if nodes[i].ID == id {
nodes[i].LastSeen = time.Now().Unix()
_ = saveNodes(nodes)
return
}
}
}
// bearerToken extrait le jeton d'un en-tête « Authorization: Bearer … ».
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if s, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(s)
}
return ""
}
// ─── Appel d'un outil de poste (routage serveur→poste→serveur) ─────────────
// nodeCall envoie une demande d'exécution au poste et attend son résultat. Toute
// la sécurité côté serveur tient ici : on ne route que vers un poste connecté et
// une capacité effectivement autorisée.
func nodeCall(slug, cap string, args map[string]any) string {
nc := nodeGet(slug)
if nc == nil {
return "[erreur] poste « " + slug + " » déconnecté"
}
if !nodeCapAllowed(nc.caps, cap) {
return "[erreur] capacité « " + cap + " » non autorisée sur ce poste"
}
id := nodeRandHex(8)
ch := make(chan string, 1)
nc.mu.Lock()
nc.pending[id] = ch
nc.mu.Unlock()
defer func() {
nc.mu.Lock()
delete(nc.pending, id)
nc.mu.Unlock()
}()
if err := nc.send(nodeMsg{Type: "call", ID: id, Cap: cap, Args: args}); err != nil {
return "[erreur] envoi au poste impossible: " + err.Error()
}
select {
case res := <-ch:
return res
case <-time.After(nodeCallTimeout):
return "[timeout] le poste n'a pas répondu à temps"
case <-nc.ctx.Done():
return "[erreur] poste déconnecté pendant l'appel"
}
}
// nodeEditRemote applique une édition (old→new, old unique) à un fichier d'un
// poste : on le lit, on remplace localement, on réécrit — via les capacités
// read + write du poste. Reproduit fileEdit mais à distance.
func nodeEditRemote(slug, path, oldText, newText string) string {
if oldText == "" {
return "[erreur] old vide"
}
content := nodeCall(slug, nodeCapRead, map[string]any{"path": path})
if isNodeErr(content) {
return content
}
n := strings.Count(content, oldText)
if n == 0 {
if newText != "" && strings.Contains(content, newText) {
return "[ok] déjà à jour — le fichier contient déjà cette modification"
}
return "[erreur] old introuvable dans le fichier"
}
if n > 1 {
return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
}
updated := strings.Replace(content, oldText, newText, 1)
return nodeCall(slug, nodeCapWrite, map[string]any{"path": path, "content": updated})
}
// isNodeErr indique si un résultat d'appel de poste est un échec (à ne pas
// traiter comme du contenu utile).
func isNodeErr(s string) bool {
return strings.HasPrefix(s, "[erreur]") || strings.HasPrefix(s, "[refusé]") || strings.HasPrefix(s, "[timeout]")
}
// ─── Cible d'exécution de l'agent (quel PC l'IA pilote) ────────────────────
// agentTargetSlug renvoie le slug du poste sur lequel l'agent agit, ou "" pour
// le serveur local (comportement historique). Persisté dans bkState.
func agentTargetSlug() string { return getStr(bkState, "agent_target") }
func setAgentTargetSlug(slug string) error { return putStr(bkState, "agent_target", slug) }
// nodeTargetMeta décrit la cible d'exécution courante pour le prompt système.
type nodeTargetMeta struct {
slug, name, os, root string
connected bool
}
// nodeTargetMetaGet renvoie la cible sélectionnée (ok=false si c'est le serveur
// local). Complète nom/os/dossier depuis le registre si le poste est connecté,
// sinon depuis l'enregistrement d'appairage.
func nodeTargetMetaGet() (nodeTargetMeta, bool) {
slug := agentTargetSlug()
if slug == "" {
return nodeTargetMeta{}, false
}
m := nodeTargetMeta{slug: slug}
if nc := nodeGet(slug); nc != nil {
m.name, m.os, m.root, m.connected = nc.name, nc.os, nc.root, true
return m, true
}
for _, n := range loadNodes() {
if nodeSlug(n.Name) == slug {
m.name, m.os, m.root = n.Name, n.OS, n.Root
break
}
}
if m.name == "" {
m.name = slug
}
return m, true
}
// agentTargetShellName renvoie le shell de la MACHINE CIBLE : cmd.exe si le poste
// ciblé tourne sous Windows, bash sinon. Sans cible → le shell du serveur local.
// Indispensable : le serveur peut être sous Linux (bash) alors que le poste est
// sous Windows (cmd.exe) — annoncer le mauvais shell fait écrire au modèle une
// syntaxe que la machine cible ne comprend pas.
func agentTargetShellName() string {
if tgt, ok := nodeTargetMetaGet(); ok {
if strings.HasPrefix(strings.ToLower(tgt.os), "windows") {
return "cmd.exe"
}
return "bash"
}
return shellName()
}
+137
View File
@@ -0,0 +1,137 @@
package ajean
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
// TestNodeEndToEnd exerce tout le chemin serveur : enrôlement via code, connexion
// WebSocket, négociation des capacités (intersection propriétaire ∩ poste), et
// routage d'un appel d'outil jusqu'au poste et retour.
func TestNodeEndToEnd(t *testing.T) {
testHome(t)
// Le propriétaire n'autorise que read + shell (pas write).
if err := savePairPending(nodePairPending{
Code: "ABCD1234",
Caps: []string{nodeCapRead, nodeCapShell},
Expires: time.Now().Add(time.Minute).Unix(),
}); err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/node/enroll", handleNodeEnroll)
mux.HandleFunc("/api/node/ws", handleNodeWS)
srv := httptest.NewServer(mux)
defer srv.Close()
// 1) Enrôlement : le code est échangé contre une clé d'appareil.
body, _ := json.Marshal(map[string]string{"code": "abcd1234", "name": "testpc", "os": "linux/amd64"})
resp, err := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
var enr struct {
OK bool `json:"ok"`
Key string `json:"key"`
Caps []string `json:"caps"`
}
_ = json.NewDecoder(resp.Body).Decode(&enr)
resp.Body.Close()
if !enr.OK || enr.Key == "" {
t.Fatalf("enrôlement échoué: %+v", enr)
}
// Le code est à usage unique : un second enrôlement doit échouer.
resp2, _ := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
if resp2.StatusCode == 200 {
t.Fatal("le code d'appairage aurait dû être consommé (usage unique)")
}
resp2.Body.Close()
// 2) Le poste se connecte et sert les appels.
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/node/ws"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
HTTPHeader: http.Header{"Authorization": {"Bearer " + enr.Key}},
})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.CloseNow()
// Le poste déclare pouvoir read/shell/write ; l'effectif sera ∩ = read/shell.
if err := wsjson.Write(ctx, c, nodeMsg{Type: "hello", Name: "testpc", OS: "linux/amd64",
Caps: []string{nodeCapRead, nodeCapShell, nodeCapWrite}}); err != nil {
t.Fatalf("hello: %v", err)
}
// Boucle client : répond à chaque call par "RESULT:<cap>".
go func() {
for {
var m nodeMsg
if err := wsjson.Read(context.Background(), c, &m); err != nil {
return
}
if m.Type == "call" {
_ = wsjson.Write(context.Background(), c, nodeMsg{Type: "result", ID: m.ID, Result: "RESULT:" + m.Cap})
}
}
}()
// Attend l'enregistrement du poste.
slug := nodeSlug("testpc")
var nc *nodeConn
for i := 0; i < 100; i++ {
if nc = nodeGet(slug); nc != nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if nc == nil {
t.Fatal("le poste ne s'est pas enregistré")
}
// 3) Capacités effectives = intersection : read + shell, PAS write.
if len(nc.caps) != 2 || nc.caps[0] != nodeCapShell || nc.caps[1] != nodeCapRead {
t.Fatalf("capacités effectives inattendues: %v", nc.caps)
}
// 4) Un appel autorisé traverse jusqu'au poste et revient.
if got := nodeCall(slug, nodeCapRead, map[string]any{"path": "x"}); got != "RESULT:read" {
t.Fatalf("appel read: attendu RESULT:read, obtenu %q", got)
}
// 5) Un appel NON autorisé (write) est refusé côté serveur, sans atteindre le poste.
if got := nodeCall(slug, nodeCapWrite, map[string]any{"path": "x", "content": "y"}); !strings.Contains(got, "non autorisée") {
t.Fatalf("appel write aurait dû être refusé, obtenu %q", got)
}
// 6) Sélection de la cible : l'agent agit sur ce poste. La méta reflète un
// poste connecté, et un appel shell (autorisé) est bien routé jusqu'au poste.
if err := setAgentTargetSlug(slug); err != nil {
t.Fatal(err)
}
meta, ok := nodeTargetMetaGet()
if !ok || !meta.connected || meta.slug != slug {
t.Fatalf("cible inattendue: ok=%v %+v", ok, meta)
}
if got := nodeCall(slug, nodeCapShell, map[string]any{"command": "echo hi"}); got != "RESULT:shell" {
t.Fatalf("routage shell: attendu RESULT:shell, obtenu %q", got)
}
// 7) nodeEditRemote lit puis réécrit via le poste (le client fictif renvoie
// "RESULT:read" à la lecture → "old" introuvable, donc erreur explicite, pas
// un plantage : on vérifie juste que le chemin read→write est emprunté).
if got := nodeEditRemote(slug, "f.txt", "absent", "x"); !strings.Contains(got, "introuvable") {
t.Fatalf("nodeEditRemote: attendu 'introuvable', obtenu %q", got)
}
setAgentTargetSlug("") // ne pas fuiter la cible sur d'autres tests
}
+5 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.8.8"
const Version = "0.8.9"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() {
@@ -158,6 +158,10 @@ Accès distant (ajean.link) :
link code code d'appairage (10 min, usage unique) pour le portail
link status | logout état du jeton / l'oublier
Poste distant (piloter un autre PC depuis l'IA) :
le poste s'installe avec le binaire LÉGER séparé « ajean-remote » (cmd/ajean-remote),
pas avec ce binaire : ajean-remote install <url-serveur> --code CODE
Backend llama.cpp :
llamacpp install clone + compile llama.cpp (CUDA/ROCm/Metal/CPU), pointe BIN dessus
llamacpp update git pull + recompile le backend existant
+222 -2
View File
@@ -1166,6 +1166,21 @@ button:hover{border-color:var(--dim);color:var(--text)}
/* Conversation vide : un seul message centré à la place des réglages. */
.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}
/* Postes distants : cases de capacités, code d'appairage, commande. */
.node-caps{display:flex;flex-wrap:wrap;gap:6px}
.node-cap{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer;user-select:none}
.node-cap input{margin:0}
.node-code{font-family:var(--mono,monospace);font-size:22px;font-weight:700;letter-spacing:3px;color:var(--accent);padding:8px 0}
.node-cmd{white-space:pre-wrap;word-break:break-all;background:var(--code-bg);border:1px solid var(--border);border-radius:7px;padding:8px;margin:6px 0;font-size:12px}
/* Poste distant : sélecteur de cible d'exécution. */
.node-target{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px}
.node-target-l{font-size:12px;color:var(--dim);margin-right:2px;width:100%}
.node-target-opt{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer}
.node-target-opt.on{border-color:var(--accent);color:var(--accent)}
.node-target-opt.off{opacity:.6}
.node-target-opt input{margin:0}
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
@@ -1287,6 +1302,15 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div>
</div>
</div>
<div id="node-block" class="gated-group">
<div class="subhead">Postes distants<span id="node-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Pilote un <b>autre PC</b> depuis l'IA. installe le petit client <b>ajean-remote</b> sur le PC secondaire et lance <code>ajean-remote install &lt;url&gt; --code CODE</code> : il se connecte en sortant (aucun port à ouvrir) et expose à l'IA un jeu d'outils restreint (shell, fichiers). ⚠ tu décides des capacités et du dossier autorisé pour chaque poste ; le poste peut aussi demander confirmation localement.</span></span></div>
<div class="slist">
<div class="rowbody">
<div id="node-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l'IA puisse agir dessus)</div></div>
<button class="addbtn" style="margin-top:6px" onclick="openNodePair()">+ appairer un poste</button>
</div>
</div>
</div>
<div id="param-block" class="gated-group">
<div class="subhead">Paramètres</div>
<div class="slist">
@@ -1680,6 +1704,40 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div>
</div>
</div>
<div id="node-pair-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)hideModal('node-pair-modal')">
<div class="modal-card">
<div class="modal-head">
<strong>Appairer un poste distant</strong>
<button class="modal-x" onclick="hideModal('node-pair-modal')" aria-label="Fermer">×</button>
</div>
<div class="modal-body">
<div class="fld">
<label class="fld-l">Capacités autorisées <span class="opt">(ce que l'IA pourra faire sur ce poste)</span></label>
<div id="node-pair-caps" class="node-caps"></div>
</div>
<div class="fld">
<label class="fld-l" for="node-pair-root">Dossier autorisé <span class="opt">(confine lecture/écriture ; laisser vide = dossier de travail par défaut du poste)</span></label>
<input id="node-pair-root" class="ctl" placeholder="C:\\Users\\moi\\ajean ou /home/moi/ajean" spellcheck="false">
</div>
<div id="node-pair-out" style="display:none">
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
<div class="node-code" id="node-pair-code">——</div>
<div class="muted" style="font-size:12px;line-height:1.5">
Sur le PC à piloter, installe AJEAN puis lance :
<pre class="node-cmd" id="node-pair-cmd"></pre>
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
</div>
</div>
</div>
<div class="modal-foot">
<div class="foot-l"></div>
<div class="foot-r">
<button onclick="hideModal('node-pair-modal')">fermer</button>
<button class="btn-save" onclick="genNodeCode()">générer le code</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none">
<div class="modal-card">
<div class="modal-head">
@@ -2478,7 +2536,7 @@ async function loadAgent(){
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
function setAgentGate(on){
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
['mem-block','net-block','mcp-block','node-block','param-block'].forEach(id=>{
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
});
}
@@ -2851,7 +2909,7 @@ async function loadAll(){
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
// hauteurs réservées en place pour toujours.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
@@ -5332,4 +5390,166 @@ async function attachPaths(){
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
}
renderAttach();
// ─── Postes distants ─────────────────────────────────────────────────────────
// Gestion des « postes » : d'autres PC qui se connectent au serveur pour que
// l'IA puisse agir dessus. Le propriétaire génère un code d'appairage, choisit
// les capacités autorisées et le dossier racine, puis peut lister/révoquer les
// postes. Sous-réglage du mode agent (grisé quand l'agent est off).
let nodeList = [];
let nodeTarget = '';
let nodeAllCaps = ['shell','read','write','list'];
const nodeCapLabels = {shell:'shell', read:'lire', write:'écrire', list:'lister'};
async function loadNode(){ renderNode(await jget('/api/node')); }
function renderNode(r){
nodeList = (r && r.nodes) || [];
nodeTarget = (r && r.target) || '';
if(r && r.all_caps && r.all_caps.length) nodeAllCaps = r.all_caps;
const connected = nodeList.filter(n=>n.connected).length;
setBadge('node-badge', connected ? connected : null);
const list = document.getElementById('node-list');
list.textContent = '';
if(!nodeList.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l\'IA puisse agir dessus)</div>';
return;
}
// Sélecteur de CIBLE : sur quelle machine l'IA agit (ses outils bash/write/edit).
renderNodeTarget(list, connected);
nodeList.forEach(n=>{
const row = document.createElement('div'); row.className = 'mcp-row'+(n.connected?'':' off');
const dot = document.createElement('span'); dot.className = 'mcp-dot '+(n.connected?'mcp-dot-ok':'mcp-dot-off');
dot.title = n.connected ? 'connecté' : 'hors ligne';
const info = document.createElement('div'); info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name'; nm.textContent = n.name; nm.title = n.os||'';
const meta = document.createElement('div'); meta.className = 'mcp-meta';
const os = document.createElement('span'); os.className = 'mcp-tag'; os.textContent = n.os||'?'; meta.appendChild(os);
const caps = document.createElement('span'); caps.className = 'mcp-tools';
caps.textContent = (n.caps&&n.caps.length) ? n.caps.map(c=>nodeCapLabels[c]||c).join(', ') : 'aucune capacité';
meta.appendChild(caps);
if(n.root){ const rt = document.createElement('span'); rt.className='mcp-tools'; rt.textContent = '📁 '+n.root; rt.title=n.root; meta.appendChild(rt); }
info.appendChild(nm); info.appendChild(meta);
const edit = document.createElement('button'); edit.className='mcp-edit'; edit.title='régler'; edit.textContent='✎';
edit.onclick = (e)=>{ e.stopPropagation(); openNodeEdit(n.id); };
row.appendChild(dot); row.appendChild(info); row.appendChild(edit);
row.onclick = ()=>openNodeEdit(n.id);
list.appendChild(row);
});
}
// Sélecteur « L'IA agit sur : Ce serveur / <poste connecté> ». Les mêmes outils
// (bash/write/edit) s'exécutent sur la machine choisie ; un poste hors ligne ne
// peut pas être cible (fail-closed côté serveur).
function renderNodeTarget(list, connected){
const wrap = document.createElement('div'); wrap.className = 'node-target';
const lab = document.createElement('div'); lab.className = 'node-target-l';
lab.textContent = "L'IA agit sur :"; wrap.appendChild(lab);
const opts = [{slug:'', name:'Ce serveur'}].concat(nodeList.filter(n=>n.connected).map(n=>({slug:n.slug, name:n.name})));
// Cible sélectionnée mais hors ligne : on l'ajoute quand même, marquée, pour ne
// pas la perdre silencieusement.
if(nodeTarget && !opts.some(o=>o.slug===nodeTarget)){
const n = nodeList.find(x=>x.slug===nodeTarget);
opts.push({slug:nodeTarget, name:(n?n.name:nodeTarget)+' (hors ligne)', off:true});
}
opts.forEach(o=>{
const l = document.createElement('label'); l.className = 'node-target-opt'+(o.slug===nodeTarget?' on':'')+(o.off?' off':'');
const rb = document.createElement('input'); rb.type='radio'; rb.name='node-target'; rb.checked = o.slug===nodeTarget;
rb.onchange = ()=>setNodeTarget(o.slug);
const sp = document.createElement('span'); sp.textContent = o.name;
l.appendChild(rb); l.appendChild(sp); wrap.appendChild(l);
});
list.appendChild(wrap);
}
async function setNodeTarget(slug){
const r = await jpost('/api/node/target', {slug});
nodeTarget = (r && r.target) || '';
toast(slug ? ('l\'IA agit sur « '+slug+' »') : 'l\'IA agit sur ce serveur');
loadNode();
}
// ── Appairage : génère un code que le poste échange contre sa clé ────────────
function nodeCapCheckboxes(containerId, selected){
const box = document.getElementById(containerId);
box.textContent = '';
const sel = new Set(selected||[]);
nodeAllCaps.forEach(c=>{
const lab = document.createElement('label'); lab.className='node-cap';
const cb = document.createElement('input'); cb.type='checkbox'; cb.value=c; cb.checked = sel.has(c);
const sp = document.createElement('span'); sp.textContent = nodeCapLabels[c]||c;
lab.appendChild(cb); lab.appendChild(sp);
box.appendChild(lab);
});
}
function nodeCapValues(containerId){
return [...document.querySelectorAll('#'+containerId+' input:checked')].map(cb=>cb.value);
}
function openNodePair(){
nodeCapCheckboxes('node-pair-caps', ['read','list']); // défaut prudent
document.getElementById('node-pair-root').value = '';
document.getElementById('node-pair-out').style.display = 'none';
showModal('node-pair-modal');
}
async function genNodeCode(){
const caps = nodeCapValues('node-pair-caps');
if(!caps.length){ toast('choisis au moins une capacité'); return; }
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/pair', {caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
document.getElementById('node-pair-code').textContent = r.code;
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
const allow = caps.join(',');
const rootArg = root ? (' --root "'+root+'"') : '';
document.getElementById('node-pair-cmd').textContent =
'ajean-remote install '+location.origin+' --code '+r.code+' --allow '+allow+rootArg;
document.getElementById('node-pair-out').style.display = '';
loadNode();
}
// ── Édition d'un poste appairé : capacités, dossier, révocation ──────────────
let nodeEditing = null;
function openNodeEdit(id){
const n = nodeList.find(x=>x.id===id); if(!n) return;
nodeEditing = id;
nodeCapCheckboxes('node-pair-caps', n.caps);
document.getElementById('node-pair-root').value = n.root||'';
document.getElementById('node-pair-out').style.display = 'none';
// Réutilise la modale d'appairage en mode édition : on remplace le pied.
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Poste « '+n.name+' »'+(n.connected?' (connecté)':'');
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '';
const cancel = document.createElement('button'); cancel.textContent='fermer'; cancel.onclick=()=>{ hideModal('node-pair-modal'); resetNodeFoot(); };
const save = document.createElement('button'); save.className='btn-save'; save.textContent='enregistrer'; save.onclick=saveNodeCaps;
footR.appendChild(cancel); footR.appendChild(save);
const footL = document.querySelector('#node-pair-modal .foot-l');
footL.innerHTML = '';
const del = document.createElement('button'); del.className='btn-danger'; del.textContent='révoquer'; del.onclick=()=>revokeNode(id);
footL.appendChild(del);
showModal('node-pair-modal');
}
function resetNodeFoot(){
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Appairer un poste distant';
document.querySelector('#node-pair-modal .foot-l').innerHTML = '';
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '<button onclick="hideModal(\'node-pair-modal\')">fermer</button><button class="btn-save" onclick="genNodeCode()">générer le code</button>';
nodeEditing = null;
}
async function saveNodeCaps(){
if(!nodeEditing) return;
const caps = nodeCapValues('node-pair-caps');
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/caps', {id:nodeEditing, caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
toast('poste mis à jour'); hideModal('node-pair-modal'); resetNodeFoot(); loadNode();
}
async function revokeNode(id){
const n = nodeList.find(x=>x.id===id);
if(!await askConfirm('Révoquer le poste « '+(n?n.name:'')+' » ? Sa clé sera oubliée et il sera déconnecté.', {title:'Révoquer le poste ?', okText:'Révoquer', danger:true})) return;
await jpost('/api/node/revoke', {id});
hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); toast('poste révoqué');
}
</script></body></html>
+43
View File
@@ -146,6 +146,15 @@
</div>
</div>
</div>
<div id="node-block" class="gated-group">
<div class="subhead">Postes distants<span id="node-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Pilote un <b>autre PC</b> depuis l'IA. installe le petit client <b>ajean-remote</b> sur le PC secondaire et lance <code>ajean-remote install &lt;url&gt; --code CODE</code> : il se connecte en sortant (aucun port à ouvrir) et expose à l'IA un jeu d'outils restreint (shell, fichiers). ⚠ tu décides des capacités et du dossier autorisé pour chaque poste ; le poste peut aussi demander confirmation localement.</span></span></div>
<div class="slist">
<div class="rowbody">
<div id="node-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l'IA puisse agir dessus)</div></div>
<button class="addbtn" style="margin-top:6px" onclick="openNodePair()">+ appairer un poste</button>
</div>
</div>
</div>
<div id="param-block" class="gated-group">
<div class="subhead">Paramètres</div>
<div class="slist">
@@ -539,6 +548,40 @@
</div>
</div>
</div>
<div id="node-pair-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)hideModal('node-pair-modal')">
<div class="modal-card">
<div class="modal-head">
<strong>Appairer un poste distant</strong>
<button class="modal-x" onclick="hideModal('node-pair-modal')" aria-label="Fermer">×</button>
</div>
<div class="modal-body">
<div class="fld">
<label class="fld-l">Capacités autorisées <span class="opt">(ce que l'IA pourra faire sur ce poste)</span></label>
<div id="node-pair-caps" class="node-caps"></div>
</div>
<div class="fld">
<label class="fld-l" for="node-pair-root">Dossier autorisé <span class="opt">(confine lecture/écriture ; laisser vide = dossier de travail par défaut du poste)</span></label>
<input id="node-pair-root" class="ctl" placeholder="C:\\Users\\moi\\ajean ou /home/moi/ajean" spellcheck="false">
</div>
<div id="node-pair-out" style="display:none">
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
<div class="node-code" id="node-pair-code">——</div>
<div class="muted" style="font-size:12px;line-height:1.5">
Sur le PC à piloter, installe AJEAN puis lance :
<pre class="node-cmd" id="node-pair-cmd"></pre>
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
</div>
</div>
</div>
<div class="modal-foot">
<div class="foot-l"></div>
<div class="foot-r">
<button onclick="hideModal('node-pair-modal')">fermer</button>
<button class="btn-save" onclick="genNodeCode()">générer le code</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none">
<div class="modal-card">
<div class="modal-head">
+2 -2
View File
@@ -118,7 +118,7 @@ async function loadAgent(){
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
function setAgentGate(on){
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
['mem-block','net-block','mcp-block','node-block','param-block'].forEach(id=>{
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
});
}
@@ -491,7 +491,7 @@ async function loadAll(){
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
// hauteurs réservées en place pour toujours.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
+162
View File
@@ -0,0 +1,162 @@
// ─── Postes distants ─────────────────────────────────────────────────────────
// Gestion des « postes » : d'autres PC qui se connectent au serveur pour que
// l'IA puisse agir dessus. Le propriétaire génère un code d'appairage, choisit
// les capacités autorisées et le dossier racine, puis peut lister/révoquer les
// postes. Sous-réglage du mode agent (grisé quand l'agent est off).
let nodeList = [];
let nodeTarget = '';
let nodeAllCaps = ['shell','read','write','list'];
const nodeCapLabels = {shell:'shell', read:'lire', write:'écrire', list:'lister'};
async function loadNode(){ renderNode(await jget('/api/node')); }
function renderNode(r){
nodeList = (r && r.nodes) || [];
nodeTarget = (r && r.target) || '';
if(r && r.all_caps && r.all_caps.length) nodeAllCaps = r.all_caps;
const connected = nodeList.filter(n=>n.connected).length;
setBadge('node-badge', connected ? connected : null);
const list = document.getElementById('node-list');
list.textContent = '';
if(!nodeList.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l\'IA puisse agir dessus)</div>';
return;
}
// Sélecteur de CIBLE : sur quelle machine l'IA agit (ses outils bash/write/edit).
renderNodeTarget(list, connected);
nodeList.forEach(n=>{
const row = document.createElement('div'); row.className = 'mcp-row'+(n.connected?'':' off');
const dot = document.createElement('span'); dot.className = 'mcp-dot '+(n.connected?'mcp-dot-ok':'mcp-dot-off');
dot.title = n.connected ? 'connecté' : 'hors ligne';
const info = document.createElement('div'); info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name'; nm.textContent = n.name; nm.title = n.os||'';
const meta = document.createElement('div'); meta.className = 'mcp-meta';
const os = document.createElement('span'); os.className = 'mcp-tag'; os.textContent = n.os||'?'; meta.appendChild(os);
const caps = document.createElement('span'); caps.className = 'mcp-tools';
caps.textContent = (n.caps&&n.caps.length) ? n.caps.map(c=>nodeCapLabels[c]||c).join(', ') : 'aucune capacité';
meta.appendChild(caps);
if(n.root){ const rt = document.createElement('span'); rt.className='mcp-tools'; rt.textContent = '📁 '+n.root; rt.title=n.root; meta.appendChild(rt); }
info.appendChild(nm); info.appendChild(meta);
const edit = document.createElement('button'); edit.className='mcp-edit'; edit.title='régler'; edit.textContent='✎';
edit.onclick = (e)=>{ e.stopPropagation(); openNodeEdit(n.id); };
row.appendChild(dot); row.appendChild(info); row.appendChild(edit);
row.onclick = ()=>openNodeEdit(n.id);
list.appendChild(row);
});
}
// Sélecteur « L'IA agit sur : Ce serveur / <poste connecté> ». Les mêmes outils
// (bash/write/edit) s'exécutent sur la machine choisie ; un poste hors ligne ne
// peut pas être cible (fail-closed côté serveur).
function renderNodeTarget(list, connected){
const wrap = document.createElement('div'); wrap.className = 'node-target';
const lab = document.createElement('div'); lab.className = 'node-target-l';
lab.textContent = "L'IA agit sur :"; wrap.appendChild(lab);
const opts = [{slug:'', name:'Ce serveur'}].concat(nodeList.filter(n=>n.connected).map(n=>({slug:n.slug, name:n.name})));
// Cible sélectionnée mais hors ligne : on l'ajoute quand même, marquée, pour ne
// pas la perdre silencieusement.
if(nodeTarget && !opts.some(o=>o.slug===nodeTarget)){
const n = nodeList.find(x=>x.slug===nodeTarget);
opts.push({slug:nodeTarget, name:(n?n.name:nodeTarget)+' (hors ligne)', off:true});
}
opts.forEach(o=>{
const l = document.createElement('label'); l.className = 'node-target-opt'+(o.slug===nodeTarget?' on':'')+(o.off?' off':'');
const rb = document.createElement('input'); rb.type='radio'; rb.name='node-target'; rb.checked = o.slug===nodeTarget;
rb.onchange = ()=>setNodeTarget(o.slug);
const sp = document.createElement('span'); sp.textContent = o.name;
l.appendChild(rb); l.appendChild(sp); wrap.appendChild(l);
});
list.appendChild(wrap);
}
async function setNodeTarget(slug){
const r = await jpost('/api/node/target', {slug});
nodeTarget = (r && r.target) || '';
toast(slug ? ('l\'IA agit sur « '+slug+' »') : 'l\'IA agit sur ce serveur');
loadNode();
}
// ── Appairage : génère un code que le poste échange contre sa clé ────────────
function nodeCapCheckboxes(containerId, selected){
const box = document.getElementById(containerId);
box.textContent = '';
const sel = new Set(selected||[]);
nodeAllCaps.forEach(c=>{
const lab = document.createElement('label'); lab.className='node-cap';
const cb = document.createElement('input'); cb.type='checkbox'; cb.value=c; cb.checked = sel.has(c);
const sp = document.createElement('span'); sp.textContent = nodeCapLabels[c]||c;
lab.appendChild(cb); lab.appendChild(sp);
box.appendChild(lab);
});
}
function nodeCapValues(containerId){
return [...document.querySelectorAll('#'+containerId+' input:checked')].map(cb=>cb.value);
}
function openNodePair(){
nodeCapCheckboxes('node-pair-caps', ['read','list']); // défaut prudent
document.getElementById('node-pair-root').value = '';
document.getElementById('node-pair-out').style.display = 'none';
showModal('node-pair-modal');
}
async function genNodeCode(){
const caps = nodeCapValues('node-pair-caps');
if(!caps.length){ toast('choisis au moins une capacité'); return; }
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/pair', {caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
document.getElementById('node-pair-code').textContent = r.code;
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
const allow = caps.join(',');
const rootArg = root ? (' --root "'+root+'"') : '';
document.getElementById('node-pair-cmd').textContent =
'ajean-remote install '+location.origin+' --code '+r.code+' --allow '+allow+rootArg;
document.getElementById('node-pair-out').style.display = '';
loadNode();
}
// ── Édition d'un poste appairé : capacités, dossier, révocation ──────────────
let nodeEditing = null;
function openNodeEdit(id){
const n = nodeList.find(x=>x.id===id); if(!n) return;
nodeEditing = id;
nodeCapCheckboxes('node-pair-caps', n.caps);
document.getElementById('node-pair-root').value = n.root||'';
document.getElementById('node-pair-out').style.display = 'none';
// Réutilise la modale d'appairage en mode édition : on remplace le pied.
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Poste « '+n.name+' »'+(n.connected?' (connecté)':'');
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '';
const cancel = document.createElement('button'); cancel.textContent='fermer'; cancel.onclick=()=>{ hideModal('node-pair-modal'); resetNodeFoot(); };
const save = document.createElement('button'); save.className='btn-save'; save.textContent='enregistrer'; save.onclick=saveNodeCaps;
footR.appendChild(cancel); footR.appendChild(save);
const footL = document.querySelector('#node-pair-modal .foot-l');
footL.innerHTML = '';
const del = document.createElement('button'); del.className='btn-danger'; del.textContent='révoquer'; del.onclick=()=>revokeNode(id);
footL.appendChild(del);
showModal('node-pair-modal');
}
function resetNodeFoot(){
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Appairer un poste distant';
document.querySelector('#node-pair-modal .foot-l').innerHTML = '';
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '<button onclick="hideModal(\'node-pair-modal\')">fermer</button><button class="btn-save" onclick="genNodeCode()">générer le code</button>';
nodeEditing = null;
}
async function saveNodeCaps(){
if(!nodeEditing) return;
const caps = nodeCapValues('node-pair-caps');
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/caps', {id:nodeEditing, caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
toast('poste mis à jour'); hideModal('node-pair-modal'); resetNodeFoot(); loadNode();
}
async function revokeNode(id){
const n = nodeList.find(x=>x.id===id);
if(!await askConfirm('Révoquer le poste « '+(n?n.name:'')+' » ? Sa clé sera oubliée et il sera déconnecté.', {title:'Révoquer le poste ?', okText:'Révoquer', danger:true})) return;
await jpost('/api/node/revoke', {id});
hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); toast('poste révoqué');
}
+15
View File
@@ -1140,3 +1140,18 @@ button:hover{border-color:var(--dim);color:var(--text)}
/* Conversation vide : un seul message centré à la place des réglages. */
.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}
/* Postes distants : cases de capacités, code d'appairage, commande. */
.node-caps{display:flex;flex-wrap:wrap;gap:6px}
.node-cap{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer;user-select:none}
.node-cap input{margin:0}
.node-code{font-family:var(--mono,monospace);font-size:22px;font-weight:700;letter-spacing:3px;color:var(--accent);padding:8px 0}
.node-cmd{white-space:pre-wrap;word-break:break-all;background:var(--code-bg);border:1px solid var(--border);border-radius:7px;padding:8px;margin:6px 0;font-size:12px}
/* Poste distant : sélecteur de cible d'exécution. */
.node-target{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px}
.node-target-l{font-size:12px;color:var(--dim);margin-right:2px;width:100%}
.node-target-opt{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer}
.node-target-opt.on{border-color:var(--accent);color:var(--accent)}
.node-target-opt.off{opacity:.6}
.node-target-opt input{margin:0}
+12
View File
@@ -103,6 +103,12 @@ func newWebMux() *http.ServeMux {
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
mux.HandleFunc("/", handleIndex)
// Poste distant : deux routes PUBLIQUES (pas derrière la clé de pilotage).
// L'enrôlement est authentifié par le code d'appairage à usage unique, et le
// WebSocket par la clé d'appareil (Bearer) — le poste ne connaît pas la clé
// de pilotage. Voir node_api.go / node_server.go.
mux.HandleFunc("/api/node/enroll", handleNodeEnroll)
mux.HandleFunc("/api/node/ws", handleNodeWS)
mux.HandleFunc("/marked.min.js", func(w http.ResponseWriter, r *http.Request) {
b, _ := uiFS.ReadFile("ui/marked.min.js")
w.Header().Set("Content-Type", "application/javascript")
@@ -155,6 +161,12 @@ func newWebMux() *http.ServeMux {
api("/api/link/start", handleLinkStart) // (re)démarre le tunnel avec la clé déjà enregistrée
api("/api/link/disconnect", handleLinkDisconnect) // arrête le lien + oublie la clé
api("/api/link/paircode", handleLinkPairCode) // code d'appairage + empreinte pour la 1re connexion
// Postes distants : gestion réservée au propriétaire (clé de pilotage).
api("/api/node", handleNodes) // liste + état de connexion
api("/api/node/pair", handleNodePair) // génère un code d'appairage
api("/api/node/caps", handleNodeCaps) // règle capacités + dossier racine
api("/api/node/target", handleNodeTarget) // choisit la machine cible de l'agent
api("/api/node/revoke", handleNodeRevoke) // oublie la clé + déconnecte
api("/api/internet", handleInternet)
api("/api/mcp", handleMCP)
api("/api/mcp/save", handleMCPSave)
+385
View File
@@ -0,0 +1,385 @@
// Package nodeclient est le CLIENT léger « poste distant ». Il tourne sur un PC
// secondaire, s'appaire une fois avec le serveur ajean, puis ouvre une connexion
// SORTANTE et exécute — sous contrôle local — les demandes de l'IA.
//
// Volontairement autonome : dépend seulement de nodewire (protocole partagé,
// zéro dépendance) et de coder/websocket. AUCUN import d'ajean → binaire de
// quelques Mo, sans UI ni moteur.
//
// Sécurité côté poste : allowlist locale des capacités, confinement des fichiers
// à un dossier racine, confirmation des actions à effet de bord (sauf --yes).
package nodeclient
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/nathaninline/ajean/internal/nodewire"
)
const (
defaultTimeoutSec = 30
maxTimeoutSec = 300
maxOutput = 8000 // caractères de stdout/stderr renvoyés
readMax = 100000 // caractères max d'une lecture de fichier
)
// Config persiste l'appairage et les préférences locales du poste.
type Config struct {
ServerURL string `json:"server_url"`
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Caps []string `json:"caps"`
Root string `json:"root"`
AutoYes bool `json:"auto_yes"`
}
// dataDir est le dossier de données du poste. Sur Windows : C:\ProgramData\
// ajean-remote — MACHINE-WIDE, pour que le service (qui tourne en LocalSystem) lise
// la MÊME config que celle écrite par `install` (le profil utilisateur de
// LocalSystem est différent du tien). Ailleurs : dossier de config utilisateur.
func dataDir() string {
if runtime.GOOS == "windows" {
if pd := os.Getenv("ProgramData"); pd != "" {
return filepath.Join(pd, "ajean-remote")
}
}
dir, err := os.UserConfigDir()
if err != nil || dir == "" {
dir, _ = os.Getwd()
}
return filepath.Join(dir, "ajean-remote")
}
// ConfigPath renvoie le chemin du fichier de configuration.
func ConfigPath() string { return filepath.Join(dataDir(), "config.json") }
// InstalledExePath est l'emplacement stable où `install` copie le binaire, pour
// qu'il survive à la suppression du téléchargement.
func InstalledExePath() string {
name := "ajean-remote"
if runtime.GOOS == "windows" {
name += ".exe"
}
return filepath.Join(dataDir(), name)
}
// DataDir est exporté pour les installateurs par plateforme.
func DataDir() string { return dataDir() }
// SanitizeCaps filtre une liste de capacités (réexport de nodewire pour la CLI).
func SanitizeCaps(caps []string) []string { return nodewire.SanitizeCaps(caps) }
func LoadConfig() (Config, error) {
var c Config
b, err := os.ReadFile(ConfigPath())
if err != nil {
return c, err
}
return c, json.Unmarshal(b, &c)
}
func SaveConfig(c Config) error {
p := ConfigPath()
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
b, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(p, b, 0o600) // 0600 : la clé d'appareil est un secret
}
// Enroll échange un code d'appairage contre une clé d'appareil et complète cfg.
func Enroll(cfg *Config, code string) error {
name, _ := os.Hostname()
body, _ := json.Marshal(map[string]string{
"code": code, "name": name, "os": runtime.GOOS + "/" + runtime.GOARCH,
})
endpoint := strings.TrimRight(cfg.ServerURL, "/") + "/api/node/enroll"
req, _ := http.NewRequest("POST", endpoint, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return fmt.Errorf("appairage: %w", err)
}
defer resp.Body.Close()
var out struct {
OK bool `json:"ok"`
ID string `json:"id"`
Key string `json:"key"`
Caps []string `json:"caps"`
Root string `json:"root"`
Name string `json:"name"`
Err string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
if !out.OK || out.Key == "" {
if out.Err == "" {
out.Err = "réponse inattendue du serveur (" + resp.Status + ")"
}
return errors.New(out.Err)
}
cfg.ID, cfg.Key, cfg.Caps = out.ID, out.Key, out.Caps
if out.Root != "" {
cfg.Root = out.Root
}
if out.Name != "" {
cfg.Name = out.Name
}
return nil
}
// Run maintient la connexion pour toujours (reconnexion auto). quiet coupe les
// impressions (mode service). Bloque jusqu'à ctx.Done().
func Run(ctx context.Context, cfg Config, quiet bool) {
backoff := time.Second
for {
if ctx.Err() != nil {
return
}
err := session(ctx, cfg, quiet)
if err != nil && !quiet {
fmt.Printf("[node] session terminée: %v — reconnexion dans %s\n", err, backoff)
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < 30*time.Second {
backoff *= 2
}
}
}
func wsURL(server string) string {
s := strings.TrimRight(server, "/")
switch {
case strings.HasPrefix(s, "https://"):
s = "wss://" + strings.TrimPrefix(s, "https://")
case strings.HasPrefix(s, "http://"):
s = "ws://" + strings.TrimPrefix(s, "http://")
case !strings.Contains(s, "://"):
s = "wss://" + s
}
return s + "/api/node/ws"
}
func session(ctx context.Context, cfg Config, quiet bool) error {
dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
c, _, err := websocket.Dial(dialCtx, wsURL(cfg.ServerURL), &websocket.DialOptions{
HTTPHeader: http.Header{"Authorization": {"Bearer " + cfg.Key}},
})
if err != nil {
return fmt.Errorf("connexion: %w", err)
}
c.SetReadLimit(-1)
defer c.CloseNow()
hello := nodewire.Msg{Type: "hello", Name: cfg.Name, OS: runtime.GOOS + "/" + runtime.GOARCH, Caps: cfg.Caps}
if err := wsjson.Write(dialCtx, c, hello); err != nil {
return fmt.Errorf("hello: %w", err)
}
if !quiet {
fmt.Printf("[node] connecté ✓ (en attente des demandes de l'IA)\n")
}
for {
var m nodewire.Msg
if err := wsjson.Read(ctx, c, &m); err != nil {
return err
}
if m.Type != "call" {
continue
}
result := execute(ctx, cfg, m, quiet)
wc, wcancel := context.WithTimeout(ctx, 15*time.Second)
err := wsjson.Write(wc, c, nodewire.Msg{Type: "result", ID: m.ID, Result: result})
wcancel()
if err != nil {
return err
}
}
}
// execute applique les gardes locales puis exécute la demande.
func execute(ctx context.Context, cfg Config, m nodewire.Msg, quiet bool) string {
if !nodewire.CapAllowed(cfg.Caps, m.Cap) {
return "[refusé] la capacité « " + m.Cap + " » n'est pas activée sur ce poste"
}
if !cfg.AutoYes && (m.Cap == nodewire.CapShell || m.Cap == nodewire.CapWrite) {
if !confirm(m, quiet) {
return "[refusé] l'utilisateur du poste a refusé cette action"
}
}
switch m.Cap {
case nodewire.CapShell:
command, _ := m.Args["command"].(string)
to := 0
if v, ok := m.Args["timeout"].(float64); ok {
to = int(v)
}
return runShell(ctx, command, to, cfg.Root)
case nodewire.CapRead:
path, _ := m.Args["path"].(string)
return readFile(cfg.Root, path)
case nodewire.CapWrite:
path, _ := m.Args["path"].(string)
content, _ := m.Args["content"].(string)
return writeFile(cfg.Root, path, content)
case nodewire.CapList:
path, _ := m.Args["path"].(string)
return listDir(cfg.Root, path)
}
return "[erreur] capacité inconnue: " + m.Cap
}
// confirm demande validation sur le terminal du poste. Sans terminal (service)
// et sans --yes → REFUSE : mieux vaut bloquer qu'exécuter sans surveillance.
func confirm(m nodewire.Msg, quiet bool) bool {
fi, _ := os.Stdin.Stat()
interactive := fi != nil && (fi.Mode()&os.ModeCharDevice) != 0
if !interactive || quiet {
return false
}
label := ""
if c, ok := m.Args["command"].(string); ok {
label = c
} else if p, ok := m.Args["path"].(string); ok {
label = p
}
fmt.Printf("\n[node] l'IA veut %s sur ce poste :\n %s\nAutoriser ? [y/N] ", nodewire.CapLabel(m.Cap), label)
sc := bufio.NewScanner(os.Stdin)
if sc.Scan() {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "y")
}
return false
}
func runShell(parent context.Context, command string, timeoutSec int, dir string) string {
if strings.TrimSpace(command) == "" {
return "[erreur] commande vide"
}
if timeoutSec <= 0 {
timeoutSec = defaultTimeoutSec
}
if timeoutSec > maxTimeoutSec {
timeoutSec = maxTimeoutSec
}
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := shellCmd(ctx, command)
if dir != "" {
if err := os.MkdirAll(dir, 0o755); err == nil {
cmd.Dir = dir
}
}
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.WaitDelay = 2 * time.Second
err := cmd.Run()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Sprintf("[timeout après %ds]", timeoutSec)
}
exit := 0
if err != nil {
if ee, ok := err.(interface{ ExitCode() int }); ok {
exit = ee.ExitCode()
} else {
return fmt.Sprintf("[erreur: %v]", err)
}
}
parts := []string{fmt.Sprintf("exit: %d", exit)}
if o := tail(stdout.String(), maxOutput); o != "" {
parts = append(parts, "stdout:\n"+o)
}
if e := tail(stderr.String(), maxOutput); e != "" {
parts = append(parts, "stderr:\n"+e)
}
return strings.Join(parts, "\n\n")
}
func readFile(root, path string) string {
abs, err := nodewire.ResolvePath(root, path)
if err != nil {
return "[erreur] " + err.Error()
}
f, err := os.Open(abs)
if err != nil {
return "[erreur] " + err.Error()
}
defer f.Close()
b, err := io.ReadAll(io.LimitReader(f, readMax+1))
if err != nil {
return "[erreur] " + err.Error()
}
if len(b) > readMax {
return string(b[:readMax]) + "\n…[tronqué]"
}
return string(b)
}
func writeFile(root, path, content string) string {
abs, err := nodewire.ResolvePath(root, path)
if err != nil {
return "[erreur] " + err.Error()
}
if d := filepath.Dir(abs); d != "" {
if err := os.MkdirAll(d, 0o755); err != nil {
return "[erreur] " + err.Error()
}
}
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
return "[erreur] " + err.Error()
}
return fmt.Sprintf("[ok] %s écrit (%d octets)", abs, len(content))
}
func listDir(root, path string) string {
if strings.TrimSpace(path) == "" {
path = "."
}
abs, err := nodewire.ResolvePath(root, path)
if err != nil {
return "[erreur] " + err.Error()
}
entries, err := os.ReadDir(abs)
if err != nil {
return "[erreur] " + err.Error()
}
names := make([]string, 0, len(entries))
for _, e := range entries {
n := e.Name()
if e.IsDir() {
n += "/"
}
names = append(names, n)
}
sort.Strings(names)
if len(names) == 0 {
return "[vide]"
}
return strings.Join(names, "\n")
}
func tail(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[len(r)-n:])
}
+104
View File
@@ -0,0 +1,104 @@
//go:build !windows
package nodeclient
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
// RunService : hors Windows, pas de gestionnaire de services intégré — on tourne
// simplement en avant-plan (le service est géré par systemd/launchd, cf Install).
func RunService(cfg Config) error {
Run(context.Background(), cfg, false)
return nil
}
// Install installe un service de démarrage automatique : systemd --user (Linux)
// ou launchd (macOS). Le binaire est copié à un emplacement stable.
func Install() error {
self, err := os.Executable()
if err != nil {
return err
}
dst := InstalledExePath()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if abs, _ := filepath.Abs(self); abs != dst {
if err := copyFile(self, dst); err != nil {
return fmt.Errorf("copie du binaire: %w", err)
}
}
switch runtime.GOOS {
case "linux":
return installSystemd(dst)
case "darwin":
return installLaunchd(dst)
}
return fmt.Errorf("installation auto non gérée sur %s — lance « ajean-remote connect » via ton propre gestionnaire de démarrage", runtime.GOOS)
}
func Uninstall() error {
switch runtime.GOOS {
case "linux":
_ = exec.Command("systemctl", "--user", "disable", "--now", "ajean-remote.service").Run()
return os.Remove(filepath.Join(os.Getenv("HOME"), ".config/systemd/user/ajean-remote.service"))
case "darwin":
p := filepath.Join(os.Getenv("HOME"), "Library/LaunchAgents/link.ajean.remote.plist")
_ = exec.Command("launchctl", "unload", p).Run()
return os.Remove(p)
}
return fmt.Errorf("désinstallation non gérée sur %s", runtime.GOOS)
}
func installSystemd(exe string) error {
unit := "[Unit]\nDescription=AJEAN poste distant\nAfter=network-online.target\n\n" +
"[Service]\nExecStart=" + exe + " run\nRestart=always\nRestartSec=5\n\n" +
"[Install]\nWantedBy=default.target\n"
dir := filepath.Join(os.Getenv("HOME"), ".config/systemd/user")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "ajean-remote.service"), []byte(unit), 0o644); err != nil {
return err
}
_ = exec.Command("systemctl", "--user", "daemon-reload").Run()
if err := exec.Command("systemctl", "--user", "enable", "--now", "ajean-remote.service").Run(); err != nil {
return fmt.Errorf("systemctl --user enable: %w (astuce : « loginctl enable-linger %s » pour survivre à la déconnexion)", err, os.Getenv("USER"))
}
return nil
}
func installLaunchd(exe string) error {
plist := `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>link.ajean.remote</string>
<key>ProgramArguments</key><array><string>` + exe + `</string><string>run</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
</dict></plist>
`
dir := filepath.Join(os.Getenv("HOME"), "Library/LaunchAgents")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
p := filepath.Join(dir, "link.ajean.remote.plist")
if err := os.WriteFile(p, []byte(plist), 0o644); err != nil {
return err
}
return exec.Command("launchctl", "load", p).Run()
}
func copyFile(src, dst string) error {
b, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, b, 0o755)
}
+123
View File
@@ -0,0 +1,123 @@
package nodeclient
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
const serviceName = "AjeanRemote"
// RunService lance la boucle client : en vrai service Windows si on est démarré
// par le gestionnaire de services, sinon en avant-plan (utile pour `run` lancé
// à la main).
func RunService(cfg Config) error {
isSvc, err := svc.IsWindowsService()
if err != nil {
return err
}
if !isSvc {
Run(context.Background(), cfg, false)
return nil
}
return svc.Run(serviceName, &handler{cfg: cfg})
}
type handler struct{ cfg Config }
func (h *handler) Execute(_ []string, r <-chan svc.ChangeRequest, s chan<- svc.Status) (bool, uint32) {
const accepted = svc.AcceptStop | svc.AcceptShutdown
s <- svc.Status{State: svc.StartPending}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Run(ctx, h.cfg, true) // quiet : un service n'a pas de console
s <- svc.Status{State: svc.Running, Accepts: accepted}
for c := range r {
switch c.Cmd {
case svc.Interrogate:
s <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
s <- svc.Status{State: svc.StopPending}
return false, 0
}
}
return false, 0
}
// Install copie le binaire à un emplacement stable, crée le service (démarrage
// automatique) et le lance. Nécessite les droits administrateur.
func Install() error {
self, err := os.Executable()
if err != nil {
return err
}
dst := InstalledExePath()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if abs, _ := filepath.Abs(self); abs != dst {
if err := copyFile(self, dst); err != nil {
return fmt.Errorf("copie du binaire: %w", err)
}
}
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("gestionnaire de services (droits admin requis ?): %w", err)
}
defer m.Disconnect()
if s, err := m.OpenService(serviceName); err == nil {
s.Close()
return fmt.Errorf("service déjà installé — « ajean-remote uninstall » d'abord")
}
s, err := m.CreateService(serviceName, dst, mgr.Config{
StartType: mgr.StartAutomatic,
DisplayName: "AJEAN — poste distant",
Description: "Pont léger permettant à votre serveur IA AJEAN d'agir sur ce PC.",
}, "run")
if err != nil {
return fmt.Errorf("création du service: %w", err)
}
defer s.Close()
if err := s.Start(); err != nil {
return fmt.Errorf("service créé mais démarrage échoué: %w", err)
}
return nil
}
// Uninstall arrête et supprime le service.
func Uninstall() error {
m, err := mgr.Connect()
if err != nil {
return err
}
defer m.Disconnect()
s, err := m.OpenService(serviceName)
if err != nil {
return fmt.Errorf("service non installé")
}
defer s.Close()
_, _ = s.Control(svc.Stop)
time.Sleep(700 * time.Millisecond)
return s.Delete()
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package nodeclient
import (
"context"
"os/exec"
)
// shellCmd construit la commande shell hors Windows : bash -c.
func shellCmd(ctx context.Context, command string) *exec.Cmd {
return exec.CommandContext(ctx, "bash", "-c", command)
}
+30
View File
@@ -0,0 +1,30 @@
package nodeclient
import (
"context"
"os/exec"
"syscall"
)
// shellCmd construit la commande shell sur Windows : cmd.exe, fenêtre cachée.
//
// ⚠️ On passe la ligne de commande BRUTE via CmdLine plutôt que par Args. Sinon
// Go ré-échappe les guillemets internes (EscapeArg) et cmd.exe reçoit une ligne
// mal formée : `dir "C:\Users\X"` renvoyait « syntaxe du nom de fichier
// incorrecte », alors que la même commande sans guillemets marchait. Avec
// `cmd /S /C "<commande>"`, l'option /S dit à cmd de ne retirer QUE le premier et
// le dernier guillemet et d'exécuter le reste tel quel — les chemins entre
// guillemets (avec espaces) passent alors correctement.
func shellCmd(ctx context.Context, command string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "cmd")
// `chcp 65001>nul &` force la sortie de cmd en UTF-8. Sinon cmd émet dans la
// codepage OEM (CP850 en FR) : les accents (« Répertoire », « numéro ») et le
// séparateur de milliers arrivaient en octets non-UTF-8 → rendus « � » une fois
// sérialisés en JSON. Avec /S, cmd ne retire que le 1er et le dernier guillemet,
// donc les guillemets internes de la commande passent intacts.
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CmdLine: `cmd /S /C "chcp 65001>nul & ` + command + `"`,
}
return cmd
}
+32
View File
@@ -0,0 +1,32 @@
package nodeclient
import (
"context"
"strings"
"testing"
)
// Un chemin ENTRE GUILLEMETS doit fonctionner (régression : Go ré-échappait les
// guillemets et cmd renvoyait « syntaxe du nom de fichier incorrecte »).
func TestRunShellQuotedPath(t *testing.T) {
out := runShell(context.Background(), `dir "C:\Windows" /b`, 10, "")
if !strings.Contains(out, "exit: 0") {
t.Fatalf("commande avec chemin entre guillemets a échoué:\n%s", out)
}
if !strings.Contains(strings.ToLower(out), "system32") {
t.Fatalf("sortie inattendue (System32 absent):\n%s", out)
}
// La sortie doit être de l'UTF-8 propre : pas de caractère de remplacement
// U+FFFD (le « � » qui apparaissait avec la codepage OEM).
if strings.ContainsRune(out, '\uFFFD') {
t.Fatalf("la sortie contient des caractères mal encodés (�):\n%s", out)
}
}
// L'accent de « Répertoire » doit être correct (validation de chcp 65001).
func TestRunShellUTF8Accents(t *testing.T) {
out := runShell(context.Background(), `dir C:\Windows`, 10, "")
if strings.ContainsRune(out, '\uFFFD') {
t.Fatalf("caractères mal encodés dans la sortie:\n%s", out)
}
}
+118
View File
@@ -0,0 +1,118 @@
// Package nodewire porte le protocole PARTAGÉ entre le serveur ajean et le
// client léger « poste distant » (ajean-node). Zéro dépendance hors stdlib :
// c'est ce qui permet au client de rester minuscule (il n'embarque ni l'UI, ni
// llama.cpp, ni bbolt, ni MCP — juste ce fichier + coder/websocket).
//
// Le format du fil (Msg + capacités) DOIT rester identique des deux côtés :
// centraliser ici évite toute dérive entre serveur et client.
package nodewire
import (
"errors"
"fmt"
"path/filepath"
"strings"
)
// Capacités qu'un poste peut exposer. Chacune est un pouvoir concret donné à
// l'IA sur la machine distante.
const (
CapShell = "shell" // exécuter une commande shell
CapRead = "read" // lire un fichier (confiné à la racine)
CapWrite = "write" // écrire un fichier (confiné à la racine)
CapList = "list" // lister un dossier (confiné à la racine)
)
// AllCaps est l'ordre canonique d'affichage/itération.
var AllCaps = []string{CapShell, CapRead, CapWrite, CapList}
// CapLabel donne un libellé court en français.
func CapLabel(cap string) string {
switch cap {
case CapShell:
return "shell"
case CapRead:
return "lire des fichiers"
case CapWrite:
return "écrire des fichiers"
case CapList:
return "lister des dossiers"
}
return cap
}
// Msg est l'unique enveloppe échangée sur le WebSocket, dans les deux sens.
// - client→serveur au démarrage : {type:"hello", name, os, caps:[…]}
// - serveur→client : {type:"call", id, cap, args}
// - client→serveur : {type:"result", id, result}
type Msg struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Cap string `json:"cap,omitempty"`
Args map[string]any `json:"args,omitempty"`
Result string `json:"result,omitempty"`
Name string `json:"name,omitempty"`
OS string `json:"os,omitempty"`
Caps []string `json:"caps,omitempty"`
}
// CapAllowed indique si cap figure dans allowed.
func CapAllowed(allowed []string, cap string) bool {
for _, c := range allowed {
if c == cap {
return true
}
}
return false
}
// CapIntersect renvoie, dans l'ordre canonique, les capacités présentes dans les
// deux listes — le jeu EFFECTIF exposé à l'IA (propriétaire ∩ poste).
func CapIntersect(allowed, declared []string) []string {
var out []string
for _, c := range AllCaps {
if CapAllowed(allowed, c) && CapAllowed(declared, c) {
out = append(out, c)
}
}
return out
}
// SanitizeCaps ne garde que des capacités connues, sans doublon, dans l'ordre
// canonique. Toute entrée inconnue est ignorée.
func SanitizeCaps(caps []string) []string {
var out []string
for _, c := range AllCaps {
if CapAllowed(caps, c) {
out = append(out, c)
}
}
return out
}
// ResolvePath résout un chemin demandé À L'INTÉRIEUR de la racine autorisée et
// REFUSE tout ce qui en sortirait (traversée « .. », absolu hors racine). C'est
// le confinement des outils fichiers.
func ResolvePath(root, p string) (string, error) {
if strings.TrimSpace(root) == "" {
return "", errors.New("aucun dossier racine configuré sur ce poste")
}
if strings.TrimSpace(p) == "" {
return "", errors.New("chemin vide")
}
root = filepath.Clean(root)
var abs string
if filepath.IsAbs(p) {
abs = filepath.Clean(p)
} else {
abs = filepath.Clean(filepath.Join(root, p))
}
rel, err := filepath.Rel(root, abs)
if err != nil {
return "", fmt.Errorf("chemin invalide: %v", err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("chemin hors du dossier autorisé (%s)", root)
}
return abs, nil
}