Files
synapbus/internal/harness/subprocess/subprocess.go
T
Algis DumbrisandClaude Opus 4.6 b8a70bfe66 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>
2026-04-13 20:57:28 +03:00

383 lines
11 KiB
Go

// Package subprocess is the local-process implementation of
// harness.Harness. It runs an agent as a child process of synapbus
// using os/exec, captures stdout+stderr, and reads an optional
// result.json the child may have written into its workdir.
//
// Works on Mac and Linux identically (no CGO, no platform-specific
// syscalls). Windows is not targeted because synapbus itself does not
// target Windows.
package subprocess
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/harness"
)
// Config tunes the subprocess harness. Zero-value defaults are sensible
// for development; production callers typically set BaseDir to a
// predictable location under SYNAPBUS_DATA_DIR so forensics are easy.
type Config struct {
// BaseDir is the parent directory under which a per-run workdir is
// created. Defaults to os.TempDir() when empty.
BaseDir string
// LogsCap bounds the number of bytes kept in ExecResult.Logs. The
// full stdout/stderr stream is written to `stdout.log` /
// `stderr.log` inside the workdir for forensics. Defaults to 64 KiB.
LogsCap int
// KeepWorkdirOnSuccess leaves the workdir behind even for
// zero-exit runs. Useful for debugging test flakes.
KeepWorkdirOnSuccess bool
}
// Harness runs agents as local subprocesses.
type Harness struct {
cfg Config
logger *slog.Logger
}
// New constructs a subprocess harness. Pass zero Config for defaults.
func New(cfg Config, logger *slog.Logger) *Harness {
if cfg.LogsCap <= 0 {
cfg.LogsCap = 64 * 1024
}
if logger == nil {
logger = slog.Default()
}
return &Harness{
cfg: cfg,
logger: logger.With("harness", "subprocess"),
}
}
// Name returns the registered harness name.
func (h *Harness) Name() string { return "subprocess" }
// Capabilities advertises backend features.
func (h *Harness) Capabilities() harness.Capabilities {
return harness.Capabilities{
SystemPrompt: true,
SessionResume: true,
Skills: false,
OTelNative: true,
MaxConcurrency: 4,
}
}
// TestEnvironment is a cheap sanity check: BaseDir (or os.TempDir) must
// exist and be writable. Per-agent binary reachability is checked at
// Execute time because the binary is agent-specific.
func (h *Harness) TestEnvironment(ctx context.Context) error {
base := h.cfg.BaseDir
if base == "" {
base = os.TempDir()
}
info, err := os.Stat(base)
if err != nil {
return fmt.Errorf("subprocess: base dir %q: %w", base, err)
}
if !info.IsDir() {
return fmt.Errorf("subprocess: base dir %q is not a directory", base)
}
return nil
}
// Provision is a no-op for subprocess. All per-run state goes into the
// workdir Execute creates on the fly.
func (h *Harness) Provision(ctx context.Context, agent *agents.Agent) error {
return nil
}
// ErrNoLocalCommand is returned when an agent has no LocalCommand
// configured so the subprocess backend cannot know what to run.
var ErrNoLocalCommand = errors.New("subprocess: agent has no local_command configured")
// Execute launches the child, waits for it to exit (or for ctx /
// Budget to fire), and returns its output.
func (h *Harness) Execute(ctx context.Context, req *harness.ExecRequest) (*harness.ExecResult, error) {
if req == nil {
return nil, errors.New("subprocess: nil ExecRequest")
}
if req.Agent == nil {
return nil, errors.New("subprocess: ExecRequest.Agent is required")
}
argv, err := parseLocalCommand(req.Agent.LocalCommand)
if err != nil {
return nil, err
}
// Per-run workdir
base := h.cfg.BaseDir
if base == "" {
base = os.TempDir()
}
if err := os.MkdirAll(base, 0o755); err != nil {
return nil, fmt.Errorf("subprocess: mkdir base: %w", err)
}
runDirName := sanitizeRunDir(req.RunID)
if runDirName == "" {
runDirName = fmt.Sprintf("run-%d", time.Now().UnixNano())
}
workdir := filepath.Join(base, runDirName)
if err := os.MkdirAll(workdir, 0o755); err != nil {
return nil, fmt.Errorf("subprocess: mkdir workdir: %w", err)
}
// Context with Budget timeout if set.
runCtx := ctx
if req.Budget.MaxWallClock > 0 {
var cancel context.CancelFunc
runCtx, cancel = context.WithTimeout(ctx, req.Budget.MaxWallClock)
defer cancel()
}
// Write the triggering message to message.json for the child to
// read if it cares. Simple, explicit, no stdin-piping ambiguity.
if req.Message != nil {
raw, _ := json.Marshal(req.Message)
_ = 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, cfg)
var stdout, stderr bytes.Buffer
cmd.Stdout = io.MultiWriter(&stdout, limitedFileWriter(workdir, "stdout.log"))
cmd.Stderr = io.MultiWriter(&stderr, limitedFileWriter(workdir, "stderr.log"))
h.logger.Info("subprocess launching",
"run_id", req.RunID,
"agent", req.AgentName,
"cmd", argv[0],
"workdir", workdir,
)
startedAt := time.Now()
runErr := cmd.Run()
duration := time.Since(startedAt)
exitCode := 0
if runErr != nil {
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
exitCode = 1
}
}
// Load optional result.json
var resultJSON json.RawMessage
if raw, readErr := os.ReadFile(filepath.Join(workdir, "result.json")); readErr == nil && len(raw) > 0 {
if json.Valid(raw) {
resultJSON = json.RawMessage(raw)
}
}
logs := mergeLogs(&stdout, &stderr, h.cfg.LogsCap)
// Cleanup policy: remove workdir on success unless configured to
// keep it; always keep on failure so users can inspect stdout.log /
// stderr.log / message.json / result.json.
if exitCode == 0 && !h.cfg.KeepWorkdirOnSuccess {
_ = os.RemoveAll(workdir)
}
h.logger.Info("subprocess finished",
"run_id", req.RunID,
"agent", req.AgentName,
"exit", exitCode,
"duration_ms", duration.Milliseconds(),
)
result := &harness.ExecResult{
ExitCode: exitCode,
Logs: logs,
ResultJSON: resultJSON,
}
// Distinguish context timeout from plain failures so the caller
// can tell "budget exceeded" from "the agent crashed".
if runErr != nil && errors.Is(runCtx.Err(), context.DeadlineExceeded) {
return result, fmt.Errorf("subprocess: wall-clock budget %s exceeded", req.Budget.MaxWallClock)
}
if runErr != nil && errors.Is(runCtx.Err(), context.Canceled) {
return result, runCtx.Err()
}
return result, nil
}
// Cancel is a no-op for subprocess today — cancellation happens via the
// context passed to Execute. A future iteration could track in-flight
// runs by RunID and send SIGTERM to them.
func (h *Harness) Cancel(ctx context.Context, runID string) error {
return nil
}
// -- helpers --------------------------------------------------------------
// parseLocalCommand accepts either a JSON array (["claude", "--print"])
// or a simple space-separated string. Returns the argv slice or an
// error if neither form parses.
func parseLocalCommand(raw string) ([]string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return nil, ErrNoLocalCommand
}
if strings.HasPrefix(s, "[") {
var argv []string
if err := json.Unmarshal([]byte(s), &argv); err != nil {
return nil, fmt.Errorf("subprocess: parse local_command JSON: %w", err)
}
if len(argv) == 0 {
return nil, ErrNoLocalCommand
}
return argv, nil
}
// Fall back to whitespace split. Suitable for simple commands.
parts := strings.Fields(s)
if len(parts) == 0 {
return nil, ErrNoLocalCommand
}
return parts, nil
}
// 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 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 {
env[kv[:i]] = kv[i+1:]
}
}
// agent env map (K8sEnvJSON is shared across backends today)
if req.Agent != nil && req.Agent.K8sEnvJSON != "" {
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(req.Agent.K8sEnvJSON), &m); err == nil {
for k, v := range m {
var s string
if err := json.Unmarshal(v, &s); err == nil {
env[k] = s
continue
}
env[k] = strings.Trim(string(v), "\"")
}
}
}
// 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
}
// run context
env["SYNAPBUS_RUN_ID"] = req.RunID
env["SYNAPBUS_AGENT"] = req.AgentName
env["SYNAPBUS_WORKDIR"] = workdir
if req.Message != nil {
env["SYNAPBUS_MESSAGE_ID"] = fmt.Sprintf("%d", req.Message.ID)
env["SYNAPBUS_FROM_AGENT"] = req.Message.FromAgent
}
out := make([]string, 0, len(env))
for k, v := range env {
out = append(out, k+"="+v)
}
return out
}
// mergeLogs interleaves stdout then stderr with a header, bounded by cap.
func mergeLogs(out, errb *bytes.Buffer, cap int) string {
var b strings.Builder
if out.Len() > 0 {
b.WriteString(out.String())
}
if errb.Len() > 0 {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString("-- stderr --\n")
b.WriteString(errb.String())
}
s := b.String()
if cap > 0 && len(s) > cap {
// Keep the tail — most informative on failure.
s = "... [truncated " + fmt.Sprintf("%d", len(s)-cap) + " bytes] ...\n" + s[len(s)-cap:]
}
return s
}
// limitedFileWriter returns a writer that appends to a file inside the
// workdir. Errors are silently ignored — logs are best-effort and must
// not fail the run.
func limitedFileWriter(workdir, name string) io.Writer {
f, err := os.OpenFile(filepath.Join(workdir, name), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return io.Discard
}
return f
}
// sanitizeRunDir strips characters that cause surprises on case-folded
// filesystems or in shell globs. Keeps the resulting name readable.
func sanitizeRunDir(runID string) string {
if runID == "" {
return ""
}
var b strings.Builder
for _, r := range runID {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_':
b.WriteRune(r)
default:
b.WriteByte('-')
}
}
name := b.String()
if len(name) > 64 {
name = name[:64]
}
return name
}