Files
multica/apps
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
..