1package agent
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7
8 "github.com/bnema/zut/packages/agent/tools"
9 "github.com/bnema/zut/packages/core"
10)
11
12func pythonTestEnv(t *testing.T) (project string) {
13 t.Helper()
14 root := t.TempDir()
15 t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
16 t.Setenv("HOME", filepath.Join(root, "home"))
17 t.Setenv("USERPROFILE", filepath.Join(root, "home"))
18 project = filepath.Join(root, "project")
19 if err := os.MkdirAll(project, 0o755); err != nil {
20 t.Fatal(err)
21 }
22 return project
23}
24
25func pythonTestArgs(project string) Args {
26 return Args{
27 Provider: "ollama",
28 Model: "any-local-model",
29 CWD: project,
30 NoLSP: true,
31 NoSkill: true,
32 NoContextFiles: true,
33 }
34}
35
36func resolvePython(t *testing.T, args Args) Resolved {
37 t.Helper()
38 resolved, err := Resolve(args, false)
39 if err != nil {
40 t.Fatalf("Resolve: %v", err)
41 }
42 return resolved
43}
44
45func pythonToolFrom(t *testing.T, r Resolved) *tools.PythonTool {
46 t.Helper()
47 tool, ok := r.ToolRegistry["python"]
48 if !ok {
49 t.Fatal("python not registered")
50 }
51 py, ok := tool.(*tools.PythonTool)
52 if !ok {
53 t.Fatalf("python = %T, want *tools.PythonTool", tool)
54 }
55 return py
56}
57
58func TestPythonRegisteredByDefault(t *testing.T) {
59 project := pythonTestEnv(t)
60 resolved := resolvePython(t, pythonTestArgs(project))
61 py := pythonToolFrom(t, resolved)
62 if py.CWD != project {
63 t.Fatalf("python CWD = %q, want %q", py.CWD, project)
64 }
65 if py.Interpreter != "" {
66 t.Fatalf("python Interpreter = %q, want empty default", py.Interpreter)
67 }
68 if py.Sandbox != resolved.Sandbox {
69 t.Fatal("python must share the live sandbox pointer")
70 }
71}
72
73func TestPythonExplicitEmptySelectionDenied(t *testing.T) {
74 project := pythonTestEnv(t)
75 args := pythonTestArgs(project)
76 args.ToolsSet = true
77 args.Tools = []string{}
78 resolved := resolvePython(t, args)
79 if _, ok := resolved.ToolRegistry["python"]; ok {
80 t.Fatal("explicit empty selection must not enable python")
81 }
82 // Other tools retain their historical empty-list behavior.
83 if _, ok := resolved.ToolRegistry["bash"]; !ok {
84 t.Fatal("explicit empty selection must preserve other tools' behavior")
85 }
86}
87
88func TestPythonExplicitInclusionExclusion(t *testing.T) {
89 project := pythonTestEnv(t)
90 included := pythonTestArgs(project)
91 included.Tools = []string{"python"}
92 resolved := resolvePython(t, included)
93 if _, ok := resolved.ToolRegistry["python"]; !ok {
94 t.Fatal("explicit python selection must register python")
95 }
96 if _, ok := resolved.ToolRegistry["bash"]; ok {
97 t.Fatal("explicit python-only selection must not register bash")
98 }
99
100 excluded := pythonTestArgs(project)
101 excluded.Tools = []string{"bash"}
102 resolved = resolvePython(t, excluded)
103 if _, ok := resolved.ToolRegistry["python"]; ok {
104 t.Fatal("bash-only selection must not register python")
105 }
106 if _, ok := resolved.ToolRegistry["bash"]; !ok {
107 t.Fatal("bash-only selection must register bash")
108 }
109}
110
111func TestPythonNoToolsAndPackagedDenial(t *testing.T) {
112 project := pythonTestEnv(t)
113 args := pythonTestArgs(project)
114 args.NoTools = true
115 if resolved := resolvePython(t, args); len(resolved.ToolRegistry) != 0 {
116 t.Fatalf("NoTools registry = %d tools, want empty", len(resolved.ToolRegistry))
117 }
118
119 args = pythonTestArgs(project)
120 args.PermissionSet = &tools.PermissionSet{}
121 resolved := resolvePython(t, args)
122 if _, ok := resolved.ToolRegistry["python"]; ok {
123 t.Fatal("packaged agents must not receive python")
124 }
125
126 // SDK-shaped selections: nil/empty without provenance keeps the default.
127 if !pythonToolAllowed(Args{}) {
128 t.Fatal("omitted selection must allow python")
129 }
130 if pythonToolAllowed(Args{ToolsSet: true}) {
131 t.Fatal("explicit empty selection must deny python")
132 }
133 if !pythonToolAllowed(Args{Tools: []string{"bash", "python"}}) {
134 t.Fatal("explicit inclusion must allow python")
135 }
136}
137
138func TestPythonSummaryPresenceAbsence(t *testing.T) {
139 project := pythonTestEnv(t)
140 resolved := resolvePython(t, pythonTestArgs(project))
141 found := false
142 for _, s := range resolved.ToolSummary {
143 if s.Name == "python" {
144 found = true
145 }
146 }
147 if !found {
148 t.Fatal("default summary must advertise python")
149 }
150
151 args := pythonTestArgs(project)
152 args.Tools = []string{"bash"}
153 resolved = resolvePython(t, args)
154 for _, s := range resolved.ToolSummary {
155 if s.Name == "python" {
156 t.Fatal("bash-only summary must not advertise python")
157 }
158 }
159}
160
161func TestPythonConfigRoundTripAndAssembly(t *testing.T) {
162 project := pythonTestEnv(t)
163 cfg, err := LoadConfig()
164 if err != nil {
165 t.Fatal(err)
166 }
167 cfg.PythonInterpreter = "/opt/py/bin/python"
168 if err := SaveConfig(cfg); err != nil {
169 t.Fatal(err)
170 }
171 loaded, err := LoadConfig()
172 if err != nil {
173 t.Fatal(err)
174 }
175 if loaded.PythonInterpreter != "/opt/py/bin/python" {
176 t.Fatalf("config round-trip = %q", loaded.PythonInterpreter)
177 }
178 resolved := resolvePython(t, pythonTestArgs(project))
179 if got := pythonToolFrom(t, resolved).Interpreter; got != "/opt/py/bin/python" {
180 t.Fatalf("assembled interpreter = %q", got)
181 }
182}
183
184func TestPythonRebuildUsesCurrentCWDAndSandbox(t *testing.T) {
185 root := t.TempDir()
186 t.Setenv("ZUT_HOME", filepath.Join(root, "zut-home"))
187 t.Setenv("HOME", filepath.Join(root, "home"))
188 t.Setenv("USERPROFILE", filepath.Join(root, "home"))
189 dirA := filepath.Join(root, "a")
190 dirB := filepath.Join(root, "b")
191 if err := os.MkdirAll(dirA, 0o755); err != nil {
192 t.Fatal(err)
193 }
194 if err := os.MkdirAll(dirB, 0o755); err != nil {
195 t.Fatal(err)
196 }
197 args := pythonTestArgs(dirA)
198 first := resolvePython(t, args)
199 if pythonToolFrom(t, first).CWD != dirA {
200 t.Fatal("first build must use its own CWD")
201 }
202
203 // A normal rebuild picks up the current CWD rather than stale state,
204 // and UseSandbox keeps the live jail state across rebuilds.
205 args.CWD = dirB
206 second := resolvePython(t, args)
207 if pythonToolFrom(t, second).CWD != dirB {
208 t.Fatalf("rebuild CWD = %q, want %q", pythonToolFrom(t, second).CWD, dirB)
209 }
210 fresh := tools.NewSandbox(dirB)
211 fresh.Lock()
212 second.UseSandbox(fresh)
213 if got := pythonToolFrom(t, second).Sandbox; got != fresh {
214 t.Fatal("UseSandbox must propagate the live sandbox to python")
215 }
216 if !pythonToolFrom(t, second).Sandbox.Locked() {
217 t.Fatal("python must observe the live jail state")
218 }
219}
220
221type pythonExtStub struct {
222 infos []ExtensionToolInfo
223}
224
225func (s pythonExtStub) Tools() []ExtensionToolInfo { return s.infos }
226
227func (s pythonExtStub) NewExtensionTool(info ExtensionToolInfo) core.Tool {
228 return &tools.GrepTool{}
229}
230
231func TestPythonExtensionCannotBypassPolicyExclusion(t *testing.T) {
232 project := pythonTestEnv(t)
233 args := pythonTestArgs(project)
234 args.Tools = []string{"bash"}
235 resolved := resolvePython(t, args)
236 stub := pythonExtStub{infos: []ExtensionToolInfo{
237 {Extension: "x", Name: "python"},
238 {Extension: "x", Name: "weather"},
239 }}
240 resolved.MergeExtensionTools(stub)
241 if _, ok := resolved.ToolRegistry["python"]; ok {
242 t.Fatal("an extension must not reintroduce a policy-excluded python")
243 }
244 if _, ok := resolved.ToolRegistry["weather"]; !ok {
245 t.Fatal("unrelated extension tools must still merge")
246 }
247}
248
249func TestPythonUnavailableInterpreterDoesNotBlockStartup(t *testing.T) {
250 project := pythonTestEnv(t)
251 cfg, err := LoadConfig()
252 if err != nil {
253 t.Fatal(err)
254 }
255 cfg.PythonInterpreter = filepath.Join(project, "missing", "python")
256 if err := SaveConfig(cfg); err != nil {
257 t.Fatal(err)
258 }
259 // Startup succeeds; the missing interpreter fails only on invocation
260 // with an actionable error (covered at the tool boundary).
261 resolved := resolvePython(t, pythonTestArgs(project))
262 pythonToolFrom(t, resolved)
263}