127 lines
4.5 KiB
Go
127 lines
4.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sysagent/internal/project"
|
|
)
|
|
|
|
func TestInsecureProjectRefused(t *testing.T) {
|
|
dir := setupProject(t, "http://127.0.0.1:1", map[string]string{"agent.md": agentMD})
|
|
os.Chmod(filepath.Join(dir, "agent.md"), 0o666)
|
|
_, err := Bootstrap(context.Background(), Options{Root: dir, Stdout: io.Discard, Stderr: io.Discard, Version: "test"})
|
|
if err == nil || !strings.Contains(err.Error(), "writable by any user") {
|
|
t.Fatalf("expected refusal, got %v", err)
|
|
}
|
|
// El fallo de arranque libera el bloqueo: tras corregirlo, arranca.
|
|
os.Chmod(filepath.Join(dir, "agent.md"), 0o644)
|
|
a, err := Bootstrap(context.Background(), Options{Root: dir, Stdout: io.Discard, Stderr: io.Discard, Version: "test"})
|
|
if err != nil {
|
|
t.Fatalf("after fixing permissions: %v", err)
|
|
}
|
|
a.Close()
|
|
}
|
|
|
|
func TestSingleInstanceLock(t *testing.T) {
|
|
dir := setupProject(t, "http://127.0.0.1:1", map[string]string{"agent.md": agentMD})
|
|
a, err := Bootstrap(context.Background(), Options{Root: dir, Stdout: io.Discard, Stderr: io.Discard, Version: "test"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = Bootstrap(context.Background(), Options{Root: dir, Stdout: io.Discard, Stderr: io.Discard, Version: "test"})
|
|
if !errors.Is(err, project.ErrLocked) {
|
|
t.Errorf("second instance: %v", err)
|
|
}
|
|
a.Close()
|
|
b, err := Bootstrap(context.Background(), Options{Root: dir, Stdout: io.Discard, Stderr: io.Discard, Version: "test"})
|
|
if err != nil {
|
|
t.Fatalf("after Close the lock must be free: %v", err)
|
|
}
|
|
b.Close()
|
|
}
|
|
|
|
func TestCLISecretNeverReachesModelOrDisk(t *testing.T) {
|
|
const secret = "Sup3r-S3cret-Pw!"
|
|
m := &scriptedModel{reply: func(n int, req map[string]any) (int, any) {
|
|
switch n {
|
|
case 1:
|
|
return 200, toolCall("s1", "ask_secret", map[string]any{"name": "dbpw", "prompt": "Password de admin en db01"})
|
|
case 2:
|
|
return 200, toolCall("s2", "exec", map[string]any{"command": "printf", "args": []string{"pw=%s", "{{secret:dbpw}}"}})
|
|
default:
|
|
return 200, text("Hecho con la credencial.")
|
|
}
|
|
}}
|
|
srv := m.server(t)
|
|
defer srv.Close()
|
|
dir := setupProject(t, srv.URL, map[string]string{"agent.md": agentMD})
|
|
|
|
// La segunda línea es la contraseña: la consume ask_secret, no el chat.
|
|
a, out := bootstrap(t, dir, true, "conéctate a db01\n"+secret+"\n/secrets\n/quit\n")
|
|
if err := a.RunCLI(context.Background(), make(chan os.Signal)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.Close()
|
|
|
|
if !strings.Contains(out.String(), "Password de admin en db01") || !strings.Contains(out.String(), "dbpw -> use as {{secret:dbpw}}") {
|
|
t.Errorf("output:\n%s", out.String())
|
|
}
|
|
all, _ := json.Marshal(m.requests)
|
|
if strings.Contains(string(all), secret) {
|
|
t.Fatal("secret was sent to the model")
|
|
}
|
|
if !strings.Contains(string(all), "pw={{secret:dbpw}}") {
|
|
t.Error("model should see the masked command output")
|
|
}
|
|
for _, f := range []string{"agent.log", "contexto.mem", ".context-history", "memoria.md"} {
|
|
data, _ := os.ReadFile(filepath.Join(dir, f))
|
|
if strings.Contains(string(data), secret) {
|
|
t.Errorf("secret persisted in %s", f)
|
|
}
|
|
}
|
|
hist, _ := os.ReadFile(filepath.Join(dir, ".context-history"))
|
|
if !strings.Contains(string(hist), "conéctate a db01") {
|
|
t.Errorf("history not recorded: %q", hist)
|
|
}
|
|
if st, _ := os.Stat(filepath.Join(dir, ".context-history")); st.Mode().Perm() != 0o600 {
|
|
t.Errorf("history perm = %o", st.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func TestTaskModeCannotAskSecretsAndReportsConflicts(t *testing.T) {
|
|
m := &scriptedModel{reply: func(n int, req map[string]any) (int, any) {
|
|
if n == 1 {
|
|
return 200, toolCall("s1", "ask_secret", map[string]any{"name": "x", "prompt": "pw"})
|
|
}
|
|
return 200, text("No hay operador.\nCONFLICT: 01-a.md, 02-b.md - una abre el puerto 80 y la otra lo cierra\nTASK_STATUS: BLOCKED")
|
|
}}
|
|
srv := m.server(t)
|
|
defer srv.Close()
|
|
dir := setupProject(t, srv.URL, map[string]string{"agent.md": agentMD, "01-a.md": "tarea"})
|
|
a, _ := bootstrap(t, dir, false, "")
|
|
if err := a.RunTasks(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
msgs := messages(m.requests[1])
|
|
if !strings.Contains(msgs[len(msgs)-1]["content"].(string), "no interactive operator") {
|
|
t.Error("ask_secret in task mode must be refused")
|
|
}
|
|
if !strings.Contains(msgs[0]["content"].(string), "Tareas de esta ejecución, en orden: 01-a.md") {
|
|
t.Error("task list missing from system prompt")
|
|
}
|
|
found := false
|
|
for _, j := range a.state.Journal {
|
|
found = found || j.Kind == "conflict" && strings.Contains(j.Text, "puerto 80")
|
|
}
|
|
if !found {
|
|
t.Error("conflict not recorded in the journal")
|
|
}
|
|
}
|