Files
multica/server/internal/middleware/workspace_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

192 lines
5.7 KiB
Go

package middleware
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
const testResolverSlug = "middleware-resolver-test"
// openPool returns a connected pgxpool, or skips the test if the database is
// unreachable. Mirrors the handler package's fixture approach so tests don't
// require a DB in environments where one isn't available.
func openPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
pool, err := pgxpool.New(context.Background(), dbURL)
if err != nil {
t.Skipf("skipping: could not connect to database: %v", err)
}
if err := pool.Ping(context.Background()); err != nil {
pool.Close()
t.Skipf("skipping: database not reachable: %v", err)
}
return pool
}
// setupResolverFixture inserts a workspace with a known slug and returns its
// UUID. The caller is responsible for calling the returned cleanup func.
func setupResolverFixture(t *testing.T, pool *pgxpool.Pool) (workspaceID string, cleanup func()) {
t.Helper()
ctx := context.Background()
// Pre-cleanup in case a previous run didn't finish.
_, _ = pool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, testResolverSlug)
if err := pool.QueryRow(ctx,
`INSERT INTO workspace (name, slug, description, issue_prefix) VALUES ($1, $2, '', 'MRT') RETURNING id`,
"Middleware Resolver Test", testResolverSlug,
).Scan(&workspaceID); err != nil {
t.Fatalf("insert workspace: %v", err)
}
return workspaceID, func() {
_, _ = pool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, testResolverSlug)
}
}
// TestResolveWorkspaceIDFromRequest pins down the priority order of the
// shared resolver. Every handler-level lookup of workspace identity — whether
// a route sits inside or outside the workspace middleware — must produce
// identical results, in the same priority, across all five supported
// mechanisms. Breaking any row here is a behavioral regression.
func TestResolveWorkspaceIDFromRequest(t *testing.T) {
pool := openPool(t)
defer pool.Close()
queries := db.New(pool)
workspaceID, cleanup := setupResolverFixture(t, pool)
defer cleanup()
const (
uuidA = "00000000-0000-0000-0000-000000000001"
uuidB = "00000000-0000-0000-0000-000000000002"
)
cases := []struct {
name string
setup func(r *http.Request)
want string
wantEmpty bool
}{
{
name: "context UUID wins over everything else",
setup: func(r *http.Request) {
ctx := context.WithValue(r.Context(), ctxKeyWorkspaceID, uuidA)
*r = *r.WithContext(ctx)
r.Header.Set("X-Workspace-Slug", testResolverSlug)
r.Header.Set("X-Workspace-ID", uuidB)
},
want: uuidA,
},
{
name: "X-Workspace-Slug header resolves to UUID via DB lookup",
setup: func(r *http.Request) {
r.Header.Set("X-Workspace-Slug", testResolverSlug)
},
want: workspaceID,
},
{
name: "X-Workspace-Slug wins over X-Workspace-ID (post-refactor priority)",
setup: func(r *http.Request) {
r.Header.Set("X-Workspace-Slug", testResolverSlug)
r.Header.Set("X-Workspace-ID", uuidB)
},
want: workspaceID,
},
{
name: "unknown X-Workspace-Slug falls through to UUID header",
setup: func(r *http.Request) {
r.Header.Set("X-Workspace-Slug", "does-not-exist")
r.Header.Set("X-Workspace-ID", uuidB)
},
want: uuidB,
},
{
name: "?workspace_slug query resolves to UUID via DB lookup",
setup: func(r *http.Request) {
q := r.URL.Query()
q.Set("workspace_slug", testResolverSlug)
r.URL.RawQuery = q.Encode()
},
want: workspaceID,
},
{
name: "X-Workspace-ID header is returned when no slug provided",
setup: func(r *http.Request) {
r.Header.Set("X-Workspace-ID", uuidA)
},
want: uuidA,
},
{
name: "?workspace_id query is the last-resort fallback",
setup: func(r *http.Request) {
q := r.URL.Query()
q.Set("workspace_id", uuidA)
r.URL.RawQuery = q.Encode()
},
want: uuidA,
},
{
name: "no identifier at all returns empty",
setup: func(r *http.Request) {},
wantEmpty: true,
},
{
name: "unknown slug with no UUID fallback returns empty",
setup: func(r *http.Request) {
r.Header.Set("X-Workspace-Slug", "does-not-exist")
},
wantEmpty: true,
},
{
// MUL-2600: a mat_ task token authenticates the request and
// the auth middleware writes the token-bound workspace into
// X-Workspace-ID along with X-Actor-Source=task_token. Any
// other workspace identifier the agent puts on the wire — a
// slug pointing at a sibling workspace, a different
// workspace_id — must be ignored. Otherwise an agent could
// route owner-token traffic at any workspace its host is
// also a member of.
name: "task_token actor: client-supplied slug/id cannot override token-bound workspace",
setup: func(r *http.Request) {
r.Header.Set("X-Actor-Source", "task_token")
r.Header.Set("X-Workspace-ID", uuidA)
// All of these should be ignored under task_token.
r.Header.Set("X-Workspace-Slug", testResolverSlug)
q := r.URL.Query()
q.Set("workspace_slug", testResolverSlug)
q.Set("workspace_id", uuidB)
r.URL.RawQuery = q.Encode()
},
want: uuidA,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/anything", nil)
tc.setup(req)
got := ResolveWorkspaceIDFromRequest(req, queries)
if tc.wantEmpty {
if got != "" {
t.Fatalf("expected empty, got %q", got)
}
return
}
if got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}