Files
synapbus/internal/harness/subprocess/subprocess_test.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

393 lines
11 KiB
Go

package subprocess_test
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/harness"
"github.com/synapbus/synapbus/internal/harness/subprocess"
"github.com/synapbus/synapbus/internal/messaging"
)
func requirePosix(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("subprocess tests use /bin/sh; not applicable on Windows")
}
}
func newAgent(cmd string) *agents.Agent {
return &agents.Agent{Name: "test-agent", LocalCommand: cmd}
}
func TestSubprocess_ImplementsInterface(t *testing.T) {
var _ harness.Harness = (*subprocess.Harness)(nil)
}
func TestSubprocess_NameAndCapabilities(t *testing.T) {
h := subprocess.New(subprocess.Config{}, nil)
if h.Name() != "subprocess" {
t.Fatalf("Name = %q", h.Name())
}
caps := h.Capabilities()
if !caps.SystemPrompt || !caps.SessionResume || !caps.OTelNative {
t.Fatalf("caps missing expected flags: %+v", caps)
}
}
func TestSubprocess_TestEnvironment_OK(t *testing.T) {
h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil)
if err := h.TestEnvironment(context.Background()); err != nil {
t.Fatalf("TestEnvironment err = %v", err)
}
}
func TestSubprocess_TestEnvironment_MissingDir(t *testing.T) {
h := subprocess.New(subprocess.Config{BaseDir: "/nonexistent/does/not/exist/xyz"}, nil)
if err := h.TestEnvironment(context.Background()); err == nil {
t.Fatal("expected error for missing base dir")
}
}
func TestSubprocess_Execute_Success(t *testing.T) {
requirePosix(t)
h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil)
req := &harness.ExecRequest{
RunID: "run-success",
AgentName: "test",
Agent: newAgent(`["sh","-c","echo hello world"]`),
}
res, err := h.Execute(context.Background(), req)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if res.ExitCode != 0 {
t.Errorf("ExitCode = %d, want 0", res.ExitCode)
}
if !strings.Contains(res.Logs, "hello world") {
t.Errorf("logs missing stdout: %q", res.Logs)
}
}
func TestSubprocess_Execute_NonZeroExit(t *testing.T) {
requirePosix(t)
h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil)
req := &harness.ExecRequest{
RunID: "run-fail",
AgentName: "test",
Agent: newAgent(`["sh","-c","echo oops; exit 7"]`),
}
res, err := h.Execute(context.Background(), req)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if res.ExitCode != 7 {
t.Errorf("ExitCode = %d, want 7", res.ExitCode)
}
if !strings.Contains(res.Logs, "oops") {
t.Errorf("logs missing stdout: %q", res.Logs)
}
}
func TestSubprocess_Execute_StderrCaptured(t *testing.T) {
requirePosix(t)
h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil)
req := &harness.ExecRequest{
RunID: "run-stderr",
AgentName: "test",
Agent: newAgent(`["sh","-c","echo first; echo bad >&2; exit 0"]`),
}
res, err := h.Execute(context.Background(), req)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if !strings.Contains(res.Logs, "first") {
t.Errorf("logs missing stdout: %q", res.Logs)
}
if !strings.Contains(res.Logs, "bad") {
t.Errorf("logs missing stderr: %q", res.Logs)
}
if !strings.Contains(res.Logs, "-- stderr --") {
t.Errorf("logs missing stderr header: %q", res.Logs)
}
}
func TestSubprocess_Execute_EnvPropagation(t *testing.T) {
requirePosix(t)
h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil)
req := &harness.ExecRequest{
RunID: "run-env",
AgentName: "my-agent",
Agent: newAgent(`["sh","-c","echo run=$SYNAPBUS_RUN_ID agent=$SYNAPBUS_AGENT custom=$CUSTOM"]`),
Env: map[string]string{"CUSTOM": "xyz"},
}
res, err := h.Execute(context.Background(), req)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if !strings.Contains(res.Logs, "run=run-env") {
t.Errorf("run id not propagated: %q", res.Logs)
}
if !strings.Contains(res.Logs, "agent=my-agent") {
t.Errorf("agent name not propagated: %q", res.Logs)
}
if !strings.Contains(res.Logs, "custom=xyz") {
t.Errorf("caller env not propagated: %q", res.Logs)
}
}
func TestSubprocess_Execute_ReadsResultJSON(t *testing.T) {
requirePosix(t)
baseDir := t.TempDir()
h := subprocess.New(subprocess.Config{BaseDir: baseDir, KeepWorkdirOnSuccess: true}, nil)
req := &harness.ExecRequest{
RunID: "run-json",
AgentName: "test",
Agent: newAgent(`["sh","-c","printf '{\"answer\":42,\"ok\":true}' > result.json"]`),
}
res, err := h.Execute(context.Background(), req)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if res.ExitCode != 0 {
t.Errorf("ExitCode = %d, want 0", res.ExitCode)
}
if len(res.ResultJSON) == 0 {
t.Fatal("ResultJSON empty")
}
if !strings.Contains(string(res.ResultJSON), `"answer":42`) {
t.Errorf("ResultJSON = %s", res.ResultJSON)
}
// Verify the workdir is still present (KeepWorkdirOnSuccess=true)
if _, err := os.Stat(filepath.Join(baseDir, "run-json", "result.json")); err != nil {
t.Errorf("workdir removed despite KeepWorkdirOnSuccess: %v", err)
}
}
func TestSubprocess_Execute_WritesMessageJSON(t *testing.T) {
requirePosix(t)
baseDir := t.TempDir()
h := subprocess.New(subprocess.Config{BaseDir: baseDir, KeepWorkdirOnSuccess: true}, nil)
req := &harness.ExecRequest{
RunID: "run-msg",
AgentName: "test",
Agent: newAgent(`["sh","-c","cat message.json > result.json"]`),
Message: &messaging.Message{
ID: 123,
FromAgent: "alice",
Body: "hi",
},
}
res, err := h.Execute(context.Background(), req)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if !strings.Contains(string(res.ResultJSON), `"from_agent":"alice"`) {
t.Errorf("message.json missing from_agent: %s", res.ResultJSON)
}
if !strings.Contains(string(res.ResultJSON), `"body":"hi"`) {
t.Errorf("message.json missing body: %s", res.ResultJSON)
}
}
func TestSubprocess_Execute_WorkdirCleanedOnSuccess(t *testing.T) {
requirePosix(t)
baseDir := t.TempDir()
h := subprocess.New(subprocess.Config{BaseDir: baseDir}, nil)
req := &harness.ExecRequest{
RunID: "run-clean",
AgentName: "test",
Agent: newAgent(`["sh","-c","true"]`),
}
_, err := h.Execute(context.Background(), req)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if _, err := os.Stat(filepath.Join(baseDir, "run-clean")); !os.IsNotExist(err) {
t.Errorf("workdir not cleaned up: err=%v", err)
}
}
func TestSubprocess_Execute_WorkdirKeptOnFailure(t *testing.T) {
requirePosix(t)
baseDir := t.TempDir()
h := subprocess.New(subprocess.Config{BaseDir: baseDir}, nil)
req := &harness.ExecRequest{
RunID: "run-forensic",
AgentName: "test",
Agent: newAgent(`["sh","-c","exit 5"]`),
}
_, err := h.Execute(context.Background(), req)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if _, err := os.Stat(filepath.Join(baseDir, "run-forensic")); err != nil {
t.Errorf("workdir removed on failure (should be kept): %v", err)
}
}
func TestSubprocess_Execute_BudgetTimeout(t *testing.T) {
requirePosix(t)
h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil)
req := &harness.ExecRequest{
RunID: "run-timeout",
AgentName: "test",
Agent: newAgent(`["sh","-c","sleep 5"]`),
Budget: harness.Budget{MaxWallClock: 50 * time.Millisecond},
}
start := time.Now()
_, err := h.Execute(context.Background(), req)
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected budget timeout error")
}
if !strings.Contains(err.Error(), "budget") {
t.Errorf("err = %v, want 'budget' in message", err)
}
if elapsed > time.Second {
t.Errorf("budget not enforced; elapsed = %s", elapsed)
}
}
func TestSubprocess_Execute_ContextCancel(t *testing.T) {
requirePosix(t)
h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
req := &harness.ExecRequest{
RunID: "run-cancel",
AgentName: "test",
Agent: newAgent(`["sh","-c","sleep 5"]`),
}
start := time.Now()
_, _ = h.Execute(ctx, req)
if time.Since(start) > time.Second {
t.Error("context cancel did not stop child")
}
}
func TestSubprocess_Execute_NilAgent(t *testing.T) {
h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil)
_, err := h.Execute(context.Background(), &harness.ExecRequest{RunID: "r"})
if err == nil {
t.Fatal("expected error for nil agent")
}
}
func TestSubprocess_Execute_NoLocalCommand(t *testing.T) {
h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil)
_, err := h.Execute(context.Background(), &harness.ExecRequest{
RunID: "r",
Agent: &agents.Agent{Name: "no-cmd"},
})
if err == nil {
t.Fatal("expected ErrNoLocalCommand")
}
}
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)
h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil)
// Non-JSON form: simple whitespace-split.
req := &harness.ExecRequest{
RunID: "run-ws",
AgentName: "test",
Agent: &agents.Agent{Name: "a", LocalCommand: "echo plain-form"},
}
res, err := h.Execute(context.Background(), req)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if !strings.Contains(res.Logs, "plain-form") {
t.Errorf("whitespace form failed: %q", res.Logs)
}
}