diff --git a/cmd/synapbus/admin.go b/cmd/synapbus/admin.go index c82d3bb..fa52d3b 100644 --- a/cmd/synapbus/admin.go +++ b/cmd/synapbus/admin.go @@ -3,12 +3,14 @@ package main import ( "archive/tar" "bufio" + "bytes" "compress/gzip" "encoding/json" "fmt" "io" "net" "os" + "os/exec" "path/filepath" "strings" "text/tabwriter" @@ -1073,10 +1075,213 @@ func addAdminCommands(rootCmd *cobra.Command) { attachmentsCmd.AddCommand(attachmentsGCCmd, attachmentsBackupCmd, attachmentsRestoreCmd) + // ----- harness commands ----- + harnessCmd := &cobra.Command{ + Use: "harness", + Short: "Manage per-agent harness configuration (subprocess / webhook backends)", + } + + harnessConfigCmd := &cobra.Command{ + Use: "config", + Short: "Read / write the harness config for an agent", + } + + var harnessConfigGetAgent string + var harnessConfigGetRaw bool + harnessConfigGetCmd := &cobra.Command{ + Use: "get", + Short: "Print an agent's harness_name, local_command, and harness_config_json", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := adminRequest("harness.config_get", map[string]any{ + "agent_name": harnessConfigGetAgent, + }) + if err != nil { + return err + } + data, _ := resp["data"].(map[string]any) + if harnessConfigGetRaw { + // Print just the harness_config_json string — suitable + // for piping into `set` after editing. + if s, ok := data["harness_config_json"].(string); ok { + fmt.Println(s) + } + return nil + } + printJSON(data) + return nil + }, + } + harnessConfigGetCmd.Flags().StringVar(&harnessConfigGetAgent, "agent", "", "Agent name") + harnessConfigGetCmd.Flags().BoolVar(&harnessConfigGetRaw, "raw", false, "Print only the harness_config_json string (no envelope)") + _ = harnessConfigGetCmd.MarkFlagRequired("agent") + + var ( + harnessConfigSetAgent string + harnessConfigSetHarnessName string + harnessConfigSetLocalCommand string + harnessConfigSetFile string + harnessConfigSetClear bool + ) + harnessConfigSetCmd := &cobra.Command{ + Use: "set", + Short: "Update an agent's harness config. Reads JSON from --file or stdin.", + Long: `Update an agent's harness_name, local_command, and/or harness_config_json. + +Fields left unset are unchanged. To CLEAR a field, use --clear on a set +that targets only that field, or pass an empty string to the underlying +admin call. + +Examples: + + # Set subprocess backend + local command + synapbus harness config set --agent researcher \ + --harness-name subprocess \ + --local-command '["claude","--print","--max-turns","50"]' + + # Load harness_config_json from a file (CLAUDE.md, mcp_servers, skills) + synapbus harness config set --agent researcher --file ./researcher.json + + # Pipe in from another command + cat config.json | synapbus harness config set --agent researcher + + # Clear the harness_config_json + synapbus harness config set --agent researcher --clear`, + RunE: func(cmd *cobra.Command, args []string) error { + reqArgs := map[string]any{"agent_name": harnessConfigSetAgent} + if harnessConfigSetHarnessName != "" { + reqArgs["harness_name"] = harnessConfigSetHarnessName + } + if harnessConfigSetLocalCommand != "" { + reqArgs["local_command"] = harnessConfigSetLocalCommand + } + + var configBytes []byte + if harnessConfigSetClear { + reqArgs["harness_config_json"] = json.RawMessage(`"-"`) + } else if harnessConfigSetFile != "" { + b, err := os.ReadFile(harnessConfigSetFile) + if err != nil { + return fmt.Errorf("read --file: %w", err) + } + configBytes = b + } else { + // If stdin has data, read it. Otherwise just send the + // other flags and leave harness_config_json unchanged. + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("read stdin: %w", err) + } + if len(bytes.TrimSpace(b)) > 0 { + configBytes = b + } + } + } + if len(configBytes) > 0 { + if !json.Valid(configBytes) { + return fmt.Errorf("harness_config_json is not valid JSON") + } + reqArgs["harness_config_json"] = json.RawMessage(configBytes) + } + + resp, err := adminRequest("harness.config_set", reqArgs) + if err != nil { + return err + } + printJSON(resp["data"]) + return nil + }, + } + harnessConfigSetCmd.Flags().StringVar(&harnessConfigSetAgent, "agent", "", "Agent name") + harnessConfigSetCmd.Flags().StringVar(&harnessConfigSetHarnessName, "harness-name", "", "Backend (k8sjob / subprocess / webhook)") + harnessConfigSetCmd.Flags().StringVar(&harnessConfigSetLocalCommand, "local-command", "", "JSON argv for subprocess backend") + harnessConfigSetCmd.Flags().StringVar(&harnessConfigSetFile, "file", "", "Path to harness_config_json file") + harnessConfigSetCmd.Flags().BoolVar(&harnessConfigSetClear, "clear", false, "Clear harness_config_json (set to NULL)") + _ = harnessConfigSetCmd.MarkFlagRequired("agent") + + var harnessConfigEditAgent string + harnessConfigEditCmd := &cobra.Command{ + Use: "edit", + Short: "Open the current harness_config_json in $EDITOR and save on exit", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := adminRequest("harness.config_get", map[string]any{ + "agent_name": harnessConfigEditAgent, + }) + if err != nil { + return err + } + data, _ := resp["data"].(map[string]any) + current, _ := data["harness_config_json"].(string) + if current == "" { + current = "{}" + } else { + // Pretty-print for a better editing experience. + var pretty any + if err := json.Unmarshal([]byte(current), &pretty); err == nil { + if b, err := json.MarshalIndent(pretty, "", " "); err == nil { + current = string(b) + } + } + } + + tmp, err := os.CreateTemp("", "synapbus-harness-*.json") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := tmp.WriteString(current); err != nil { + tmp.Close() + return err + } + tmp.Close() + + editor := os.Getenv("VISUAL") + if editor == "" { + editor = os.Getenv("EDITOR") + } + if editor == "" { + editor = "vi" + } + editCmd := exec.Command("sh", "-c", editor+" "+tmpPath) + editCmd.Stdin = os.Stdin + editCmd.Stdout = os.Stdout + editCmd.Stderr = os.Stderr + if err := editCmd.Run(); err != nil { + return fmt.Errorf("editor: %w", err) + } + + edited, err := os.ReadFile(tmpPath) + if err != nil { + return err + } + if !json.Valid(edited) { + return fmt.Errorf("edited file is not valid JSON — aborting (nothing saved)") + } + + resp, err = adminRequest("harness.config_set", map[string]any{ + "agent_name": harnessConfigEditAgent, + "harness_config_json": json.RawMessage(edited), + }) + if err != nil { + return err + } + fmt.Println("saved") + printJSON(resp["data"]) + return nil + }, + } + harnessConfigEditCmd.Flags().StringVar(&harnessConfigEditAgent, "agent", "", "Agent name") + _ = harnessConfigEditCmd.MarkFlagRequired("agent") + + harnessConfigCmd.AddCommand(harnessConfigGetCmd, harnessConfigSetCmd, harnessConfigEditCmd) + harnessCmd.AddCommand(harnessConfigCmd) + // ----- add persistent flag and commands to root ----- rootCmd.PersistentFlags().StringVar(&adminSocket, "socket", "/tmp/synapbus.sock", "Path to admin Unix socket") - rootCmd.AddCommand(userCmd, agentCmd, auditCmd, backupCmd, messagesCmd, channelsCmd, conversationsCmd, embeddingsCmd, dbCmd, retentionCmd, webhookCmd, k8sCmd, attachmentsCmd) + rootCmd.AddCommand(userCmd, agentCmd, auditCmd, backupCmd, messagesCmd, channelsCmd, conversationsCmd, embeddingsCmd, dbCmd, retentionCmd, webhookCmd, k8sCmd, attachmentsCmd, harnessCmd) } // toTableRows remaps []map[string]string using a header->key mapping. diff --git a/internal/admin/socket.go b/internal/admin/socket.go index fd0904e..e7ffb75 100644 --- a/internal/admin/socket.go +++ b/internal/admin/socket.go @@ -218,6 +218,12 @@ func (s *AdminServer) dispatch(req Request) Response { case "attachments.gc": return s.handleAttachmentsGC(ctx) + // --- harness config (subprocess / webhook agent config) --- + case "harness.config_get": + return s.handleHarnessConfigGet(ctx, req.Args) + case "harness.config_set": + return s.handleHarnessConfigSet(ctx, req.Args) + default: return Response{OK: false, Error: fmt.Sprintf("unknown command: %s", req.Command)} } @@ -1691,5 +1697,97 @@ func (s *AdminServer) handleAttachmentsGC(ctx context.Context) Response { }} } +// ---------- harness config handlers ---------- + +func (s *AdminServer) handleHarnessConfigGet(ctx context.Context, args json.RawMessage) Response { + var p struct { + AgentName string `json:"agent_name"` + } + if err := json.Unmarshal(args, &p); err != nil { + return Response{OK: false, Error: "invalid args: " + err.Error()} + } + if p.AgentName == "" { + return Response{OK: false, Error: "agent_name is required"} + } + agent, err := s.services.Agents.GetAgent(ctx, p.AgentName) + if err != nil { + return Response{OK: false, Error: err.Error()} + } + // Parse harness_config_json if present so the caller gets + // structured output instead of an opaque string. Tolerate empty. + var parsed any + if agent.HarnessConfigJSON != "" { + if err := json.Unmarshal([]byte(agent.HarnessConfigJSON), &parsed); err != nil { + // Return raw + parse error, not a hard failure — callers + // might legitimately want to see broken config to fix it. + return Response{OK: true, Data: map[string]any{ + "agent_name": agent.Name, + "harness_name": agent.HarnessName, + "local_command": agent.LocalCommand, + "harness_config_json": agent.HarnessConfigJSON, + "parse_error": err.Error(), + }} + } + } + return Response{OK: true, Data: map[string]any{ + "agent_name": agent.Name, + "harness_name": agent.HarnessName, + "local_command": agent.LocalCommand, + "harness_config_json": agent.HarnessConfigJSON, + "harness_config": parsed, + }} +} + +func (s *AdminServer) handleHarnessConfigSet(ctx context.Context, args json.RawMessage) Response { + var p struct { + AgentName string `json:"agent_name"` + HarnessName string `json:"harness_name"` + LocalCommand string `json:"local_command"` + HarnessConfigJSON json.RawMessage `json:"harness_config_json"` + } + if err := json.Unmarshal(args, &p); err != nil { + return Response{OK: false, Error: "invalid args: " + err.Error()} + } + if p.AgentName == "" { + return Response{OK: false, Error: "agent_name is required"} + } + + // Validate harness_config_json shape when provided. An empty object + // ({}) and null are both valid (clear with "-"); otherwise it must + // parse as JSON. + configJSON := "" + if len(p.HarnessConfigJSON) > 0 { + raw := strings.TrimSpace(string(p.HarnessConfigJSON)) + switch raw { + case "", "null": + configJSON = "-" + case `"-"`: + configJSON = "-" + default: + if !json.Valid(p.HarnessConfigJSON) { + return Response{OK: false, Error: "harness_config_json is not valid JSON"} + } + configJSON = string(p.HarnessConfigJSON) + } + } + + // Delegate to the store. Empty strings mean "leave unchanged", + // "-" means "clear". + if err := s.services.Agents.Store().UpdateHarnessConfig(ctx, p.AgentName, p.HarnessName, p.LocalCommand, configJSON); err != nil { + return Response{OK: false, Error: err.Error()} + } + + agent, err := s.services.Agents.GetAgent(ctx, p.AgentName) + if err != nil { + return Response{OK: false, Error: err.Error()} + } + return Response{OK: true, Data: map[string]any{ + "agent_name": agent.Name, + "harness_name": agent.HarnessName, + "local_command": agent.LocalCommand, + "harness_config_json": agent.HarnessConfigJSON, + }} +} + // Ensure the messaging import is used. var _ = messaging.StatusPending diff --git a/internal/agents/service.go b/internal/agents/service.go index 83b045f..443d032 100644 --- a/internal/agents/service.go +++ b/internal/agents/service.go @@ -37,6 +37,13 @@ func (s *AgentService) SetDeadLetterStore(dls *messaging.DeadLetterStore) { s.deadLetterStore = dls } +// Store returns the underlying AgentStore. Exposed so admin handlers +// can reach store-level helpers (e.g. UpdateHarnessConfig) that don't +// warrant full service-level business logic of their own. +func (s *AgentService) Store() AgentStore { + return s.store +} + // Register creates a new agent with a generated API key. // Returns the agent and the raw API key (shown once). func (s *AgentService) Register(ctx context.Context, name, displayName, agentType string, capabilities json.RawMessage, ownerID int64) (*Agent, string, error) { diff --git a/internal/agents/store.go b/internal/agents/store.go index df1010b..7abeca6 100644 --- a/internal/agents/store.go +++ b/internal/agents/store.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "strings" ) // AgentStore defines the storage interface for agent operations. @@ -25,6 +26,11 @@ type AgentStore interface { UpdateK8sImage(ctx context.Context, name, image, envJSON, preset string) error SetPendingWork(ctx context.Context, name string, pending bool) error ListReactiveAgents(ctx context.Context) ([]*Agent, error) + + // Harness config (migration 019). Pass empty strings to leave a + // field unchanged; pass a blank placeholder ("-") to explicitly + // clear it. Returns sql.ErrNoRows if the agent doesn't exist. + UpdateHarnessConfig(ctx context.Context, name, harnessName, localCommand, harnessConfigJSON string) error } // SQLiteAgentStore implements AgentStore using SQLite. @@ -107,6 +113,57 @@ func (s *SQLiteAgentStore) UpdateK8sImage(ctx context.Context, name, image, envJ return err } +// UpdateHarnessConfig updates any subset of harness_name / local_command / +// harness_config_json for an agent. Pass empty string to leave a field +// unchanged; pass "-" to explicitly clear (set to NULL). +func (s *SQLiteAgentStore) UpdateHarnessConfig(ctx context.Context, name, harnessName, localCommand, harnessConfigJSON string) error { + sets := []string{} + args := []any{} + if harnessName != "" { + sets = append(sets, "harness_name = ?") + if harnessName == "-" { + args = append(args, nil) + } else { + args = append(args, harnessName) + } + } + if localCommand != "" { + sets = append(sets, "local_command = ?") + if localCommand == "-" { + args = append(args, nil) + } else { + args = append(args, localCommand) + } + } + if harnessConfigJSON != "" { + sets = append(sets, "harness_config_json = ?") + if harnessConfigJSON == "-" { + args = append(args, nil) + } else { + args = append(args, harnessConfigJSON) + } + } + if len(sets) == 0 { + return nil + } + sets = append(sets, "updated_at = CURRENT_TIMESTAMP") + args = append(args, name) + query := "UPDATE agents SET " + strings.Join(sets, ", ") + " WHERE name = ? AND status = 'active'" + + result, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("update harness config: %w", err) + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + return nil +} + // SetPendingWork sets the pending_work flag for an agent. func (s *SQLiteAgentStore) SetPendingWork(ctx context.Context, name string, pending bool) error { val := 0 diff --git a/internal/agents/store_test.go b/internal/agents/store_test.go index 0791958..dab437b 100644 --- a/internal/agents/store_test.go +++ b/internal/agents/store_test.go @@ -77,6 +77,73 @@ func TestSQLiteAgentStore_CreateAndGet(t *testing.T) { } } +func TestSQLiteAgentStore_UpdateHarnessConfig(t *testing.T) { + db := newTestDB(t) + store := NewSQLiteAgentStore(db) + ctx := context.Background() + + agent := &Agent{ + Name: "harness-bot", + DisplayName: "Harness Bot", + Type: "ai", + OwnerID: 1, + APIKeyHash: "h", + } + if err := store.CreateAgent(ctx, agent); err != nil { + t.Fatalf("CreateAgent: %v", err) + } + + // Set all three fields at once. + cfgJSON := `{"claude_md":"You are X","env":{"FOO":"1"}}` + if err := store.UpdateHarnessConfig(ctx, "harness-bot", "subprocess", `["sh","-c","true"]`, cfgJSON); err != nil { + t.Fatalf("UpdateHarnessConfig: %v", err) + } + got, _ := store.GetAgentByName(ctx, "harness-bot") + if got.HarnessName != "subprocess" { + t.Errorf("HarnessName = %q", got.HarnessName) + } + if got.LocalCommand != `["sh","-c","true"]` { + t.Errorf("LocalCommand = %q", got.LocalCommand) + } + if got.HarnessConfigJSON != cfgJSON { + t.Errorf("HarnessConfigJSON = %q", got.HarnessConfigJSON) + } + + // Partial update: only local_command changes. + if err := store.UpdateHarnessConfig(ctx, "harness-bot", "", `["claude","--print"]`, ""); err != nil { + t.Fatalf("UpdateHarnessConfig partial: %v", err) + } + got, _ = store.GetAgentByName(ctx, "harness-bot") + if got.LocalCommand != `["claude","--print"]` { + t.Errorf("LocalCommand after partial = %q", got.LocalCommand) + } + if got.HarnessName != "subprocess" { + t.Errorf("HarnessName should be unchanged: %q", got.HarnessName) + } + if got.HarnessConfigJSON != cfgJSON { + t.Errorf("HarnessConfigJSON should be unchanged") + } + + // Clear harness_config_json via "-" + if err := store.UpdateHarnessConfig(ctx, "harness-bot", "", "", "-"); err != nil { + t.Fatalf("UpdateHarnessConfig clear: %v", err) + } + got, _ = store.GetAgentByName(ctx, "harness-bot") + if got.HarnessConfigJSON != "" { + t.Errorf("HarnessConfigJSON not cleared: %q", got.HarnessConfigJSON) + } + + // Unknown agent → sql.ErrNoRows. + if err := store.UpdateHarnessConfig(ctx, "no-such-agent", "subprocess", "", ""); err == nil { + t.Error("expected ErrNoRows for missing agent") + } + + // No fields → no-op (no error). + if err := store.UpdateHarnessConfig(ctx, "harness-bot", "", "", ""); err != nil { + t.Errorf("no-field update: %v", err) + } +} + func TestSQLiteAgentStore_DuplicateName(t *testing.T) { db := newTestDB(t) store := NewSQLiteAgentStore(db) diff --git a/internal/harness/subprocess/config.go b/internal/harness/subprocess/config.go new file mode 100644 index 0000000..c03403a --- /dev/null +++ b/internal/harness/subprocess/config.go @@ -0,0 +1,220 @@ +package subprocess + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// AgentConfig is the typed shape of an agent's harness_config_json +// when that agent targets the subprocess backend. All fields are +// optional — an empty config is valid and simply does nothing. +// +// This config is stored on the agent row (harness_config_json) and +// edited via the admin CLI: +// +// synapbus harness config get --agent +// synapbus harness config set --agent --file config.json +// +// At Execute time the harness copies these fields into the per-run +// workdir so Claude Code / Gemini / Codex CLIs can find them via the +// conventions they already use (CLAUDE.md / AGENTS.md / .mcp.json +// in cwd; skills under .claude/skills; subagents under .claude/agents). +type AgentConfig struct { + // ClaudeMD is the content of CLAUDE.md written into workdir. + ClaudeMD string `json:"claude_md,omitempty"` + + // AgentsMD is the content of AGENTS.md written into workdir. Used + // by Codex / Gemini CLIs that follow the AGENTS.md convention. + AgentsMD string `json:"agents_md,omitempty"` + + // MCPServers become the `mcpServers` object in workdir/.mcp.json. + // Claude Code picks this up from cwd automatically; other CLIs + // can be pointed at it with an explicit flag in local_command. + MCPServers []MCPServerSpec `json:"mcp_servers,omitempty"` + + // Skills materialise as workdir/.claude/skills//SKILL.md. + // Each entry's Content is written verbatim — typically a YAML + // frontmatter block followed by markdown body, matching the + // superpowers skill format. + Skills []SkillSpec `json:"skills,omitempty"` + + // Subagents materialise as workdir/.claude/agents/.md. + Subagents []SubagentSpec `json:"subagents,omitempty"` + + // Env is an extra env-var map layered on top of the agent's + // k8s_env_json and the caller-supplied Env. Last write wins, so + // use this field for agent-specific overrides. + Env map[string]string `json:"env,omitempty"` +} + +// MCPServerSpec is one entry of the .mcp.json `mcpServers` object. +// We intentionally mirror Claude Code's shape so the marshalled file +// is drop-in compatible. +type MCPServerSpec struct { + // Name is the key in the mcpServers object (e.g. "synapbus"). + Name string `json:"name"` + + // Type is the transport. Claude Code accepts "stdio" (default), + // "http", and "sse". Empty = stdio. + Type string `json:"type,omitempty"` + + // Stdio transport fields + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + + // HTTP / SSE transport fields + URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + + // Env is passed to the MCP server child (stdio only). + Env map[string]string `json:"env,omitempty"` +} + +// SkillSpec is one entry under .claude/skills. +type SkillSpec struct { + Name string `json:"name"` // directory name — sanitised at write time + Content string `json:"content"` // SKILL.md body (typically YAML + md) +} + +// SubagentSpec is one entry under .claude/agents. +type SubagentSpec struct { + Name string `json:"name"` // file basename — sanitised at write time + Content string `json:"content"` // markdown body +} + +// ParseAgentConfig tolerates empty / nil / invalid JSON. Empty yields +// a zero-value AgentConfig; invalid JSON returns an error so operators +// see the typo at Execute time rather than silently booting a +// misconfigured agent. +func ParseAgentConfig(raw string) (AgentConfig, error) { + var cfg AgentConfig + if raw == "" { + return cfg, nil + } + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return cfg, fmt.Errorf("subprocess: parse harness_config_json: %w", err) + } + return cfg, nil +} + +// MaterialiseAgentConfig writes the config into workdir so the child +// process can find it. Existing files are overwritten; missing parent +// dirs are created. Returns the first write error encountered. +func MaterialiseAgentConfig(workdir string, cfg AgentConfig) error { + if cfg.ClaudeMD != "" { + if err := writeFile(filepath.Join(workdir, "CLAUDE.md"), cfg.ClaudeMD); err != nil { + return err + } + } + if cfg.AgentsMD != "" { + if err := writeFile(filepath.Join(workdir, "AGENTS.md"), cfg.AgentsMD); err != nil { + return err + } + } + if len(cfg.MCPServers) > 0 { + if err := writeMCPConfig(workdir, cfg.MCPServers); err != nil { + return err + } + } + for _, sk := range cfg.Skills { + if err := writeSkill(workdir, sk); err != nil { + return err + } + } + for _, sa := range cfg.Subagents { + if err := writeSubagent(workdir, sa); err != nil { + return err + } + } + return nil +} + +// mcpConfigFile is the wire format of .mcp.json. We keep the outer +// struct small so marshalling produces exactly the shape Claude Code +// expects. +type mcpConfigFile struct { + MCPServers map[string]mcpServerEntry `json:"mcpServers"` +} + +type mcpServerEntry struct { + Type string `json:"type,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +func writeMCPConfig(workdir string, servers []MCPServerSpec) error { + out := mcpConfigFile{MCPServers: map[string]mcpServerEntry{}} + for _, s := range servers { + if s.Name == "" { + continue + } + out.MCPServers[s.Name] = mcpServerEntry{ + Type: s.Type, + Command: s.Command, + Args: s.Args, + URL: s.URL, + Headers: s.Headers, + Env: s.Env, + } + } + raw, err := json.MarshalIndent(out, "", " ") + if err != nil { + return fmt.Errorf("subprocess: marshal mcp config: %w", err) + } + return writeFile(filepath.Join(workdir, ".mcp.json"), string(raw)) +} + +func writeSkill(workdir string, sk SkillSpec) error { + name := sanitizeChildName(sk.Name) + if name == "" { + return fmt.Errorf("subprocess: skill has empty name") + } + dir := filepath.Join(workdir, ".claude", "skills", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("subprocess: mkdir skill %q: %w", name, err) + } + return writeFile(filepath.Join(dir, "SKILL.md"), sk.Content) +} + +func writeSubagent(workdir string, sa SubagentSpec) error { + name := sanitizeChildName(sa.Name) + if name == "" { + return fmt.Errorf("subprocess: subagent has empty name") + } + dir := filepath.Join(workdir, ".claude", "agents") + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("subprocess: mkdir agents: %w", err) + } + return writeFile(filepath.Join(dir, name+".md"), sa.Content) +} + +func writeFile(path, content string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("subprocess: mkdir %q: %w", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("subprocess: write %q: %w", path, err) + } + return nil +} + +// sanitizeChildName strips path separators and other characters that +// would escape the workdir. Keeps [A-Za-z0-9._-] only. +func sanitizeChildName(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + out = append(out, c) + case c == '.' || c == '_' || c == '-': + out = append(out, c) + } + } + return string(out) +} diff --git a/internal/harness/subprocess/config_test.go b/internal/harness/subprocess/config_test.go new file mode 100644 index 0000000..3e3864f --- /dev/null +++ b/internal/harness/subprocess/config_test.go @@ -0,0 +1,219 @@ +package subprocess_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/synapbus/synapbus/internal/harness/subprocess" +) + +func TestParseAgentConfig_Empty(t *testing.T) { + cfg, err := subprocess.ParseAgentConfig("") + if err != nil { + t.Fatalf("empty: %v", err) + } + if cfg.ClaudeMD != "" || len(cfg.MCPServers) != 0 { + t.Errorf("empty config not zero: %+v", cfg) + } +} + +func TestParseAgentConfig_InvalidJSON(t *testing.T) { + _, err := subprocess.ParseAgentConfig("not-json") + if err == nil { + t.Fatal("expected parse error for invalid JSON") + } +} + +func TestParseAgentConfig_AllFields(t *testing.T) { + raw := `{ + "claude_md": "You are X", + "agents_md": "You are X (codex)", + "mcp_servers": [ + {"name":"synapbus","type":"http","url":"http://kubic.home.arpa:30088/mcp","headers":{"Authorization":"Bearer abc"}}, + {"name":"local","command":"npx","args":["-y","@foo/mcp"],"env":{"KEY":"val"}} + ], + "skills": [ + {"name":"brainstorming","content":"---\nname: brainstorming\n---\nbody"} + ], + "subagents": [ + {"name":"researcher","content":"# researcher"} + ], + "env": {"EXTRA":"yes"} + }` + cfg, err := subprocess.ParseAgentConfig(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if cfg.ClaudeMD != "You are X" { + t.Errorf("claude_md = %q", cfg.ClaudeMD) + } + if len(cfg.MCPServers) != 2 { + t.Fatalf("mcp_servers len = %d", len(cfg.MCPServers)) + } + if cfg.MCPServers[0].Type != "http" || cfg.MCPServers[0].URL == "" { + t.Errorf("http mcp server: %+v", cfg.MCPServers[0]) + } + if cfg.MCPServers[1].Command != "npx" || len(cfg.MCPServers[1].Args) != 2 { + t.Errorf("stdio mcp server: %+v", cfg.MCPServers[1]) + } + if len(cfg.Skills) != 1 || cfg.Skills[0].Name != "brainstorming" { + t.Errorf("skills: %+v", cfg.Skills) + } + if len(cfg.Subagents) != 1 || cfg.Subagents[0].Name != "researcher" { + t.Errorf("subagents: %+v", cfg.Subagents) + } + if cfg.Env["EXTRA"] != "yes" { + t.Errorf("env: %+v", cfg.Env) + } +} + +func TestMaterialise_WritesAllArtifacts(t *testing.T) { + workdir := t.TempDir() + cfg := subprocess.AgentConfig{ + ClaudeMD: "# Agent\nYou are helpful.", + AgentsMD: "# Agent (codex form)", + MCPServers: []subprocess.MCPServerSpec{ + {Name: "synapbus", Type: "http", URL: "http://kubic:30088/mcp", Headers: map[string]string{"Authorization": "Bearer xxx"}}, + {Name: "filesystem", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"}}, + }, + Skills: []subprocess.SkillSpec{ + {Name: "brainstorming", Content: "---\nname: brainstorming\n---\n\nBody"}, + {Name: "debugging", Content: "---\nname: debugging\n---\n\nBody2"}, + }, + Subagents: []subprocess.SubagentSpec{ + {Name: "researcher", Content: "# researcher agent"}, + }, + } + if err := subprocess.MaterialiseAgentConfig(workdir, cfg); err != nil { + t.Fatalf("materialise: %v", err) + } + + // CLAUDE.md + got, err := os.ReadFile(filepath.Join(workdir, "CLAUDE.md")) + if err != nil { + t.Fatalf("CLAUDE.md: %v", err) + } + if !strings.Contains(string(got), "You are helpful.") { + t.Errorf("CLAUDE.md content = %q", got) + } + + // AGENTS.md + got, err = os.ReadFile(filepath.Join(workdir, "AGENTS.md")) + if err != nil { + t.Fatalf("AGENTS.md: %v", err) + } + if !strings.Contains(string(got), "codex form") { + t.Errorf("AGENTS.md content = %q", got) + } + + // .mcp.json — verify structure matches Claude Code expected shape + raw, err := os.ReadFile(filepath.Join(workdir, ".mcp.json")) + if err != nil { + t.Fatalf(".mcp.json: %v", err) + } + var parsed struct { + MCPServers map[string]struct { + Type string `json:"type,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + } `json:"mcpServers"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("parse .mcp.json: %v", err) + } + syn, ok := parsed.MCPServers["synapbus"] + if !ok { + t.Fatal(".mcp.json missing synapbus entry") + } + if syn.Type != "http" || syn.URL != "http://kubic:30088/mcp" { + t.Errorf("synapbus entry = %+v", syn) + } + if syn.Headers["Authorization"] != "Bearer xxx" { + t.Errorf("synapbus headers = %+v", syn.Headers) + } + fs, ok := parsed.MCPServers["filesystem"] + if !ok { + t.Fatal(".mcp.json missing filesystem entry") + } + if fs.Command != "npx" || len(fs.Args) != 3 { + t.Errorf("filesystem entry = %+v", fs) + } + + // .claude/skills//SKILL.md for each skill + for _, name := range []string{"brainstorming", "debugging"} { + p := filepath.Join(workdir, ".claude", "skills", name, "SKILL.md") + if _, err := os.Stat(p); err != nil { + t.Errorf("skill %q not materialised: %v", name, err) + } + } + + // .claude/agents/.md + if _, err := os.Stat(filepath.Join(workdir, ".claude", "agents", "researcher.md")); err != nil { + t.Errorf("subagent not materialised: %v", err) + } +} + +func TestMaterialise_EmptyConfigIsNoOp(t *testing.T) { + workdir := t.TempDir() + if err := subprocess.MaterialiseAgentConfig(workdir, subprocess.AgentConfig{}); err != nil { + t.Fatalf("empty: %v", err) + } + entries, _ := os.ReadDir(workdir) + if len(entries) != 0 { + t.Errorf("empty config produced %d entries: %v", len(entries), entries) + } +} + +func TestMaterialise_SanitizesSkillNames(t *testing.T) { + workdir := t.TempDir() + cfg := subprocess.AgentConfig{ + Skills: []subprocess.SkillSpec{ + {Name: "../escape", Content: "x"}, + {Name: "/etc/passwd", Content: "x"}, + {Name: "ok-skill_1.0", Content: "x"}, + }, + } + if err := subprocess.MaterialiseAgentConfig(workdir, cfg); err != nil { + t.Fatalf("materialise: %v", err) + } + + // The "../escape" should become "..escape" after sanitisation — no path escape. + // Path traversal via cfg must NOT land outside workdir. + parent := filepath.Dir(workdir) + escaped, _ := os.ReadDir(parent) + for _, e := range escaped { + if e.Name() == "escape" || e.Name() == "etc" { + t.Errorf("sanitisation failed: found %q outside workdir", e.Name()) + } + } + + // Valid skill is present. + if _, err := os.Stat(filepath.Join(workdir, ".claude", "skills", "ok-skill_1.0", "SKILL.md")); err != nil { + t.Errorf("valid skill missing: %v", err) + } +} + +func TestMaterialise_MCPServerWithoutName_Skipped(t *testing.T) { + workdir := t.TempDir() + cfg := subprocess.AgentConfig{ + MCPServers: []subprocess.MCPServerSpec{ + {Name: "", URL: "http://ignored"}, + {Name: "good", URL: "http://good"}, + }, + } + if err := subprocess.MaterialiseAgentConfig(workdir, cfg); err != nil { + t.Fatalf("materialise: %v", err) + } + raw, _ := os.ReadFile(filepath.Join(workdir, ".mcp.json")) + if !strings.Contains(string(raw), `"good"`) { + t.Errorf("good entry missing: %s", raw) + } + if strings.Contains(string(raw), "ignored") { + t.Errorf("empty-name entry should be dropped: %s", raw) + } +} diff --git a/internal/harness/subprocess/subprocess.go b/internal/harness/subprocess/subprocess.go index 04682d6..d921f8e 100644 --- a/internal/harness/subprocess/subprocess.go +++ b/internal/harness/subprocess/subprocess.go @@ -153,9 +153,21 @@ func (h *Harness) Execute(ctx context.Context, req *harness.ExecRequest) (*harne _ = os.WriteFile(filepath.Join(workdir, "message.json"), raw, 0o644) } + // Materialise the agent's declarative config (CLAUDE.md, AGENTS.md, + // .mcp.json, .claude/skills/*, .claude/agents/*) into the workdir. + // The child's CLI (claude, gemini, codex) finds them via the + // conventions it already uses. + cfg, err := ParseAgentConfig(req.Agent.HarnessConfigJSON) + if err != nil { + return nil, err + } + if err := MaterialiseAgentConfig(workdir, cfg); err != nil { + return nil, err + } + cmd := exec.CommandContext(runCtx, argv[0], argv[1:]...) cmd.Dir = workdir - cmd.Env = buildEnv(req, workdir) + cmd.Env = buildEnv(req, workdir, cfg) var stdout, stderr bytes.Buffer cmd.Stdout = io.MultiWriter(&stdout, limitedFileWriter(workdir, "stdout.log")) @@ -261,9 +273,10 @@ func parseLocalCommand(raw string) ([]string, error) { // buildEnv constructs the env var list for the child. Starts from the // parent's environment (so HOME, PATH, credentials are inherited by // default — matches current K8s Pod behaviour). Then layers the -// agent's k8s_env_json (for consistency between backends), then caller -// overrides, then the SYNAPBUS_* run-context variables. -func buildEnv(req *harness.ExecRequest, workdir string) []string { +// agent's k8s_env_json (for cross-backend consistency), then the +// harness_config_json `env` block (per-agent harness-specific env), +// then caller overrides, then the SYNAPBUS_* run-context variables. +func buildEnv(req *harness.ExecRequest, workdir string, cfg AgentConfig) []string { env := map[string]string{} for _, kv := range os.Environ() { if i := strings.IndexByte(kv, '='); i >= 0 { @@ -286,6 +299,11 @@ func buildEnv(req *harness.ExecRequest, workdir string) []string { } } + // harness_config_json env block + for k, v := range cfg.Env { + env[k] = v + } + // caller overrides for k, v := range req.Env { env[k] = v diff --git a/internal/harness/subprocess/subprocess_test.go b/internal/harness/subprocess/subprocess_test.go index 0408ca4..6eaded2 100644 --- a/internal/harness/subprocess/subprocess_test.go +++ b/internal/harness/subprocess/subprocess_test.go @@ -303,6 +303,75 @@ func TestSubprocess_Execute_NoLocalCommand(t *testing.T) { } } +func TestSubprocess_Execute_MaterialisesHarnessConfig(t *testing.T) { + requirePosix(t) + + baseDir := t.TempDir() + h := subprocess.New(subprocess.Config{BaseDir: baseDir, KeepWorkdirOnSuccess: true}, nil) + + cfg := `{ + "claude_md": "You are tester", + "mcp_servers": [ + {"name":"synapbus","type":"http","url":"http://kubic:30088/mcp"} + ], + "skills": [ + {"name":"debugging","content":"---\nname: debugging\n---\nbody"} + ], + "env": {"AGENT_GREETING":"hello"} + }` + agent := &agents.Agent{ + Name: "e2e", + LocalCommand: `["sh","-c","cat CLAUDE.md >> result.out; cat .mcp.json >> result.out; cat .claude/skills/debugging/SKILL.md >> result.out; echo GREETING=$AGENT_GREETING >> result.out; echo ok"]`, + HarnessConfigJSON: cfg, + } + req := &harness.ExecRequest{ + RunID: "e2e-run", + AgentName: "e2e", + Agent: agent, + } + res, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if res.ExitCode != 0 { + t.Errorf("ExitCode = %d", res.ExitCode) + } + + out, err := os.ReadFile(filepath.Join(baseDir, "e2e-run", "result.out")) + if err != nil { + t.Fatalf("read result.out: %v", err) + } + got := string(out) + for _, want := range []string{ + "You are tester", + `"synapbus"`, + "http://kubic:30088/mcp", + "name: debugging", + "GREETING=hello", + } { + if !strings.Contains(got, want) { + t.Errorf("result.out missing %q:\n%s", want, got) + } + } +} + +func TestSubprocess_Execute_InvalidHarnessConfigErrors(t *testing.T) { + h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil) + req := &harness.ExecRequest{ + RunID: "r", + AgentName: "a", + Agent: &agents.Agent{ + Name: "a", + LocalCommand: `["sh","-c","true"]`, + HarnessConfigJSON: "not-json", + }, + } + _, err := h.Execute(context.Background(), req) + if err == nil { + t.Fatal("expected parse error for invalid harness_config_json") + } +} + func TestSubprocess_Execute_AcceptsWhitespaceArgvForm(t *testing.T) { requirePosix(t) diff --git a/web/src/routes/agents/[name]/+page.svelte b/web/src/routes/agents/[name]/+page.svelte index 47a08e8..1c608e8 100644 --- a/web/src/routes/agents/[name]/+page.svelte +++ b/web/src/routes/agents/[name]/+page.svelte @@ -466,6 +466,159 @@ + + {@const resolvedBackend = agent.harness_name || (agent.k8s_image ? 'k8sjob (inferred)' : (agent.local_command ? 'subprocess (inferred)' : (agent.harness_config_json && agent.harness_config_json.includes('"url"') ? 'webhook (inferred)' : 'none')))} + {@const parsedHarnessConfig = (() => { + if (!agent.harness_config_json) return null; + try { return JSON.parse(agent.harness_config_json); } catch { return { _parse_error: 'invalid JSON' }; } + })()} +
+
+

+ + + + Harness +

+ + {resolvedBackend} + +
+
+
+
+

Explicit backend

+

{agent.harness_name || '—'}

+
+
+

K8s image

+

{agent.k8s_image || '—'}

+
+
+ + {#if agent.local_command} +
+

Local command

+
{agent.local_command}
+
+ {/if} + + {#if parsedHarnessConfig} + {#if parsedHarnessConfig._parse_error} +
+ harness_config_json: {parsedHarnessConfig._parse_error} +
+ {:else} +
+
+

CLAUDE.md

+

{parsedHarnessConfig.claude_md ? `${parsedHarnessConfig.claude_md.length} bytes` : '—'}

+
+
+

AGENTS.md

+

{parsedHarnessConfig.agents_md ? `${parsedHarnessConfig.agents_md.length} bytes` : '—'}

+
+
+

MCP servers

+

{parsedHarnessConfig.mcp_servers?.length ?? 0}

+
+
+

Skills

+

{parsedHarnessConfig.skills?.length ?? 0}

+
+
+ + {#if parsedHarnessConfig.claude_md} +
+ CLAUDE.md +
{parsedHarnessConfig.claude_md}
+
+ {/if} + + {#if parsedHarnessConfig.agents_md} +
+ AGENTS.md +
{parsedHarnessConfig.agents_md}
+
+ {/if} + + {#if parsedHarnessConfig.mcp_servers && parsedHarnessConfig.mcp_servers.length > 0} +
+ MCP servers ({parsedHarnessConfig.mcp_servers.length}) +
+ {#each parsedHarnessConfig.mcp_servers as srv} +
+
+ {srv.name || '(unnamed)'} + {srv.type || 'stdio'} +
+ {#if srv.url} +

{srv.url}

+ {/if} + {#if srv.command} +

$ {srv.command} {(srv.args || []).join(' ')}

+ {/if} + {#if srv.headers && Object.keys(srv.headers).length > 0} +

{Object.keys(srv.headers).length} header(s)

+ {/if} +
+ {/each} +
+
+ {/if} + + {#if parsedHarnessConfig.skills && parsedHarnessConfig.skills.length > 0} +
+ Skills ({parsedHarnessConfig.skills.length}) +
+ {#each parsedHarnessConfig.skills as sk} +
+ .claude/skills/{sk.name}/SKILL.md + ({sk.content?.length ?? 0} bytes) +
+ {/each} +
+
+ {/if} + + {#if parsedHarnessConfig.subagents && parsedHarnessConfig.subagents.length > 0} +
+ Subagents ({parsedHarnessConfig.subagents.length}) +
+ {#each parsedHarnessConfig.subagents as sa} +
+ .claude/agents/{sa.name}.md + ({sa.content?.length ?? 0} bytes) +
+ {/each} +
+
+ {/if} + + {#if parsedHarnessConfig.env && Object.keys(parsedHarnessConfig.env).length > 0} +
+ Env ({Object.keys(parsedHarnessConfig.env).length}) +
+ {#each Object.entries(parsedHarnessConfig.env) as [k, v]} +
+ {k}={v} +
+ {/each} +
+
+ {/if} + {/if} + {:else if !agent.harness_name && !agent.local_command && !agent.k8s_image} +

No harness configured. This agent cannot receive reactive runs.

+ {/if} + +
+

Edit via admin CLI:

+ synapbus harness config edit --agent {agentName} +
+
+
+