Files
multica/server/internal/handler/agent_test.go
Kagura eefc6cebaa feat(server): add workspace-level always_redact_env setting (MUL-2495) (#2367)
* feat(server): add workspace-level always_redact_env setting

When a workspace opts into always_redact_env (via workspace settings JSON),
all agent GET/LIST responses will have custom_env values masked and
mcp_config nulled regardless of the caller's role. This provides a stricter
security posture for single-tenant self-hosts or environments where
screen-sharing or pairing makes plaintext secrets a risk.

The setting is opt-in and defaults to false (preserving existing behavior).
Owners can still write secrets via the update path; they just cannot read
them back through the API when this setting is enabled.

Closes #2352

* fix(server): fail-closed on GetWorkspace, add HTTP tests, distinguish redaction reason

Address review feedback on #2367:

1. GetWorkspace failure now returns 500 instead of silently defaulting
   to alwaysRedact=false (fail-open → fail-closed).

2. Add HTTP-level regression tests for always_redact_env:
   - GetAgent with flag on → owner sees redacted env
   - ListAgents with flag on → owner sees redacted env
   - GetAgent with default settings → owner sees plaintext env

3. Add custom_env_redacted_reason field ('policy' | 'role') to
   distinguish workspace-policy redaction from role-based redaction.
   UI now only sets readOnly when reason is 'role', allowing owners
   to edit env even when always_redact_env is enabled.

4. Write-back footgun tracked in #2999.

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

* fix(test): clear workspace settings before DefaultNoRedactForOwner

Guard against test-order leakage: if a preceding test enabled
always_redact_env on the shared workspace and its cleanup didn't
run (e.g. due to -shuffle or parallel execution), this test would
incorrectly see policy-level redaction. Explicitly reset settings
to NULL before assertions.

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

* fix(ui): make EnvTab read-only when env is redacted by any policy

Previously the readOnly guard only checked for 'role' redaction,
leaving the tab editable under 'policy' redaction. This meant
a user could save the form with '****' placeholder values,
permanently overwriting the actual secrets.

Use the boolean custom_env_redacted flag instead so the tab is
locked regardless of the redaction reason.

Fixes the regression flagged in the third-pass review.

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

* fix: reset workspace settings to empty JSON instead of NULL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: gofmt AgentResponse struct alignment

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

---------

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 14:10:09 +08:00

350 lines
12 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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 TestWorkspaceAlwaysRedactEnv(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 := workspaceAlwaysRedactEnv(tt.settings); got != tt.want {
t.Errorf("workspaceAlwaysRedactEnv(%q) = %v, want %v", tt.settings, got, tt.want)
}
})
}
}
func resetWorkspaceSettings(t *testing.T, workspaceID string) {
t.Helper()
if _, err := testPool.Exec(context.Background(), "UPDATE workspace SET settings = '{}'::jsonb WHERE id = $1", workspaceID); err != nil {
t.Logf("warning: failed to reset workspace settings: %v", err)
}
}
func TestGetAgent_AlwaysRedactEnv_OwnerSeesRedacted(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Enable always_redact_env on workspace.
if _, err := testPool.Exec(ctx, `UPDATE workspace SET settings = '{"always_redact_env": true}' WHERE id = $1`, testWorkspaceID); err != nil {
t.Fatalf("failed to set workspace settings: %v", err)
}
t.Cleanup(func() { resetWorkspaceSettings(t, testWorkspaceID) })
agentID := createHandlerTestAgent(t, "redact-get-test-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())
}
var resp AgentResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.CustomEnvRedacted {
t.Error("expected custom_env_redacted to be true")
}
if resp.CustomEnvRedactedReason != "policy" {
t.Errorf("expected custom_env_redacted_reason to be 'policy', got %q", resp.CustomEnvRedactedReason)
}
if resp.CustomEnv["SECRET_KEY"] != "****" {
t.Errorf("expected SECRET_KEY to be redacted, got %q", resp.CustomEnv["SECRET_KEY"])
}
}
func TestListAgents_AlwaysRedactEnv_OwnerSeesRedacted(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
if _, err := testPool.Exec(ctx, `UPDATE workspace SET settings = '{"always_redact_env": true}' WHERE id = $1`, testWorkspaceID); err != nil {
t.Fatalf("failed to set workspace settings: %v", err)
}
t.Cleanup(func() { resetWorkspaceSettings(t, testWorkspaceID) })
agentName := "redact-list-test-agent"
agentID := createHandlerTestAgent(t, agentName, 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", 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 agents []AgentResponse
if err := json.Unmarshal(w.Body.Bytes(), &agents); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
var found *AgentResponse
for i := range agents {
if agents[i].Name == agentName {
found = &agents[i]
break
}
}
if found == nil {
t.Fatal("agent not found in list response")
}
if !found.CustomEnvRedacted {
t.Error("expected custom_env_redacted to be true")
}
if found.CustomEnvRedactedReason != "policy" {
t.Errorf("expected custom_env_redacted_reason to be 'policy', got %q", found.CustomEnvRedactedReason)
}
if found.CustomEnv["SECRET_KEY"] != "****" {
t.Errorf("expected SECRET_KEY to be redacted, got %q", found.CustomEnv["SECRET_KEY"])
}
}
func TestGetAgent_DefaultNoRedactForOwner(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Ensure workspace has no always_redact_env policy (guards against test-order leakage).
if _, err := testPool.Exec(ctx, `UPDATE workspace SET settings = '{}'::jsonb WHERE id = $1`, testWorkspaceID); err != nil {
t.Fatalf("failed to clear workspace settings: %v", err)
}
agentID := createHandlerTestAgent(t, "no-redact-get-test-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())
}
var resp AgentResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.CustomEnvRedacted {
t.Error("expected custom_env_redacted to be false")
}
if resp.CustomEnvRedactedReason != "" {
t.Errorf("expected custom_env_redacted_reason to be empty, got %q", resp.CustomEnvRedactedReason)
}
if resp.CustomEnv["SECRET_KEY"] != "super-secret" {
t.Errorf("expected SECRET_KEY to be visible, got %q", resp.CustomEnv["SECRET_KEY"])
}
}