Files
multica/server/internal/handler/agent_test.go
Bohan Jiang 13f74e651a feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600) (#3209)
* feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600)

The agent resource shape (list / get / create / update / archive /
restore responses + WebSocket events) no longer carries `custom_env`
values. Reads/writes of env now flow exclusively through a dedicated
`/api/agents/{id}/env` endpoint that is owner/admin-only, rejects
agent-actor sessions, applies a "****" sentinel preserve guard on
PUT, and writes a persistent audit row per reveal/update.

Why
- `multica agent list --output json` historically returned plaintext
  `custom_env` for owner/admin callers (the redaction gate gave only
  members the masked map). Any agent token running on the workspace
  inherits its owner's role and could read every other agent's
  secrets just by listing.
- Patching list/get redaction alone (PR #3175 direction) left
  symmetric leaks via mutation responses, WS events, the "reveal"
  path itself (no actor-aware auth), and a `****` overwrite footgun
  on UpdateAgent.

What changed
- Backend: drop `custom_env` from AgentResponse; add coarse
  `has_custom_env` + `custom_env_key_count`. Strip env handling from
  UpdateAgent (silently ignored if sent). Keep CreateAgent's
  custom_env acceptance.
- Backend: new GET/PUT `/api/agents/{id}/env` handlers in
  `internal/handler/agent_env.go`:
  - resolveActor → 403 for agent actors (closes the lateral-movement
    path).
  - Owner/admin role gate via existing helper.
  - PUT honours value == "****" as "preserve existing value".
  - Both write to `activity_log` with `agent_env_revealed` /
    `agent_env_updated` actions. Audit details record key names only,
    never values.
- Daemon claim path (`ClaimAgentTask`) unchanged — `TaskAgentData`
  still carries plaintext env for runtime injection.
- SQL: new `UpdateAgentCustomEnv` query; sqlc regenerated (v1.31.1).
- CLI: new `multica agent env get|set` subcommands. `--custom-env*`
  flags removed from `multica agent update`; the no-fields error
  now points to the new path.
- Frontend: drop env fields from `Agent` + `UpdateAgentRequest`; add
  `getAgentEnv` / `updateAgentEnv` client methods; rewrite env-tab
  to show "N variables configured" + explicit "Reveal & edit"
  button, fetching values only on intentional reveal.
- Locales: parity-safe additions to en + zh-Hans.
- Docs: agents-create.{mdx,zh.mdx} reflect the new threat model and
  endpoint.
- Mobile: schema drops `custom_env` / `custom_env_redacted`, adds
  metadata fields.

Tests
- Handler tests pinned the new invariants: no env in list/get
  responses, owner reveal happy-path + audit row, agent-actor 403,
  `****` sentinel preserves real values, UpdateAgent silently
  ignores `custom_env`, pure `mergeAgentEnv` cases.
- CLI tests pivot to the new flag surface: `agent update` MUST NOT
  expose the env flags; `agent env set` MUST expose
  --custom-env-stdin/--custom-env-file.
- Frontend test fixtures updated; pnpm typecheck / test / lint
  pass cleanly.

This is a breaking API change. Scripts that read `custom_env` from
`/api/agents` must migrate to `GET /api/agents/{id}/env`.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): close actor-spoofing + audit fail-closed in env endpoints (MUL-2600)

Addresses Elon's review of #3209:

* Mint a task-scoped `mat_` token per claim, bound to (agent, task,
  workspace, owner). Daemon injects it into the agent process in place
  of its own credential. Auth middleware authoritatively rebuilds
  X-User-ID / X-Agent-ID / X-Task-ID from the token row and sets
  X-Actor-Source=task_token; that header is server-set only — incoming
  values are stripped before any auth branch runs. resolveActor honors
  the header so an agent that strips X-Agent-ID / X-Task-ID still
  resolves as actor=agent.
* GetAgentEnv / UpdateAgentEnv are now fail-closed on audit-log
  failures: GET refuses to return plaintext, PUT persists inside the
  same tx as the audit row so they commit/roll back together.
* PUT /api/agents/{id} returns 400 when the body carries custom_env
  instead of silently dropping it — directs callers to the audited env
  endpoint.
* Agent actors never see mcp_config, even when the underlying member
  is owner/admin; mutation broadcasts go through a redaction shim so
  WS subscribers don't pick it up either.
* Fix backend test that asserted dense JSON (jsonb::text renders
  whitespace) and frontend test that assumed a unique "Test User"
  match.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): close residual MUL-2600 gaps from review (MUL-2600)

Migration 108 FK now correctly references agent_task_queue(id) instead
of the non-existent agent_task table; the previous name blocked CI
backend migrations.

Task-token-authenticated requests can no longer be re-routed at a
different workspace by passing workspace_slug / workspace_id /
?workspace_id / a URL workspace param. ResolveWorkspaceIDFromRequest
and resolveWorkspaceUUID both short-circuit on X-Actor-Source=task_token
and return only the token-bound X-Workspace-ID; buildMiddleware adds a
defence-in-depth 403 if any URL-resolved workspace disagrees with the
token binding.

mcp_config no longer leaks back to agent actors through UpdateAgent /
CreateAgent / ArchiveAgent / RestoreAgent HTTP responses — the same
redactAgentResponseForActor helper that GetAgent/ListAgents use is now
applied to mutation responses too. WS broadcasts were already redacted
via broadcastAgentResponse.

FailTask and every TaskService cancel path (CancelTask /
CancelTasksForIssue / CancelTasksForAgent / CancelTasksByTriggerComment
/ BroadcastCancelledTasks) now eagerly DeleteTaskTokensByTask so the
mat_ token's 24h window doesn't outlive a terminated task. Failure is
non-fatal — the FK cascade and expiry remain durable guards.

Doc-only: clarify that PUT /api/agents/{id} now hard-rejects bodies
that carry custom_env (was previously "silently ignores").

Tests:
- middleware: TestResolveWorkspaceIDFromRequest gains a task_token
  case asserting client-supplied slug/id/query cannot override the
  bound workspace.
- handler: TestUpdateAgent_RedactsMcpConfigForAgentActor and
  TestUpdateAgent_KeepsMcpConfigForMemberActor pin the mutation-
  response redaction contract per actor type.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): match redacted mcp_config as JSON null, not Go nil (MUL-2600)

`AgentResponse.McpConfig` is `json.RawMessage` without `omitempty`, so
the redacted response serialises as `"mcp_config": null`. On decode,
`json.RawMessage` keeps the literal bytes `null` rather than collapsing
to Go nil, which made the assertion fire on a non-leak.

The product contract (field always present, distinguished from "no
config" via `mcp_config_redacted`) is intentional, so adjust the test
to check for "no secret-bearing content" instead of weakening the
contract via `omitempty`.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 18:42:48 +08:00

799 lines
30 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 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 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")
}
}
// 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