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()); +}