Files
multica/server/internal/handler/comment_worker_handoff_test.go
13eb4bda93 MUL-8154 fix(daemon): enforce runtime ownership on daemon routes (#9423)
* fix(daemon): enforce runtime ownership on daemon routes (MUL-8154)

/api/daemon/* accepts any workspace member's PAT/JWT, and authorization only
checked workspace membership. Any member could claim another member's tasks
(receiving the agent's custom_env, MCP config and an owner-scoped task token),
write task results/messages, and take a runtime over by re-registering its
daemon_id.

- Runtime-level check on every runtime/task daemon route, HTTP heartbeat,
  deregister, batch claim (and so the WS claim RPC) and the WS handshake:
  user credentials must own the runtime (NULL owner fails closed); daemon
  tokens must match a non-empty runtime daemon_id.
- Register: pre-check that every existing row on the daemon_id belongs to the
  caller, plus a required_owner_id fence in both upserts (incl. the
  failed-profile path) so a concurrent registration cannot win the row.
  Daemon-token registration keeps the existing owner.
- Legacy merge: only for user-credential registrations, and only when the
  caller owns both rows, verified on the FOR UPDATE-locked rows.
- Tasks without a runtime are not writable through daemon routes; status
  stays readable.
- GetTaskStatus / WS lease queries carry runtime owner/daemon so the hot
  paths keep one round trip.

Co-authored-by: multica-agent <[email protected]>

* test(daemon): authenticate fixtures as the runtime owner (MUL-8154)

Most handler tests drove daemon routes with a daemon token whose daemon_id
did not match the fixture runtime, relying on the workspace-wide token scope
this fix removes. They now authenticate the way real daemons do, with the
runtime owner's user credential (newOwnerDaemonRequest). Tests about daemon
token scoping keep mdt_ and get fixtures with a matching daemon_id; the
ownerless-runtime tests also assert that a user credential is refused.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): serialize registration per daemon and keep Qoder WS claims working (MUL-8154)

Review of #9423:

- P1: the owner pre-check and the per-provider/profile upserts hit different
  conflict keys, so two members registering different providers of the same
  daemon could both pass the pre-check and both insert, leaving one daemon
  with two owners (and a daemon token for either runtime reaching the
  other's). Registration now runs the pre-check and every runtime write in one
  transaction under a pg_advisory_xact_lock on (workspace_id, daemon_id), on a
  single connection so queued registrations cannot starve the holder. Legacy
  merges, analytics and the register event run after commit; failed-profile
  records use savepoints so they stay best-effort.
- P2: the Qoder revocation check read the Authorization header, which the WS
  RPC bridge's in-process request does not carry, so valid Qoder WS claims
  silently returned nothing. DaemonAuth now records the mdt_ token hash in the
  request context, the WS identity keeps it, the RPC bridge re-attaches it,
  and the check validates that hash on every call (revocation still applies
  to an open connection).
- Regression tests for cross-provider and provider+profile concurrent
  registration, and for valid/revoked Qoder credentials over WS RPC.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): bind daemon tokens to the runtime owner they were issued for (MUL-8154)

Second review of #9423: a daemon_id can be re-registered by another member
after its runtimes are deleted, and a daemon token was bound only to
(workspace, daemon_id), so the previous owner's Remote MCP token reached the
new owner's runtime (heartbeat, claim, owner-scoped task token).

- daemon_token.owner_id (migration 588). Remote MCP tokens are bound to the
  locked runtime owner inside FinalizeTaskClaim; live tokens are backfilled
  only where their daemon has exactly one owner.
- DaemonAuth carries the owner binding (DB and cache; cache key bumped to v2
  so pre-upgrade entries are never read), the WS identity keeps it and the
  RPC bridge replays it.
- Daemon-token authorization now also requires token owner == runtime owner.
  Qoder-managed tokens stay unbound (governed by cloud_agent_connection), and
  user credentials can no longer register the reserved qoder-managed-* ids.
- Tests: the reuse lifecycle over HTTP, a new WS handshake and an already
  open WS connection; the reserved daemon_id; the concurrency barrier now
  hooks the registration transaction (TxStarter) so the interleaving is real.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): owner-bind daemon-token registration; drop unsafe token backfill (MUL-8154)

Third review of #9423:

- Migration 588 no longer backfills daemon_token.owner_id from the daemon's
  current owner: that owner may be a later re-registrant, which re-bound the
  previous owner's live token to them. Existing non-Qoder tokens stay NULL and
  are refused (24h Remote MCP credentials, re-issued on the next claim).
- owner_id is a plain UUID column, no foreign key / cascade (AGENTS.md).
- Register via a non-Qoder daemon token now uses the token's owner for the
  in-transaction pre-check and the SQL owner fence, and refuses unbound
  tokens. Qoder keeps its connection-based check; daemon tokens still never
  trigger legacy merges.
- Tests: an old owner's bound token and an unbound legacy token can neither
  register over nor heartbeat the replacement owner's runtime.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): grant the Qoder registration exemption only with a live connection (MUL-8154)

Fourth review of #9423: register skipped the token owner binding for any
qoder-managed-* daemon token, but checked the Qoder connection only when the
body declared a qoder_cloud runtime. A pre-upgrade Remote MCP token under that
prefix (no owner, no connection) could register type=claude and rewrite
another member's runtime.

Register now verifies the token against a live cloud_agent_connection before
anything else whenever the token is in the Qoder namespace, regardless of the
declared runtimes, and restricts that namespace to the bridge's own shape: a
built-in qoder_cloud runtime, no custom profiles, no failed_profiles.

Test covers built-in, custom-profile, failed-profiles-only and qoder_cloud
bodies with an unconnected token, plus the connected token's allowed and
refused shapes.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-22 16:47:06 +08:00

396 lines
16 KiB
Go

package handler
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/multica-ai/multica/server/internal/testutil"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
func claimWorkerReplyRun(t *testing.T, runtimeID string) *AgentTaskResponse {
t.Helper()
req := newOwnerDaemonRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil)
req = withURLParam(req, "runtimeId", runtimeID)
req.Header.Set("X-Client-Capabilities", protocol.DaemonCapabilityCoalescedCommentsV1)
var response struct {
Task *AgentTaskResponse `json:"task"`
}
testutil.Call(t, testHandler.ClaimTaskByRuntime, req).Want(http.StatusOK).JSON(&response)
return response.Task
}
func completeWorkerReplyRun(t *testing.T, taskID string) {
t.Helper()
req := newOwnerDaemonRequest(http.MethodPost, "/api/daemon/tasks/"+taskID+"/complete", map[string]any{"output": "Processed the inputs delivered to this run"})
req = withURLParam(req, "taskId", taskID)
testutil.Call(t, testHandler.CompleteTask, req).Want(http.StatusOK)
}
// A worker progress comment wakes the leader; its final reply must also reach a
// leader run even if the first wake was claimed before the reply arrived.
func TestWorkerReplyDelivery(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
for _, state := range []string{"queued", "dispatched", "running"} {
for _, explicit := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/explicit=%t", state, explicit), func(t *testing.T) {
ctx := context.Background()
leaderRuntimeID := dbfx.Runtime(t, "Worker handoff leader runtime")
leaderID := dbfx.Agent(t, "Worker handoff leader", leaderRuntimeID, testutil.Cols{"max_concurrent_tasks": 3})
workerRuntimeID := dbfx.Runtime(t, "Worker handoff worker runtime")
workerID := dbfx.Agent(t, "Worker handoff worker", workerRuntimeID)
squadID := dbfx.Squad(t, "Worker handoff squad", leaderID)
dbfx.SquadMember(t, squadID, "agent", workerID)
issueID := dbfx.Issue(t, "Worker results must reach the coordinator", testutil.Cols{
"status": "in_progress", "assignee_type": "squad", "assignee_id": squadID,
})
sourceID := dbfx.Task(t, leaderID, testutil.Cols{
"runtime_id": leaderRuntimeID, "issue_id": issueID, "status": "completed",
"is_leader_task": true, "squad_id": squadID,
"originator_user_id": testUserID, "accountable_user_id": testUserID,
})
rootID := dbfx.Comment(t, issueID, fmt.Sprintf("[@Worker](mention://agent/%s) verify the implementation", workerID), testutil.Cols{
"author_type": "agent", "author_id": leaderID, "source_task_id": sourceID,
})
workerTaskID := dbfx.Task(t, workerID, testutil.Cols{
"runtime_id": workerRuntimeID, "issue_id": issueID, "status": "running",
"trigger_comment_id": rootID, "squad_id": squadID, "delegated_from_task_id": sourceID,
"originator_user_id": testUserID, "accountable_user_id": testUserID,
})
post := func(content string) CommentResponse {
req := withURLParam(newRequest(http.MethodPost, "/api/issues/"+issueID+"/comments", map[string]any{
"content": content, "parent_id": rootID,
}), "id", issueID)
req.Header.Set("X-Agent-ID", workerID)
req.Header.Set("X-Task-ID", workerTaskID)
var response CommentResponse
testutil.Call(t, testHandler.CreateComment, req).Want(http.StatusCreated).JSON(&response)
if response.AuthorType != "agent" || response.SourceTaskID == nil || *response.SourceTaskID != workerTaskID {
t.Fatal("worker reply lost its authenticated source task")
}
return response
}
progress := post("Verification started; final results will follow in this thread")
var leaderTaskID string
dbfx.QueryRow(t, `SELECT id FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`, issueID, leaderID).Scan(&leaderTaskID)
var first *AgentTaskResponse
if state != "queued" {
first = claimWorkerReplyRun(t, leaderRuntimeID)
if first == nil || first.ID != leaderTaskID || !slices.Contains(first.DeliveredCommentIDs, progress.ID) {
t.Fatal("first leader run did not receive the worker's progress")
}
}
if state == "running" {
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(leaderTaskID)); err != nil {
t.Fatal(err)
}
}
content := "Verification complete: found a correctness issue; please coordinate the repair"
if explicit {
content = fmt.Sprintf("[@Squad](mention://squad/%s) %s", squadID, content)
}
details := post("Evidence: the final reply can arrive after the leader claims its inputs")
result := post(content)
stored, err := testHandler.Queries.GetAgentTask(ctx, parseUUID(leaderTaskID))
if err != nil {
t.Fatal(err)
}
if state == "dispatched" && !explicit {
for _, id := range []string{details.ID, result.ID} {
if !slices.Contains(stored.CoalescedCommentIds, parseUUID(id)) || slices.Contains(stored.DeliveredCommentIds, parseUUID(id)) {
t.Fatal("accepted worker reply must be planned without changing the earlier delivery receipt")
}
}
if stored.TriggerCommentID != parseUUID(progress.ID) {
t.Fatal("registering a worker reply must not replace an already claimed trigger")
}
}
if state == "queued" {
first = claimWorkerReplyRun(t, leaderRuntimeID)
if first == nil || first.ID != leaderTaskID || !slices.Contains(first.DeliveredCommentIDs, result.ID) || !slices.Contains(first.DeliveredCommentIDs, details.ID) || first.TriggerCommentContent != content {
t.Fatal("queued leader must coalesce and receive the worker result")
}
} else if slices.Contains(first.DeliveredCommentIDs, result.ID) {
t.Fatal("an earlier claim cannot have delivered a later comment")
}
if state != "running" {
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(leaderTaskID)); err != nil {
t.Fatal(err)
}
}
if other := claimWorkerReplyRun(t, leaderRuntimeID); other != nil {
t.Fatal("same issue/leader must remain serialized")
}
completeWorkerReplyRun(t, leaderTaskID)
next := claimWorkerReplyRun(t, leaderRuntimeID)
if state == "queued" {
if next != nil {
t.Fatal("already delivered result must not create another leader run")
}
return
}
if next == nil {
t.Fatal("worker result persisted but was neither delivered nor followed by another leader run")
}
if !next.IsLeaderTask || !slices.Contains(next.DeliveredCommentIDs, result.ID) || !slices.Contains(next.DeliveredCommentIDs, details.ID) || next.TriggerCommentContent != content {
t.Fatal("follow-up must deliver the result in the squad leader role")
}
successor, err := testHandler.Queries.GetAgentTask(ctx, parseUUID(next.ID))
if err != nil {
t.Fatal(err)
}
if successor.SquadID != parseUUID(squadID) || successor.OriginatorUserID != parseUUID(testUserID) || successor.AccountableUserID != parseUUID(testUserID) || successor.DelegatedFromTaskID != parseUUID(workerTaskID) {
t.Fatal("worker follow-up lost squad or human delegation provenance")
}
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(next.ID)); err != nil {
t.Fatal(err)
}
completeWorkerReplyRun(t, next.ID)
if another := claimWorkerReplyRun(t, leaderRuntimeID); another != nil {
t.Fatal("worker result must not generate an endless leader follow-up loop")
}
})
}
}
}
// Unplanned replies must be in the completing run's thread and timestamp window:
// otherwise a passing negative case could merely be the SQL scope excluding it.
func TestWorkerReplyReconcileBoundaries(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
for _, mode := range []string{"accepted", "unplanned", "suppressed", "leader_reply", "note", "archived_leader", "reassigned", "registration_failure", "completed_before_registration"} {
t.Run(mode, func(t *testing.T) {
ctx := context.Background()
runtimeID := dbfx.Runtime(t, "Worker replay boundary leader")
leaderID := dbfx.Agent(t, "Worker replay boundary leader", runtimeID)
workerRuntimeID := dbfx.Runtime(t, "Worker replay boundary worker")
workerID := dbfx.Agent(t, "Worker replay boundary worker", workerRuntimeID)
squadID := dbfx.Squad(t, "Worker replay boundary squad", leaderID)
dbfx.SquadMember(t, squadID, "agent", workerID)
issueID := dbfx.Issue(t, "Worker replay boundary", testutil.Cols{
"status": "in_progress", "assignee_type": "squad", "assignee_id": squadID,
})
rootID := dbfx.Comment(t, issueID, "Coordinate this work", testutil.Cols{"created_at": testutil.Raw("now() - interval '6 minutes'")})
taskID := dbfx.Task(t, leaderID, testutil.Cols{
"runtime_id": runtimeID, "issue_id": issueID, "status": "queued",
"trigger_comment_id": rootID, "is_leader_task": true, "squad_id": squadID,
"originator_user_id": testUserID, "accountable_user_id": testUserID,
"created_at": testutil.Raw("now() - interval '5 minutes'"),
})
workerTaskID := dbfx.Task(t, workerID, testutil.Cols{
"runtime_id": workerRuntimeID, "issue_id": issueID, "status": "running",
"trigger_comment_id": rootID, "squad_id": squadID,
"originator_user_id": testUserID, "accountable_user_id": testUserID,
})
first := claimWorkerReplyRun(t, runtimeID)
if first == nil || first.ID != taskID {
t.Fatal("leader run was not claimed")
}
var replyID string
if mode == "unplanned" {
replyID = dbfx.Comment(t, issueID, "An ordinary reply with no accepted dispatch", testutil.Cols{
"parent_id": rootID, "author_type": "agent", "author_id": workerID, "source_task_id": workerTaskID,
})
} else {
body := map[string]any{"content": "The worker has results", "parent_id": rootID}
if mode == "note" {
body["content"] = "/note an informational update"
}
if mode == "suppressed" {
body["suppress_agent_ids"] = []string{leaderID}
}
req := withURLParam(newRequest(http.MethodPost, "/api/issues/"+issueID+"/comments", body), "id", issueID)
req.Header.Set("X-Agent-ID", workerID)
req.Header.Set("X-Task-ID", workerTaskID)
if mode == "leader_reply" {
req.Header.Set("X-Agent-ID", leaderID)
req.Header.Set("X-Task-ID", taskID)
}
var response CommentResponse
h := *testHandler
registration := &workerReplyRegistrationDB{DBTX: testPool}
if mode == "registration_failure" {
registration.err = errors.New("injected worker reply registration failure")
}
if mode == "completed_before_registration" {
registration.before = func() {
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(taskID)); err != nil {
t.Fatal(err)
}
completeWorkerReplyRun(t, taskID)
}
}
if mode == "registration_failure" || mode == "completed_before_registration" {
h.Queries = db.New(registration)
}
testutil.Call(t, h.CreateComment, req).Want(http.StatusCreated).JSON(&response)
if (mode == "registration_failure" || mode == "completed_before_registration") && registration.calls != 1 {
t.Fatalf("registration probe called %d times", registration.calls)
}
replyID = response.ID
}
task, err := testHandler.Queries.GetAgentTask(ctx, parseUUID(taskID))
if err != nil {
t.Fatal(err)
}
comments, err := testHandler.Queries.ListReconcilableCommentsForIssueSince(ctx, db.ListReconcilableCommentsForIssueSinceParams{
IssueID: task.IssueID, CommentThreadID: task.CommentThreadID, Since: task.CreatedAt,
PlannedCommentIds: task.CoalescedCommentIds,
})
if err != nil {
t.Fatal(err)
}
if !slices.ContainsFunc(comments, func(c db.Comment) bool { return c.ID == parseUUID(replyID) }) {
t.Fatal("reply must reach the replay filter, not be excluded by the SQL thread/time window")
}
wantPlanned := mode == "accepted" || mode == "archived_leader" || mode == "reassigned"
if slices.Contains(task.CoalescedCommentIds, parseUUID(replyID)) != wantPlanned {
t.Fatalf("unexpected creation-time obligation for %s", mode)
}
if mode == "archived_leader" {
dbfx.Exec(t, `UPDATE agent SET archived_at = now() WHERE id = $1`, leaderID)
}
if mode == "reassigned" {
dbfx.Exec(t, `UPDATE issue SET assignee_type = 'agent', assignee_id = $2 WHERE id = $1`, issueID, workerID)
}
if mode != "completed_before_registration" {
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(taskID)); err != nil {
t.Fatal(err)
}
completeWorkerReplyRun(t, taskID)
}
queued := dbfx.Count(t, `SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND status = 'queued'`, issueID)
if mode != "accepted" && mode != "completed_before_registration" {
if queued != 0 {
t.Fatalf("%s must not create a completion-driven run, got %d", mode, queued)
}
return
}
if queued != 1 {
t.Fatalf("accepted reply must create exactly one successor, got %d", queued)
}
next := claimWorkerReplyRun(t, runtimeID)
if next == nil || !slices.Contains(next.DeliveredCommentIDs, replyID) {
t.Fatal("accepted reply was not delivered")
}
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(next.ID)); err != nil {
t.Fatal(err)
}
completeWorkerReplyRun(t, next.ID)
if extra := claimWorkerReplyRun(t, runtimeID); extra != nil {
t.Fatal("delivered worker reply replayed again")
}
})
}
}
// If completion commits while registration waits for the task row lock, the
// caller must see a miss and enqueue fresh, not attach an obligation to a run
// whose completion snapshot can no longer include it.
func TestWorkerReplyRegistrationLosesCompletionRace(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
runtimeID := dbfx.Runtime(t, "Worker registration race")
agentID := dbfx.Agent(t, "Worker registration race", runtimeID)
issueID := dbfx.Issue(t, "Worker registration race")
rootID := dbfx.Comment(t, issueID, "Initial input")
replyID := dbfx.Comment(t, issueID, "Worker result", testutil.Cols{"parent_id": rootID})
taskID := dbfx.Task(t, agentID, testutil.Cols{
"runtime_id": runtimeID, "issue_id": issueID, "status": "running", "trigger_comment_id": rootID,
})
tx, err := testPool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := tx.Rollback(context.Background()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
t.Error(err)
}
}()
if _, err := tx.Exec(ctx, `UPDATE agent_task_queue SET status = 'completed', completed_at = now() WHERE id = $1`, taskID); err != nil {
t.Fatal(err)
}
conn, err := testPool.Acquire(ctx)
if err != nil {
t.Fatal(err)
}
defer conn.Release()
pid := conn.Conn().PgConn().PID()
done := make(chan struct{})
var registrationErr error
go func() {
_, registrationErr = db.New(conn).RegisterPlannedCommentForActiveTask(ctx, db.RegisterPlannedCommentForActiveTaskParams{
IssueID: parseUUID(issueID), AgentID: parseUUID(agentID), CommentID: parseUUID(replyID),
})
close(done)
}()
// Stop the registration query before returning its connection to the pool,
// including assertion failures while it is waiting for the task row lock.
defer func() { cancel(); <-done }()
for {
var blocked bool
if err := testPool.QueryRow(ctx, `SELECT cardinality(pg_blocking_pids($1)) > 0`, pid).Scan(&blocked); err != nil {
t.Fatal(err)
}
if blocked {
break
}
select {
case <-ctx.Done():
t.Fatal("registration did not block on the completing row")
case <-time.After(5 * time.Millisecond):
}
}
if err := tx.Commit(ctx); err != nil {
t.Fatal(err)
}
<-done
if !errors.Is(registrationErr, pgx.ErrNoRows) {
t.Fatalf("registration after completion = %v, want no rows", registrationErr)
}
task, err := testHandler.Queries.GetAgentTask(ctx, parseUUID(taskID))
if err != nil {
t.Fatal(err)
}
if slices.Contains(task.CoalescedCommentIds, parseUUID(replyID)) {
t.Fatal("completed run acquired an undeliverable obligation")
}
}
// Intercept only the new obligation write; all routing, comment persistence,
// completion, and successor enqueue still use their real database paths.
type workerReplyRegistrationDB struct {
db.DBTX
before func()
err error
calls int
}
func (d *workerReplyRegistrationDB) QueryRow(ctx context.Context, query string, args ...any) pgx.Row {
if strings.Contains(query, "-- name: RegisterPlannedCommentForActiveTask :one") {
d.calls++
if d.before != nil {
d.before()
}
if d.err != nil {
return errRow{err: d.err}
}
}
return d.DBTX.QueryRow(ctx, query, args...)
}