1package extensions
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7
8 "github.com/patriceckhart/zot/packages/agent/extproto"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15)
16
17// stubHooks records every callback so the test can assert on them.
18type stubHooks struct {
19 mu sync.Mutex
20 notifies []string
21 displays []string
22 submits []string
23 submitSlash []string
24 clearNotes []string
25 panels []extproto.PanelSpec
26 panelExts []string
27}
28
29func (s *stubHooks) Notify(name, level, message string) {
30 s.mu.Lock()
31 defer s.mu.Unlock()
32 s.notifies = append(s.notifies, name+":"+level+":"+message)
33}
34func (s *stubHooks) Submit(text string) {
35 s.mu.Lock()
36 defer s.mu.Unlock()
37 s.submits = append(s.submits, text)
38}
39func (s *stubHooks) SubmitSlash(text string) {
40 s.mu.Lock()
41 defer s.mu.Unlock()
42 s.submitSlash = append(s.submitSlash, text)
43}
44func (s *stubHooks) Insert(string) {}
45func (s *stubHooks) Display(name, text string) {
46 s.mu.Lock()
47 defer s.mu.Unlock()
48 s.displays = append(s.displays, name+":"+text)
49}
50func (s *stubHooks) ClearNotes(name string) {
51 s.mu.Lock()
52 defer s.mu.Unlock()
53 s.clearNotes = append(s.clearNotes, name)
54}
55func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
56 s.mu.Lock()
57 defer s.mu.Unlock()
58 s.panelExts = append(s.panelExts, extName)
59 s.panels = append(s.panels, spec)
60}
61func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
62func (s *stubHooks) ClosePanel(string, string) {}
63
64// writeMockExtension creates a minimal extension on disk that uses a
65// shell script (or batch file on windows) to drive the protocol. The
66// script reads commands from stdin and emits hard-coded responses,
67// exercising the manager's spawn/handshake/dispatch path without
68// needing the SDK.
69func writeMockExtension(t *testing.T, root string) {
70 t.Helper()
71 if runtime.GOOS == "windows" {
72 t.Skip("mock extension uses /bin/sh; skip on windows")
73 }
74
75 dir := filepath.Join(root, "mock")
76 if err := os.MkdirAll(dir, 0o755); err != nil {
77 t.Fatal(err)
78 }
79
80 // Shell script: emit hello, read frames, respond. Reads until
81 // stdin closes; tail's -F keeps the pipe alive long enough for
82 // the manager to send command_invoked.
83 script := `#!/bin/sh
84printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'
85printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}'
86while IFS= read -r line; do
87 case "$line" in
88 *'"type":"command_invoked"'*)
89 id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
90 case "$line" in
91 *'"name":"Ping"'*)
92 printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"
93 ;;
94 *)
95 printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"error\":\"non-canonical command name\"}"
96 ;;
97 esac
98 ;;
99 *'"type":"shutdown"'*)
100 printf '%s\n' '{"type":"shutdown_ack"}'
101 exit 0
102 ;;
103 esac
104done
105`
106 scriptPath := filepath.Join(dir, "run.sh")
107 if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
108 t.Fatal(err)
109 }
110
111 manifest := map[string]any{
112 "name": "mock",
113 "version": "0.0.1",
114 "exec": "./run.sh",
115 }
116 mfb, _ := json.Marshal(manifest)
117 if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
118 t.Fatal(err)
119 }
120}
121
122func TestDiscoverReportsExitStatusAndLogWhenExtensionExitsBeforeHello(t *testing.T) {
123 if runtime.GOOS == "windows" {
124 t.Skip("mock extension uses /bin/sh; skip on windows")
125 }
126
127 tmp := t.TempDir()
128 extDir := filepath.Join(tmp, "extensions", "broken")
129 if err := os.MkdirAll(extDir, 0o755); err != nil {
130 t.Fatal(err)
131 }
132 script := "#!/bin/sh\nprintf '%s\\n' 'compile failed' >&2\nexit 23\n"
133 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
134 t.Fatal(err)
135 }
136 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"broken","exec":"./run.sh"}`), 0o644); err != nil {
137 t.Fatal(err)
138 }
139
140 mgr := New(tmp, "", "0.0.0-test", "", "", nil)
141 errs := mgr.Discover(context.Background())
142 if len(errs) != 1 {
143 t.Fatalf("discover errors = %v, want one", errs)
144 }
145 logPath := filepath.Join(tmp, "logs", "ext-broken.log")
146 got := errs[0].Error()
147 if strings.Contains(got, "%!w") {
148 t.Fatalf("error contains formatting artifact: %s", got)
149 }
150 if !strings.Contains(got, "extension exited before hello: exit status 23") || !strings.Contains(got, logPath) {
151 t.Fatalf("error does not report exit status and identify stderr log: %s", got)
152 }
153 logData, err := os.ReadFile(logPath)
154 if err != nil {
155 t.Fatalf("read extension log: %v", err)
156 }
157 if !strings.Contains(string(logData), "compile failed") {
158 t.Fatalf("extension stderr missing from log: %s", logData)
159 }
160}
161
162func TestDiscoverReportsSignalWhenExtensionExitsBeforeHello(t *testing.T) {
163 if runtime.GOOS == "windows" {
164 t.Skip("mock extension uses Unix signals; skip on windows")
165 }
166
167 tmp := t.TempDir()
168 extDir := filepath.Join(tmp, "extensions", "signaled")
169 if err := os.MkdirAll(extDir, 0o755); err != nil {
170 t.Fatal(err)
171 }
172 script := "#!/bin/sh\nkill -TERM $$\n"
173 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
174 t.Fatal(err)
175 }
176 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"signaled","exec":"./run.sh"}`), 0o644); err != nil {
177 t.Fatal(err)
178 }
179
180 mgr := New(tmp, "", "0.0.0-test", "", "", nil)
181 errs := mgr.Discover(context.Background())
182 if len(errs) != 1 {
183 t.Fatalf("discover errors = %v, want one", errs)
184 }
185 got := errs[0].Error()
186 if !strings.Contains(got, "extension exited before hello: signal:") || !strings.Contains(got, "ext-signaled.log") {
187 t.Fatalf("error does not report signal and identify stderr log: %s", got)
188 }
189}
190
191func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
192 if runtime.GOOS == "windows" {
193 t.Skip("mock extension uses /bin/sh; skip on windows")
194 }
195
196 tmp := t.TempDir()
197 extDir := filepath.Join(tmp, "broken")
198 if err := os.MkdirAll(extDir, 0o755); err != nil {
199 t.Fatal(err)
200 }
201 scriptPath := filepath.Join(extDir, "run.sh")
202 script := "#!/bin/sh\nprintf '%s\\n' 'not-json'\nwhile IFS= read -r line; do :; done\n"
203 if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
204 t.Fatal(err)
205 }
206
207 ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir}
208 mgr := New(tmp, "", "0.0.0-test", "", "", nil)
209 err := mgr.spawn(context.Background(), ext)
210 if err == nil || !strings.Contains(err.Error(), "parse hello") {
211 t.Fatalf("spawn error = %v, want parse hello failure", err)
212 }
213 if ext.cmd == nil || ext.cmd.ProcessState == nil {
214 t.Fatal("failed handshake process was not reaped")
215 }
216 if _, err := ext.logFile.WriteString("after cleanup"); err == nil {
217 t.Fatal("failed handshake log file was not closed")
218 }
219}
220
221func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
222 tmp := t.TempDir()
223 extDir := filepath.Join(tmp, "extensions", "theme-only")
224 if err := os.MkdirAll(extDir, 0o755); err != nil {
225 t.Fatal(err)
226 }
227 manifest := `{"name":"theme-only","version":"1.0.0","description":"theme only"}`
228 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
229 t.Fatal(err)
230 }
231 theme := `{"name":"Theme Only","description":"theme from extension","colors":{"dark":{"accent":204}}}`
232 if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(theme), 0o644); err != nil {
233 t.Fatal(err)
234 }
235
236 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", nil)
237 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
238 t.Fatalf("discover errors: %v", errs)
239 }
240 defer mgr.Stop(10 * time.Millisecond)
241
242 opts := mgr.ThemeOptions()
243 if len(opts) != 1 {
244 t.Fatalf("theme options = %d, want 1", len(opts))
245 }
246 if opts[0].Label != "Theme Only" || opts[0].Path != filepath.Join(extDir, "theme.json") {
247 t.Fatalf("unexpected theme option: %#v", opts[0])
248 }
249 if !strings.Contains(opts[0].Description, "from extension theme-only") {
250 t.Fatalf("description missing extension source: %q", opts[0].Description)
251 }
252}
253
254func TestManagerSpawnAndInvoke(t *testing.T) {
255 tmp := t.TempDir()
256 extRoot := filepath.Join(tmp, "extensions")
257 if err := os.MkdirAll(extRoot, 0o755); err != nil {
258 t.Fatal(err)
259 }
260 writeMockExtension(t, extRoot)
261
262 hooks := &stubHooks{}
263 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
264
265 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
266 t.Fatalf("discover errors: %v", errs)
267 }
268 defer mgr.Stop(2 * time.Second)
269
270 // Give the extension a beat to send register_command frames after
271 // the hello handshake.
272 time.Sleep(150 * time.Millisecond)
273
274 cmds := mgr.Commands()
275 found := false
276 for _, c := range cmds {
277 if c.Name == "Ping" {
278 found = true
279 break
280 }
281 }
282 if !found {
283 t.Fatalf("expected canonical command 'Ping', got %#v", cmds)
284 }
285 if !mgr.HasCommand("PING") {
286 t.Fatal("HasCommand(\"PING\") = false")
287 }
288 if owner := mgr.CommandOwner("pInG"); owner != "mock" {
289 t.Fatalf("CommandOwner(\"pInG\") = %q, want mock", owner)
290 }
291
292 resp, err := mgr.Invoke(context.Background(), "PING", "", 2*time.Second)
293 if err != nil {
294 t.Fatalf("invoke: %v", err)
295 }
296 if resp.Action != "display" {
297 t.Errorf("expected action=display, got %q", resp.Action)
298 }
299 if resp.Display != "pong" {
300 t.Errorf("expected display=pong, got %q", resp.Display)
301 }
302}
303
304func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
305 if runtime.GOOS == "windows" {
306 t.Skip("mock extension uses /bin/sh; skip on windows")
307 }
308
309 tmp := t.TempDir()
310 extRoot := filepath.Join(tmp, "extensions")
311 writeDiagExtension := func(name, script string) {
312 t.Helper()
313 dir := filepath.Join(extRoot, name)
314 if err := os.MkdirAll(dir, 0o755); err != nil {
315 t.Fatal(err)
316 }
317 if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil {
318 t.Fatal(err)
319 }
320 mfb, _ := json.Marshal(map[string]any{"name": name, "exec": "./run.sh"})
321 if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
322 t.Fatal(err)
323 }
324 }
325
326 writeDiagExtension("a-first", `#!/bin/sh
327printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}'
328printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}'
329printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
330printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
331printf '%s\n' '{"type":"ready"}'
332while IFS= read -r line; do
333 case "$line" in
334 *'"type":"shutdown"'*) exit 0 ;;
335 esac
336done
337`)
338 writeDiagExtension("b-second", `#!/bin/sh
339printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}'
340printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}'
341printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
342printf '%s\n' '{"type":"ready"}'
343while IFS= read -r line; do
344 case "$line" in
345 *'"type":"shutdown"'*) exit 0 ;;
346 esac
347done
348`)
349
350 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", &stubHooks{})
351 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
352 t.Fatalf("discover errors: %v", errs)
353 }
354 defer mgr.Stop(2 * time.Second)
355 mgr.WaitForReady(2 * time.Second)
356
357 diags := mgr.Diagnostics()
358 byName := map[string]ExtensionDiagnostic{}
359 for _, d := range diags {
360 byName[d.Name] = d
361 }
362
363 first := byName["a-first"]
364 if len(first.Messages) == 0 || !strings.Contains(strings.Join(first.Messages, "\n"), "malformed json frame") {
365 t.Fatalf("expected malformed-frame diagnostic, got %#v", first.Messages)
366 }
367
368 var shadowedTool bool
369 var activeCommands, shadowedCommands int
370 var conflictMessage bool
371 for _, d := range diags {
372 if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {
373 conflictMessage = true
374 }
375 for _, tool := range d.Tools {
376 if tool.Name == "shared" && !tool.Active {
377 shadowedTool = true
378 }
379 }
380 for _, command := range d.Commands {
381 if !strings.EqualFold(command.Name, "casetest") {
382 continue
383 }
384 if command.Active {
385 activeCommands++
386 } else {
387 shadowedCommands++
388 }
389 }
390 }
391 if !shadowedTool {
392 t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)
393 }
394 if activeCommands != 1 || shadowedCommands != 1 {
395 t.Fatalf("case-only command registrations: active=%d shadowed=%d; diagnostics=%#v", activeCommands, shadowedCommands, diags)
396 }
397 if !conflictMessage {
398 t.Fatalf("expected conflict diagnostic, got %#v", diags)
399 }
400}
401
402// TestSpontaneousSubmit verifies that an extension can submit a
403// non-empty prompt outside of any command response.
404func TestSpontaneousSubmit(t *testing.T) {
405 if runtime.GOOS == "windows" {
406 t.Skip("mock extension uses /bin/sh; skip on windows")
407 }
408
409 tmp := t.TempDir()
410 extDir := filepath.Join(tmp, "extensions", "submit-mock")
411 if err := os.MkdirAll(extDir, 0o755); err != nil {
412 t.Fatal(err)
413 }
414
415 script := `#!/bin/sh
416printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}'
417printf '%s\n' '{"type":"ready"}'
418printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}'
419printf '%s\n' '{"type":"submit","text":" "}'
420while IFS= read -r line; do
421 case "$line" in
422 *'"type":"shutdown"'*)
423 printf '%s\n' '{"type":"shutdown_ack"}'
424 exit 0
425 ;;
426 esac
427done
428`
429 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
430 t.Fatal(err)
431 }
432 mfb, _ := json.Marshal(map[string]any{"name": "submit-mock", "exec": "./run.sh"})
433 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
434 t.Fatal(err)
435 }
436
437 hooks := &stubHooks{}
438 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
439 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
440 t.Fatalf("discover errors: %v", errs)
441 }
442 defer mgr.Stop(2 * time.Second)
443
444 deadline := time.Now().Add(2 * time.Second)
445 for time.Now().Before(deadline) {
446 hooks.mu.Lock()
447 n := len(hooks.submits)
448 hooks.mu.Unlock()
449 if n > 0 {
450 break
451 }
452 time.Sleep(20 * time.Millisecond)
453 }
454
455 hooks.mu.Lock()
456 defer hooks.mu.Unlock()
457 if len(hooks.submits) != 1 {
458 t.Fatalf("Submit calls = %#v, want one non-empty call", hooks.submits)
459 }
460 if hooks.submits[0] != "explain this repository briefly" {
461 t.Fatalf("Submit text = %q", hooks.submits[0])
462 }
463}
464
465// TestSpontaneousOpenPanel verifies that an extension sending an
466// open_panel frame outside of any command response causes the manager
467// to call hooks.OpenPanel with the correct PanelSpec fields.
468func TestSpontaneousOpenPanel(t *testing.T) {
469 if runtime.GOOS == "windows" {
470 t.Skip("mock extension uses /bin/sh; skip on windows")
471 }
472
473 tmp := t.TempDir()
474 extDir := filepath.Join(tmp, "extensions", "panel-mock")
475 if err := os.MkdirAll(extDir, 0o755); err != nil {
476 t.Fatal(err)
477 }
478
479 // Extension emits hello + ready, then immediately fires a
480 // spontaneous open_panel, then waits for shutdown.
481 script := `#!/bin/sh
482printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}'
483printf '%s\n' '{"type":"ready"}'
484printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'
485while IFS= read -r line; do
486 case "$line" in
487 *'"type":"shutdown"'*)
488 printf '%s\n' '{"type":"shutdown_ack"}'
489 exit 0
490 ;;
491 esac
492done
493`
494 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
495 t.Fatal(err)
496 }
497 mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"})
498 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
499 t.Fatal(err)
500 }
501
502 hooks := &stubHooks{}
503 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
504 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
505 t.Fatalf("discover errors: %v", errs)
506 }
507 defer mgr.Stop(2 * time.Second)
508
509 // Give the extension time to flush its open_panel frame.
510 deadline := time.Now().Add(2 * time.Second)
511 for time.Now().Before(deadline) {
512 hooks.mu.Lock()
513 n := len(hooks.panels)
514 hooks.mu.Unlock()
515 if n > 0 {
516 break
517 }
518 time.Sleep(20 * time.Millisecond)
519 }
520
521 hooks.mu.Lock()
522 defer hooks.mu.Unlock()
523
524 if len(hooks.panels) == 0 {
525 t.Fatal("hooks.OpenPanel was never called")
526 }
527 spec := hooks.panels[0]
528 if spec.ID != "test-panel" {
529 t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID)
530 }
531 if spec.Title != "Hello Panel" {
532 t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title)
533 }
534 if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" {
535 t.Errorf("panel lines: want [line one line two], got %v", spec.Lines)
536 }
537 if spec.Footer != "esc close" {
538 t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer)
539 }
540 if hooks.panelExts[0] != "panel-mock" {
541 t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0])
542 }
543}