1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "os"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/bnema/zut/packages/core"
14 "github.com/bnema/zut/packages/provider"
15)
16
17func pythonArgsJSON(t *testing.T, code string, timeout int64) json.RawMessage {
18 t.Helper()
19 return mustJSON(t, map[string]any{"code": code, "timeout": timeout})
20}
21
22func stubPythonTool(resolved resolvedPython, resolveErr error, outcome pythonExecOutcome, execErr error) *PythonTool {
23 return &PythonTool{
24 CWD: "/session",
25 resolveHook: func(context.Context, string, string) (resolvedPython, error) {
26 return resolved, resolveErr
27 },
28 execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
29 return outcome, execErr
30 },
31 }
32}
33
34func TestPythonSchemaRequiresCodeAndTimeout(t *testing.T) {
35 var schema struct {
36 Properties map[string]struct {
37 Type string `json:"type"`
38 Minimum *int64 `json:"minimum"`
39 Maximum *int64 `json:"maximum"`
40 Description string `json:"description"`
41 } `json:"properties"`
42 Required []string `json:"required"`
43 }
44 if err := json.Unmarshal((&PythonTool{}).Schema(), &schema); err != nil {
45 t.Fatal(err)
46 }
47 timeout, ok := schema.Properties["timeout"]
48 if !ok || timeout.Type != "integer" || timeout.Minimum == nil || *timeout.Minimum != 1 || timeout.Maximum == nil || *timeout.Maximum != maxPythonTimeoutSeconds {
49 t.Fatalf("timeout schema = %+v", timeout)
50 }
51 if _, ok := schema.Properties["code"]; !ok {
52 t.Fatal("code property missing")
53 }
54 required := map[string]bool{}
55 for _, name := range schema.Required {
56 required[name] = true
57 }
58 if !required["code"] || !required["timeout"] {
59 t.Fatalf("required = %v", schema.Required)
60 }
61 if (&PythonTool{}).Name() != "python" {
62 t.Fatalf("name = %q", (&PythonTool{}).Name())
63 }
64}
65
66func TestPythonExecArgvDirect(t *testing.T) {
67 argv := pythonExecArgv("/usr/bin/python3")
68 if len(argv) != 4 || argv[0] != "/usr/bin/python3" || argv[1] != "-u" || argv[2] != "-B" || argv[3] != "-" {
69 t.Fatalf("argv = %q, want [exe -u -B -]", argv)
70 }
71}
72
73func TestPythonRejectsInvalidArgs(t *testing.T) {
74 tool := stubPythonTool(resolvedPython{}, nil, pythonExecOutcome{}, nil)
75 cases := []struct {
76 name string
77 raw json.RawMessage
78 want string
79 }{
80 {"blank code", mustJSON(t, map[string]any{"code": " ", "timeout": 5}), "code is required"},
81 {"missing timeout", mustJSON(t, map[string]any{"code": "print(1)"}), "timeout is required"},
82 {"zero timeout", mustJSON(t, map[string]any{"code": "print(1)", "timeout": 0}), "timeout must be between 1 and"},
83 {"negative timeout", mustJSON(t, map[string]any{"code": "print(1)", "timeout": -2}), "timeout must be between 1 and"},
84 }
85 for _, tc := range cases {
86 t.Run(tc.name, func(t *testing.T) {
87 res, err := tool.Execute(context.Background(), tc.raw, nil)
88 if err != nil {
89 t.Fatalf("err = %v, want model-visible IsError result", err)
90 }
91 if !res.IsError {
92 t.Fatal("want IsError for invalid args")
93 }
94 if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, tc.want) {
95 t.Fatalf("got %q want %q", got, tc.want)
96 }
97 })
98 }
99}
100
101func TestPythonDeniedNeverResolves(t *testing.T) {
102 resolveCalls := 0
103 execCalls := 0
104 mk := func(sandbox *Sandbox) *PythonTool {
105 return &PythonTool{
106 CWD: "/session",
107 Sandbox: sandbox,
108 resolveHook: func(context.Context, string, string) (resolvedPython, error) {
109 resolveCalls++
110 return resolvedPython{Path: "/usr/bin/python3"}, nil
111 },
112 execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
113 execCalls++
114 return pythonExecOutcome{}, nil
115 },
116 }
117 }
118 jailed := NewSandbox("/session")
119 jailed.Lock()
120 _, err := mk(jailed).Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
121 if err == nil || !strings.Contains(err.Error(), "cannot be confined") {
122 t.Fatalf("jail err = %v, want typed confinement refusal", err)
123 }
124 var jailDenied *core.ToolDeniedError
125 if !errors.As(err, &jailDenied) {
126 t.Fatalf("jail err type = %T, want *core.ToolDeniedError", err)
127 }
128 restricted := NewSandbox("/session")
129 restricted.SetPermissions(&PermissionSet{})
130 _, err = mk(restricted).Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
131 if err == nil || !strings.Contains(err.Error(), "permission denied") {
132 t.Fatalf("permission err = %v, want typed permission refusal", err)
133 }
134 var permDenied *core.ToolDeniedError
135 if !errors.As(err, &permDenied) {
136 t.Fatalf("permission err type = %T, want *core.ToolDeniedError", err)
137 }
138 if resolveCalls != 0 || execCalls != 0 {
139 t.Fatalf("denied calls must never resolve or execute (resolve=%d exec=%d)", resolveCalls, execCalls)
140 }
141}
142
143func TestPythonMissingInterpreterActionable(t *testing.T) {
144 tool := &PythonTool{
145 CWD: "/session",
146 resolveHook: func(context.Context, string, string) (resolvedPython, error) {
147 return resolvedPython{}, missingPythonError()
148 },
149 execHook: func(context.Context, string, string, string, func(string)) (pythonExecOutcome, error) {
150 t.Fatal("missing interpreter must not execute")
151 return pythonExecOutcome{}, nil
152 },
153 }
154 res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "print(1)", 5), nil)
155 if err != nil {
156 t.Fatalf("err = %v, want model-visible IsError result", err)
157 }
158 if !res.IsError {
159 t.Fatal("want IsError for missing interpreter")
160 }
161 got := res.Content[0].(provider.TextBlock).Text
162 if !strings.Contains(got, "existing Python 3") || !strings.Contains(got, "never installs") {
163 t.Fatalf("result = %q, want actionable guidance", got)
164 }
165}
166
167func TestPythonSuccessResult(t *testing.T) {
168 interp := resolvedPython{Path: "/usr/bin/python3", Version: pythonVersion{Major: 3, Minor: 12, Micro: 1}}
169 tool := stubPythonTool(interp, nil, pythonExecOutcome{Captured: "hi\n", ExitCode: 0}, nil)
170 res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "print('hi')", 5), nil)
171 if err != nil {
172 t.Fatal(err)
173 }
174 if res.IsError {
175 t.Fatal("unexpected IsError")
176 }
177 text := res.Content[0].(provider.TextBlock).Text
178 for _, want := range []string{"python 3.12.1", "/usr/bin/python3", "hi", "[exit 0]"} {
179 if !strings.Contains(text, want) {
180 t.Fatalf("result missing %q:\n%s", want, text)
181 }
182 }
183 details := res.Details.(map[string]any)
184 if details["exit_code"] != 0 || details["interpreter"] != "/usr/bin/python3" || details["python_version"] != "3.12.1" || details["bytes_truncated"] != false || details["lines_truncated"] != false {
185 t.Fatalf("details = %#v", details)
186 }
187 if path, _ := details["full_output_path"].(string); path != "" || strings.Contains(text, "full output") {
188 t.Fatalf("untruncated result must not persist a full-output file: %q", path)
189 }
190}
191
192func TestPythonFailureStatuses(t *testing.T) {
193 interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
194 for _, tc := range []struct {
195 name string
196 outcome pythonExecOutcome
197 want string
198 }{
199 {"nonzero", pythonExecOutcome{Captured: "boom\n", ExitCode: 3}, "[exit 3]"},
200 {"timeout", pythonExecOutcome{Captured: "", ExitCode: -1, TimedOut: true}, "[timed out after 5 seconds]"},
201 {"cancelled", pythonExecOutcome{Captured: "", ExitCode: -1, Cancelled: true}, "[cancelled]"},
202 } {
203 t.Run(tc.name, func(t *testing.T) {
204 tool := stubPythonTool(interp, nil, tc.outcome, nil)
205 res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
206 if err != nil {
207 t.Fatal(err)
208 }
209 if !res.IsError {
210 t.Fatal("want IsError")
211 }
212 if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, tc.want) {
213 t.Fatalf("got %q want %q", got, tc.want)
214 }
215 })
216 }
217}
218
219func TestPythonCancelErrorCapture(t *testing.T) {
220 var ce pythonCancelError
221 if err := ce.get(); err != nil {
222 t.Fatalf("empty capture = %v, want nil", err)
223 }
224 ce.set(errors.New("kill group: boom"))
225 if err := ce.get(); err == nil || !strings.Contains(err.Error(), "boom") {
226 t.Fatalf("captured = %v, want kill error", err)
227 }
228 // A nil receiver must not panic: probes pass nil explicitly.
229 var nilCE *pythonCancelError
230 nilCE.set(errors.New("ignored"))
231 if err := nilCE.get(); err != nil {
232 t.Fatalf("nil capture = %v, want nil", err)
233 }
234}
235
236func TestPythonTruncationMarkedWithFullOutput(t *testing.T) {
237 interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3, Minor: 1, Micro: 0}}
238 // Exactly maxPythonLines newline-terminated lines must not count as
239 // truncated: the trailing newline terminates the last line.
240 exact := strings.Repeat("x\n", maxPythonLines)
241 tool := stubPythonTool(interp, nil, pythonExecOutcome{Captured: exact, ExitCode: 0}, nil)
242 res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
243 if err != nil {
244 t.Fatal(err)
245 }
246 if got := res.Content[0].(provider.TextBlock).Text; strings.Contains(got, "truncated at") {
247 t.Fatalf("exact line budget must not truncate:\n%s", got)
248 }
249 if res.Details.(map[string]any)["lines_truncated"] != false {
250 t.Fatalf("details = %#v", res.Details)
251 }
252 if path, _ := res.Details.(map[string]any)["full_output_path"].(string); path != "" {
253 t.Fatalf("untruncated result must not persist an artifact: %q", path)
254 }
255
256 long := strings.Repeat("x\n", maxPythonLines+10)
257 tool = stubPythonTool(interp, nil, pythonExecOutcome{Captured: long, ExitCode: 0}, nil)
258 res, err = tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
259 if err != nil {
260 t.Fatal(err)
261 }
262 text := res.Content[0].(provider.TextBlock).Text
263 if !strings.Contains(text, "truncated at 2000 lines") {
264 t.Fatalf("lines truncation not marked:\n%s", text[len(text)-500:])
265 }
266 details := res.Details.(map[string]any)
267 if details["lines_truncated"] != true {
268 t.Fatalf("details = %#v", res.Details)
269 }
270 fullPath, _ := details["full_output_path"].(string)
271 if fullPath == "" || !strings.Contains(text, "full output: "+fullPath) {
272 t.Fatalf("truncated result must reference a full-output file: %q\n%s", fullPath, text)
273 }
274 t.Cleanup(func() { _ = os.Remove(fullPath) })
275 persisted, err := os.ReadFile(fullPath)
276 if err != nil {
277 t.Fatal(err)
278 }
279 if string(persisted) != long {
280 t.Fatal("full-output file must hold the byte-capped buffer beyond line trimming")
281 }
282
283 big := strings.Repeat("y", maxPythonBytes+100)
284 // The stub bypasses capture, so trim the fixture the way the real
285 // runner would: byte-capped, then line-trimmed for display.
286 big = big[:maxPythonBytes]
287 tool = stubPythonTool(interp, nil, pythonExecOutcome{Captured: big, ExitCode: 0, BytesTrunc: true}, nil)
288 res, err = tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil)
289 if err != nil {
290 t.Fatal(err)
291 }
292 if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "truncated at 51200 bytes") {
293 t.Fatalf("bytes truncation not marked")
294 }
295 if path, _ := res.Details.(map[string]any)["full_output_path"].(string); path == "" {
296 t.Fatal("byte-truncated result must persist a full-output file")
297 } else {
298 t.Cleanup(func() { _ = os.Remove(path) })
299 }
300}
301
302func TestPythonProgressStreamsMergedOutput(t *testing.T) {
303 interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
304 var calls []string
305 tool := &PythonTool{
306 CWD: "/session",
307 resolveHook: func(context.Context, string, string) (resolvedPython, error) {
308 return interp, nil
309 },
310 execHook: func(_ context.Context, _, _, _ string, progress func(string)) (pythonExecOutcome, error) {
311 progress("out\n")
312 progress("err\n")
313 return pythonExecOutcome{Captured: "out\nerr\n", ExitCode: 0}, nil
314 },
315 }
316 res, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), func(s string) {
317 calls = append(calls, s)
318 })
319 if err != nil {
320 t.Fatal(err)
321 }
322 if strings.Join(calls, "") != "out\nerr\n" {
323 t.Fatalf("progress = %q", calls)
324 }
325 if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "out") || !strings.Contains(got, "err") {
326 t.Fatalf("merged output missing:\n%s", got)
327 }
328}
329
330func TestAppendPythonChunkBoundaries(t *testing.T) {
331 var buf bytes.Buffer
332 if appendPythonChunk(&buf, bytes.Repeat([]byte("y"), maxPythonBytes)) {
333 t.Fatal("output exactly filling the budget must not report truncation")
334 }
335 if buf.Len() != maxPythonBytes {
336 t.Fatalf("buffer len = %d, want %d", buf.Len(), maxPythonBytes)
337 }
338 // One byte over discards exactly that byte.
339 if !appendPythonChunk(&buf, []byte("!")) {
340 t.Fatal("overflow byte must report truncation")
341 }
342 if buf.Len() != maxPythonBytes || buf.Bytes()[buf.Len()-1] == '!' {
343 t.Fatal("overflow byte must not be retained")
344 }
345 // A chunk straddling the boundary keeps its fitting prefix and
346 // reports the discarded remainder.
347 buf.Reset()
348 if appendPythonChunk(&buf, bytes.Repeat([]byte("a"), maxPythonBytes-1)) {
349 t.Fatal("sub-budget write must not report truncation")
350 }
351 if !appendPythonChunk(&buf, []byte("bc")) {
352 t.Fatal("straddling write must report its discarded remainder")
353 }
354 if buf.Len() != maxPythonBytes || buf.Bytes()[buf.Len()-1] != 'b' {
355 t.Fatal("straddling write must retain only its fitting prefix")
356 }
357 if appendPythonChunk(&buf, nil) {
358 t.Fatal("empty chunk must not report truncation")
359 }
360}
361
362func TestPythonTimeoutBounds(t *testing.T) {
363 if _, err := pythonTimeoutDuration(0); err == nil {
364 t.Fatal("want error for 0")
365 }
366 if _, err := pythonTimeoutDuration(maxPythonTimeoutSeconds + 1); err == nil {
367 t.Fatal("want error above maximum")
368 }
369 if d, err := pythonTimeoutDuration(2); err != nil || d != 2*time.Second {
370 t.Fatalf("d = %s, err = %v", d, err)
371 }
372}
373
374func TestPythonResolutionSharesTimeoutBudget(t *testing.T) {
375 interp := resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}}
376 tool := &PythonTool{
377 CWD: "/session",
378 resolveHook: func(ctx context.Context, _ string, _ string) (resolvedPython, error) {
379 if _, ok := ctx.Deadline(); !ok {
380 t.Fatal("resolution must share the invocation deadline")
381 }
382 return interp, nil
383 },
384 execHook: func(ctx context.Context, _, _, _ string, _ func(string)) (pythonExecOutcome, error) {
385 if _, ok := ctx.Deadline(); !ok {
386 t.Fatal("execution must share the invocation deadline")
387 }
388 return pythonExecOutcome{ExitCode: 0}, nil
389 },
390 }
391 if _, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil); err != nil {
392 t.Fatal(err)
393 }
394}
395
396func TestPythonExecErrorPropagates(t *testing.T) {
397 tool := stubPythonTool(
398 resolvedPython{Path: "/py", Version: pythonVersion{Major: 3}},
399 nil, pythonExecOutcome{}, errors.New("start: boom"),
400 )
401 if _, err := tool.Execute(context.Background(), pythonArgsJSON(t, "x", 5), nil); err == nil || !strings.Contains(err.Error(), "boom") {
402 t.Fatalf("err = %v", err)
403 }
404}