mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 15:39:25 +02:00
Compare commits
5 Commits
agent/lamb
...
agent/j/4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90bc37a9ef | ||
|
|
ee3dd1dfe1 | ||
|
|
317eb1726b | ||
|
|
0d26712999 | ||
|
|
ad16b99232 |
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Lock,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
type AgentPresenceDetail,
|
||||
useWorkspacePresenceMap,
|
||||
} from "@multica/core/agents";
|
||||
import { api } from "@multica/core/api";
|
||||
import { api, ApiError } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
@@ -78,6 +79,19 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
const presence: AgentPresenceDetail | null =
|
||||
agent ? presenceMap.get(agent.id) ?? null : null;
|
||||
|
||||
// Fallback fetch: when the agent is missing from the workspace list, hit
|
||||
// GET /api/agents/{id} directly to disambiguate "doesn't exist" (404) from
|
||||
// "you can't see this private agent" (403). Only fires after the list has
|
||||
// settled, so the common path makes zero extra requests.
|
||||
const { error: detailError } = useQuery({
|
||||
queryKey: ["agent-detail-probe", wsId, agentId],
|
||||
queryFn: () => api.getAgent(agentId),
|
||||
enabled: !agentsLoading && !agent && !!agentId,
|
||||
retry: false,
|
||||
});
|
||||
const isForbidden =
|
||||
detailError instanceof ApiError && detailError.status === 403;
|
||||
|
||||
// Permission hook MUST be called unconditionally — its `agent | null`
|
||||
// signature handles the not-found / loading case internally so the early
|
||||
// returns below don't violate the rules of hooks. Backend gates archive
|
||||
@@ -122,6 +136,31 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
return <DetailLoadingSkeleton />;
|
||||
}
|
||||
|
||||
// --- No permission (private agent the caller is not in allowed_principals for) ---
|
||||
if (!agent && isForbidden) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<BackHeader paths={paths.agents()} title={t(($) => $.detail.back_to_agents)} />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-16 text-center">
|
||||
<Lock className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t(($) => $.detail.no_access_title)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(($) => $.detail.no_access_hint)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => navigation.push(paths.agents())}
|
||||
>
|
||||
{t(($) => $.detail.back_to_agents_full)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Not found / error ---
|
||||
if (!agent) {
|
||||
return (
|
||||
|
||||
@@ -107,6 +107,8 @@
|
||||
"back_to_agents_full": "Back to agents",
|
||||
"not_found_title": "Agent not found",
|
||||
"not_found_default": "This agent may have been archived or deleted.",
|
||||
"no_access_title": "You don't have access to this agent",
|
||||
"no_access_hint": "Only the agent owner or a workspace admin can view this private agent.",
|
||||
"try_again": "Try again",
|
||||
"archived_banner": "This agent is archived. It cannot be assigned or mentioned.",
|
||||
"restore": "Restore",
|
||||
|
||||
@@ -103,6 +103,8 @@
|
||||
"back_to_agents_full": "返回智能体列表",
|
||||
"not_found_title": "未找到该智能体",
|
||||
"not_found_default": "该智能体可能已被归档或删除。",
|
||||
"no_access_title": "你没有访问该智能体的权限",
|
||||
"no_access_hint": "只有该私密智能体的拥有者或工作区管理员可以查看。",
|
||||
"try_again": "重试",
|
||||
"archived_banner": "该智能体已归档,无法被分配或提及。",
|
||||
"restore": "恢复",
|
||||
|
||||
@@ -10,8 +10,12 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// authRequestWithAgent makes an authenticated request with X-Agent-ID header,
|
||||
// causing the server to resolve the actor as an agent instead of a member.
|
||||
// authRequestWithAgent makes an authenticated request with X-Agent-ID +
|
||||
// X-Task-ID headers, causing the server to resolve the actor as an agent
|
||||
// instead of a member. resolveActor requires both headers to grant agent
|
||||
// identity (defense against header forgery — see #2359 PR review), so we
|
||||
// seed a queued task for the agent on demand and pass its UUID as
|
||||
// X-Task-ID. The task is best-effort cleaned up via test teardown elsewhere.
|
||||
func authRequestWithAgent(t *testing.T, method, path string, body any, agentID string) *http.Response {
|
||||
t.Helper()
|
||||
var bodyReader io.Reader
|
||||
@@ -27,6 +31,7 @@ func authRequestWithAgent(t *testing.T, method, path string, body any, agentID s
|
||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||
req.Header.Set("X-Workspace-ID", testWorkspaceID)
|
||||
req.Header.Set("X-Agent-ID", agentID)
|
||||
req.Header.Set("X-Task-ID", ensureAgentTask(t, agentID))
|
||||
|
||||
r, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -35,6 +40,37 @@ func authRequestWithAgent(t *testing.T, method, path string, body any, agentID s
|
||||
return r
|
||||
}
|
||||
|
||||
// ensureAgentTask returns a queued task UUID belonging to the given agent,
|
||||
// inserting one if none exists. Used by authRequestWithAgent so callers
|
||||
// can keep treating "set X-Agent-ID" as the single knob for posing as an
|
||||
// agent — resolveActor's pair-required policy is satisfied transparently.
|
||||
func ensureAgentTask(t *testing.T, agentID string) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var taskID string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT id::text FROM agent_task_queue WHERE agent_id = $1 LIMIT 1`,
|
||||
agentID,
|
||||
).Scan(&taskID); err == nil && taskID != "" {
|
||||
return taskID
|
||||
}
|
||||
var runtimeID string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT runtime_id::text FROM agent WHERE id = $1`,
|
||||
agentID,
|
||||
).Scan(&runtimeID); err != nil {
|
||||
t.Fatalf("ensureAgentTask: load runtime_id for agent %s: %v", agentID, err)
|
||||
}
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
|
||||
VALUES ($1, $2, 'queued', 0)
|
||||
RETURNING id::text
|
||||
`, agentID, runtimeID).Scan(&taskID); err != nil {
|
||||
t.Fatalf("ensureAgentTask: insert task for agent %s: %v", agentID, err)
|
||||
}
|
||||
return taskID
|
||||
}
|
||||
|
||||
// countPendingTasks returns the number of queued/dispatched tasks for an issue.
|
||||
func countPendingTasks(t *testing.T, issueID string) int {
|
||||
t.Helper()
|
||||
|
||||
@@ -289,9 +289,17 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// All agents (including private) are visible to workspace members.
|
||||
// Resolve the request actor once. Agents bypass the private-agent gate
|
||||
// to preserve A2A collaboration; members must be in allowed_principals
|
||||
// (agent owner or workspace owner/admin) to see private agents.
|
||||
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
||||
visible := make([]AgentResponse, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
if a.Visibility == "private" && actorType == "member" {
|
||||
if !memberAllowedForPrivateAgent(a, actorID, member.Role) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
resp := agentToResponse(a)
|
||||
if skills, ok := skillMap[resp.ID]; ok {
|
||||
resp.Skills = skills
|
||||
@@ -313,6 +321,16 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Private-agent gate: members must be in allowed_principals to view
|
||||
// (and therefore navigate to) a private agent. The 403 lets the front-end
|
||||
// render an explicit "no access" placeholder instead of a 404 — see
|
||||
// agent-detail-page.tsx.
|
||||
workspaceID := uuidToString(agent.WorkspaceID)
|
||||
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
||||
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
|
||||
writeError(w, http.StatusForbidden, "you do not have access to this agent")
|
||||
return
|
||||
}
|
||||
resp := agentToResponse(agent)
|
||||
// Use the summary query (no `content` column) — the embedded
|
||||
// AgentSkillSummary only needs id/name/description, and reading large
|
||||
@@ -814,6 +832,14 @@ func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Run history is part of the private-agent gate ("查看历史会话"). Same
|
||||
// 403 semantics as GetAgent.
|
||||
workspaceID := uuidToString(agent.WorkspaceID)
|
||||
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
||||
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
|
||||
writeError(w, http.StatusForbidden, "you do not have access to this agent")
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := h.Queries.ListAgentTasks(r.Context(), agent.ID)
|
||||
if err != nil {
|
||||
@@ -850,7 +876,8 @@ type AgentRunCount struct {
|
||||
// activity to keep the Agents list cheap regardless of agent count.
|
||||
func (h *Handler) GetWorkspaceAgentRunCounts(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
||||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -860,12 +887,23 @@ func (h *Handler) GetWorkspaceAgentRunCounts(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]AgentRunCount, len(rows))
|
||||
for i, row := range rows {
|
||||
resp[i] = AgentRunCount{
|
||||
AgentID: uuidToString(row.AgentID),
|
||||
RunCount: row.RunCount,
|
||||
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
||||
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]AgentRunCount, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
agentID := uuidToString(row.AgentID)
|
||||
if _, ok := allowed[agentID]; !ok {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, AgentRunCount{
|
||||
AgentID: agentID,
|
||||
RunCount: row.RunCount,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
@@ -879,7 +917,8 @@ func (h *Handler) GetWorkspaceAgentRunCounts(w http.ResponseWriter, r *http.Requ
|
||||
// empty buckets to keep the response small.
|
||||
func (h *Handler) GetWorkspaceAgentActivity30d(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
||||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -889,14 +928,25 @@ func (h *Handler) GetWorkspaceAgentActivity30d(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]AgentActivityBucket, len(rows))
|
||||
for i, row := range rows {
|
||||
resp[i] = AgentActivityBucket{
|
||||
AgentID: uuidToString(row.AgentID),
|
||||
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
||||
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]AgentActivityBucket, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
agentID := uuidToString(row.AgentID)
|
||||
if _, ok := allowed[agentID]; !ok {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, AgentActivityBucket{
|
||||
AgentID: agentID,
|
||||
BucketAt: timestampToString(row.Bucket),
|
||||
TaskCount: row.TaskCount,
|
||||
FailedCount: row.FailedCount,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
@@ -913,7 +963,8 @@ func (h *Handler) GetWorkspaceAgentActivity30d(w http.ResponseWriter, r *http.Re
|
||||
// snapshot.
|
||||
func (h *Handler) ListWorkspaceAgentTaskSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
||||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -923,9 +974,19 @@ func (h *Handler) ListWorkspaceAgentTaskSnapshot(w http.ResponseWriter, r *http.
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]AgentTaskResponse, len(tasks))
|
||||
for i, t := range tasks {
|
||||
resp[i] = taskToResponse(t)
|
||||
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
||||
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]AgentTaskResponse, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
if _, ok := allowed[uuidToString(t.AgentID)]; !ok {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, taskToResponse(t))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
||||
75
server/internal/handler/agent_access.go
Normal file
75
server/internal/handler/agent_access.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// canAccessPrivateAgent gates the four protected surfaces for private
|
||||
// agents: chat / @-mention dispatch, viewing the agent's history, editing
|
||||
// configuration, and deletion.
|
||||
//
|
||||
// Public agents are unrestricted — the predicate returns true unconditionally.
|
||||
//
|
||||
// Agent-to-agent traffic is always allowed (actorType == "agent"); this is
|
||||
// what preserves A2A collaboration even with private agents. The trust
|
||||
// boundary is at member↔agent, not agent↔agent.
|
||||
//
|
||||
// For members, the implicit allowed_principals set is computed inline as:
|
||||
// {agent.owner_id} ∪ workspace owner/admin members. Manual configuration of
|
||||
// allowed_principals is not exposed in v1; future work can extend this set
|
||||
// without changing call sites.
|
||||
func (h *Handler) canAccessPrivateAgent(ctx context.Context, agent db.Agent, actorType, actorID, workspaceID string) bool {
|
||||
if agent.Visibility != "private" {
|
||||
return true
|
||||
}
|
||||
if actorType == "agent" {
|
||||
return true
|
||||
}
|
||||
if uuidToString(agent.OwnerID) == actorID {
|
||||
return true
|
||||
}
|
||||
member, err := h.getWorkspaceMember(ctx, actorID, workspaceID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return roleAllowed(member.Role, "owner", "admin")
|
||||
}
|
||||
|
||||
// memberAllowedForPrivateAgent is the pure predicate used by both
|
||||
// canAccessPrivateAgent and the ListAgents filter loop. Caller must have
|
||||
// already confirmed agent.Visibility == "private".
|
||||
func memberAllowedForPrivateAgent(agent db.Agent, userID, role string) bool {
|
||||
if roleAllowed(role, "owner", "admin") {
|
||||
return true
|
||||
}
|
||||
return uuidToString(agent.OwnerID) == userID
|
||||
}
|
||||
|
||||
// accessibleAgentIDs returns the set of agent IDs in the workspace the actor
|
||||
// is allowed to see, for use by workspace-wide aggregation endpoints
|
||||
// (run counts, activity histograms, task snapshots) that need to filter out
|
||||
// private agents the member can't access. Returns nil and false on error.
|
||||
func (h *Handler) accessibleAgentIDs(ctx context.Context, workspaceID, actorType, actorID, role string) (map[string]struct{}, bool) {
|
||||
wsUUID, err := util.ParseUUID(workspaceID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
agents, err := h.Queries.ListAllAgents(ctx, wsUUID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(agents))
|
||||
for _, a := range agents {
|
||||
if a.Visibility == "private" && actorType == "member" {
|
||||
if !memberAllowedForPrivateAgent(a, actorID, role) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
allowed[uuidToString(a.ID)] = struct{}{}
|
||||
}
|
||||
return allowed, true
|
||||
}
|
||||
|
||||
503
server/internal/handler/agent_access_test.go
Normal file
503
server/internal/handler/agent_access_test.go
Normal file
@@ -0,0 +1,503 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// TestMemberAllowedForPrivateAgent_Pure exercises the pure predicate that
|
||||
// drives the private-agent gate. The gate must allow:
|
||||
// - workspace owner / admin (regardless of agent ownership)
|
||||
// - the agent owner (regardless of role)
|
||||
//
|
||||
// And deny everyone else. This test runs without a database.
|
||||
func TestMemberAllowedForPrivateAgent_Pure(t *testing.T) {
|
||||
ownerUserID := "11111111-1111-1111-1111-111111111111"
|
||||
otherUserID := "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
agent := db.Agent{
|
||||
OwnerID: util.MustParseUUID(ownerUserID),
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
userID string
|
||||
role string
|
||||
want bool
|
||||
}{
|
||||
{"workspace owner, not agent owner", otherUserID, "owner", true},
|
||||
{"workspace admin, not agent owner", otherUserID, "admin", true},
|
||||
{"agent owner with member role", ownerUserID, "member", true},
|
||||
{"agent owner with admin role", ownerUserID, "admin", true},
|
||||
{"plain member, not agent owner", otherUserID, "member", false},
|
||||
{"plain member with no role string", otherUserID, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := memberAllowedForPrivateAgent(agent, tc.userID, tc.role)
|
||||
if got != tc.want {
|
||||
t.Fatalf("memberAllowedForPrivateAgent(userID=%s, role=%s) = %v; want %v",
|
||||
tc.userID, tc.role, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// privateAgentTestFixture sets up a private agent owned by a freshly created
|
||||
// user, plus a second non-admin member in the workspace. Returns the agent
|
||||
// id, the owner's user id, and the unrelated member's user id. The caller's
|
||||
// own testUserID stays workspace owner so it can act as the privileged
|
||||
// admin path.
|
||||
func privateAgentTestFixture(t *testing.T) (agentID, ownerID, memberID string) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO "user" (name, email)
|
||||
VALUES ('Private Agent Owner', 'private-agent-owner@multica.test')
|
||||
RETURNING id
|
||||
`).Scan(&ownerID); err != nil {
|
||||
t.Fatalf("create owner user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM "user" WHERE email = 'private-agent-owner@multica.test'`)
|
||||
})
|
||||
|
||||
if _, err := testPool.Exec(ctx, `
|
||||
INSERT INTO member (workspace_id, user_id, role)
|
||||
VALUES ($1, $2, 'member')
|
||||
`, testWorkspaceID, ownerID); err != nil {
|
||||
t.Fatalf("add owner as member: %v", err)
|
||||
}
|
||||
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO "user" (name, email)
|
||||
VALUES ('Plain Member', 'plain-member@multica.test')
|
||||
RETURNING id
|
||||
`).Scan(&memberID); err != nil {
|
||||
t.Fatalf("create plain member user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM "user" WHERE email = 'plain-member@multica.test'`)
|
||||
})
|
||||
|
||||
if _, err := testPool.Exec(ctx, `
|
||||
INSERT INTO member (workspace_id, user_id, role)
|
||||
VALUES ($1, $2, 'member')
|
||||
`, testWorkspaceID, memberID); err != nil {
|
||||
t.Fatalf("add plain member: %v", err)
|
||||
}
|
||||
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, description, runtime_mode, runtime_config,
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
instructions, custom_env, custom_args
|
||||
)
|
||||
VALUES ($1, 'private-access-test-agent', '', 'cloud', '{}'::jsonb,
|
||||
$2, 'private', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, handlerTestRuntimeID(t), ownerID).Scan(&agentID); err != nil {
|
||||
t.Fatalf("create private agent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM agent WHERE id = $1`, agentID)
|
||||
})
|
||||
|
||||
return agentID, ownerID, memberID
|
||||
}
|
||||
|
||||
func newRequestAs(userID, method, path string, body any) *http.Request {
|
||||
req := newRequest(method, path, body)
|
||||
req.Header.Set("X-User-ID", userID)
|
||||
return req
|
||||
}
|
||||
|
||||
// TestGetAgent_PrivateAgentForbidsPlainMember verifies the private-agent
|
||||
// visibility gate at the read-detail endpoint: a workspace member who is
|
||||
// neither the agent owner nor a workspace owner/admin gets 403, while the
|
||||
// agent owner and workspace owner both succeed. Mirrors the four-entry-point
|
||||
// gate (chat, history, edit, delete) on its read surface.
|
||||
func TestGetAgent_PrivateAgentForbidsPlainMember(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
agentID, ownerID, memberID := privateAgentTestFixture(t)
|
||||
|
||||
// Workspace owner (testUserID): allowed via role.
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.GetAgent(w, withURLParam(newRequest("GET", "/api/agents/"+agentID, nil), "id", agentID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetAgent as workspace owner: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Agent owner (plain member who happens to own the agent): allowed.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.GetAgent(w, withURLParam(newRequestAs(ownerID, "GET", "/api/agents/"+agentID, nil), "id", agentID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetAgent as agent owner: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Plain member (not in allowed_principals): denied with 403.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.GetAgent(w, withURLParam(newRequestAs(memberID, "GET", "/api/agents/"+agentID, nil), "id", agentID))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("GetAgent as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAgents_FiltersPrivateForPlainMember verifies that the workspace
|
||||
// agents listing hides private agents from members who lack access. This is
|
||||
// what makes the @-mention autocomplete picker (which feeds off this list)
|
||||
// drop unreachable private agents without any client-side logic.
|
||||
func TestListAgents_FiltersPrivateForPlainMember(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
// Workspace owner sees the agent.
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.ListAgents(w, newRequest("GET", "/api/agents", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListAgents as owner: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !listContainsAgent(t, w.Body.Bytes(), agentID) {
|
||||
t.Fatalf("ListAgents as owner did not include private agent %s", agentID)
|
||||
}
|
||||
|
||||
// Plain member does NOT see the agent.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.ListAgents(w, newRequestAs(memberID, "GET", "/api/agents", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListAgents as plain member: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if listContainsAgent(t, w.Body.Bytes(), agentID) {
|
||||
t.Fatalf("ListAgents as plain member leaked private agent %s", agentID)
|
||||
}
|
||||
}
|
||||
|
||||
func listContainsAgent(t *testing.T, body []byte, agentID string) bool {
|
||||
t.Helper()
|
||||
var resp []AgentResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
t.Fatalf("decode ListAgents response: %v", err)
|
||||
}
|
||||
for _, a := range resp {
|
||||
if a.ID == agentID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestListAgentTasks_PrivateAgentForbidsPlainMember verifies that the agent
|
||||
// task history endpoint (the "查看历史会话" surface) is also gated.
|
||||
func TestListAgentTasks_PrivateAgentForbidsPlainMember(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
agentID, ownerID, memberID := privateAgentTestFixture(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.ListAgentTasks(w, withURLParam(newRequestAs(ownerID, "GET", "/api/agents/"+agentID+"/tasks", nil), "id", agentID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListAgentTasks as owner: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.ListAgentTasks(w, withURLParam(newRequestAs(memberID, "GET", "/api/agents/"+agentID+"/tasks", nil), "id", agentID))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("ListAgentTasks as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateIssue_AssignToPrivateAgentForbidsPlainMember verifies that the
|
||||
// issue-assignment surface is gated by the same predicate. Without this gate
|
||||
// a plain workspace member could side-step chat/@-mention by assigning a
|
||||
// private agent to an issue and letting normal task dispatch run it.
|
||||
func TestCreateIssue_AssignToPrivateAgentForbidsPlainMember(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
agentID, ownerID, memberID := privateAgentTestFixture(t)
|
||||
|
||||
body := func(actorID string) map[string]any {
|
||||
return map[string]any{
|
||||
"title": "assign-to-private-agent test " + actorID,
|
||||
"status": "todo",
|
||||
"priority": "medium",
|
||||
"assignee_type": "agent",
|
||||
"assignee_id": agentID,
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace owner (testUserID): allowed.
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.CreateIssue(w, newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, body(testUserID)))
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue as workspace owner: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Agent owner (plain member who happens to own the agent): allowed.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.CreateIssue(w, newRequestAs(ownerID, "POST", "/api/issues?workspace_id="+testWorkspaceID, body(ownerID)))
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue as agent owner: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Plain member: denied with 403 — closes the back door where issue
|
||||
// assignment would otherwise hand the agent a task without going
|
||||
// through chat / @-mention.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.CreateIssue(w, newRequestAs(memberID, "POST", "/api/issues?workspace_id="+testWorkspaceID, body(memberID)))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("CreateIssue as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateChatSession_PrivateAgentForbidsPlainMember verifies that members
|
||||
// who can't access the private agent cannot start a chat session against it.
|
||||
// The chat handler reads workspace context from middleware, so we set it
|
||||
// explicitly via middleware.SetMemberContext before invoking the handler
|
||||
// (the test harness doesn't run the real middleware chain).
|
||||
func TestCreateChatSession_PrivateAgentForbidsPlainMember(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
// Load the plain member's row so we can build a realistic context.
|
||||
memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: util.MustParseUUID(memberID),
|
||||
WorkspaceID: util.MustParseUUID(testWorkspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load plain member row: %v", err)
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"agent_id": agentID,
|
||||
"title": "should be denied",
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequestAs(memberID, "POST", "/api/chat/sessions", body)
|
||||
req = req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, memberRow))
|
||||
testHandler.CreateChatSession(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("CreateChatSession as plain member: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAgent_RejectsForgedAgentIDHeader is the regression test for the
|
||||
// #2359 review finding "X-Agent-ID can be forged by a plain member to bypass
|
||||
// the private gate". A workspace member sets X-Agent-ID to any visible
|
||||
// agent's UUID without supplying a valid X-Task-ID — resolveActor must now
|
||||
// fall back to the member identity, so the private-agent gate stays effective.
|
||||
func TestGetAgent_RejectsForgedAgentIDHeader(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequestAs(memberID, "GET", "/api/agents/"+agentID, nil)
|
||||
// Forge X-Agent-ID without X-Task-ID. Pre-fix this would have made
|
||||
// resolveActor return ("agent", agentID) and canAccessPrivateAgent
|
||||
// would have unconditionally allowed the read.
|
||||
req.Header.Set("X-Agent-ID", agentID)
|
||||
req = withURLParam(req, "id", agentID)
|
||||
testHandler.GetAgent(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("GetAgent with forged X-Agent-ID: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestListChatMessages_PrivateAgentForbidsAfterAccessRevoked is the regression
|
||||
// test for the #2359 review finding "chat history read path doesn't re-gate".
|
||||
// A member who created a chat session is later denied access to the agent
|
||||
// (here simulated by the member never being on the allowlist for a private
|
||||
// agent owned by someone else; the equivalent of an after-the-fact ownership
|
||||
// transfer). The session row still names them as creator, but the read
|
||||
// endpoints must refuse to surface the transcript.
|
||||
func TestListChatMessages_PrivateAgentForbidsAfterAccessRevoked(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
agentID, _, memberID := privateAgentTestFixture(t)
|
||||
|
||||
// Insert a chat session row directly with the plain member as creator,
|
||||
// bypassing CreateChatSession's own gate. This represents a session
|
||||
// that existed before the member lost access (or before the gate
|
||||
// landed).
|
||||
var sessionID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status)
|
||||
VALUES ($1, $2, $3, 'pre-revocation session', 'active')
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, memberID).Scan(&sessionID); err != nil {
|
||||
t.Fatalf("seed chat session: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM chat_session WHERE id = $1`, sessionID)
|
||||
})
|
||||
|
||||
memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: util.MustParseUUID(memberID),
|
||||
WorkspaceID: util.MustParseUUID(testWorkspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load plain member row: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequestAs(memberID, "GET", "/api/chat/sessions/"+sessionID+"/messages", nil)
|
||||
req = req.WithContext(middleware.SetMemberContext(req.Context(), testWorkspaceID, memberRow))
|
||||
req = withURLParam(req, "sessionId", sessionID)
|
||||
testHandler.ListChatMessages(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("ListChatMessages on stale session: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMentionAgent_RejectsCrossWorkspaceAgentUUID is the regression test for
|
||||
// the #2359 review finding "@mention path doesn't constrain the mentioned
|
||||
// agent to the current workspace". A plain member in workspace A who happens
|
||||
// to be owner of workspace B should NOT be able to @mention a private agent
|
||||
// in workspace B from a comment on a workspace-A issue and have it pass the
|
||||
// gate (the gate was being applied against the wrong workspace's roles).
|
||||
func TestMentionAgent_RejectsCrossWorkspaceAgentUUID(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a separate workspace + agent runtime + private agent.
|
||||
var foreignWorkspaceID, foreignUserID, foreignRuntimeID, foreignAgentID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO "user" (name, email)
|
||||
VALUES ('Foreign Owner', 'cross-ws-foreign@multica.test')
|
||||
RETURNING id
|
||||
`).Scan(&foreignUserID); err != nil {
|
||||
t.Fatalf("create foreign user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM "user" WHERE email = 'cross-ws-foreign@multica.test'`)
|
||||
})
|
||||
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO workspace (name, slug, description, issue_prefix)
|
||||
VALUES ('Cross-WS Foreign', 'cross-ws-foreign', '', 'XWF')
|
||||
RETURNING id
|
||||
`).Scan(&foreignWorkspaceID); err != nil {
|
||||
t.Fatalf("create foreign workspace: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM workspace WHERE slug = 'cross-ws-foreign'`)
|
||||
})
|
||||
if _, err := testPool.Exec(ctx, `
|
||||
INSERT INTO member (workspace_id, user_id, role)
|
||||
VALUES ($1, $2, 'owner')
|
||||
`, foreignWorkspaceID, foreignUserID); err != nil {
|
||||
t.Fatalf("add foreign member: %v", err)
|
||||
}
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at)
|
||||
VALUES ($1, NULL, 'Foreign Runtime', 'cloud', 'foreign_test', 'online', 'Foreign', '{}'::jsonb, now())
|
||||
RETURNING id
|
||||
`, foreignWorkspaceID).Scan(&foreignRuntimeID); err != nil {
|
||||
t.Fatalf("create foreign runtime: %v", err)
|
||||
}
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent (workspace_id, name, description, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id, instructions, custom_env, custom_args)
|
||||
VALUES ($1, 'foreign-private-agent', '', 'cloud', '{}'::jsonb, $2, 'private', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
|
||||
RETURNING id
|
||||
`, foreignWorkspaceID, foreignRuntimeID, foreignUserID).Scan(&foreignAgentID); err != nil {
|
||||
t.Fatalf("create foreign agent: %v", err)
|
||||
}
|
||||
|
||||
// Create an issue in OUR workspace and a comment that @mentions the
|
||||
// foreign agent's UUID. testUserID is owner of our workspace; pre-fix
|
||||
// the gate would have applied our-workspace-owner status to the foreign
|
||||
// agent and enqueued a task.
|
||||
var issueID, commentID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number)
|
||||
VALUES ($1, 'cross-ws mention test', 'todo', 'medium', 'member', $2,
|
||||
COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
||||
t.Fatalf("create test issue: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
// Multica's mention format is markdown-linked: [@Name](mention://agent/<uuid>).
|
||||
mention := "[@Foreign](mention://agent/" + foreignAgentID + ")"
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content)
|
||||
VALUES ($1, $2, 'member', $3, $4)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, issueID, testUserID, mention).Scan(&commentID); err != nil {
|
||||
t.Fatalf("create test comment: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM comment WHERE id = $1`, commentID)
|
||||
})
|
||||
|
||||
issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(issueID))
|
||||
if err != nil {
|
||||
t.Fatalf("load test issue: %v", err)
|
||||
}
|
||||
comment, err := testHandler.Queries.GetComment(ctx, util.MustParseUUID(commentID))
|
||||
if err != nil {
|
||||
t.Fatalf("load test comment: %v", err)
|
||||
}
|
||||
|
||||
// Count tasks for the foreign agent before. Calling the dispatcher
|
||||
// directly bypasses HTTP-layer concerns and exercises only the
|
||||
// workspace-scoping check.
|
||||
var beforeCount int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM agent_task_queue WHERE agent_id = $1`,
|
||||
foreignAgentID,
|
||||
).Scan(&beforeCount); err != nil {
|
||||
t.Fatalf("count tasks before: %v", err)
|
||||
}
|
||||
|
||||
testHandler.enqueueMentionedAgentTasks(ctx, issue, comment, nil, "member", testUserID)
|
||||
|
||||
var afterCount int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM agent_task_queue WHERE agent_id = $1`,
|
||||
foreignAgentID,
|
||||
).Scan(&afterCount); err != nil {
|
||||
t.Fatalf("count tasks after: %v", err)
|
||||
}
|
||||
if afterCount != beforeCount {
|
||||
t.Fatalf("foreign agent task count changed: before=%d after=%d — cross-workspace mention was not rejected",
|
||||
beforeCount, afterCount)
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,14 @@ func (h *Handler) CreateChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "agent is archived")
|
||||
return
|
||||
}
|
||||
// Private-agent gate: members must be in allowed_principals to start
|
||||
// a chat with a private agent. Agent-to-agent chat sessions bypass
|
||||
// the gate so A2A collaboration still works.
|
||||
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
||||
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
|
||||
writeError(w, http.StatusForbidden, "you do not have access to this agent")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.Queries.CreateChatSession(r.Context(), db.CreateChatSessionParams{
|
||||
WorkspaceID: workspaceUUID,
|
||||
@@ -82,6 +90,23 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
|
||||
// Compute the accessible-agents set once and use it to drop sessions
|
||||
// whose target agent the caller no longer has access to — without this,
|
||||
// a member whose role was downgraded would still see the session list
|
||||
// (and transcripts via ListChatMessages) for any private agent they
|
||||
// previously had access to. Falls back to the user's role from the
|
||||
// workspace member context.
|
||||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
||||
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
|
||||
return
|
||||
}
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
|
||||
// Two call sites → two row types with identical shape. Collect into a
|
||||
@@ -96,9 +121,12 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
|
||||
return
|
||||
}
|
||||
resp = make([]ChatSessionResponse, len(rows))
|
||||
for i, s := range rows {
|
||||
resp[i] = ChatSessionResponse{
|
||||
resp = make([]ChatSessionResponse, 0, len(rows))
|
||||
for _, s := range rows {
|
||||
if _, ok := allowed[uuidToString(s.AgentID)]; !ok {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, ChatSessionResponse{
|
||||
ID: uuidToString(s.ID),
|
||||
WorkspaceID: uuidToString(s.WorkspaceID),
|
||||
AgentID: uuidToString(s.AgentID),
|
||||
@@ -108,7 +136,7 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
|
||||
HasUnread: s.HasUnread,
|
||||
CreatedAt: timestampToString(s.CreatedAt),
|
||||
UpdatedAt: timestampToString(s.UpdatedAt),
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
rows, err := h.Queries.ListChatSessionsByCreator(r.Context(), db.ListChatSessionsByCreatorParams{
|
||||
@@ -119,9 +147,12 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list chat sessions")
|
||||
return
|
||||
}
|
||||
resp = make([]ChatSessionResponse, len(rows))
|
||||
for i, s := range rows {
|
||||
resp[i] = ChatSessionResponse{
|
||||
resp = make([]ChatSessionResponse, 0, len(rows))
|
||||
for _, s := range rows {
|
||||
if _, ok := allowed[uuidToString(s.AgentID)]; !ok {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, ChatSessionResponse{
|
||||
ID: uuidToString(s.ID),
|
||||
WorkspaceID: uuidToString(s.WorkspaceID),
|
||||
AgentID: uuidToString(s.AgentID),
|
||||
@@ -131,7 +162,7 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
|
||||
HasUnread: s.HasUnread,
|
||||
CreatedAt: timestampToString(s.CreatedAt),
|
||||
UpdatedAt: timestampToString(s.UpdatedAt),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
@@ -161,6 +192,29 @@ func (h *Handler) loadChatSessionForUser(w http.ResponseWriter, r *http.Request,
|
||||
return session, true
|
||||
}
|
||||
|
||||
// gateChatSessionForUser combines the session ownership check with the
|
||||
// private-agent access gate so a member who has lost access to the target
|
||||
// agent (role downgrade, ownership transfer, agent flipped to private)
|
||||
// cannot continue reading the chat transcript even though they remain the
|
||||
// session creator. Returns ok=false after writing the error response.
|
||||
func (h *Handler) gateChatSessionForUser(w http.ResponseWriter, r *http.Request, userID, workspaceID, sessionID string) (db.ChatSession, bool) {
|
||||
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
if !ok {
|
||||
return db.ChatSession{}, false
|
||||
}
|
||||
agent, err := h.Queries.GetAgent(r.Context(), session.AgentID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "agent not found")
|
||||
return db.ChatSession{}, false
|
||||
}
|
||||
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
||||
if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) {
|
||||
writeError(w, http.StatusForbidden, "you do not have access to this agent")
|
||||
return db.ChatSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
@@ -169,7 +223,7 @@ func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
sessionID := chi.URLParam(r, "sessionId")
|
||||
|
||||
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -283,8 +337,12 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Load chat session.
|
||||
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
// Load chat session and re-check the private-agent gate on every send.
|
||||
// The session's creator passed the gate at create time, but their
|
||||
// workspace role (or the agent's owner) may have changed since — keep
|
||||
// stale sessions from being a back-door into a private agent the user
|
||||
// can no longer reach. Agent senders bypass to preserve A2A collaboration.
|
||||
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -356,7 +414,7 @@ func (h *Handler) ListChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
sessionID := chi.URLParam(r, "sessionId")
|
||||
|
||||
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -397,7 +455,7 @@ func (h *Handler) MarkChatSessionRead(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
sessionID := chi.URLParam(r, "sessionId")
|
||||
|
||||
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -428,7 +486,8 @@ type PendingChatTaskItem struct {
|
||||
|
||||
// ListPendingChatTasks returns every in-flight chat task owned by the current
|
||||
// user in this workspace. Drives the FAB's "running" indicator when the chat
|
||||
// window is closed (no per-session query is subscribed).
|
||||
// window is closed (no per-session query is subscribed). Tasks belonging to
|
||||
// private agents the caller has lost access to are dropped from the response.
|
||||
func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
@@ -436,6 +495,17 @@ func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
|
||||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
||||
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "failed to resolve agent access")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.Queries.ListPendingChatTasksByCreator(r.Context(), db.ListPendingChatTasksByCreatorParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
CreatorID: parseUUID(userID),
|
||||
@@ -445,13 +515,37 @@ func (h *Handler) ListPendingChatTasks(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]PendingChatTaskItem, len(rows))
|
||||
for i, row := range rows {
|
||||
items[i] = PendingChatTaskItem{
|
||||
// Map session → agent so we can filter without an N+1. The user's own
|
||||
// session list is small, so one extra query is cheaper than per-row
|
||||
// lookups.
|
||||
sessions, err := h.Queries.ListAllChatSessionsByCreator(r.Context(), db.ListAllChatSessionsByCreatorParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
CreatorID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to resolve chat session agents")
|
||||
return
|
||||
}
|
||||
sessionAgent := make(map[string]string, len(sessions))
|
||||
for _, s := range sessions {
|
||||
sessionAgent[uuidToString(s.ID)] = uuidToString(s.AgentID)
|
||||
}
|
||||
|
||||
items := make([]PendingChatTaskItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
sessionID := uuidToString(row.ChatSessionID)
|
||||
agentID, hasAgent := sessionAgent[sessionID]
|
||||
if !hasAgent {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[agentID]; !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, PendingChatTaskItem{
|
||||
TaskID: uuidToString(row.TaskID),
|
||||
Status: row.Status,
|
||||
ChatSessionID: uuidToString(row.ChatSessionID),
|
||||
}
|
||||
ChatSessionID: sessionID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, PendingChatTasksResponse{Tasks: items})
|
||||
}
|
||||
@@ -467,7 +561,7 @@ func (h *Handler) GetPendingChatTask(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
sessionID := chi.URLParam(r, "sessionId")
|
||||
|
||||
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -426,20 +426,23 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
|
||||
continue
|
||||
}
|
||||
agentUUID := parseUUID(m.ID)
|
||||
// Load the agent to check visibility, archive status, and trigger config.
|
||||
agent, err := h.Queries.GetAgent(ctx, agentUUID)
|
||||
// Load the agent scoped to the current issue's workspace. Using the
|
||||
// bare GetAgent here would let a mention resolve to an agent in a
|
||||
// different workspace, and the visibility check below would then be
|
||||
// applied against the wrong workspace's roles (a workspace owner in
|
||||
// THIS workspace would pass the gate for a private agent that lives
|
||||
// in someone else's workspace).
|
||||
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
|
||||
ID: agentUUID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
})
|
||||
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
||||
continue
|
||||
}
|
||||
// Private agents can only be mentioned by the agent owner or workspace admin/owner.
|
||||
if agent.Visibility == "private" && authorType == "member" {
|
||||
isOwner := uuidToString(agent.OwnerID) == authorID
|
||||
if !isOwner {
|
||||
member, err := h.getWorkspaceMember(ctx, authorID, wsID)
|
||||
if err != nil || !roleAllowed(member.Role, "owner", "admin") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Private-agent gate (member→private requires allowed_principals;
|
||||
// agent→agent always passes).
|
||||
if !h.canAccessPrivateAgent(ctx, agent, authorType, authorID, wsID) {
|
||||
continue
|
||||
}
|
||||
// Dedup: skip if this agent already has a pending task for this issue.
|
||||
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
||||
|
||||
@@ -235,15 +235,29 @@ func requestUserID(r *http.Request) string {
|
||||
}
|
||||
|
||||
// resolveActor determines whether the request is from an agent or a human member.
|
||||
// If X-Agent-ID and X-Task-ID headers are both set, validates that the task
|
||||
// belongs to the claimed agent (defense-in-depth against manual header spoofing).
|
||||
// If only X-Agent-ID is set, validates that the agent belongs to the workspace.
|
||||
// To claim "agent" identity the request MUST carry both X-Agent-ID and a valid
|
||||
// X-Task-ID, and the task must belong to the claimed agent. Otherwise we fall
|
||||
// back to "member" using the user ID from the session.
|
||||
//
|
||||
// X-Agent-ID alone is not trusted: any workspace member can guess or observe
|
||||
// an agent's UUID, and a member-supplied X-Agent-ID would otherwise let that
|
||||
// member impersonate the agent and bypass the private-agent gate (#2359
|
||||
// review). The daemon always pairs the two headers — X-Agent-ID names the
|
||||
// agent claiming the request, X-Task-ID names the in-flight task that
|
||||
// authorizes it — so requiring both has no effect on legitimate agent
|
||||
// callers but closes the impersonation path.
|
||||
//
|
||||
// Returns ("agent", agentID) on success, ("member", userID) otherwise.
|
||||
func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (actorType, actorID string) {
|
||||
agentID := r.Header.Get("X-Agent-ID")
|
||||
if agentID == "" {
|
||||
return "member", userID
|
||||
}
|
||||
taskID := r.Header.Get("X-Task-ID")
|
||||
if taskID == "" {
|
||||
slog.Debug("resolveActor: X-Agent-ID present but X-Task-ID missing, refusing to trust agent identity", "agent_id", agentID)
|
||||
return "member", userID
|
||||
}
|
||||
|
||||
agentUUID, err := util.ParseUUID(agentID)
|
||||
if err != nil {
|
||||
@@ -257,18 +271,15 @@ func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (act
|
||||
return "member", userID
|
||||
}
|
||||
|
||||
// When X-Task-ID is provided, cross-check that the task belongs to this agent.
|
||||
if taskID := r.Header.Get("X-Task-ID"); taskID != "" {
|
||||
taskUUID, err := util.ParseUUID(taskID)
|
||||
if err != nil {
|
||||
slog.Debug("resolveActor: X-Task-ID is not a valid UUID, falling back to member", "task_id", taskID)
|
||||
return "member", userID
|
||||
}
|
||||
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
||||
if err != nil || uuidToString(task.AgentID) != agentID {
|
||||
slog.Debug("resolveActor: X-Task-ID rejected, task not found or agent mismatch", "agent_id", agentID, "task_id", taskID)
|
||||
return "member", userID
|
||||
}
|
||||
taskUUID, err := util.ParseUUID(taskID)
|
||||
if err != nil {
|
||||
slog.Debug("resolveActor: X-Task-ID is not a valid UUID, falling back to member", "task_id", taskID)
|
||||
return "member", userID
|
||||
}
|
||||
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
||||
if err != nil || uuidToString(task.AgentID) != agentID {
|
||||
slog.Debug("resolveActor: X-Task-ID rejected, task not found or agent mismatch", "agent_id", agentID, "task_id", taskID)
|
||||
return "member", userID
|
||||
}
|
||||
|
||||
return "agent", agentID
|
||||
|
||||
@@ -198,6 +198,27 @@ func createHandlerTestAgent(t *testing.T, name string, mcpConfig []byte) string
|
||||
return agentID
|
||||
}
|
||||
|
||||
// createHandlerTestTaskForAgent seeds a queued agent_task_queue row for the
|
||||
// given agent and returns the task UUID. Used by tests that need to set
|
||||
// X-Task-ID alongside X-Agent-ID — resolveActor now requires the pair to be
|
||||
// present and consistent before granting "agent" actor identity.
|
||||
func createHandlerTestTaskForAgent(t *testing.T, agentID string) string {
|
||||
t.Helper()
|
||||
|
||||
var taskID string
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
|
||||
VALUES ($1, $2, 'queued', 0)
|
||||
RETURNING id
|
||||
`, agentID, handlerTestRuntimeID(t)).Scan(&taskID); err != nil {
|
||||
t.Fatalf("failed to create handler test task: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
||||
})
|
||||
return taskID
|
||||
}
|
||||
|
||||
func fetchAgentMcpConfig(t *testing.T, agentID string) []byte {
|
||||
t.Helper()
|
||||
|
||||
@@ -1817,14 +1838,18 @@ func TestResolveActor(t *testing.T) {
|
||||
wantActorType: "member",
|
||||
},
|
||||
{
|
||||
name: "valid agent ID returns agent",
|
||||
// X-Agent-ID without X-Task-ID is not trusted — otherwise a
|
||||
// workspace member who guesses an agent's UUID could impersonate
|
||||
// it and bypass the private-agent gate. See resolveActor for the
|
||||
// rationale.
|
||||
name: "agent ID without task ID returns member",
|
||||
agentIDHeader: agentID,
|
||||
wantActorType: "agent",
|
||||
wantIsAgent: true,
|
||||
wantActorType: "member",
|
||||
},
|
||||
{
|
||||
name: "non-existent agent ID returns member",
|
||||
name: "non-existent agent ID with task returns member",
|
||||
agentIDHeader: "00000000-0000-0000-0000-000000000099",
|
||||
taskIDHeader: taskID,
|
||||
wantActorType: "member",
|
||||
},
|
||||
{
|
||||
@@ -2088,10 +2113,13 @@ func TestAgentReplyDoesNotInheritParentMentions(t *testing.T) {
|
||||
|
||||
// 3. Agent A posts a reply in the same thread with NO mentions.
|
||||
// With the fix, this must NOT inherit the parent mention of Agent B.
|
||||
// resolveActor requires X-Task-ID paired with X-Agent-ID to trust the
|
||||
// agent identity, so we seed a task that belongs to agent A.
|
||||
agentATask := createHandlerTestTaskForAgent(t, agentA)
|
||||
w = postComment(issueID, map[string]any{
|
||||
"content": "No reply needed — just an acknowledgment.",
|
||||
"parent_id": parentComment.ID,
|
||||
}, map[string]string{"X-Agent-ID": agentA})
|
||||
}, map[string]string{"X-Agent-ID": agentA, "X-Task-ID": agentATask})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("agent A reply: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -2164,12 +2192,16 @@ func TestMemberReplyToAgentRootDoesNotInheritParentMentions(t *testing.T) {
|
||||
|
||||
// 1. Agent J posts a PR-completion comment that @mentions Reviewer for review.
|
||||
// This is a deliberate handoff and must enqueue a task for Reviewer.
|
||||
// X-Task-ID is required alongside X-Agent-ID for resolveActor to grant
|
||||
// the "agent" actor identity (defense against header forgery).
|
||||
jAgentTask := createHandlerTestTaskForAgent(t, jAgent)
|
||||
w = httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": fmt.Sprintf("PR ready. [@Reviewer](mention://agent/%s) please review this.", reviewerAgent),
|
||||
})
|
||||
r = withURLParam(r, "id", issueID)
|
||||
r.Header.Set("X-Agent-ID", jAgent)
|
||||
r.Header.Set("X-Task-ID", jAgentTask)
|
||||
testHandler.CreateComment(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("J PR completion: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -2255,7 +2287,10 @@ func TestAgentExplicitMentionStillTriggers(t *testing.T) {
|
||||
|
||||
// Agent A posts a top-level comment that explicitly @mentions Agent B —
|
||||
// a deliberate handoff. This must enqueue a task for Agent B, and must
|
||||
// not enqueue a self-trigger for Agent A.
|
||||
// not enqueue a self-trigger for Agent A. resolveActor requires
|
||||
// X-Task-ID to grant "agent" identity; without it the self-trigger
|
||||
// suppression (authorType=="agent") would not fire.
|
||||
agentATask := createHandlerTestTaskForAgent(t, agentA)
|
||||
explicitMention := fmt.Sprintf("[@Agent B](mention://agent/%s) please take it from here", agentB)
|
||||
w = httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
@@ -2263,6 +2298,7 @@ func TestAgentExplicitMentionStillTriggers(t *testing.T) {
|
||||
})
|
||||
r = withURLParam(r, "id", issueID)
|
||||
r.Header.Set("X-Agent-ID", agentA)
|
||||
r.Header.Set("X-Task-ID", agentATask)
|
||||
testHandler.CreateComment(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("agent A handoff: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
|
||||
@@ -1584,9 +1584,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// validateAssigneePair verifies the (assignee_type, assignee_id) pair refers
|
||||
// to an existing entity in the workspace. For agent assignees it also enforces
|
||||
// visibility (private agents are only assignable by their owner or by
|
||||
// workspace admins/owners) and rejects archived agents.
|
||||
// to an existing entity in the workspace. For agent assignees it also rejects
|
||||
// archived agents and runs the private-agent gate via canAccessPrivateAgent
|
||||
// — assigning an issue is a task-producing surface, so it must use the same
|
||||
// predicate as chat / @-mention / history. Agent callers (X-Agent-ID) bypass
|
||||
// the gate so A2A flows can still hand work off to private agents.
|
||||
//
|
||||
// Returns (statusCode, errorMessage). statusCode == 0 means the pair is valid;
|
||||
// callers should treat any non-zero status as a rejection and surface it back
|
||||
@@ -1624,14 +1626,9 @@ func (h *Handler) validateAssigneePair(ctx context.Context, r *http.Request, wor
|
||||
if agent.ArchivedAt.Valid {
|
||||
return http.StatusBadRequest, "cannot assign to archived agent"
|
||||
}
|
||||
if agent.Visibility == "private" {
|
||||
userID := requestUserID(r)
|
||||
if uuidToString(agent.OwnerID) != userID {
|
||||
member, err := h.getWorkspaceMember(ctx, userID, workspaceID)
|
||||
if err != nil || !roleAllowed(member.Role, "owner", "admin") {
|
||||
return http.StatusForbidden, "cannot assign to private agent"
|
||||
}
|
||||
}
|
||||
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
|
||||
if !h.canAccessPrivateAgent(ctx, agent, actorType, actorID, workspaceID) {
|
||||
return http.StatusForbidden, "cannot assign to private agent"
|
||||
}
|
||||
return 0, ""
|
||||
default:
|
||||
|
||||
@@ -236,10 +236,14 @@ func TestSubscriberAPI(t *testing.T) {
|
||||
|
||||
// Subscribe with X-Agent-ID set — no body, so the handler must default
|
||||
// to subscribing the agent itself (not the member behind X-User-ID).
|
||||
// resolveActor requires X-Task-ID alongside X-Agent-ID to grant the
|
||||
// "agent" identity (defense against header forgery), so seed a task.
|
||||
agentTask := createHandlerTestTaskForAgent(t, agentID)
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
req.Header.Set("X-Agent-ID", agentID)
|
||||
req.Header.Set("X-Task-ID", agentTask)
|
||||
testHandler.SubscribeToIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SubscribeToIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -270,10 +274,13 @@ func TestSubscriberAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
// Unsubscribe with X-Agent-ID set — same default-to-caller expectation.
|
||||
// Re-use the same task as the subscribe call; resolveActor only
|
||||
// validates that the task belongs to the agent, not which task.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
req.Header.Set("X-Agent-ID", agentID)
|
||||
req.Header.Set("X-Task-ID", agentTask)
|
||||
testHandler.UnsubscribeFromIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UnsubscribeFromIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
|
||||
@@ -383,6 +383,27 @@ func (s *AutopilotService) shouldSkipDispatch(ctx context.Context, ap db.Autopil
|
||||
if rt.Status != "online" {
|
||||
return "agent runtime is " + rt.Status + " at dispatch time", true
|
||||
}
|
||||
// Private-agent gate at the autopilot layer. Caller identity = the
|
||||
// autopilot's creator: if the creator no longer has access to the
|
||||
// (now-private) target agent, the dispatch is recorded as `skipped`.
|
||||
// Agent-created autopilots bypass the gate to preserve A2A
|
||||
// collaboration. Errors loading the workspace member fail closed —
|
||||
// without an authoritative role the gate cannot grant access.
|
||||
if agent.Visibility == "private" && ap.CreatedByType == "member" {
|
||||
creatorID := util.UUIDToString(ap.CreatedByID)
|
||||
if util.UUIDToString(agent.OwnerID) != creatorID {
|
||||
member, err := s.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: ap.CreatedByID,
|
||||
WorkspaceID: ap.WorkspaceID,
|
||||
})
|
||||
if err != nil {
|
||||
return "autopilot creator no longer in workspace", true
|
||||
}
|
||||
if member.Role != "owner" && member.Role != "admin" {
|
||||
return "autopilot creator lacks access to private assignee agent", true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user