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>
318 lines
8.0 KiB
Go
318 lines
8.0 KiB
Go
package agents
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"github.com/synapbus/synapbus/internal/storage"
|
|
)
|
|
|
|
func newTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open database: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
|
t.Fatalf("enable foreign keys: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if err := storage.RunMigrations(ctx, db); err != nil {
|
|
t.Fatalf("run migrations: %v", err)
|
|
}
|
|
|
|
// Seed a test user for owner_id FK
|
|
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testowner', 'hash', 'Test Owner')`)
|
|
|
|
return db
|
|
}
|
|
|
|
func TestSQLiteAgentStore_CreateAndGet(t *testing.T) {
|
|
db := newTestDB(t)
|
|
store := NewSQLiteAgentStore(db)
|
|
ctx := context.Background()
|
|
|
|
agent := &Agent{
|
|
Name: "test-bot",
|
|
DisplayName: "Test Bot",
|
|
Type: "ai",
|
|
Capabilities: json.RawMessage(`{"skills":["testing"]}`),
|
|
OwnerID: 1,
|
|
APIKeyHash: "somehash",
|
|
}
|
|
|
|
if err := store.CreateAgent(ctx, agent); err != nil {
|
|
t.Fatalf("CreateAgent: %v", err)
|
|
}
|
|
|
|
if agent.ID == 0 {
|
|
t.Error("agent ID should not be 0")
|
|
}
|
|
|
|
// Get by name
|
|
got, err := store.GetAgentByName(ctx, "test-bot")
|
|
if err != nil {
|
|
t.Fatalf("GetAgentByName: %v", err)
|
|
}
|
|
if got.DisplayName != "Test Bot" {
|
|
t.Errorf("DisplayName = %q, want %q", got.DisplayName, "Test Bot")
|
|
}
|
|
|
|
// Get by ID
|
|
got2, err := store.GetAgentByID(ctx, agent.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetAgentByID: %v", err)
|
|
}
|
|
if got2.Name != "test-bot" {
|
|
t.Errorf("Name = %q, want %q", got2.Name, "test-bot")
|
|
}
|
|
}
|
|
|
|
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)
|
|
ctx := context.Background()
|
|
|
|
agent := &Agent{
|
|
Name: "dup-bot",
|
|
DisplayName: "Dup Bot",
|
|
Type: "ai",
|
|
Capabilities: json.RawMessage("{}"),
|
|
OwnerID: 1,
|
|
APIKeyHash: "hash1",
|
|
}
|
|
|
|
if err := store.CreateAgent(ctx, agent); err != nil {
|
|
t.Fatalf("CreateAgent: %v", err)
|
|
}
|
|
|
|
agent2 := &Agent{
|
|
Name: "dup-bot",
|
|
DisplayName: "Dup Bot 2",
|
|
Type: "ai",
|
|
Capabilities: json.RawMessage("{}"),
|
|
OwnerID: 1,
|
|
APIKeyHash: "hash2",
|
|
}
|
|
|
|
err := store.CreateAgent(ctx, agent2)
|
|
if err == nil {
|
|
t.Error("expected error for duplicate name")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteAgentStore_Update(t *testing.T) {
|
|
db := newTestDB(t)
|
|
store := NewSQLiteAgentStore(db)
|
|
ctx := context.Background()
|
|
|
|
agent := &Agent{
|
|
Name: "update-bot",
|
|
DisplayName: "Update Bot",
|
|
Type: "ai",
|
|
Capabilities: json.RawMessage(`{"skills":["v1"]}`),
|
|
OwnerID: 1,
|
|
APIKeyHash: "hash",
|
|
}
|
|
|
|
store.CreateAgent(ctx, agent)
|
|
|
|
agent.DisplayName = "Updated Bot"
|
|
agent.Capabilities = json.RawMessage(`{"skills":["v1","v2"]}`)
|
|
|
|
if err := store.UpdateAgent(ctx, agent); err != nil {
|
|
t.Fatalf("UpdateAgent: %v", err)
|
|
}
|
|
|
|
got, err := store.GetAgentByName(ctx, "update-bot")
|
|
if err != nil {
|
|
t.Fatalf("GetAgentByName: %v", err)
|
|
}
|
|
if got.DisplayName != "Updated Bot" {
|
|
t.Errorf("DisplayName = %q, want %q", got.DisplayName, "Updated Bot")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteAgentStore_Deactivate(t *testing.T) {
|
|
db := newTestDB(t)
|
|
store := NewSQLiteAgentStore(db)
|
|
ctx := context.Background()
|
|
|
|
agent := &Agent{
|
|
Name: "deactivate-bot",
|
|
DisplayName: "Deactivate Bot",
|
|
Type: "ai",
|
|
Capabilities: json.RawMessage("{}"),
|
|
OwnerID: 1,
|
|
APIKeyHash: "hash",
|
|
}
|
|
|
|
store.CreateAgent(ctx, agent)
|
|
|
|
if err := store.DeactivateAgent(ctx, "deactivate-bot"); err != nil {
|
|
t.Fatalf("DeactivateAgent: %v", err)
|
|
}
|
|
|
|
// Should not be found (GetByName filters active only)
|
|
_, err := store.GetAgentByName(ctx, "deactivate-bot")
|
|
if err == nil {
|
|
t.Error("expected error for deactivated agent")
|
|
}
|
|
|
|
// Deactivate non-existent
|
|
err = store.DeactivateAgent(ctx, "nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent agent")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteAgentStore_ListActive(t *testing.T) {
|
|
db := newTestDB(t)
|
|
store := NewSQLiteAgentStore(db)
|
|
ctx := context.Background()
|
|
|
|
for _, name := range []string{"bot-a", "bot-b", "bot-c"} {
|
|
store.CreateAgent(ctx, &Agent{
|
|
Name: name,
|
|
DisplayName: name,
|
|
Type: "ai",
|
|
Capabilities: json.RawMessage("{}"),
|
|
OwnerID: 1,
|
|
APIKeyHash: "hash",
|
|
})
|
|
}
|
|
|
|
agents, err := store.ListActiveAgents(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListActiveAgents: %v", err)
|
|
}
|
|
if len(agents) != 3 {
|
|
t.Errorf("got %d agents, want 3", len(agents))
|
|
}
|
|
}
|
|
|
|
func TestSQLiteAgentStore_SearchByCapability(t *testing.T) {
|
|
db := newTestDB(t)
|
|
store := NewSQLiteAgentStore(db)
|
|
ctx := context.Background()
|
|
|
|
store.CreateAgent(ctx, &Agent{
|
|
Name: "searcher",
|
|
DisplayName: "Searcher",
|
|
Type: "ai",
|
|
Capabilities: json.RawMessage(`{"skills":["web-search","summarization"]}`),
|
|
OwnerID: 1,
|
|
APIKeyHash: "hash",
|
|
})
|
|
|
|
store.CreateAgent(ctx, &Agent{
|
|
Name: "analyzer",
|
|
DisplayName: "Analyzer",
|
|
Type: "ai",
|
|
Capabilities: json.RawMessage(`{"skills":["sentiment-analysis"]}`),
|
|
OwnerID: 1,
|
|
APIKeyHash: "hash",
|
|
})
|
|
|
|
t.Run("match found", func(t *testing.T) {
|
|
results, err := store.SearchAgentsByCapability(ctx, "web-search")
|
|
if err != nil {
|
|
t.Fatalf("SearchAgentsByCapability: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Errorf("got %d results, want 1", len(results))
|
|
}
|
|
if len(results) > 0 && results[0].Name != "searcher" {
|
|
t.Errorf("Name = %s, want searcher", results[0].Name)
|
|
}
|
|
})
|
|
|
|
t.Run("no match", func(t *testing.T) {
|
|
results, err := store.SearchAgentsByCapability(ctx, "quantum-computing")
|
|
if err != nil {
|
|
t.Fatalf("SearchAgentsByCapability: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("got %d results, want 0", len(results))
|
|
}
|
|
})
|
|
}
|
|
|
|
var _ = storage.RunMigrations
|