mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-17 23:29:01 +02:00
Opening the agent detail page in v0.3.37 crashed the whole route with "Cannot read properties of undefined (reading 'some')" whenever the cached agent record's `invocation_targets` was missing at runtime — even though the TypeScript type declares it a required `AgentInvocationTarget[]`. Root cause: `AccessPicker`'s `hasWorkspaceTarget` / `selectedMemberIds` / `selectedTeamIds` helpers called `.some()` / `.filter()` directly on the prop, and the same unguarded pattern was mirrored in `AgentMcpTab` and the `canAssignAgentToIssue` permission gate. The field can legitimately be undefined at runtime because: - `packages/core/api/schemas.ts` declares `invocation_targets` as `.optional()`, and `MinimalAgentSchema` (used for the create-from- template response) also marks it optional. - `api.listAgents` / `api.getAgent` return raw JSON without running through the schema, so an older self-host backend that predates MUL-3963 (permission_mode + invocation_targets) — the exact scenario in GH #4915 — yields an Agent with the field missing. Fix (`?? []`) at every site that indexes into the list, matching the already-established pattern in `create-agent-dialog.tsx`: - `access-picker.tsx`: coerce inside the three helpers and widen the prop type to `AgentInvocationTarget[] | undefined` so the accepted runtime shape is documented. - `agent-mcp-tab.tsx`: `(agent.invocation_targets ?? []).some(...)`. - `permissions/rules.ts`: `agent.invocation_targets ?? []` before the workspace / member membership checks. Adds three regression tests (AccessPicker owner + read-only paths, AgentMcpTab shared-warning path, canAssignAgentToIssue) that pass `undefined` for invocation_targets and assert the UI degrades to the private / empty-allowlist state instead of throwing. Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
245 lines
7.5 KiB
TypeScript
245 lines
7.5 KiB
TypeScript
import type {
|
|
Agent,
|
|
Comment,
|
|
Member,
|
|
MemberRole,
|
|
RuntimeDevice,
|
|
Skill,
|
|
} from "../types";
|
|
import { ALLOW, deny, type Decision, type PermissionContext } from "./types";
|
|
|
|
/**
|
|
* Pure permission rules — single source of truth that mirrors the Go backend
|
|
* gates in `server/internal/handler/`. Hooks in `use-resource-permissions.ts`
|
|
* are thin wrappers that pull `PermissionContext` from auth + member queries
|
|
* and forward to these.
|
|
*
|
|
* Returning a `Decision` (not a boolean) lets every surface — disabled state,
|
|
* tooltip, banner copy — read the same `reason` and stay consistent without
|
|
* sprinkling copy through the view layer.
|
|
*/
|
|
|
|
const isAdminLike = (role: MemberRole | null) =>
|
|
role === "owner" || role === "admin";
|
|
|
|
// ---- Agents ----------------------------------------------------------------
|
|
|
|
/**
|
|
* Update / archive / restore agent fields. The backend gates archive and
|
|
* restore identically to edit (`server/internal/handler/agent.go:519-535`),
|
|
* so callers can use `canEditAgent` for all three.
|
|
*/
|
|
export function canEditAgent(agent: Agent, ctx: PermissionContext): Decision {
|
|
if (ctx.userId === null) {
|
|
return deny("not_authenticated", "Sign in to edit this agent.");
|
|
}
|
|
if (isAdminLike(ctx.role)) return ALLOW;
|
|
if (agent.owner_id !== null && agent.owner_id === ctx.userId) return ALLOW;
|
|
return deny(
|
|
"not_resource_owner",
|
|
"Only the agent owner and workspace admins can edit this agent.",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Invoke an agent — assign it to an issue, @mention it, chat with it, or
|
|
* otherwise trigger a run. Mirrors the MUL-3963 backend invocation gate,
|
|
* which reads `permission_mode` + `invocation_targets` (NOT the derived
|
|
* `visibility` field):
|
|
*
|
|
* - owner: always
|
|
* - permission_mode "private": ONLY the owner. This is the key behavior
|
|
* change — workspace admins NO LONGER bypass a private agent.
|
|
* - permission_mode "public_to" + workspace target: any workspace member
|
|
* - permission_mode "public_to" + member target: only the matching user
|
|
* - team target: reserved, INERT in v1 (never grants)
|
|
*/
|
|
export function canAssignAgentToIssue(
|
|
agent: Agent,
|
|
ctx: PermissionContext,
|
|
): Decision {
|
|
if (ctx.userId === null) {
|
|
return deny("not_authenticated", "Sign in to assign agents.");
|
|
}
|
|
|
|
// The owner may always invoke their own agent, regardless of mode.
|
|
if (agent.owner_id !== null && agent.owner_id === ctx.userId) {
|
|
return ALLOW;
|
|
}
|
|
|
|
if (agent.permission_mode === "private") {
|
|
return deny(
|
|
"private_visibility",
|
|
"Personal agent — only the owner can assign work.",
|
|
);
|
|
}
|
|
|
|
// permission_mode === "public_to": resolve the invocation grants. A
|
|
// workspace grant opens invocation to any workspace member. The `?? []`
|
|
// guards against legacy self-host backends / stale caches that omit the
|
|
// field even though the type says required-array (GH #4915).
|
|
const targets = agent.invocation_targets ?? [];
|
|
if (targets.some((t) => t.target_type === "workspace")) {
|
|
if (ctx.role === null) {
|
|
return deny("not_member", "Join this workspace to assign agents.");
|
|
}
|
|
return ALLOW;
|
|
}
|
|
|
|
// A member grant opens invocation to exactly the targeted user. Team
|
|
// targets are reserved and INERT in v1 — they never grant.
|
|
if (
|
|
targets.some(
|
|
(t) => t.target_type === "member" && t.target_id === ctx.userId,
|
|
)
|
|
) {
|
|
return ALLOW;
|
|
}
|
|
|
|
return deny(
|
|
"private_visibility",
|
|
"Restricted agent — you don't have access to assign work to it.",
|
|
);
|
|
}
|
|
|
|
// ---- Skills ----------------------------------------------------------------
|
|
|
|
export function canEditSkill(skill: Skill, ctx: PermissionContext): Decision {
|
|
if (ctx.userId === null) {
|
|
return deny("not_authenticated", "Sign in to edit this skill.");
|
|
}
|
|
if (isAdminLike(ctx.role)) return ALLOW;
|
|
if (skill.created_by !== null && skill.created_by === ctx.userId) {
|
|
return ALLOW;
|
|
}
|
|
return deny(
|
|
"not_resource_owner",
|
|
"Only the creator and workspace admins can edit this skill.",
|
|
);
|
|
}
|
|
|
|
export function canDeleteSkill(skill: Skill, ctx: PermissionContext): Decision {
|
|
return canEditSkill(skill, ctx);
|
|
}
|
|
|
|
// ---- Comments --------------------------------------------------------------
|
|
|
|
export function canEditComment(
|
|
comment: Comment,
|
|
ctx: PermissionContext,
|
|
): Decision {
|
|
if (ctx.userId === null) {
|
|
return deny("not_authenticated", "Sign in to edit comments.");
|
|
}
|
|
// Only member-authored comments can be edited; agent-authored comments are
|
|
// immutable from any human's perspective.
|
|
if (comment.author_type !== "member") {
|
|
return deny(
|
|
"not_resource_owner",
|
|
"Agent-authored comments cannot be edited.",
|
|
);
|
|
}
|
|
if (comment.author_id === ctx.userId) return ALLOW;
|
|
if (isAdminLike(ctx.role)) return ALLOW;
|
|
return deny(
|
|
"not_resource_owner",
|
|
"Only the author and workspace admins can edit this comment.",
|
|
);
|
|
}
|
|
|
|
export function canDeleteComment(
|
|
comment: Comment,
|
|
ctx: PermissionContext,
|
|
): Decision {
|
|
if (ctx.userId === null) {
|
|
return deny("not_authenticated", "Sign in to delete comments.");
|
|
}
|
|
if (comment.author_type === "member" && comment.author_id === ctx.userId) {
|
|
return ALLOW;
|
|
}
|
|
if (isAdminLike(ctx.role)) return ALLOW;
|
|
return deny(
|
|
"not_resource_owner",
|
|
"Only the author and workspace admins can delete this comment.",
|
|
);
|
|
}
|
|
|
|
// ---- Runtimes --------------------------------------------------------------
|
|
|
|
export function canDeleteRuntime(
|
|
runtime: RuntimeDevice,
|
|
ctx: PermissionContext,
|
|
): Decision {
|
|
if (ctx.userId === null) {
|
|
return deny("not_authenticated", "Sign in to delete runtimes.");
|
|
}
|
|
if (isAdminLike(ctx.role)) return ALLOW;
|
|
if (runtime.owner_id !== null && runtime.owner_id === ctx.userId) {
|
|
return ALLOW;
|
|
}
|
|
return deny(
|
|
"not_resource_owner",
|
|
"Only the runtime owner and workspace admins can delete this runtime.",
|
|
);
|
|
}
|
|
|
|
// ---- Workspace -------------------------------------------------------------
|
|
|
|
export function canUpdateWorkspaceSettings(ctx: PermissionContext): Decision {
|
|
if (isAdminLike(ctx.role)) return ALLOW;
|
|
return deny(
|
|
"not_admin_role",
|
|
"Only workspace owners and admins can update workspace settings.",
|
|
);
|
|
}
|
|
|
|
export function canDeleteWorkspace(ctx: PermissionContext): Decision {
|
|
if (ctx.role === "owner") return ALLOW;
|
|
return deny(
|
|
"not_owner_role",
|
|
"Only the workspace owner can delete this workspace.",
|
|
);
|
|
}
|
|
|
|
export function canManageMembers(ctx: PermissionContext): Decision {
|
|
if (isAdminLike(ctx.role)) return ALLOW;
|
|
return deny(
|
|
"not_admin_role",
|
|
"Only workspace owners and admins can manage members.",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Encodes the role-change matrix from `workspace.go:458-530`:
|
|
* - admins cannot touch the owner role (neither demote owners nor promote)
|
|
* - the last owner cannot be demoted
|
|
* - non-managers cannot change roles at all
|
|
*
|
|
* `ownerCount` is the number of workspace members currently with role=owner.
|
|
* Caller derives it locally from the cached member list.
|
|
*/
|
|
export function canChangeMemberRole(
|
|
target: Pick<Member, "role">,
|
|
ownerCount: number,
|
|
ctx: PermissionContext,
|
|
): Decision {
|
|
const manage = canManageMembers(ctx);
|
|
if (!manage.allowed) return manage;
|
|
|
|
if (target.role === "owner") {
|
|
if (ctx.role !== "owner") {
|
|
return deny(
|
|
"not_owner_role",
|
|
"Only the workspace owner can change another owner's role.",
|
|
);
|
|
}
|
|
if (ownerCount <= 1) {
|
|
return deny(
|
|
"last_owner",
|
|
"Promote another member to owner first — a workspace must keep at least one owner.",
|
|
);
|
|
}
|
|
}
|
|
return ALLOW;
|
|
}
|