diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed84f34..e51aada 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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-[-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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b8759b9..829859c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 / »** 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. diff --git a/cmd/ajean-remote/main.go b/cmd/ajean-remote/main.go new file mode 100644 index 0000000..d7f2078 --- /dev/null +++ b/cmd/ajean-remote/main.go @@ -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 --code CODE [--allow shell,read,write,list] [--root DIR] +// appaire ce PC et l'installe en service (démarrage auto) +// ajean-remote connect --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 --code CODE [options] appaire + installe en service (démarrage auto, invisible) + ajean-remote connect --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 +} diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index fb8dd1a..b4288a0 100644 Binary files a/cmd/ajean/resource_windows_amd64.syso and b/cmd/ajean/resource_windows_amd64.syso differ diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index 055a8ea..18c0269 100644 Binary files a/cmd/ajean/resource_windows_arm64.syso and b/cmd/ajean/resource_windows_arm64.syso differ diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index 1c0848f..8f31987 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -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": { diff --git a/internal/ajean/chat_tools.go b/internal/ajean/chat_tools.go index 228980c..a2c9d42 100644 --- a/internal/ajean/chat_tools.go +++ b/internal/ajean/chat_tools.go @@ -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\\\\...).") + 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" diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go index 18f0f2e..f6ced71 100644 --- a/internal/ajean/llm_client.go +++ b/internal/ajean/llm_client.go @@ -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": diff --git a/internal/ajean/node_api.go b/internal/ajean/node_api.go new file mode 100644 index 0000000..47d26f9 --- /dev/null +++ b/internal/ajean/node_api.go @@ -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}) +} diff --git a/internal/ajean/node_proto.go b/internal/ajean/node_proto.go new file mode 100644 index 0000000..96b7a8c --- /dev/null +++ b/internal/ajean/node_proto.go @@ -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 +} diff --git a/internal/ajean/node_proto_test.go b/internal/ajean/node_proto_test.go new file mode 100644 index 0000000..a5b861d --- /dev/null +++ b/internal/ajean/node_proto_test.go @@ -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") + } +} diff --git a/internal/ajean/node_server.go b/internal/ajean/node_server.go new file mode 100644 index 0000000..779f5a1 --- /dev/null +++ b/internal/ajean/node_server.go @@ -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() +} diff --git a/internal/ajean/node_ws_test.go b/internal/ajean/node_ws_test.go new file mode 100644 index 0000000..bcd2c65 --- /dev/null +++ b/internal/ajean/node_ws_test.go @@ -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:". + 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 +} diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 81ceeb4..c5b0a73 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -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 --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 diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 82d9238..ad59cf7 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -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}