mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 21:48:47 +02:00
v0.8.9 : piloter un autre PC depuis l'IA (poste distant ajean-remote)
This commit is contained in:
1 parent
6d36464971
commit
7fa5461136
27 files changed
+2432
-49
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 <url> --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>
|
||||
@@ -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 <url> --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">
|
||||
|
||||
@@ -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); }
|
||||
@@ -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é');
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user