mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 15:08:16 +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)
|
||||
|
||||
Reference in new issue
Block a user