From a739534104cb95b49dda4560d1edbbea024ed3f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 11:51:20 +0000 Subject: [PATCH] =?UTF-8?q?Lot=207=20:=20outils=20fichiers=20et=20shell=20?= =?UTF-8?q?ex=C3=A9cut=C3=A9s=20par=20la=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quand la façade tient la conversation (LOKI_CHAT=rust), read, grep, glob, write, edit, bash et ask s'exécutent dans rust/loki-rs/src/tools au lieu de repartir au Go par /internal/v1/tool/exec : mêmes garde-fous dans le même ordre (commande catastrophique, « & » final en mode Code, bac à sable du mode Code, dossier mémoire interdit, lu avant d'écrire), même diff pour l'UI, même nettoyage de la sortie du shell. Le jeton de l'API interne est retiré de l'environnement du shell de l'agent. L'état partagé reste au Go, en un seul exemplaire (sous-agents et tâches le lisent aussi) : POST /internal/v1/tool/context (dossier de travail, dossier mémoire, cible distante), /tool/tracker (lecture notée, contrôle avant écriture), /tool/wrote (note après écriture, diagnostics LSP). Repli sur le Go pour une cible distante ou un contexte indisponible. Parité : internal/loki/parity_tools_test.go passe 58 appels à execTool sur une arborescence de test et range le journal dans tests/parity_tools ; tests/tools.rs le retrouve à l'identique avec exec_local. Écarts assumés consignés dans docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- ...10-15-rust-migration-lot7-outils-design.md | 65 ++ internal/loki/parity_tools_test.go | 264 ++++++ internal/loki/web_internal.go | 6 + internal/loki/web_internal_toolstate.go | 78 ++ internal/loki/web_internal_toolstate_test.go | 74 ++ rust/Cargo.lock | 39 + rust/loki-rs/Cargo.toml | 1 + rust/loki-rs/src/chat/host.rs | 21 +- rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/tools/diff.rs | 188 +++++ rust/loki-rs/src/tools/files.rs | 785 ++++++++++++++++++ rust/loki-rs/src/tools/mod.rs | 292 +++++++ rust/loki-rs/src/tools/policy.rs | 337 ++++++++ rust/loki-rs/src/tools/shell.rs | 317 +++++++ rust/loki-rs/tests/common/fakego.rs | 21 + rust/loki-rs/tests/lot6.rs | 45 + rust/loki-rs/tests/parity_tools/outils.json | 767 +++++++++++++++++ rust/loki-rs/tests/tools.rs | 261 ++++++ 18 files changed, 3560 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md create mode 100644 internal/loki/parity_tools_test.go create mode 100644 internal/loki/web_internal_toolstate.go create mode 100644 internal/loki/web_internal_toolstate_test.go create mode 100644 rust/loki-rs/src/tools/diff.rs create mode 100644 rust/loki-rs/src/tools/files.rs create mode 100644 rust/loki-rs/src/tools/mod.rs create mode 100644 rust/loki-rs/src/tools/policy.rs create mode 100644 rust/loki-rs/src/tools/shell.rs create mode 100644 rust/loki-rs/tests/parity_tools/outils.json create mode 100644 rust/loki-rs/tests/tools.rs diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md new file mode 100644 index 0000000..785d321 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md @@ -0,0 +1,65 @@ +# Lot 7 — outils de l'agent exécutés par la façade + +## Contexte + +Lot 6 : quand la façade tient la conversation (`LOKI_CHAT=rust`), la boucle du +tour est en Rust mais chaque appel d'outil repart au Go +(`/internal/v1/tool/exec`). Lot 7 : porter les outils eux-mêmes, en retirant +les appels au Go un par un. Rien ne change sans `LOKI_CHAT=rust` : la boucle +Go exécute ses outils comme avant. + +## Étape 1 — fichiers et shell (fait) + +Exécutés par la façade (`rust/loki-rs/src/tools/`) : `read`, `grep`, `glob`, +`write`, `edit`, `bash`, `ask`, avec les mêmes garde-fous dans le même ordre +(commande catastrophique, `&` final en mode Code, bac à sable du mode Code, +dossier mémoire interdit, « lu avant d'écrire »), le même diff pour l'UI et le +même nettoyage de la sortie du shell (`| tail -N`, ANSI, fin bornée). + +L'état partagé reste au Go, en un seul exemplaire, derrière trois routes +internes (`web_internal_toolstate.go`) : + +| Route | Rôle | +|---|---| +| `POST /internal/v1/tool/context` | dossier de travail (`agentCwd`), dossier mémoire, cible distante | +| `POST /internal/v1/tool/tracker` | `read` : lecture notée ; `check` : refus éventuel avant écriture | +| `POST /internal/v1/tool/wrote` | après une écriture : note du suivi, bloc des diagnostics LSP | + +Le suivi reste au Go parce que les sous-agents et les tâches planifiées, qui y +tournent, le lisent et l'écrivent aussi ; les serveurs LSP y vivent. + +Repli sur le Go : outil non porté ; `write`/`edit`/`bash` quand l'agent vise +un poste distant (une commande catastrophique est refusée avant, ici) ; +contexte indisponible. + +Le jeton de l'API interne est retiré de l'environnement du shell de l'agent. + +### Parité + +`internal/loki/parity_tools_test.go` passe 58 appels à `execTool` sur une +arborescence de test et range le journal (libellés, résultats, diffs, +questions, arborescence finale) dans `rust/loki-rs/tests/parity_tools/` ; +`rust/loki-rs/tests/tools.rs` rejoue les mêmes appels dans `exec_local` et +retrouve le même journal. Le test Go échoue si le comportement Go change +(`LOKI_PARITY_UPDATE=1` pour régénérer, puis le test Rust dit si le port suit). + +### Écarts assumés + +- Motif `grep` invalide : le message d'erreur est celui du moteur d'expressions + de Rust (crate `regex`), pas celui de Go. +- Classes `\w`, `\b`, `\s`, `\d` d'un motif `grep` : Unicode en Rust, ASCII en + Go (un mot accentué y est un mot entier). +- Octets hors UTF-8 dans un fichier lu ou cherché : remplacés par U+FFFD par + séquence invalide (Go : par octet, à l'encodage JSON). `edit` travaille sur + les octets et laisse intact ce qu'il ne remplace pas. +- `mkdir` en échec : le chemin cité est le dossier parent visé, pas le + composant qui a échoué. + +## Étapes suivantes + +1. Web (`web_search`, `web_open`, `web_read`, `web_grep`, `web_images`) : + configuration seule, pas d'état partagé. +2. Shell d'arrière-plan (`bash_bg`, `bash_tail`) et git (`git_status`, + `git_diff`, `git_clone`) : jobs tenus par le Go aujourd'hui. +3. Mémoire (`mem_*`, `recall*`) : avec le portage du chiffrement. +4. Tâches, navigateur (CDP), MCP, sous-agents, critères et mode Code. diff --git a/internal/loki/parity_tools_test.go b/internal/loki/parity_tools_test.go new file mode 100644 index 0000000..96a6fe0 --- /dev/null +++ b/internal/loki/parity_tools_test.go @@ -0,0 +1,264 @@ +package loki + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + "time" +) + +// parity_tools_test.go — journal de référence des outils fichiers et du shell +// (lot 7 de la migration Rust). +// +// Une arborescence de test, une suite d'appels d'outils passés à execTool tels +// que la boucle les passe (libellé compris), dans l'ordre : leurs résultats, +// diffs et questions, puis l'arborescence finale, sont rangés dans +// rust/loki-rs/tests/parity_tools/outils.json. Le test Rust (tests/tools.rs) +// rejoue les mêmes appels dans tools::exec_local et doit retrouver ce journal. +// Chemins réécrits en {{ROOT}}, durées du shell en {{DUR}}. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils + +const parityToolsFile = "../../rust/loki-rs/tests/parity_tools/outils.json" + +type parityFile struct { + Path string `json:"path"` + Content string `json:"content"` + // Âge en secondes (glob trie par date de modification). + Age int `json:"age"` +} + +type parityToolCase struct { + Name string `json:"name"` + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Code bool `json:"code,omitempty"` + // Unix : commande shell propre à bash. + Unix bool `json:"unix,omitempty"` + + Label string `json:"label"` + Result string `json:"result"` + Diff []DiffLine `json:"diff,omitempty"` + Added int `json:"added,omitempty"` + Removed int `json:"removed,omitempty"` + Ask *AskEvent `json:"ask,omitempty"` +} + +type parityToolsFixture struct { + // Dossier de travail et dossier mémoire, relatifs à {{ROOT}}. + Cwd string `json:"cwd"` + MemoryDir string `json:"memory_dir"` + Files []parityFile `json:"files"` + Cases []parityToolCase `json:"cases"` + After map[string]string `json:"after"` +} + +func parityToolTree() []parityFile { + long := strings.Repeat("é", 600) + many := strings.Repeat("aiguille\n", 70) + return []parityFile{ + {Path: "ws/a.txt", Content: "ligne 1\nligne 2\nligne 3\n", Age: 50}, + {Path: "ws/b.txt", Content: "alpha\nbeta\n", Age: 40}, + {Path: "ws/vide.txt", Content: "", Age: 30}, + {Path: "ws/long.txt", Content: long + "\nfin\n", Age: 20}, + {Path: "ws/crlf.txt", Content: "un\r\ndeux\r\ntrois\r\n", Age: 10}, + {Path: "ws/bin.dat", Content: "PK\x00\x01binaire", Age: 60}, + {Path: "ws/src/main.go", Content: "package main\n\nfunc main() { x := 1 }\n", Age: 5}, + {Path: "ws/src/lib/util.go", Content: "package lib\n// x marque\n", Age: 3}, + {Path: "ws/src/lib/notes.md", Content: "x dans les notes\n", Age: 4}, + {Path: "ws/node_modules/dep/index.js", Content: "ligne cachée\n", Age: 70}, + {Path: "ws/beaucoup.txt", Content: many, Age: 80}, + {Path: "dehors.txt", Content: "hors du dossier\n", Age: 90}, + } +} + +func parityToolCases() []parityToolCase { + c := func(name, tool string, args map[string]any) parityToolCase { + return parityToolCase{Name: name, Tool: tool, Args: args} + } + code := func(p parityToolCase) parityToolCase { p.Code = true; return p } + unix := func(p parityToolCase) parityToolCase { p.Unix = true; return p } + return []parityToolCase{ + c("lecture", "read", map[string]any{"file": "a.txt"}), + c("lecture_fenetre", "read", map[string]any{"file": "a.txt", "offset": 2, "limit": 1}), + c("lecture_fenetre_decimale", "read", map[string]any{"file": "a.txt", "offset": 2.7, "limit": "3"}), + c("lecture_chemin_vide", "read", map[string]any{"file": " "}), + c("lecture_absent", "read", map[string]any{"file": "absent.txt"}), + c("lecture_dossier", "read", map[string]any{"file": "src"}), + c("lecture_binaire", "read", map[string]any{"file": "bin.dat"}), + c("lecture_au_dela", "read", map[string]any{"file": "a.txt", "offset": 99}), + c("lecture_ligne_longue", "read", map[string]any{"file": "long.txt"}), + c("lecture_fichier_vide", "read", map[string]any{"file": "vide.txt"}), + c("lecture_absolue", "read", map[string]any{"file": "{{ROOT}}/dehors.txt"}), + c("lecture_relative_sortante", "read", map[string]any{"file": "src/../../dehors.txt"}), + code(c("lecture_code_hors_dossier", "read", map[string]any{"file": "../dehors.txt"})), + code(c("lecture_code", "read", map[string]any{"file": "b.txt"})), + + c("grep", "grep", map[string]any{"pattern": "ligne [12]"}), + c("grep_filtre", "grep", map[string]any{"pattern": "x", "glob": "*.go"}), + c("grep_filtre_classe", "grep", map[string]any{"pattern": "x", "glob": "[mn]*"}), + c("grep_fichier", "grep", map[string]any{"pattern": "beta", "path": "b.txt"}), + c("grep_sous_dossier", "grep", map[string]any{"pattern": "package", "path": "src/lib"}), + c("grep_point", "grep", map[string]any{"pattern": "alpha", "path": "."}), + c("grep_aucun", "grep", map[string]any{"pattern": "zzz"}), + c("grep_motif_vide", "grep", map[string]any{"pattern": " "}), + c("grep_plafond", "grep", map[string]any{"pattern": "aiguille"}), + c("grep_absent", "grep", map[string]any{"pattern": "a", "path": "nulle-part"}), + code(c("grep_code_hors_dossier", "grep", map[string]any{"pattern": "a", "path": "{{ROOT}}"})), + + c("glob_go", "glob", map[string]any{"pattern": "**/*.go"}), + c("glob_racine", "glob", map[string]any{"pattern": "*.txt"}), + c("glob_point", "glob", map[string]any{"pattern": "./src/*"}), + c("glob_aucun", "glob", map[string]any{"pattern": "*.rs"}), + c("glob_vide", "glob", map[string]any{"pattern": ""}), + + c("question", "ask", map[string]any{"question": "Lequel ?", "options": []any{"a", " ", "b", "c", "d", "e"}}), + c("question_vide", "ask", map[string]any{"question": ""}), + + c("ecriture_neuve", "write", map[string]any{"file": "neuf/n.txt", "content": "un\ndeux\n"}), + c("ecriture_existant", "write", map[string]any{"file": "b.txt", "content": "remplacé\n"}), + c("ecriture_memoire", "write", map[string]any{"file": "{{MEM}}/x.md", "content": "x\n"}), + c("ecriture_chemin_vide", "write", map[string]any{"file": "", "content": "x"}), + code(c("ecriture_code_non_lu", "write", map[string]any{"file": "src/lib/notes.md", "content": "x\n"})), + code(c("ecriture_code_hors_dossier", "write", map[string]any{"file": "../evade.txt", "content": "x\n"})), + code(c("ecriture_code_neuve", "write", map[string]any{"file": "code.txt", "content": "c\n"})), + + c("edition", "edit", map[string]any{"file": "a.txt", "old": "ligne 2", "new": "ligne deux"}), + c("edition_deja_faite", "edit", map[string]any{"file": "a.txt", "old": "ligne 2", "new": "ligne deux"}), + c("edition_introuvable", "edit", map[string]any{"file": "a.txt", "old": "zzz", "new": "y"}), + c("edition_ambigue", "edit", map[string]any{"file": "a.txt", "old": "ligne", "new": "l"}), + c("edition_crlf", "edit", map[string]any{"file": "crlf.txt", "old": "deux\ntrois", "new": "2\n3"}), + c("edition_old_vide", "edit", map[string]any{"file": "a.txt", "old": "", "new": "y"}), + c("edition_fichier_absent", "edit", map[string]any{"file": "absent.txt", "old": "a", "new": "b"}), + code(c("edition_code_non_lu", "edit", map[string]any{"file": "src/main.go", "old": "x := 1", "new": "x := 2"})), + code(c("edition_code_apres_lecture", "edit", map[string]any{"file": "b.txt", "old": "remplacé", "new": "rem"})), + + unix(c("shell", "bash", map[string]any{"command": "echo bonjour"})), + unix(c("shell_sortie_et_erreurs", "bash", map[string]any{"command": "echo out; echo err >&2; exit 3"})), + unix(c("shell_tail", "bash", map[string]any{"command": "printf 'a\\nb\\nc\\n' | tail -2"})), + unix(c("shell_ansi", "bash", map[string]any{"command": "printf '\\033[31mrouge\\033[0m\\n'"})), + unix(c("shell_dossier", "bash", map[string]any{"command": "ls src"})), + unix(c("shell_longue_sortie", "bash", map[string]any{"command": "seq 1 3000"})), + unix(c("shell_delai", "bash", map[string]any{"command": "echo avant; sleep 3", "timeout": 1})), + c("shell_dangereux", "bash", map[string]any{"command": "rm -rf /"}), + unix(c("shell_memoire", "bash", map[string]any{"command": "cat {{MEM}}/x.md"})), + unix(code(c("shell_code_arriere_plan", "bash", map[string]any{"command": "sleep 1 &"}))), + } +} + +// subst remplace {{ROOT}} et {{MEM}} dans les chaînes des arguments. +func paritySubst(v any, root, mem string) any { + switch x := v.(type) { + case string: + return strings.NewReplacer("{{MEM}}", mem, "{{ROOT}}", root).Replace(x) + case map[string]any: + out := map[string]any{} + for k, e := range x { + out[k] = paritySubst(e, root, mem) + } + return out + case []any: + out := make([]any, len(x)) + for i, e := range x { + out[i] = paritySubst(e, root, mem) + } + return out + } + return v +} + +var parityDurRe = regexp.MustCompile(`(exit: -?\d+) · [0-9.]+[a-zµ]+(?:[0-9.]+[a-zµ]+)*`) + +func parityNorm(s, root string) string { + s = strings.ReplaceAll(s, root, "{{ROOT}}") + return parityDurRe.ReplaceAllString(s, "$1 · {{DUR}}") +} + +func TestPariteOutils(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("journal tiré sous Unix") + } + root := testHome(t) + if r, err := filepath.EvalSymlinks(root); err == nil { + root = r // macOS : /var → /private/var, comme le verraient les outils + } + t.Setenv("LOKI_HOME", root) + ws := filepath.Join(root, "ws") + now := time.Now() + for _, f := range parityToolTree() { + p := filepath.Join(root, filepath.FromSlash(f.Path)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(f.Content), 0o644); err != nil { + t.Fatal(err) + } + mt := now.Add(-time.Duration(f.Age) * time.Second) + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } + } + setTaskWorkspace(ws) + t.Cleanup(func() { setTaskWorkspace("") }) + mem := memoryDir() + relMem, err := filepath.Rel(root, mem) + if err != nil || strings.HasPrefix(relMem, "..") { + t.Fatalf("dossier mémoire hors de la racine : %s", mem) + } + + fx := parityToolsFixture{Cwd: "ws", MemoryDir: filepath.ToSlash(relMem), Files: parityToolTree()} + for _, pc := range parityToolCases() { + args := paritySubst(pc.Args, root, mem).(map[string]any) + // Les nombres passent par JSON, comme ceux d'un vrai appel. + b, _ := json.Marshal(args) + args = map[string]any{} + _ = json.Unmarshal(b, &args) + label := toolCallLabel(pc.Tool, args) + res := execTool(context.Background(), pc.Tool, args, label, Caps{Code: pc.Code}) + pc.Label = parityNorm(label, root) + pc.Result = parityNorm(res.Result, root) + pc.Diff, pc.Added, pc.Removed, pc.Ask = res.Diff, res.Added, res.Removed, res.Ask + fx.Cases = append(fx.Cases, pc) + } + fx.After = map[string]string{} + _ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, _ := filepath.Rel(root, p) + rel = filepath.ToSlash(rel) + if !strings.HasPrefix(rel, "ws/") && rel != "dehors.txt" && rel != "evade.txt" { + return nil // base, configuration : propres au Go + } + b, _ := os.ReadFile(p) + fx.After[rel] = string(b) + return nil + }) + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityToolsFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityToolsFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityToolsFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if string(got) != string(want) { + t.Errorf("les outils ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils,\n"+ + "puis cargo test --test tools dit si le port Rust suit.\n--- obtenu ---\n%s", parityToolsFile, got) + } +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 4259abd..8782b75 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -71,6 +71,12 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalOAIEnd(w, r) case "/internal/v1/tool/exec": handleInternalToolExec(w, r) + case "/internal/v1/tool/context": + handleInternalToolContext(w, r) + case "/internal/v1/tool/tracker": + handleInternalToolTracker(w, r) + case "/internal/v1/tool/wrote": + handleInternalToolWrote(w, r) case "/internal/v1/crypt/seal": handleInternalCrypt(w, r, true) case "/internal/v1/crypt/open": diff --git a/internal/loki/web_internal_toolstate.go b/internal/loki/web_internal_toolstate.go new file mode 100644 index 0000000..dbe75a2 --- /dev/null +++ b/internal/loki/web_internal_toolstate.go @@ -0,0 +1,78 @@ +package loki + +import ( + "net/http" + "strings" +) + +// API interne de l'état des outils (lot 7 de la migration Rust). +// +// La façade exécute elle-même les outils fichiers et le shell (read, grep, +// glob, write, edit, bash, ask) quand elle tient la conversation. Ce qu'ils +// partagent avec le reste de Loki reste ici, en un seul exemplaire : le +// dossier de travail de la discussion, le dossier mémoire interdit au shell, +// la cible distante, le suivi « lu avant d'écrire » (lu aussi par les +// sous-agents et les tâches, qui tournent ici) et les diagnostics LSP. +// +// POST /internal/v1/tool/context {} +// → {"cwd":"…","memory_dir":"…","target":"slug|"} +// POST /internal/v1/tool/tracker {"op":"read","path":"/abs"} → {} +// POST /internal/v1/tool/tracker {"op":"check","path":"/abs","for_write":true} +// → {"refusal":"[refusé] …"|""} +// POST /internal/v1/tool/wrote {"path":"/abs","caps":{…}} +// → {"lsp":"\n\n[diagnostics LSP] …"|""} +// +// wrote : après une écriture réussie (write/edit) — note du tracker, puis le +// bloc des diagnostics LSP que la boucle ajoute au résultat de l'outil. + +func handleInternalToolContext(w http.ResponseWriter, r *http.Request) { + var in struct{} + if !readInternalJSON(w, r, &in) { + return + } + sendJSON(w, http.StatusOK, map[string]any{ + "cwd": agentCwd(), + "memory_dir": memoryDir(), + "target": agentTargetSlug(), + }) +} + +func handleInternalToolTracker(w http.ResponseWriter, r *http.Request) { + var in struct { + Op string `json:"op"` + Path string `json:"path"` + ForWrite bool `json:"for_write"` + } + if !readInternalJSON(w, r, &in) { + return + } + if strings.TrimSpace(in.Path) == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "chemin manquant"}) + return + } + switch in.Op { + case "read": + trackerNoteRead(in.Path) + sendJSON(w, http.StatusOK, map[string]any{}) + case "check": + sendJSON(w, http.StatusOK, map[string]any{"refusal": trackerCheck(in.Path, in.ForWrite)}) + default: + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "op inconnue"}) + } +} + +func handleInternalToolWrote(w http.ResponseWriter, r *http.Request) { + var in struct { + Path string `json:"path"` + Caps Caps `json:"caps"` + } + if !readInternalJSON(w, r, &in) { + return + } + if strings.TrimSpace(in.Path) == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "chemin manquant"}) + return + } + trackerNoteWrite(in.Path) + sendJSON(w, http.StatusOK, map[string]any{"lsp": lspDiagBlock(in.Path, in.Caps)}) +} diff --git a/internal/loki/web_internal_toolstate_test.go b/internal/loki/web_internal_toolstate_test.go new file mode 100644 index 0000000..5c8f1ce --- /dev/null +++ b/internal/loki/web_internal_toolstate_test.go @@ -0,0 +1,74 @@ +package loki + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Le contexte des outils dit à la façade où travaille l'agent, ce que le shell +// n'a pas le droit de toucher, et s'il vise un poste distant. +func TestOutilsInterneContexte(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + w := appelOAIInterne(t, "POST", "/internal/v1/tool/context", `{}`) + if w.Code != 200 { + t.Fatalf("contexte : %d %s", w.Code, w.Body.String()) + } + got := leaseJSON(t, w.Body.String()) + if got["cwd"] != agentCwd() || got["memory_dir"] != memoryDir() || got["target"] != "" { + t.Fatalf("contexte : %v", got) + } + if w := appelOAIInterne(t, "GET", "/internal/v1/tool/context", ``); w.Code != 405 { + t.Fatalf("GET : %d", w.Code) + } +} + +// Une lecture notée par la façade autorise l'écriture, exactement comme une +// lecture faite ici ; une écriture notée aussi. +func TestOutilsInterneTracker(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + f := filepath.Join(t.TempDir(), "a.txt") + if err := os.WriteFile(f, []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + check := func() string { + t.Helper() + w := appelOAIInterne(t, "POST", "/internal/v1/tool/tracker", `{"op":"check","path":`+jsonQuote(f)+`,"for_write":true}`) + if w.Code != 200 { + t.Fatalf("check : %d %s", w.Code, w.Body.String()) + } + r, _ := leaseJSON(t, w.Body.String())["refusal"].(string) + return r + } + if r := check(); !strings.Contains(r, "pas lu") { + t.Fatalf("fichier jamais lu accepté : %q", r) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/tool/tracker", `{"op":"read","path":`+jsonQuote(f)+`}`); w.Code != 200 { + t.Fatalf("read : %d", w.Code) + } + if r := check(); r != "" { + t.Fatalf("lecture notée mais refus : %q", r) + } + g := filepath.Join(filepath.Dir(f), "b.txt") + if err := os.WriteFile(g, []byte("y\n"), 0o644); err != nil { + t.Fatal(err) + } + w := appelOAIInterne(t, "POST", "/internal/v1/tool/wrote", `{"path":`+jsonQuote(g)+`,"caps":{"Code":true}}`) + if w.Code != 200 { + t.Fatalf("wrote : %d %s", w.Code, w.Body.String()) + } + if trackerCheck(g, false) != "" { + t.Fatal("écriture notée mais edit refusé") + } + for _, body := range []string{"", "{", `{"op":"read"}`, `{"op":"x","path":"/a"}`} { + if w := appelOAIInterne(t, "POST", "/internal/v1/tool/tracker", body); w.Code != 400 { + t.Fatalf("%q : %d", body, w.Code) + } + } + if w := appelOAIInterne(t, "POST", "/internal/v1/tool/wrote", `{}`); w.Code != 400 { + t.Fatalf("wrote sans chemin : %d", w.Code) + } +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f744208..9df7f87 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -667,6 +676,7 @@ dependencies = [ "hyper", "hyper-util", "libc", + "regex", "reqwest", "rustls", "serde", @@ -779,6 +789,35 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.13.5" diff --git a/rust/loki-rs/Cargo.toml b/rust/loki-rs/Cargo.toml index 0a5da59..fcf2da2 100644 --- a/rust/loki-rs/Cargo.toml +++ b/rust/loki-rs/Cargo.toml @@ -12,6 +12,7 @@ hex = "0.4.3" http-body-util = "0.1.5" hyper = { version = "1.12.0", features = ["http1", "server", "client"] } hyper-util = { version = "0.1.21", features = ["tokio", "server", "client-legacy", "http1"] } +regex = "1.13.1" reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "stream", "http2"] } rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12"], optional = true } serde = { version = "1.0.229", features = ["derive"] } diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 9228ea1..c8a26cd 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -2,8 +2,10 @@ //! //! - envoi au moteur par [`EngineHttp`] (clé de CET endpoint), pannes //! classées et formulées comme `friendlyLLMError` ; -//! - outils exécutés par le Go (`/internal/v1/tool/exec`), avec ce qu'il tire -//! du résultat (capture de la bulle, aperçu du navigateur, image relayée) ; +//! - outils fichiers et shell exécutés ici ([`crate::tools`], état partagé +//! tenu par le Go) ; les autres par le Go (`/internal/v1/tool/exec`), avec +//! ce qu'il tire du résultat (capture de la bulle, aperçu du navigateur, +//! image relayée) ; //! - résultats complets (« voir plus ») confiés à l'écrivain des discussions ; //! - compaction par [`Compactor`] : résumé demandé au moteur //! ([`EngineSummarizer`]), blocs archivés pour recall par le Go @@ -390,6 +392,21 @@ impl TurnHost for GoTurnHost { } async fn exec_tool(&self, name: &str, args: &Map, label: &str) -> ToolOutcome { + // Outils fichiers et shell : ici (lot 7), l'état partagé restant au Go. + if crate::tools::is_local(name) { + let st = crate::tools::GoToolState { + store: self.shared.store.clone(), + caps: self.caps.clone(), + }; + let code = self + .caps + .get("Code") + .and_then(Value::as_bool) + .unwrap_or(false); + if let Some(out) = crate::tools::exec_local(&st, code, name, args, label).await { + return out; + } + } match self .shared .store diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index ff5cecd..be2668b 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -24,5 +24,6 @@ pub mod presets; pub mod proxy; pub mod store; pub mod sysinfo; +pub mod tools; pub mod ui; pub mod update; diff --git a/rust/loki-rs/src/tools/diff.rs b/rust/loki-rs/src/tools/diff.rs new file mode 100644 index 0000000..2fe5723 --- /dev/null +++ b/rust/loki-rs/src/tools/diff.rs @@ -0,0 +1,188 @@ +//! Petit diff ligne à ligne pour l'interface — port de `chat_diff.go`. +//! +//! Quand l'agent modifie un fichier (`edit`) ou en crée un (`write`), l'UI +//! affiche les lignes ajoutées (vert) et retirées (rouge). LCS classique sur +//! les lignes, avec des garde-fous pour ne jamais transformer un gros +//! remplacement en pavé. Les compteurs +N / -N sont calculés AVANT la coupe +//! des lignes affichées. + +use crate::chat::types::DiffLine; + +/// Au-delà (partie modifiée), comparaison sans détail (LCS trop lente). +const DIFF_MAX_LINES: usize = 400; +/// Lignes envoyées à l'UI (le reste est résumé). +const DIFF_MAX_SHOWN: usize = 120; +/// Lignes identiques gardées autour d'un changement. +const DIFF_CONTEXT: usize = 3; +const DIFF_OMIT_PREFIX: &str = "…"; + +fn line(op: &str, text: &str) -> DiffLine { + DiffLine { + op: op.into(), + text: text.into(), + } +} + +/// `lineDiff` : lignes à afficher (bornées), lignes ajoutées, retirées. +pub fn line_diff(old_text: &str, new_text: &str) -> (Vec, i64, i64) { + let a = split_lines(old_text); + let b = split_lines(new_text); + let mut pre = 0; + while pre < a.len() && pre < b.len() && a[pre] == b[pre] { + pre += 1; + } + let mut suf = 0; + while suf < a.len() - pre && suf < b.len() - pre && a[a.len() - 1 - suf] == b[b.len() - 1 - suf] + { + suf += 1; + } + let (ma, mb) = (&a[pre..a.len() - suf], &b[pre..b.len() - suf]); + let mid: Vec = if ma.len() > DIFF_MAX_LINES || mb.len() > DIFF_MAX_LINES { + ma.iter() + .map(|l| line("-", l)) + .chain(mb.iter().map(|l| line("+", l))) + .collect() + } else { + lcs_diff(ma, mb) + }; + let added = mid.iter().filter(|d| d.op == "+").count() as i64; + let removed = mid.iter().filter(|d| d.op == "-").count() as i64; + + let mut out = Vec::new(); + if pre > DIFF_CONTEXT { + out.push(line(" ", &omitted(pre - DIFF_CONTEXT))); + } + for l in &a[pre.saturating_sub(DIFF_CONTEXT)..pre] { + out.push(line(" ", l)); + } + out.extend(mid); + for l in &a[a.len() - suf..a.len() - suf + suf.min(DIFF_CONTEXT)] { + out.push(line(" ", l)); + } + if suf > DIFF_CONTEXT { + out.push(line(" ", &omitted(suf - DIFF_CONTEXT))); + } + (cap_lines(out), added, removed) +} + +/// Diff exact par plus longue sous-séquence commune. +fn lcs_diff(a: &[String], b: &[String]) -> Vec { + let (n, m) = (a.len(), b.len()); + let mut lcs = vec![vec![0usize; m + 1]; n + 1]; + for i in (0..n).rev() { + for j in (0..m).rev() { + lcs[i][j] = if a[i] == b[j] { + lcs[i + 1][j + 1] + 1 + } else { + lcs[i + 1][j].max(lcs[i][j + 1]) + }; + } + } + let mut out = Vec::new(); + let (mut i, mut j) = (0, 0); + while i < n && j < m { + if a[i] == b[j] { + out.push(line(" ", &a[i])); + i += 1; + j += 1; + } else if lcs[i + 1][j] >= lcs[i][j + 1] { + out.push(line("-", &a[i])); + i += 1; + } else { + out.push(line("+", &b[j])); + j += 1; + } + } + out.extend(a[i..].iter().map(|l| line("-", l))); + out.extend(b[j..].iter().map(|l| line("+", l))); + out +} + +/// `addedDiff` : contenu entièrement nouveau, toutes ses lignes en ajout. +pub fn added_diff(text: &str) -> (Vec, i64) { + let ls = split_lines(text); + let n = ls.len() as i64; + (cap_lines(ls.iter().map(|l| line("+", l)).collect()), n) +} + +/// Un saut de ligne final termine la dernière ligne, il n'en ouvre pas une +/// vide. Les fins CRLF comptent comme LF. +fn split_lines(s: &str) -> Vec { + let s = s.replace("\r\n", "\n"); + let s = s.strip_suffix('\n').unwrap_or(&s); + if s.is_empty() { + return Vec::new(); + } + s.split('\n').map(str::to_string).collect() +} + +fn omitted(n: usize) -> String { + if n == 1 { + format!("{DIFF_OMIT_PREFIX}(1 ligne identique)") + } else { + format!("{DIFF_OMIT_PREFIX}({n} lignes identiques)") + } +} + +fn cap_lines(mut d: Vec) -> Vec { + if d.len() <= DIFF_MAX_SHOWN { + return d; + } + let cut = d.len() - DIFF_MAX_SHOWN; + d.truncate(DIFF_MAX_SHOWN); + d.push(line( + " ", + &format!("{DIFF_OMIT_PREFIX}({cut} lignes de plus)"), + )); + d +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ops(d: &[DiffLine]) -> Vec { + d.iter().map(|l| format!("{}{}", l.op, l.text)).collect() + } + + #[test] + fn une_ligne_changee_dans_un_gros_bloc() { + let old: String = (1..=20).map(|i| format!("l{i}\n")).collect(); + let new = old.replace("l10\n", "dix\n"); + let (d, a, r) = line_diff(&old, &new); + assert_eq!((a, r), (1, 1)); + assert_eq!( + ops(&d), + [ + " …(6 lignes identiques)", + " l7", + " l8", + " l9", + "-l10", + "+dix", + " l11", + " l12", + " l13", + " …(7 lignes identiques)" + ] + ); + } + + #[test] + fn ajout_et_coupe() { + let text: String = (0..130).map(|i| format!("{i}\n")).collect(); + let (d, n) = added_diff(&text); + assert_eq!(n, 130); + assert_eq!(d.len(), 121); + assert_eq!(d[120].text, "…(10 lignes de plus)"); + assert_eq!(added_diff("").1, 0); + assert_eq!(added_diff("a\r\nb\r\n").1, 2); + } + + #[test] + fn gros_remplacement_sans_lcs() { + let old: String = (0..401).map(|i| format!("a{i}\n")).collect(); + let (_, a, r) = line_diff(&old, "b\n"); + assert_eq!((a, r), (1, 401)); + } +} diff --git a/rust/loki-rs/src/tools/files.rs b/rust/loki-rs/src/tools/files.rs new file mode 100644 index 0000000..c4521cf --- /dev/null +++ b/rust/loki-rs/src/tools/files.rs @@ -0,0 +1,785 @@ +//! Outils fichiers de l'agent — port de `code_tools.go` (read, grep, glob, +//! ask) et de `fileWrite` / `fileEdit` (`chat_tools.go`). +//! +//! Fonctions synchrones (disque) : l'appelant les lance hors du runtime +//! (`spawn_blocking`) et se charge de ce qui est partagé avec le Go (suivi +//! « lu avant d'écrire », diagnostics LSP — voir `tools::exec_local`). + +use std::collections::HashMap; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; + +use regex::Regex; +use serde_json::{Map, Value}; + +use super::policy::{ + code_path_allowed, guard_tool_only_path, refused_path_result, resolve_agent_path, to_slash, +}; + +/// Lignes par appel (offset/limit pour la suite). +pub const READ_MAX_LINES: usize = 1000; +pub const READ_DEF_LINES: usize = 400; +/// Une ligne minifiée ne doit pas manger le contexte. +const READ_MAX_LINE_LEN: usize = 500; +const GREP_MAX_HITS: usize = 60; +const GLOB_MAX_HITS: usize = 200; + +/// `skipDirs` : dépendances et artefacts, jamais du code à lire. +const SKIP_DIRS: [&str; 11] = [ + ".git", + "node_modules", + "vendor", + "dist", + "build", + "target", + "__pycache__", + ".venv", + "venv", + ".next", + ".cache", +]; + +fn skip_dir(name: &str) -> bool { + SKIP_DIRS.contains(&name) +} + +/// Texte d'erreur du système à la manière de Go (`no such file or +/// directory`) : le message de l'OS, sans « (os error N) », minuscule en tête +/// hors Windows. +pub fn os_err_text(e: &std::io::Error) -> String { + let s = e.to_string(); + let s = match s.rfind(" (os error ") { + Some(i) => s[..i].to_string(), + None => s, + }; + if cfg!(windows) { + return s; + } + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_lowercase().chain(c).collect(), + None => s, + } +} + +/// `*PathError` de Go : « op chemin: texte ». +fn path_err(op: &str, p: &Path, e: &std::io::Error) -> String { + format!("[erreur] {op} {}: {}", p.display(), os_err_text(e)) +} + +/// `os.ReadFile`, erreurs au format Go (`open …` / `read …`). +fn read_file(p: &Path) -> Result, String> { + let mut f = fs::File::open(p).map_err(|e| path_err("open", p, &e))?; + let mut b = Vec::new(); + f.read_to_end(&mut b).map_err(|e| path_err("read", p, &e))?; + Ok(b) +} + +/// `isBinary` : un NUL dans les premiers 8 Kio. +pub fn is_binary(b: &[u8]) -> bool { + b[..b.len().min(8192)].contains(&0) +} + +/// Ligne bornée à `READ_MAX_LINE_LEN` caractères (suivie de « … » si coupée). +fn cap_line(l: &str) -> String { + match l.char_indices().nth(READ_MAX_LINE_LEN) { + Some((i, _)) => format!("{}…", &l[..i]), + None => l.to_string(), + } +} + +/// Entier positif d'un argument JSON (`args["x"].(float64)` puis `int(v)`). +pub fn arg_int(args: &Map, k: &str) -> Option { + args.get(k).and_then(Value::as_f64).map(|v| v as i64) +} + +pub fn arg_str<'a>(args: &'a Map, k: &str) -> &'a str { + args.get(k).and_then(Value::as_str).unwrap_or("") +} + +/// Ce qu'un outil fichier rend : le résultat, et le chemin LU à noter dans +/// le suivi (read réussi). +pub struct ReadOut { + pub result: String, + pub noted: Option, +} + +/// `toolRead` : fichier avec numéros de ligne. +pub fn tool_read(cwd: &Path, args: &Map, code: bool) -> ReadOut { + let err = |result: String| ReadOut { + result, + noted: None, + }; + let file = arg_str(args, "file"); + if file.trim().is_empty() { + return err("[erreur] chemin vide".into()); + } + let path = resolve_agent_path(cwd, file); + if code && !code_path_allowed(cwd, &path) { + return err(refused_path_result(file)); + } + let offset = arg_int(args, "offset").filter(|v| *v > 0).unwrap_or(1) as usize; + let limit = (arg_int(args, "limit") + .filter(|v| *v > 0) + .unwrap_or(READ_DEF_LINES as i64) as usize) + .min(READ_MAX_LINES); + let b = match read_file(&path) { + Ok(b) => b, + Err(e) => return err(e), + }; + if is_binary(&b) { + let size = fs::metadata(&path) + .map(|m| m.len()) + .unwrap_or(b.len() as u64); + return err(format!( + "[binaire] {file} ({size} octets) — pas un fichier texte" + )); + } + let text = String::from_utf8_lossy(&b); + let lines: Vec<&str> = text.split('\n').collect(); + let mut total = lines.len(); + // Fichier fini par \n : la dernière entrée vide n'est pas une ligne. + if total > 0 && lines[total - 1].is_empty() { + total -= 1; + } + if offset > total { + return err(format!("[erreur] offset {offset} > {total} lignes")); + } + let end = (offset - 1 + limit).min(total); + let mut out = String::new(); + for (i, l) in lines.iter().enumerate().take(end).skip(offset - 1) { + out.push_str(&format!("{}: {}\n", i + 1, cap_line(l))); + } + if end < total { + out.push_str(&format!( + "… ({} lignes restantes — relis avec offset={})\n", + total - end, + end + 1 + )); + } + ReadOut { + result: out.trim_end_matches('\n').to_string(), + noted: Some(path), + } +} + +/// Parcours à la `filepath.WalkDir` : ordre lexical, liens non suivis, +/// dossiers ignorés (`skipDirs`, la racine comprise). `visit` rend `false` +/// pour tout arrêter. +fn walk(root: &Path, visit: &mut dyn FnMut(&Path, &fs::Metadata) -> bool) { + let Ok(meta) = fs::symlink_metadata(root) else { + return; + }; + walk_entry(root, &meta, visit); +} + +fn walk_entry( + path: &Path, + meta: &fs::Metadata, + visit: &mut dyn FnMut(&Path, &fs::Metadata) -> bool, +) -> bool { + if !meta.is_dir() { + return visit(path, meta); + } + if path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(skip_dir) + { + return true; + } + let Ok(rd) = fs::read_dir(path) else { + return true; // dossier illisible : on continue ailleurs + }; + let mut entries: Vec<(String, PathBuf)> = rd + .filter_map(Result::ok) + .map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())) + .collect(); + entries.sort(); + for (_, p) in entries { + let Ok(m) = fs::symlink_metadata(&p) else { + continue; + }; + if !walk_entry(&p, &m, visit) { + return false; + } + } + true +} + +/// Chemin relatif à la racine du parcours, en `/` (`.` pour la racine). +fn rel_slash(root: &Path, p: &Path) -> String { + match p.strip_prefix(root) { + Ok(r) if r.as_os_str().is_empty() => ".".into(), + Ok(r) => to_slash(r), + Err(_) => to_slash(p), + } +} + +/// `toolGrep` : motif regexp dans les fichiers. +pub fn tool_grep(cwd: &Path, args: &Map, code: bool) -> String { + let pattern = arg_str(args, "pattern"); + if pattern.trim().is_empty() { + return "[erreur] motif vide".into(); + } + let re = match Regex::new(pattern) { + Ok(re) => re, + Err(e) => return format!("[erreur] motif invalide : {e}"), + }; + let p = arg_str(args, "path"); + let root = if !p.trim().is_empty() && p != "." { + resolve_agent_path(cwd, p) + } else { + cwd.to_path_buf() + }; + if code && !code_path_allowed(cwd, &root) { + return refused_path_result(&root.display().to_string()); + } + let glob_pat = arg_str(args, "glob"); + let mut hits: Vec = Vec::new(); + walk(&root, &mut |path, _| { + if !glob_pat.is_empty() { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + if !go_match(glob_pat, &name).unwrap_or(false) { + return true; + } + } + if hits.len() >= GREP_MAX_HITS { + return false; + } + let Ok(b) = fs::read(path) else { + return true; + }; + if is_binary(&b) { + return true; + } + let rel = rel_slash(&root, path); + let text = String::from_utf8_lossy(&b); + for (i, line) in text.split('\n').enumerate() { + if re.is_match(line) { + hits.push(format!("{rel}:{}: {}", i + 1, cap_line(line).trim())); + if hits.len() >= GREP_MAX_HITS { + return false; + } + } + } + true + }); + if hits.is_empty() { + return "[aucun résultat]".into(); + } + let mut out = hits.join("\n"); + if hits.len() >= GREP_MAX_HITS { + out.push_str(&format!( + "\n… (plafond de {GREP_MAX_HITS} résultats atteint — affine le motif)" + )); + } + out +} + +/// `toolGlob` : fichiers d'un motif (`**` compris), plus récents d'abord. +pub fn tool_glob(cwd: &Path, args: &Map) -> String { + let pattern = arg_str(args, "pattern"); + if pattern.trim().is_empty() { + return "[erreur] motif vide".into(); + } + let re = match glob_to_regex(pattern) { + Ok(re) => re, + Err(e) => return format!("[erreur] motif invalide : {e}"), + }; + let mut hits: Vec<(String, std::time::SystemTime)> = Vec::new(); + walk(cwd, &mut |path, meta| { + let rel = rel_slash(cwd, path); + if re.is_match(&rel) { + hits.push((rel, meta.modified().unwrap_or(std::time::UNIX_EPOCH))); + } + true + }); + if hits.is_empty() { + return "[aucun fichier]".into(); + } + hits.sort_by_key(|h| std::cmp::Reverse(h.1)); + hits.truncate(GLOB_MAX_HITS); + hits.into_iter().map(|h| h.0).collect::>().join("\n") +} + +/// `globToRegexp` : glob (avec `**`) → expression sur des chemins en `/`. +/// `**` = n'importe quelle profondeur, `*` = tout sauf `/`, `?` = un +/// caractère sauf `/`. +pub fn glob_to_regex(pattern: &str) -> Result { + let p = if cfg!(windows) { + pattern.replace('\\', "/") + } else { + pattern.to_string() + }; + let p = p.strip_prefix("./").unwrap_or(&p); + let mut b = String::from("^"); + let mut rest = p; + while let Some(c) = rest.chars().next() { + if rest.starts_with("**/") { + b.push_str("(?:[^/]+/)*"); + rest = &rest[3..]; + continue; + } + if rest.starts_with("**") { + b.push_str(".*"); + rest = &rest[2..]; + continue; + } + match c { + '*' => b.push_str("[^/]*"), + '?' => b.push_str("[^/]"), + '.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => { + b.push('\\'); + b.push(c); + } + _ => b.push(c), + } + rest = &rest[c.len_utf8()..]; + } + b.push('$'); + Regex::new(&b) +} + +/// `filepath.Match` de Go (`*`, `?`, `[classes]`, `\` d'échappement hors +/// Windows), sur un nom sans séparateur. `None` : motif mal formé. +pub fn go_match(pattern: &str, name: &str) -> Option { + let sep = std::path::MAIN_SEPARATOR; + let mut pattern = pattern; + let mut name = name; + 'pat: while !pattern.is_empty() { + let (star, chunk, rest) = scan_chunk(pattern); + pattern = rest; + if star && chunk.is_empty() { + return Some(!name.contains(sep)); + } + let (t, ok, err) = match_chunk(chunk, name); + if ok && (t.is_empty() || !pattern.is_empty()) { + name = t; + continue; + } + if err { + return None; + } + if star { + for (i, ch) in name.char_indices() { + if ch == sep { + break; + } + let skip = i + ch.len_utf8(); + // Go avance d'un OCTET ; sur du texte valide, les positions + // intermédiaires d'un caractère ne peuvent rien faire correspondre. + let (t, ok, err) = match_chunk(chunk, &name[skip..]); + if ok { + if pattern.is_empty() && !t.is_empty() { + continue; + } + name = t; + continue 'pat; + } + if err { + return None; + } + } + } + while !pattern.is_empty() { + let (_, chunk, rest) = scan_chunk(pattern); + pattern = rest; + if match_chunk(chunk, "").2 { + return None; + } + } + return Some(false); + } + Some(name.is_empty()) +} + +fn scan_chunk(mut pattern: &str) -> (bool, &str, &str) { + let mut star = false; + while let Some(r) = pattern.strip_prefix('*') { + pattern = r; + star = true; + } + let b = pattern.as_bytes(); + let mut inrange = false; + let mut i = 0; + while i < b.len() { + match b[i] { + b'\\' if !cfg!(windows) => { + if i + 1 < b.len() { + i += 1; + } + } + b'[' => inrange = true, + b']' => inrange = false, + b'*' if !inrange => break, + _ => {} + } + i += 1; + } + (star, &pattern[..i], &pattern[i..]) +} + +/// `matchChunk` : (reste du nom, correspond ?, motif mal formé ?). +fn match_chunk<'a>(chunk: &str, s: &'a str) -> (&'a str, bool, bool) { + let sep = std::path::MAIN_SEPARATOR; + let mut chunk = chunk; + let mut s = s; + let mut failed = false; + while let Some(c0) = chunk.chars().next() { + if !failed && s.is_empty() { + failed = true; + } + match c0 { + '[' => { + let mut r = '\0'; + if !failed { + let ch = s.chars().next().unwrap_or('\0'); + r = ch; + s = &s[ch.len_utf8()..]; + } + chunk = &chunk[1..]; + let mut negated = false; + if let Some(rest) = chunk.strip_prefix('^') { + negated = true; + chunk = rest; + } + let mut matched = false; + let mut nrange = 0; + loop { + if nrange > 0 + && let Some(rest) = chunk.strip_prefix(']') + { + chunk = rest; + break; + } + let Some((lo, rest)) = get_esc(chunk) else { + return ("", false, true); + }; + chunk = rest; + let mut hi = lo; + if let Some(rest) = chunk.strip_prefix('-') { + let Some((h, rest)) = get_esc(rest) else { + return ("", false, true); + }; + hi = h; + chunk = rest; + } + if lo <= r && r <= hi { + matched = true; + } + nrange += 1; + } + if matched == negated { + failed = true; + } + } + '?' => { + if !failed { + let ch = s.chars().next().unwrap_or('\0'); + if ch == sep { + failed = true; + } + s = &s[ch.len_utf8()..]; + } + chunk = &chunk[1..]; + } + _ => { + let mut lit = c0; + if c0 == '\\' && !cfg!(windows) { + chunk = &chunk[1..]; + match chunk.chars().next() { + Some(c) => lit = c, + None => return ("", false, true), + } + } + if !failed { + // Go compare OCTET par octet : même chose, caractère + // entier contre caractère entier, sur du texte valide. + match s.chars().next() { + Some(c) if c == lit => s = &s[c.len_utf8()..], + Some(c) => { + failed = true; + s = &s[c.len_utf8()..]; + } + None => failed = true, + } + } + chunk = &chunk[lit.len_utf8()..]; + } + } + } + if failed { + ("", false, false) + } else { + (s, true, false) + } +} + +/// `getEsc` : un caractère de classe (échappé ou non) ; `None` : motif mal +/// formé (vide, `-` ou `]` en tête, ou rien après). +fn get_esc(chunk: &str) -> Option<(char, &str)> { + let mut chunk = chunk; + match chunk.chars().next() { + None | Some('-') | Some(']') => return None, + Some('\\') if !cfg!(windows) => { + chunk = &chunk[1..]; + chunk.chars().next()?; + } + _ => {} + } + let r = chunk.chars().next()?; + let rest = &chunk[r.len_utf8()..]; + if rest.is_empty() { + return None; + } + Some((r, rest)) +} + +/// Verrou par fichier : sérialise deux écritures du même fichier. +fn file_mu(path: &Path) -> Arc> { + static MAP: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + MAP.lock() + .unwrap_or_else(|e| e.into_inner()) + .entry(super::policy::clean(path)) + .or_default() + .clone() +} + +/// `os.WriteFile` : écrit, crée au besoin (0644) ; un fichier existant garde +/// son mode (un script 0755 réécrit reste exécutable). +fn write_file(path: &Path, content: &[u8]) -> Result<(), String> { + let mut opts = fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o644); + } + let mut f = opts.open(path).map_err(|e| path_err("open", path, &e))?; + std::io::Write::write_all(&mut f, content).map_err(|e| path_err("write", path, &e))?; + Ok(()) +} + +/// `fileWrite` : écrit le contenu tel quel, dossiers parents compris. +pub fn file_write(cwd: &Path, memory_dir: &Path, path: &str, content: &str) -> String { + if path.trim().is_empty() { + return "[erreur] chemin vide".into(); + } + let path = resolve_agent_path(cwd, path); + if let Some(msg) = guard_tool_only_path(&path, memory_dir) { + return msg; + } + let mu = file_mu(&path); + let _g = mu.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(dir) = path.parent() + && !dir.as_os_str().is_empty() + && dir != Path::new(".") + && let Err(e) = fs::create_dir_all(dir) + { + return path_err("mkdir", dir, &e); + } + let existed = fs::metadata(&path).is_ok(); + if let Err(e) = write_file(&path, content.as_bytes()) { + return e; + } + let verb = if existed { "réécrit" } else { "créé" }; + format!("[ok] {} {verb} ({} octets)", path.display(), content.len()) +} + +/// `fileEdit` : UN remplacement exact (`old` doit apparaître une seule fois). +/// Un fichier en CRLF est comparé en LF et réécrit en CRLF. +pub fn file_edit(cwd: &Path, memory_dir: &Path, path: &str, old: &str, new: &str) -> String { + if path.trim().is_empty() { + return "[erreur] chemin vide".into(); + } + if old.is_empty() { + return "[erreur] old vide".into(); + } + let path = resolve_agent_path(cwd, path); + if let Some(msg) = guard_tool_only_path(&path, memory_dir) { + return msg; + } + let mu = file_mu(&path); + let _g = mu.lock().unwrap_or_else(|e| e.into_inner()); + // Octets, pas texte : un fichier hors UTF-8 (latin-1…) garde intact ce que + // le remplacement ne touche pas. + let content = match read_file(&path) { + Ok(b) => b, + Err(e) => return e, + }; + let (old, new) = (old.as_bytes(), new.as_bytes()); + let crlf = contains(&content, b"\r\n"); + let search = if crlf && !contains(old, b"\r\n") { + replace_all(&content, b"\r\n", b"\n") + } else { + content.clone() + }; + let n = count(&search, old); + if n == 0 { + if !new.is_empty() && contains(&search, new) { + return "[ok] déjà à jour — le fichier contient déjà cette modification".into(); + } + return "[erreur] old introuvable dans le fichier".into(); + } + if n > 1 { + return format!( + "[erreur] old apparaît {n} fois — ajoute du contexte pour le rendre unique" + ); + } + let i = find(&search, old).unwrap_or(0); + let mut updated = [&search[..i], new, &search[i + old.len()..]].concat(); + if search != content { + updated = replace_all(&updated, b"\n", b"\r\n"); + } + if let Err(e) = write_file(&path, &updated) { + return e; + } + format!("[ok] {} modifié (1 remplacement)", path.display()) +} + +fn find(h: &[u8], n: &[u8]) -> Option { + if n.is_empty() { + return Some(0); + } + h.windows(n.len()).position(|w| w == n) +} + +fn contains(h: &[u8], n: &[u8]) -> bool { + find(h, n).is_some() +} + +/// Occurrences sans chevauchement (`strings.Count`, `old` non vide). +fn count(h: &[u8], n: &[u8]) -> usize { + let (mut c, mut i) = (0, 0); + while let Some(j) = find(&h[i..], n) { + c += 1; + i += j + n.len(); + } + c +} + +fn replace_all(h: &[u8], from: &[u8], to: &[u8]) -> Vec { + let mut out = Vec::with_capacity(h.len()); + let mut i = 0; + while let Some(j) = find(&h[i..], from) { + out.extend_from_slice(&h[i..i + j]); + out.extend_from_slice(to); + i += j + from.len(); + } + out.extend_from_slice(&h[i..]); + out +} + +/// `toolAsk` : la question part vers l'UI par un événement dédié ; ici, dire +/// au modèle de clore son tour. +pub fn tool_ask(args: &Map) -> String { + if arg_str(args, "question").trim().is_empty() { + return "[erreur] question vide".into(); + } + "[question posée à l'utilisateur] Termine ton tour MAINTENANT sans appeler d'autre outil : sa réponse arrivera comme prochain message.".into() +} + +/// `askOptions` : options non vides, 4 au plus. +pub fn ask_options(args: &Map) -> Vec { + args.get("options") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .take(4) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn args(v: Value) -> Map { + v.as_object().unwrap().clone() + } + + #[test] + fn filepath_match_de_go() { + for (p, n, want) in [ + ("*.go", "a.go", Some(true)), + ("*.go", "a.rs", Some(false)), + ("a?c", "abc", Some(true)), + ("[a-c]x", "bx", Some(true)), + ("[^a-c]x", "bx", Some(false)), + ("*", "", Some(true)), + ("*.é", "été.é", Some(true)), + ("[", "a", None), + ("a[", "b", None), + ("\\*", "*", Some(true)), + ] { + assert_eq!(go_match(p, n), want, "{p} {n}"); + } + } + + #[test] + fn glob_en_regex() { + let re = glob_to_regex("src/**/*.rs").unwrap(); + assert!(re.is_match("src/a.rs") && re.is_match("src/x/y/a.rs")); + assert!(!re.is_match("src/a.rs.bak") && !re.is_match("a.rs")); + assert!(glob_to_regex("./**").unwrap().is_match("a/b")); + } + + #[test] + fn lecture_numerotee() { + let d = tempfile::tempdir().unwrap(); + fs::write(d.path().join("a.txt"), "un\ndeux\ntrois\n").unwrap(); + let r = tool_read( + d.path(), + &args(json!({"file":"a.txt","offset":2,"limit":1})), + false, + ); + assert_eq!( + r.result, + "2: deux\n… (1 lignes restantes — relis avec offset=3)" + ); + assert!(r.noted.is_some()); + let r = tool_read(d.path(), &args(json!({"file":"absent"})), false); + assert!( + r.result.starts_with("[erreur] open ") + && r.result.ends_with(": no such file or directory") + || cfg!(windows) + ); + assert!(r.noted.is_none()); + assert_eq!( + tool_read(d.path(), &args(json!({"file":" "})), false).result, + "[erreur] chemin vide" + ); + } + + #[test] + fn edition_crlf_et_deja_fait() { + let d = tempfile::tempdir().unwrap(); + let mem = d.path().join("memory"); + fs::write(d.path().join("w.txt"), "a\r\nb\r\n").unwrap(); + assert_eq!( + file_edit(d.path(), &mem, "w.txt", "a\nb", "a\nc"), + format!( + "[ok] {} modifié (1 remplacement)", + d.path().join("w.txt").display() + ) + ); + assert_eq!( + fs::read_to_string(d.path().join("w.txt")).unwrap(), + "a\r\nc\r\n" + ); + assert!(file_edit(d.path(), &mem, "w.txt", "a\nb", "a\nc").starts_with("[ok] déjà à jour")); + assert!(file_write(d.path(), &mem, "memory/x.md", "x").starts_with("[refusé]")); + } +} diff --git a/rust/loki-rs/src/tools/mod.rs b/rust/loki-rs/src/tools/mod.rs new file mode 100644 index 0000000..fdee2f4 --- /dev/null +++ b/rust/loki-rs/src/tools/mod.rs @@ -0,0 +1,292 @@ +//! Outils de l'agent exécutés par la façade (lot 7 de la migration). +//! +//! Quand la façade tient la conversation (`LOKI_CHAT=rust`), les outils +//! fichiers et le shell s'exécutent ici : `read`, `grep`, `glob`, `write`, +//! `edit`, `bash`, `ask`. Les autres (mémoire, web, tâches, navigateur, MCP, +//! sous-agents, critères…) restent au Go (`/internal/v1/tool/exec`), de même +//! que `write`/`edit`/`bash` quand l'agent vise un poste distant. +//! +//! Ce que ces outils partagent avec le reste de Loki reste au Go, en un seul +//! exemplaire ([`ToolState`]) : le dossier de travail de la discussion, le +//! dossier mémoire, la cible distante, le suivi « lu avant d'écrire » (aussi +//! tenu par les sous-agents et les tâches, qui tournent là-bas) et les +//! diagnostics LSP. Les garde-fous sont ceux du Go, dans le même ordre. + +pub mod diff; +pub mod files; +pub mod policy; +pub mod shell; + +use std::future::Future; +use std::path::{Path, PathBuf}; + +use serde_json::{Map, Value, json}; + +use crate::chat::turn::ToolOutcome; +use crate::chat::types::AskEvent; +use crate::store::Store; + +/// Ce que les outils doivent savoir avant de s'exécuter. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ToolCtx { + /// Dossier de travail (`agentCwd`). + pub cwd: PathBuf, + /// Dossier interdit au shell et à write/edit (`memoryDir`). + pub memory_dir: PathBuf, + /// Poste distant visé ("" : cette machine). + pub target: String, + /// Mode Code : chemins bornés au dossier, lu avant d'écrire. + pub code: bool, +} + +/// L'état partagé avec le Go. +pub trait ToolState: Send + Sync { + /// Le contexte du moment ; `Err` : outils locaux indisponibles. + fn context(&self) -> impl Future> + Send; + /// `trackerNoteRead`. + fn note_read(&self, path: &Path) -> impl Future + Send; + /// `trackerCheck` : le refus, ou `None`. + fn check(&self, path: &Path, for_write: bool) -> impl Future> + Send; + /// Après une écriture réussie : `trackerNoteWrite` puis le bloc LSP. + fn wrote(&self, path: &Path) -> impl Future + Send; +} + +/// Outils exécutés ici (hors cible distante pour write/edit/bash). +pub fn is_local(name: &str) -> bool { + matches!( + name, + "read" | "grep" | "glob" | "write" | "edit" | "bash" | "ask" + ) +} + +fn outcome(result: String) -> ToolOutcome { + ToolOutcome { + result, + ..Default::default() + } +} + +async fn blocking(f: impl FnOnce() -> T + Send + 'static) -> Result { + tokio::task::spawn_blocking(f) + .await + .map_err(|e| format!("[erreur] outil interrompu : {e}")) +} + +/// `codeWriteGuard` : en mode Code, borne de chemin puis « lu avant +/// d'écrire ». `None` : l'écriture peut avoir lieu. +async fn code_write_guard( + st: &S, + ctx: &ToolCtx, + file: &str, + for_write: bool, +) -> Option { + if !ctx.code { + return None; + } + let path = policy::resolve_agent_path(&ctx.cwd, file); + if !policy::code_path_allowed(&ctx.cwd, &path) { + return Some(policy::refused_path_result(file)); + } + st.check(&path, for_write).await +} + +/// Exécute un outil ici ; `None` : c'est au Go de le faire (outil non porté, +/// cible distante, contexte indisponible). +pub async fn exec_local( + st: &S, + code: bool, + name: &str, + args: &Map, + label: &str, +) -> Option { + if !is_local(name) { + return None; + } + let mut ctx = match st.context().await { + Ok(c) => c, + Err(e) => { + eprintln!("[outils] contexte indisponible, exécution par le Go : {e}"); + return None; + } + }; + ctx.code = code; + let remote = !ctx.target.is_empty(); + let out = match name { + "read" => { + let (cwd, a) = (ctx.cwd.clone(), args.clone()); + let r = match blocking(move || files::tool_read(&cwd, &a, code)).await { + Ok(r) => r, + Err(e) => return Some(outcome(e)), + }; + if let Some(p) = &r.noted { + st.note_read(p).await; + } + outcome(r.result) + } + "grep" => { + let (cwd, a) = (ctx.cwd.clone(), args.clone()); + outcome( + blocking(move || files::tool_grep(&cwd, &a, code)) + .await + .unwrap_or_else(|e| e), + ) + } + "glob" => { + let (cwd, a) = (ctx.cwd.clone(), args.clone()); + outcome( + blocking(move || files::tool_glob(&cwd, &a)) + .await + .unwrap_or_else(|e| e), + ) + } + "ask" => { + let result = files::tool_ask(args); + let ask = (!result.starts_with("[erreur]")).then(|| AskEvent { + question: files::arg_str(args, "question").to_string(), + options: files::ask_options(args), + }); + ToolOutcome { + result, + ask, + ..Default::default() + } + } + "write" if !remote => { + let content = files::arg_str(args, "content").to_string(); + if let Some(msg) = code_write_guard(st, &ctx, label, true).await { + return Some(outcome(msg)); + } + let (cwd, mem, l, c) = ( + ctx.cwd.clone(), + ctx.memory_dir.clone(), + label.to_string(), + content.clone(), + ); + let mut result = match blocking(move || files::file_write(&cwd, &mem, &l, &c)).await { + Ok(r) => r, + Err(e) => return Some(outcome(e)), + }; + let mut o = ToolOutcome::default(); + if !result.starts_with("[erreur]") { + (o.diff, o.added) = diff::added_diff(&content); + result += &st.wrote(&policy::resolve_agent_path(&ctx.cwd, label)).await; + } + o.result = result; + o + } + "edit" if !remote => { + let (old, new) = ( + files::arg_str(args, "old").to_string(), + files::arg_str(args, "new").to_string(), + ); + if let Some(msg) = code_write_guard(st, &ctx, label, false).await { + return Some(outcome(msg)); + } + let (cwd, mem, l, o2, n2) = ( + ctx.cwd.clone(), + ctx.memory_dir.clone(), + label.to_string(), + old.clone(), + new.clone(), + ); + let mut result = + match blocking(move || files::file_edit(&cwd, &mem, &l, &o2, &n2)).await { + Ok(r) => r, + Err(e) => return Some(outcome(e)), + }; + let mut o = ToolOutcome::default(); + if !result.starts_with("[erreur]") { + (o.diff, o.added, o.removed) = diff::line_diff(&old, &new); + result += &st.wrote(&policy::resolve_agent_path(&ctx.cwd, label)).await; + } + o.result = result; + o + } + "bash" if !remote || policy::dangerous_command(label).is_some() => { + let timeout = files::arg_int(args, "timeout").unwrap_or(0); + // Refusées AVANT toute exécution, cible distante comprise. + if let Some(reason) = policy::dangerous_command(label) { + return Some(outcome(policy::refused_command_result(reason))); + } + // Mode Code : « cmd & » laisse un orphelin sans sortie ni arrêt. + if code && shell::trailing_background(label) { + return Some(outcome("[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash.".into())); + } + outcome(shell::run_shell(&ctx.cwd, &ctx.memory_dir, label, timeout).await) + } + _ => return None, // cible distante : le Go passe par le poste + }; + Some(out) +} + +/// [`ToolState`] servi par l'API interne du Go. +#[derive(Clone)] +pub struct GoToolState { + pub store: Store, + /// Capacités du tour (pour les diagnostics LSP). + pub caps: Value, +} + +impl ToolState for GoToolState { + async fn context(&self) -> Result { + let (status, v) = self + .store + .internal_post("/internal/v1/tool/context", &json!({})) + .await?; + if !status.is_success() { + return Err(format!("contexte des outils : {status}")); + } + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + Ok(ToolCtx { + cwd: PathBuf::from(s("cwd")), + memory_dir: PathBuf::from(s("memory_dir")), + target: s("target"), + code: false, + }) + } + + async fn note_read(&self, path: &Path) { + let body = json!({ "op": "read", "path": path }); + if let Err(e) = self + .store + .internal_post("/internal/v1/tool/tracker", &body) + .await + { + eprintln!("[outils] lecture non notée : {e}"); + } + } + + async fn check(&self, path: &Path, for_write: bool) -> Option { + let body = json!({ "op": "check", "path": path, "for_write": for_write }); + match self + .store + .internal_post("/internal/v1/tool/tracker", &body) + .await + { + Ok((st, v)) if st.is_success() => v + .get("refusal") + .and_then(Value::as_str) + .filter(|r| !r.is_empty()) + .map(str::to_string), + // Sans le suivi, écrire serait écrire à l'aveugle : refus. + Ok((st, _)) => Some(format!("[erreur] suivi des lectures indisponible : {st}")), + Err(e) => Some(format!("[erreur] suivi des lectures indisponible : {e}")), + } + } + + async fn wrote(&self, path: &Path) -> String { + let body = json!({ "path": path, "caps": self.caps }); + match self + .store + .internal_post("/internal/v1/tool/wrote", &body) + .await + { + Ok((st, v)) if st.is_success() => v + .get("lsp") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + Ok(_) | Err(_) => String::new(), + } + } +} diff --git a/rust/loki-rs/src/tools/policy.rs b/rust/loki-rs/src/tools/policy.rs new file mode 100644 index 0000000..8e2175b --- /dev/null +++ b/rust/loki-rs/src/tools/policy.rs @@ -0,0 +1,337 @@ +//! Chemins et garde-fous des outils de l'agent — port de `code_policy.go`, +//! `chat_workspace.go` (`resolveAgentPath`) et `chat_scripts.go` (dossier +//! mémoire réservé à ses outils). +//! +//! - [`dangerous_command`] : commandes CATASTROPHIQUES refusées (effacement +//! de disque, arrêt machine, fork bomb), mode chat compris ; +//! - [`code_path_allowed`] : en mode Code, les chemins des outils fichiers +//! sont bornés au dossier de la discussion, liens symboliques résolus ; +//! - [`guard_tool_only_path`] / [`guard_tool_only_command`] : le dossier +//! mémoire n'est jamais touché par write/edit/bash, seulement par mem_*. + +use std::path::{Component, Path, PathBuf}; +use std::sync::LazyLock; + +use regex::Regex; + +/// `filepath.Clean` : forme lexicale la plus courte (`.` et `..` résolus sans +/// toucher au disque, séparateurs doublés retirés). Vide → `.`. +pub fn clean(p: &Path) -> PathBuf { + let mut out: Vec = Vec::new(); + let mut rooted = false; + for c in p.components() { + match c { + Component::Prefix(_) => out.push(c), + Component::RootDir => { + rooted = true; + out.push(c); + } + Component::CurDir => {} + Component::ParentDir => match out.last() { + Some(Component::Normal(_)) => { + out.pop(); + } + // `/..` reste `/`. + _ if rooted => {} + _ => out.push(c), + }, + Component::Normal(_) => out.push(c), + } + } + if out.is_empty() { + return PathBuf::from("."); + } + out.iter().collect() +} + +/// `os.UserHomeDir` : `$HOME` (`%USERPROFILE%` sous Windows). +fn home_dir() -> Option { + let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + std::env::var_os(var) + .filter(|h| !h.is_empty()) + .map(PathBuf::from) +} + +/// `resolveAgentPath` : chemin fourni par le modèle. Absolu → inchangé ; +/// `~/x` → dans le home ; relatif → dans le dossier de travail `cwd`. +pub fn resolve_agent_path(cwd: &Path, p: &str) -> PathBuf { + let p = p.trim(); + if p.is_empty() { + return PathBuf::new(); + } + if (p == "~" || p.starts_with("~/") || p.starts_with("~\\")) + && let Some(home) = home_dir() + { + let rest = &p[1..]; + let rest = rest.strip_prefix('/').unwrap_or(rest); + return clean(&home.join(from_slash(rest))); + } + let path = Path::new(p); + if path.is_absolute() { + return path.to_path_buf(); + } + clean(&cwd.join(from_slash(p))) +} + +/// `filepath.FromSlash`. +fn from_slash(p: &str) -> PathBuf { + if cfg!(windows) { + PathBuf::from(p.replace('/', "\\")) + } else { + PathBuf::from(p) + } +} + +/// `filepath.ToSlash`. +pub fn to_slash(p: &Path) -> String { + let s = p.to_string_lossy(); + if cfg!(windows) { + s.replace('\\', "/") + } else { + s.into_owned() + } +} + +/// `filepath.EvalSymlinks`, à défaut le chemin tel quel. +fn eval_symlinks(p: &Path) -> PathBuf { + match std::fs::canonicalize(p) { + Ok(c) => strip_verbatim(c), + Err(_) => p.to_path_buf(), + } +} + +/// `canonicalize` rend `\\?\C:\…` sous Windows ; `EvalSymlinks` non. +fn strip_verbatim(p: PathBuf) -> PathBuf { + if cfg!(windows) + && let Some(s) = p.to_str() + && let Some(rest) = s.strip_prefix(r"\\?\") + && !rest.starts_with("UNC\\") + { + return PathBuf::from(rest); + } + p +} + +/// `p` est `root` lui-même ou dedans (lexicalement, chemins nettoyés). +fn within(root: &Path, p: &Path) -> bool { + clean(p).starts_with(clean(root)) +} + +/// `sameOrWithin` : liens symboliques résolus des deux côtés. +fn same_or_within(root: &Path, p: &Path) -> bool { + within(&eval_symlinks(root), &eval_symlinks(p)) +} + +/// `codePathAllowed` : un chemin (déjà résolu) est-il DANS le dossier de la +/// discussion `ws` ? Le fichier peut ne pas exister encore (write) : c'est son +/// ancêtre existant le plus proche qui doit y être, liens résolus ; et le +/// chemin final ne doit pas en ressortir par des `..` lexicaux. +pub fn code_path_allowed(ws: &Path, abs: &Path) -> bool { + if ws.as_os_str().is_empty() { + return false; + } + let abs = clean(abs); + let mut probe = abs.clone(); + loop { + if std::fs::metadata(&probe).is_ok() { + break; + } + match probe.parent() { + Some(parent) if parent != probe => probe = parent.to_path_buf(), + _ => break, + } + } + same_or_within(ws, &probe) && within(ws, &abs) +} + +/// `refusedPathResult` : refus d'un chemin hors du bac à sable du mode Code. +pub fn refused_path_result(path: &str) -> String { + format!( + "[refusé] {path} est hors du dossier de cette discussion. \ + En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs." + ) +} + +/// `dangerousPatterns` : motifs de commandes refusées, et pourquoi. +/// Volontairement ÉTROIT : la perte de données irréversible et l'arrêt de la +/// machine, pas la moindre écriture. +static DANGEROUS: LazyLock> = LazyLock::new(|| { + let root = "efface la racine du disque ou le home"; + let disk = "écrit directement sur un périphérique disque"; + let drive = "efface la racine d'un lecteur"; + [ + ( + r#"(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s*$"#, + root, + ), + ( + r#"(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s"#, + root, + ), + (r"(?i)\bmkfs(\.[a-z0-9]+)?\b", "formate un système de fichiers"), + (r"(?i)\bdd\b[^\n]*\bof=/dev/", disk), + (r"(?i)>\s*/dev/sd[a-z]\b", disk), + ( + r"(?i)(^|[;&|]\s*|\bsudo\s+)(shutdown|poweroff|reboot|halt)\b", + "arrête ou redémarre la machine", + ), + (r":\(\)\s*\{\s*:\|:", "fork bomb"), + ( + r"(?i)\bch(mod|own)\b[^\n]*-[a-z]*R[a-z]*\s+[^\s]+\s+/\s*$", + "change récursivement les droits de la racine", + ), + (r"(?i)\b(rd|rmdir)\s+/s\b[^\n]*\s[a-z]:\\?\s*$", drive), + (r"(?i)\bdel\s+(/[fsq]\s+)*[a-z]:\\\*", drive), + (r"(?i)\bformat\s+[a-z]:", "formate un lecteur"), + ] + .into_iter() + .map(|(re, why)| (Regex::new(re).expect("motif de commande dangereuse"), why)) + .collect() +}); + +/// `dangerousCommand` : la raison du refus, `None` si la commande passe. +pub fn dangerous_command(cmd: &str) -> Option<&'static str> { + DANGEROUS + .iter() + .find(|(re, _)| re.is_match(cmd)) + .map(|(_, why)| *why) +} + +/// `refusedCommandResult` : dire POURQUOI et QUOI FAIRE, sinon le modèle +/// réessaie en boucle avec des variantes. +pub fn refused_command_result(reason: &str) -> String { + format!( + "[refusé] Commande bloquée par la politique de sécurité : {reason}. \ + Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal." + ) +} + +/// `normPath` : absolu, nettoyé, en minuscules sous Windows. +fn norm_path(p: &Path) -> String { + let abs = if p.is_absolute() { + p.to_path_buf() + } else { + std::env::current_dir().unwrap_or_default().join(p) + }; + let s = clean(&abs).to_string_lossy().into_owned(); + if cfg!(windows) { s.to_lowercase() } else { s } +} + +/// `underDir` : `path` est `dir` ou dessous. +fn under_dir(path: &Path, dir: &Path) -> bool { + let (a, b) = (norm_path(path), norm_path(dir)); + a == b || a.starts_with(&format!("{b}{}", std::path::MAIN_SEPARATOR)) +} + +const TOOL_ONLY_LABEL: &str = "les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)"; + +/// `guardToolOnlyPath` : refus d'un write/edit dans le dossier mémoire. +pub fn guard_tool_only_path(path: &Path, memory_dir: &Path) -> Option { + if memory_dir.as_os_str().is_empty() || !under_dir(path, memory_dir) { + return None; + } + Some(format!( + "[refusé] pas d'accès direct au dossier {}. Utilise {TOOL_ONLY_LABEL}.", + memory_dir.display() + )) +} + +/// `guardToolOnlyCommand` : refus d'une commande qui MENTIONNE le dossier +/// mémoire, lecture comprise. +pub fn guard_tool_only_command(command: &str, memory_dir: &Path) -> Option { + if memory_dir.as_os_str().is_empty() + || !command + .to_lowercase() + .contains(&norm_path(memory_dir).to_lowercase()) + { + return None; + } + Some(format!( + "[refusé] pas d'accès direct au dossier {} via le shell. Utilise {TOOL_ONLY_LABEL}.", + memory_dir.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nettoyage_comme_filepath_clean() { + for (input, want) in [ + ("", "."), + ("a/./b", "a/b"), + ("a/../..", ".."), + ("/../a", "/a"), + ("a//b/", "a/b"), + ("../../x/../y", "../../y"), + ("/", "/"), + ] { + assert_eq!(clean(Path::new(input)), PathBuf::from(want), "{input:?}"); + } + } + + #[cfg(unix)] + #[test] + fn chemins_de_l_agent() { + let cwd = Path::new("/w/conv"); + assert_eq!( + resolve_agent_path(cwd, " a/../b.txt "), + PathBuf::from("/w/conv/b.txt") + ); + assert_eq!(resolve_agent_path(cwd, "/etc/x"), PathBuf::from("/etc/x")); + assert_eq!(resolve_agent_path(cwd, ""), PathBuf::new()); + } + + #[test] + fn commandes_dangereuses() { + for c in [ + "rm -rf /", + "sudo rm -fr ~ ", + "mkfs.ext4 /dev/sda1", + "dd if=x of=/dev/sda", + "ls; reboot", + ":(){ :|:& };:", + "format c:", + ] { + assert!(dangerous_command(c).is_some(), "{c}"); + } + for c in [ + "rm -rf build", + "grep reboot log.txt", + "dd if=a of=b", + "echo format", + ] { + assert!(dangerous_command(c).is_none(), "{c}"); + } + } + + #[test] + fn bac_a_sable_du_mode_code() { + let ws = tempfile::tempdir().unwrap(); + let w = ws.path(); + std::fs::create_dir(w.join("src")).unwrap(); + assert!(code_path_allowed(w, &w.join("src/neuf/a.rs"))); + assert!(code_path_allowed(w, w)); + assert!(!code_path_allowed(w, &w.join("../dehors"))); + assert!(!code_path_allowed(w, Path::new("/etc/passwd"))); + assert!(!code_path_allowed(Path::new(""), &w.join("a"))); + #[cfg(unix)] + { + std::os::unix::fs::symlink("/etc", w.join("lien")).unwrap(); + assert!(!code_path_allowed(w, &w.join("lien/passwd"))); + } + } + + #[test] + fn dossier_memoire_reserve() { + let mem = std::env::temp_dir().join("loki-mem-test"); + assert!(guard_tool_only_path(&mem.join("a.md"), &mem).is_some()); + assert!(guard_tool_only_path(&mem, &mem).is_some()); + assert!(guard_tool_only_path(&std::env::temp_dir().join("loki-mem-testx"), &mem).is_none()); + let cmd = format!("cat {}/a.md", mem.display()); + assert!(guard_tool_only_command(&cmd, &mem).is_some()); + assert!(guard_tool_only_command("ls", &mem).is_none()); + assert!(guard_tool_only_command("ls", Path::new("")).is_none()); + } +} diff --git a/rust/loki-rs/src/tools/shell.rs b/rust/loki-rs/src/tools/shell.rs new file mode 100644 index 0000000..b952000 --- /dev/null +++ b/rust/loki-rs/src/tools/shell.rs @@ -0,0 +1,317 @@ +//! Le shell de l'agent — port de `runShell` (`chat_tools.go`) et de +//! `chat_shell_hygiene.go`. +//! +//! La commande part dans `/bin/bash -c` (`cmd /C` sous Windows), dans le +//! dossier de la discussion, délai borné. Ce qu'elle rend au modèle est +//! nettoyé : « | tail -N » final retiré (le code de sortie serait celui de +//! tail) puis appliqué ici, séquences ANSI retirées, sortie bornée à sa fin. +//! +//! L'arrêt du tour abandonne le futur : le process est tué (`kill_on_drop`). + +use std::path::Path; +use std::process::Stdio; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use regex::Regex; +use tokio::io::AsyncReadExt; + +use super::policy::guard_tool_only_command; + +pub const TOOL_DEFAULT_TIMEOUT: i64 = 30; +pub const TOOL_MAX_TIMEOUT: i64 = 300; +/// Caractères de stdout/stderr rendus au modèle. +pub const TOOL_MAX_OUTPUT: usize = 8000; +/// `WaitDelay` : attente bornée des tubes après la fin du process — un +/// « ./serveur & » laisse un petit-enfant accroché aux tubes, et le tour ne +/// rendrait jamais la main. +const WAIT_DELAY: Duration = Duration::from_secs(2); + +/// « | tail », « | tail -20 », « | tail -n 20 », « | tail -n20 » en fin. +static TAIL_PIPE: LazyLock = LazyLock::new(|| { + Regex::new(r"\s*\|\s*tail(?:\s+-n\s*([0-9]+)|\s+-([0-9]+))?\s*$").expect("motif tail") +}); + +/// `splitTailPipe` : la commande sans son « | tail -N » final, et N (0 : pas +/// de tail ; 10 : tail sans nombre). +pub fn split_tail_pipe(command: &str) -> (String, usize) { + let Some(c) = TAIL_PIPE.captures(command) else { + return (command.to_string(), 0); + }; + let n = [1, 2] + .into_iter() + .filter_map(|g| c.get(g)) + .filter_map(|m| m.as_str().parse::().ok()) + .find(|v| *v > 0) + .unwrap_or(10); + let rest = command[..c.get(0).map_or(0, |m| m.start())].trim(); + if rest.is_empty() { + return (command.to_string(), 0); + } + (rest.to_string(), n) +} + +/// Séquences CSI (couleurs, curseur) et OSC (titres de fenêtre). +static ANSI: LazyLock = LazyLock::new(|| { + Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)").expect("motif ANSI") +}); + +pub fn strip_ansi(s: &str) -> String { + if !s.contains('\x1b') { + return s.to_string(); + } + ANSI.replace_all(s, "").into_owned() +} + +/// `trailingBackground` : la commande finit par un « & » (pas « && »). +pub fn trailing_background(command: &str) -> bool { + let c = command.trim(); + c.ends_with('&') && !c.ends_with("&&") +} + +/// `tailLines` : les n dernières lignes. +pub fn tail_lines(s: &str, n: usize) -> String { + let lines: Vec<&str> = s.trim_end_matches('\n').split('\n').collect(); + lines[lines.len().saturating_sub(n)..].join("\n") +} + +/// `tailOutput` : la FIN d'une sortie (`TOOL_MAX_OUTPUT` caractères), avec ce +/// qui a été coupé dit en clair. +pub fn tail_output(s: &str) -> String { + let n = s.chars().count(); + if n <= TOOL_MAX_OUTPUT { + return s.to_string(); + } + let start = s + .char_indices() + .nth(n - TOOL_MAX_OUTPUT) + .map_or(0, |(i, _)| i); + format!( + "[sortie tronquée : {n} caractères au total, seuls les {TOOL_MAX_OUTPUT} derniers sont gardés]\n{}", + &s[start..] + ) +} + +/// `time.Duration.String()` d'une durée arrondie à 100 ms (« 0s », « 300ms », +/// « 1.2s », « 1m5s », « 1h0m0s »). +pub fn go_duration(d: Duration) -> String { + let ms = ((d.as_millis() + 50) / 100 * 100) as u64; + if ms == 0 { + return "0s".into(); + } + if ms < 1000 { + return format!("{ms}ms"); + } + let (h, rem) = (ms / 3_600_000, ms % 3_600_000); + let (m, rem) = (rem / 60_000, rem % 60_000); + let (s, tenth) = (rem / 1000, rem % 1000 / 100); + let secs = if tenth > 0 { + format!("{s}.{tenth}s") + } else { + format!("{s}s") + }; + match (h, m) { + (0, 0) => secs, + (0, m) => format!("{m}m{secs}"), + (h, m) => format!("{h}h{m}m{secs}"), + } +} + +/// Lit un tube jusqu'au bout dans un tampon partagé (lisible même si la +/// lecture est abandonnée en route). +fn drain( + mut r: R, +) -> (Arc>>, tokio::task::JoinHandle<()>) { + let buf = Arc::new(Mutex::new(Vec::new())); + let b = buf.clone(); + let h = tokio::spawn(async move { + let mut chunk = [0u8; 8192]; + while let Ok(n) = r.read(&mut chunk).await { + if n == 0 { + break; + } + b.lock() + .unwrap_or_else(|e| e.into_inner()) + .extend_from_slice(&chunk[..n]); + } + }); + (buf, h) +} + +/// La commande du shell de la plateforme. Le jeton de l'API interne n'a rien +/// à faire dans le shell de l'agent. +fn shell_cmd(command: &str) -> tokio::process::Command { + #[cfg(windows)] + let mut c = { + let mut c = tokio::process::Command::new("cmd"); + c.arg("/C").arg(command); + c.creation_flags(0x0800_0000); // CREATE_NO_WINDOW : pas de flash de console + c + }; + #[cfg(not(windows))] + let mut c = { + let mut c = tokio::process::Command::new("/bin/bash"); + c.arg("-c").arg(command); + c + }; + c.env_remove(crate::auth::INTERNAL_ENV); + c +} + +/// `runShell` : « exit: N · durée », puis stdout et stderr nettoyés. +pub async fn run_shell(cwd: &Path, memory_dir: &Path, command: &str, timeout_sec: i64) -> String { + if let Some(msg) = guard_tool_only_command(command, memory_dir) { + return msg; + } + let timeout_sec = match timeout_sec { + t if t <= 0 => TOOL_DEFAULT_TIMEOUT, + t => t.min(TOOL_MAX_TIMEOUT), + }; + let (command, keep_lines) = split_tail_pipe(command); + let start = Instant::now(); + let mut cmd = shell_cmd(&command); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + // Dans le dossier de la discussion, recréé au besoin (l'utilisateur ou le + // modèle a pu le supprimer) ; à défaut, là où on peut. + if !cwd.as_os_str().is_empty() && std::fs::create_dir_all(cwd).is_ok() { + cmd.current_dir(cwd); + } + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => return format!("[erreur: {e}]"), + }; + let (out_buf, out_h) = drain(child.stdout.take().expect("stdout")); + let (err_buf, err_h) = drain(child.stderr.take().expect("stderr")); + let deadline = Duration::from_secs(timeout_sec as u64); + let (status, timed_out) = match tokio::time::timeout(deadline, child.wait()).await { + Ok(st) => (st.ok(), false), + Err(_) => { + let _ = child.start_kill(); + (child.wait().await.ok(), true) + } + }; + // Tubes encore tenus par un petit-enfant : WAIT_DELAY, puis on lâche. + let pipes_closed = tokio::time::timeout(WAIT_DELAY, async { + let _ = out_h.await; + let _ = err_h.await; + }) + .await + .is_ok(); + let take = |b: &Arc>>| { + String::from_utf8_lossy(&b.lock().unwrap_or_else(|e| e.into_inner())).into_owned() + }; + let clean = |s: &str| tail_output(&strip_ansi(s)); + let mut out = take(&out_buf); + if keep_lines > 0 { + out = tail_lines(&out, keep_lines); + } + let out = clean(&out); + let err_out = clean(&take(&err_buf)); + if timed_out { + let mut msg = format!( + "[timeout après {timeout_sec}s — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]" + ); + if !out.is_empty() { + msg.push_str(&format!("\n\nstdout (partiel):\n{out}")); + } + if !err_out.is_empty() { + msg.push_str(&format!("\n\nstderr (partiel):\n{err_out}")); + } + return msg; + } + let exit = match status { + Some(st) => st.code().unwrap_or(-1), + None => return "[erreur: attente du process impossible]".into(), + }; + // Go : sortie 0 mais tubes jamais fermés → ErrWaitDelay. + if exit == 0 && !pipes_closed { + return "[erreur: exec: WaitDelay expired before I/O complete]".into(); + } + let mut parts = vec![format!("exit: {exit} · {}", go_duration(start.elapsed()))]; + if !out.is_empty() { + parts.push(format!("stdout:\n{out}")); + } + if !err_out.is_empty() { + parts.push(format!("stderr:\n{err_out}")); + } + parts.join("\n\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tail_final_retire() { + assert_eq!(split_tail_pipe("make | tail -20"), ("make".into(), 20)); + assert_eq!(split_tail_pipe("make | tail -n 5 "), ("make".into(), 5)); + assert_eq!(split_tail_pipe("make|tail"), ("make".into(), 10)); + assert_eq!(split_tail_pipe("| tail -3"), ("| tail -3".into(), 0)); + assert_eq!(split_tail_pipe("tail -f x"), ("tail -f x".into(), 0)); + } + + #[test] + fn ansi_et_arriere_plan() { + assert_eq!( + strip_ansi("\x1b[31mrouge\x1b[0m \x1b]0;titre\x07ok"), + "rouge ok" + ); + assert!(trailing_background("serveur &") && !trailing_background("a && b")); + } + + #[test] + fn durees_comme_go() { + for (ms, want) in [ + (0, "0s"), + (49, "0s"), + (50, "100ms"), + (340, "300ms"), + (1_250, "1.3s"), + (12_000, "12s"), + (65_400, "1m5.4s"), + (3_600_000, "1h0m0s"), + ] { + assert_eq!(go_duration(Duration::from_millis(ms)), want, "{ms}"); + } + } + + #[test] + fn sortie_bornee() { + let s = "x".repeat(TOOL_MAX_OUTPUT + 5); + let t = tail_output(&s); + assert!(t.starts_with("[sortie tronquée : 8005 caractères au total")); + assert_eq!(tail_lines("a\nb\nc\n", 2), "b\nc"); + } + + #[cfg(unix)] + #[tokio::test] + async fn commande_et_delai() { + let d = tempfile::tempdir().unwrap(); + let mem = d.path().join("memory"); + let r = run_shell(d.path(), &mem, "echo un; echo deux >&2; exit 3", 5).await; + assert!(r.starts_with("exit: 3 · "), "{r}"); + assert!(r.ends_with("stdout:\nun\n\n\nstderr:\ndeux\n"), "{r:?}"); + let r = run_shell(d.path(), &mem, "printf 'a\\nb\\nc\\n' | tail -2", 5).await; + assert!(r.ends_with("stdout:\nb\nc"), "{r:?}"); + let r = run_shell(d.path(), &mem, "pwd", 5).await; + assert!(r.contains(&d.path().display().to_string()), "{r}"); + let r = run_shell(d.path(), &mem, "echo avant; sleep 5", 1).await; + assert!(r.starts_with("[timeout après 1s"), "{r}"); + assert!(r.contains("stdout (partiel):\navant"), "{r}"); + let r = run_shell(d.path(), &mem, &format!("ls {}", mem.display()), 5).await; + assert!(r.starts_with("[refusé] pas d'accès direct"), "{r}"); + } + + #[test] + fn jeton_interne_absent_du_shell() { + let c = shell_cmd("env"); + assert!( + c.as_std() + .get_envs() + .any(|(k, v)| k == crate::auth::INTERNAL_ENV && v.is_none()) + ); + } +} diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 7bd2642..8d86fc5 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -46,6 +46,10 @@ pub struct FakeGo { pub turn_log: Mutex>, /// Adresse d'écoute du faux Go (pour un client `Store` direct). pub backend: Mutex>, + /// Contexte des outils exécutés par la façade (None : 404, le Go les + /// exécute), et journal du suivi (lectures, écritures, contrôles). + pub tool_ctx: Mutex>, + pub tracker_log: Mutex>, } impl FakeGo { @@ -234,6 +238,23 @@ async fn handle(st: Arc, req: Request) -> Response *st.go_busy.lock().unwrap() = None; resp(204, "") } + "/internal/v1/tool/context" => match st.tool_ctx.lock().unwrap().clone() { + Some(v) => resp(200, v.to_string()), + None => resp(404, "404 page not found"), + }, + "/internal/v1/tool/tracker" | "/internal/v1/tool/wrote" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let op = v["op"].as_str().unwrap_or("wrote").to_string(); + let path = v["path"].as_str().unwrap_or("").to_string(); + st.tracker_log.lock().unwrap().push(format!("{op} {path}")); + let out = if op == "check" { + serde_json::json!({ "refusal": "" }) + } else { + serde_json::json!({ "lsp": "" }) + }; + resp(200, out.to_string()) + } "/internal/v1/tool/exec" => { let body = req.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 192d9ac..91d31c7 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -799,6 +799,51 @@ async fn tour_de_bout_en_bout() { assert!(again.dup); } +/// Lot 7 : un `read` s'exécute dans la façade, dans le dossier de travail que +/// lui donne le Go, et la lecture est notée dans le suivi du Go. +#[tokio::test] +async fn outil_execute_par_la_facade() { + let ws = tempfile::tempdir().unwrap(); + std::fs::write(ws.path().join("notes.txt"), "première\nseconde\n").unwrap(); + let script = vec![ + sse(&[ + serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"read","arguments":"{\"file\":\"notes.txt\"}"}}]}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}), + ]), + sse(&[ + serde_json::json!({"choices":[{"delta":{"content": "lu"}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"stop"}]}), + ]), + ]; + let (url, seen) = fake_engine(script).await; + let (svc, go, _stop, _t) = service(Some(&url)).await; + *go.tool_ctx.lock().unwrap() = Some(serde_json::json!({ + "cwd": ws.path(), + "memory_dir": ws.path().join("memory"), + "target": "", + })); + svc.send(serde_json::json!({"message": "lis", "cid": "b1"})) + .await + .unwrap(); + until_done(&svc, 1).await; + let tool = deltas(&svc, "tool_used") + .into_iter() + .find(|t| t["done"] == true) + .unwrap(); + assert_eq!(tool["result"], "1: première\n2: seconde"); + // Le résultat est reparti vers le moteur, sans passer par /tool/exec. + let seen = seen.lock().unwrap().clone(); + assert_eq!( + seen[1].1["messages"].as_array().unwrap().last().unwrap()["content"], + "1: première\n2: seconde" + ); + let notes = ws.path().join("notes.txt"); + assert_eq!( + *go.tracker_log.lock().unwrap(), + [format!("read {}", notes.display())] + ); +} + #[tokio::test] async fn message_en_file_injecte_dans_le_tour() { let gate = Arc::new(tokio::sync::Semaphore::new(0)); diff --git a/rust/loki-rs/tests/parity_tools/outils.json b/rust/loki-rs/tests/parity_tools/outils.json new file mode 100644 index 0000000..21bea58 --- /dev/null +++ b/rust/loki-rs/tests/parity_tools/outils.json @@ -0,0 +1,767 @@ +{ + "cwd": "ws", + "memory_dir": "memory/generale", + "files": [ + { + "path": "ws/a.txt", + "content": "ligne 1\nligne 2\nligne 3\n", + "age": 50 + }, + { + "path": "ws/b.txt", + "content": "alpha\nbeta\n", + "age": 40 + }, + { + "path": "ws/vide.txt", + "content": "", + "age": 30 + }, + { + "path": "ws/long.txt", + "content": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\nfin\n", + "age": 20 + }, + { + "path": "ws/crlf.txt", + "content": "un\r\ndeux\r\ntrois\r\n", + "age": 10 + }, + { + "path": "ws/bin.dat", + "content": "PK\u0000\u0001binaire", + "age": 60 + }, + { + "path": "ws/src/main.go", + "content": "package main\n\nfunc main() { x := 1 }\n", + "age": 5 + }, + { + "path": "ws/src/lib/util.go", + "content": "package lib\n// x marque\n", + "age": 3 + }, + { + "path": "ws/src/lib/notes.md", + "content": "x dans les notes\n", + "age": 4 + }, + { + "path": "ws/node_modules/dep/index.js", + "content": "ligne cachée\n", + "age": 70 + }, + { + "path": "ws/beaucoup.txt", + "content": "aiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\n", + "age": 80 + }, + { + "path": "dehors.txt", + "content": "hors du dossier\n", + "age": 90 + } + ], + "cases": [ + { + "name": "lecture", + "tool": "read", + "args": { + "file": "a.txt" + }, + "label": "a.txt", + "result": "1: ligne 1\n2: ligne 2\n3: ligne 3" + }, + { + "name": "lecture_fenetre", + "tool": "read", + "args": { + "file": "a.txt", + "limit": 1, + "offset": 2 + }, + "label": "a.txt", + "result": "2: ligne 2\n… (1 lignes restantes — relis avec offset=3)" + }, + { + "name": "lecture_fenetre_decimale", + "tool": "read", + "args": { + "file": "a.txt", + "limit": "3", + "offset": 2.7 + }, + "label": "a.txt", + "result": "2: ligne 2\n3: ligne 3" + }, + { + "name": "lecture_chemin_vide", + "tool": "read", + "args": { + "file": " " + }, + "label": " ", + "result": "[erreur] chemin vide" + }, + { + "name": "lecture_absent", + "tool": "read", + "args": { + "file": "absent.txt" + }, + "label": "absent.txt", + "result": "[erreur] open {{ROOT}}/ws/absent.txt: no such file or directory" + }, + { + "name": "lecture_dossier", + "tool": "read", + "args": { + "file": "src" + }, + "label": "src", + "result": "[erreur] read {{ROOT}}/ws/src: is a directory" + }, + { + "name": "lecture_binaire", + "tool": "read", + "args": { + "file": "bin.dat" + }, + "label": "bin.dat", + "result": "[binaire] bin.dat (11 octets) — pas un fichier texte" + }, + { + "name": "lecture_au_dela", + "tool": "read", + "args": { + "file": "a.txt", + "offset": 99 + }, + "label": "a.txt", + "result": "[erreur] offset 99 > 3 lignes" + }, + { + "name": "lecture_ligne_longue", + "tool": "read", + "args": { + "file": "long.txt" + }, + "label": "long.txt", + "result": "1: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…\n2: fin" + }, + { + "name": "lecture_fichier_vide", + "tool": "read", + "args": { + "file": "vide.txt" + }, + "label": "vide.txt", + "result": "[erreur] offset 1 > 0 lignes" + }, + { + "name": "lecture_absolue", + "tool": "read", + "args": { + "file": "{{ROOT}}/dehors.txt" + }, + "label": "{{ROOT}}/dehors.txt", + "result": "1: hors du dossier" + }, + { + "name": "lecture_relative_sortante", + "tool": "read", + "args": { + "file": "src/../../dehors.txt" + }, + "label": "src/../../dehors.txt", + "result": "1: hors du dossier" + }, + { + "name": "lecture_code_hors_dossier", + "tool": "read", + "args": { + "file": "../dehors.txt" + }, + "code": true, + "label": "../dehors.txt", + "result": "[refusé] ../dehors.txt est hors du dossier de cette discussion. En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs." + }, + { + "name": "lecture_code", + "tool": "read", + "args": { + "file": "b.txt" + }, + "code": true, + "label": "b.txt", + "result": "1: alpha\n2: beta" + }, + { + "name": "grep", + "tool": "grep", + "args": { + "pattern": "ligne [12]" + }, + "label": "ligne [12]", + "result": "a.txt:1: ligne 1\na.txt:2: ligne 2" + }, + { + "name": "grep_filtre", + "tool": "grep", + "args": { + "glob": "*.go", + "pattern": "x" + }, + "label": "x", + "result": "src/lib/util.go:2: // x marque\nsrc/main.go:3: func main() { x := 1 }" + }, + { + "name": "grep_filtre_classe", + "tool": "grep", + "args": { + "glob": "[mn]*", + "pattern": "x" + }, + "label": "x", + "result": "src/lib/notes.md:1: x dans les notes\nsrc/main.go:3: func main() { x := 1 }" + }, + { + "name": "grep_fichier", + "tool": "grep", + "args": { + "path": "b.txt", + "pattern": "beta" + }, + "label": "beta", + "result": ".:2: beta" + }, + { + "name": "grep_sous_dossier", + "tool": "grep", + "args": { + "path": "src/lib", + "pattern": "package" + }, + "label": "package", + "result": "util.go:1: package lib" + }, + { + "name": "grep_point", + "tool": "grep", + "args": { + "path": ".", + "pattern": "alpha" + }, + "label": "alpha", + "result": "b.txt:1: alpha" + }, + { + "name": "grep_aucun", + "tool": "grep", + "args": { + "pattern": "zzz" + }, + "label": "zzz", + "result": "[aucun résultat]" + }, + { + "name": "grep_motif_vide", + "tool": "grep", + "args": { + "pattern": " " + }, + "label": " ", + "result": "[erreur] motif vide" + }, + { + "name": "grep_plafond", + "tool": "grep", + "args": { + "pattern": "aiguille" + }, + "label": "aiguille", + "result": "beaucoup.txt:1: aiguille\nbeaucoup.txt:2: aiguille\nbeaucoup.txt:3: aiguille\nbeaucoup.txt:4: aiguille\nbeaucoup.txt:5: aiguille\nbeaucoup.txt:6: aiguille\nbeaucoup.txt:7: aiguille\nbeaucoup.txt:8: aiguille\nbeaucoup.txt:9: aiguille\nbeaucoup.txt:10: aiguille\nbeaucoup.txt:11: aiguille\nbeaucoup.txt:12: aiguille\nbeaucoup.txt:13: aiguille\nbeaucoup.txt:14: aiguille\nbeaucoup.txt:15: aiguille\nbeaucoup.txt:16: aiguille\nbeaucoup.txt:17: aiguille\nbeaucoup.txt:18: aiguille\nbeaucoup.txt:19: aiguille\nbeaucoup.txt:20: aiguille\nbeaucoup.txt:21: aiguille\nbeaucoup.txt:22: aiguille\nbeaucoup.txt:23: aiguille\nbeaucoup.txt:24: aiguille\nbeaucoup.txt:25: aiguille\nbeaucoup.txt:26: aiguille\nbeaucoup.txt:27: aiguille\nbeaucoup.txt:28: aiguille\nbeaucoup.txt:29: aiguille\nbeaucoup.txt:30: aiguille\nbeaucoup.txt:31: aiguille\nbeaucoup.txt:32: aiguille\nbeaucoup.txt:33: aiguille\nbeaucoup.txt:34: aiguille\nbeaucoup.txt:35: aiguille\nbeaucoup.txt:36: aiguille\nbeaucoup.txt:37: aiguille\nbeaucoup.txt:38: aiguille\nbeaucoup.txt:39: aiguille\nbeaucoup.txt:40: aiguille\nbeaucoup.txt:41: aiguille\nbeaucoup.txt:42: aiguille\nbeaucoup.txt:43: aiguille\nbeaucoup.txt:44: aiguille\nbeaucoup.txt:45: aiguille\nbeaucoup.txt:46: aiguille\nbeaucoup.txt:47: aiguille\nbeaucoup.txt:48: aiguille\nbeaucoup.txt:49: aiguille\nbeaucoup.txt:50: aiguille\nbeaucoup.txt:51: aiguille\nbeaucoup.txt:52: aiguille\nbeaucoup.txt:53: aiguille\nbeaucoup.txt:54: aiguille\nbeaucoup.txt:55: aiguille\nbeaucoup.txt:56: aiguille\nbeaucoup.txt:57: aiguille\nbeaucoup.txt:58: aiguille\nbeaucoup.txt:59: aiguille\nbeaucoup.txt:60: aiguille\n… (plafond de 60 résultats atteint — affine le motif)" + }, + { + "name": "grep_absent", + "tool": "grep", + "args": { + "path": "nulle-part", + "pattern": "a" + }, + "label": "a", + "result": "[aucun résultat]" + }, + { + "name": "grep_code_hors_dossier", + "tool": "grep", + "args": { + "path": "{{ROOT}}", + "pattern": "a" + }, + "code": true, + "label": "a", + "result": "[refusé] {{ROOT}} est hors du dossier de cette discussion. En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs." + }, + { + "name": "glob_go", + "tool": "glob", + "args": { + "pattern": "**/*.go" + }, + "label": "**/*.go", + "result": "src/lib/util.go\nsrc/main.go" + }, + { + "name": "glob_racine", + "tool": "glob", + "args": { + "pattern": "*.txt" + }, + "label": "*.txt", + "result": "crlf.txt\nlong.txt\nvide.txt\nb.txt\na.txt\nbeaucoup.txt" + }, + { + "name": "glob_point", + "tool": "glob", + "args": { + "pattern": "./src/*" + }, + "label": "./src/*", + "result": "src/main.go" + }, + { + "name": "glob_aucun", + "tool": "glob", + "args": { + "pattern": "*.rs" + }, + "label": "*.rs", + "result": "[aucun fichier]" + }, + { + "name": "glob_vide", + "tool": "glob", + "args": { + "pattern": "" + }, + "label": "", + "result": "[erreur] motif vide" + }, + { + "name": "question", + "tool": "ask", + "args": { + "options": [ + "a", + " ", + "b", + "c", + "d", + "e" + ], + "question": "Lequel ?" + }, + "label": "Lequel ?", + "result": "[question posée à l'utilisateur] Termine ton tour MAINTENANT sans appeler d'autre outil : sa réponse arrivera comme prochain message.", + "ask": { + "question": "Lequel ?", + "options": [ + "a", + "b", + "c", + "d" + ] + } + }, + { + "name": "question_vide", + "tool": "ask", + "args": { + "question": "" + }, + "label": "", + "result": "[erreur] question vide" + }, + { + "name": "ecriture_neuve", + "tool": "write", + "args": { + "content": "un\ndeux\n", + "file": "neuf/n.txt" + }, + "label": "neuf/n.txt", + "result": "[ok] {{ROOT}}/ws/neuf/n.txt créé (8 octets)", + "diff": [ + { + "op": "+", + "text": "un" + }, + { + "op": "+", + "text": "deux" + } + ], + "added": 2 + }, + { + "name": "ecriture_existant", + "tool": "write", + "args": { + "content": "remplacé\n", + "file": "b.txt" + }, + "label": "b.txt", + "result": "[ok] {{ROOT}}/ws/b.txt réécrit (10 octets)", + "diff": [ + { + "op": "+", + "text": "remplacé" + } + ], + "added": 1 + }, + { + "name": "ecriture_memoire", + "tool": "write", + "args": { + "content": "x\n", + "file": "{{MEM}}/x.md" + }, + "label": "{{ROOT}}/memory/generale/x.md", + "result": "[refusé] pas d'accès direct au dossier {{ROOT}}/memory/generale. Utilise les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete).", + "diff": [ + { + "op": "+", + "text": "x" + } + ], + "added": 1 + }, + { + "name": "ecriture_chemin_vide", + "tool": "write", + "args": { + "content": "x", + "file": "" + }, + "label": "", + "result": "[erreur] chemin vide" + }, + { + "name": "ecriture_code_non_lu", + "tool": "write", + "args": { + "content": "x\n", + "file": "src/lib/notes.md" + }, + "code": true, + "label": "src/lib/notes.md", + "result": "[refusé] {{ROOT}}/ws/src/lib/notes.md existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le." + }, + { + "name": "ecriture_code_hors_dossier", + "tool": "write", + "args": { + "content": "x\n", + "file": "../evade.txt" + }, + "code": true, + "label": "../evade.txt", + "result": "[refusé] ../evade.txt est hors du dossier de cette discussion. En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs." + }, + { + "name": "ecriture_code_neuve", + "tool": "write", + "args": { + "content": "c\n", + "file": "code.txt" + }, + "code": true, + "label": "code.txt", + "result": "[ok] {{ROOT}}/ws/code.txt créé (2 octets)", + "diff": [ + { + "op": "+", + "text": "c" + } + ], + "added": 1 + }, + { + "name": "edition", + "tool": "edit", + "args": { + "file": "a.txt", + "new": "ligne deux", + "old": "ligne 2" + }, + "label": "a.txt", + "result": "[ok] {{ROOT}}/ws/a.txt modifié (1 remplacement)", + "diff": [ + { + "op": "-", + "text": "ligne 2" + }, + { + "op": "+", + "text": "ligne deux" + } + ], + "added": 1, + "removed": 1 + }, + { + "name": "edition_deja_faite", + "tool": "edit", + "args": { + "file": "a.txt", + "new": "ligne deux", + "old": "ligne 2" + }, + "label": "a.txt", + "result": "[ok] déjà à jour — le fichier contient déjà cette modification", + "diff": [ + { + "op": "-", + "text": "ligne 2" + }, + { + "op": "+", + "text": "ligne deux" + } + ], + "added": 1, + "removed": 1 + }, + { + "name": "edition_introuvable", + "tool": "edit", + "args": { + "file": "a.txt", + "new": "y", + "old": "zzz" + }, + "label": "a.txt", + "result": "[erreur] old introuvable dans le fichier" + }, + { + "name": "edition_ambigue", + "tool": "edit", + "args": { + "file": "a.txt", + "new": "l", + "old": "ligne" + }, + "label": "a.txt", + "result": "[erreur] old apparaît 3 fois — ajoute du contexte pour le rendre unique" + }, + { + "name": "edition_crlf", + "tool": "edit", + "args": { + "file": "crlf.txt", + "new": "2\n3", + "old": "deux\ntrois" + }, + "label": "crlf.txt", + "result": "[ok] {{ROOT}}/ws/crlf.txt modifié (1 remplacement)", + "diff": [ + { + "op": "-", + "text": "deux" + }, + { + "op": "-", + "text": "trois" + }, + { + "op": "+", + "text": "2" + }, + { + "op": "+", + "text": "3" + } + ], + "added": 2, + "removed": 2 + }, + { + "name": "edition_old_vide", + "tool": "edit", + "args": { + "file": "a.txt", + "new": "y", + "old": "" + }, + "label": "a.txt", + "result": "[erreur] old vide" + }, + { + "name": "edition_fichier_absent", + "tool": "edit", + "args": { + "file": "absent.txt", + "new": "b", + "old": "a" + }, + "label": "absent.txt", + "result": "[erreur] open {{ROOT}}/ws/absent.txt: no such file or directory" + }, + { + "name": "edition_code_non_lu", + "tool": "edit", + "args": { + "file": "src/main.go", + "new": "x := 2", + "old": "x := 1" + }, + "code": true, + "label": "src/main.go", + "result": "[refusé] {{ROOT}}/ws/src/main.go existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le." + }, + { + "name": "edition_code_apres_lecture", + "tool": "edit", + "args": { + "file": "b.txt", + "new": "rem", + "old": "remplacé" + }, + "code": true, + "label": "b.txt", + "result": "[ok] {{ROOT}}/ws/b.txt modifié (1 remplacement)", + "diff": [ + { + "op": "-", + "text": "remplacé" + }, + { + "op": "+", + "text": "rem" + } + ], + "added": 1, + "removed": 1 + }, + { + "name": "shell", + "tool": "bash", + "args": { + "command": "echo bonjour" + }, + "unix": true, + "label": "echo bonjour", + "result": "exit: 0 · {{DUR}}\n\nstdout:\nbonjour\n" + }, + { + "name": "shell_sortie_et_erreurs", + "tool": "bash", + "args": { + "command": "echo out; echo err >&2; exit 3" + }, + "unix": true, + "label": "echo out; echo err >&2; exit 3", + "result": "exit: 3 · {{DUR}}\n\nstdout:\nout\n\n\nstderr:\nerr\n" + }, + { + "name": "shell_tail", + "tool": "bash", + "args": { + "command": "printf 'a\\nb\\nc\\n' | tail -2" + }, + "unix": true, + "label": "printf 'a\\nb\\nc\\n' | tail -2", + "result": "exit: 0 · {{DUR}}\n\nstdout:\nb\nc" + }, + { + "name": "shell_ansi", + "tool": "bash", + "args": { + "command": "printf '\\033[31mrouge\\033[0m\\n'" + }, + "unix": true, + "label": "printf '\\033[31mrouge\\033[0m\\n'", + "result": "exit: 0 · {{DUR}}\n\nstdout:\nrouge\n" + }, + { + "name": "shell_dossier", + "tool": "bash", + "args": { + "command": "ls src" + }, + "unix": true, + "label": "ls src", + "result": "exit: 0 · {{DUR}}\n\nstdout:\nlib\nmain.go\n" + }, + { + "name": "shell_longue_sortie", + "tool": "bash", + "args": { + "command": "seq 1 3000" + }, + "unix": true, + "label": "seq 1 3000", + "result": "exit: 0 · {{DUR}}\n\nstdout:\n[sortie tronquée : 13893 caractères au total, seuls les 8000 derniers sont gardés]\n1401\n1402\n1403\n1404\n1405\n1406\n1407\n1408\n1409\n1410\n1411\n1412\n1413\n1414\n1415\n1416\n1417\n1418\n1419\n1420\n1421\n1422\n1423\n1424\n1425\n1426\n1427\n1428\n1429\n1430\n1431\n1432\n1433\n1434\n1435\n1436\n1437\n1438\n1439\n1440\n1441\n1442\n1443\n1444\n1445\n1446\n1447\n1448\n1449\n1450\n1451\n1452\n1453\n1454\n1455\n1456\n1457\n1458\n1459\n1460\n1461\n1462\n1463\n1464\n1465\n1466\n1467\n1468\n1469\n1470\n1471\n1472\n1473\n1474\n1475\n1476\n1477\n1478\n1479\n1480\n1481\n1482\n1483\n1484\n1485\n1486\n1487\n1488\n1489\n1490\n1491\n1492\n1493\n1494\n1495\n1496\n1497\n1498\n1499\n1500\n1501\n1502\n1503\n1504\n1505\n1506\n1507\n1508\n1509\n1510\n1511\n1512\n1513\n1514\n1515\n1516\n1517\n1518\n1519\n1520\n1521\n1522\n1523\n1524\n1525\n1526\n1527\n1528\n1529\n1530\n1531\n1532\n1533\n1534\n1535\n1536\n1537\n1538\n1539\n1540\n1541\n1542\n1543\n1544\n1545\n1546\n1547\n1548\n1549\n1550\n1551\n1552\n1553\n1554\n1555\n1556\n1557\n1558\n1559\n1560\n1561\n1562\n1563\n1564\n1565\n1566\n1567\n1568\n1569\n1570\n1571\n1572\n1573\n1574\n1575\n1576\n1577\n1578\n1579\n1580\n1581\n1582\n1583\n1584\n1585\n1586\n1587\n1588\n1589\n1590\n1591\n1592\n1593\n1594\n1595\n1596\n1597\n1598\n1599\n1600\n1601\n1602\n1603\n1604\n1605\n1606\n1607\n1608\n1609\n1610\n1611\n1612\n1613\n1614\n1615\n1616\n1617\n1618\n1619\n1620\n1621\n1622\n1623\n1624\n1625\n1626\n1627\n1628\n1629\n1630\n1631\n1632\n1633\n1634\n1635\n1636\n1637\n1638\n1639\n1640\n1641\n1642\n1643\n1644\n1645\n1646\n1647\n1648\n1649\n1650\n1651\n1652\n1653\n1654\n1655\n1656\n1657\n1658\n1659\n1660\n1661\n1662\n1663\n1664\n1665\n1666\n1667\n1668\n1669\n1670\n1671\n1672\n1673\n1674\n1675\n1676\n1677\n1678\n1679\n1680\n1681\n1682\n1683\n1684\n1685\n1686\n1687\n1688\n1689\n1690\n1691\n1692\n1693\n1694\n1695\n1696\n1697\n1698\n1699\n1700\n1701\n1702\n1703\n1704\n1705\n1706\n1707\n1708\n1709\n1710\n1711\n1712\n1713\n1714\n1715\n1716\n1717\n1718\n1719\n1720\n1721\n1722\n1723\n1724\n1725\n1726\n1727\n1728\n1729\n1730\n1731\n1732\n1733\n1734\n1735\n1736\n1737\n1738\n1739\n1740\n1741\n1742\n1743\n1744\n1745\n1746\n1747\n1748\n1749\n1750\n1751\n1752\n1753\n1754\n1755\n1756\n1757\n1758\n1759\n1760\n1761\n1762\n1763\n1764\n1765\n1766\n1767\n1768\n1769\n1770\n1771\n1772\n1773\n1774\n1775\n1776\n1777\n1778\n1779\n1780\n1781\n1782\n1783\n1784\n1785\n1786\n1787\n1788\n1789\n1790\n1791\n1792\n1793\n1794\n1795\n1796\n1797\n1798\n1799\n1800\n1801\n1802\n1803\n1804\n1805\n1806\n1807\n1808\n1809\n1810\n1811\n1812\n1813\n1814\n1815\n1816\n1817\n1818\n1819\n1820\n1821\n1822\n1823\n1824\n1825\n1826\n1827\n1828\n1829\n1830\n1831\n1832\n1833\n1834\n1835\n1836\n1837\n1838\n1839\n1840\n1841\n1842\n1843\n1844\n1845\n1846\n1847\n1848\n1849\n1850\n1851\n1852\n1853\n1854\n1855\n1856\n1857\n1858\n1859\n1860\n1861\n1862\n1863\n1864\n1865\n1866\n1867\n1868\n1869\n1870\n1871\n1872\n1873\n1874\n1875\n1876\n1877\n1878\n1879\n1880\n1881\n1882\n1883\n1884\n1885\n1886\n1887\n1888\n1889\n1890\n1891\n1892\n1893\n1894\n1895\n1896\n1897\n1898\n1899\n1900\n1901\n1902\n1903\n1904\n1905\n1906\n1907\n1908\n1909\n1910\n1911\n1912\n1913\n1914\n1915\n1916\n1917\n1918\n1919\n1920\n1921\n1922\n1923\n1924\n1925\n1926\n1927\n1928\n1929\n1930\n1931\n1932\n1933\n1934\n1935\n1936\n1937\n1938\n1939\n1940\n1941\n1942\n1943\n1944\n1945\n1946\n1947\n1948\n1949\n1950\n1951\n1952\n1953\n1954\n1955\n1956\n1957\n1958\n1959\n1960\n1961\n1962\n1963\n1964\n1965\n1966\n1967\n1968\n1969\n1970\n1971\n1972\n1973\n1974\n1975\n1976\n1977\n1978\n1979\n1980\n1981\n1982\n1983\n1984\n1985\n1986\n1987\n1988\n1989\n1990\n1991\n1992\n1993\n1994\n1995\n1996\n1997\n1998\n1999\n2000\n2001\n2002\n2003\n2004\n2005\n2006\n2007\n2008\n2009\n2010\n2011\n2012\n2013\n2014\n2015\n2016\n2017\n2018\n2019\n2020\n2021\n2022\n2023\n2024\n2025\n2026\n2027\n2028\n2029\n2030\n2031\n2032\n2033\n2034\n2035\n2036\n2037\n2038\n2039\n2040\n2041\n2042\n2043\n2044\n2045\n2046\n2047\n2048\n2049\n2050\n2051\n2052\n2053\n2054\n2055\n2056\n2057\n2058\n2059\n2060\n2061\n2062\n2063\n2064\n2065\n2066\n2067\n2068\n2069\n2070\n2071\n2072\n2073\n2074\n2075\n2076\n2077\n2078\n2079\n2080\n2081\n2082\n2083\n2084\n2085\n2086\n2087\n2088\n2089\n2090\n2091\n2092\n2093\n2094\n2095\n2096\n2097\n2098\n2099\n2100\n2101\n2102\n2103\n2104\n2105\n2106\n2107\n2108\n2109\n2110\n2111\n2112\n2113\n2114\n2115\n2116\n2117\n2118\n2119\n2120\n2121\n2122\n2123\n2124\n2125\n2126\n2127\n2128\n2129\n2130\n2131\n2132\n2133\n2134\n2135\n2136\n2137\n2138\n2139\n2140\n2141\n2142\n2143\n2144\n2145\n2146\n2147\n2148\n2149\n2150\n2151\n2152\n2153\n2154\n2155\n2156\n2157\n2158\n2159\n2160\n2161\n2162\n2163\n2164\n2165\n2166\n2167\n2168\n2169\n2170\n2171\n2172\n2173\n2174\n2175\n2176\n2177\n2178\n2179\n2180\n2181\n2182\n2183\n2184\n2185\n2186\n2187\n2188\n2189\n2190\n2191\n2192\n2193\n2194\n2195\n2196\n2197\n2198\n2199\n2200\n2201\n2202\n2203\n2204\n2205\n2206\n2207\n2208\n2209\n2210\n2211\n2212\n2213\n2214\n2215\n2216\n2217\n2218\n2219\n2220\n2221\n2222\n2223\n2224\n2225\n2226\n2227\n2228\n2229\n2230\n2231\n2232\n2233\n2234\n2235\n2236\n2237\n2238\n2239\n2240\n2241\n2242\n2243\n2244\n2245\n2246\n2247\n2248\n2249\n2250\n2251\n2252\n2253\n2254\n2255\n2256\n2257\n2258\n2259\n2260\n2261\n2262\n2263\n2264\n2265\n2266\n2267\n2268\n2269\n2270\n2271\n2272\n2273\n2274\n2275\n2276\n2277\n2278\n2279\n2280\n2281\n2282\n2283\n2284\n2285\n2286\n2287\n2288\n2289\n2290\n2291\n2292\n2293\n2294\n2295\n2296\n2297\n2298\n2299\n2300\n2301\n2302\n2303\n2304\n2305\n2306\n2307\n2308\n2309\n2310\n2311\n2312\n2313\n2314\n2315\n2316\n2317\n2318\n2319\n2320\n2321\n2322\n2323\n2324\n2325\n2326\n2327\n2328\n2329\n2330\n2331\n2332\n2333\n2334\n2335\n2336\n2337\n2338\n2339\n2340\n2341\n2342\n2343\n2344\n2345\n2346\n2347\n2348\n2349\n2350\n2351\n2352\n2353\n2354\n2355\n2356\n2357\n2358\n2359\n2360\n2361\n2362\n2363\n2364\n2365\n2366\n2367\n2368\n2369\n2370\n2371\n2372\n2373\n2374\n2375\n2376\n2377\n2378\n2379\n2380\n2381\n2382\n2383\n2384\n2385\n2386\n2387\n2388\n2389\n2390\n2391\n2392\n2393\n2394\n2395\n2396\n2397\n2398\n2399\n2400\n2401\n2402\n2403\n2404\n2405\n2406\n2407\n2408\n2409\n2410\n2411\n2412\n2413\n2414\n2415\n2416\n2417\n2418\n2419\n2420\n2421\n2422\n2423\n2424\n2425\n2426\n2427\n2428\n2429\n2430\n2431\n2432\n2433\n2434\n2435\n2436\n2437\n2438\n2439\n2440\n2441\n2442\n2443\n2444\n2445\n2446\n2447\n2448\n2449\n2450\n2451\n2452\n2453\n2454\n2455\n2456\n2457\n2458\n2459\n2460\n2461\n2462\n2463\n2464\n2465\n2466\n2467\n2468\n2469\n2470\n2471\n2472\n2473\n2474\n2475\n2476\n2477\n2478\n2479\n2480\n2481\n2482\n2483\n2484\n2485\n2486\n2487\n2488\n2489\n2490\n2491\n2492\n2493\n2494\n2495\n2496\n2497\n2498\n2499\n2500\n2501\n2502\n2503\n2504\n2505\n2506\n2507\n2508\n2509\n2510\n2511\n2512\n2513\n2514\n2515\n2516\n2517\n2518\n2519\n2520\n2521\n2522\n2523\n2524\n2525\n2526\n2527\n2528\n2529\n2530\n2531\n2532\n2533\n2534\n2535\n2536\n2537\n2538\n2539\n2540\n2541\n2542\n2543\n2544\n2545\n2546\n2547\n2548\n2549\n2550\n2551\n2552\n2553\n2554\n2555\n2556\n2557\n2558\n2559\n2560\n2561\n2562\n2563\n2564\n2565\n2566\n2567\n2568\n2569\n2570\n2571\n2572\n2573\n2574\n2575\n2576\n2577\n2578\n2579\n2580\n2581\n2582\n2583\n2584\n2585\n2586\n2587\n2588\n2589\n2590\n2591\n2592\n2593\n2594\n2595\n2596\n2597\n2598\n2599\n2600\n2601\n2602\n2603\n2604\n2605\n2606\n2607\n2608\n2609\n2610\n2611\n2612\n2613\n2614\n2615\n2616\n2617\n2618\n2619\n2620\n2621\n2622\n2623\n2624\n2625\n2626\n2627\n2628\n2629\n2630\n2631\n2632\n2633\n2634\n2635\n2636\n2637\n2638\n2639\n2640\n2641\n2642\n2643\n2644\n2645\n2646\n2647\n2648\n2649\n2650\n2651\n2652\n2653\n2654\n2655\n2656\n2657\n2658\n2659\n2660\n2661\n2662\n2663\n2664\n2665\n2666\n2667\n2668\n2669\n2670\n2671\n2672\n2673\n2674\n2675\n2676\n2677\n2678\n2679\n2680\n2681\n2682\n2683\n2684\n2685\n2686\n2687\n2688\n2689\n2690\n2691\n2692\n2693\n2694\n2695\n2696\n2697\n2698\n2699\n2700\n2701\n2702\n2703\n2704\n2705\n2706\n2707\n2708\n2709\n2710\n2711\n2712\n2713\n2714\n2715\n2716\n2717\n2718\n2719\n2720\n2721\n2722\n2723\n2724\n2725\n2726\n2727\n2728\n2729\n2730\n2731\n2732\n2733\n2734\n2735\n2736\n2737\n2738\n2739\n2740\n2741\n2742\n2743\n2744\n2745\n2746\n2747\n2748\n2749\n2750\n2751\n2752\n2753\n2754\n2755\n2756\n2757\n2758\n2759\n2760\n2761\n2762\n2763\n2764\n2765\n2766\n2767\n2768\n2769\n2770\n2771\n2772\n2773\n2774\n2775\n2776\n2777\n2778\n2779\n2780\n2781\n2782\n2783\n2784\n2785\n2786\n2787\n2788\n2789\n2790\n2791\n2792\n2793\n2794\n2795\n2796\n2797\n2798\n2799\n2800\n2801\n2802\n2803\n2804\n2805\n2806\n2807\n2808\n2809\n2810\n2811\n2812\n2813\n2814\n2815\n2816\n2817\n2818\n2819\n2820\n2821\n2822\n2823\n2824\n2825\n2826\n2827\n2828\n2829\n2830\n2831\n2832\n2833\n2834\n2835\n2836\n2837\n2838\n2839\n2840\n2841\n2842\n2843\n2844\n2845\n2846\n2847\n2848\n2849\n2850\n2851\n2852\n2853\n2854\n2855\n2856\n2857\n2858\n2859\n2860\n2861\n2862\n2863\n2864\n2865\n2866\n2867\n2868\n2869\n2870\n2871\n2872\n2873\n2874\n2875\n2876\n2877\n2878\n2879\n2880\n2881\n2882\n2883\n2884\n2885\n2886\n2887\n2888\n2889\n2890\n2891\n2892\n2893\n2894\n2895\n2896\n2897\n2898\n2899\n2900\n2901\n2902\n2903\n2904\n2905\n2906\n2907\n2908\n2909\n2910\n2911\n2912\n2913\n2914\n2915\n2916\n2917\n2918\n2919\n2920\n2921\n2922\n2923\n2924\n2925\n2926\n2927\n2928\n2929\n2930\n2931\n2932\n2933\n2934\n2935\n2936\n2937\n2938\n2939\n2940\n2941\n2942\n2943\n2944\n2945\n2946\n2947\n2948\n2949\n2950\n2951\n2952\n2953\n2954\n2955\n2956\n2957\n2958\n2959\n2960\n2961\n2962\n2963\n2964\n2965\n2966\n2967\n2968\n2969\n2970\n2971\n2972\n2973\n2974\n2975\n2976\n2977\n2978\n2979\n2980\n2981\n2982\n2983\n2984\n2985\n2986\n2987\n2988\n2989\n2990\n2991\n2992\n2993\n2994\n2995\n2996\n2997\n2998\n2999\n3000\n" + }, + { + "name": "shell_delai", + "tool": "bash", + "args": { + "command": "echo avant; sleep 3", + "timeout": 1 + }, + "unix": true, + "label": "echo avant; sleep 3", + "result": "[timeout après 1s — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]\n\nstdout (partiel):\navant\n" + }, + { + "name": "shell_dangereux", + "tool": "bash", + "args": { + "command": "rm -rf /" + }, + "label": "rm -rf /", + "result": "[refusé] Commande bloquée par la politique de sécurité : efface la racine du disque ou le home. Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal." + }, + { + "name": "shell_memoire", + "tool": "bash", + "args": { + "command": "cat {{MEM}}/x.md" + }, + "unix": true, + "label": "cat {{ROOT}}/memory/generale/x.md", + "result": "[refusé] pas d'accès direct au dossier {{ROOT}}/memory/generale via le shell. Utilise les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)." + }, + { + "name": "shell_code_arriere_plan", + "tool": "bash", + "args": { + "command": "sleep 1 &" + }, + "code": true, + "unix": true, + "label": "sleep 1 &", + "result": "[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash." + } + ], + "after": { + "dehors.txt": "hors du dossier\n", + "ws/a.txt": "ligne 1\nligne deux\nligne 3\n", + "ws/b.txt": "rem\n", + "ws/beaucoup.txt": "aiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\n", + "ws/bin.dat": "PK\u0000\u0001binaire", + "ws/code.txt": "c\n", + "ws/crlf.txt": "un\r\n2\r\n3\r\n", + "ws/long.txt": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\nfin\n", + "ws/neuf/n.txt": "un\ndeux\n", + "ws/node_modules/dep/index.js": "ligne cachée\n", + "ws/src/lib/notes.md": "x dans les notes\n", + "ws/src/lib/util.go": "package lib\n// x marque\n", + "ws/src/main.go": "package main\n\nfunc main() { x := 1 }\n", + "ws/vide.txt": "" + } +} diff --git a/rust/loki-rs/tests/tools.rs b/rust/loki-rs/tests/tools.rs new file mode 100644 index 0000000..f92f8b7 --- /dev/null +++ b/rust/loki-rs/tests/tools.rs @@ -0,0 +1,261 @@ +//! Parité des outils fichiers et du shell avec le Go (lot 7). +//! +//! `tests/parity_tools/outils.json` : journal produit par `execTool` +//! (`internal/loki/parity_tools_test.go`, régénéré par +//! `LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils`). La +//! même arborescence est recréée ici et les mêmes appels passent, dans le même +//! ordre, par `tools::exec_local` : libellés, résultats, diffs, questions et +//! arborescence finale doivent être ceux du Go. L'état partagé (suivi « lu +//! avant d'écrire ») est tenu par le Go en service ; ici un faux qui en +//! reprend les règles et les messages. + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, SystemTime}; + +use regex::Regex; +use serde::Deserialize; +use serde_json::{Map, Value, json}; + +use loki_rs::chat::stream::tool_call_label; +use loki_rs::chat::types::{AskEvent, DiffLine}; +use loki_rs::tools::{ToolCtx, ToolState, exec_local}; + +#[derive(Deserialize)] +struct FileFx { + path: String, + content: String, + age: u64, +} + +#[derive(Deserialize)] +struct CaseFx { + name: String, + tool: String, + args: Map, + #[serde(default)] + code: bool, + #[serde(default)] + unix: bool, + label: String, + result: String, + #[serde(default)] + diff: Vec, + #[serde(default)] + added: i64, + #[serde(default)] + removed: i64, + #[serde(default)] + ask: Option, +} + +#[derive(Deserialize)] +struct Fixture { + cwd: String, + memory_dir: String, + files: Vec, + cases: Vec, + after: BTreeMap, +} + +/// Le suivi « lu avant d'écrire » du Go (`code_tracker.go`), en mémoire. +struct FakeState { + ctx: ToolCtx, + reads: Mutex>, +} + +impl ToolState for FakeState { + async fn context(&self) -> Result { + Ok(self.ctx.clone()) + } + + async fn note_read(&self, path: &Path) { + self.reads + .lock() + .unwrap() + .insert(path.to_path_buf(), SystemTime::now()); + } + + async fn check(&self, path: &Path, _for_write: bool) -> Option { + let mtime = std::fs::metadata(path).ok()?.modified().ok()?; + let p = path.display(); + match self.reads.lock().unwrap().get(path) { + None => Some(format!( + "[refusé] {p} existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le." + )), + Some(at) if mtime > *at => Some(format!( + "[refusé] {p} a changé sur le disque depuis ta lecture. Relis-le (outil read) avant de le modifier." + )), + Some(_) => None, + } + } + + async fn wrote(&self, path: &Path) -> String { + self.note_read(path).await; + String::new() + } +} + +fn subst(v: &Value, root: &str, mem: &str) -> Value { + match v { + Value::String(s) => Value::String(s.replace("{{MEM}}", mem).replace("{{ROOT}}", root)), + Value::Object(m) => Value::Object( + m.iter() + .map(|(k, e)| (k.clone(), subst(e, root, mem))) + .collect(), + ), + Value::Array(a) => Value::Array(a.iter().map(|e| subst(e, root, mem)).collect()), + _ => v.clone(), + } +} + +fn norm(s: &str, root: &str) -> String { + static DUR: std::sync::LazyLock = std::sync::LazyLock::new(|| { + Regex::new(r"(exit: -?\d+) · [0-9.]+[a-zµ]+(?:[0-9.]+[a-zµ]+)*").unwrap() + }); + DUR.replace_all(&s.replace(root, "{{ROOT}}"), "$1 · {{DUR}}") + .into_owned() +} + +fn tree(root: &Path) -> BTreeMap { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap) { + for e in std::fs::read_dir(dir).unwrap().flatten() { + let p = e.path(); + if p.is_dir() { + walk(root, &p, out); + continue; + } + let rel = p + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if rel.starts_with("ws/") || rel == "dehors.txt" || rel == "evade.txt" { + out.insert( + rel, + String::from_utf8_lossy(&std::fs::read(&p).unwrap()).into_owned(), + ); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +#[tokio::test] +async fn outils_comme_le_go() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/parity_tools/outils.json"); + let fx: Fixture = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap(); + let root_s = root.display().to_string(); + let now = SystemTime::now(); + for f in &fx.files { + let p = root.join(&f.path); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, &f.content).unwrap(); + std::fs::File::options() + .write(true) + .open(&p) + .unwrap() + .set_modified(now - Duration::from_secs(f.age)) + .unwrap(); + } + let mem = root.join(&fx.memory_dir); + let st = FakeState { + ctx: ToolCtx { + cwd: root.join(&fx.cwd), + memory_dir: mem.clone(), + target: String::new(), + code: false, + }, + reads: Mutex::new(HashMap::new()), + }; + let mem_s = mem.display().to_string(); + let mut diffs = Vec::new(); + for c in &fx.cases { + if c.unix && cfg!(windows) { + continue; + } + let args = match subst(&Value::Object(c.args.clone()), &root_s, &mem_s) { + Value::Object(m) => m, + _ => unreachable!(), + }; + let label = tool_call_label(&c.tool, &args); + let out = exec_local(&st, c.code, &c.tool, &args, &label) + .await + .unwrap_or_else(|| panic!("{} : non exécuté ici", c.name)); + let got = json!({ + "label": norm(&label, &root_s), + "result": norm(&out.result, &root_s), + "diff": out.diff, + "added": out.added, + "removed": out.removed, + "ask": out.ask, + }); + let want = json!({ + "label": c.label, + "result": c.result, + "diff": c.diff, + "added": c.added, + "removed": c.removed, + "ask": c.ask, + }); + if got != want { + diffs.push(format!("{} :\n obtenu {got}\n attendu {want}", c.name)); + } + } + let after = tree(&root); + if after != fx.after { + diffs.push(format!( + "arborescence finale :\n obtenu {after:?}\n attendu {:?}", + fx.after + )); + } + assert!( + diffs.is_empty(), + "{} écart(s) avec le Go :\n\n{}", + diffs.len(), + diffs.join("\n\n") + ); +} + +/// Outils non portés, ou cible distante : c'est au Go de les exécuter. +#[tokio::test] +async fn le_reste_va_au_go() { + let st = FakeState { + ctx: ToolCtx { + cwd: PathBuf::from("/nulle-part"), + memory_dir: PathBuf::from("/nulle-part/memory"), + target: "poste".into(), + code: false, + }, + reads: Mutex::new(HashMap::new()), + }; + let a = |v: Value| v.as_object().unwrap().clone(); + for (name, args) in [ + ("mem_read", json!({"file":"x"})), + ("web_search", json!({"query":"x"})), + ("write", json!({"file":"a","content":"x"})), + ("edit", json!({"file":"a","old":"x","new":"y"})), + ("bash", json!({"command":"ls"})), + ] { + let args = a(args); + let label = tool_call_label(name, &args); + assert!( + exec_local(&st, false, name, &args, &label).await.is_none(), + "{name}" + ); + } + // Une commande catastrophique est refusée ici, cible distante comprise. + let args = a(json!({"command":"rm -rf /"})); + let out = exec_local(&st, false, "bash", &args, "rm -rf /") + .await + .unwrap(); + assert!(out.result.starts_with("[refusé] Commande bloquée")); + // read/grep/glob lisent toujours cette machine. + let args = a(json!({"pattern":"*"})); + assert!(exec_local(&st, false, "glob", &args, "*").await.is_some()); +}