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
+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">