Adds the Clotho dual-run verification system and hardens the entire agent dispatch pipeline against command injection, token exposure, and SSH MitM attacks. Breaks the agentci→handlers circular dependency. Security: - SanitizePath (regex whitelist + filepath.Base) for all dispatch inputs - EscapeShellArg for shell argument safety - SecureSSHCommand (StrictHostKeyChecking=yes, BatchMode=yes) - ForgeToken removed from ticket JSON, transferred via .env with 0600 - ssh-keyscan on agent add populates known_hosts before first connection Clotho: - Spinner orchestrator determines Standard vs Dual execution mode - Config-driven via ClothoConfig (strategy, validation_threshold) - Agent runner supports claude/codex/gemini backends with dual-run - Divergence detection compares thread outputs via git diff API: - LoadActiveAgents() returns map[string]AgentConfig (no handlers import) - LoadClothoConfig() reads clotho section from config - Forge helpers: AssignIssue, EnsureLabel, AddIssueLabels 32 tests pass (19 agentci + 13 dispatch). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
329 lines
7.7 KiB
Go
329 lines
7.7 KiB
Go
package agentci
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/host-uk/core/pkg/config"
|
|
"github.com/host-uk/core/pkg/io"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func newTestConfig(t *testing.T, yaml string) *config.Config {
|
|
t.Helper()
|
|
m := io.NewMockMedium()
|
|
if yaml != "" {
|
|
m.Files["/tmp/test/config.yaml"] = yaml
|
|
}
|
|
cfg, err := config.New(config.WithMedium(m), config.WithPath("/tmp/test/config.yaml"))
|
|
require.NoError(t, err)
|
|
return cfg
|
|
}
|
|
|
|
func TestLoadAgents_Good(t *testing.T) {
|
|
cfg := newTestConfig(t, `
|
|
agentci:
|
|
agents:
|
|
darbs-claude:
|
|
host: claude@192.168.0.201
|
|
queue_dir: /home/claude/ai-work/queue
|
|
forgejo_user: darbs-claude
|
|
model: sonnet
|
|
runner: claude
|
|
active: true
|
|
`)
|
|
agents, err := LoadAgents(cfg)
|
|
require.NoError(t, err)
|
|
require.Len(t, agents, 1)
|
|
|
|
agent := agents["darbs-claude"]
|
|
assert.Equal(t, "claude@192.168.0.201", agent.Host)
|
|
assert.Equal(t, "/home/claude/ai-work/queue", agent.QueueDir)
|
|
assert.Equal(t, "sonnet", agent.Model)
|
|
assert.Equal(t, "claude", agent.Runner)
|
|
}
|
|
|
|
func TestLoadAgents_Good_MultipleAgents(t *testing.T) {
|
|
cfg := newTestConfig(t, `
|
|
agentci:
|
|
agents:
|
|
darbs-claude:
|
|
host: claude@192.168.0.201
|
|
queue_dir: /home/claude/ai-work/queue
|
|
active: true
|
|
local-codex:
|
|
host: localhost
|
|
queue_dir: /home/claude/ai-work/queue
|
|
runner: codex
|
|
active: true
|
|
`)
|
|
agents, err := LoadAgents(cfg)
|
|
require.NoError(t, err)
|
|
assert.Len(t, agents, 2)
|
|
assert.Contains(t, agents, "darbs-claude")
|
|
assert.Contains(t, agents, "local-codex")
|
|
}
|
|
|
|
func TestLoadAgents_Good_SkipsInactive(t *testing.T) {
|
|
cfg := newTestConfig(t, `
|
|
agentci:
|
|
agents:
|
|
active-agent:
|
|
host: claude@10.0.0.1
|
|
active: true
|
|
offline-agent:
|
|
host: claude@10.0.0.2
|
|
active: false
|
|
`)
|
|
agents, err := LoadAgents(cfg)
|
|
require.NoError(t, err)
|
|
// Both are returned, but only active-agent has defaults applied.
|
|
assert.Len(t, agents, 2)
|
|
assert.Contains(t, agents, "active-agent")
|
|
}
|
|
|
|
func TestLoadActiveAgents_Good(t *testing.T) {
|
|
cfg := newTestConfig(t, `
|
|
agentci:
|
|
agents:
|
|
active-agent:
|
|
host: claude@10.0.0.1
|
|
active: true
|
|
offline-agent:
|
|
host: claude@10.0.0.2
|
|
active: false
|
|
`)
|
|
active, err := LoadActiveAgents(cfg)
|
|
require.NoError(t, err)
|
|
assert.Len(t, active, 1)
|
|
assert.Contains(t, active, "active-agent")
|
|
}
|
|
|
|
func TestLoadAgents_Good_Defaults(t *testing.T) {
|
|
cfg := newTestConfig(t, `
|
|
agentci:
|
|
agents:
|
|
minimal:
|
|
host: claude@10.0.0.1
|
|
active: true
|
|
`)
|
|
agents, err := LoadAgents(cfg)
|
|
require.NoError(t, err)
|
|
require.Len(t, agents, 1)
|
|
|
|
agent := agents["minimal"]
|
|
assert.Equal(t, "/home/claude/ai-work/queue", agent.QueueDir)
|
|
assert.Equal(t, "sonnet", agent.Model)
|
|
assert.Equal(t, "claude", agent.Runner)
|
|
}
|
|
|
|
func TestLoadAgents_Good_NoConfig(t *testing.T) {
|
|
cfg := newTestConfig(t, "")
|
|
agents, err := LoadAgents(cfg)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, agents)
|
|
}
|
|
|
|
func TestLoadAgents_Bad_MissingHost(t *testing.T) {
|
|
cfg := newTestConfig(t, `
|
|
agentci:
|
|
agents:
|
|
broken:
|
|
queue_dir: /tmp
|
|
active: true
|
|
`)
|
|
_, err := LoadAgents(cfg)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "host is required")
|
|
}
|
|
|
|
func TestLoadAgents_Good_WithDualRun(t *testing.T) {
|
|
cfg := newTestConfig(t, `
|
|
agentci:
|
|
agents:
|
|
gemini-agent:
|
|
host: localhost
|
|
runner: gemini
|
|
model: gemini-2.0-flash
|
|
verify_model: gemini-1.5-pro
|
|
dual_run: true
|
|
active: true
|
|
`)
|
|
agents, err := LoadAgents(cfg)
|
|
require.NoError(t, err)
|
|
|
|
agent := agents["gemini-agent"]
|
|
assert.Equal(t, "gemini", agent.Runner)
|
|
assert.Equal(t, "gemini-2.0-flash", agent.Model)
|
|
assert.Equal(t, "gemini-1.5-pro", agent.VerifyModel)
|
|
assert.True(t, agent.DualRun)
|
|
}
|
|
|
|
func TestLoadClothoConfig_Good(t *testing.T) {
|
|
cfg := newTestConfig(t, `
|
|
agentci:
|
|
clotho:
|
|
strategy: clotho-verified
|
|
validation_threshold: 0.9
|
|
signing_key_path: /etc/core/keys/clotho.pub
|
|
`)
|
|
cc, err := LoadClothoConfig(cfg)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "clotho-verified", cc.Strategy)
|
|
assert.Equal(t, 0.9, cc.ValidationThreshold)
|
|
assert.Equal(t, "/etc/core/keys/clotho.pub", cc.SigningKeyPath)
|
|
}
|
|
|
|
func TestLoadClothoConfig_Good_Defaults(t *testing.T) {
|
|
cfg := newTestConfig(t, "")
|
|
cc, err := LoadClothoConfig(cfg)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "direct", cc.Strategy)
|
|
assert.Equal(t, 0.85, cc.ValidationThreshold)
|
|
}
|
|
|
|
func TestSaveAgent_Good(t *testing.T) {
|
|
cfg := newTestConfig(t, "")
|
|
|
|
err := SaveAgent(cfg, "new-agent", AgentConfig{
|
|
Host: "claude@10.0.0.5",
|
|
QueueDir: "/home/claude/ai-work/queue",
|
|
ForgejoUser: "new-agent",
|
|
Model: "haiku",
|
|
Runner: "claude",
|
|
Active: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
agents, err := ListAgents(cfg)
|
|
require.NoError(t, err)
|
|
require.Contains(t, agents, "new-agent")
|
|
assert.Equal(t, "claude@10.0.0.5", agents["new-agent"].Host)
|
|
assert.Equal(t, "haiku", agents["new-agent"].Model)
|
|
}
|
|
|
|
func TestSaveAgent_Good_WithDualRun(t *testing.T) {
|
|
cfg := newTestConfig(t, "")
|
|
|
|
err := SaveAgent(cfg, "verified-agent", AgentConfig{
|
|
Host: "claude@10.0.0.5",
|
|
Model: "gemini-2.0-flash",
|
|
VerifyModel: "gemini-1.5-pro",
|
|
DualRun: true,
|
|
Active: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
agents, err := ListAgents(cfg)
|
|
require.NoError(t, err)
|
|
require.Contains(t, agents, "verified-agent")
|
|
assert.True(t, agents["verified-agent"].DualRun)
|
|
}
|
|
|
|
func TestSaveAgent_Good_OmitsEmptyOptionals(t *testing.T) {
|
|
cfg := newTestConfig(t, "")
|
|
|
|
err := SaveAgent(cfg, "minimal", AgentConfig{
|
|
Host: "claude@10.0.0.1",
|
|
Active: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
agents, err := ListAgents(cfg)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, agents, "minimal")
|
|
}
|
|
|
|
func TestRemoveAgent_Good(t *testing.T) {
|
|
cfg := newTestConfig(t, `
|
|
agentci:
|
|
agents:
|
|
to-remove:
|
|
host: claude@10.0.0.1
|
|
active: true
|
|
to-keep:
|
|
host: claude@10.0.0.2
|
|
active: true
|
|
`)
|
|
err := RemoveAgent(cfg, "to-remove")
|
|
require.NoError(t, err)
|
|
|
|
agents, err := ListAgents(cfg)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, agents, "to-remove")
|
|
assert.Contains(t, agents, "to-keep")
|
|
}
|
|
|
|
func TestRemoveAgent_Bad_NotFound(t *testing.T) {
|
|
cfg := newTestConfig(t, `
|
|
agentci:
|
|
agents:
|
|
existing:
|
|
host: claude@10.0.0.1
|
|
active: true
|
|
`)
|
|
err := RemoveAgent(cfg, "nonexistent")
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not found")
|
|
}
|
|
|
|
func TestRemoveAgent_Bad_NoAgents(t *testing.T) {
|
|
cfg := newTestConfig(t, "")
|
|
err := RemoveAgent(cfg, "anything")
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "no agents configured")
|
|
}
|
|
|
|
func TestListAgents_Good(t *testing.T) {
|
|
cfg := newTestConfig(t, `
|
|
agentci:
|
|
agents:
|
|
agent-a:
|
|
host: claude@10.0.0.1
|
|
active: true
|
|
agent-b:
|
|
host: claude@10.0.0.2
|
|
active: false
|
|
`)
|
|
agents, err := ListAgents(cfg)
|
|
require.NoError(t, err)
|
|
assert.Len(t, agents, 2)
|
|
assert.True(t, agents["agent-a"].Active)
|
|
assert.False(t, agents["agent-b"].Active)
|
|
}
|
|
|
|
func TestListAgents_Good_Empty(t *testing.T) {
|
|
cfg := newTestConfig(t, "")
|
|
agents, err := ListAgents(cfg)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, agents)
|
|
}
|
|
|
|
func TestRoundTrip_SaveThenLoad(t *testing.T) {
|
|
cfg := newTestConfig(t, "")
|
|
|
|
err := SaveAgent(cfg, "alpha", AgentConfig{
|
|
Host: "claude@alpha",
|
|
QueueDir: "/home/claude/work/queue",
|
|
ForgejoUser: "alpha-bot",
|
|
Model: "opus",
|
|
Runner: "claude",
|
|
Active: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = SaveAgent(cfg, "beta", AgentConfig{
|
|
Host: "claude@beta",
|
|
ForgejoUser: "beta-bot",
|
|
Runner: "codex",
|
|
Active: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
agents, err := LoadActiveAgents(cfg)
|
|
require.NoError(t, err)
|
|
assert.Len(t, agents, 2)
|
|
assert.Equal(t, "claude@alpha", agents["alpha"].Host)
|
|
assert.Equal(t, "opus", agents["alpha"].Model)
|
|
assert.Equal(t, "codex", agents["beta"].Runner)
|
|
}
|