feat(harness): Option C — subprocess agent config (CLAUDE.md, MCP, skills)

Makes the subprocess backend fully self-contained: each agent carries
its instructions, MCP servers, skills, and subagents in its
harness_config_json column, viewable in the Web UI, editable via CLI.

internal/harness/subprocess/config.go (NEW):

  AgentConfig struct with optional fields:
    - claude_md       → workdir/CLAUDE.md
    - agents_md       → workdir/AGENTS.md
    - mcp_servers     → workdir/.mcp.json (Claude Code format)
    - skills          → workdir/.claude/skills/<name>/SKILL.md
    - subagents       → workdir/.claude/agents/<name>.md
    - env             → layered into child env (after k8s_env_json,
                        before caller overrides)

  ParseAgentConfig  tolerates empty / returns error on invalid JSON.
  MaterialiseAgentConfig writes all artifacts into the workdir with
  path-traversal sanitisation on skill/subagent names.

subprocess.Harness.Execute now calls Parse + Materialise before exec,
so an agent's declarative config is on disk by the time the child
CLI's cwd lookup fires. buildEnv takes the parsed config and overlays
cfg.Env on top of k8s_env_json.

Tests:
  config_test.go — 6 cases: empty, invalid JSON, full round-trip,
    materialise writes all artefacts, empty is a no-op, skill names
    are sanitised against "../escape" / "/etc/passwd", mcp entries
    without a name are dropped.
  subprocess_test.go — 2 new e2e cases: agent with CLAUDE.md + mcp
    servers + skills + env sees all of them from inside the child via
    cat/echo; invalid harness_config_json surfaces as Execute error.

internal/agents/store.go:

  AgentStore gains UpdateHarnessConfig(ctx, name, harnessName,
  localCommand, harnessConfigJSON). Empty strings leave a field
  unchanged; literal "-" clears (sets to NULL). Returns sql.ErrNoRows
  on missing agent. AgentService exposes Store() so admin handlers
  can reach it without adding a full service method for a
  config-set-style operation.

  store_test.go: 6-subcase test covers set-all, partial update, clear,
  unknown agent, and no-field no-op.

internal/admin/socket.go:

  Two new admin commands:
    harness.config_get {agent_name} → {harness_name, local_command,
        harness_config_json, harness_config (parsed), parse_error?}
    harness.config_set {agent_name, harness_name?, local_command?,
        harness_config_json?} → updated fields
  config_set validates JSON shape before calling the store; null /
  "-" literals clear the column.

cmd/synapbus/admin.go:

  New top-level `harness config` command group:
    synapbus harness config get --agent <name> [--raw]
    synapbus harness config set --agent <name>
        [--harness-name subprocess]
        [--local-command '["claude","--print"]']
        [--file config.json]    # or pipe from stdin
        [--clear]
    synapbus harness config edit --agent <name>
        # fetches current config, opens $VISUAL/$EDITOR/vi,
        # validates JSON on save, writes back via config_set

web/src/routes/agents/[name]/+page.svelte:

  New read-only "Harness" panel on the agent detail page:
    - Resolved backend badge (explicit or inferred from k8s_image /
      local_command / harness_config_json.url)
    - Grid summary: CLAUDE.md size, AGENTS.md size, MCP server count,
      skills count
    - Collapsible details for CLAUDE.md, AGENTS.md, each MCP server
      (name / type / url|command / header count), skill filenames,
      subagent filenames, env vars
    - Footer hint showing the CLI edit command
  No edit controls — editing is CLI-only by design (safer, fits an
  ops-heavy workflow).

