Merge branch 'codex/bugseti-mcp' into new

This commit is contained in:
Snider 2026-02-08 21:59:29 +00:00
commit 3f9840b2ea
13 changed files with 820 additions and 33 deletions

4
.githooks/pre-commit Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
exec core go qa full --fix

View file

@ -9,6 +9,7 @@ interface Config {
notificationsEnabled: boolean;
notificationSound: boolean;
workspaceDir: string;
marketplaceMcpRoot: string;
theme: string;
autoSeedContext: boolean;
workHours?: {
@ -161,6 +162,13 @@ interface Config {
<input type="text" class="form-input" [(ngModel)]="config.workspaceDir"
placeholder="Leave empty for default">
</div>
<div class="form-group">
<label class="form-label">Marketplace MCP Root</label>
<input type="text" class="form-input" [(ngModel)]="config.marketplaceMcpRoot"
placeholder="Path to core-agent (optional)">
<p class="section-description">Override the marketplace MCP root. Leave empty to auto-detect.</p>
</div>
</section>
</div>
</div>
@ -308,6 +316,7 @@ export class SettingsComponent implements OnInit {
notificationsEnabled: true,
notificationSound: true,
workspaceDir: '',
marketplaceMcpRoot: '',
theme: 'dark',
autoSeedContext: true,
workHours: {

View file

@ -37,7 +37,7 @@ func main() {
}
// Initialize core services
notifyService := bugseti.NewNotifyService()
notifyService := bugseti.NewNotifyService(configService)
statsService := bugseti.NewStatsService(configService)
fetcherService := bugseti.NewFetcherService(configService, notifyService)
queueService := bugseti.NewQueueService(configService)

View file

@ -37,6 +37,8 @@ type Config struct {
// Workspace
WorkspaceDir string `json:"workspaceDir,omitempty"`
DataDir string `json:"dataDir,omitempty"`
// Marketplace MCP
MarketplaceMCPRoot string `json:"marketplaceMcpRoot,omitempty"`
// Onboarding
Onboarded bool `json:"onboarded"`
@ -96,6 +98,7 @@ func NewConfigService() *ConfigService {
MaxConcurrentIssues: 1,
AutoSeedContext: true,
DataDir: bugsetiDir,
MarketplaceMCPRoot: "",
UpdateChannel: "stable",
AutoUpdate: false,
UpdateCheckInterval: 6, // Check every 6 hours
@ -181,6 +184,13 @@ func (c *ConfigService) GetConfig() Config {
return *c.config
}
// GetMarketplaceMCPRoot returns the configured marketplace MCP root path.
func (c *ConfigService) GetMarketplaceMCPRoot() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.config.MarketplaceMCPRoot
}
// SetConfig updates the configuration and saves it.
func (c *ConfigService) SetConfig(config Config) error {
c.mu.Lock()

View file

@ -0,0 +1,236 @@
// Package bugseti provides services for the BugSETI distributed bug fixing application.
package bugseti
import (
"bytes"
"context"
"encoding/xml"
"strings"
"sync"
"time"
)
const (
maxEnvRunes = 512
maxTitleRunes = 160
maxNotificationRunes = 200
maxSummaryRunes = 4000
maxBodyRunes = 8000
maxFileRunes = 260
)
type EthicsGuard struct {
Modal string
Axioms map[string]any
Loaded bool
}
var (
ethicsGuardMu sync.Mutex
ethicsGuard *EthicsGuard
ethicsGuardRoot string
)
func getEthicsGuard(ctx context.Context) *EthicsGuard {
return getEthicsGuardWithRoot(ctx, "")
}
func getEthicsGuardWithRoot(ctx context.Context, rootHint string) *EthicsGuard {
rootHint = strings.TrimSpace(rootHint)
ethicsGuardMu.Lock()
defer ethicsGuardMu.Unlock()
if ethicsGuard != nil && ethicsGuardRoot == rootHint {
return ethicsGuard
}
guard := loadEthicsGuard(ctx, rootHint)
if guard == nil {
guard = &EthicsGuard{}
}
ethicsGuard = guard
ethicsGuardRoot = rootHint
if ethicsGuard == nil {
return &EthicsGuard{}
}
return ethicsGuard
}
func guardFromMarketplace(ctx context.Context, client marketplaceClient) *EthicsGuard {
if client == nil {
return &EthicsGuard{}
}
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
ethics, err := client.EthicsCheck(ctx)
if err != nil || ethics == nil {
return &EthicsGuard{}
}
return &EthicsGuard{
Modal: ethics.Modal,
Axioms: ethics.Axioms,
Loaded: true,
}
}
func loadEthicsGuard(ctx context.Context, rootHint string) *EthicsGuard {
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
client, err := newMarketplaceClient(ctx, rootHint)
if err != nil {
return &EthicsGuard{}
}
defer client.Close()
ethics, err := client.EthicsCheck(ctx)
if err != nil || ethics == nil {
return &EthicsGuard{}
}
return &EthicsGuard{
Modal: ethics.Modal,
Axioms: ethics.Axioms,
Loaded: true,
}
}
func (g *EthicsGuard) SanitizeEnv(value string) string {
return sanitizeInline(value, maxEnvRunes)
}
func (g *EthicsGuard) SanitizeTitle(value string) string {
return sanitizeInline(value, maxTitleRunes)
}
func (g *EthicsGuard) SanitizeNotification(value string) string {
return sanitizeInline(value, maxNotificationRunes)
}
func (g *EthicsGuard) SanitizeSummary(value string) string {
return sanitizeMultiline(value, maxSummaryRunes)
}
func (g *EthicsGuard) SanitizeBody(value string) string {
return sanitizeMultiline(value, maxBodyRunes)
}
func (g *EthicsGuard) SanitizeFiles(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]bool)
clean := make([]string, 0, len(values))
for _, value := range values {
trimmed := sanitizeInline(value, maxFileRunes)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, "..") {
continue
}
if seen[trimmed] {
continue
}
seen[trimmed] = true
clean = append(clean, trimmed)
}
return clean
}
func (g *EthicsGuard) SanitizeList(values []string, maxRunes int) []string {
if len(values) == 0 {
return nil
}
if maxRunes <= 0 {
maxRunes = maxTitleRunes
}
clean := make([]string, 0, len(values))
for _, value := range values {
trimmed := sanitizeInline(value, maxRunes)
if trimmed == "" {
continue
}
clean = append(clean, trimmed)
}
return clean
}
func sanitizeInline(input string, maxRunes int) string {
return sanitizeText(input, maxRunes, false)
}
func sanitizeMultiline(input string, maxRunes int) string {
return sanitizeText(input, maxRunes, true)
}
func sanitizeText(input string, maxRunes int, allowNewlines bool) string {
if input == "" {
return ""
}
if maxRunes <= 0 {
maxRunes = maxSummaryRunes
}
var b strings.Builder
count := 0
for _, r := range input {
if r == '\r' {
continue
}
if r == '\n' {
if allowNewlines {
b.WriteRune(r)
count++
} else {
b.WriteRune(' ')
count++
}
if count >= maxRunes {
break
}
continue
}
if r == '\t' {
b.WriteRune(' ')
count++
if count >= maxRunes {
break
}
continue
}
if r < 0x20 || r == 0x7f {
continue
}
b.WriteRune(r)
count++
if count >= maxRunes {
break
}
}
return strings.TrimSpace(b.String())
}
func escapeAppleScript(value string) string {
value = strings.ReplaceAll(value, "\\", "\\\\")
value = strings.ReplaceAll(value, "\"", "\\\"")
return value
}
func escapePowerShellXML(value string) string {
var buffer bytes.Buffer
_ = xml.EscapeText(&buffer, []byte(value))
return buffer.String()
}

View file

@ -0,0 +1,28 @@
package bugseti
import "testing"
func TestSanitizeInline_Good(t *testing.T) {
input := "Hello world"
output := sanitizeInline(input, 50)
if output != input {
t.Fatalf("expected %q, got %q", input, output)
}
}
func TestSanitizeInline_Bad(t *testing.T) {
input := "Hello\nworld\t\x00"
expected := "Hello world"
output := sanitizeInline(input, 50)
if output != expected {
t.Fatalf("expected %q, got %q", expected, output)
}
}
func TestSanitizeMultiline_Ugly(t *testing.T) {
input := "ab\ncd\tef\x00"
output := sanitizeMultiline(input, 5)
if output != "ab\ncd" {
t.Fatalf("expected %q, got %q", "ab\ncd", output)
}
}

View file

@ -1,3 +1,17 @@
module github.com/host-uk/core/internal/bugseti
go 1.25.5
require github.com/mark3labs/mcp-go v0.43.2
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

39
internal/bugseti/go.sum Normal file
View file

@ -0,0 +1,39 @@
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,246 @@
// Package bugseti provides services for the BugSETI distributed bug fixing application.
package bugseti
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
)
type Marketplace struct {
Schema string `json:"$schema,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Owner MarketplaceOwner `json:"owner"`
Plugins []MarketplacePlugin `json:"plugins"`
}
type MarketplaceOwner struct {
Name string `json:"name"`
Email string `json:"email"`
}
type MarketplacePlugin struct {
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Source string `json:"source"`
Category string `json:"category"`
}
type PluginInfo struct {
Plugin MarketplacePlugin `json:"plugin"`
Path string `json:"path"`
Manifest map[string]any `json:"manifest,omitempty"`
Commands []string `json:"commands,omitempty"`
Skills []string `json:"skills,omitempty"`
}
type EthicsContext struct {
Modal string `json:"modal"`
Axioms map[string]any `json:"axioms"`
}
type marketplaceClient interface {
ListMarketplace(ctx context.Context) ([]MarketplacePlugin, error)
PluginInfo(ctx context.Context, name string) (*PluginInfo, error)
EthicsCheck(ctx context.Context) (*EthicsContext, error)
Close() error
}
type mcpMarketplaceClient struct {
client *client.Client
}
func newMarketplaceClient(ctx context.Context, rootHint string) (marketplaceClient, error) {
if ctx == nil {
ctx = context.Background()
}
command, args, err := resolveMarketplaceCommand(rootHint)
if err != nil {
return nil, err
}
mcpClient, err := client.NewStdioMCPClient(command, nil, args...)
if err != nil {
return nil, fmt.Errorf("failed to start marketplace MCP client: %w", err)
}
initRequest := mcp.InitializeRequest{}
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initRequest.Params.ClientInfo = mcp.Implementation{
Name: "bugseti",
Version: GetVersion(),
}
initCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if _, err := mcpClient.Initialize(initCtx, initRequest); err != nil {
_ = mcpClient.Close()
return nil, fmt.Errorf("failed to initialize marketplace MCP client: %w", err)
}
return &mcpMarketplaceClient{client: mcpClient}, nil
}
func (c *mcpMarketplaceClient) Close() error {
if c == nil || c.client == nil {
return nil
}
return c.client.Close()
}
func (c *mcpMarketplaceClient) ListMarketplace(ctx context.Context) ([]MarketplacePlugin, error) {
var marketplace Marketplace
if err := c.callToolStructured(ctx, "marketplace_list", nil, &marketplace); err != nil {
return nil, err
}
return marketplace.Plugins, nil
}
func (c *mcpMarketplaceClient) PluginInfo(ctx context.Context, name string) (*PluginInfo, error) {
var info PluginInfo
args := map[string]any{"name": name}
if err := c.callToolStructured(ctx, "marketplace_plugin_info", args, &info); err != nil {
return nil, err
}
return &info, nil
}
func (c *mcpMarketplaceClient) EthicsCheck(ctx context.Context) (*EthicsContext, error) {
var ethics EthicsContext
if err := c.callToolStructured(ctx, "ethics_check", nil, &ethics); err != nil {
return nil, err
}
return &ethics, nil
}
func (c *mcpMarketplaceClient) callToolStructured(ctx context.Context, name string, args map[string]any, target any) error {
if c == nil || c.client == nil {
return errors.New("marketplace client is not initialized")
}
if ctx == nil {
ctx = context.Background()
}
request := mcp.CallToolRequest{}
request.Params.Name = name
if args != nil {
request.Params.Arguments = args
}
result, err := c.client.CallTool(ctx, request)
if err != nil {
return err
}
if result == nil {
return errors.New("marketplace tool returned no result")
}
if result.IsError {
return fmt.Errorf("marketplace tool %s error: %s", name, toolResultMessage(result))
}
if result.StructuredContent == nil {
return fmt.Errorf("marketplace tool %s returned no structured content", name)
}
payload, err := json.Marshal(result.StructuredContent)
if err != nil {
return fmt.Errorf("failed to encode marketplace response: %w", err)
}
if err := json.Unmarshal(payload, target); err != nil {
return fmt.Errorf("failed to decode marketplace response: %w", err)
}
return nil
}
func toolResultMessage(result *mcp.CallToolResult) string {
if result == nil {
return "unknown error"
}
for _, content := range result.Content {
switch value := content.(type) {
case mcp.TextContent:
if value.Text != "" {
return value.Text
}
case *mcp.TextContent:
if value != nil && value.Text != "" {
return value.Text
}
}
}
return "unknown error"
}
func resolveMarketplaceCommand(rootHint string) (string, []string, error) {
if command := strings.TrimSpace(os.Getenv("BUGSETI_MCP_COMMAND")); command != "" {
args := strings.Fields(os.Getenv("BUGSETI_MCP_ARGS"))
return command, args, nil
}
if root := strings.TrimSpace(rootHint); root != "" {
path := filepath.Join(root, "mcp")
return "go", []string{"run", path}, nil
}
if root := strings.TrimSpace(os.Getenv("BUGSETI_MCP_ROOT")); root != "" {
path := filepath.Join(root, "mcp")
return "go", []string{"run", path}, nil
}
if root, ok := findCoreAgentRoot(); ok {
return "go", []string{"run", filepath.Join(root, "mcp")}, nil
}
return "", nil, fmt.Errorf("marketplace MCP server not configured (set BUGSETI_MCP_COMMAND or BUGSETI_MCP_ROOT)")
}
func findCoreAgentRoot() (string, bool) {
var candidates []string
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, cwd)
candidates = append(candidates, filepath.Dir(cwd))
}
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
candidates = append(candidates, exeDir)
candidates = append(candidates, filepath.Dir(exeDir))
}
seen := make(map[string]bool)
for _, base := range candidates {
base = filepath.Clean(base)
if seen[base] {
continue
}
seen[base] = true
root := filepath.Join(base, "core-agent")
if hasMcpDir(root) {
return root, true
}
root = filepath.Join(base, "..", "core-agent")
if hasMcpDir(root) {
return filepath.Clean(root), true
}
}
return "", false
}
func hasMcpDir(root string) bool {
if root == "" {
return false
}
info, err := os.Stat(filepath.Join(root, "mcp", "main.go"))
return err == nil && !info.IsDir()
}

View file

@ -14,13 +14,15 @@ import (
type NotifyService struct {
enabled bool
sound bool
config *ConfigService
}
// NewNotifyService creates a new NotifyService.
func NewNotifyService() *NotifyService {
func NewNotifyService(config *ConfigService) *NotifyService {
return &NotifyService{
enabled: true,
sound: true,
config: config,
}
}
@ -45,7 +47,11 @@ func (n *NotifyService) Notify(title, message string) error {
return nil
}
log.Printf("Notification: %s - %s", title, message)
guard := getEthicsGuardWithRoot(context.Background(), n.getMarketplaceRoot())
safeTitle := guard.SanitizeNotification(title)
safeMessage := guard.SanitizeNotification(message)
log.Printf("Notification: %s - %s", safeTitle, safeMessage)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -53,11 +59,11 @@ func (n *NotifyService) Notify(title, message string) error {
var err error
switch runtime.GOOS {
case "darwin":
err = n.notifyMacOS(ctx, title, message)
err = n.notifyMacOS(ctx, safeTitle, safeMessage)
case "linux":
err = n.notifyLinux(ctx, title, message)
err = n.notifyLinux(ctx, safeTitle, safeMessage)
case "windows":
err = n.notifyWindows(ctx, title, message)
err = n.notifyWindows(ctx, safeTitle, safeMessage)
default:
err = fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
@ -68,6 +74,13 @@ func (n *NotifyService) Notify(title, message string) error {
return err
}
func (n *NotifyService) getMarketplaceRoot() string {
if n == nil || n.config == nil {
return ""
}
return n.config.GetMarketplaceMCPRoot()
}
// NotifyIssue sends a notification about a new issue.
func (n *NotifyService) NotifyIssue(issue *Issue) error {
title := "New Issue Available"
@ -84,7 +97,7 @@ func (n *NotifyService) NotifyPRStatus(repo string, prNumber int, status string)
// notifyMacOS sends a notification on macOS using osascript.
func (n *NotifyService) notifyMacOS(ctx context.Context, title, message string) error {
script := fmt.Sprintf(`display notification "%s" with title "%s"`, message, title)
script := fmt.Sprintf(`display notification "%s" with title "%s"`, escapeAppleScript(message), escapeAppleScript(title))
if n.sound {
script += ` sound name "Glass"`
}
@ -106,6 +119,9 @@ func (n *NotifyService) notifyLinux(ctx context.Context, title, message string)
// notifyWindows sends a notification on Windows using PowerShell.
func (n *NotifyService) notifyWindows(ctx context.Context, title, message string) error {
title = escapePowerShellXML(title)
message = escapePowerShellXML(message)
script := fmt.Sprintf(`
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null

View file

@ -48,7 +48,8 @@ func (s *SeederService) SeedIssue(issue *Issue) (*IssueContext, error) {
if err != nil {
log.Printf("Seed skill failed, using fallback: %v", err)
// Fallback to basic context preparation
ctx = s.prepareBasicContext(issue)
guard := getEthicsGuardWithRoot(context.Background(), s.config.GetMarketplaceMCPRoot())
ctx = s.prepareBasicContext(issue, guard)
}
ctx.PreparedAt = time.Now()
@ -91,22 +92,20 @@ func (s *SeederService) runSeedSkill(issue *Issue, workDir string) (*IssueContex
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// Look for the plugin script
pluginPaths := []string{
"/home/shared/hostuk/claude-plugins/agentic-flows/skills/seed-agent-developer/scripts/analyze-issue.sh",
filepath.Join(os.Getenv("HOME"), ".claude/plugins/agentic-flows/skills/seed-agent-developer/scripts/analyze-issue.sh"),
}
mcpCtx, mcpCancel := context.WithTimeout(ctx, 20*time.Second)
defer mcpCancel()
var scriptPath string
for _, p := range pluginPaths {
if _, err := os.Stat(p); err == nil {
scriptPath = p
break
}
marketplace, err := newMarketplaceClient(mcpCtx, s.config.GetMarketplaceMCPRoot())
if err != nil {
return nil, err
}
defer marketplace.Close()
if scriptPath == "" {
return nil, fmt.Errorf("seed-agent-developer skill not found")
guard := guardFromMarketplace(mcpCtx, marketplace)
scriptPath, err := findSeedSkillScript(mcpCtx, marketplace)
if err != nil {
return nil, err
}
// Run the analyze-issue script
@ -114,9 +113,9 @@ func (s *SeederService) runSeedSkill(issue *Issue, workDir string) (*IssueContex
cmd.Dir = workDir
cmd.Env = append(os.Environ(),
fmt.Sprintf("ISSUE_NUMBER=%d", issue.Number),
fmt.Sprintf("ISSUE_REPO=%s", issue.Repo),
fmt.Sprintf("ISSUE_TITLE=%s", issue.Title),
fmt.Sprintf("ISSUE_URL=%s", issue.URL),
fmt.Sprintf("ISSUE_REPO=%s", guard.SanitizeEnv(issue.Repo)),
fmt.Sprintf("ISSUE_TITLE=%s", guard.SanitizeEnv(issue.Title)),
fmt.Sprintf("ISSUE_URL=%s", guard.SanitizeEnv(issue.URL)),
)
var stdout, stderr bytes.Buffer
@ -139,36 +138,36 @@ func (s *SeederService) runSeedSkill(issue *Issue, workDir string) (*IssueContex
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
// If not JSON, treat as plain text summary
return &IssueContext{
return sanitizeIssueContext(&IssueContext{
Summary: stdout.String(),
Complexity: "unknown",
}, nil
}, guard), nil
}
return &IssueContext{
return sanitizeIssueContext(&IssueContext{
Summary: result.Summary,
RelevantFiles: result.RelevantFiles,
SuggestedFix: result.SuggestedFix,
RelatedIssues: result.RelatedIssues,
Complexity: result.Complexity,
EstimatedTime: result.EstimatedTime,
}, nil
}, guard), nil
}
// prepareBasicContext creates a basic context without the seed skill.
func (s *SeederService) prepareBasicContext(issue *Issue) *IssueContext {
func (s *SeederService) prepareBasicContext(issue *Issue, guard *EthicsGuard) *IssueContext {
// Extract potential file references from issue body
files := extractFileReferences(issue.Body)
// Estimate complexity based on labels and body length
complexity := estimateComplexity(issue)
return &IssueContext{
return sanitizeIssueContext(&IssueContext{
Summary: fmt.Sprintf("Issue #%d in %s: %s", issue.Number, issue.Repo, issue.Title),
RelevantFiles: files,
Complexity: complexity,
EstimatedTime: estimateTime(complexity),
}
}, guard)
}
// sanitizeRepoName converts owner/repo to a safe directory name.
@ -256,6 +255,87 @@ func estimateTime(complexity string) string {
}
}
const seedSkillName = "seed-agent-developer"
func findSeedSkillScript(ctx context.Context, marketplace marketplaceClient) (string, error) {
if marketplace == nil {
return "", fmt.Errorf("marketplace client is nil")
}
plugins, err := marketplace.ListMarketplace(ctx)
if err != nil {
return "", err
}
for _, plugin := range plugins {
info, err := marketplace.PluginInfo(ctx, plugin.Name)
if err != nil || info == nil {
continue
}
if !containsSkill(info.Skills, seedSkillName) {
continue
}
scriptPath, err := safeJoinUnder(info.Path, "skills", seedSkillName, "scripts", "analyze-issue.sh")
if err != nil {
continue
}
if stat, err := os.Stat(scriptPath); err == nil && !stat.IsDir() {
return scriptPath, nil
}
}
return "", fmt.Errorf("seed-agent-developer skill not found in marketplace")
}
func containsSkill(skills []string, name string) bool {
for _, skill := range skills {
if skill == name {
return true
}
}
return false
}
func safeJoinUnder(base string, elems ...string) (string, error) {
if base == "" {
return "", fmt.Errorf("base path is empty")
}
baseAbs, err := filepath.Abs(base)
if err != nil {
return "", fmt.Errorf("failed to resolve base path: %w", err)
}
joined := filepath.Join(append([]string{baseAbs}, elems...)...)
rel, err := filepath.Rel(baseAbs, joined)
if err != nil {
return "", fmt.Errorf("failed to resolve relative path: %w", err)
}
if strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("resolved path escapes base: %s", rel)
}
return joined, nil
}
func sanitizeIssueContext(ctx *IssueContext, guard *EthicsGuard) *IssueContext {
if ctx == nil {
return nil
}
if guard == nil {
guard = &EthicsGuard{}
}
ctx.Summary = guard.SanitizeSummary(ctx.Summary)
ctx.SuggestedFix = guard.SanitizeSummary(ctx.SuggestedFix)
ctx.Complexity = guard.SanitizeTitle(ctx.Complexity)
ctx.EstimatedTime = guard.SanitizeTitle(ctx.EstimatedTime)
ctx.RelatedIssues = guard.SanitizeList(ctx.RelatedIssues, maxTitleRunes)
ctx.RelevantFiles = guard.SanitizeFiles(ctx.RelevantFiles)
return ctx
}
// GetWorkspaceDir returns the workspace directory for an issue.
func (s *SeederService) GetWorkspaceDir(issue *Issue) string {
baseDir := s.config.GetWorkspaceDir()

View file

@ -0,0 +1,97 @@
package bugseti
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
)
type fakeMarketplaceClient struct {
plugins []MarketplacePlugin
infos map[string]*PluginInfo
listErr error
infoErr map[string]error
}
func (f *fakeMarketplaceClient) ListMarketplace(ctx context.Context) ([]MarketplacePlugin, error) {
if f.listErr != nil {
return nil, f.listErr
}
return f.plugins, nil
}
func (f *fakeMarketplaceClient) PluginInfo(ctx context.Context, name string) (*PluginInfo, error) {
if err, ok := f.infoErr[name]; ok {
return nil, err
}
info, ok := f.infos[name]
if !ok {
return nil, fmt.Errorf("plugin not found")
}
return info, nil
}
func (f *fakeMarketplaceClient) EthicsCheck(ctx context.Context) (*EthicsContext, error) {
return nil, fmt.Errorf("not implemented")
}
func (f *fakeMarketplaceClient) Close() error {
return nil
}
func TestFindSeedSkillScript_Good(t *testing.T) {
root := t.TempDir()
scriptPath := filepath.Join(root, "skills", seedSkillName, "scripts", "analyze-issue.sh")
if err := os.MkdirAll(filepath.Dir(scriptPath), 0755); err != nil {
t.Fatalf("failed to create script directory: %v", err)
}
if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\n"), 0755); err != nil {
t.Fatalf("failed to write script: %v", err)
}
plugin := MarketplacePlugin{Name: "seed-plugin"}
client := &fakeMarketplaceClient{
plugins: []MarketplacePlugin{plugin},
infos: map[string]*PluginInfo{
plugin.Name: {
Plugin: plugin,
Path: root,
Skills: []string{seedSkillName},
},
},
}
found, err := findSeedSkillScript(context.Background(), client)
if err != nil {
t.Fatalf("expected script path, got error: %v", err)
}
if found != scriptPath {
t.Fatalf("expected %q, got %q", scriptPath, found)
}
}
func TestFindSeedSkillScript_Bad(t *testing.T) {
plugin := MarketplacePlugin{Name: "empty-plugin"}
client := &fakeMarketplaceClient{
plugins: []MarketplacePlugin{plugin},
infos: map[string]*PluginInfo{
plugin.Name: {
Plugin: plugin,
Path: t.TempDir(),
Skills: []string{"not-the-skill"},
},
},
}
if _, err := findSeedSkillScript(context.Background(), client); err == nil {
t.Fatal("expected error when skill is missing")
}
}
func TestSafeJoinUnder_Ugly(t *testing.T) {
if _, err := safeJoinUnder("", "skills"); err == nil {
t.Fatal("expected error for empty base path")
}
}

View file

@ -67,6 +67,9 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
return nil, fmt.Errorf("work directory not specified")
}
guard := getEthicsGuardWithRoot(context.Background(), s.config.GetMarketplaceMCPRoot())
issueTitle := guard.SanitizeTitle(issue.Title)
// Step 1: Ensure we have a fork
forkOwner, err := s.ensureFork(issue.Repo)
if err != nil {
@ -85,7 +88,9 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
// Step 3: Stage and commit changes
commitMsg := submission.CommitMsg
if commitMsg == "" {
commitMsg = fmt.Sprintf("fix: resolve issue #%d\n\n%s\n\nFixes #%d", issue.Number, issue.Title, issue.Number)
commitMsg = fmt.Sprintf("fix: resolve issue #%d\n\n%s\n\nFixes #%d", issue.Number, issueTitle, issue.Number)
} else {
commitMsg = guard.SanitizeBody(commitMsg)
}
if err := s.commitChanges(workDir, submission.Files, commitMsg); err != nil {
return &PRResult{Success: false, Error: fmt.Sprintf("commit failed: %v", err)}, err
@ -99,12 +104,15 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
// Step 5: Create PR
prTitle := submission.Title
if prTitle == "" {
prTitle = fmt.Sprintf("Fix #%d: %s", issue.Number, issue.Title)
prTitle = fmt.Sprintf("Fix #%d: %s", issue.Number, issueTitle)
} else {
prTitle = guard.SanitizeTitle(prTitle)
}
prBody := submission.Body
if prBody == "" {
prBody = s.generatePRBody(issue)
}
prBody = guard.SanitizeBody(prBody)
prURL, prNumber, err := s.createPR(issue.Repo, forkOwner, branch, prTitle, prBody)
if err != nil {