Why: the full-agent batch (Agent-Native PR #3, Agent-Squad PR #3) made roles capable of declaring model/tools/skills/bash execution, but the product composition root had no path to register the kernel backend or inject search/exec/skills config. Evidence: go vet ./... clean; go test ./... 19 packages ok. New cases: buildBackends registers agent-native only when composition-root options are enabled (default backend stays agent-system, per the parity progressive-enablement clause) and resolves explicit Backend:agent-native roles; InterpreterExecConfigForKernel validates absolute interpreter paths and the env whitelist reused from the product exec tools. Impact: deps/agent-native pinned to f9ab2ef and deps/agent-squad to 9a402e4; app.go resolves settings.models.chat, tools.tavily.*, runtime.exec, and <data>/skills into squad.AgentNativeConfig per run; squadadapter exposes the AgentNativeOptions hook and buildBackends. Refs: Dev task feature/full-agent-r1 batch B10.
162 lines
5.1 KiB
Go
162 lines
5.1 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
type execFake struct {
|
|
root string
|
|
owner string
|
|
conv string
|
|
execOn bool
|
|
settings map[string]bool
|
|
ctx context.Context
|
|
}
|
|
|
|
func (c *execFake) ProjectID() string { return "p1" }
|
|
func (c *execFake) ConversationID() string { return c.conv }
|
|
func (c *execFake) OwnerID() string { return c.owner }
|
|
func (c *execFake) WorkspaceRoot() string { return c.root }
|
|
func (c *execFake) Context() context.Context {
|
|
if c.ctx != nil {
|
|
return c.ctx
|
|
}
|
|
return context.Background()
|
|
}
|
|
func (c *execFake) SettingBool(key string, def bool) bool {
|
|
if c.settings != nil {
|
|
if v, ok := c.settings[key]; ok {
|
|
return v
|
|
}
|
|
}
|
|
if key == execSetting {
|
|
return c.execOn
|
|
}
|
|
return def
|
|
}
|
|
func (c *execFake) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
|
|
func (c *execFake) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
|
return core.Artifact{ID: "art_" + name, Name: name, Kind: kind, MIME: mime, Size: int64(len(content))}, nil
|
|
}
|
|
func (c *execFake) PeerConversations() ([]core.Conversation, error) { return nil, nil }
|
|
func (c *execFake) PeerConversationMessages(string, int) ([]core.Message, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func newInterpRegistry(t *testing.T) *Registry {
|
|
t.Helper()
|
|
r := NewRegistry()
|
|
if err := RegisterInterpreterTools(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func interpCall(t *testing.T, r *Registry, rc *execFake, name, args string) core.ToolCallResult {
|
|
t.Helper()
|
|
return r.Execute(rc, core.ToolCallRequest{CallID: "c1", Name: name, Arguments: json.RawMessage(args)})
|
|
}
|
|
|
|
func TestRunShellEchoKeepsScript(t *testing.T) {
|
|
r := newInterpRegistry(t)
|
|
root := t.TempDir()
|
|
rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true}
|
|
const script = "echo hello-from-shell\n"
|
|
res := interpCall(t, r, rc, "run_shell", `{"script":"echo hello-from-shell\n"}`)
|
|
if res.Status != "ok" || !strings.Contains(res.Output, "exit=0") || !strings.Contains(res.Output, "hello-from-shell") {
|
|
t.Fatalf("echo script: %s %s", res.Status, res.Output)
|
|
}
|
|
dir := filepath.Join(root, "o1", "c1", execScriptDir)
|
|
ents, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ents) != 1 {
|
|
t.Fatalf("script should be retained for audit, got %d files", len(ents))
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(dir, ents[0].Name()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(b) != script {
|
|
t.Fatalf("script bytes mismatch: %q", b)
|
|
}
|
|
}
|
|
|
|
func TestRunShellCommandAlias(t *testing.T) {
|
|
r := newInterpRegistry(t)
|
|
rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true}
|
|
res := interpCall(t, r, rc, "run_shell", `{"command":"echo aliased"}`)
|
|
if res.Status != "ok" || !strings.Contains(res.Output, "aliased") {
|
|
t.Fatalf("command alias: %s %s", res.Status, res.Output)
|
|
}
|
|
}
|
|
|
|
func TestRunShellEmptyAndGate(t *testing.T) {
|
|
r := newInterpRegistry(t)
|
|
rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true}
|
|
res := interpCall(t, r, rc, "run_shell", `{"script":" "}`)
|
|
if res.Status != "error" || !strings.Contains(res.Output, "script required") {
|
|
t.Fatalf("empty script: %s %s", res.Status, res.Output)
|
|
}
|
|
rc.execOn = false
|
|
res = interpCall(t, r, rc, "run_shell", `{"script":"echo no"}`)
|
|
if res.Status != "error" || !strings.Contains(res.Output, "runtime.exec") {
|
|
t.Fatalf("gate off: %s %s", res.Status, res.Output)
|
|
}
|
|
}
|
|
|
|
func TestRunShellTimeoutKillsProcessGroup(t *testing.T) {
|
|
r := newInterpRegistry(t)
|
|
rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true}
|
|
res := interpCall(t, r, rc, "run_shell", `{"timeout_sec":1,"script":"sleep 30 &\nwait\n"}`)
|
|
if res.Status != "ok" || !strings.Contains(res.Output, "exit=timeout(1s)") {
|
|
t.Fatalf("timeout: %s %s", res.Status, res.Output)
|
|
}
|
|
}
|
|
|
|
func TestRunPythonWritesZipMagic(t *testing.T) {
|
|
r := newInterpRegistry(t)
|
|
root := t.TempDir()
|
|
rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true}
|
|
res := interpCall(t, r, rc, "run_python", `{"script":"open('note.docx','wb').write(b'PK\\x03\\x04hello')\n"}`)
|
|
if res.Status != "ok" || !strings.Contains(res.Output, "exit=0") {
|
|
t.Fatalf("python: %s %s", res.Status, res.Output)
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(root, "o1", "c1", "note.docx"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(b) < 4 || string(b[:4]) != "PK\x03\x04" {
|
|
t.Fatalf("docx magic: %q", b)
|
|
}
|
|
}
|
|
|
|
func TestInterpreterExecConfigForKernel(t *testing.T) {
|
|
cfg := InterpreterExecConfig()
|
|
if cfg == nil {
|
|
t.Fatal("host has bash/python3; kernel exec config must resolve")
|
|
}
|
|
if cfg.Bash == "" && cfg.Python == "" {
|
|
t.Fatal("at least one interpreter path must be set")
|
|
}
|
|
if !filepath.IsAbs(cfg.Bash) && cfg.Bash != "" {
|
|
t.Fatalf("bash path must be absolute, got %q", cfg.Bash)
|
|
}
|
|
for _, kv := range cfg.Env {
|
|
if strings.HasPrefix(kv, "PATH=") || strings.HasPrefix(kv, "HOME=") || strings.HasPrefix(kv, "LANG=") ||
|
|
strings.HasPrefix(kv, "LC_") || strings.HasPrefix(kv, "TZ=") || strings.HasPrefix(kv, "TERM=") ||
|
|
kv == "PYTHONDONTWRITEBYTECODE=1" {
|
|
continue
|
|
}
|
|
t.Fatalf("env whitelist violated: %q", kv)
|
|
}
|
|
}
|