Verified: full project test suite (40+ packages including integration
tests) plus `vite build` of the Svelte app all green; `go vet ./...`
clean; the existing TestSubprocess_Execute_MaterialisesHarnessConfig
e2e test proves the round-trip from harness_config_json → workdir →
child process works end-to-end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-04-13 20:57:28 +03:00
co-authored by Claude Opus 4.6
parent 8ddccd2452
commit b8a70bfe66
10 changed files with 1118 additions and 5 deletions
+206 -1
View File
@@ -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.
+98
View File
@@ -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
+7
View File
@@ -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) {
+57
View File
@@ -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
+67
View File
@@ -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)
+220
View File
@@ -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 <name>
// synapbus harness config set --agent <name> --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/<name>/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/<name>.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)
}
+219
View File
@@ -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/<name>/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/<name>.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)
}
}
+22 -4
View File
@@ -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
@@ -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)
+153
View File
@@ -466,6 +466,159 @@
</div>
</div>
<!-- Harness Configuration (subprocess / webhook / k8sjob) -->
{@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' }; }
})()}
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
<h2 class="font-semibold text-sm text-text-primary font-display flex items-center gap-2">
<svg class="w-4 h-4 text-accent-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
Harness
</h2>
<span class="badge text-[10px] {resolvedBackend.startsWith('none') ? 'bg-bg-tertiary text-text-secondary' : 'bg-accent-blue/20 text-accent-blue'}">
{resolvedBackend}
</span>
</div>
<div class="p-5 space-y-4 text-sm">
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs text-text-secondary mb-0.5">Explicit backend</p>
<p class="text-text-primary font-mono text-xs">{agent.harness_name || '—'}</p>
</div>
<div>
<p class="text-xs text-text-secondary mb-0.5">K8s image</p>
<p class="text-text-primary font-mono text-xs break-all">{agent.k8s_image || '—'}</p>
</div>
</div>
{#if agent.local_command}
<div>
<p class="text-xs text-text-secondary mb-1">Local command</p>
<pre class="text-xs bg-bg-primary p-3 rounded-lg border border-border overflow-x-auto font-mono text-text-primary/80 whitespace-pre-wrap">{agent.local_command}</pre>
</div>
{/if}
{#if parsedHarnessConfig}
{#if parsedHarnessConfig._parse_error}
<div class="px-3 py-2 bg-accent-red/10 rounded text-xs text-accent-red">
harness_config_json: {parsedHarnessConfig._parse_error}
</div>
{:else}
<div class="grid grid-cols-4 gap-2">
<div class="px-3 py-2 bg-bg-tertiary rounded">
<p class="text-[10px] text-text-secondary uppercase tracking-wider">CLAUDE.md</p>
<p class="text-xs text-text-primary">{parsedHarnessConfig.claude_md ? `${parsedHarnessConfig.claude_md.length} bytes` : '—'}</p>
</div>
<div class="px-3 py-2 bg-bg-tertiary rounded">
<p class="text-[10px] text-text-secondary uppercase tracking-wider">AGENTS.md</p>
<p class="text-xs text-text-primary">{parsedHarnessConfig.agents_md ? `${parsedHarnessConfig.agents_md.length} bytes` : '—'}</p>
</div>
<div class="px-3 py-2 bg-bg-tertiary rounded">
<p class="text-[10px] text-text-secondary uppercase tracking-wider">MCP servers</p>
<p class="text-xs text-text-primary">{parsedHarnessConfig.mcp_servers?.length ?? 0}</p>
</div>
<div class="px-3 py-2 bg-bg-tertiary rounded">
<p class="text-[10px] text-text-secondary uppercase tracking-wider">Skills</p>
<p class="text-xs text-text-primary">{parsedHarnessConfig.skills?.length ?? 0}</p>
</div>
</div>
{#if parsedHarnessConfig.claude_md}
<details class="bg-bg-primary border border-border rounded-lg">
<summary class="px-3 py-2 text-xs text-text-secondary cursor-pointer hover:text-text-primary">CLAUDE.md</summary>
<pre class="px-3 pb-3 text-xs font-mono text-text-primary/80 whitespace-pre-wrap break-words max-h-80 overflow-y-auto">{parsedHarnessConfig.claude_md}</pre>
</details>
{/if}
{#if parsedHarnessConfig.agents_md}
<details class="bg-bg-primary border border-border rounded-lg">
<summary class="px-3 py-2 text-xs text-text-secondary cursor-pointer hover:text-text-primary">AGENTS.md</summary>
<pre class="px-3 pb-3 text-xs font-mono text-text-primary/80 whitespace-pre-wrap break-words max-h-80 overflow-y-auto">{parsedHarnessConfig.agents_md}</pre>
</details>
{/if}
{#if parsedHarnessConfig.mcp_servers && parsedHarnessConfig.mcp_servers.length > 0}
<details class="bg-bg-primary border border-border rounded-lg" open>
<summary class="px-3 py-2 text-xs text-text-secondary cursor-pointer hover:text-text-primary">MCP servers ({parsedHarnessConfig.mcp_servers.length})</summary>
<div class="px-3 pb-3 space-y-2">
{#each parsedHarnessConfig.mcp_servers as srv}
<div class="p-2 bg-bg-tertiary rounded border border-border">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-semibold text-text-primary font-mono">{srv.name || '(unnamed)'}</span>
<span class="badge text-[9px] bg-accent-purple/20 text-accent-purple">{srv.type || 'stdio'}</span>
</div>
{#if srv.url}
<p class="text-[11px] text-text-secondary font-mono break-all">{srv.url}</p>
{/if}
{#if srv.command}
<p class="text-[11px] text-text-secondary font-mono break-all">$ {srv.command} {(srv.args || []).join(' ')}</p>
{/if}
{#if srv.headers && Object.keys(srv.headers).length > 0}
<p class="text-[10px] text-text-secondary">{Object.keys(srv.headers).length} header(s)</p>
{/if}
</div>
{/each}
</div>
</details>
{/if}
{#if parsedHarnessConfig.skills && parsedHarnessConfig.skills.length > 0}
<details class="bg-bg-primary border border-border rounded-lg">
<summary class="px-3 py-2 text-xs text-text-secondary cursor-pointer hover:text-text-primary">Skills ({parsedHarnessConfig.skills.length})</summary>
<div class="px-3 pb-3 space-y-1">
{#each parsedHarnessConfig.skills as sk}
<div class="text-xs font-mono text-text-primary">
.claude/skills/<span class="text-accent-blue">{sk.name}</span>/SKILL.md
<span class="text-text-secondary">({sk.content?.length ?? 0} bytes)</span>
</div>
{/each}
</div>
</details>
{/if}
{#if parsedHarnessConfig.subagents && parsedHarnessConfig.subagents.length > 0}
<details class="bg-bg-primary border border-border rounded-lg">
<summary class="px-3 py-2 text-xs text-text-secondary cursor-pointer hover:text-text-primary">Subagents ({parsedHarnessConfig.subagents.length})</summary>
<div class="px-3 pb-3 space-y-1">
{#each parsedHarnessConfig.subagents as sa}
<div class="text-xs font-mono text-text-primary">
.claude/agents/<span class="text-accent-blue">{sa.name}</span>.md
<span class="text-text-secondary">({sa.content?.length ?? 0} bytes)</span>
</div>
{/each}
</div>
</details>
{/if}
{#if parsedHarnessConfig.env && Object.keys(parsedHarnessConfig.env).length > 0}
<details class="bg-bg-primary border border-border rounded-lg">
<summary class="px-3 py-2 text-xs text-text-secondary cursor-pointer hover:text-text-primary">Env ({Object.keys(parsedHarnessConfig.env).length})</summary>
<div class="px-3 pb-3 space-y-0.5">
{#each Object.entries(parsedHarnessConfig.env) as [k, v]}
<div class="text-xs font-mono">
<span class="text-accent-yellow">{k}</span><span class="text-text-secondary">=</span><span class="text-text-primary">{v}</span>
</div>
{/each}
</div>
</details>
{/if}
{/if}
{:else if !agent.harness_name && !agent.local_command && !agent.k8s_image}
<p class="text-xs text-text-secondary italic">No harness configured. This agent cannot receive reactive runs.</p>
{/if}
<div class="pt-2 text-[11px] text-text-secondary border-t border-border">
<p class="mb-1">Edit via admin CLI:</p>
<code class="font-mono text-text-primary bg-bg-primary px-2 py-1 rounded inline-block">synapbus harness config edit --agent {agentName}</code>
</div>
</div>
</div>
<!-- Trust Scores -->
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border">