From f9d685344d898b7a7bf3b1644ae86b320cb6ce98 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 19 Mar 2026 08:14:29 -0500 Subject: [PATCH] Expose agent task assignment permissions Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 3 + packages/shared/src/types/agent.ts | 23 +++++ packages/shared/src/types/index.ts | 3 + packages/shared/src/validators/agent.ts | 1 + server/src/routes/access.ts | 8 ++ server/src/routes/agents.ts | 114 +++++++++++++++++++-- server/src/services/access.ts | 82 +++++++++++++++ server/src/services/company-portability.ts | 9 ++ ui/src/api/agents.ts | 14 ++- ui/src/pages/AgentDetail.tsx | 80 ++++++++++++--- 10 files changed, 310 insertions(+), 27 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 665b47c4..98850f9e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -123,6 +123,9 @@ export type { InstanceExperimentalSettings, InstanceSettings, Agent, + AgentAccessState, + AgentChainOfCommandEntry, + AgentDetail, AgentPermissions, AgentKeyCreated, AgentConfigRevision, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index dd1ae45f..550e34aa 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -4,11 +4,29 @@ import type { AgentRole, AgentStatus, } from "../constants.js"; +import type { + CompanyMembership, + PrincipalPermissionGrant, +} from "./access.js"; export interface AgentPermissions { canCreateAgents: boolean; } +export interface AgentAccessState { + canAssignTasks: boolean; + taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none"; + membership: CompanyMembership | null; + grants: PrincipalPermissionGrant[]; +} + +export interface AgentChainOfCommandEntry { + id: string; + name: string; + role: AgentRole; + title: string | null; +} + export interface Agent { id: string; companyId: string; @@ -34,6 +52,11 @@ export interface Agent { updatedAt: Date; } +export interface AgentDetail extends Agent { + chainOfCommand: AgentChainOfCommandEntry[]; + access: AgentAccessState; +} + export interface AgentKeyCreated { id: string; name: string; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 01b20b74..f573db4d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -2,6 +2,9 @@ export type { Company } from "./company.js"; export type { InstanceExperimentalSettings, InstanceSettings } from "./instance.js"; export type { Agent, + AgentAccessState, + AgentChainOfCommandEntry, + AgentDetail, AgentPermissions, AgentKeyCreated, AgentConfigRevision, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index f703f036..107551be 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -100,6 +100,7 @@ export type TestAdapterEnvironment = z.infer; diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index a966d12b..3e29bb47 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2450,6 +2450,14 @@ export function accessRoutes( "member", "active" ); + await access.setPrincipalPermission( + companyId, + "agent", + created.id, + "tasks:assign", + true, + req.actor.userId ?? null + ); const grants = grantsFromDefaults( invite.defaultsPayload as Record | null, "agent" diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index db91ad8c..2c6b12b4 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -71,6 +71,80 @@ export function agentRoutes(db: Db) { return Boolean((agent.permissions as Record).canCreateAgents); } + async function buildAgentAccessState(agent: NonNullable>>) { + const membership = await access.getMembership(agent.companyId, "agent", agent.id); + const grants = membership + ? await access.listPrincipalGrants(agent.companyId, "agent", agent.id) + : []; + const hasExplicitTaskAssignGrant = grants.some((grant) => grant.permissionKey === "tasks:assign"); + + if (agent.role === "ceo") { + return { + canAssignTasks: true, + taskAssignSource: "ceo_role" as const, + membership, + grants, + }; + } + + if (canCreateAgents(agent)) { + return { + canAssignTasks: true, + taskAssignSource: "agent_creator" as const, + membership, + grants, + }; + } + + if (hasExplicitTaskAssignGrant) { + return { + canAssignTasks: true, + taskAssignSource: "explicit_grant" as const, + membership, + grants, + }; + } + + return { + canAssignTasks: false, + taskAssignSource: "none" as const, + membership, + grants, + }; + } + + async function buildAgentDetail( + agent: NonNullable>>, + options?: { restricted?: boolean }, + ) { + const [chainOfCommand, accessState] = await Promise.all([ + svc.getChainOfCommand(agent.id), + buildAgentAccessState(agent), + ]); + + return { + ...(options?.restricted ? redactForRestrictedAgentView(agent) : agent), + chainOfCommand, + access: accessState, + }; + } + + async function applyDefaultAgentTaskAssignGrant( + companyId: string, + agentId: string, + grantedByUserId: string | null, + ) { + await access.ensureMembership(companyId, "agent", agentId, "member", "active"); + await access.setPrincipalPermission( + companyId, + "agent", + agentId, + "tasks:assign", + true, + grantedByUserId, + ); + } + async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { @@ -575,8 +649,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } - const chainOfCommand = await svc.getChainOfCommand(agent.id); - res.json({ ...agent, chainOfCommand }); + res.json(await buildAgentDetail(agent)); }); router.get("/agents/me/inbox-lite", async (req, res) => { @@ -618,13 +691,11 @@ export function agentRoutes(db: Db) { if (req.actor.type === "agent" && req.actor.agentId !== id) { const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId); if (!canRead) { - const chainOfCommand = await svc.getChainOfCommand(agent.id); - res.json({ ...redactForRestrictedAgentView(agent), chainOfCommand }); + res.json(await buildAgentDetail(agent, { restricted: true })); return; } } - const chainOfCommand = await svc.getChainOfCommand(agent.id); - res.json({ ...agent, chainOfCommand }); + res.json(await buildAgentDetail(agent)); }); router.get("/agents/:id/configuration", async (req, res) => { @@ -884,6 +955,12 @@ export function agentRoutes(db: Db) { }, }); + await applyDefaultAgentTaskAssignGrant( + companyId, + agent.id, + actor.actorType === "user" ? actor.actorId : null, + ); + if (approval) { await logActivity(db, { companyId, @@ -945,6 +1022,12 @@ export function agentRoutes(db: Db) { details: { name: agent.name, role: agent.role }, }); + await applyDefaultAgentTaskAssignGrant( + companyId, + agent.id, + req.actor.type === "board" ? (req.actor.userId ?? null) : null, + ); + if (agent.budgetMonthlyCents > 0) { await budgets.upsertPolicy( companyId, @@ -988,6 +1071,18 @@ export function agentRoutes(db: Db) { return; } + const effectiveCanAssignTasks = + agent.role === "ceo" || Boolean(agent.permissions?.canCreateAgents) || req.body.canAssignTasks; + await access.ensureMembership(agent.companyId, "agent", agent.id, "member", "active"); + await access.setPrincipalPermission( + agent.companyId, + "agent", + agent.id, + "tasks:assign", + effectiveCanAssignTasks, + req.actor.type === "board" ? (req.actor.userId ?? null) : null, + ); + const actor = getActorInfo(req); await logActivity(db, { companyId: agent.companyId, @@ -998,10 +1093,13 @@ export function agentRoutes(db: Db) { action: "agent.permissions_updated", entityType: "agent", entityId: agent.id, - details: req.body, + details: { + canCreateAgents: agent.permissions?.canCreateAgents ?? false, + canAssignTasks: effectiveCanAssignTasks, + }, }); - res.json(agent); + res.json(await buildAgentDetail(agent)); }); router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => { diff --git a/server/src/services/access.ts b/server/src/services/access.ts index 9ec0387d..e02b36d7 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -251,6 +251,86 @@ export function accessService(db: Db) { }); } + async function listPrincipalGrants( + companyId: string, + principalType: PrincipalType, + principalId: string, + ) { + return db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, principalType), + eq(principalPermissionGrants.principalId, principalId), + ), + ) + .orderBy(principalPermissionGrants.permissionKey); + } + + async function setPrincipalPermission( + companyId: string, + principalType: PrincipalType, + principalId: string, + permissionKey: PermissionKey, + enabled: boolean, + grantedByUserId: string | null, + scope: Record | null = null, + ) { + if (!enabled) { + await db + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, principalType), + eq(principalPermissionGrants.principalId, principalId), + eq(principalPermissionGrants.permissionKey, permissionKey), + ), + ); + return; + } + + await ensureMembership(companyId, principalType, principalId, "member", "active"); + + const existing = await db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, principalType), + eq(principalPermissionGrants.principalId, principalId), + eq(principalPermissionGrants.permissionKey, permissionKey), + ), + ) + .then((rows) => rows[0] ?? null); + + if (existing) { + await db + .update(principalPermissionGrants) + .set({ + scope, + grantedByUserId, + updatedAt: new Date(), + }) + .where(eq(principalPermissionGrants.id, existing.id)); + return; + } + + await db.insert(principalPermissionGrants).values({ + companyId, + principalType, + principalId, + permissionKey, + scope, + grantedByUserId, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + return { isInstanceAdmin, canUser, @@ -264,5 +344,7 @@ export function accessService(db: Db) { listUserCompanyAccess, setUserCompanyAccess, setPrincipalGrants, + listPrincipalGrants, + setPrincipalPermission, }; } diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index f067e957..7afdb381 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -955,6 +955,15 @@ export function companyPortabilityService(db: Db) { } const created = await agents.create(targetCompany.id, patch); + await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); + await access.setPrincipalPermission( + targetCompany.id, + "agent", + created.id, + "tasks:assign", + true, + actorUserId ?? null, + ); importedSlugToAgentId.set(planAgent.slug, created.id); existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id); resultAgents.push({ diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 9008fbca..1f4a642e 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,5 +1,6 @@ import type { Agent, + AgentDetail, AdapterEnvironmentTestResult, AgentKeyCreated, AgentRuntimeState, @@ -45,6 +46,11 @@ export interface AgentHireResponse { approval: Approval | null; } +export interface AgentPermissionUpdate { + canCreateAgents: boolean; + canAssignTasks: boolean; +} + function withCompanyScope(path: string, companyId?: string) { if (!companyId) return path; const separator = path.includes("?") ? "&" : "?"; @@ -62,7 +68,7 @@ export const agentsApi = { api.get[]>(`/companies/${companyId}/agent-configurations`), get: async (id: string, companyId?: string) => { try { - return await api.get(agentPath(id, companyId)); + return await api.get(agentPath(id, companyId)); } catch (error) { // Backward-compat fallback: if backend shortname lookup reports ambiguity, // resolve using company agent list while ignoring terminated agents. @@ -83,7 +89,7 @@ export const agentsApi = { (agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey, ); if (matches.length !== 1) throw error; - return api.get(agentPath(matches[0]!.id, companyId)); + return api.get(agentPath(matches[0]!.id, companyId)); } }, getConfiguration: (id: string, companyId?: string) => @@ -100,8 +106,8 @@ export const agentsApi = { api.post(`/companies/${companyId}/agent-hires`, data), update: (id: string, data: Record, companyId?: string) => api.patch(agentPath(id, companyId), data), - updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) => - api.patch(agentPath(id, companyId, "/permissions"), data), + updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) => + api.patch(agentPath(id, companyId, "/permissions"), data), pause: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/pause"), {}), resume: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/resume"), {}), terminate: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/terminate"), {}), diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index f11be75e..9c2a66a3 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,7 +1,13 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { agentsApi, type AgentKey, type ClaudeLoginResult, type AvailableSkill } from "../api/agents"; +import { + agentsApi, + type AgentKey, + type ClaudeLoginResult, + type AvailableSkill, + type AgentPermissionUpdate, +} from "../api/agents"; import { budgetsApi } from "../api/budgets"; import { heartbeatsApi } from "../api/heartbeats"; import { ApiError } from "../api/client"; @@ -64,6 +70,7 @@ import { RunTranscriptView, type TranscriptMode } from "../components/transcript import { isUuidLike, type Agent, + type AgentDetail as AgentDetailRecord, type BudgetPolicySummary, type HeartbeatRun, type HeartbeatRunEvent, @@ -486,7 +493,7 @@ export function AgentDetail() { const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []); const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []); - const { data: agent, isLoading, error } = useQuery({ + const { data: agent, isLoading, error } = useQuery({ queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null], queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId), enabled: canFetchAgent, @@ -672,8 +679,8 @@ export function AgentDetail() { }); const updatePermissions = useMutation({ - mutationFn: (canCreateAgents: boolean) => - agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, resolvedCompanyId ?? undefined), + mutationFn: (permissions: AgentPermissionUpdate) => + agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined), onSuccess: () => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); @@ -1076,7 +1083,7 @@ function AgentOverview({ agentId, agentRouteId, }: { - agent: Agent; + agent: AgentDetailRecord; runs: HeartbeatRun[]; assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[]; runtimeState?: AgentRuntimeState; @@ -1233,14 +1240,14 @@ function AgentConfigurePage({ onSavingChange, updatePermissions, }: { - agent: Agent; + agent: AgentDetailRecord; agentId: string; companyId?: string; onDirtyChange: (dirty: boolean) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; - updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; + updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; }) { const queryClient = useQueryClient(); const [revisionsOpen, setRevisionsOpen] = useState(false); @@ -1340,13 +1347,13 @@ function ConfigurationTab({ onSavingChange, updatePermissions, }: { - agent: Agent; + agent: AgentDetailRecord; companyId?: string; onDirtyChange: (dirty: boolean) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; - updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; + updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; }) { const queryClient = useQueryClient(); const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); @@ -1389,6 +1396,19 @@ function ConfigurationTab({ onSavingChange(isConfigSaving); }, [onSavingChange, isConfigSaving]); + const canCreateAgents = Boolean(agent.permissions?.canCreateAgents); + const canAssignTasks = Boolean(agent.access?.canAssignTasks); + const taskAssignSource = agent.access?.taskAssignSource ?? "none"; + const taskAssignLocked = agent.role === "ceo" || canCreateAgents; + const taskAssignHint = + taskAssignSource === "ceo_role" + ? "Enabled automatically for CEO agents." + : taskAssignSource === "agent_creator" + ? "Enabled automatically while this agent can create new agents." + : taskAssignSource === "explicit_grant" + ? "Enabled via explicit company permission grant." + : "Disabled unless explicitly granted."; + return (

Permissions

-
-
- Can create new agents +
+
+
+
Can create new agents
+

+ Lets this agent create or hire agents and implicitly assign tasks. +

+
+
+
+
+
Can assign tasks
+

+ {taskAssignHint} +

+
+