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

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

No files matched your search

+28 -1
View File
@@ -53,7 +53,9 @@ func baseSystemPrompt(caps Caps) string {
// consignes que les schémas ne portent pas — le shell utilisé, et les
// politiques d'usage.
if caps.Agent {
b.WriteString("\n\nThe shell is " + shellName() + ": use its syntax.\n")
// Le shell de la machine CIBLE (cmd.exe pour un poste Windows), pas celui du
// serveur : sinon le modèle écrit du bash là où tourne cmd.exe (et inversement).
b.WriteString("\n\nThe shell is " + agentTargetShellName() + ": use its syntax.\n")
} else {
b.WriteString("\n\n")
}
@@ -114,6 +116,31 @@ func machineSystemPrompt(caps Caps) string {
if !caps.Agent {
return ""
}
// Cible = un poste distant : bash/write/edit s'exécutent LÀ-BAS, pas sur ce
// serveur. Le modèle doit le savoir explicitement, sinon il croit agir sur
// l'hôte du serveur et se trompe de machine.
if tgt, ok := nodeTargetMetaGet(); ok {
var b strings.Builder
b.WriteString("Machine: you are operating on a REMOTE node named " + tgt.name)
if tgt.os != "" {
b.WriteString(" (" + tgt.os + ")")
}
b.WriteString(". Your bash, write and edit tools run on THAT machine — a different computer than this server.")
if strings.HasPrefix(strings.ToLower(tgt.os), "windows") {
b.WriteString(" Its shell is cmd.exe: use cmd syntax (never bash idioms like ls, 2>nul, single quotes, or 'cmd //c'). To create a file, use the write tool, never echo/type into it.")
}
if tgt.root != "" {
b.WriteString(" File paths in write/edit resolve inside its working folder " + tgt.root + "; read/write are confined there.")
}
// Le poste peut tourner en compte de service (Windows: LocalSystem) : les
// variables d'environnement personnelles (%USERPROFILE%, $HOME) ne désignent
// PAS forcément l'utilisateur interactif. Utiliser des chemins absolus.
b.WriteString(" It may run as a background service account, so %USERPROFILE%/$HOME may not point to the interactive user — prefer absolute paths (e.g. C:\\Users\\<name>\\...).")
if !tgt.connected {
b.WriteString(" ⚠ It is currently OFFLINE: those tools will fail until it reconnects. Tell the user instead of trying repeatedly.")
}
return b.String()
}
host, _ := os.Hostname()
if host == "" {
host = "unknown"
+30 -9
View File
@@ -167,7 +167,7 @@ func bashTool() Tool {
Type: "function",
Function: ToolFunction{
Name: "bash",
Description: "Run a shell command (" + shellName() + " syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
Description: "Run a shell command (" + agentTargetShellName() + " syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
@@ -264,6 +264,10 @@ func EnabledTools(caps Caps) []Tool {
if caps.Agent {
tools = append(tools, mcpTools()...)
}
// Postes distants : PAS de nouveaux outils. L'IA garde bash/write/edit ; c'est
// leur CIBLE D'EXÉCUTION qui change quand un poste est sélectionné (voir le
// routage dans la boucle d'outils et agentTargetSlug). Redonner des outils que
// le modèle a déjà (node__…__shell alors qu'il a bash) doublonnait le catalogue.
return tools
}
@@ -1004,17 +1008,27 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
}
case "write":
content, _ := args["content"].(string)
result = fileWrite(label, content)
if !strings.HasPrefix(result, "[erreur]") {
diff = addedDiff(content)
if tgt := agentTargetSlug(); tgt != "" {
// Cible = un poste distant : on écrit LÀ-BAS. Pas de diff (on
// n'a pas l'ancien contenu du fichier distant).
result = nodeCall(tgt, nodeCapWrite, map[string]any{"path": label, "content": content})
} else {
result = fileWrite(label, content)
if !strings.HasPrefix(result, "[erreur]") {
diff = addedDiff(content)
}
}
case "edit":
oldText, _ := args["old"].(string)
newText, _ := args["new"].(string)
result = fileEdit(label, oldText, newText)
// Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
if !strings.HasPrefix(result, "[erreur]") {
diff = lineDiff(oldText, newText)
if tgt := agentTargetSlug(); tgt != "" {
result = nodeEditRemote(tgt, label, oldText, newText)
} else {
result = fileEdit(label, oldText, newText)
// Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
if !strings.HasPrefix(result, "[erreur]") {
diff = lineDiff(oldText, newText)
}
}
case "bash":
to := 0
@@ -1024,7 +1038,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
case int:
to = v
}
result = runShell(ctx, label, to)
if tgt := agentTargetSlug(); tgt != "" {
// Cible = un poste distant : la commande s'exécute LÀ-BAS via le
// canal du poste (fail-closed : nodeCall renvoie une erreur si le
// poste est déconnecté, on n'exécute JAMAIS sur le serveur à sa place).
result = nodeCall(tgt, nodeCapShell, map[string]any{"command": label, "timeout": to})
} else {
result = runShell(ctx, label, to)
}
case "web_search":
result = capWebOutput(toolWebSearch(args))
case "web_open":
+225
View File
@@ -0,0 +1,225 @@
// node_api.go — endpoints HTTP du poste distant.
//
// Deux familles :
// - GESTION (propriétaire, derrière la clé de pilotage) : générer un code
// d'appairage, lister/configurer/révoquer les postes.
// - ENRÔLEMENT (public) : le poste échange un code d'appairage à usage unique
// contre sa clé d'appareil. Authentifié par le code, pas par la clé de
// pilotage — que le poste ne possède pas.
package ajean
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/coder/websocket"
)
// handleNodePair (authed) génère un code d'appairage à usage unique valable
// nodePairCodeTTL. Le propriétaire choisit les capacités accordées et le dossier
// racine ; le poste ne pourra jamais dépasser ça.
func handleNodePair(w http.ResponseWriter, r *http.Request) {
var req struct {
Caps []string `json:"caps"`
Root string `json:"root"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
caps := nodeSanitizeCaps(req.Caps)
if len(caps) == 0 {
// Défaut prudent : lecture seule. Le propriétaire élargit ensuite.
caps = []string{nodeCapRead, nodeCapList}
}
p := nodePairPending{
Code: strings.ToUpper(nodeRandHex(4)), // 8 caractères hex lisibles
Caps: caps,
Root: strings.TrimSpace(req.Root),
Expires: time.Now().Add(nodePairCodeTTL).Unix(),
}
if err := savePairPending(p); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{
"ok": true,
"code": p.Code,
"caps": p.Caps,
"root": p.Root,
"expires": p.Expires,
"ttl_min": int(nodePairCodeTTL.Minutes()),
})
}
// handleNodeEnroll (PUBLIC) échange un code d'appairage contre une clé
// d'appareil. Le code est à usage unique : consommé dès qu'il sert.
func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
Name string `json:"name"`
OS string `json:"os"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
pending, ok := loadPairPending()
if !ok {
sendJSON(w, 403, map[string]any{"error": "aucun code d'appairage actif — générez-en un dans l'interface"})
return
}
if !strings.EqualFold(strings.TrimSpace(req.Code), pending.Code) {
sendJSON(w, 403, map[string]any{"error": "code incorrect"})
return
}
// Consomme le code immédiatement (usage unique), même si la suite échoue.
clearPairPending()
key := nodeRandHex(24)
name := strings.TrimSpace(req.Name)
if name == "" {
name = "poste"
}
node := pairedNode{
ID: nodeRandHex(8),
Name: name,
OS: strings.TrimSpace(req.OS),
KeyHash: nodeHashKey(key),
Caps: pending.Caps,
Root: pending.Root,
CreatedAt: time.Now().Unix(),
}
nodes := append(loadNodes(), node)
if err := saveNodes(nodes); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
// La clé n'est renvoyée QU'ICI, une seule fois : le poste la stocke en local.
sendJSON(w, 200, map[string]any{
"ok": true,
"id": node.ID,
"key": key,
"caps": node.Caps,
"root": node.Root,
"name": node.Name,
})
}
// handleNodes (authed) liste les postes appairés avec leur état de connexion.
func handleNodes(w http.ResponseWriter, r *http.Request) {
connected := map[string]bool{}
for _, nc := range nodeConnected() {
connected[nc.id] = true
}
nodes := loadNodes()
out := make([]map[string]any, 0, len(nodes))
for _, n := range nodes {
out = append(out, map[string]any{
"id": n.ID,
"name": n.Name,
"slug": nodeSlug(n.Name),
"os": n.OS,
"caps": n.Caps,
"root": n.Root,
"created_at": n.CreatedAt,
"last_seen": n.LastSeen,
"connected": connected[n.ID],
})
}
_, pending := loadPairPending()
sendJSON(w, 200, map[string]any{"ok": true, "nodes": out, "all_caps": nodeAllCaps, "pairing": pending, "target": agentTargetSlug()})
}
// handleNodeTarget (authed) choisit la machine sur laquelle l'agent agit :
// slug d'un poste, ou "" pour le serveur local.
func handleNodeTarget(w http.ResponseWriter, r *http.Request) {
var req struct {
Slug string `json:"slug"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
if err := setAgentTargetSlug(strings.TrimSpace(req.Slug)); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "target": agentTargetSlug()})
}
// handleNodeCaps (authed) met à jour les capacités autorisées et/ou le dossier
// racine d'un poste. Prend effet à la prochaine (re)connexion du poste.
func handleNodeCaps(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Caps []string `json:"caps"`
Root *string `json:"root"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
nodes := loadNodes()
found := false
for i := range nodes {
if nodes[i].ID == req.ID {
nodes[i].Caps = nodeSanitizeCaps(req.Caps)
if req.Root != nil {
nodes[i].Root = strings.TrimSpace(*req.Root)
}
found = true
break
}
}
if !found {
sendJSON(w, 404, map[string]any{"error": "poste inconnu"})
return
}
if err := saveNodes(nodes); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
// Coupe la session en cours pour que les nouvelles capacités s'appliquent
// (le poste se reconnecte et re-négocie l'intersection).
for _, nc := range nodeConnected() {
if nc.id == req.ID {
_ = nc.conn.Close(websocket.StatusNormalClosure, "capacités mises à jour")
}
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleNodeRevoke (authed) révoque un poste : oublie sa clé et le déconnecte.
func handleNodeRevoke(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
nodes := loadNodes()
kept := nodes[:0]
removed := false
for _, n := range nodes {
if n.ID == req.ID {
removed = true
continue
}
kept = append(kept, n)
}
if !removed {
sendJSON(w, 404, map[string]any{"error": "poste inconnu"})
return
}
if err := saveNodes(kept); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
for _, nc := range nodeConnected() {
if nc.id == req.ID {
_ = nc.conn.Close(websocket.StatusPolicyViolation, "poste révoqué")
}
}
sendJSON(w, 200, map[string]any{"ok": true})
}
+69
View File
@@ -0,0 +1,69 @@
// node_proto.go — côté serveur, alias vers le protocole partagé (internal/nodewire)
// + les helpers de nommage d'outils propres à ajean.
//
// Le format du fil (Msg, capacités, confinement de chemin) vit dans nodewire,
// paquet SANS dépendance, pour que le client léger ajean-node le partage sans
// tirer tout ajean. Ici on ne fait qu'exposer ces symboles sous les noms
// historiques du paquet, pour ne pas toucher le reste du code serveur.
package ajean
import (
"strings"
"github.com/nathaninline/ajean/internal/nodewire"
)
// Capacités (alias des constantes partagées).
const (
nodeCapShell = nodewire.CapShell
nodeCapRead = nodewire.CapRead
nodeCapWrite = nodewire.CapWrite
nodeCapList = nodewire.CapList
)
// nodeAllCaps : ordre canonique partagé.
var nodeAllCaps = nodewire.AllCaps
// nodeMsg : enveloppe du fil (alias de type → interop garantie avec le client).
type nodeMsg = nodewire.Msg
func nodeCapLabel(cap string) string { return nodewire.CapLabel(cap) }
func nodeCapAllowed(allowed []string, cap string) bool { return nodewire.CapAllowed(allowed, cap) }
func nodeCapIntersect(allowed, declared []string) []string {
return nodewire.CapIntersect(allowed, declared)
}
func nodeSanitizeCaps(caps []string) []string { return nodewire.SanitizeCaps(caps) }
func nodeResolvePath(root, p string) (string, error) { return nodewire.ResolvePath(root, p) }
// ── Helpers de nommage d'outils, propres au serveur ────────────────────────
// (conservés pour les tests ; l'IA ne reçoit plus d'outils node__* — voir la
// refonte « cible d'exécution » dans llm_client.go / node_server.go.)
// nodeSlug transforme un nom de poste en identifiant sûr ([A-Za-z0-9_-]).
func nodeSlug(name string) string {
s := mcpSanitize(name)
if s == "" {
s = "poste"
}
return s
}
func nodeToolName(slug, cap string) string { return "node__" + slug + "__" + cap }
func isNodeTool(name string) bool { return strings.HasPrefix(name, "node__") }
func parseNodeToolName(name string) (slug, cap string, ok bool) {
rest, found := strings.CutPrefix(name, "node__")
if !found {
return "", "", false
}
i := strings.LastIndex(rest, "__")
if i < 0 {
return "", "", false
}
slug, cap = rest[:i], rest[i+2:]
if slug == "" || cap == "" {
return "", "", false
}
return slug, cap, true
}
+89
View File
@@ -0,0 +1,89 @@
package ajean
import (
"path/filepath"
"runtime"
"testing"
)
func TestNodeCapIntersect(t *testing.T) {
got := nodeCapIntersect(
[]string{nodeCapShell, nodeCapRead, nodeCapWrite}, // autorisées par le propriétaire
[]string{nodeCapRead, nodeCapList}, // déclarées par le poste
)
// Seul read est dans les deux ; l'ordre doit être canonique.
if len(got) != 1 || got[0] != nodeCapRead {
t.Fatalf("intersection attendue [read], obtenu %v", got)
}
}
func TestNodeSanitizeCapsDropsUnknown(t *testing.T) {
got := nodeSanitizeCaps([]string{"write", "danger", "read", "read"})
// Ordre canonique (read avant write), doublon et inconnu retirés.
want := []string{nodeCapRead, nodeCapWrite}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("attendu %v, obtenu %v", want, got)
}
}
func TestNodeToolNameRoundTrip(t *testing.T) {
name := nodeToolName("mon_poste", nodeCapShell)
if !isNodeTool(name) {
t.Fatalf("%s devrait être reconnu comme outil de poste", name)
}
slug, cap, ok := parseNodeToolName(name)
if !ok || slug != "mon_poste" || cap != nodeCapShell {
t.Fatalf("parse: ok=%v slug=%q cap=%q", ok, slug, cap)
}
}
func TestNodeToolNameSlugWithDoubleUnderscore(t *testing.T) {
// Un slug contenant « __ » ne doit pas casser la reconstitution : on coupe
// sur le DERNIER séparateur, et la capacité n'en contient jamais.
name := nodeToolName("a__b", nodeCapWrite)
slug, cap, ok := parseNodeToolName(name)
if !ok || slug != "a__b" || cap != nodeCapWrite {
t.Fatalf("parse: ok=%v slug=%q cap=%q", ok, slug, cap)
}
}
func TestNodeResolvePathConfinement(t *testing.T) {
root := t.TempDir()
// Chemin relatif normal : accepté, sous la racine.
got, err := nodeResolvePath(root, "sous/dossier/f.txt")
if err != nil {
t.Fatalf("chemin relatif refusé à tort: %v", err)
}
if want := filepath.Join(root, "sous", "dossier", "f.txt"); got != want {
t.Fatalf("attendu %q, obtenu %q", want, got)
}
// Traversée « .. » : refusée.
if _, err := nodeResolvePath(root, "../evade.txt"); err == nil {
t.Fatal("la traversée .. aurait dû être refusée")
}
if _, err := nodeResolvePath(root, "a/../../evade.txt"); err == nil {
t.Fatal("la traversée imbriquée aurait dû être refusée")
}
// Chemin absolu hors racine : refusé.
outside := filepath.Join(filepath.Dir(root), "ailleurs.txt")
if runtime.GOOS == "windows" {
outside = `C:\Windows\System32\evil.txt`
}
if _, err := nodeResolvePath(root, outside); err == nil {
t.Fatalf("un chemin absolu hors racine (%s) aurait dû être refusé", outside)
}
// Chemin absolu SOUS la racine : accepté.
inside := filepath.Join(root, "ok.txt")
if _, err := nodeResolvePath(root, inside); err != nil {
t.Fatalf("un chemin absolu sous la racine refusé à tort: %v", err)
}
// Racine vide : refusée (aucun dossier autorisé = aucun accès).
if _, err := nodeResolvePath("", "f.txt"); err == nil {
t.Fatal("une racine vide aurait dû refuser tout chemin")
}
}
+381
View File
@@ -0,0 +1,381 @@
// node_server.go — côté SERVEUR du poste distant : appairage, endpoint
// WebSocket, registre des postes connectés, et exposition de leurs outils à
// l'agent. Le serveur n'exécute JAMAIS rien lui-même ici : il route une demande
// d'outil vers le poste concerné et attend sa réponse.
package ajean
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
const (
nodePairCodeTTL = 10 * time.Minute
nodeCallTimeout = 5 * time.Minute // un shell distant peut être long
nodeHelloWait = 15 * time.Second
)
// pairedNode est un poste appairé, tel que persisté dans bkState["nodes"].
// On ne garde JAMAIS la clé en clair : seulement son SHA-256. Le poste, lui,
// conserve la clé en local (voir node_client.go).
type pairedNode struct {
ID string `json:"id"`
Name string `json:"name"`
OS string `json:"os"`
KeyHash string `json:"key_hash"`
Caps []string `json:"caps"` // capacités AUTORISÉES par le propriétaire
Root string `json:"root,omitempty"`
CreatedAt int64 `json:"created_at"`
LastSeen int64 `json:"last_seen,omitempty"`
}
func loadNodes() []pairedNode {
var l []pairedNode
getJSON(bkState, "nodes", &l)
return l
}
func saveNodes(l []pairedNode) error { return putJSON(bkState, "nodes", l) }
// findNodeByKey retrouve un poste appairé à partir de la clé d'appareil présentée
// (comparaison à temps constant sur le hash). Renvoie nil si aucune ne matche.
func findNodeByKey(key string) *pairedNode {
if key == "" {
return nil
}
want := nodeHashKey(key)
nodes := loadNodes()
for i := range nodes {
if subtle.ConstantTimeCompare([]byte(nodes[i].KeyHash), []byte(want)) == 1 {
return &nodes[i]
}
}
return nil
}
func nodeHashKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
// nodeRandHex renvoie n octets aléatoires en hexadécimal (2n caractères).
func nodeRandHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// ─── Code d'appairage (usage unique, TTL court) ────────────────────────────
type nodePairPending struct {
Code string `json:"code"`
Caps []string `json:"caps"` // capacités que ce code accordera
Root string `json:"root,omitempty"`
Expires int64 `json:"expires"`
}
func loadPairPending() (nodePairPending, bool) {
var p nodePairPending
if !getJSON(bkState, "node_pair", &p) || p.Code == "" {
return nodePairPending{}, false
}
if time.Now().Unix() > p.Expires {
return nodePairPending{}, false
}
return p, true
}
func savePairPending(p nodePairPending) error { return putJSON(bkState, "node_pair", p) }
func clearPairPending() { _ = putJSON(bkState, "node_pair", nodePairPending{}) }
// ─── Registre des postes CONNECTÉS (en mémoire) ────────────────────────────
type nodeConn struct {
id string
slug string
name string
os string
caps []string // capacités EFFECTIVES (autorisées ∩ déclarées)
root string
conn *websocket.Conn
ctx context.Context
writeMu sync.Mutex
mu sync.Mutex
pending map[string]chan string
}
func (nc *nodeConn) send(m nodeMsg) error {
nc.writeMu.Lock()
defer nc.writeMu.Unlock()
ctx, cancel := context.WithTimeout(nc.ctx, 15*time.Second)
defer cancel()
return wsjson.Write(ctx, nc.conn, m)
}
var (
nodeRegMu sync.Mutex
nodeReg = map[string]*nodeConn{} // slug → connexion vivante
)
func nodeRegister(nc *nodeConn) {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
// Un même slug déjà connecté (reconnexion, ou deux postes de même nom) : on
// ferme l'ancien pour éviter deux postes indiscernables sous le même outil.
if old, ok := nodeReg[nc.slug]; ok {
_ = old.conn.Close(websocket.StatusPolicyViolation, "remplacé par une nouvelle session")
}
nodeReg[nc.slug] = nc
}
func nodeUnregister(nc *nodeConn) {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
if cur, ok := nodeReg[nc.slug]; ok && cur == nc {
delete(nodeReg, nc.slug)
}
}
func nodeGet(slug string) *nodeConn {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
return nodeReg[slug]
}
func nodeConnected() []*nodeConn {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
out := make([]*nodeConn, 0, len(nodeReg))
for _, nc := range nodeReg {
out = append(out, nc)
}
sort.Slice(out, func(i, j int) bool { return out[i].slug < out[j].slug })
return out
}
// ─── Endpoint WebSocket : le poste se connecte ici ─────────────────────────
// handleNodeWS accueille la connexion sortante d'un poste. Route PUBLIQUE (pas
// derrière la clé de pilotage) : l'authentification se fait par la clé d'appareil
// remise à l'appairage, présentée en Bearer.
func handleNodeWS(w http.ResponseWriter, r *http.Request) {
key := bearerToken(r)
pn := findNodeByKey(key)
if pn == nil {
http.Error(w, "poste non appairé", http.StatusUnauthorized)
return
}
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
if err != nil {
return
}
c.SetReadLimit(-1)
ctx := r.Context()
// 1er message : le hello où le poste déclare son nom/os et ce qu'il sait faire.
helloCtx, cancel := context.WithTimeout(ctx, nodeHelloWait)
var hello nodeMsg
err = wsjson.Read(helloCtx, c, &hello)
cancel()
if err != nil || hello.Type != "hello" {
_ = c.Close(websocket.StatusProtocolError, "hello attendu")
return
}
name := strings.TrimSpace(hello.Name)
if name == "" {
name = pn.Name
}
caps := nodeCapIntersect(pn.Caps, hello.Caps)
nc := &nodeConn{
id: pn.ID,
slug: nodeSlug(name),
name: name,
os: strings.TrimSpace(hello.OS),
caps: caps,
root: pn.Root,
conn: c,
ctx: ctx,
pending: map[string]chan string{},
}
nodeRegister(nc)
defer nodeUnregister(nc)
touchNodeSeen(pn.ID)
// Boucle de lecture : les seules entrées attendues sont des « result » qui
// débloquent l'appel en attente correspondant. Le ping/keepalive est géré par
// coder/websocket.
for {
var m nodeMsg
if err := wsjson.Read(ctx, c, &m); err != nil {
_ = c.CloseNow()
return
}
if m.Type == "result" {
nc.mu.Lock()
ch := nc.pending[m.ID]
delete(nc.pending, m.ID)
nc.mu.Unlock()
if ch != nil {
ch <- m.Result
}
}
}
}
func touchNodeSeen(id string) {
nodes := loadNodes()
for i := range nodes {
if nodes[i].ID == id {
nodes[i].LastSeen = time.Now().Unix()
_ = saveNodes(nodes)
return
}
}
}
// bearerToken extrait le jeton d'un en-tête « Authorization: Bearer … ».
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if s, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(s)
}
return ""
}
// ─── Appel d'un outil de poste (routage serveur→poste→serveur) ─────────────
// nodeCall envoie une demande d'exécution au poste et attend son résultat. Toute
// la sécurité côté serveur tient ici : on ne route que vers un poste connecté et
// une capacité effectivement autorisée.
func nodeCall(slug, cap string, args map[string]any) string {
nc := nodeGet(slug)
if nc == nil {
return "[erreur] poste « " + slug + " » déconnecté"
}
if !nodeCapAllowed(nc.caps, cap) {
return "[erreur] capacité « " + cap + " » non autorisée sur ce poste"
}
id := nodeRandHex(8)
ch := make(chan string, 1)
nc.mu.Lock()
nc.pending[id] = ch
nc.mu.Unlock()
defer func() {
nc.mu.Lock()
delete(nc.pending, id)
nc.mu.Unlock()
}()
if err := nc.send(nodeMsg{Type: "call", ID: id, Cap: cap, Args: args}); err != nil {
return "[erreur] envoi au poste impossible: " + err.Error()
}
select {
case res := <-ch:
return res
case <-time.After(nodeCallTimeout):
return "[timeout] le poste n'a pas répondu à temps"
case <-nc.ctx.Done():
return "[erreur] poste déconnecté pendant l'appel"
}
}
// nodeEditRemote applique une édition (old→new, old unique) à un fichier d'un
// poste : on le lit, on remplace localement, on réécrit — via les capacités
// read + write du poste. Reproduit fileEdit mais à distance.
func nodeEditRemote(slug, path, oldText, newText string) string {
if oldText == "" {
return "[erreur] old vide"
}
content := nodeCall(slug, nodeCapRead, map[string]any{"path": path})
if isNodeErr(content) {
return content
}
n := strings.Count(content, oldText)
if n == 0 {
if newText != "" && strings.Contains(content, newText) {
return "[ok] déjà à jour — le fichier contient déjà cette modification"
}
return "[erreur] old introuvable dans le fichier"
}
if n > 1 {
return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
}
updated := strings.Replace(content, oldText, newText, 1)
return nodeCall(slug, nodeCapWrite, map[string]any{"path": path, "content": updated})
}
// isNodeErr indique si un résultat d'appel de poste est un échec (à ne pas
// traiter comme du contenu utile).
func isNodeErr(s string) bool {
return strings.HasPrefix(s, "[erreur]") || strings.HasPrefix(s, "[refusé]") || strings.HasPrefix(s, "[timeout]")
}
// ─── Cible d'exécution de l'agent (quel PC l'IA pilote) ────────────────────
// agentTargetSlug renvoie le slug du poste sur lequel l'agent agit, ou "" pour
// le serveur local (comportement historique). Persisté dans bkState.
func agentTargetSlug() string { return getStr(bkState, "agent_target") }
func setAgentTargetSlug(slug string) error { return putStr(bkState, "agent_target", slug) }
// nodeTargetMeta décrit la cible d'exécution courante pour le prompt système.
type nodeTargetMeta struct {
slug, name, os, root string
connected bool
}
// nodeTargetMetaGet renvoie la cible sélectionnée (ok=false si c'est le serveur
// local). Complète nom/os/dossier depuis le registre si le poste est connecté,
// sinon depuis l'enregistrement d'appairage.
func nodeTargetMetaGet() (nodeTargetMeta, bool) {
slug := agentTargetSlug()
if slug == "" {
return nodeTargetMeta{}, false
}
m := nodeTargetMeta{slug: slug}
if nc := nodeGet(slug); nc != nil {
m.name, m.os, m.root, m.connected = nc.name, nc.os, nc.root, true
return m, true
}
for _, n := range loadNodes() {
if nodeSlug(n.Name) == slug {
m.name, m.os, m.root = n.Name, n.OS, n.Root
break
}
}
if m.name == "" {
m.name = slug
}
return m, true
}
// agentTargetShellName renvoie le shell de la MACHINE CIBLE : cmd.exe si le poste
// ciblé tourne sous Windows, bash sinon. Sans cible → le shell du serveur local.
// Indispensable : le serveur peut être sous Linux (bash) alors que le poste est
// sous Windows (cmd.exe) — annoncer le mauvais shell fait écrire au modèle une
// syntaxe que la machine cible ne comprend pas.
func agentTargetShellName() string {
if tgt, ok := nodeTargetMetaGet(); ok {
if strings.HasPrefix(strings.ToLower(tgt.os), "windows") {
return "cmd.exe"
}
return "bash"
}
return shellName()
}
+137
View File
@@ -0,0 +1,137 @@
package ajean
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
// TestNodeEndToEnd exerce tout le chemin serveur : enrôlement via code, connexion
// WebSocket, négociation des capacités (intersection propriétaire ∩ poste), et
// routage d'un appel d'outil jusqu'au poste et retour.
func TestNodeEndToEnd(t *testing.T) {
testHome(t)
// Le propriétaire n'autorise que read + shell (pas write).
if err := savePairPending(nodePairPending{
Code: "ABCD1234",
Caps: []string{nodeCapRead, nodeCapShell},
Expires: time.Now().Add(time.Minute).Unix(),
}); err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/node/enroll", handleNodeEnroll)
mux.HandleFunc("/api/node/ws", handleNodeWS)
srv := httptest.NewServer(mux)
defer srv.Close()
// 1) Enrôlement : le code est échangé contre une clé d'appareil.
body, _ := json.Marshal(map[string]string{"code": "abcd1234", "name": "testpc", "os": "linux/amd64"})
resp, err := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
var enr struct {
OK bool `json:"ok"`
Key string `json:"key"`
Caps []string `json:"caps"`
}
_ = json.NewDecoder(resp.Body).Decode(&enr)
resp.Body.Close()
if !enr.OK || enr.Key == "" {
t.Fatalf("enrôlement échoué: %+v", enr)
}
// Le code est à usage unique : un second enrôlement doit échouer.
resp2, _ := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
if resp2.StatusCode == 200 {
t.Fatal("le code d'appairage aurait dû être consommé (usage unique)")
}
resp2.Body.Close()
// 2) Le poste se connecte et sert les appels.
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/node/ws"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
HTTPHeader: http.Header{"Authorization": {"Bearer " + enr.Key}},
})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.CloseNow()
// Le poste déclare pouvoir read/shell/write ; l'effectif sera ∩ = read/shell.
if err := wsjson.Write(ctx, c, nodeMsg{Type: "hello", Name: "testpc", OS: "linux/amd64",
Caps: []string{nodeCapRead, nodeCapShell, nodeCapWrite}}); err != nil {
t.Fatalf("hello: %v", err)
}
// Boucle client : répond à chaque call par "RESULT:<cap>".
go func() {
for {
var m nodeMsg
if err := wsjson.Read(context.Background(), c, &m); err != nil {
return
}
if m.Type == "call" {
_ = wsjson.Write(context.Background(), c, nodeMsg{Type: "result", ID: m.ID, Result: "RESULT:" + m.Cap})
}
}
}()
// Attend l'enregistrement du poste.
slug := nodeSlug("testpc")
var nc *nodeConn
for i := 0; i < 100; i++ {
if nc = nodeGet(slug); nc != nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if nc == nil {
t.Fatal("le poste ne s'est pas enregistré")
}
// 3) Capacités effectives = intersection : read + shell, PAS write.
if len(nc.caps) != 2 || nc.caps[0] != nodeCapShell || nc.caps[1] != nodeCapRead {
t.Fatalf("capacités effectives inattendues: %v", nc.caps)
}
// 4) Un appel autorisé traverse jusqu'au poste et revient.
if got := nodeCall(slug, nodeCapRead, map[string]any{"path": "x"}); got != "RESULT:read" {
t.Fatalf("appel read: attendu RESULT:read, obtenu %q", got)
}
// 5) Un appel NON autorisé (write) est refusé côté serveur, sans atteindre le poste.
if got := nodeCall(slug, nodeCapWrite, map[string]any{"path": "x", "content": "y"}); !strings.Contains(got, "non autorisée") {
t.Fatalf("appel write aurait dû être refusé, obtenu %q", got)
}
// 6) Sélection de la cible : l'agent agit sur ce poste. La méta reflète un
// poste connecté, et un appel shell (autorisé) est bien routé jusqu'au poste.
if err := setAgentTargetSlug(slug); err != nil {
t.Fatal(err)
}
meta, ok := nodeTargetMetaGet()
if !ok || !meta.connected || meta.slug != slug {
t.Fatalf("cible inattendue: ok=%v %+v", ok, meta)
}
if got := nodeCall(slug, nodeCapShell, map[string]any{"command": "echo hi"}); got != "RESULT:shell" {
t.Fatalf("routage shell: attendu RESULT:shell, obtenu %q", got)
}
// 7) nodeEditRemote lit puis réécrit via le poste (le client fictif renvoie
// "RESULT:read" à la lecture → "old" introuvable, donc erreur explicite, pas
// un plantage : on vérifie juste que le chemin read→write est emprunté).
if got := nodeEditRemote(slug, "f.txt", "absent", "x"); !strings.Contains(got, "introuvable") {
t.Fatalf("nodeEditRemote: attendu 'introuvable', obtenu %q", got)
}
setAgentTargetSlug("") // ne pas fuiter la cible sur d'autres tests
}
+5 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.8.8"
const Version = "0.8.9"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() {
@@ -158,6 +158,10 @@ Accès distant (ajean.link) :
link code code d'appairage (10 min, usage unique) pour le portail
link status | logout état du jeton / l'oublier
Poste distant (piloter un autre PC depuis l'IA) :
le poste s'installe avec le binaire LÉGER séparé « ajean-remote » (cmd/ajean-remote),
pas avec ce binaire : ajean-remote install <url-serveur> --code CODE
Backend llama.cpp :
llamacpp install clone + compile llama.cpp (CUDA/ROCm/Metal/CPU), pointe BIN dessus
llamacpp update git pull + recompile le backend existant
+222 -2
View File
@@ -1166,6 +1166,21 @@ button:hover{border-color:var(--dim);color:var(--text)}
/* Conversation vide : un seul message centré à la place des réglages. */
.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}
/* Postes distants : cases de capacités, code d'appairage, commande. */
.node-caps{display:flex;flex-wrap:wrap;gap:6px}
.node-cap{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer;user-select:none}
.node-cap input{margin:0}
.node-code{font-family:var(--mono,monospace);font-size:22px;font-weight:700;letter-spacing:3px;color:var(--accent);padding:8px 0}
.node-cmd{white-space:pre-wrap;word-break:break-all;background:var(--code-bg);border:1px solid var(--border);border-radius:7px;padding:8px;margin:6px 0;font-size:12px}
/* Poste distant : sélecteur de cible d'exécution. */
.node-target{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px}
.node-target-l{font-size:12px;color:var(--dim);margin-right:2px;width:100%}
.node-target-opt{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer}
.node-target-opt.on{border-color:var(--accent);color:var(--accent)}
.node-target-opt.off{opacity:.6}
.node-target-opt input{margin:0}
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
@@ -1287,6 +1302,15 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div>
</div>
</div>
<div id="node-block" class="gated-group">
<div class="subhead">Postes distants<span id="node-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Pilote un <b>autre PC</b> depuis l'IA. installe le petit client <b>ajean-remote</b> sur le PC secondaire et lance <code>ajean-remote install &lt;url&gt; --code CODE</code> : il se connecte en sortant (aucun port à ouvrir) et expose à l'IA un jeu d'outils restreint (shell, fichiers). ⚠ tu décides des capacités et du dossier autorisé pour chaque poste ; le poste peut aussi demander confirmation localement.</span></span></div>
<div class="slist">
<div class="rowbody">
<div id="node-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l'IA puisse agir dessus)</div></div>
<button class="addbtn" style="margin-top:6px" onclick="openNodePair()">+ appairer un poste</button>
</div>
</div>
</div>
<div id="param-block" class="gated-group">
<div class="subhead">Paramètres</div>
<div class="slist">
@@ -1680,6 +1704,40 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div>
</div>
</div>
<div id="node-pair-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)hideModal('node-pair-modal')">
<div class="modal-card">
<div class="modal-head">
<strong>Appairer un poste distant</strong>
<button class="modal-x" onclick="hideModal('node-pair-modal')" aria-label="Fermer">×</button>
</div>
<div class="modal-body">
<div class="fld">
<label class="fld-l">Capacités autorisées <span class="opt">(ce que l'IA pourra faire sur ce poste)</span></label>
<div id="node-pair-caps" class="node-caps"></div>
</div>
<div class="fld">
<label class="fld-l" for="node-pair-root">Dossier autorisé <span class="opt">(confine lecture/écriture ; laisser vide = dossier de travail par défaut du poste)</span></label>
<input id="node-pair-root" class="ctl" placeholder="C:\\Users\\moi\\ajean ou /home/moi/ajean" spellcheck="false">
</div>
<div id="node-pair-out" style="display:none">
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
<div class="node-code" id="node-pair-code">——</div>
<div class="muted" style="font-size:12px;line-height:1.5">
Sur le PC à piloter, installe AJEAN puis lance :
<pre class="node-cmd" id="node-pair-cmd"></pre>
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
</div>
</div>
</div>
<div class="modal-foot">
<div class="foot-l"></div>
<div class="foot-r">
<button onclick="hideModal('node-pair-modal')">fermer</button>
<button class="btn-save" onclick="genNodeCode()">générer le code</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none">
<div class="modal-card">
<div class="modal-head">
@@ -2478,7 +2536,7 @@ async function loadAgent(){
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
function setAgentGate(on){
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
['mem-block','net-block','mcp-block','node-block','param-block'].forEach(id=>{
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
});
}
@@ -2851,7 +2909,7 @@ async function loadAll(){
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
// hauteurs réservées en place pour toujours.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
@@ -5332,4 +5390,166 @@ async function attachPaths(){
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
}
renderAttach();
// ─── Postes distants ─────────────────────────────────────────────────────────
// Gestion des « postes » : d'autres PC qui se connectent au serveur pour que
// l'IA puisse agir dessus. Le propriétaire génère un code d'appairage, choisit
// les capacités autorisées et le dossier racine, puis peut lister/révoquer les
// postes. Sous-réglage du mode agent (grisé quand l'agent est off).
let nodeList = [];
let nodeTarget = '';
let nodeAllCaps = ['shell','read','write','list'];
const nodeCapLabels = {shell:'shell', read:'lire', write:'écrire', list:'lister'};
async function loadNode(){ renderNode(await jget('/api/node')); }
function renderNode(r){
nodeList = (r && r.nodes) || [];
nodeTarget = (r && r.target) || '';
if(r && r.all_caps && r.all_caps.length) nodeAllCaps = r.all_caps;
const connected = nodeList.filter(n=>n.connected).length;
setBadge('node-badge', connected ? connected : null);
const list = document.getElementById('node-list');
list.textContent = '';
if(!nodeList.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l\'IA puisse agir dessus)</div>';
return;
}
// Sélecteur de CIBLE : sur quelle machine l'IA agit (ses outils bash/write/edit).
renderNodeTarget(list, connected);
nodeList.forEach(n=>{
const row = document.createElement('div'); row.className = 'mcp-row'+(n.connected?'':' off');
const dot = document.createElement('span'); dot.className = 'mcp-dot '+(n.connected?'mcp-dot-ok':'mcp-dot-off');
dot.title = n.connected ? 'connecté' : 'hors ligne';
const info = document.createElement('div'); info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name'; nm.textContent = n.name; nm.title = n.os||'';
const meta = document.createElement('div'); meta.className = 'mcp-meta';
const os = document.createElement('span'); os.className = 'mcp-tag'; os.textContent = n.os||'?'; meta.appendChild(os);
const caps = document.createElement('span'); caps.className = 'mcp-tools';
caps.textContent = (n.caps&&n.caps.length) ? n.caps.map(c=>nodeCapLabels[c]||c).join(', ') : 'aucune capacité';
meta.appendChild(caps);
if(n.root){ const rt = document.createElement('span'); rt.className='mcp-tools'; rt.textContent = '📁 '+n.root; rt.title=n.root; meta.appendChild(rt); }
info.appendChild(nm); info.appendChild(meta);
const edit = document.createElement('button'); edit.className='mcp-edit'; edit.title='régler'; edit.textContent='✎';
edit.onclick = (e)=>{ e.stopPropagation(); openNodeEdit(n.id); };
row.appendChild(dot); row.appendChild(info); row.appendChild(edit);
row.onclick = ()=>openNodeEdit(n.id);
list.appendChild(row);
});
}
// Sélecteur « L'IA agit sur : Ce serveur / <poste connecté> ». Les mêmes outils
// (bash/write/edit) s'exécutent sur la machine choisie ; un poste hors ligne ne
// peut pas être cible (fail-closed côté serveur).
function renderNodeTarget(list, connected){
const wrap = document.createElement('div'); wrap.className = 'node-target';
const lab = document.createElement('div'); lab.className = 'node-target-l';
lab.textContent = "L'IA agit sur :"; wrap.appendChild(lab);
const opts = [{slug:'', name:'Ce serveur'}].concat(nodeList.filter(n=>n.connected).map(n=>({slug:n.slug, name:n.name})));
// Cible sélectionnée mais hors ligne : on l'ajoute quand même, marquée, pour ne
// pas la perdre silencieusement.
if(nodeTarget && !opts.some(o=>o.slug===nodeTarget)){
const n = nodeList.find(x=>x.slug===nodeTarget);
opts.push({slug:nodeTarget, name:(n?n.name:nodeTarget)+' (hors ligne)', off:true});
}
opts.forEach(o=>{
const l = document.createElement('label'); l.className = 'node-target-opt'+(o.slug===nodeTarget?' on':'')+(o.off?' off':'');
const rb = document.createElement('input'); rb.type='radio'; rb.name='node-target'; rb.checked = o.slug===nodeTarget;
rb.onchange = ()=>setNodeTarget(o.slug);
const sp = document.createElement('span'); sp.textContent = o.name;
l.appendChild(rb); l.appendChild(sp); wrap.appendChild(l);
});
list.appendChild(wrap);
}
async function setNodeTarget(slug){
const r = await jpost('/api/node/target', {slug});
nodeTarget = (r && r.target) || '';
toast(slug ? ('l\'IA agit sur « '+slug+' »') : 'l\'IA agit sur ce serveur');
loadNode();
}
// ── Appairage : génère un code que le poste échange contre sa clé ────────────
function nodeCapCheckboxes(containerId, selected){
const box = document.getElementById(containerId);
box.textContent = '';
const sel = new Set(selected||[]);
nodeAllCaps.forEach(c=>{
const lab = document.createElement('label'); lab.className='node-cap';
const cb = document.createElement('input'); cb.type='checkbox'; cb.value=c; cb.checked = sel.has(c);
const sp = document.createElement('span'); sp.textContent = nodeCapLabels[c]||c;
lab.appendChild(cb); lab.appendChild(sp);
box.appendChild(lab);
});
}
function nodeCapValues(containerId){
return [...document.querySelectorAll('#'+containerId+' input:checked')].map(cb=>cb.value);
}
function openNodePair(){
nodeCapCheckboxes('node-pair-caps', ['read','list']); // défaut prudent
document.getElementById('node-pair-root').value = '';
document.getElementById('node-pair-out').style.display = 'none';
showModal('node-pair-modal');
}
async function genNodeCode(){
const caps = nodeCapValues('node-pair-caps');
if(!caps.length){ toast('choisis au moins une capacité'); return; }
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/pair', {caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
document.getElementById('node-pair-code').textContent = r.code;
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
const allow = caps.join(',');
const rootArg = root ? (' --root "'+root+'"') : '';
document.getElementById('node-pair-cmd').textContent =
'ajean-remote install '+location.origin+' --code '+r.code+' --allow '+allow+rootArg;
document.getElementById('node-pair-out').style.display = '';
loadNode();
}
// ── Édition d'un poste appairé : capacités, dossier, révocation ──────────────
let nodeEditing = null;
function openNodeEdit(id){
const n = nodeList.find(x=>x.id===id); if(!n) return;
nodeEditing = id;
nodeCapCheckboxes('node-pair-caps', n.caps);
document.getElementById('node-pair-root').value = n.root||'';
document.getElementById('node-pair-out').style.display = 'none';
// Réutilise la modale d'appairage en mode édition : on remplace le pied.
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Poste « '+n.name+' »'+(n.connected?' (connecté)':'');
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '';
const cancel = document.createElement('button'); cancel.textContent='fermer'; cancel.onclick=()=>{ hideModal('node-pair-modal'); resetNodeFoot(); };
const save = document.createElement('button'); save.className='btn-save'; save.textContent='enregistrer'; save.onclick=saveNodeCaps;
footR.appendChild(cancel); footR.appendChild(save);
const footL = document.querySelector('#node-pair-modal .foot-l');
footL.innerHTML = '';
const del = document.createElement('button'); del.className='btn-danger'; del.textContent='révoquer'; del.onclick=()=>revokeNode(id);
footL.appendChild(del);
showModal('node-pair-modal');
}
function resetNodeFoot(){
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Appairer un poste distant';
document.querySelector('#node-pair-modal .foot-l').innerHTML = '';
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '<button onclick="hideModal(\'node-pair-modal\')">fermer</button><button class="btn-save" onclick="genNodeCode()">générer le code</button>';
nodeEditing = null;
}
async function saveNodeCaps(){
if(!nodeEditing) return;
const caps = nodeCapValues('node-pair-caps');
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/caps', {id:nodeEditing, caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
toast('poste mis à jour'); hideModal('node-pair-modal'); resetNodeFoot(); loadNode();
}
async function revokeNode(id){
const n = nodeList.find(x=>x.id===id);
if(!await askConfirm('Révoquer le poste « '+(n?n.name:'')+' » ? Sa clé sera oubliée et il sera déconnecté.', {title:'Révoquer le poste ?', okText:'Révoquer', danger:true})) return;
await jpost('/api/node/revoke', {id});
hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); toast('poste révoqué');
}
</script></body></html>
+43
View File
@@ -146,6 +146,15 @@
</div>
</div>
</div>
<div id="node-block" class="gated-group">
<div class="subhead">Postes distants<span id="node-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Pilote un <b>autre PC</b> depuis l'IA. installe le petit client <b>ajean-remote</b> sur le PC secondaire et lance <code>ajean-remote install &lt;url&gt; --code CODE</code> : il se connecte en sortant (aucun port à ouvrir) et expose à l'IA un jeu d'outils restreint (shell, fichiers). ⚠ tu décides des capacités et du dossier autorisé pour chaque poste ; le poste peut aussi demander confirmation localement.</span></span></div>
<div class="slist">
<div class="rowbody">
<div id="node-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l'IA puisse agir dessus)</div></div>
<button class="addbtn" style="margin-top:6px" onclick="openNodePair()">+ appairer un poste</button>
</div>
</div>
</div>
<div id="param-block" class="gated-group">
<div class="subhead">Paramètres</div>
<div class="slist">
@@ -539,6 +548,40 @@
</div>
</div>
</div>
<div id="node-pair-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)hideModal('node-pair-modal')">
<div class="modal-card">
<div class="modal-head">
<strong>Appairer un poste distant</strong>
<button class="modal-x" onclick="hideModal('node-pair-modal')" aria-label="Fermer">×</button>
</div>
<div class="modal-body">
<div class="fld">
<label class="fld-l">Capacités autorisées <span class="opt">(ce que l'IA pourra faire sur ce poste)</span></label>
<div id="node-pair-caps" class="node-caps"></div>
</div>
<div class="fld">
<label class="fld-l" for="node-pair-root">Dossier autorisé <span class="opt">(confine lecture/écriture ; laisser vide = dossier de travail par défaut du poste)</span></label>
<input id="node-pair-root" class="ctl" placeholder="C:\\Users\\moi\\ajean ou /home/moi/ajean" spellcheck="false">
</div>
<div id="node-pair-out" style="display:none">
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
<div class="node-code" id="node-pair-code">——</div>
<div class="muted" style="font-size:12px;line-height:1.5">
Sur le PC à piloter, installe AJEAN puis lance :
<pre class="node-cmd" id="node-pair-cmd"></pre>
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
</div>
</div>
</div>
<div class="modal-foot">
<div class="foot-l"></div>
<div class="foot-r">
<button onclick="hideModal('node-pair-modal')">fermer</button>
<button class="btn-save" onclick="genNodeCode()">générer le code</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none">
<div class="modal-card">
<div class="modal-head">
+2 -2
View File
@@ -118,7 +118,7 @@ async function loadAgent(){
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
function setAgentGate(on){
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
['mem-block','net-block','mcp-block','node-block','param-block'].forEach(id=>{
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
});
}
@@ -491,7 +491,7 @@ async function loadAll(){
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
// hauteurs réservées en place pour toujours.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
+162
View File
@@ -0,0 +1,162 @@
// ─── Postes distants ─────────────────────────────────────────────────────────
// Gestion des « postes » : d'autres PC qui se connectent au serveur pour que
// l'IA puisse agir dessus. Le propriétaire génère un code d'appairage, choisit
// les capacités autorisées et le dossier racine, puis peut lister/révoquer les
// postes. Sous-réglage du mode agent (grisé quand l'agent est off).
let nodeList = [];
let nodeTarget = '';
let nodeAllCaps = ['shell','read','write','list'];
const nodeCapLabels = {shell:'shell', read:'lire', write:'écrire', list:'lister'};
async function loadNode(){ renderNode(await jget('/api/node')); }
function renderNode(r){
nodeList = (r && r.nodes) || [];
nodeTarget = (r && r.target) || '';
if(r && r.all_caps && r.all_caps.length) nodeAllCaps = r.all_caps;
const connected = nodeList.filter(n=>n.connected).length;
setBadge('node-badge', connected ? connected : null);
const list = document.getElementById('node-list');
list.textContent = '';
if(!nodeList.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucun poste — appaire un PC pour que l\'IA puisse agir dessus)</div>';
return;
}
// Sélecteur de CIBLE : sur quelle machine l'IA agit (ses outils bash/write/edit).
renderNodeTarget(list, connected);
nodeList.forEach(n=>{
const row = document.createElement('div'); row.className = 'mcp-row'+(n.connected?'':' off');
const dot = document.createElement('span'); dot.className = 'mcp-dot '+(n.connected?'mcp-dot-ok':'mcp-dot-off');
dot.title = n.connected ? 'connecté' : 'hors ligne';
const info = document.createElement('div'); info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name'; nm.textContent = n.name; nm.title = n.os||'';
const meta = document.createElement('div'); meta.className = 'mcp-meta';
const os = document.createElement('span'); os.className = 'mcp-tag'; os.textContent = n.os||'?'; meta.appendChild(os);
const caps = document.createElement('span'); caps.className = 'mcp-tools';
caps.textContent = (n.caps&&n.caps.length) ? n.caps.map(c=>nodeCapLabels[c]||c).join(', ') : 'aucune capacité';
meta.appendChild(caps);
if(n.root){ const rt = document.createElement('span'); rt.className='mcp-tools'; rt.textContent = '📁 '+n.root; rt.title=n.root; meta.appendChild(rt); }
info.appendChild(nm); info.appendChild(meta);
const edit = document.createElement('button'); edit.className='mcp-edit'; edit.title='régler'; edit.textContent='✎';
edit.onclick = (e)=>{ e.stopPropagation(); openNodeEdit(n.id); };
row.appendChild(dot); row.appendChild(info); row.appendChild(edit);
row.onclick = ()=>openNodeEdit(n.id);
list.appendChild(row);
});
}
// Sélecteur « L'IA agit sur : Ce serveur / <poste connecté> ». Les mêmes outils
// (bash/write/edit) s'exécutent sur la machine choisie ; un poste hors ligne ne
// peut pas être cible (fail-closed côté serveur).
function renderNodeTarget(list, connected){
const wrap = document.createElement('div'); wrap.className = 'node-target';
const lab = document.createElement('div'); lab.className = 'node-target-l';
lab.textContent = "L'IA agit sur :"; wrap.appendChild(lab);
const opts = [{slug:'', name:'Ce serveur'}].concat(nodeList.filter(n=>n.connected).map(n=>({slug:n.slug, name:n.name})));
// Cible sélectionnée mais hors ligne : on l'ajoute quand même, marquée, pour ne
// pas la perdre silencieusement.
if(nodeTarget && !opts.some(o=>o.slug===nodeTarget)){
const n = nodeList.find(x=>x.slug===nodeTarget);
opts.push({slug:nodeTarget, name:(n?n.name:nodeTarget)+' (hors ligne)', off:true});
}
opts.forEach(o=>{
const l = document.createElement('label'); l.className = 'node-target-opt'+(o.slug===nodeTarget?' on':'')+(o.off?' off':'');
const rb = document.createElement('input'); rb.type='radio'; rb.name='node-target'; rb.checked = o.slug===nodeTarget;
rb.onchange = ()=>setNodeTarget(o.slug);
const sp = document.createElement('span'); sp.textContent = o.name;
l.appendChild(rb); l.appendChild(sp); wrap.appendChild(l);
});
list.appendChild(wrap);
}
async function setNodeTarget(slug){
const r = await jpost('/api/node/target', {slug});
nodeTarget = (r && r.target) || '';
toast(slug ? ('l\'IA agit sur « '+slug+' »') : 'l\'IA agit sur ce serveur');
loadNode();
}
// ── Appairage : génère un code que le poste échange contre sa clé ────────────
function nodeCapCheckboxes(containerId, selected){
const box = document.getElementById(containerId);
box.textContent = '';
const sel = new Set(selected||[]);
nodeAllCaps.forEach(c=>{
const lab = document.createElement('label'); lab.className='node-cap';
const cb = document.createElement('input'); cb.type='checkbox'; cb.value=c; cb.checked = sel.has(c);
const sp = document.createElement('span'); sp.textContent = nodeCapLabels[c]||c;
lab.appendChild(cb); lab.appendChild(sp);
box.appendChild(lab);
});
}
function nodeCapValues(containerId){
return [...document.querySelectorAll('#'+containerId+' input:checked')].map(cb=>cb.value);
}
function openNodePair(){
nodeCapCheckboxes('node-pair-caps', ['read','list']); // défaut prudent
document.getElementById('node-pair-root').value = '';
document.getElementById('node-pair-out').style.display = 'none';
showModal('node-pair-modal');
}
async function genNodeCode(){
const caps = nodeCapValues('node-pair-caps');
if(!caps.length){ toast('choisis au moins une capacité'); return; }
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/pair', {caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
document.getElementById('node-pair-code').textContent = r.code;
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
const allow = caps.join(',');
const rootArg = root ? (' --root "'+root+'"') : '';
document.getElementById('node-pair-cmd').textContent =
'ajean-remote install '+location.origin+' --code '+r.code+' --allow '+allow+rootArg;
document.getElementById('node-pair-out').style.display = '';
loadNode();
}
// ── Édition d'un poste appairé : capacités, dossier, révocation ──────────────
let nodeEditing = null;
function openNodeEdit(id){
const n = nodeList.find(x=>x.id===id); if(!n) return;
nodeEditing = id;
nodeCapCheckboxes('node-pair-caps', n.caps);
document.getElementById('node-pair-root').value = n.root||'';
document.getElementById('node-pair-out').style.display = 'none';
// Réutilise la modale d'appairage en mode édition : on remplace le pied.
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Poste « '+n.name+' »'+(n.connected?' (connecté)':'');
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '';
const cancel = document.createElement('button'); cancel.textContent='fermer'; cancel.onclick=()=>{ hideModal('node-pair-modal'); resetNodeFoot(); };
const save = document.createElement('button'); save.className='btn-save'; save.textContent='enregistrer'; save.onclick=saveNodeCaps;
footR.appendChild(cancel); footR.appendChild(save);
const footL = document.querySelector('#node-pair-modal .foot-l');
footL.innerHTML = '';
const del = document.createElement('button'); del.className='btn-danger'; del.textContent='révoquer'; del.onclick=()=>revokeNode(id);
footL.appendChild(del);
showModal('node-pair-modal');
}
function resetNodeFoot(){
document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Appairer un poste distant';
document.querySelector('#node-pair-modal .foot-l').innerHTML = '';
const footR = document.querySelector('#node-pair-modal .foot-r');
footR.innerHTML = '<button onclick="hideModal(\'node-pair-modal\')">fermer</button><button class="btn-save" onclick="genNodeCode()">générer le code</button>';
nodeEditing = null;
}
async function saveNodeCaps(){
if(!nodeEditing) return;
const caps = nodeCapValues('node-pair-caps');
const root = document.getElementById('node-pair-root').value.trim();
const r = await jpost('/api/node/caps', {id:nodeEditing, caps, root});
if(!r.ok){ toast(r.error||'échec'); return; }
toast('poste mis à jour'); hideModal('node-pair-modal'); resetNodeFoot(); loadNode();
}
async function revokeNode(id){
const n = nodeList.find(x=>x.id===id);
if(!await askConfirm('Révoquer le poste « '+(n?n.name:'')+' » ? Sa clé sera oubliée et il sera déconnecté.', {title:'Révoquer le poste ?', okText:'Révoquer', danger:true})) return;
await jpost('/api/node/revoke', {id});
hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); toast('poste révoqué');
}
+15
View File
@@ -1140,3 +1140,18 @@ button:hover{border-color:var(--dim);color:var(--text)}
/* Conversation vide : un seul message centré à la place des réglages. */
.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px}
.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7}
/* Postes distants : cases de capacités, code d'appairage, commande. */
.node-caps{display:flex;flex-wrap:wrap;gap:6px}
.node-cap{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer;user-select:none}
.node-cap input{margin:0}
.node-code{font-family:var(--mono,monospace);font-size:22px;font-weight:700;letter-spacing:3px;color:var(--accent);padding:8px 0}
.node-cmd{white-space:pre-wrap;word-break:break-all;background:var(--code-bg);border:1px solid var(--border);border-radius:7px;padding:8px;margin:6px 0;font-size:12px}
/* Poste distant : sélecteur de cible d'exécution. */
.node-target{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px}
.node-target-l{font-size:12px;color:var(--dim);margin-right:2px;width:100%}
.node-target-opt{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer}
.node-target-opt.on{border-color:var(--accent);color:var(--accent)}
.node-target-opt.off{opacity:.6}
.node-target-opt input{margin:0}
+12
View File
@@ -103,6 +103,12 @@ func newWebMux() *http.ServeMux {
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
mux.HandleFunc("/", handleIndex)
// Poste distant : deux routes PUBLIQUES (pas derrière la clé de pilotage).
// L'enrôlement est authentifié par le code d'appairage à usage unique, et le
// WebSocket par la clé d'appareil (Bearer) — le poste ne connaît pas la clé
// de pilotage. Voir node_api.go / node_server.go.
mux.HandleFunc("/api/node/enroll", handleNodeEnroll)
mux.HandleFunc("/api/node/ws", handleNodeWS)
mux.HandleFunc("/marked.min.js", func(w http.ResponseWriter, r *http.Request) {
b, _ := uiFS.ReadFile("ui/marked.min.js")
w.Header().Set("Content-Type", "application/javascript")
@@ -155,6 +161,12 @@ func newWebMux() *http.ServeMux {
api("/api/link/start", handleLinkStart) // (re)démarre le tunnel avec la clé déjà enregistrée
api("/api/link/disconnect", handleLinkDisconnect) // arrête le lien + oublie la clé
api("/api/link/paircode", handleLinkPairCode) // code d'appairage + empreinte pour la 1re connexion
// Postes distants : gestion réservée au propriétaire (clé de pilotage).
api("/api/node", handleNodes) // liste + état de connexion
api("/api/node/pair", handleNodePair) // génère un code d'appairage
api("/api/node/caps", handleNodeCaps) // règle capacités + dossier racine
api("/api/node/target", handleNodeTarget) // choisit la machine cible de l'agent
api("/api/node/revoke", handleNodeRevoke) // oublie la clé + déconnecte
api("/api/internet", handleInternet)
api("/api/mcp", handleMCP)
api("/api/mcp/save", handleMCPSave)