mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
* 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>
142 lines
5.1 KiB
Go
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)
|
|
})
|
|
})
|
|
}
|