// plugin_tool_test.go - Declarative shell tool 测试. // // 跨平台说明: 本测试使用 /bin/sh + POSIX shell 命令. Windows CI 会 skip, // 因为 Flyto 目标平台是 Linux/macOS 优先 + WSL 的 Windows 支持. package plugin import ( "context" "encoding/json" "errors" "os" "path/filepath" "runtime" "strings" "testing" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // skipOnWindows 让单个测试在 Windows 环境下跳过 (因为用了 /bin/sh). func skipOnWindows(t *testing.T) { t.Helper() if runtime.GOOS == "windows" { t.Skip("test uses /bin/sh, not available on Windows (Flyto Windows users run in WSL)") } } // TestPluginShellTool_HappyPath 验证最基础的执行路径: sh -c 输出 hello, 捕获 stdout. func TestPluginShellTool_HappyPath(t *testing.T) { skipOnWindows(t) def := PluginToolDef{ Name: "greet", Description: "returns hello", Command: "/bin/sh", Args: []string{"-c", "printf hello"}, } tool := NewPluginShellTool(def, t.TempDir(), "myplugin", execenv.DefaultExecutor{}) res, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatalf("Execute: %v", err) } if res == nil { t.Fatal("nil result") } if res.IsError { t.Errorf("unexpected IsError=true: %+v", res) } if res.Output != "hello" { t.Errorf("expected %q, got %q", "hello", res.Output) } } // TestPluginShellTool_Namespacing 验证 tool 名被正确命名空间化. func TestPluginShellTool_Namespacing(t *testing.T) { def := PluginToolDef{Name: "foo", Command: "/bin/true"} tool := NewPluginShellTool(def, "/tmp", "myplugin", execenv.DefaultExecutor{}) if got := tool.Name(); got != "myplugin:foo" { t.Errorf("expected namespaced name %q, got %q", "myplugin:foo", got) } // 空 pluginName 时不加前缀 tool2 := NewPluginShellTool(def, "/tmp", "", execenv.DefaultExecutor{}) if got := tool2.Name(); got != "foo" { t.Errorf("expected bare name %q, got %q", "foo", got) } } // TestPluginShellTool_ExitCodeNonZero 验证非零 exit code 转换为 IsError Result. func TestPluginShellTool_ExitCodeNonZero(t *testing.T) { skipOnWindows(t) def := PluginToolDef{ Name: "fail", Command: "/bin/sh", Args: []string{"-c", "echo oops >&2; exit 7"}, } tool := NewPluginShellTool(def, t.TempDir(), "test", execenv.DefaultExecutor{}) res, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatalf("unexpected exec error (non-zero exit should be Result not error): %v", err) } if !res.IsError { t.Error("expected IsError=true for non-zero exit") } if !strings.Contains(res.Output, "exit code 7") { t.Errorf("expected exit code 7 in output, got %q", res.Output) } if !strings.Contains(res.Output, "oops") { t.Errorf("expected stderr 'oops' in output, got %q", res.Output) } } // TestPluginShellTool_Timeout 验证超时场景返回 IsError Result 且包含 timeout 字样. func TestPluginShellTool_Timeout(t *testing.T) { skipOnWindows(t) def := PluginToolDef{ Name: "slow", Command: "/bin/sh", Args: []string{"-c", "sleep 5"}, TimeoutSeconds: 1, // 超过 1 秒就杀 } tool := NewPluginShellTool(def, t.TempDir(), "test", execenv.DefaultExecutor{}) start := time.Now() res, err := tool.Execute(context.Background(), nil, nil) elapsed := time.Since(start) if err != nil { t.Fatalf("timeout should return IsError Result not error, got %v", err) } if !res.IsError { t.Error("expected IsError=true for timeout") } if !strings.Contains(res.Output, "timed out") { t.Errorf("expected 'timed out' in output, got %q", res.Output) } // 超时应该接近 1s, 远小于 5s (证明 SIGKILL 生效) if elapsed > 3*time.Second { t.Errorf("timeout too slow: took %s (expected ~1s)", elapsed) } } // TestPluginShellTool_EnvInjection 验证 plugin 声明的 env 变量能被子进程读到. func TestPluginShellTool_EnvInjection(t *testing.T) { skipOnWindows(t) def := PluginToolDef{ Name: "env_test", Command: "/bin/sh", Args: []string{"-c", "printf $FLYTO_TEST_KEY"}, Env: map[string]string{"FLYTO_TEST_KEY": "secret-value"}, } tool := NewPluginShellTool(def, t.TempDir(), "test", execenv.DefaultExecutor{}) res, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatal(err) } if res.Output != "secret-value" { t.Errorf("expected 'secret-value', got %q", res.Output) } } // TestPluginShellTool_StdinJSON 验证 input JSON 被传入子进程 stdin. func TestPluginShellTool_StdinJSON(t *testing.T) { skipOnWindows(t) def := PluginToolDef{ Name: "echo_stdin", Command: "/bin/sh", Args: []string{"-c", "cat"}, // cat echoes stdin } tool := NewPluginShellTool(def, t.TempDir(), "test", execenv.DefaultExecutor{}) input := json.RawMessage(`{"msg":"hello world"}`) res, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatal(err) } if res.Output != `{"msg":"hello world"}` { t.Errorf("expected stdin echoed back, got %q", res.Output) } } // TestPluginShellTool_WorkDirDefault 验证默认 WorkDir 是 pluginDir. func TestPluginShellTool_WorkDirDefault(t *testing.T) { skipOnWindows(t) pluginDir := t.TempDir() // 在 plugin 目录写一个 marker 文件 if err := os.WriteFile(filepath.Join(pluginDir, "marker.txt"), []byte("found"), 0o644); err != nil { t.Fatal(err) } def := PluginToolDef{ Name: "read_marker", Command: "/bin/sh", Args: []string{"-c", "cat marker.txt"}, } tool := NewPluginShellTool(def, pluginDir, "test", execenv.DefaultExecutor{}) res, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatal(err) } if res.Output != "found" { t.Errorf("expected pluginDir as default WorkDir, got %q", res.Output) } } // TestPluginShellTool_WorkDirRelative 验证相对 WorkDir 被解析为 pluginDir/sub. func TestPluginShellTool_WorkDirRelative(t *testing.T) { skipOnWindows(t) pluginDir := t.TempDir() subDir := filepath.Join(pluginDir, "bin") if err := os.MkdirAll(subDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(subDir, "data.txt"), []byte("sub"), 0o644); err != nil { t.Fatal(err) } def := PluginToolDef{ Name: "read_data", Command: "/bin/sh", Args: []string{"-c", "cat data.txt"}, WorkDir: "bin", // 相对 pluginDir } tool := NewPluginShellTool(def, pluginDir, "test", execenv.DefaultExecutor{}) res, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatal(err) } if res.Output != "sub" { t.Errorf("expected relative WorkDir resolved, got %q", res.Output) } } // TestPluginShellTool_CommandNotFound 验证 command 不存在时返回 error (而非 Result). func TestPluginShellTool_CommandNotFound(t *testing.T) { def := PluginToolDef{ Name: "missing", Command: "/nonexistent/binary/that/doesnt/exist/anywhere", } tool := NewPluginShellTool(def, t.TempDir(), "test", execenv.DefaultExecutor{}) _, err := tool.Execute(context.Background(), nil, nil) if err == nil { t.Fatal("expected exec error for nonexistent command") } // 确认 error 消息包含 tool name if !strings.Contains(err.Error(), "missing") { t.Errorf("expected tool name 'missing' in error, got %v", err) } } // TestPluginShellTool_Metadata 验证 Metadata() 返回声明的字段值. func TestPluginShellTool_Metadata(t *testing.T) { def := PluginToolDef{ Name: "tool", Command: "/bin/true", ConcurrencySafe: true, ReadOnly: true, Destructive: false, PermissionClass: "readonly", } tool := NewPluginShellTool(def, "/tmp", "test", execenv.DefaultExecutor{}) // Must implement MetadataProvider mp, ok := tool.(tools.MetadataProvider) if !ok { t.Fatal("pluginShellTool should implement tools.MetadataProvider") } md := mp.Metadata() if !md.ConcurrencySafe { t.Error("expected ConcurrencySafe=true") } if !md.ReadOnly { t.Error("expected ReadOnly=true") } if md.Destructive { t.Error("expected Destructive=false") } if md.PermissionClass != "readonly" { t.Errorf("expected PermissionClass=readonly, got %q", md.PermissionClass) } } // TestPluginShellTool_MetadataDefaultPermClass 验证空 PermissionClass 默认为 "bash". func TestPluginShellTool_MetadataDefaultPermClass(t *testing.T) { def := PluginToolDef{ Name: "tool", Command: "/bin/true", // PermissionClass 留空 } tool := NewPluginShellTool(def, "/tmp", "test", execenv.DefaultExecutor{}) mp := tool.(tools.MetadataProvider) if mp.Metadata().PermissionClass != "bash" { t.Errorf("expected default 'bash', got %q", mp.Metadata().PermissionClass) } } // TestPluginShellTool_DefaultSchema 验证未声明 InputSchema 时使用 emptyObjectSchema. func TestPluginShellTool_DefaultSchema(t *testing.T) { def := PluginToolDef{Name: "tool", Command: "/bin/true"} tool := NewPluginShellTool(def, "/tmp", "test", execenv.DefaultExecutor{}) schema := tool.InputSchema() if !strings.Contains(string(schema), `"type":"object"`) { t.Errorf("expected default object schema, got %q", schema) } } // TestPluginShellTool_CustomSchema 验证声明的 InputSchema 被原样保留. func TestPluginShellTool_CustomSchema(t *testing.T) { custom := json.RawMessage(`{"type":"object","properties":{"x":{"type":"number"}},"required":["x"]}`) def := PluginToolDef{ Name: "tool", Command: "/bin/true", InputSchema: custom, } tool := NewPluginShellTool(def, "/tmp", "test", execenv.DefaultExecutor{}) if string(tool.InputSchema()) != string(custom) { t.Errorf("custom schema not preserved: %q", tool.InputSchema()) } } // TestLoadPluginToolsSkipInvalid 验证 loadPluginTools 跳过空 Name 或空 Command 的声明. func TestLoadPluginToolsSkipInvalid(t *testing.T) { defs := []PluginToolDef{ {Name: "valid", Command: "/bin/true"}, {Name: "", Command: "/bin/true"}, // 空 name, 跳过 {Name: "nocmd", Command: ""}, // 空 command, 跳过 {Name: "also_valid", Command: "/bin/false"}, } out := loadPluginTools(defs, "/tmp", "p", execenv.DefaultExecutor{}) if len(out) != 2 { t.Errorf("expected 2 valid tools, got %d", len(out)) } if out[0].Name() != "p:valid" || out[1].Name() != "p:also_valid" { t.Errorf("unexpected names: %q %q", out[0].Name(), out[1].Name()) } } // TestLoadPluginToolsEmpty 验证空输入返回 nil. func TestLoadPluginToolsEmpty(t *testing.T) { if out := loadPluginTools(nil, "/tmp", "p", execenv.DefaultExecutor{}); out != nil { t.Errorf("expected nil, got %v", out) } if out := loadPluginTools([]PluginToolDef{}, "/tmp", "p", execenv.DefaultExecutor{}); out != nil { t.Errorf("expected nil for empty slice, got %v", out) } } // TestPluginShellTool_ContextCancel 验证外部 ctx 取消会终止子进程. func TestPluginShellTool_ContextCancel(t *testing.T) { skipOnWindows(t) def := PluginToolDef{ Name: "cancellable", Command: "/bin/sh", Args: []string{"-c", "sleep 10"}, TimeoutSeconds: 30, // 长超时, 靠外部 ctx 取消 } tool := NewPluginShellTool(def, t.TempDir(), "test", execenv.DefaultExecutor{}) ctx, cancel := context.WithCancel(context.Background()) // 100ms 后取消 go func() { time.Sleep(100 * time.Millisecond) cancel() }() start := time.Now() _, err := tool.Execute(ctx, nil, nil) elapsed := time.Since(start) // 外部 cancel 会让 cmd.Run 返回 signal: killed 类错误, 不是超时也不是 exit error // Execute 当前实现会把这个 wrap 成 exec failed error 或 IsError Result // (取决于 errors.As 对 ExitError 的行为) if elapsed > 2*time.Second { t.Errorf("context cancel too slow: took %s", elapsed) } _ = err // 接受两种行为, 主要验证快速返回 } // TestPluginShellTool_InterfaceCompile 编译时确认 pluginShellTool 实现 tools.Tool. func TestPluginShellTool_InterfaceCompile(t *testing.T) { var _ tools.Tool = (*pluginShellTool)(nil) var _ tools.MetadataProvider = (*pluginShellTool)(nil) } // TestPluginShellTool_NoOsEnvLeak 验证 Flyto 主进程的敏感 env 不会泄漏到 plugin // tool 子进程. 这是 A1 安全修复的核心测试 - 没有这个测试, 整个 env 隔离机制 // 是"声称的保护"而非"验证过的保护". func TestPluginShellTool_NoOsEnvLeak(t *testing.T) { skipOnWindows(t) // 注入一个模拟"Flyto 主进程密钥" - 和真实的 ANTHROPIC_API_KEY 类似 // t.Setenv 会在测试结束时自动清理, 不污染其他测试 t.Setenv("FLYTO_SECRET_TEST_XYZ_DO_NOT_LEAK", "supersecret-should-never-appear-in-subprocess") def := PluginToolDef{ Name: "env_leak_canary", Command: "/bin/sh", // 如果 subprocess 能看到这个 env, printf 会输出 "supersecret-..." // 看不到则输出空字符串 (变量未定义展开为空) Args: []string{"-c", `printf "%s" "$FLYTO_SECRET_TEST_XYZ_DO_NOT_LEAK"`}, } tool := NewPluginShellTool(def, t.TempDir(), "test", execenv.DefaultExecutor{}) res, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatal(err) } if res.Output != "" { t.Errorf("SECURITY: Flyto 主进程 env 泄漏到 plugin subprocess: got %q (expected empty)", res.Output) } } // TestPluginShellTool_PathLookupStillWorks 验证白名单包含 PATH, 非绝对路径 // 命令 (如 "sh" / "python") 仍能通过 PATH 查找执行. 如果 A1 修复搞错把 PATH // 一起过滤掉, 本测试会失败于 "command not found". func TestPluginShellTool_PathLookupStillWorks(t *testing.T) { skipOnWindows(t) def := PluginToolDef{ Name: "path_lookup_canary", Command: "sh", // 非绝对路径, 依赖 PATH 查找 Args: []string{"-c", "printf ok"}, } tool := NewPluginShellTool(def, t.TempDir(), "test", execenv.DefaultExecutor{}) res, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatalf("PATH 查找失败, env 白名单可能漏了 PATH: %v", err) } if res.Output != "ok" { t.Errorf("expected 'ok', got %q", res.Output) } } // TestPluginShellTool_PluginEnvOverridesDefault 验证 plugin 声明的 Env 能覆盖 // 白名单的默认值 (e.g. plugin 声明 HOME=/custom 会盖掉 OS 的 HOME). func TestPluginShellTool_PluginEnvOverridesDefault(t *testing.T) { skipOnWindows(t) def := PluginToolDef{ Name: "home_override", Command: "/bin/sh", Args: []string{"-c", `printf "%s" "$HOME"`}, Env: map[string]string{"HOME": "/custom/home/path"}, } tool := NewPluginShellTool(def, t.TempDir(), "test", execenv.DefaultExecutor{}) res, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatal(err) } if res.Output != "/custom/home/path" { t.Errorf("plugin Env 未覆盖默认 HOME: got %q, expected /custom/home/path", res.Output) } } // TestHostGetAllTools_EndToEnd 验证 Plugin → Host.GetAllTools() 的完整路径: // LoadFromDir 加载一个含 tools 字段的 plugin.json, Host.GetAllTools() 返回 // 命名空间化后的 tools.Tool 实例. 这是 A3 集成修复的关键测试 - 证明 plugin // 端的数据流在 Host 层是通的 (engine 层的 syncPluginTools 由 engine 单测覆盖). func TestHostGetAllTools_EndToEnd(t *testing.T) { dir := t.TempDir() manifest := `{ "name": "myplugin", "version": "0.1.0", "description": "test plugin with declarative tool", "tools": [ {"name":"hello","description":"say hello","command":"/bin/echo","args":["hi"]}, {"name":"ping","description":"ping host","command":"/bin/echo","args":["pong"]} ] }` if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(manifest), 0o644); err != nil { t.Fatal(err) } h := NewHost(execenv.DefaultExecutor{}) if err := h.LoadFromDir(dir); err != nil { t.Fatalf("LoadFromDir: %v", err) } allTools := h.GetAllTools() if len(allTools) != 2 { t.Fatalf("expected 2 tools from plugin, got %d", len(allTools)) } // 按名字建立 set 便于无序断言 names := make(map[string]bool) for _, tool := range allTools { names[tool.Name()] = true } if !names["myplugin:hello"] { t.Errorf("missing tool 'myplugin:hello' in %v", names) } if !names["myplugin:ping"] { t.Errorf("missing tool 'myplugin:ping' in %v", names) } // 禁用 plugin, GetAllTools 应返回空 if err := h.Disable("myplugin"); err != nil { t.Fatal(err) } if len(h.GetAllTools()) != 0 { t.Error("disabled plugin should not contribute tools to GetAllTools") } } // TestPluginShellTool_ExitErrorCheck 辅助验证 errors.As 对 exec.ExitError 的识别. // 主要是抓 Execute 内部的分类逻辑正确. func TestPluginShellTool_ExitErrorCheck(t *testing.T) { var ee interface{ ExitCode() int } err := errors.New("not an exit error") _ = errors.As(err, &ee) // 本测试只是 compile check + 防止 errors.As 用法漂移 _ = ee }