Files
multica/server/internal/handler/issue_agent_create_origin_test.go
Multica Eve 86c3f30524 fix(server): keep originator on agent-created issues so A2A mentions stay authorized (MUL-4305) (#5149)
* fix(server): attribute agent-created issues so downstream A2A mentions keep the originator (MUL-4305)

An agent creating an issue via the ordinary `issue create` path left the new
issue with no origin link, so resolveOriginatorForIssueTask could not recover
the top-of-chain human. Any assignment / squad-leader run derived from that
issue lost the originator, and A2A @-mentions those runs emitted failed the
canInvokeAgent gate against private agents (after MUL-3963).

Fix (mirrors the comment.source_task_id stamp from MUL-4015):
- CreateIssue stamps origin_type='agent_create' + origin_id=<acting task>,
  resolved from the SERVER-trusted X-Task-ID (never a client-reported field).
- resolveOriginatorForIssueTask inherits the origin task's originator for
  agent_create just like quick_create.
- Align the squad-leader gate originator with the enqueue path via the new
  exported OriginatorForIssueTask, so the gate and the persisted task row
  agree instead of drifting to an empty originator for agent-triggered assigns.
- Migration 149 adds 'agent_create' to issue_origin_type_check.
- Classify agent_create in analytics to avoid the unknown-origin warning.

Tests: agent_create attribution + gate/enqueue consistency (service), and the
HTTP boundary stamp + security regression (member / forged X-Agent-ID must not
smuggle an agent_create origin).

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

* test(server): add end-to-end regression for agent-created issue originator chain (MUL-4305)

Locks the real product path the layered tests could miss, per PR review:

1. CreateAssignSquad_PrivateWorkerTriggered — human H triggers agent A → A
   creates an issue via the ordinary create path AND assigns it to a squad
   whose leader is a private agent owned by H → the leader's assignment run
   @-mentions a second private agent J (owned by H) → asserts the leader task
   carries H and J ends up with a queued task attributed to H. This is the
   exact line-failure shape from the issue.
2. UpdateAssignSquad_HandlerGateAdmitsPrivateLeader — agent A creates an
   unassigned issue then assigns it to a private-leader squad via UpdateIssue,
   exercising the handler enqueueSquadLeaderTask gate (which the create path's
   ungated service enqueue does not hit); asserts the leader task is enqueued
   carrying H.

Both wire handler create stamp → origin resolution → squad-leader gate →
comment source-task stamp → private-worker invocation gate. Verified they FAIL
against a simulated pre-fix resolver (leader originator empty; private worker /
leader get 0 tasks) and PASS with the fix.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 15:10:09 +08:00

142 lines
5.1 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestCreateIssue_AgentCreate_StampsActingTaskOrigin locks the MUL-4305 fix at
// the HTTP boundary: when an agent creates an issue through the ordinary POST
// /api/issues path (no explicit origin, not quick_create), the handler stamps
// origin_type='agent_create' + origin_id=<acting task>, resolved from the
// SERVER-trusted X-Task-ID (resolveActor only grants "agent" once the
// agent/task pair is validated). That link is what lets
// resolveOriginatorForIssueTask recover the top-of-chain human for any
// downstream assignment / squad-leader run and keep A2A mentions authorized.
func TestCreateIssue_AgentCreate_StampsActingTaskOrigin(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var agentID, runtimeID string
if err := testPool.QueryRow(ctx,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID, &runtimeID); err != nil {
t.Fatalf("find test agent: %v", err)
}
// Acting task carrying the human originator (testUserID). resolveActor
// validates this (agent, task) pair before the handler trusts X-Task-ID.
var taskID string
if err := testPool.QueryRow(ctx,
`INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, originator_user_id)
VALUES ($1, $2, 'running', 0, $3) RETURNING id`,
agentID, runtimeID, testUserID,
).Scan(&taskID); err != nil {
t.Fatalf("seed acting task: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Agent-created via normal create (MUL-4305)",
})
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", taskID)
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created IssueResponse
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode issue: %v", err)
}
t.Cleanup(func() {
cleanup := withURLParam(newRequest("DELETE", "/api/issues/"+created.ID, nil), "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanup)
})
var originType, originID string
if err := testPool.QueryRow(ctx,
`SELECT COALESCE(origin_type, ''), COALESCE(origin_id::text, '') FROM issue WHERE id = $1`, created.ID,
).Scan(&originType, &originID); err != nil {
t.Fatalf("load issue origin: %v", err)
}
if originType != "agent_create" {
t.Fatalf("origin_type = %q, want agent_create", originType)
}
if originID != taskID {
t.Fatalf("origin_id = %q, want acting task %q", originID, taskID)
}
}
// TestCreateIssue_NoAgentCreateStampForMemberOrForgedAgent is the security
// regression for MUL-4305: the agent_create stamp must only ride a genuine
// agent actor. A plain member create carries no origin, and a member who
// forges X-Agent-ID without a valid X-Task-ID is demoted to "member" by
// resolveActor — so it must NOT smuggle an agent_create origin (which would
// later let a downstream run inherit a human identity the caller never had).
func TestCreateIssue_NoAgentCreateStampForMemberOrForgedAgent(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var agentID string
if err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID); err != nil {
t.Fatalf("find test agent: %v", err)
}
assertNoAgentOrigin := func(t *testing.T, mutate func(*http.Request)) {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "No agent_create stamp expected (MUL-4305)",
})
if mutate != nil {
mutate(req)
}
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created IssueResponse
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode issue: %v", err)
}
t.Cleanup(func() {
cleanup := withURLParam(newRequest("DELETE", "/api/issues/"+created.ID, nil), "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanup)
})
var originType string
if err := testPool.QueryRow(ctx,
`SELECT COALESCE(origin_type, '') FROM issue WHERE id = $1`, created.ID,
).Scan(&originType); err != nil {
t.Fatalf("load issue origin: %v", err)
}
if originType != "" {
t.Fatalf("origin_type = %q, want empty (no agent_create stamp)", originType)
}
}
t.Run("plain member create", func(t *testing.T) {
assertNoAgentOrigin(t, nil)
})
t.Run("forged X-Agent-ID without X-Task-ID", func(t *testing.T) {
// resolveActor refuses to trust X-Agent-ID without a paired, valid
// X-Task-ID, so this stays a member create and gets no origin stamp.
assertNoAgentOrigin(t, func(req *http.Request) {
req.Header.Set("X-Agent-ID", agentID)
})
})
}