Skip to content

Commit ef49140

Browse files
authored
Merge PR #2589: fix: FormatMailBody detects run.sh and dispatches script instead of markdown
Dogs now receive concrete 'bash run.sh' commands instead of ambiguous markdown instructions when a plugin has a run.sh. Prevents dogs from improvising bash that kills valid sessions. PR: #2589 Author: seanbearden
2 parents b865fb1 + 0f33903 commit ef49140

3 files changed

Lines changed: 199 additions & 5 deletions

File tree

internal/plugin/scanner.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,18 @@ func (s *Scanner) loadPlugin(pluginDir string, location Location, rigName string
130130
return nil, fmt.Errorf("reading plugin.md: %w", err)
131131
}
132132

133-
return parsePluginMD(content, pluginDir, location, rigName)
133+
plugin, err := parsePluginMD(content, pluginDir, location, rigName)
134+
if err != nil {
135+
return nil, err
136+
}
137+
138+
// Check for run.sh alongside plugin.md
139+
runScriptPath := filepath.Join(pluginDir, "run.sh")
140+
if info, statErr := os.Stat(runScriptPath); statErr == nil && !info.IsDir() {
141+
plugin.HasRunScript = true
142+
}
143+
144+
return plugin, nil
134145
}
135146

136147
// parsePluginMD parses a plugin.md file with TOML frontmatter.

internal/plugin/scanner_test.go

Lines changed: 168 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package plugin
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
78
)
89

@@ -400,17 +401,60 @@ func TestParsePluginMD_GitHubSheriff(t *testing.T) {
400401
}
401402

402403
func TestParsePluginMD_SessionHygiene(t *testing.T) {
403-
// Verify the actual session-hygiene plugin.md parses correctly.
404-
content, err := os.ReadFile(filepath.Join("..", "..", "plugins", "session-hygiene", "plugin.md"))
404+
// Use a temp dir with a fixture plugin.md and run.sh so the test
405+
// doesn't depend on the local filesystem layout (fails in CI).
406+
pluginDir := t.TempDir()
407+
408+
pluginContent := []byte(`+++
409+
name = "session-hygiene"
410+
description = "Clean up zombie tmux sessions and orphaned dog sessions"
411+
version = 2
412+
413+
[gate]
414+
type = "cooldown"
415+
duration = "30m"
416+
417+
[tracking]
418+
labels = ["plugin:session-hygiene", "category:cleanup"]
419+
digest = true
420+
421+
[execution]
422+
timeout = "5m"
423+
notify_on_failure = true
424+
severity = "low"
425+
+++
426+
427+
# Session Hygiene
428+
429+
Deterministic cleanup of zombie tmux sessions and orphaned dog sessions.
430+
`)
431+
432+
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.md"), pluginContent, 0644); err != nil {
433+
t.Fatalf("writing plugin.md fixture: %v", err)
434+
}
435+
if err := os.WriteFile(filepath.Join(pluginDir, "run.sh"), []byte("#!/bin/bash\necho ok\n"), 0755); err != nil {
436+
t.Fatalf("writing run.sh fixture: %v", err)
437+
}
438+
439+
content, err := os.ReadFile(filepath.Join(pluginDir, "plugin.md"))
405440
if err != nil {
406-
t.Skipf("session-hygiene plugin not found (expected in plugins/): %v", err)
441+
t.Fatalf("reading plugin.md fixture: %v", err)
407442
}
408443

409-
plugin, err := parsePluginMD(content, "/test/session-hygiene", LocationRig, "gastown")
444+
plugin, err := parsePluginMD(content, pluginDir, LocationRig, "gastown")
410445
if err != nil {
411446
t.Fatalf("parsePluginMD failed: %v", err)
412447
}
413448

