Files
multica/server/internal/handler/agent_access_test.go
T
c2474e2fe1 fix(agents): align private-agent invoke surfaces with the owner-only contract (MUL-6380) (#7202)
* fix(mobile): gate the chat agent picker on the real invoke rule (MUL-6380)

apps/mobile/lib/can-assign-agent.ts was a hand-copied mirror of the
pre-MUL-3963 permission model and had drifted from the server in both
directions:

- it granted workspace owner/admin a bypass on private agents, so an admin
  could pick a teammate's personal agent in the chat picker and only learn
  it was refused when the send hit canInvokeAgent (403);
- it judged on the derived `visibility` field, which collapses "public_to
  scoped to specific people" into "private" — so a member the owner had
  explicitly granted access to never saw that agent on mobile at all.

Delete the mirror and call `canAssignAgentToIssue` from
@multica/core/permissions directly, the same rule the issue mention bar on
this app already uses (mention-suggestion-bar.tsx). The root CLAUDE.md
sharing rule allows mobile to import pure functions from @multica/core, so
the mirror bought nothing but a second copy to drift.

No new tests: the access matrix stays canonical in
packages/core/permissions/rules.test.ts, which already covers the admin,
owner, plain-member and member-target cases.

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

* fix(agents): stop telling users workspace admins can run a personal agent (MUL-6380)

Since MUL-3963 the invoke gate is owner-only for a private agent — admins
keep management and view access, not the ability to trigger a run. Several
strings still describe the old model, and they sit next to controls that
enforce the new one: the "Personal" badge tooltip on the agent detail page,
overview summary and hover card says "only you and workspace admins can use
this agent", while the assignee picker beside it is disabled and reads "only
the owner can assign work".

- packages/core/agents/visibility-label.ts: fix VISIBILITY_DESCRIPTION and
  VISIBILITY_TOOLTIP (shown in the create-agent dialog, the visibility picker
  and the agents-list lock tooltip). The description covers a CHOICE, which
  submits permission_mode "private" — owner-only, so "Only you can assign".
  The tooltip covers an EXISTING agent, where `visibility: "private"` is the
  lossy projection that also covers "shared with specific people" — so it
  names the owner's grants instead of promising either extreme. The doc
  comments asserted the opposite contract and cited a long-stale line ref.
- packages/views/locales/{en,zh-Hans,ja,ko}/agents.json: same fix for the
  VisibilityBadge tooltip, which is also its aria-label.

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

* fix(issues): stop naming the target's permission mode when an assign is denied (MUL-6380)

validateAssigneePair answered a refused assignment with "cannot assign to
private agent" / "cannot assign to squad with private leader". Both are
wrong twice over:

- they disclose the target's permission mode to a caller who was just told
  they may not use it, which is exactly what ReasonInvocationNotAllowed
  (dispatch/reason.go) deliberately avoids — it does not distinguish "not
  permitted" from "does not exist";
- "private agent" is simply inaccurate for a `public_to` agent scoped to
  specific people, which is refused through the same branch.

Report the missing permission instead of the target's configuration.

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

* fix(issues): correct the authorization contract stated at the assignment gate (MUL-6380)

Review found the drift this PR set out to fix still present in the comments and
test docs closest to the gate itself:

- validateAssigneePair's doc comment claimed assignment runs
  canAccessPrivateAgent and that agent callers bypass the gate. It runs
  canInvokeAgent, and an agent caller is judged by the top-of-chain human
  originator like anyone else — the code twenty lines below has said so since
  MUL-3963.
- TestShouldEnqueueOnComment_PrivateAgentGate's doc listed "allow workspace
  owners/admins" and "allow agent-to-agent traffic regardless of visibility";
  its own cases assert both are DENIED.
- TestCreateIssue_SquadPrivateLeader_OwnerAllowed's doc said a workspace owner
  can assign; the body uses the leader agent's owner, and an inline comment
  already corrected the mismatch.
- The comment added earlier in this PR overclaimed: it read as if the 403 were
  enumeration-safe, but the not-in-workspace branch still answers 400. Narrowed
  to the guarantee actually made — the reason no longer names the permission
  mode.

Also pins the denial BODY, which no test covered: status-only assertions let
the reason sentence drift back into describing the target. New cases cover the
workspace owner, a plain member, an agent actor with no originator, a
`public_to` agent scoped to someone else (the case the old wording called
"private", which was simply false), and a private-leader squad.

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

* fix(chat): make a session read-only when its agent can no longer be run (MUL-6380)

Filtering the agent PICKER was only half the path. A chat session outlives the
permission that created it: the agent can be flipped to personal, change owner,
or drop the member from its allow-list. The server already treats that as a
defined state — the transcript stays readable through the view gate while every
send is refused by the invoke gate (MUL-4525,
admission_security_mul4525_test.go) — but the clients kept resolving the open
session's agent from the full list and never re-checked whether it could still
be run. The composer looked live, and the user found out from a 403.

Judge the SESSION's agent, not just the picker list, on every surface:

- packages/views: `isAgentAccessRevoked` in use-chat-controller.ts and
  chat-window.tsx, wired into the composer disable, upload affordance, quick
  actions, queued send-now, and a new AgentAccessRevokedBanner. handleSend keeps
  a belt-and-braces guard so it never spends a roundtrip on a certain refusal.
- apps/mobile: same derivation in the chat tab, plus the failure path — a
  thrown send only restored the draft, so the 403 read as a silent no-op. It now
  names the cause, via a mobile-local `dispatchReasonCode` (core's narrows on
  core's ApiError class, which mobile never throws).

Two reason-precedence fixes fall out of this. "No agents in this workspace" was
shown when the workspace's ONLY agent was the revoked one — an agent IS bound,
it just cannot be run — so revoked access now wins in the placeholder, the
banner and mobile's disabled reason. And the queue's Steer button said "Steer is
available after the current reply starts", telling the user to wait for
something waiting cannot fix; it now states the permission reason.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 16:33:48 +08:00

600 lines
23 KiB
Go

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"
)
// TestMemberAllowedToViewAgent_Pure exercises the pure predicate that drives
// the private-agent VIEW gate. For a private agent it 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 TestMemberAllowedToViewAgent_Pure(t *testing.T) {
ownerUserID := "11111111-1111-1111-1111-111111111111"
otherUserID := "22222222-2222-2222-2222-222222222222"
agent := db.Agent{
OwnerID: util.MustParseUUID(ownerUserID),
PermissionMode: "private",
}
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 := memberAllowedToViewAgent(agent, nil, tc.userID, tc.role)
if got != tc.want {
t.Fatalf("memberAllowedToViewAgent(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', '[email protected]')
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 = '[email protected]'`)
})
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', '[email protected]')
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 = '[email protected]'`)
})
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) who is NOT the agent owner: DENIED under
// the invocation-permission model (MUL-3963) — admin/owner status no
// longer grants the ability to invoke someone else's private agent.
w := httptest.NewRecorder()
testHandler.CreateIssue(w, newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, body(testUserID)))
if w.Code != http.StatusForbidden {
t.Fatalf("CreateIssue as workspace owner (not agent owner): expected 403, 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', '[email protected]')
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 = '[email protected]'`)
})
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)
}
enqueueMentionedAgentTasksForTest(t, 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)
}
}
// TestShouldEnqueueOnComment_PrivateAgentGate is the regression test for
// GH #3300: after an owner/admin assigns a private agent to an issue, the
// agent's UUID is "welded" onto that issue and any member with comment
// access could previously dispatch a new task to the private agent simply by
// posting a plain (non-@mention) comment, bypassing the visibility gate that
// #2359 added to chat / @mention / assignment.
//
// The gate must (as tightened by MUL-3963 — the cases below are the source of
// truth, this list only summarises them):
// - reject plain workspace members (not owner, not admin, not agent owner)
// - allow the agent owner
// - reject workspace owners/admins: management access is not invoke access
// - reject an agent actor with no owner / allow-listed human originator —
// A2A is judged by the top of the chain, never by the immediate agent
func TestShouldEnqueueOnComment_PrivateAgentGate(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, ownerID, memberID := privateAgentTestFixture(t)
// Assign the private agent to a fresh issue. Owner/admin would normally
// be the one performing this step; we insert directly so the test
// focuses on the on_comment trigger path.
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id,
assignee_type, assignee_id, number)
VALUES ($1, 'on_comment private-agent gate test', 'todo', 'medium', 'member', $2,
'agent', $3,
COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1)
RETURNING id
`, testWorkspaceID, testUserID, agentID).Scan(&issueID); err != nil {
t.Fatalf("create issue assigned to private agent: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(issueID))
if err != nil {
t.Fatalf("load issue: %v", err)
}
cases := []struct {
name string
actorType string
actorID string
want bool
reason string
}{
{
name: "plain member — denied",
actorType: "member",
actorID: memberID,
want: false,
reason: "GH #3300: plain members must not be able to dispatch a task to a private agent via on_comment",
},
{
name: "agent owner — allowed",
actorType: "member",
actorID: ownerID,
want: true,
reason: "agent owner is always in the allowed_principals set",
},
{
name: "workspace owner — denied (not agent owner)",
actorType: "member",
actorID: testUserID,
want: false,
reason: "MUL-3963: workspace owners/admins no longer bypass a private agent's invocation gate",
},
{
name: "agent-to-agent — denied without allowed originator",
actorType: "agent",
actorID: agentID,
want: false,
reason: "MUL-3963: A2A is judged by the top-of-chain originator; a private agent denies an agent actor with no owner/allow-listed originator",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := testHandler.shouldEnqueueAssigneeFallback(ctx, issue, tc.actorType, tc.actorID, commentTriggerComputeOptions{})
if got != tc.want {
t.Fatalf("%s\n actor=%s/%s got=%v want=%v",
tc.reason, tc.actorType, tc.actorID, got, tc.want)
}
})
}
}