mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 15:39:25 +02:00
Compare commits
3 Commits
feat/react
...
agent/agen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcf5952e89 | ||
|
|
6b82bb2441 | ||
|
|
ad19a268a8 |
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
@@ -73,3 +74,18 @@ func (h *Handler) accessibleAgentIDs(ctx context.Context, workspaceID, actorType
|
||||
return allowed, true
|
||||
}
|
||||
|
||||
|
||||
// canEnqueueSquadLeader returns true when the given actor is allowed to
|
||||
// trigger the squad's private leader. It loads the leader agent and delegates
|
||||
// to canAccessPrivateAgent. Non-private leaders always pass. System-initiated
|
||||
// triggers (e.g. github webhooks) pass by treating "system" like "agent".
|
||||
func (h *Handler) canEnqueueSquadLeader(ctx context.Context, leaderID pgtype.UUID, actorType, actorID, workspaceID string) bool {
|
||||
agent, err := h.Queries.GetAgent(ctx, leaderID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if actorType == "system" {
|
||||
actorType = "agent"
|
||||
}
|
||||
return h.canAccessPrivateAgent(ctx, agent, actorType, actorID, workspaceID)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
@@ -890,6 +891,13 @@ func (h *Handler) validateAutopilotAssignee(w http.ResponseWriter, r *http.Reque
|
||||
writeError(w, http.StatusUnprocessableEntity, "squad leader is archived; pick a different squad or rotate the leader before assigning autopilot")
|
||||
return false
|
||||
}
|
||||
// Private-leader gate: the member configuring the autopilot must have
|
||||
// access to the private leader, same as validateAssigneePair.
|
||||
actorType, actorID := h.resolveActor(r, requestUserID(r), util.UUIDToString(workspaceID))
|
||||
if !h.canAccessPrivateAgent(r.Context(), leader, actorType, actorID, util.UUIDToString(workspaceID)) {
|
||||
writeError(w, http.StatusForbidden, "cannot assign autopilot to squad with private leader")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "assignee_type must be agent or squad")
|
||||
|
||||
324
server/internal/handler/autopilot_private_leader_test.go
Normal file
324
server/internal/handler/autopilot_private_leader_test.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCreateAutopilot_SquadPrivateLeader_PlainMemberBlocked verifies that a
|
||||
// plain member cannot create an autopilot assigned to a squad whose leader
|
||||
// is a private agent.
|
||||
func TestCreateAutopilot_SquadPrivateLeader_PlainMemberBlocked(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'AP Private Leader Create', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequestAs(memberID, "POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "should be blocked",
|
||||
"assignee_type": "squad",
|
||||
"assignee_id": squadID,
|
||||
"execution_mode": "create_issue",
|
||||
})
|
||||
testHandler.CreateAutopilot(w, r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateAutopilot_SquadPrivateLeader_PlainMemberBlocked verifies that a
|
||||
// plain member cannot update an autopilot to point at a private-leader squad.
|
||||
func TestUpdateAutopilot_SquadPrivateLeader_PlainMemberBlocked(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
// Create a non-private agent for the initial autopilot.
|
||||
publicAgentID := createHandlerTestAgent(t, "ap-private-leader-public", nil)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'AP Private Leader Update', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
// Create autopilot as workspace owner assigned to the public agent.
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "update target ap",
|
||||
"assignee_id": publicAgentID,
|
||||
"execution_mode": "create_issue",
|
||||
})
|
||||
testHandler.CreateAutopilot(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ap AutopilotResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&ap); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID)
|
||||
})
|
||||
|
||||
// Plain member tries to update to the private-leader squad.
|
||||
squadType := "squad"
|
||||
w = httptest.NewRecorder()
|
||||
r = newRequestAs(memberID, "PATCH", "/api/autopilots/"+ap.ID+"?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"assignee_type": squadType,
|
||||
"assignee_id": squadID,
|
||||
})
|
||||
r = withURLParam(r, "id", ap.ID)
|
||||
testHandler.UpdateAutopilot(w, r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateAutopilot_SquadPrivateLeader_OwnerAllowed verifies that a
|
||||
// workspace owner CAN create an autopilot assigned to a private-leader squad.
|
||||
func TestCreateAutopilot_SquadPrivateLeader_OwnerAllowed(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, _ := privateAgentTestFixture(t)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'AP Private Leader Owner', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
// testUserID is workspace owner — should succeed.
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "owner creates private-leader squad ap",
|
||||
"assignee_type": "squad",
|
||||
"assignee_id": squadID,
|
||||
"execution_mode": "create_issue",
|
||||
})
|
||||
testHandler.CreateAutopilot(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ap AutopilotResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&ap); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID)
|
||||
})
|
||||
}
|
||||
|
||||
// TestTriggerAutopilot_SquadPrivateLeader_OwnerCanDispatch verifies that a
|
||||
// squad autopilot with private leader configured by an owner triggers
|
||||
// correctly at dispatch time.
|
||||
func TestTriggerAutopilot_SquadPrivateLeader_OwnerCanDispatch(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, _ := privateAgentTestFixture(t)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'AP Private Leader Dispatch', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
// Create autopilot as owner.
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "dispatch test private leader squad",
|
||||
"assignee_type": "squad",
|
||||
"assignee_id": squadID,
|
||||
"execution_mode": "create_issue",
|
||||
})
|
||||
testHandler.CreateAutopilot(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ap AutopilotResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&ap); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM autopilot_run WHERE autopilot_id = $1`, ap.ID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id IN (SELECT id FROM issue WHERE workspace_id = $1 AND title LIKE 'dispatch test private leader squad%')`, testWorkspaceID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE workspace_id = $1 AND title LIKE 'dispatch test private leader squad%'`, testWorkspaceID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID)
|
||||
})
|
||||
|
||||
// Trigger — should succeed since owner created it.
|
||||
w = httptest.NewRecorder()
|
||||
r = newRequest("POST", "/api/autopilots/"+ap.ID+"/trigger?workspace_id="+testWorkspaceID, nil)
|
||||
r = withURLParam(r, "id", ap.ID)
|
||||
testHandler.TriggerAutopilot(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("TriggerAutopilot: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var run AutopilotRunResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&run); err != nil {
|
||||
t.Fatalf("decode run: %v", err)
|
||||
}
|
||||
if run.Status != "issue_created" {
|
||||
t.Fatalf("run status = %q, want issue_created", run.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTriggerAutopilot_SquadPrivateLeader_PlainMemberCreator_Blocked verifies
|
||||
// that if an autopilot pointing to a private-leader squad was somehow saved
|
||||
// by a plain member (legacy data), dispatch is blocked at runtime.
|
||||
func TestTriggerAutopilot_SquadPrivateLeader_PlainMemberCreator_Blocked(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'AP Private Leader Blocked Dispatch', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
// Directly insert an autopilot with the plain member as creator
|
||||
// (simulating legacy data before the save-time gate).
|
||||
var apID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO autopilot (workspace_id, title, assignee_type, assignee_id,
|
||||
execution_mode, created_by_type, created_by_id, status)
|
||||
VALUES ($1, 'legacy illegal ap', 'squad', $2, 'create_issue', 'member', $3, 'active')
|
||||
RETURNING id
|
||||
`, testWorkspaceID, squadID, memberID).Scan(&apID); err != nil {
|
||||
t.Fatalf("create autopilot: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM autopilot_run WHERE autopilot_id = $1`, apID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, apID)
|
||||
})
|
||||
|
||||
// Trigger as workspace owner — the dispatch should fail because the
|
||||
// autopilot's creator (plain member) cannot access the private leader.
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/autopilots/"+apID+"/trigger?workspace_id="+testWorkspaceID, nil)
|
||||
r = withURLParam(r, "id", apID)
|
||||
testHandler.TriggerAutopilot(w, r)
|
||||
// Dispatch returns 200 with status=skipped (or failed) — the run is created
|
||||
// but the dispatch is blocked by the private-leader gate.
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("TriggerAutopilot: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var run AutopilotRunResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&run); err != nil {
|
||||
t.Fatalf("decode run: %v", err)
|
||||
}
|
||||
// The dispatch-time gate should cause a skipped or failed run.
|
||||
if run.Status == "issue_created" || run.Status == "running" {
|
||||
t.Fatalf("run status = %q; want skipped/failed since creator is plain member", run.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTriggerAutopilot_RunOnly_SquadPrivateLeader_PlainMemberCreator_Blocked
|
||||
// mirrors the create_issue dispatch test above but exercises the run_only
|
||||
// dispatch path (dispatchRunOnly), ensuring both dispatch branches gate
|
||||
// private-leader access.
|
||||
func TestTriggerAutopilot_RunOnly_SquadPrivateLeader_PlainMemberCreator_Blocked(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'AP RunOnly Private Leader Blocked', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
// Legacy autopilot: run_only mode, plain member creator, private-leader squad.
|
||||
var apID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO autopilot (workspace_id, title, assignee_type, assignee_id,
|
||||
execution_mode, created_by_type, created_by_id, status)
|
||||
VALUES ($1, 'legacy run_only illegal ap', 'squad', $2, 'run_only', 'member', $3, 'active')
|
||||
RETURNING id
|
||||
`, testWorkspaceID, squadID, memberID).Scan(&apID); err != nil {
|
||||
t.Fatalf("create autopilot: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM autopilot_run WHERE autopilot_id = $1`, apID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, apID)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/autopilots/"+apID+"/trigger?workspace_id="+testWorkspaceID, nil)
|
||||
r = withURLParam(r, "id", apID)
|
||||
testHandler.TriggerAutopilot(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("TriggerAutopilot: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var run AutopilotRunResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&run); err != nil {
|
||||
t.Fatalf("decode run: %v", err)
|
||||
}
|
||||
if run.Status == "running" {
|
||||
t.Fatalf("run status = %q; want skipped/failed since creator is plain member and leader is private", run.Status)
|
||||
}
|
||||
}
|
||||
@@ -1133,7 +1133,7 @@ func (h *Handler) advanceIssueToDone(ctx context.Context, issue db.Issue, worksp
|
||||
// it here would leave the parent silent for the dominant completion path.
|
||||
// notifyParentOfChildDone re-checks every guard (prev != done, parent
|
||||
// exists, parent not terminal), so calling it unconditionally is safe.
|
||||
h.notifyParentOfChildDone(ctx, issue, updated)
|
||||
h.notifyParentOfChildDone(ctx, issue, updated, "system", "")
|
||||
|
||||
prefix := h.getIssuePrefix(ctx, issue.WorkspaceID)
|
||||
resp := issueToResponse(updated, prefix)
|
||||
|
||||
@@ -2554,7 +2554,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
// loops in PR #2918). The helper guards on transition + parent state and
|
||||
// fails best-effort.
|
||||
if statusChanged {
|
||||
h.notifyParentOfChildDone(r.Context(), prevIssue, issue)
|
||||
h.notifyParentOfChildDone(r.Context(), prevIssue, issue, actorType, actorID)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
@@ -2623,6 +2623,10 @@ func (h *Handler) validateAssigneePair(ctx context.Context, r *http.Request, wor
|
||||
if err != nil || leader.ArchivedAt.Valid {
|
||||
return http.StatusBadRequest, "squad leader is archived; cannot assign to this squad"
|
||||
}
|
||||
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
||||
if !h.canAccessPrivateAgent(ctx, leader, actorType, actorID, workspaceID) {
|
||||
return http.StatusForbidden, "cannot assign to squad with private leader"
|
||||
}
|
||||
return 0, ""
|
||||
default:
|
||||
return http.StatusBadRequest, "assignee_type must be 'member', 'agent', or 'squad'"
|
||||
@@ -3029,7 +3033,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
||||
// Platform-driven parent notification, mirrored from UpdateIssue
|
||||
// (MUL-2538). Best-effort; failure does not abort the batch.
|
||||
if statusChanged {
|
||||
h.notifyParentOfChildDone(r.Context(), prevIssue, issue)
|
||||
h.notifyParentOfChildDone(r.Context(), prevIssue, issue, actorType, actorID)
|
||||
}
|
||||
|
||||
updated++
|
||||
|
||||
@@ -48,7 +48,7 @@ import (
|
||||
// Errors are logged at warn level and swallowed: this is a best-effort
|
||||
// notification on the side of a successful status update; failing it must
|
||||
// not roll back the user's status change.
|
||||
func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Issue) {
|
||||
func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Issue, actorType, actorID string) {
|
||||
if !issue.ParentIssueID.Valid {
|
||||
return
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Is
|
||||
// author_type='system'); this keeps smuggled mentions from the child
|
||||
// title inert and gives the platform a single place to apply the loop
|
||||
// and idempotency guards.
|
||||
h.dispatchParentAssigneeTrigger(ctx, parent, issue, comment)
|
||||
h.dispatchParentAssigneeTrigger(ctx, parent, issue, comment, actorType, actorID)
|
||||
}
|
||||
|
||||
// sanitizeChildTitleForSystemComment removes mention-style markdown from a
|
||||
@@ -243,7 +243,7 @@ func sanitizeMentionLabel(name string) string {
|
||||
// for the same parent (e.g. two children finishing back-to-back).
|
||||
// - Readiness: archived agents / missing runtimes are silently skipped
|
||||
// so a closed-out agent does not surface as a phantom assignee.
|
||||
func (h *Handler) dispatchParentAssigneeTrigger(ctx context.Context, parent, child db.Issue, systemComment db.Comment) {
|
||||
func (h *Handler) dispatchParentAssigneeTrigger(ctx context.Context, parent, child db.Issue, systemComment db.Comment, actorType, actorID string) {
|
||||
if !parent.AssigneeType.Valid || !parent.AssigneeID.Valid {
|
||||
return
|
||||
}
|
||||
@@ -252,7 +252,7 @@ func (h *Handler) dispatchParentAssigneeTrigger(ctx context.Context, parent, chi
|
||||
case "agent":
|
||||
h.triggerChildDoneAgent(ctx, parent, systemComment.ID)
|
||||
case "squad":
|
||||
h.triggerChildDoneSquad(ctx, parent, child, systemComment.ID)
|
||||
h.triggerChildDoneSquad(ctx, parent, child, systemComment.ID, actorType, actorID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ func (h *Handler) triggerChildDoneAgent(ctx context.Context, parent db.Issue, tr
|
||||
// - same effective leader on both sides — child agent == leader, or
|
||||
// child squad's leader == this squad's leader (the cross-squad shared
|
||||
// leader loop).
|
||||
func (h *Handler) triggerChildDoneSquad(ctx context.Context, parent, child db.Issue, triggerCommentID pgtype.UUID) {
|
||||
func (h *Handler) triggerChildDoneSquad(ctx context.Context, parent, child db.Issue, triggerCommentID pgtype.UUID, actorType, actorID string) {
|
||||
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
||||
ID: parent.AssigneeID,
|
||||
WorkspaceID: parent.WorkspaceID,
|
||||
@@ -310,6 +310,11 @@ func (h *Handler) triggerChildDoneSquad(ctx context.Context, parent, child db.Is
|
||||
return
|
||||
}
|
||||
|
||||
// Private-leader gate: deny if the actor cannot access the leader.
|
||||
if !h.canEnqueueSquadLeader(ctx, squad.LeaderID, actorType, actorID, uuidToString(parent.WorkspaceID)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Same-squad child → the leader has already observed the work via its
|
||||
// own coordination cycle on the child; firing again on the parent would
|
||||
// just re-trigger the same leader run with no new signal.
|
||||
|
||||
@@ -1033,6 +1033,11 @@ func (h *Handler) enqueueSquadLeaderTask(ctx context.Context, issue db.Issue, tr
|
||||
return
|
||||
}
|
||||
|
||||
// Private-leader gate: deny if the actor cannot access the leader.
|
||||
if !h.canEnqueueSquadLeader(ctx, squad.LeaderID, authorType, authorID, uuidToString(issue.WorkspaceID)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dedup: skip if leader already has a pending task for this issue.
|
||||
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
||||
IssueID: issue.ID,
|
||||
|
||||
367
server/internal/handler/squad_private_leader_test.go
Normal file
367
server/internal/handler/squad_private_leader_test.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCreateIssue_SquadPrivateLeader_PlainMemberBlocked verifies that a
|
||||
// plain member cannot create an issue assigned to a squad whose leader is
|
||||
// a private agent.
|
||||
func TestCreateIssue_SquadPrivateLeader_PlainMemberBlocked(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'Private Leader Create Test', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequestAs(memberID, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Should be blocked",
|
||||
"assignee_type": "squad",
|
||||
"assignee_id": squadID,
|
||||
})
|
||||
testHandler.CreateIssue(w, r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateIssue_SquadPrivateLeader_PlainMemberBlocked verifies that a
|
||||
// plain member cannot update an issue's assignee to a private-leader squad.
|
||||
func TestUpdateIssue_SquadPrivateLeader_PlainMemberBlocked(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'Private Leader Update Test', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
// Create an unassigned issue as workspace owner.
|
||||
var issueID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO issue (workspace_id, creator_type, creator_id, title)
|
||||
VALUES ($1, 'member', $2, 'update target')
|
||||
RETURNING id
|
||||
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
||||
t.Fatalf("create issue: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequestAs(memberID, "PATCH", "/api/issues/"+issueID, map[string]any{
|
||||
"assignee_type": "squad",
|
||||
"assignee_id": squadID,
|
||||
})
|
||||
r = withURLParam(r, "id", issueID)
|
||||
testHandler.UpdateIssue(w, r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateIssue_SquadPrivateLeader_OwnerAllowed verifies that a workspace
|
||||
// owner CAN assign an issue to a squad with a private leader.
|
||||
func TestCreateIssue_SquadPrivateLeader_OwnerAllowed(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, _ := privateAgentTestFixture(t)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'Private Leader Owner Test', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
// testUserID is workspace owner — should succeed.
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Owner assigns private-leader squad",
|
||||
"assignee_type": "squad",
|
||||
"assignee_id": squadID,
|
||||
})
|
||||
testHandler.CreateIssue(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("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: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, created.ID)
|
||||
})
|
||||
}
|
||||
|
||||
// TestComment_SquadPrivateLeader_PlainMemberNoEnqueue verifies that a plain
|
||||
// member posting a comment on an issue assigned to a private-leader squad
|
||||
// does NOT trigger the leader.
|
||||
func TestComment_SquadPrivateLeader_PlainMemberNoEnqueue(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'Private Leader Comment Test', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
// Create issue assigned to the squad as workspace owner.
|
||||
var issueID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO issue (workspace_id, creator_type, creator_id, title, assignee_type, assignee_id)
|
||||
VALUES ($1, 'member', $2, 'private leader comment test', 'squad', $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, testUserID, squadID).Scan(&issueID); err != nil {
|
||||
t.Fatalf("create issue: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
// Plain member posts a plain comment (not a @mention).
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequestAs(memberID, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "any update on this?",
|
||||
})
|
||||
r = withURLParam(r, "id", issueID)
|
||||
testHandler.CreateComment(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The private leader must NOT have a queued task.
|
||||
var count int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
issueID, agentID,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count tasks: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("private leader got %d queued tasks from plain member comment; want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildDone_SquadPrivateLeader_PlainMemberNoEnqueue verifies that when
|
||||
// a plain member completes a child issue whose parent is assigned to a
|
||||
// private-leader squad, the leader is NOT enqueued.
|
||||
func TestChildDone_SquadPrivateLeader_PlainMemberNoEnqueue(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'Private Leader ChildDone Test', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
// Create parent issue assigned to the squad (as workspace owner).
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "parent with private-leader squad",
|
||||
"assignee_type": "squad",
|
||||
"assignee_id": squadID,
|
||||
})
|
||||
testHandler.CreateIssue(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var parent IssueResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&parent); err != nil {
|
||||
t.Fatalf("decode parent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, parent.ID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, parent.ID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE parent_issue_id = $1`, parent.ID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, parent.ID)
|
||||
})
|
||||
|
||||
// Clear any tasks enqueued by the create.
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, parent.ID)
|
||||
|
||||
// Create a child issue via API (as workspace owner, with member assignee).
|
||||
w = httptest.NewRecorder()
|
||||
r = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "child task",
|
||||
"parent_issue_id": parent.ID,
|
||||
"assignee_type": "member",
|
||||
"assignee_id": memberID,
|
||||
"status": "in_progress",
|
||||
})
|
||||
testHandler.CreateIssue(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create child: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var child IssueResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&child); err != nil {
|
||||
t.Fatalf("decode child: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, child.ID)
|
||||
})
|
||||
|
||||
// Plain member moves child to done.
|
||||
w = httptest.NewRecorder()
|
||||
r = newRequestAs(memberID, "PATCH", "/api/issues/"+child.ID, map[string]any{
|
||||
"status": "done",
|
||||
})
|
||||
r = withURLParam(r, "id", child.ID)
|
||||
testHandler.UpdateIssue(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue (child done): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The private leader must NOT have a queued task on the parent.
|
||||
var count int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
parent.ID, agentID,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count tasks: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("private leader got %d queued tasks from plain member child-done; want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComment_SquadPrivateLeader_AgentActorAllowed verifies that an agent
|
||||
// actor CAN trigger the private leader via comment on a squad-assigned issue.
|
||||
func TestComment_SquadPrivateLeader_AgentActorAllowed(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, _, _ := privateAgentTestFixture(t)
|
||||
otherAgentID := createHandlerTestAgent(t, "squad-private-leader-agent-actor", nil)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'Private Leader Agent Actor Test', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
// Create issue assigned to the squad.
|
||||
var issueID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO issue (workspace_id, creator_type, creator_id, title, assignee_type, assignee_id)
|
||||
VALUES ($1, 'member', $2, 'private leader agent actor test', 'squad', $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, testUserID, squadID).Scan(&issueID); err != nil {
|
||||
t.Fatalf("create issue: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
// Create a task for the other agent so X-Agent-ID / X-Task-ID are valid.
|
||||
var taskID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id)
|
||||
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, $2)
|
||||
RETURNING id
|
||||
`, otherAgentID, issueID).Scan(&taskID); err != nil {
|
||||
t.Fatalf("create agent task: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
||||
})
|
||||
|
||||
// Agent posts a comment.
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "agent reporting in",
|
||||
})
|
||||
r.Header.Set("X-Agent-ID", otherAgentID)
|
||||
r.Header.Set("X-Task-ID", taskID)
|
||||
r = withURLParam(r, "id", issueID)
|
||||
testHandler.CreateComment(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The private leader SHOULD have a queued task — agents bypass private gate.
|
||||
var count int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
issueID, agentID,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count tasks: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatalf("private leader got 0 queued tasks from agent actor comment; want ≥1 (agents bypass private gate)")
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,12 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi
|
||||
// MUL-2429); agent-assigned autopilots go through the standard issue
|
||||
// path. Both code paths land in agent_task_queue with agent_id = leader.
|
||||
if ap.AssigneeType == "squad" {
|
||||
// Fail-closed private-leader gate: if the leader is private, verify
|
||||
// the autopilot creator still has access. This catches illegitimate
|
||||
// configs that were saved before the save-time gate was added.
|
||||
if leader.Visibility == "private" && !s.canCreatorAccessPrivateLeader(ctx, ap, leader) {
|
||||
return fmt.Errorf("autopilot creator cannot access private squad leader")
|
||||
}
|
||||
if _, err := s.TaskSvc.EnqueueTaskForSquadLeader(ctx, issue, leader.ID, pgtype.UUID{}); err != nil {
|
||||
return fmt.Errorf("enqueue squad leader task: %w", err)
|
||||
}
|
||||
@@ -293,6 +299,11 @@ func (s *AutopilotService) dispatchRunOnly(ctx context.Context, ap db.Autopilot,
|
||||
return &errDispatchSkipped{reason: formatAdmissionReason(ap, reason)}
|
||||
}
|
||||
|
||||
// Fail-closed private-leader gate for squad autopilots.
|
||||
if ap.AssigneeType == "squad" && agent.Visibility == "private" && !s.canCreatorAccessPrivateLeader(ctx, ap, agent) {
|
||||
return &errDispatchSkipped{reason: formatAdmissionReason(ap, "creator cannot access private squad leader")}
|
||||
}
|
||||
|
||||
task, err := s.Queries.CreateAutopilotTask(ctx, db.CreateAutopilotTaskParams{
|
||||
AgentID: agent.ID,
|
||||
RuntimeID: agent.RuntimeID,
|
||||
@@ -1040,3 +1051,25 @@ func (s *AutopilotService) getIssuePrefix(workspaceID pgtype.UUID) string {
|
||||
}
|
||||
return ws.IssuePrefix
|
||||
}
|
||||
|
||||
// canCreatorAccessPrivateLeader checks whether the autopilot's creator still
|
||||
// has access to a private leader agent. Mirrors handler.canAccessPrivateAgent
|
||||
// logic: agent creators always pass; member creators must be the agent owner
|
||||
// or a workspace owner/admin. Returns false (fail-closed) on any lookup error.
|
||||
func (s *AutopilotService) canCreatorAccessPrivateLeader(ctx context.Context, ap db.Autopilot, leader db.Agent) bool {
|
||||
if ap.CreatedByType == "agent" {
|
||||
return true
|
||||
}
|
||||
creatorID := util.UUIDToString(ap.CreatedByID)
|
||||
if util.UUIDToString(leader.OwnerID) == creatorID {
|
||||
return true
|
||||
}
|
||||
member, err := s.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: ap.CreatedByID,
|
||||
WorkspaceID: ap.WorkspaceID,
|
||||
})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return member.Role == "owner" || member.Role == "admin"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user