mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
1397 lines
48 KiB
Go
1397 lines
48 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestListWorkspaceAgentTaskSnapshot covers the agent presence snapshot endpoint:
|
|
// every active task (queued/dispatched/running) PLUS each agent's most recent
|
|
// OUTCOME task (completed/failed only). Cancelled tasks are excluded by design
|
|
// from the outcome half — they're a procedural signal, not an outcome, and
|
|
// must NOT mask a prior failure.
|
|
//
|
|
// The fixtures cover every branch the SQL must classify:
|
|
// - actives are always returned, no dedup
|
|
// - outcomes are deduped to "latest per agent" by completed_at
|
|
// - the OLD 2-minute window must be irrelevant (a 5-minute-old failure is
|
|
// still returned if it's the latest outcome)
|
|
// - cancelled rows are NEVER returned, even when they are temporally newer
|
|
// than a failure — this is what keeps the failed signal sticky after the
|
|
// user cancels their queued retry
|
|
func TestListWorkspaceAgentTaskSnapshot(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
// Three agents so we can verify per-agent semantics independently.
|
|
agentA := createHandlerTestAgent(t, "snapshot-agent-a", []byte(`{}`))
|
|
agentB := createHandlerTestAgent(t, "snapshot-agent-b", []byte(`{}`))
|
|
agentC := createHandlerTestAgent(t, "snapshot-agent-c", []byte(`{}`))
|
|
|
|
type taskFixture struct {
|
|
agentID string
|
|
status string
|
|
completedAt string // SQL expression; "" for NULL
|
|
label string
|
|
}
|
|
fixtures := []taskFixture{
|
|
// Agent A — actives + a newer completed supersedes an older failed.
|
|
{agentA, "queued", "", "A.queued"},
|
|
{agentA, "dispatched", "", "A.dispatched"},
|
|
{agentA, "running", "", "A.running"},
|
|
{agentA, "failed", "now() - interval '10 minutes'", "A.old_failed"},
|
|
{agentA, "completed", "now() - interval '30 seconds'", "A.latest_completed"},
|
|
|
|
// Agent B — old failure with no later outcome stays visible (no
|
|
// time window).
|
|
{agentB, "failed", "now() - interval '5 minutes'", "B.stale_failed_kept"},
|
|
|
|
// Agent C — failure followed by a NEWER cancelled. The cancelled
|
|
// must be skipped by the SQL filter so the failure remains visible.
|
|
// This is the scenario where a user fails, then cancels their
|
|
// queued retry to debug.
|
|
{agentC, "failed", "now() - interval '5 minutes'", "C.failure"},
|
|
{agentC, "cancelled", "now() - interval '30 seconds'", "C.newer_cancelled_must_be_ignored"},
|
|
}
|
|
|
|
insertedIDs := make([]string, 0, len(fixtures))
|
|
for _, f := range fixtures {
|
|
var id string
|
|
var query string
|
|
if f.completedAt == "" {
|
|
query = `INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
|
|
VALUES ($1, $2, $3, 0) RETURNING id`
|
|
} else {
|
|
query = `INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, completed_at)
|
|
VALUES ($1, $2, $3, 0, ` + f.completedAt + `) RETURNING id`
|
|
}
|
|
if err := testPool.QueryRow(ctx, query, f.agentID, testRuntimeID, f.status).Scan(&id); err != nil {
|
|
t.Fatalf("insert %s: %v", f.label, err)
|
|
}
|
|
insertedIDs = append(insertedIDs, id)
|
|
}
|
|
t.Cleanup(func() {
|
|
for _, id := range insertedIDs {
|
|
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, id)
|
|
}
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest(http.MethodGet, "/api/agent-task-snapshot", nil)
|
|
testHandler.ListWorkspaceAgentTaskSnapshot(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListWorkspaceAgentTaskSnapshot: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var tasks []AgentTaskResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
|
|
// Per-agent breakdown so leftover tasks from other tests in this package
|
|
// don't pollute the assertions.
|
|
type key struct{ agent, status string }
|
|
counts := map[key]int{}
|
|
for _, task := range tasks {
|
|
if task.AgentID != agentA && task.AgentID != agentB && task.AgentID != agentC {
|
|
continue
|
|
}
|
|
counts[key{task.AgentID, task.Status}]++
|
|
}
|
|
|
|
wantCounts := map[key]int{
|
|
// Agent A: 3 actives + the latest outcome (completed). The older
|
|
// failed must be excluded by DISTINCT ON.
|
|
{agentA, "queued"}: 1,
|
|
{agentA, "dispatched"}: 1,
|
|
{agentA, "running"}: 1,
|
|
{agentA, "completed"}: 1,
|
|
// Agent B: just the failed outcome.
|
|
{agentB, "failed"}: 1,
|
|
// Agent C: the failed outcome must survive the temporally newer
|
|
// cancellation — that's the whole point of excluding cancelled
|
|
// from the outcome half.
|
|
{agentC, "failed"}: 1,
|
|
}
|
|
for k, expected := range wantCounts {
|
|
if got := counts[k]; got != expected {
|
|
t.Errorf("agent=%s status=%s: expected %d, got %d", k.agent, k.status, expected, got)
|
|
}
|
|
}
|
|
|
|
// The OLD failed terminal on agent A must be excluded.
|
|
if counts[key{agentA, "failed"}] != 0 {
|
|
t.Errorf("agent A old failed must be superseded by newer completed; got %d", counts[key{agentA, "failed"}])
|
|
}
|
|
|
|
// No cancelled row may ever appear in the snapshot — they're filtered at
|
|
// SQL level so the front-end's "cancel doesn't mask failure" rule lands
|
|
// without any front-end logic.
|
|
for _, agentID := range []string{agentA, agentB, agentC} {
|
|
if counts[key{agentID, "cancelled"}] != 0 {
|
|
t.Errorf("agent %s: cancelled rows must be excluded from snapshot; got %d",
|
|
agentID, counts[key{agentID, "cancelled"}])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListWorkspaceWorkingAgents(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
workingAgentID := createHandlerTestAgent(t, "working-agents-running", []byte(`{}`))
|
|
queuedAgentID := createHandlerTestAgent(t, "working-agents-queued", []byte(`{}`))
|
|
|
|
// An agent owned by someone else keeps the squad fixtures mutually
|
|
// exclusive: it can lead a squad without accidentally satisfying the
|
|
// "leader owned by me" branch.
|
|
var outsiderUserID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO "user" (name, email)
|
|
VALUES ('Working Agents Outsider', 'working-agents-outsider-' || gen_random_uuid()::text || '@multica.test')
|
|
RETURNING id
|
|
`).Scan(&outsiderUserID); err != nil {
|
|
t.Fatalf("insert outsider user: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, outsiderUserID)
|
|
})
|
|
|
|
var outsiderAgentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent (
|
|
workspace_id, name, description, runtime_mode, runtime_config,
|
|
runtime_id, visibility, permission_mode, max_concurrent_tasks, owner_id,
|
|
instructions, custom_env, custom_args, mcp_config
|
|
)
|
|
VALUES (
|
|
$1, 'working-agents-outsider', '', 'cloud', '{}'::jsonb,
|
|
$2, 'workspace', 'private', 1, $3,
|
|
'', '{}'::jsonb, '[]'::jsonb, '{}'::jsonb
|
|
)
|
|
RETURNING id
|
|
`, testWorkspaceID, testRuntimeID, outsiderUserID).Scan(&outsiderAgentID); err != nil {
|
|
t.Fatalf("insert outsider agent: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM agent WHERE id = $1`, outsiderAgentID)
|
|
})
|
|
|
|
insertedSquadIDs := make([]string, 0, 3)
|
|
insertSquad := func(name, leaderID string) string {
|
|
t.Helper()
|
|
var squadID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO squad (
|
|
workspace_id, name, description, leader_id, creator_id
|
|
)
|
|
VALUES ($1, $2, '', $3, $4)
|
|
RETURNING id
|
|
`,
|
|
testWorkspaceID,
|
|
name,
|
|
leaderID,
|
|
testUserID,
|
|
).Scan(&squadID); err != nil {
|
|
t.Fatalf("insert squad %q: %v", name, err)
|
|
}
|
|
insertedSquadIDs = append(insertedSquadIDs, squadID)
|
|
return squadID
|
|
}
|
|
directMemberSquadID := insertSquad(
|
|
"working-agents-direct-member-squad",
|
|
outsiderAgentID,
|
|
)
|
|
ownedLeaderSquadID := insertSquad(
|
|
"working-agents-owned-leader-squad",
|
|
workingAgentID,
|
|
)
|
|
ownedMemberSquadID := insertSquad(
|
|
"working-agents-owned-member-squad",
|
|
outsiderAgentID,
|
|
)
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO squad_member (squad_id, member_type, member_id)
|
|
VALUES
|
|
($1, 'member', $2),
|
|
($3, 'agent', $4)
|
|
`,
|
|
directMemberSquadID,
|
|
testUserID,
|
|
ownedMemberSquadID,
|
|
workingAgentID,
|
|
); err != nil {
|
|
t.Fatalf("insert squad involvement fixtures: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
for _, squadID := range insertedSquadIDs {
|
|
testPool.Exec(ctx, `DELETE FROM squad WHERE id = $1`, squadID)
|
|
}
|
|
})
|
|
|
|
// Insert source issues directly: this test is about the working-agent
|
|
// projection, so it must not inherit unrelated CreateIssue validation or
|
|
// side effects. The fixtures cover every direct My Issues relation plus
|
|
// all three squad-involvement branches.
|
|
insertedIssueIDs := make([]string, 0, 6)
|
|
insertIssue := func(
|
|
title, creatorType, creatorID string,
|
|
assigneeType, assigneeID any,
|
|
) string {
|
|
t.Helper()
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, number, title, status, priority,
|
|
assignee_type, assignee_id, creator_type, creator_id
|
|
)
|
|
SELECT $1, COALESCE(MIN(number), 0) - 1, $2, 'todo', 'none',
|
|
$3, $4, $5, $6
|
|
FROM issue
|
|
WHERE workspace_id = $1
|
|
RETURNING id
|
|
`,
|
|
testWorkspaceID,
|
|
title,
|
|
assigneeType,
|
|
assigneeID,
|
|
creatorType,
|
|
creatorID,
|
|
).Scan(&issueID); err != nil {
|
|
t.Fatalf("insert source issue %q: %v", title, err)
|
|
}
|
|
insertedIssueIDs = append(insertedIssueIDs, issueID)
|
|
return issueID
|
|
}
|
|
assignedIssueID := insertIssue(
|
|
"working-agent-assigned-to-me",
|
|
"member",
|
|
testUserID,
|
|
"member",
|
|
testUserID,
|
|
)
|
|
ownedAgentIssueID := insertIssue(
|
|
"working-agent-owned-agent",
|
|
"agent",
|
|
outsiderAgentID,
|
|
"agent",
|
|
workingAgentID,
|
|
)
|
|
outsideIssueID := insertIssue(
|
|
"working-agent-outside-mine",
|
|
"agent",
|
|
outsiderAgentID,
|
|
nil,
|
|
nil,
|
|
)
|
|
directMemberSquadIssueID := insertIssue(
|
|
"working-agent-direct-member-squad",
|
|
"agent",
|
|
outsiderAgentID,
|
|
"squad",
|
|
directMemberSquadID,
|
|
)
|
|
ownedLeaderSquadIssueID := insertIssue(
|
|
"working-agent-owned-leader-squad",
|
|
"agent",
|
|
outsiderAgentID,
|
|
"squad",
|
|
ownedLeaderSquadID,
|
|
)
|
|
ownedMemberSquadIssueID := insertIssue(
|
|
"working-agent-owned-member-squad",
|
|
"agent",
|
|
outsiderAgentID,
|
|
"squad",
|
|
ownedMemberSquadID,
|
|
)
|
|
t.Cleanup(func() {
|
|
for _, issueID := range insertedIssueIDs {
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
|
}
|
|
})
|
|
chatSessionID := createHandlerTestChatSession(t, workingAgentID)
|
|
autopilotID := insertListTestAutopilot(t, workingAgentID, "working-agent-source-filter")
|
|
var autopilotRunID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot_run (autopilot_id, source, status)
|
|
VALUES ($1, 'manual', 'running')
|
|
RETURNING id
|
|
`, autopilotID).Scan(&autopilotRunID); err != nil {
|
|
t.Fatalf("insert autopilot run: %v", err)
|
|
}
|
|
|
|
// Nine running tasks on one agent: six issue relations, chat, autopilot,
|
|
// and quick-create (no source FK). The autopilot task also carries issue_id
|
|
// to prove source precedence keeps it out of type=issue.
|
|
insertedTaskIDs := make([]string, 0, 10)
|
|
for _, fixture := range []struct {
|
|
agentID string
|
|
status string
|
|
issueID any
|
|
chatSessionID any
|
|
autopilotRunID any
|
|
}{
|
|
{workingAgentID, "running", assignedIssueID, nil, nil},
|
|
{workingAgentID, "running", ownedAgentIssueID, nil, nil},
|
|
{workingAgentID, "running", outsideIssueID, nil, nil},
|
|
{workingAgentID, "running", directMemberSquadIssueID, nil, nil},
|
|
{workingAgentID, "running", ownedLeaderSquadIssueID, nil, nil},
|
|
{workingAgentID, "running", ownedMemberSquadIssueID, nil, nil},
|
|
{workingAgentID, "running", nil, chatSessionID, nil},
|
|
{workingAgentID, "running", assignedIssueID, nil, autopilotRunID},
|
|
{workingAgentID, "running", nil, nil, nil},
|
|
{queuedAgentID, "queued", nil, nil, nil},
|
|
} {
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, status, priority, issue_id,
|
|
chat_session_id, autopilot_run_id, started_at
|
|
)
|
|
VALUES ($1, $2, $3, 0, $4, $5, $6, now())
|
|
RETURNING id
|
|
`,
|
|
fixture.agentID,
|
|
testRuntimeID,
|
|
fixture.status,
|
|
fixture.issueID,
|
|
fixture.chatSessionID,
|
|
fixture.autopilotRunID,
|
|
).Scan(&taskID); err != nil {
|
|
t.Fatalf("insert %s task: %v", fixture.status, err)
|
|
}
|
|
insertedTaskIDs = append(insertedTaskIDs, taskID)
|
|
}
|
|
t.Cleanup(func() {
|
|
for _, taskID := range insertedTaskIDs {
|
|
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
}
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
query string
|
|
wantCount int32
|
|
wantIssueIDs []string
|
|
}{
|
|
{
|
|
name: "all sources",
|
|
wantCount: 9,
|
|
wantIssueIDs: []string{
|
|
assignedIssueID,
|
|
ownedAgentIssueID,
|
|
outsideIssueID,
|
|
directMemberSquadIssueID,
|
|
ownedLeaderSquadIssueID,
|
|
ownedMemberSquadIssueID,
|
|
},
|
|
},
|
|
{
|
|
name: "issue",
|
|
query: "?type=issue",
|
|
wantCount: 6,
|
|
wantIssueIDs: []string{
|
|
assignedIssueID,
|
|
ownedAgentIssueID,
|
|
outsideIssueID,
|
|
directMemberSquadIssueID,
|
|
ownedLeaderSquadIssueID,
|
|
ownedMemberSquadIssueID,
|
|
},
|
|
},
|
|
{
|
|
name: "autopilot",
|
|
query: "?type=autopilot",
|
|
wantCount: 1,
|
|
wantIssueIDs: []string{assignedIssueID},
|
|
},
|
|
{name: "chat", query: "?type=chat", wantCount: 1, wantIssueIDs: []string{}},
|
|
{
|
|
name: "mine defaults to any",
|
|
query: "?type=issue&scope=mine",
|
|
wantCount: 5,
|
|
wantIssueIDs: []string{
|
|
assignedIssueID,
|
|
ownedAgentIssueID,
|
|
directMemberSquadIssueID,
|
|
ownedLeaderSquadIssueID,
|
|
ownedMemberSquadIssueID,
|
|
},
|
|
},
|
|
{
|
|
name: "mine any",
|
|
query: "?type=issue&scope=mine&relation=any",
|
|
wantCount: 5,
|
|
wantIssueIDs: []string{
|
|
assignedIssueID,
|
|
ownedAgentIssueID,
|
|
directMemberSquadIssueID,
|
|
ownedLeaderSquadIssueID,
|
|
ownedMemberSquadIssueID,
|
|
},
|
|
},
|
|
{
|
|
name: "mine assigned",
|
|
query: "?type=issue&scope=mine&relation=assigned",
|
|
wantCount: 1,
|
|
wantIssueIDs: []string{assignedIssueID},
|
|
},
|
|
{
|
|
name: "mine created",
|
|
query: "?type=issue&scope=mine&relation=created",
|
|
wantCount: 1,
|
|
wantIssueIDs: []string{assignedIssueID},
|
|
},
|
|
{
|
|
name: "mine involved",
|
|
query: "?type=issue&scope=mine&relation=involved",
|
|
wantCount: 4,
|
|
wantIssueIDs: []string{
|
|
ownedAgentIssueID,
|
|
directMemberSquadIssueID,
|
|
ownedLeaderSquadIssueID,
|
|
ownedMemberSquadIssueID,
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListWorkspaceWorkingAgents(
|
|
w,
|
|
newRequest(http.MethodGet, "/api/working-agents"+tc.query, nil),
|
|
)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var agents []WorkspaceWorkingAgent
|
|
if err := json.NewDecoder(w.Body).Decode(&agents); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
|
|
var working *WorkspaceWorkingAgent
|
|
for i := range agents {
|
|
switch agents[i].ID {
|
|
case workingAgentID:
|
|
working = &agents[i]
|
|
case queuedAgentID:
|
|
t.Errorf("queued-only agent must not be returned")
|
|
}
|
|
}
|
|
if working == nil {
|
|
t.Fatalf("running agent %s was not returned", workingAgentID)
|
|
}
|
|
if working.Name != "working-agents-running" {
|
|
t.Errorf("name = %q, want %q", working.Name, "working-agents-running")
|
|
}
|
|
if working.RunningTaskCount != tc.wantCount {
|
|
t.Errorf("running_task_count = %d, want %d", working.RunningTaskCount, tc.wantCount)
|
|
}
|
|
if len(working.IssueIDs) != len(tc.wantIssueIDs) {
|
|
t.Fatalf("issue_ids = %v, want %v", working.IssueIDs, tc.wantIssueIDs)
|
|
}
|
|
wantIssueIDs := make(map[string]struct{}, len(tc.wantIssueIDs))
|
|
for _, issueID := range tc.wantIssueIDs {
|
|
wantIssueIDs[issueID] = struct{}{}
|
|
}
|
|
for _, issueID := range working.IssueIDs {
|
|
if _, ok := wantIssueIDs[issueID]; !ok {
|
|
t.Errorf("unexpected issue_id %s; want %v", issueID, tc.wantIssueIDs)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListWorkspaceWorkingAgentsRejectsInvalidFilters(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
for _, path := range []string{
|
|
"/api/working-agents?type=quick_create",
|
|
"/api/working-agents?scope=mine",
|
|
"/api/working-agents?type=chat&scope=mine",
|
|
"/api/working-agents?type=issue&scope=workspace",
|
|
"/api/working-agents?type=issue&relation=assigned",
|
|
"/api/working-agents?type=issue&scope=mine&relation=watching",
|
|
} {
|
|
t.Run(path, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListWorkspaceWorkingAgents(
|
|
w,
|
|
newRequest(http.MethodGet, path, nil),
|
|
)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateAgent_RejectsDuplicateName(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// Clean up any agents created by this test.
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(),
|
|
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
|
|
testWorkspaceID, "duplicate-name-test-agent",
|
|
)
|
|
})
|
|
|
|
body := map[string]any{
|
|
"name": "duplicate-name-test-agent",
|
|
"description": "first description",
|
|
"runtime_id": testRuntimeID,
|
|
"visibility": "private",
|
|
"max_concurrent_tasks": 1,
|
|
}
|
|
|
|
// First call — creates the agent.
|
|
w1 := httptest.NewRecorder()
|
|
testHandler.CreateAgent(w1, newRequest(http.MethodPost, "/api/agents", body))
|
|
if w1.Code != http.StatusCreated {
|
|
t.Fatalf("first CreateAgent: expected 201, got %d: %s", w1.Code, w1.Body.String())
|
|
}
|
|
var resp1 map[string]any
|
|
if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil {
|
|
t.Fatalf("decode first response: %v", err)
|
|
}
|
|
agentID1, _ := resp1["id"].(string)
|
|
if agentID1 == "" {
|
|
t.Fatalf("first CreateAgent: no id in response: %v", resp1)
|
|
}
|
|
|
|
// Second call — same name must be rejected with 409 Conflict.
|
|
// The unique constraint prevents silent duplicates; the UI shows a clear error.
|
|
body["description"] = "updated description"
|
|
w2 := httptest.NewRecorder()
|
|
testHandler.CreateAgent(w2, newRequest(http.MethodPost, "/api/agents", body))
|
|
if w2.Code != http.StatusConflict {
|
|
t.Fatalf("second CreateAgent with duplicate name: expected 409, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateAgent_AssignsAvatarDefault(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
avatarURL *string
|
|
wantAvatar string
|
|
wantEmoji bool
|
|
}{
|
|
{name: "omitted", wantEmoji: true},
|
|
{name: "empty", avatarURL: ptr(""), wantEmoji: true},
|
|
{
|
|
name: "explicit",
|
|
avatarURL: ptr("https://cdn.example.com/avatars/agent.png"),
|
|
wantAvatar: "https://cdn.example.com/avatars/agent.png",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
agentName := "avatar-default-test-" + tt.name
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(),
|
|
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
|
|
testWorkspaceID, agentName,
|
|
)
|
|
})
|
|
|
|
body := map[string]any{
|
|
"name": agentName,
|
|
"runtime_id": testRuntimeID,
|
|
}
|
|
if tt.avatarURL != nil {
|
|
body["avatar_url"] = *tt.avatarURL
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
testHandler.CreateAgent(w, newRequest(http.MethodPost, "/api/agents", body))
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAgent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if response.AvatarURL == nil {
|
|
t.Fatal("CreateAgent: avatar_url is nil")
|
|
}
|
|
if tt.wantEmoji {
|
|
if !strings.HasPrefix(*response.AvatarURL, "emoji:") {
|
|
t.Fatalf("CreateAgent: avatar_url = %q, want emoji avatar", *response.AvatarURL)
|
|
}
|
|
return
|
|
}
|
|
if *response.AvatarURL != tt.wantAvatar {
|
|
t.Fatalf("CreateAgent: avatar_url = %q, want %q", *response.AvatarURL, tt.wantAvatar)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceAlwaysRedactSecrets(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
settings []byte
|
|
want bool
|
|
}{
|
|
{"nil settings", nil, false},
|
|
{"empty settings", []byte(`{}`), false},
|
|
{"false", []byte(`{"always_redact_env": false}`), false},
|
|
{"true", []byte(`{"always_redact_env": true}`), true},
|
|
{"invalid json", []byte(`not json`), false},
|
|
{"other fields only", []byte(`{"theme": "dark"}`), false},
|
|
{"true among other fields", []byte(`{"theme": "dark", "always_redact_env": true}`), true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := workspaceAlwaysRedactSecrets(tt.settings); got != tt.want {
|
|
t.Errorf("workspaceAlwaysRedactSecrets(%q) = %v, want %v", tt.settings, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// rawJSONResponse decodes the raw map so we can assert the literal
|
|
// JSON shape — `custom_env` MUST be absent from the wire output, not
|
|
// merely empty, otherwise a future caller decoding into a wider struct
|
|
// could still see masked or partial values.
|
|
func rawJSONResponse(t *testing.T, body []byte) map[string]any {
|
|
t.Helper()
|
|
var out map[string]any
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestGetAgent_ResponseHasNoCustomEnv guards the core invariant from
|
|
// MUL-2600: the generic agent resource response NEVER carries the
|
|
// custom_env field, even for the agent's owner. Only the dedicated
|
|
// env endpoint exposes secret values.
|
|
func TestGetAgent_ResponseHasNoCustomEnv(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID := createHandlerTestAgent(t, "noenv-get-agent", nil)
|
|
if _, err := testPool.Exec(ctx, `UPDATE agent SET custom_env = '{"SECRET_KEY": "super-secret"}' WHERE id = $1`, agentID); err != nil {
|
|
t.Fatalf("failed to set custom_env: %v", err)
|
|
}
|
|
|
|
req := newRequest("GET", "/agents/"+agentID, nil)
|
|
req = withURLParam(req, "id", agentID)
|
|
w := httptest.NewRecorder()
|
|
testHandler.GetAgent(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
raw := rawJSONResponse(t, w.Body.Bytes())
|
|
if _, ok := raw["custom_env"]; ok {
|
|
t.Errorf("custom_env field must not appear in agent response, got %v", raw["custom_env"])
|
|
}
|
|
if _, ok := raw["custom_env_redacted"]; ok {
|
|
t.Errorf("custom_env_redacted field must not appear in agent response (use has_custom_env)")
|
|
}
|
|
if got, _ := raw["has_custom_env"].(bool); !got {
|
|
t.Errorf("has_custom_env expected true, got %v", raw["has_custom_env"])
|
|
}
|
|
if got, _ := raw["custom_env_key_count"].(float64); got != 1 {
|
|
t.Errorf("custom_env_key_count expected 1, got %v", raw["custom_env_key_count"])
|
|
}
|
|
|
|
// Sanity-check the typed shape too — the struct must not have
|
|
// rehydrated the masked map.
|
|
var typed AgentResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &typed); err != nil {
|
|
t.Fatalf("typed decode failed: %v", err)
|
|
}
|
|
if typed.HasCustomEnv != true {
|
|
t.Errorf("typed.HasCustomEnv expected true")
|
|
}
|
|
if typed.CustomEnvKeyCount != 1 {
|
|
t.Errorf("typed.CustomEnvKeyCount expected 1, got %d", typed.CustomEnvKeyCount)
|
|
}
|
|
}
|
|
|
|
// TestListAgents_ResponseHasNoCustomEnv mirrors the GetAgent guard for
|
|
// the list endpoint. Same invariant: no custom_env field on the wire,
|
|
// only coarse metadata.
|
|
func TestListAgents_ResponseHasNoCustomEnv(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentName := "noenv-list-agent"
|
|
agentID := createHandlerTestAgent(t, agentName, nil)
|
|
if _, err := testPool.Exec(ctx, `UPDATE agent SET custom_env = '{"SECRET_KEY": "super-secret", "OTHER": "y"}' WHERE id = $1`, agentID); err != nil {
|
|
t.Fatalf("failed to set custom_env: %v", err)
|
|
}
|
|
|
|
req := newRequest("GET", "/agents", nil)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListAgents(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var rawAgents []map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &rawAgents); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
var found map[string]any
|
|
for _, a := range rawAgents {
|
|
if name, _ := a["name"].(string); name == agentName {
|
|
found = a
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatal("agent not found in list response")
|
|
}
|
|
if _, ok := found["custom_env"]; ok {
|
|
t.Errorf("custom_env must not appear in list response")
|
|
}
|
|
if got, _ := found["custom_env_key_count"].(float64); got != 2 {
|
|
t.Errorf("custom_env_key_count expected 2, got %v", found["custom_env_key_count"])
|
|
}
|
|
if got, _ := found["has_custom_env"].(bool); !got {
|
|
t.Errorf("has_custom_env expected true")
|
|
}
|
|
}
|
|
|
|
// TestGetAgentEnv_OwnerSucceedsAndAudits exercises the happy path: an
|
|
// agent owner reveals env, and the response carries the plaintext map.
|
|
// The activity_log row is checked at the end so the audit trail is
|
|
// proven to land in the same transaction window.
|
|
func TestGetAgentEnv_OwnerSucceedsAndAudits(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID := createHandlerTestAgent(t, "env-reveal-owner-agent", nil)
|
|
if _, err := testPool.Exec(ctx, `UPDATE agent SET custom_env = '{"KEY_ONE": "v1", "KEY_TWO": "v2"}' WHERE id = $1`, agentID); err != nil {
|
|
t.Fatalf("failed to set custom_env: %v", err)
|
|
}
|
|
|
|
req := newRequest("GET", "/api/agents/"+agentID+"/env", nil)
|
|
req = withURLParam(req, "id", agentID)
|
|
w := httptest.NewRecorder()
|
|
testHandler.GetAgentEnv(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetAgentEnv: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp AgentEnvResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.AgentID != agentID {
|
|
t.Errorf("agent_id mismatch: got %q", resp.AgentID)
|
|
}
|
|
expected := map[string]string{"KEY_ONE": "v1", "KEY_TWO": "v2"}
|
|
if !reflect.DeepEqual(resp.CustomEnv, expected) {
|
|
t.Errorf("CustomEnv mismatch: got %v, want %v", resp.CustomEnv, expected)
|
|
}
|
|
|
|
// Audit row must exist; keys but not values must be recorded.
|
|
var revealedKeysJSON string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT details::text FROM activity_log
|
|
WHERE workspace_id = $1 AND action = 'agent_env_revealed'
|
|
AND details->>'agent_id' = $2
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, testWorkspaceID, agentID).Scan(&revealedKeysJSON); err != nil {
|
|
t.Fatalf("no agent_env_revealed activity row found: %v", err)
|
|
}
|
|
if !strings.Contains(revealedKeysJSON, `"KEY_ONE"`) || !strings.Contains(revealedKeysJSON, `"KEY_TWO"`) {
|
|
t.Errorf("expected revealed_keys to contain KEY_ONE and KEY_TWO, got: %s", revealedKeysJSON)
|
|
}
|
|
if strings.Contains(revealedKeysJSON, `"v1"`) || strings.Contains(revealedKeysJSON, `"v2"`) {
|
|
t.Errorf("activity details must NOT contain env values, got: %s", revealedKeysJSON)
|
|
}
|
|
}
|
|
|
|
// TestAgentEnv_AgentActorRejected proves the security-critical actor
|
|
// guard: even when the underlying user is a workspace owner, a request
|
|
// arriving from inside a running agent task is denied 403. This is
|
|
// the lateral-movement fix — an agent running with its owner's token
|
|
// cannot reveal a sibling agent's secrets.
|
|
func TestAgentEnv_AgentActorRejected(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
targetID := createHandlerTestAgent(t, "env-target-agent", nil)
|
|
if _, err := testPool.Exec(context.Background(), `UPDATE agent SET custom_env = '{"K":"v"}' WHERE id = $1`, targetID); err != nil {
|
|
t.Fatalf("failed to set custom_env: %v", err)
|
|
}
|
|
|
|
// Spin up a separate agent + task that authorises the X-Agent-ID /
|
|
// X-Task-ID header pair resolveActor checks. The owning member of
|
|
// the host agent is the same testUserID (workspace owner), which is
|
|
// the exact lateral-movement shape we want to block.
|
|
hostAgentID := createHandlerTestAgent(t, "env-host-agent", nil)
|
|
hostTaskID := createHandlerTestTaskForAgent(t, hostAgentID)
|
|
|
|
cases := []struct {
|
|
name string
|
|
fn func(http.ResponseWriter, *http.Request)
|
|
body any
|
|
}{
|
|
{"reveal", testHandler.GetAgentEnv, nil},
|
|
{"update", testHandler.UpdateAgentEnv, map[string]any{"custom_env": map[string]string{"K": "v2"}}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
method := http.MethodGet
|
|
if tc.body != nil {
|
|
method = http.MethodPut
|
|
}
|
|
req := newRequest(method, "/api/agents/"+targetID+"/env", tc.body)
|
|
req = withURLParam(req, "id", targetID)
|
|
req.Header.Set("X-Agent-ID", hostAgentID)
|
|
req.Header.Set("X-Task-ID", hostTaskID)
|
|
w := httptest.NewRecorder()
|
|
tc.fn(w, req)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 from agent actor, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAgentEnv_TaskTokenActorSource locks in the post-MUL-2600 attack
|
|
// model: an agent process that strips its identifying headers
|
|
// (X-Agent-ID / X-Task-ID) but is still authenticated by an `mat_`
|
|
// task token MUST be recognized as actor=agent and rejected on the
|
|
// env endpoint. The auth middleware sets X-Actor-Source=task_token
|
|
// from the token row; resolveActor honors that header before the
|
|
// header-pair fallback. Without this guard the lateral-movement fix
|
|
// would only block "honest" CLIs that voluntarily set both headers.
|
|
func TestAgentEnv_TaskTokenActorSource(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
targetID := createHandlerTestAgent(t, "env-tt-target-agent", nil)
|
|
if _, err := testPool.Exec(context.Background(), `UPDATE agent SET custom_env = '{"K":"v"}' WHERE id = $1`, targetID); err != nil {
|
|
t.Fatalf("failed to set custom_env: %v", err)
|
|
}
|
|
|
|
req := newRequest(http.MethodGet, "/api/agents/"+targetID+"/env", nil)
|
|
req = withURLParam(req, "id", targetID)
|
|
// Simulate the auth middleware's post-mat_-resolution state: the
|
|
// only header touching actor identity is X-Actor-Source. The agent
|
|
// process stripped X-Agent-ID and X-Task-ID, hoping to fall back
|
|
// to the member auth path — the server-set X-Actor-Source must
|
|
// short-circuit that escape.
|
|
req.Header.Set("X-Actor-Source", "task_token")
|
|
req.Header.Del("X-Agent-ID")
|
|
req.Header.Del("X-Task-ID")
|
|
w := httptest.NewRecorder()
|
|
testHandler.GetAgentEnv(w, req)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 when X-Actor-Source=task_token, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateAgentEnv_PreservesSentinelValues verifies the **** guard.
|
|
// A naive write would clobber real secrets with the masked
|
|
// placeholder; we want any key whose value comes in as **** to keep
|
|
// its stored value.
|
|
func TestUpdateAgentEnv_PreservesSentinelValues(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID := createHandlerTestAgent(t, "env-sentinel-agent", nil)
|
|
if _, err := testPool.Exec(ctx, `UPDATE agent SET custom_env = '{"KEEP_ME":"real-secret","ALSO":"another-secret"}' WHERE id = $1`, agentID); err != nil {
|
|
t.Fatalf("failed to seed custom_env: %v", err)
|
|
}
|
|
|
|
// Client sends one key with a real new value, one with **** (should
|
|
// be preserved), and one new key that isn't in the existing map but
|
|
// arrives as **** (must be dropped, never written as literal).
|
|
body := map[string]any{
|
|
"custom_env": map[string]string{
|
|
"KEEP_ME": "****",
|
|
"ALSO": "rotated",
|
|
"PHANTOM": "****",
|
|
"BRAND_NEW": "fresh",
|
|
},
|
|
}
|
|
req := newRequest(http.MethodPut, "/api/agents/"+agentID+"/env", body)
|
|
req = withURLParam(req, "id", agentID)
|
|
w := httptest.NewRecorder()
|
|
testHandler.UpdateAgentEnv(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgentEnv: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Refetch from DB so we don't rely on the response body alone.
|
|
var stored string
|
|
if err := testPool.QueryRow(ctx, `SELECT custom_env::text FROM agent WHERE id = $1`, agentID).Scan(&stored); err != nil {
|
|
t.Fatalf("failed to read back custom_env: %v", err)
|
|
}
|
|
var got map[string]string
|
|
if err := json.Unmarshal([]byte(stored), &got); err != nil {
|
|
t.Fatalf("failed to decode stored custom_env: %v", err)
|
|
}
|
|
want := map[string]string{
|
|
"KEEP_ME": "real-secret", // **** must preserve the existing value
|
|
"ALSO": "rotated", // explicit overwrite
|
|
"BRAND_NEW": "fresh", // new addition
|
|
// PHANTOM is intentionally absent — **** for a non-existent key
|
|
// is dropped, never persisted as literal `****`.
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("stored custom_env mismatch:\n got: %v\n want: %v", got, want)
|
|
}
|
|
|
|
// Audit row should reflect the diff. We decode the jsonb back into a
|
|
// typed map and compare semantically — postgres serializes jsonb with
|
|
// canonicalised whitespace (`"added_keys": ["BRAND_NEW"]`), so a raw
|
|
// substring match on the dense form silently fails on real database
|
|
// output.
|
|
var details string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT details::text FROM activity_log
|
|
WHERE workspace_id = $1 AND action = 'agent_env_updated' AND details->>'agent_id' = $2
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, testWorkspaceID, agentID).Scan(&details); err != nil {
|
|
t.Fatalf("expected agent_env_updated activity row: %v", err)
|
|
}
|
|
var auditFields struct {
|
|
AddedKeys []string `json:"added_keys"`
|
|
ChangedKeys []string `json:"changed_keys"`
|
|
PreservedKeys []string `json:"preserved_keys"`
|
|
}
|
|
if err := json.Unmarshal([]byte(details), &auditFields); err != nil {
|
|
t.Fatalf("failed to decode audit details: %v (raw=%s)", err, details)
|
|
}
|
|
if !reflect.DeepEqual(auditFields.AddedKeys, []string{"BRAND_NEW"}) {
|
|
t.Errorf("added_keys: got %v, want [BRAND_NEW]; raw=%s", auditFields.AddedKeys, details)
|
|
}
|
|
if !reflect.DeepEqual(auditFields.ChangedKeys, []string{"ALSO"}) {
|
|
t.Errorf("changed_keys: got %v, want [ALSO]; raw=%s", auditFields.ChangedKeys, details)
|
|
}
|
|
if !reflect.DeepEqual(auditFields.PreservedKeys, []string{"KEEP_ME"}) {
|
|
t.Errorf("preserved_keys: got %v, want [KEEP_ME]; raw=%s", auditFields.PreservedKeys, details)
|
|
}
|
|
// Audit must never contain values.
|
|
for _, leak := range []string{"real-secret", "another-secret", "rotated", "fresh"} {
|
|
if strings.Contains(details, leak) {
|
|
t.Errorf("audit details leaked value %q: %s", leak, details)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpdateAgent_RejectsCustomEnvInBody(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID := createHandlerTestAgent(t, "update-no-env-agent", nil)
|
|
if _, err := testPool.Exec(ctx, `UPDATE agent SET custom_env = '{"PRE":"existing"}' WHERE id = $1`, agentID); err != nil {
|
|
t.Fatalf("failed to seed custom_env: %v", err)
|
|
}
|
|
|
|
// Sending custom_env via the generic PUT /api/agents/{id} must fail
|
|
// loudly with a 400 — see the comment on the rejection in agent.go.
|
|
// Silently dropping the field used to make scripted clients believe
|
|
// they had rotated a secret when nothing actually happened.
|
|
body := map[string]any{
|
|
"description": "still updating description",
|
|
"custom_env": map[string]string{"INJECTED": "should-not-stick"},
|
|
}
|
|
req := newRequest(http.MethodPut, "/api/agents/"+agentID, body)
|
|
req = withURLParam(req, "id", agentID)
|
|
w := httptest.NewRecorder()
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateAgent: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "custom_env") || !strings.Contains(w.Body.String(), "/env") {
|
|
t.Errorf("error body should mention custom_env and the env endpoint; got %s", w.Body.String())
|
|
}
|
|
|
|
// The stored env must be untouched by the rejected request.
|
|
var stored string
|
|
if err := testPool.QueryRow(ctx, `SELECT custom_env::text FROM agent WHERE id = $1`, agentID).Scan(&stored); err != nil {
|
|
t.Fatalf("failed to read custom_env: %v", err)
|
|
}
|
|
if !strings.Contains(stored, `"PRE": "existing"`) && !strings.Contains(stored, `"PRE":"existing"`) {
|
|
t.Errorf("UpdateAgent must NOT touch custom_env; got %q", stored)
|
|
}
|
|
if strings.Contains(stored, "INJECTED") {
|
|
t.Errorf("UpdateAgent should have rejected custom_env in body; got %q", stored)
|
|
}
|
|
}
|
|
|
|
// TestMergeAgentEnv_PureFunction exercises the diff/sentinel logic
|
|
// without the DB round-trip — keeps the contract front-and-centre in
|
|
// case someone refactors the handler later.
|
|
func TestMergeAgentEnv_PureFunction(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
existing map[string]string
|
|
request map[string]string
|
|
want map[string]string
|
|
audit envAudit
|
|
}{
|
|
{
|
|
name: "preserve sentinel",
|
|
existing: map[string]string{"A": "real"},
|
|
request: map[string]string{"A": "****"},
|
|
want: map[string]string{"A": "real"},
|
|
audit: envAudit{preserved: []string{"A"}},
|
|
},
|
|
{
|
|
name: "drop sentinel for missing key",
|
|
existing: map[string]string{},
|
|
request: map[string]string{"A": "****"},
|
|
want: map[string]string{},
|
|
audit: envAudit{},
|
|
},
|
|
{
|
|
name: "add new key",
|
|
existing: map[string]string{},
|
|
request: map[string]string{"B": "v"},
|
|
want: map[string]string{"B": "v"},
|
|
audit: envAudit{added: []string{"B"}},
|
|
},
|
|
{
|
|
name: "change existing value",
|
|
existing: map[string]string{"B": "old"},
|
|
request: map[string]string{"B": "new"},
|
|
want: map[string]string{"B": "new"},
|
|
audit: envAudit{changed: []string{"B"}},
|
|
},
|
|
{
|
|
name: "remove key absent from request",
|
|
existing: map[string]string{"B": "v"},
|
|
request: map[string]string{},
|
|
want: map[string]string{},
|
|
audit: envAudit{removed: []string{"B"}},
|
|
},
|
|
{
|
|
name: "noop when value unchanged",
|
|
existing: map[string]string{"B": "same"},
|
|
request: map[string]string{"B": "same"},
|
|
want: map[string]string{"B": "same"},
|
|
audit: envAudit{},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, audit := mergeAgentEnv(tc.existing, tc.request)
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("merged map: got %v, want %v", got, tc.want)
|
|
}
|
|
if !reflect.DeepEqual(audit, tc.audit) {
|
|
t.Errorf("audit: got %+v, want %+v", audit, tc.audit)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Compile-time guard: AgentResponse must NOT carry the legacy env
|
|
// fields. Reintroducing them is a security regression — this test
|
|
// fails to compile rather than fails at runtime so reviewers see the
|
|
// breakage in the diff. Kept as a runtime test because the package
|
|
// boundary makes a struct-tag introspection cheap and obvious.
|
|
func TestAgentResponseShape_HasNoLegacyEnvFields(t *testing.T) {
|
|
typ := reflect.TypeOf(AgentResponse{})
|
|
for i := 0; i < typ.NumField(); i++ {
|
|
f := typ.Field(i)
|
|
tag := strings.Split(f.Tag.Get("json"), ",")[0]
|
|
switch tag {
|
|
case "custom_env", "custom_env_redacted", "custom_env_redacted_reason":
|
|
t.Errorf("AgentResponse must not carry %q field (MUL-2600)", tag)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUpdateAgent_RedactsMcpConfigForAgentActor closes the second leg
|
|
// of MUL-2600 review #2: an agent process with a task token (or with
|
|
// the X-Actor-Source server marker) must not be able to scrape another
|
|
// agent's mcp_config via an unrelated mutation response. Even when the
|
|
// host PAT would otherwise satisfy canManageAgent, the response body
|
|
// must come back with mcp_config redacted.
|
|
func TestUpdateAgent_RedactsMcpConfigForAgentActor(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// The target agent has a populated mcp_config that historically would
|
|
// be leaked back via the UpdateAgent / ArchiveAgent / RestoreAgent
|
|
// HTTP response.
|
|
target := createHandlerTestAgent(t, "mut-mcp-target", []byte(`{"server":"secret-config"}`))
|
|
|
|
// A second agent acts as the "calling" agent process whose task
|
|
// token authenticated the request. It is registered in the same
|
|
// workspace so resolveActor recognises X-Agent-ID as valid.
|
|
caller := createHandlerTestAgent(t, "mut-mcp-caller", nil)
|
|
taskID := insertHandlerTestTask(t, caller)
|
|
|
|
desc := "trivial mutation that should NOT leak target mcp_config"
|
|
req := newRequest(http.MethodPut, "/api/agents/"+target, map[string]any{
|
|
"description": desc,
|
|
})
|
|
req = withURLParam(req, "id", target)
|
|
// Simulate a task-token-authenticated agent request. The auth
|
|
// middleware would normally set these; we mimic both the modern
|
|
// path (X-Actor-Source) and the legacy header pair so the test is
|
|
// resilient to either resolveActor branch.
|
|
req.Header.Set("X-Actor-Source", "task_token")
|
|
req.Header.Set("X-Agent-ID", caller)
|
|
req.Header.Set("X-Task-ID", taskID)
|
|
w := httptest.NewRecorder()
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
// The response contract keeps `mcp_config` always-present so clients
|
|
// can distinguish "no config" vs "redacted" via the companion flag.
|
|
// `json.RawMessage` of a JSON null decodes to the literal bytes
|
|
// `null`, not Go nil — so check for "no secret-bearing content"
|
|
// rather than `!= nil`.
|
|
if len(resp.McpConfig) > 0 && !bytes.Equal(bytes.TrimSpace(resp.McpConfig), []byte("null")) {
|
|
t.Errorf("UpdateAgent response leaked mcp_config to agent actor: %s", string(resp.McpConfig))
|
|
}
|
|
if !resp.McpConfigRedacted {
|
|
t.Errorf("UpdateAgent response should set mcp_config_redacted=true for agent actor")
|
|
}
|
|
}
|
|
|
|
// TestUpdateAgent_KeepsMcpConfigForMemberActor is the matching positive
|
|
// test — a normal member request (owner/admin) still receives the full
|
|
// mcp_config in the mutation response, so the redaction does not
|
|
// accidentally regress the legitimate Web admin flow.
|
|
func TestUpdateAgent_KeepsMcpConfigForMemberActor(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
target := createHandlerTestAgent(t, "mut-mcp-member", []byte(`{"server":"member-visible"}`))
|
|
|
|
req := newRequest(http.MethodPut, "/api/agents/"+target, map[string]any{
|
|
"description": "owner-visible mutation",
|
|
})
|
|
req = withURLParam(req, "id", target)
|
|
w := httptest.NewRecorder()
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.McpConfig == nil {
|
|
t.Errorf("UpdateAgent response should keep mcp_config for member actor; got nil")
|
|
}
|
|
if resp.McpConfigRedacted {
|
|
t.Errorf("UpdateAgent response should NOT mark mcp_config redacted for member actor")
|
|
}
|
|
}
|
|
|
|
// TestUpdateAgent_PreservesSkillsInResponse is the regression for #3459:
|
|
// updating only description/instructions used to return "skills": []
|
|
// because the handler skipped the skill reload that GetAgent does. The
|
|
// DB row was always preserved; the response just lied about it, which
|
|
// scared users into manually re-running `agent skills set` and risked
|
|
// scripted clients writing the empty set back. We assert (a) the
|
|
// response carries the bound skills, (b) the DB row is unchanged, and
|
|
// (c) GetAgent reports the same shape so the two endpoints don't drift.
|
|
func TestUpdateAgent_PreservesSkillsInResponse(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID := createHandlerTestAgent(t, "update-preserves-skills-agent", nil)
|
|
skillA := insertHandlerTestSkill(t, "update-preserve-a", "alpha body")
|
|
skillB := insertHandlerTestSkill(t, "update-preserve-b", "beta body")
|
|
for _, sid := range []string{skillA, skillB} {
|
|
if _, err := testPool.Exec(ctx,
|
|
`INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)`,
|
|
agentID, sid,
|
|
); err != nil {
|
|
t.Fatalf("attach skill %s: %v", sid, err)
|
|
}
|
|
}
|
|
|
|
req := newRequest(http.MethodPut, "/api/agents/"+agentID, map[string]any{
|
|
"description": "metadata-only update",
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
w := httptest.NewRecorder()
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
gotIDs := map[string]bool{}
|
|
for _, s := range resp.Skills {
|
|
gotIDs[s.ID] = true
|
|
}
|
|
for _, want := range []string{skillA, skillB} {
|
|
if !gotIDs[want] {
|
|
t.Errorf("UpdateAgent response missing skill %s; got %+v", want, resp.Skills)
|
|
}
|
|
}
|
|
|
|
// Defence in depth: the junction table must be untouched too. Without
|
|
// this check a future regression that DOES wipe agent_skill rows but
|
|
// reloads them into the response would silently pass.
|
|
var rowCount int
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM agent_skill WHERE agent_id = $1`,
|
|
agentID,
|
|
).Scan(&rowCount); err != nil {
|
|
t.Fatalf("count agent_skill: %v", err)
|
|
}
|
|
if rowCount != 2 {
|
|
t.Errorf("agent_skill row count: expected 2, got %d", rowCount)
|
|
}
|
|
|
|
// GetAgent must agree with UpdateAgent on the skill list — otherwise
|
|
// CLI users will see one shape from the mutation and a different one
|
|
// on the very next read.
|
|
getReq := newRequest(http.MethodGet, "/api/agents/"+agentID, nil)
|
|
getReq = withURLParam(getReq, "id", agentID)
|
|
getW := httptest.NewRecorder()
|
|
testHandler.GetAgent(getW, getReq)
|
|
if getW.Code != http.StatusOK {
|
|
t.Fatalf("GetAgent: expected 200, got %d: %s", getW.Code, getW.Body.String())
|
|
}
|
|
var getResp AgentResponse
|
|
if err := json.NewDecoder(getW.Body).Decode(&getResp); err != nil {
|
|
t.Fatalf("decode GetAgent: %v", err)
|
|
}
|
|
if len(getResp.Skills) != len(resp.Skills) {
|
|
t.Errorf("GetAgent skill count %d != UpdateAgent skill count %d",
|
|
len(getResp.Skills), len(resp.Skills))
|
|
}
|
|
}
|
|
|
|
// TestArchiveRestoreAgent_PreservesSkillsInResponse is the sister
|
|
// regression for #3459: ArchiveAgent / RestoreAgent share the same
|
|
// agentToResponse path as UpdateAgent and previously also returned
|
|
// "skills": [] regardless of what was in the junction table. The
|
|
// archive/restore broadcasts are the only place where mobile clients
|
|
// learn about state flips, so an empty skills array there would propagate
|
|
// to every connected client until the next refetch.
|
|
func TestArchiveRestoreAgent_PreservesSkillsInResponse(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID := createHandlerTestAgent(t, "archive-preserves-skills-agent", nil)
|
|
skillID := insertHandlerTestSkill(t, "archive-preserve", "body")
|
|
if _, err := testPool.Exec(ctx,
|
|
`INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)`,
|
|
agentID, skillID,
|
|
); err != nil {
|
|
t.Fatalf("attach skill: %v", err)
|
|
}
|
|
|
|
archiveReq := newRequest(http.MethodPost, "/api/agents/"+agentID+"/archive", nil)
|
|
archiveReq = withURLParam(archiveReq, "id", agentID)
|
|
archiveW := httptest.NewRecorder()
|
|
testHandler.ArchiveAgent(archiveW, archiveReq)
|
|
if archiveW.Code != http.StatusOK {
|
|
t.Fatalf("ArchiveAgent: expected 200, got %d: %s", archiveW.Code, archiveW.Body.String())
|
|
}
|
|
var archived AgentResponse
|
|
if err := json.NewDecoder(archiveW.Body).Decode(&archived); err != nil {
|
|
t.Fatalf("decode archive: %v", err)
|
|
}
|
|
if len(archived.Skills) != 1 || archived.Skills[0].ID != skillID {
|
|
t.Errorf("ArchiveAgent: expected 1 skill %s, got %+v", skillID, archived.Skills)
|
|
}
|
|
|
|
restoreReq := newRequest(http.MethodPost, "/api/agents/"+agentID+"/restore", nil)
|
|
restoreReq = withURLParam(restoreReq, "id", agentID)
|
|
restoreW := httptest.NewRecorder()
|
|
testHandler.RestoreAgent(restoreW, restoreReq)
|
|
if restoreW.Code != http.StatusOK {
|
|
t.Fatalf("RestoreAgent: expected 200, got %d: %s", restoreW.Code, restoreW.Body.String())
|
|
}
|
|
var restored AgentResponse
|
|
if err := json.NewDecoder(restoreW.Body).Decode(&restored); err != nil {
|
|
t.Fatalf("decode restore: %v", err)
|
|
}
|
|
if len(restored.Skills) != 1 || restored.Skills[0].ID != skillID {
|
|
t.Errorf("RestoreAgent: expected 1 skill %s, got %+v", skillID, restored.Skills)
|
|
}
|
|
}
|
|
|
|
// insertHandlerTestTask creates an in_progress task for the given
|
|
// agent so resolveActor's GetAgentTask lookup succeeds without
|
|
// dragging the full TaskService into the test.
|
|
func insertHandlerTestTask(t *testing.T, agentID string) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
|
|
VALUES ($1, $2, 'running', 0)
|
|
RETURNING id
|
|
`, agentID, handlerTestRuntimeID(t)).Scan(&taskID); err != nil {
|
|
t.Fatalf("insert test task: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
})
|
|
return taskID
|
|
}
|
|
|
|
// Defence-in-depth: spot-check that the package compiles a small
|
|
// fmt.Sprintf so accidental imports stay tidy.
|
|
var _ = fmt.Sprintf
|