mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* fix: fail private runtime owner mismatches (PUCK-89) Co-authored-by: multica-agent <[email protected]> * fix: keep runtime mismatch repair off idle claim path * test: cover mixed runtime claim outcomes (PUCK-89) Co-authored-by: multica-agent <[email protected]> * fix: settle runtime owner mismatches during claim * fix: reject ownerless agents on private runtime claims * chore: restore claim version sampling layout (PUCK-89) Co-authored-by: multica-agent <[email protected]> * fix: re-authorize delivery at the finalize boundary, dedicated runtime_access_denied client copy, fixture convention (PUCK-89) Blocker 1 — final delivery gate: FinalizeTaskClaim now runs a caller- supplied authorize closure inside its transaction. The singular and batch claim paths share finalizeClaimDelivery, which re-locks the runtime row (FOR UPDATE), re-reads the agent, and re-verifies the private-runtime owner fence against CURRENT ownership before the task token commits. A concurrent re-registration that would change owner_id blocks until the gate commits, closing the stale-snapshot TOCTOU. Mismatched tasks settle through the existing failClaimedTaskBeforeLaunch -> FailTask path; singular keeps 200 {"task":null}, batch skips the task and keeps returning valid ones. Regressions cover both paths. Blocker 2 — client copy: runtime_access_denied gets dedicated actionable copy (make runtime public / rebind-copy agent) on the issues blocked-trigger mapping, both chat send-failure toasts, the chat failure-reason map, the mobile dispatch-reason helper, and the autopilot run-now toast. Generic fallbacks unchanged; locale keys added for en/ko/ja/zh-Hans. Blocker 3 — fixture convention: runtime_access_denied_test.go now uses testutil.Call; daemon_runtime_access_test.go seeds its chat task via the dbfx.Task fixture. Test semantics unchanged. Co-authored-by: multica-agent <[email protected]> * fix: bind final task token to locked runtime owner Co-authored-by: multica-agent <[email protected]> * fix: keep delivered user context on current owner Co-authored-by: multica-agent <[email protected]> * test: use testutil.Call in settlement-failure claim regression Convert the last manual httptest.NewRecorder flow in TestFinalizeClaimDelivery_SettlementFailureIsUnsettled to the repository's required testutil.Call helper (hard convention from review) and drop the now-unused net/http/httptest import. No production code changes. Co-authored-by: multica-agent <[email protected]> * PUCK-132: settle queued private-runtime owner mismatches as runtime_access_denied Align persisted task failure semantics with admission semantics: queued private-runtime ownership mismatches now settle as runtime_access_denied, letting clients reach the dedicated recovery copy instead of the generic invalid_task_identity copy. Actual task/agent identity violations (agent rebound, agent deleted, response identity mismatch, error_agent_runtime_changed) keep invalid_task_identity. - add taskfailure.ReasonRuntimeAccessDenied (permanent, non-retryable ownership authorization failure; agent process never launched) - swap the reason in the two owner-mismatch settlement paths in handler/daemon.go (response-assembly recheck + delivery-gate authz-error default branch, including ownerless-runtime denial) - regression A (queued issue task), B (queued chat task + assistant failure message via existing FailTask path), C (agent rebind keeps invalid_task_identity) Co-authored-by: multica-agent <[email protected]> * PUCK-132: map persisted runtime access failures in clients * fix(i18n): add missing fr translations for runtime_access_denied keys Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]> Co-authored-by: worker-opencode <[email protected]> Co-authored-by: puck-181 <puck-181@local>
132 lines
5.0 KiB
Go
132 lines
5.0 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
)
|
|
|
|
func createRuntimeAccessDeniedAgent(t *testing.T, ctx context.Context, runtimeID, name string) string {
|
|
t.Helper()
|
|
foreignOwnerID := dbfx.User(t, name+" owner", "runtime-access-owner-"+uuid.NewString()+"@example.com")
|
|
dbfx.Member(t, testWorkspaceID, foreignOwnerID, "member")
|
|
agentID := createCascadeFixtureAgent(t, ctx, runtimeID, name)
|
|
dbfx.Exec(t, `UPDATE agent SET owner_id = $1, visibility = 'workspace', permission_mode = 'public_to' WHERE id = $2`, foreignOwnerID, agentID)
|
|
dbfx.Exec(t, `
|
|
INSERT INTO agent_invocation_target (agent_id, target_type, target_id)
|
|
VALUES ($1, 'workspace', $2)
|
|
ON CONFLICT (agent_id, target_type, target_id) DO NOTHING
|
|
`, agentID, testWorkspaceID)
|
|
return agentID
|
|
}
|
|
|
|
func TestCreateIssue_RuntimeAccessDeniedLeavesNoticeWithoutTask(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
runtimeID := createCascadeFixtureRuntime(t, ctx, "Assignment access denied runtime")
|
|
agentID := createRuntimeAccessDeniedAgent(t, ctx, runtimeID, "Assignment access denied agent")
|
|
|
|
w := testutil.Call(t, testHandler.CreateIssue, newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "assignment access denied issue",
|
|
"status": "todo",
|
|
"assignee_type": "agent",
|
|
"assignee_id": agentID,
|
|
})).Want(http.StatusCreated)
|
|
var issue IssueResponse
|
|
w.JSON(&issue)
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issue.ID) })
|
|
|
|
var taskCount int
|
|
dbfx.QueryRow(t, `SELECT count(*) FROM agent_task_queue WHERE issue_id = $1`, issue.ID).Scan(&taskCount)
|
|
if taskCount != 0 {
|
|
t.Fatalf("assignment created %d task rows, want 0", taskCount)
|
|
}
|
|
var notice string
|
|
dbfx.QueryRow(t, `
|
|
SELECT content FROM comment
|
|
WHERE issue_id = $1 AND author_type = 'system' AND type = 'system'
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, issue.ID).Scan(¬ice)
|
|
if !strings.Contains(notice, "public") || !strings.Contains(notice, "rebind/copy") {
|
|
t.Fatalf("assignment notice = %q, want both recovery paths", notice)
|
|
}
|
|
}
|
|
|
|
func TestSendChatMessage_RuntimeAccessDeniedReturnsStructuredConflict(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
runtimeID := createCascadeFixtureRuntime(t, ctx, "Chat access denied runtime")
|
|
agentID := createRuntimeAccessDeniedAgent(t, ctx, runtimeID, "Chat access denied agent")
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
req := newRequest(http.MethodPost, "/api/chat/sessions/"+sessionID+"/messages", map[string]any{"content": "please help"})
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
req = withChatTestWorkspaceCtx(t, req)
|
|
w := testutil.Call(t, testHandler.SendChatMessage, req).Want(http.StatusConflict)
|
|
var body struct {
|
|
ReasonCode string `json:"reason_code"`
|
|
}
|
|
w.JSON(&body)
|
|
if body.ReasonCode != string(ReasonRuntimeAccessDenied) {
|
|
t.Fatalf("reason_code = %q, want %s", body.ReasonCode, ReasonRuntimeAccessDenied)
|
|
}
|
|
var taskCount int
|
|
dbfx.QueryRow(t, `SELECT count(*) FROM agent_task_queue WHERE chat_session_id = $1`, sessionID).Scan(&taskCount)
|
|
if taskCount != 0 {
|
|
t.Fatalf("blocked chat created %d task rows, want 0", taskCount)
|
|
}
|
|
}
|
|
|
|
func TestCommentMention_RuntimeAccessDeniedReportsTargetAndNotice(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
runtimeID := createCascadeFixtureRuntime(t, ctx, "Mention access denied runtime")
|
|
agentID := createRuntimeAccessDeniedAgent(t, ctx, runtimeID, "Mention access denied agent")
|
|
issueID := createMentionFixtureIssue(t, ctx, "mention access denied issue")
|
|
|
|
req := newRequest(http.MethodPost, "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "[@Agent](mention://agent/" + agentID + ") please help",
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
w := testutil.Call(t, testHandler.CreateComment, req).WantOneOf(http.StatusOK, http.StatusCreated)
|
|
var body struct {
|
|
TriggerOutcomes []struct {
|
|
TargetID string `json:"target_id"`
|
|
Status string `json:"status"`
|
|
ReasonCode string `json:"reason_code"`
|
|
} `json:"trigger_outcomes"`
|
|
}
|
|
w.JSON(&body)
|
|
found := false
|
|
for _, outcome := range body.TriggerOutcomes {
|
|
if outcome.TargetID == agentID {
|
|
found = true
|
|
if outcome.Status != string(DispatchBlocked) || outcome.ReasonCode != string(ReasonRuntimeAccessDenied) {
|
|
t.Fatalf("target outcome = %+v, want blocked/runtime_access_denied", outcome)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("no blocked outcome for agent %s: %s", agentID, w.Body.String())
|
|
}
|
|
var notice string
|
|
dbfx.QueryRow(t, `
|
|
SELECT content FROM comment
|
|
WHERE issue_id = $1 AND author_type = 'system' AND type = 'system'
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, issueID).Scan(¬ice)
|
|
if !strings.Contains(notice, "public") || !strings.Contains(notice, "rebind/copy") {
|
|
t.Fatalf("mention notice = %q, want both recovery paths", notice)
|
|
}
|
|
}
|