package loki // computer_cdp.go — pilote un Chromium/Chrome LOCAL via le protocole DevTools // (CDP), en Go pur (websocket coder/websocket déjà présent), sans dépendance // lourde type chromedp/playwright. C'est le moteur « web » du computer use : // ouvrir une URL, lire l'arbre d'accessibilité (éléments interactifs numérotés, // façon set-of-marks), cliquer/taper/défiler, et — à la demande — capturer un // screenshot pour la vision. // // Choix d'archi : le conteneur Loki embarque déjà un Chromium (celui de // Playwright, cf. Dockerfile) ; on le pilote en CDP. Une seule session navigateur // est maintenue (lazy) et réutilisée entre les appels d'outils, comme le cache // web de chat_internet.go. import ( "context" "encoding/base64" "encoding/json" "fmt" "net/http" "os" "os/exec" "path/filepath" "runtime" "sort" "strings" "sync" "time" "github.com/coder/websocket" ) var ( cdpMu sync.Mutex cdpCur *cdpSession ) // Taille FIXE du viewport piloté (et donc du screenshot). Communiquée au modèle // pour que ses coordonnées browser_click_xy lues sur l'image tombent juste. const ( cuViewW = 1280 cuViewH = 800 ) // cdpSession détient le process Chrome, la connexion websocket CDP vers la cible // « page », et le multiplexage requête/réponse (un lecteur en tâche de fond // distribue les réponses aux appels en attente par id). type cdpSession struct { cmd *exec.Cmd userDir string ws *websocket.Conn wctx context.Context wcancel context.CancelFunc mu sync.Mutex nextID int pending map[int]chan cdpMessage netMu sync.Mutex inflight int // requêtes réseau en cours (pour l'attente d'inactivité) lastNet time.Time // dernier événement réseau lastSnap string // dernier snapshot renvoyé, pour dé-dupliquer les états identiques } type cdpMessage struct { ID int `json:"id,omitempty"` Method string `json:"method,omitempty"` Result json.RawMessage `json:"result,omitempty"` Error *cdpError `json:"error,omitempty"` } type cdpError struct { Code int `json:"code"` Message string `json:"message"` } // ─── découverte du binaire navigateur ──────────────────────────────────────── // chromePath localise un binaire Chromium/Chrome/Edge exploitable. LOKI_CHROME // force un chemin ; sinon on cherche D'ABORD le Chromium de Playwright (celui // qu'embarque l'image Docker, sous PLAYWRIGHT_BROWSERS_PATH), puis les // emplacements standards par OS, puis le PATH. // // L'ordre compte : dans le conteneur, AUCUN chromium n'est installé par le // gestionnaire de paquets — le seul navigateur présent est celui que // `playwright install chromium` a déposé dans /opt/pw-browsers. Sans cette // recherche, le pilotage de navigateur serait déclaré indisponible dans l'image // même qui embarque un navigateur (voir Dockerfile, ARG PLAYWRIGHT). func chromePath() string { if p := strings.TrimSpace(os.Getenv("LOKI_CHROME")); p != "" { return p } if p := playwrightChromium(); p != "" { return p } var cands []string switch runtime.GOOS { case "windows": for _, base := range []string{os.Getenv("ProgramFiles"), os.Getenv("ProgramFiles(x86)"), os.Getenv("LocalAppData")} { if base == "" { continue } cands = append(cands, filepath.Join(base, `Google\Chrome\Application\chrome.exe`), filepath.Join(base, `Microsoft\Edge\Application\msedge.exe`), filepath.Join(base, `Chromium\Application\chrome.exe`), ) } case "darwin": cands = []string{ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", } default: for _, n := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"} { if p, err := exec.LookPath(n); err == nil { return p } } } for _, c := range cands { if c != "" { if _, err := os.Stat(c); err == nil { return c } } } for _, n := range []string{"chrome", "google-chrome", "chromium"} { if p, err := exec.LookPath(n); err == nil { return p } } return "" } // playwrightChromium renvoie le binaire Chromium déposé par Playwright, ou "" // s'il n'y en a pas. Playwright range ses navigateurs sous // PLAYWRIGHT_BROWSERS_PATH (l'image Docker fixe /opt/pw-browsers) ou, à défaut, // dans le cache utilisateur, en un dossier chromium- par version. // // On prend la version la PLUS RÉCENTE (les builds sont numérotés, tri croissant // sur le nom) : une image reconstruite laisse parfois l'ancien dossier derrière // elle, et lancer un vieux Chromium sur le CDP d'aujourd'hui, c'est chercher // des pannes sans raison. `chrome` d'abord, `headless_shell` en repli : ce // dernier ne sait pas ouvrir de fenêtre (LOKI_CU_HEADFUL serait sans effet) // mais pilote très bien une page en headless. func playwrightChromium() string { var roots []string if p := strings.TrimSpace(os.Getenv("PLAYWRIGHT_BROWSERS_PATH")); p != "" && p != "0" { roots = append(roots, p) } roots = append(roots, "/opt/pw-browsers") if home, err := os.UserHomeDir(); err == nil && home != "" { switch runtime.GOOS { case "windows": roots = append(roots, filepath.Join(home, "AppData", "Local", "ms-playwright")) case "darwin": roots = append(roots, filepath.Join(home, "Library", "Caches", "ms-playwright")) default: roots = append(roots, filepath.Join(home, ".cache", "ms-playwright")) } } // Chemin du binaire À L'INTÉRIEUR d'un dossier chromium-, par OS. var rels [][]string switch runtime.GOOS { case "windows": rels = [][]string{{"chrome-win", "chrome.exe"}, {"chrome-win", "headless_shell.exe"}} case "darwin": rels = [][]string{{"chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"}, {"chrome-mac", "headless_shell"}} default: rels = [][]string{{"chrome-linux", "chrome"}, {"chrome-linux", "headless_shell"}} } for _, root := range roots { dirs, err := filepath.Glob(filepath.Join(root, "chromium*-*")) if err != nil || len(dirs) == 0 { continue } sort.Strings(dirs) for i := len(dirs) - 1; i >= 0; i-- { for _, rel := range rels { p := filepath.Join(append([]string{dirs[i]}, rel...)...) if st, err := os.Stat(p); err == nil && !st.IsDir() { return p } } } } return "" } // ─── lancement + connexion ─────────────────────────────────────────────────── func cdpLaunch() (*cdpSession, error) { bin := chromePath() if bin == "" { return nil, fmt.Errorf("navigateur Chromium/Chrome/Edge introuvable — installe-le ou définis LOKI_CHROME=") } // Balaye les profils temporaires abandonnés (Chrome tué sans close() lors d'un // redémarrage du service) : sinon /tmp se remplit de dossiers loki-cu-* au fil // des restarts. sweepStaleCUDirs() userDir, err := os.MkdirTemp("", "loki-cu-") if err != nil { return nil, err } // Headless par défaut (marche sur le serveur sans écran) ; LOKI_CU_HEADFUL=1 // ouvre une vraie fenêtre visible sur une machine de bureau. args := []string{ "--remote-debugging-port=0", "--user-data-dir=" + userDir, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "--disable-extensions", "--disable-background-networking", "--disable-features=Translate,BackForwardCache", // Indispensables sur un serveur headless sous un compte non-root : sans // --no-sandbox Chrome plante au lancement (namespaces refusés), et /dev/shm // y est souvent minuscule → --disable-dev-shm-usage évite les crash onglet. "--no-sandbox", "--disable-dev-shm-usage", "--window-size=1280,900", "about:blank", } if os.Getenv("LOKI_CU_HEADFUL") == "" { args = append([]string{"--headless=new"}, args...) } cmd := exec.Command(bin, args...) if err := cmd.Start(); err != nil { os.RemoveAll(userDir) return nil, err } // Chrome écrit le port de debug réel dans DevToolsActivePort (ligne 1). port := "" portFile := filepath.Join(userDir, "DevToolsActivePort") deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { if b, err := os.ReadFile(portFile); err == nil { if line := strings.SplitN(strings.TrimSpace(string(b)), "\n", 2)[0]; line != "" { port = line break } } time.Sleep(100 * time.Millisecond) } if port == "" { _ = cmd.Process.Kill() os.RemoveAll(userDir) return nil, fmt.Errorf("Chrome n'a pas exposé son port de debug (DevToolsActivePort)") } wsURL, err := cdpPageWS(port) if err != nil { _ = cmd.Process.Kill() os.RemoveAll(userDir) return nil, err } ctx, cancel := context.WithCancel(context.Background()) c, _, err := websocket.Dial(ctx, wsURL, nil) if err != nil { cancel() _ = cmd.Process.Kill() os.RemoveAll(userDir) return nil, err } c.SetReadLimit(-1) // les screenshots dépassent la limite par défaut (32 Kio) s := &cdpSession{cmd: cmd, userDir: userDir, ws: c, wctx: ctx, wcancel: cancel, pending: map[int]chan cdpMessage{}} go s.readLoop() _, _ = s.call("Page.enable", nil) _, _ = s.call("Runtime.enable", nil) _, _ = s.call("DOM.enable", nil) _, _ = s.call("Network.enable", nil) // pour l'attente d'inactivité réseau (settle) // Viewport DÉTERMINISTE : sans ça, headless donne une taille bizarre (mesuré : // 1264x749) que le modèle interprétait mal pour browser_click_xy (il visait trop // bas). On force exactement cuViewW x cuViewH, DPR 1 → le screenshot fait cette // taille, et les coordonnées lues dessus = coordonnées de clic, 1:1. _, _ = s.call("Emulation.setDeviceMetricsOverride", map[string]any{ "width": cuViewW, "height": cuViewH, "deviceScaleFactor": 1, "mobile": false, }) // Garder toute navigation dans le MÊME onglet : le pilote n'est attaché qu'à une // cible « page ». Un lien target=_blank ou un window.open ouvrait un onglet que // le pilote ne voyait pas → l'IA croyait que « rien ne se passe » et bouclait // (vécu sur le test IONOS « Devenez client »). Ce script, réévalué au début de // CHAQUE document, neutralise les nouveaux onglets. _, _ = s.call("Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": cdpSameTabJS}) return s, nil } // cdpSameTabJS force les navigations « nouvel onglet » à rester dans l'onglet // courant (window.open redirigé, target=_blank retiré au clic). const cdpSameTabJS = `(function(){try{ window.open=function(u){if(u)location.href=u;return window;}; document.addEventListener('click',function(e){ var a=e.target&&e.target.closest&&e.target.closest('a[target]'); if(a)a.removeAttribute('target'); },true); }catch(e){}})();` // cdpPageWS interroge l'API HTTP de debug pour trouver l'URL websocket d'une // cible de type « page ». func cdpPageWS(port string) (string, error) { cl := &http.Client{Timeout: 5 * time.Second} deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { resp, err := cl.Get("http://127.0.0.1:" + port + "/json") if err == nil { var targets []struct { Type string `json:"type"` WS string `json:"webSocketDebuggerUrl"` } _ = json.NewDecoder(resp.Body).Decode(&targets) resp.Body.Close() for _, t := range targets { if t.Type == "page" && t.WS != "" { return t.WS, nil } } } time.Sleep(150 * time.Millisecond) } return "", fmt.Errorf("aucune cible 'page' exposée par Chrome") } func (s *cdpSession) readLoop() { for { _, data, err := s.ws.Read(s.wctx) if err != nil { s.mu.Lock() for _, ch := range s.pending { close(ch) } s.pending = map[int]chan cdpMessage{} s.mu.Unlock() return } var m cdpMessage if json.Unmarshal(data, &m) != nil { continue } if m.ID != 0 { s.mu.Lock() ch := s.pending[m.ID] delete(s.pending, m.ID) s.mu.Unlock() if ch != nil { ch <- m } continue } // Événements réseau : on suit le nombre de requêtes en vol pour que settle() // puisse attendre l'inactivité réseau (fin du chargement AJAX). switch m.Method { case "Network.requestWillBeSent": s.netMu.Lock() s.inflight++ s.lastNet = time.Now() s.netMu.Unlock() case "Network.loadingFinished", "Network.loadingFailed": s.netMu.Lock() if s.inflight > 0 { s.inflight-- } s.lastNet = time.Now() s.netMu.Unlock() } } } // call envoie une commande CDP et attend sa réponse (borné à 30 s). func (s *cdpSession) call(method string, params map[string]any) (json.RawMessage, error) { s.mu.Lock() s.nextID++ id := s.nextID ch := make(chan cdpMessage, 1) s.pending[id] = ch s.mu.Unlock() payload := map[string]any{"id": id, "method": method} if params != nil { payload["params"] = params } data, _ := json.Marshal(payload) wctx, cancel := context.WithTimeout(s.wctx, 30*time.Second) defer cancel() if err := s.ws.Write(wctx, websocket.MessageText, data); err != nil { return nil, err } select { case m, ok := <-ch: if !ok { return nil, fmt.Errorf("connexion CDP fermée") } if m.Error != nil { return nil, fmt.Errorf("%s: %s", method, m.Error.Message) } return m.Result, nil case <-wctx.Done(): return nil, fmt.Errorf("%s: délai dépassé", method) } } func (s *cdpSession) close() { if s == nil { return } if s.ws != nil { _ = s.ws.Close(websocket.StatusNormalClosure, "") } if s.wcancel != nil { s.wcancel() } if s.cmd != nil && s.cmd.Process != nil { _ = s.cmd.Process.Kill() } if s.userDir != "" { os.RemoveAll(s.userDir) } } // cdpGet renvoie la session courante, en (re)lançant Chrome au besoin. Un ping // léger valide qu'une session existante répond encore. func cdpGet() (*cdpSession, error) { cdpMu.Lock() defer cdpMu.Unlock() if cdpCur != nil { if _, err := cdpCur.call("Runtime.evaluate", map[string]any{"expression": "1", "returnByValue": true}); err == nil { return cdpCur, nil } cdpCur.close() cdpCur = nil } s, err := cdpLaunch() if err != nil { return nil, err } cdpCur = s return s, nil } // cdpShutdown ferme la session navigateur si elle existe (appelé quand on coupe // le computer use, pour ne pas laisser un Chrome headless traîner). // sweepStaleCUDirs supprime les profils Chrome temporaires (loki-cu-*) de plus // d'une heure laissés dans le dossier temp — best-effort, silencieux. func sweepStaleCUDirs() { entries, err := os.ReadDir(os.TempDir()) if err != nil { return } cutoff := time.Now().Add(-time.Hour) for _, e := range entries { if !e.IsDir() || !strings.HasPrefix(e.Name(), "loki-cu-") { continue } if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) { os.RemoveAll(filepath.Join(os.TempDir(), e.Name())) } } } func cdpShutdown() { cdpMu.Lock() defer cdpMu.Unlock() if cdpCur != nil { cdpCur.close() cdpCur = nil } } // ─── évaluation JS ─────────────────────────────────────────────────────────── // cdpEval exécute une expression JS dans la page et renvoie sa valeur (JSON brut). func (s *cdpSession) eval(expr string) (json.RawMessage, error) { res, err := s.call("Runtime.evaluate", map[string]any{ "expression": expr, "returnByValue": true, "awaitPromise": true, }) if err != nil { return nil, err } var r struct { Result struct { Value json.RawMessage `json:"value"` } `json:"result"` ExceptionDetails *struct { Text string `json:"text"` } `json:"exceptionDetails"` } _ = json.Unmarshal(res, &r) if r.ExceptionDetails != nil { return nil, fmt.Errorf("JS: %s", r.ExceptionDetails.Text) } return r.Result.Value, nil } // ─── snapshot d'accessibilité (set-of-marks) ───────────────────────────────── // cdpHelpersJS : fonctions partagées par le snapshot et browser_find, préfixées au // script évalué. Points clés : // - ajActionable ne garde que les éléments RÉELLEMENT cliquables/saisissables : // on écarte les conteneurs (list, listitem, banner, navigation, menu, region, // div/span muets…) qui polluaient la liste et embrouillaient le modèle. // - ajName enrichit un champ de formulaire avec son