449+
// Verify run.sh detection (loadPlugin does this, not parsePluginMD)
450+
runScriptPath := filepath.Join(pluginDir, "run.sh")
451+
if info, statErr := os.Stat(runScriptPath); statErr == nil && !info.IsDir() {
452+
plugin.HasRunScript = true
453+
}
454+
if !plugin.HasRunScript {
455+
t.Error("expected HasRunScript=true for session-hygiene (has run.sh)")
456+
}
457+
414458
if plugin.Name != "session-hygiene" {
415459
t.Errorf("expected name 'session-hygiene', got %q", plugin.Name)
416460
}
@@ -509,3 +553,123 @@ version = 1
509553
t.Errorf("expected location 'rig', got %q", plugins[0].Location)
510554
}
511555
}
556+
557+
func TestLoadPlugin_DetectsRunScript(t *testing.T) {
558+
tmpDir, err := os.MkdirTemp("", "plugin-runsh-test")
559+
if err != nil {
560+
t.Fatalf("failed to create temp dir: %v", err)
561+
}
562+
defer os.RemoveAll(tmpDir)
563+
564+
// Create plugin dir with plugin.md AND run.sh
565+
pluginDir := filepath.Join(tmpDir, "plugins", "with-script")
566+
if err := os.MkdirAll(pluginDir, 0755); err != nil {
567+
t.Fatalf("failed to create plugin dir: %v", err)
568+
}
569+
pluginContent := []byte(`+++
570+
name = "with-script"
571+
description = "Plugin with run.sh"
572+
version = 1
573+
+++
574+
575+
# Instructions (should be ignored when run.sh exists)
576+
`)
577+
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.md"), pluginContent, 0644); err != nil {
578+
t.Fatalf("failed to write plugin.md: %v", err)
579+
}
580+
if err := os.WriteFile(filepath.Join(pluginDir, "run.sh"), []byte("#!/bin/bash\necho hello\n"), 0755); err != nil {
581+
t.Fatalf("failed to write run.sh: %v", err)
582+
}
583+
584+
// Create plugin dir with plugin.md only (no run.sh)
585+
pluginDirNoScript := filepath.Join(tmpDir, "plugins", "no-script")
586+
if err := os.MkdirAll(pluginDirNoScript, 0755); err != nil {
587+
t.Fatalf("failed to create plugin dir: %v", err)
588+
}
589+
noScriptContent := []byte(`+++
590+
name = "no-script"
591+
description = "Plugin without run.sh"
592+
version = 1
593+
+++
594+
595+
# Instructions
596+
`)
597+
if err := os.WriteFile(filepath.Join(pluginDirNoScript, "plugin.md"), noScriptContent, 0644); err != nil {
598+
t.Fatalf("failed to write plugin.md: %v", err)
599+
}
600+
601+
scanner := NewScanner(tmpDir, nil)
602+
plugins, err := scanner.DiscoverAll()
603+
if err != nil {
604+
t.Fatalf("DiscoverAll failed: %v", err)
605+
}
606+
607+
if len(plugins) != 2 {
608+
t.Fatalf("expected 2 plugins, got %d", len(plugins))
609+
}
610+
611+
byName := make(map[string]*Plugin)
612+
for _, p := range plugins {
613+
byName[p.Name] = p
614+
}
615+
616+
if p, ok := byName["with-script"]; !ok {
617+
t.Fatal("expected to find 'with-script' plugin")
618+
} else if !p.HasRunScript {
619+
t.Error("expected HasRunScript=true for plugin with run.sh")
620+
}
621+
622+
if p, ok := byName["no-script"]; !ok {
623+
t.Fatal("expected to find 'no-script' plugin")
624+
} else if p.HasRunScript {
625+
t.Error("expected HasRunScript=false for plugin without run.sh")
626+
}
627+
}
628+
629+
func TestFormatMailBody_WithRunScript(t *testing.T) {
630+
p := &Plugin{
631+
Name: "test-plugin",
632+
Description: "A test plugin",
633+
Path: "/home/user/gt/plugins/test-plugin",
634+
HasRunScript: true,
635+
}
636+
637+
body := p.FormatMailBody()
638+
639+
// Must contain the bash command to run the script
640+
if !strings.Contains(body, "cd /home/user/gt/plugins/test-plugin && bash run.sh") {
641+
t.Error("expected mail body to contain run.sh execution command")
642+
}
643+
// Must instruct dog NOT to interpret markdown
644+
if !strings.Contains(body, "Do NOT interpret the plugin.md instructions") {
645+
t.Error("expected mail body to warn against interpreting markdown")
646+
}
647+
// Must NOT contain "## Instructions" section
648+
if strings.Contains(body, "## Instructions") {
649+
t.Error("expected mail body to NOT contain markdown instructions section")
650+
}
651+
}
652+
653+
func TestFormatMailBody_WithoutRunScript(t *testing.T) {
654+
p := &Plugin{
655+
Name: "test-plugin",
656+
Description: "A test plugin",
657+
Path: "/home/user/gt/plugins/test-plugin",
658+
Instructions: "Do the thing.",
659+
HasRunScript: false,
660+
}
661+
662+
body := p.FormatMailBody()
663+
664+
// Must contain the instructions section
665+
if !strings.Contains(body, "## Instructions") {
666+
t.Error("expected mail body to contain instructions section")
667+
}
668+
if !strings.Contains(body, "Do the thing.") {
669+
t.Error("expected mail body to contain plugin instructions")
670+
}
671+
// Must NOT contain run.sh dispatch
672+
if strings.Contains(body, "bash run.sh") {
673+
t.Error("expected mail body to NOT contain run.sh command")
674+
}
675+
}

internal/plugin/types.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ type Plugin struct {
4444

4545
// Instructions is the markdown body (after frontmatter).
4646
Instructions string `json:"instructions,omitempty"`
47+
48+
// HasRunScript is true when a run.sh exists alongside plugin.md.
49+
// When true, FormatMailBody instructs the dog to execute the script
50+
// instead of interpreting the markdown instructions.
51+
HasRunScript bool `json:"has_run_script,omitempty"`
4752
}
4853

4954
// Location indicates where a plugin was discovered.
@@ -159,6 +164,20 @@ func (p *Plugin) Summary() PluginSummary {
159164
// This is the canonical formatting used by both the daemon dispatcher
160165
// and the gt dog dispatch command.
161166
func (p *Plugin) FormatMailBody() string {
167+
if p.HasRunScript {
168+
return fmt.Sprintf(
169+
"Execute the following plugin script:\n\n"+
170+
"**Plugin**: %s\n"+
171+
"**Description**: %s\n\n"+
172+
"```bash\ncd %s && bash run.sh\n```\n\n"+
173+
"Run this command EXACTLY. Do NOT interpret the plugin.md instructions.\n"+
174+
"Do NOT write your own implementation. Just run the script and report the output.\n\n"+
175+
"After completion:\n"+
176+
"1. Create a wisp to record the result (success/failure)\n"+
177+
"2. Run `gt dog done` — this clears your work and auto-terminates the session\n",
178+
p.Name, p.Description, p.Path)
179+
}
180+
162181
var sb strings.Builder
163182

164183
sb.WriteString("Execute the following plugin:\n\n")

0 commit comments

Comments
 (0)