From dd499b88bab44e19ae6a59be0b705db2b528900f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 3 Oct 2026 00:29:15 -0700 Subject: [PATCH] feat(web): select roles when creating a workbench/agent (CL-9780) Both create surfaces offer the tenant's live platform roles as checkboxes. Selections travel as role names into NewAgentInput and CreateWorkbenchInput, resolve to per-tenant ids against live roles (fail fast on unknown names), and assign to the agent's workflow principal via stock role routes once the install lands. Reads gate the submit so a failed roles fetch never silent-fallbacks; a fresh agent with no first run yet fails closed with its deploy in place. --- apps/web/src/agent-deploy.ts | 35 ++ apps/web/src/agent-roles-query.ts | 21 ++ apps/web/src/agent-roles.test.ts | 334 ++++++++++++++++++++ apps/web/src/agent-roles.ts | 215 +++++++++++++ apps/web/src/pages/create-agent-panel.tsx | 60 +++- apps/web/src/pages/new-workbench-picker.tsx | 72 ++++- apps/web/src/pages/new-workbench.css | 23 ++ apps/web/src/workbench-create.ts | 35 +- 8 files changed, 788 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/agent-roles-query.ts create mode 100644 apps/web/src/agent-roles.test.ts create mode 100644 apps/web/src/agent-roles.ts diff --git a/apps/web/src/agent-deploy.ts b/apps/web/src/agent-deploy.ts index b90f04588..045736ffe 100644 --- a/apps/web/src/agent-deploy.ts +++ b/apps/web/src/agent-deploy.ts @@ -8,6 +8,7 @@ import type { ToolEffect } from "@corbits/worker/definition-json"; import type { McpServerDeployment } from "@corbits/worker/workflow-ids"; import { ensureAgentHubCredential } from "./agent-hub-credential"; +import { assignRolesToAgent, resolveAgentRoleIds } from "./agent-roles"; import { personMailAddress } from "./mail-address"; import { listMcpServers, resolveWorkspaceTenantId, toMcpServerDeployment } from "./mcp-servers"; import type { McpServer } from "./mcp-servers"; @@ -74,6 +75,11 @@ export type NewAgentInput = { /** Tool permissions to re-declare on a redeploy, as creator-sourced grant * requirements. */ readonly toolEffects?: readonly ToolEffect[]; + /** Role names checked in the create form, resolved against this tenant's + * live roles and assigned to the agent's workflow principal once the + * install lands. An unknown name fails the deploy before anything + * installs; a missing principal (no first run yet) fails it after. */ + readonly roles?: readonly string[]; /** Deploy anew even when nothing changed (a restart mints a fresh run). */ readonly redeploy?: boolean; }; @@ -183,6 +189,16 @@ export async function deployAgentSource( if (!isValidSlug(slug)) { throw new AgentDeployError("this name doesn't produce a usable agent address"); } + // Fail fast on an unknown role name: resolving before anything installs + // leaves no half-deployed agent behind. An empty selection skips the read. + let roleIds: readonly string[] = []; + try { + roleIds = await resolveAgentRoleIds(args.tenantId, args.input.roles ?? [], fetchImpl); + } catch (cause) { + throw new AgentDeployError( + cause instanceof Error ? cause.message : "resolving this agent's roles failed", + ); + } const assetName = agentDeploySourceAssetName(slug); const packageName = `@workbench-agent/${slug}`; @@ -247,5 +263,24 @@ export async function deployAgentSource( if (args.input.schedule !== undefined) { await scheduleAgentRun(args.tenantId, args.input.schedule, assetName, tenant.domain, fetchImpl); } + // The install already landed, so a missing principal (no first run yet) + // or a refused write fails the deploy with the agent in place — the panel + // stays open with this message, and resubmitting assigns the roles. + if (roleIds.length > 0) { + try { + await assignRolesToAgent( + { + tenantId: args.tenantId, + roleIds, + principalRef: { deploymentId: parsed.deploymentId, address: triggerAddress }, + }, + fetchImpl, + ); + } catch (cause) { + throw new AgentDeployError( + cause instanceof Error ? cause.message : "assigning this agent's roles failed", + ); + } + } return parsed; } diff --git a/apps/web/src/agent-roles-query.ts b/apps/web/src/agent-roles-query.ts new file mode 100644 index 000000000..5fe851d7e --- /dev/null +++ b/apps/web/src/agent-roles-query.ts @@ -0,0 +1,21 @@ +// The roles a create form can offer: the tenant's live platform roles, +// read through react-query so the agent and workbench create surfaces share +// one loading/error/ready contract. +import { useQuery } from "@tanstack/react-query"; + +import { toAPIQuery, type APIQuery } from "@/lib/api-query"; + +import { listTenantRoles, type RoleOption } from "./agent-roles"; + +function tenantRolesKey(tenantId: string | null) { + return ["tenant", tenantId ?? "none", "roles"] as const; +} + +export function useTenantRoles(tenantId: string | null): APIQuery { + const result = useQuery({ + queryKey: tenantRolesKey(tenantId), + enabled: tenantId !== null, + queryFn: () => listTenantRoles(tenantId as string), + }); + return toAPIQuery(result); +} diff --git a/apps/web/src/agent-roles.test.ts b/apps/web/src/agent-roles.test.ts new file mode 100644 index 000000000..34aba5adb --- /dev/null +++ b/apps/web/src/agent-roles.test.ts @@ -0,0 +1,334 @@ +// Fail-closed role assignment for created agents, over a stubbed stock API: +// names resolve against live roles, principals match run-anchored workflow +// rows, and every refusal aborts with nothing half-applied. +import { describe, expect, test } from "bun:test"; + +import { UnauthenticatedError } from "./lib/api-query"; +import { + AgentRolesError, + assignAgentRoles, + assignRolesToAgent, + listTenantRoles, + resolveAgentPrincipalId, + resolveAgentRoleIds, +} from "./agent-roles"; + +type Route = { + readonly method: string; + readonly path: string; + readonly status: number; + readonly body?: unknown; +}; + +function stubFetch(routes: readonly Route[], calls: string[]): typeof fetch { + return (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method ?? "GET").toUpperCase(); + calls.push(`${method} ${url}`); + const route = routes.find((candidate) => candidate.method === method && candidate.path === url); + if (route === undefined) return new Response("no such route", { status: 404 }); + const body = route.body === undefined ? "" : JSON.stringify(route.body); + return new Response(body, { + status: route.status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +} + +const TENANT = "tenant-1"; + +function roleRow(id: string, name: string, isSystem = false) { + return { + id, + tenantId: TENANT, + name, + description: null, + isSystem, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function rolesPage(names: readonly string[]) { + return { + data: names.map((name, index) => roleRow(`role-${index}`, name, name !== "custom")), + nextCursor: null, + }; +} + +function principalRow(id: string, refId: string, displayName: string, status = "active") { + return { id, kind: "workflow", refId, displayName, status }; +} + +function principalsPage( + rows: readonly ReturnType[], + nextCursor: string | null = null, +) { + return { data: rows, nextCursor }; +} + +const PRINCIPALS_PATH = `/api/tenants/${TENANT}/principals?kind=workflow&limit=100`; +const ROLES_PATH = `/api/tenants/${TENANT}/roles?limit=100`; + +describe("resolveAgentRoleIds", () => { + test("maps names to per-tenant ids in selection order, dropping repeats", async () => { + const calls: string[] = []; + const fetchImpl = stubFetch( + [ + { + method: "GET", + path: ROLES_PATH, + status: 200, + body: rolesPage(["owner", "member", "custom"]), + }, + ], + calls, + ); + const ids = await resolveAgentRoleIds(TENANT, ["member", "owner", "member"], fetchImpl); + expect(ids).toEqual(["role-1", "role-0"]); + expect(calls).toEqual([`GET ${ROLES_PATH}`]); + }); + + test("an empty selection reads nothing", async () => { + const calls: string[] = []; + const ids = await resolveAgentRoleIds(TENANT, [], stubFetch([], calls)); + expect(ids).toEqual([]); + expect(calls).toEqual([]); + }); + + test("an unknown name fails closed naming the role", async () => { + const calls: string[] = []; + const fetchImpl = stubFetch( + [{ method: "GET", path: ROLES_PATH, status: 200, body: rolesPage(["owner"]) }], + calls, + ); + const error = await resolveAgentRoleIds(TENANT, ["owner", "auditor"], fetchImpl).catch( + (cause: unknown) => cause, + ); + expect(error).toBeInstanceOf(AgentRolesError); + expect((error as Error).message).toContain('"auditor"'); + }); + + test("a signed-out read surfaces as unauthenticated", async () => { + const fetchImpl = stubFetch([{ method: "GET", path: ROLES_PATH, status: 401 }], []); + const error = await listTenantRoles(TENANT, fetchImpl).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(UnauthenticatedError); + }); +}); + +describe("resolveAgentPrincipalId", () => { + test("matches the anchor run principal by deployment id", async () => { + const fetchImpl = stubFetch( + [ + { + method: "GET", + path: PRINCIPALS_PATH, + status: 200, + body: principalsPage([principalRow("principal-1", "deployment-9", "Workflow (old@x)")]), + }, + ], + [], + ); + const id = await resolveAgentPrincipalId( + TENANT, + { deploymentId: "deployment-9", address: "agent@t.localhost" }, + fetchImpl, + ); + expect(id).toBe("principal-1"); + }); + + test("matches a live run principal by address display name", async () => { + const fetchImpl = stubFetch( + [ + { + method: "GET", + path: PRINCIPALS_PATH, + status: 200, + body: principalsPage([ + principalRow("principal-2", "run-7", "Workflow (agent@t.localhost)"), + ]), + }, + ], + [], + ); + const id = await resolveAgentPrincipalId( + TENANT, + { deploymentId: "deployment-9", address: "agent@t.localhost" }, + fetchImpl, + ); + expect(id).toBe("principal-2"); + }); + + test("skips removed principals and returns null when none match", async () => { + const fetchImpl = stubFetch( + [ + { + method: "GET", + path: PRINCIPALS_PATH, + status: 200, + body: principalsPage([ + principalRow( + "principal-gone", + "deployment-9", + "Workflow (agent@t.localhost)", + "removed", + ), + principalRow("principal-other", "run-8", "Workflow (other@t.localhost)"), + ]), + }, + ], + [], + ); + const id = await resolveAgentPrincipalId( + TENANT, + { deploymentId: "deployment-9", address: "agent@t.localhost" }, + fetchImpl, + ); + expect(id).toBeNull(); + }); + + test("follows cursor pages to the match", async () => { + const calls: string[] = []; + const fetchImpl = stubFetch( + [ + { + method: "GET", + path: PRINCIPALS_PATH, + status: 200, + body: principalsPage( + [principalRow("principal-other", "run-8", "Workflow (x)")], + "cursor-1", + ), + }, + { + method: "GET", + path: `${PRINCIPALS_PATH}&cursor=cursor-1`, + status: 200, + body: principalsPage([principalRow("principal-3", "deployment-9", "Workflow (y)")]), + }, + ], + calls, + ); + const id = await resolveAgentPrincipalId(TENANT, { deploymentId: "deployment-9" }, fetchImpl); + expect(id).toBe("principal-3"); + expect(calls).toEqual([`GET ${PRINCIPALS_PATH}`, `GET ${PRINCIPALS_PATH}&cursor=cursor-1`]); + }); +}); + +describe("assignAgentRoles", () => { + const assignPath = (principalId: string, roleId: string) => + `/api/tenants/${TENANT}/principals/${principalId}/roles/${roleId}`; + + test("posts each role in order", async () => { + const calls: string[] = []; + const fetchImpl = stubFetch( + [ + { method: "POST", path: assignPath("principal-1", "role-0"), status: 204 }, + { method: "POST", path: assignPath("principal-1", "role-1"), status: 204 }, + ], + calls, + ); + await assignAgentRoles(TENANT, "principal-1", ["role-0", "role-1"], fetchImpl); + expect(calls).toEqual([ + `POST ${assignPath("principal-1", "role-0")}`, + `POST ${assignPath("principal-1", "role-1")}`, + ]); + }); + + test("a refused write stops the apply with the hub's message", async () => { + const calls: string[] = []; + const fetchImpl = stubFetch( + [ + { method: "POST", path: assignPath("principal-1", "role-0"), status: 204 }, + { + method: "POST", + path: assignPath("principal-1", "role-9"), + status: 404, + body: { + error: { + code: "not_found", + message: "Role not found", + userMessage: "That role is gone.", + }, + }, + }, + { method: "POST", path: assignPath("principal-1", "role-1"), status: 204 }, + ], + calls, + ); + const error = await assignAgentRoles( + TENANT, + "principal-1", + ["role-0", "role-9", "role-1"], + fetchImpl, + ).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(AgentRolesError); + expect((error as Error).message).toContain("That role is gone."); + expect(calls).toEqual([ + `POST ${assignPath("principal-1", "role-0")}`, + `POST ${assignPath("principal-1", "role-9")}`, + ]); + }); +}); + +describe("assignRolesToAgent", () => { + test("an empty id list touches nothing", async () => { + const calls: string[] = []; + await assignRolesToAgent( + { tenantId: TENANT, roleIds: [], principalRef: { deploymentId: "deployment-9" } }, + stubFetch([], calls), + ); + expect(calls).toEqual([]); + }); + + test("a principal that does not exist yet fails with nothing assigned", async () => { + const calls: string[] = []; + const fetchImpl = stubFetch( + [{ method: "GET", path: PRINCIPALS_PATH, status: 200, body: principalsPage([]) }], + calls, + ); + const error = await assignRolesToAgent( + { + tenantId: TENANT, + roleIds: ["role-0"], + principalRef: { deploymentId: "deployment-9", address: "agent@t.localhost" }, + }, + fetchImpl, + ).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(AgentRolesError); + expect((error as Error).message).toContain("hasn't run yet"); + expect(calls).toEqual([`GET ${PRINCIPALS_PATH}`]); + }); + + test("resolves the principal, then assigns every id", async () => { + const calls: string[] = []; + const fetchImpl = stubFetch( + [ + { + method: "GET", + path: PRINCIPALS_PATH, + status: 200, + body: principalsPage([principalRow("principal-1", "deployment-9", "Workflow (z)")]), + }, + { + method: "POST", + path: `/api/tenants/${TENANT}/principals/principal-1/roles/role-0`, + status: 204, + }, + ], + calls, + ); + await assignRolesToAgent( + { + tenantId: TENANT, + roleIds: ["role-0"], + principalRef: { deploymentId: "deployment-9" }, + }, + fetchImpl, + ); + expect(calls).toEqual([ + `GET ${PRINCIPALS_PATH}`, + `POST /api/tenants/${TENANT}/principals/principal-1/roles/role-0`, + ]); + }); +}); diff --git a/apps/web/src/agent-roles.ts b/apps/web/src/agent-roles.ts new file mode 100644 index 000000000..2e0e2558b --- /dev/null +++ b/apps/web/src/agent-roles.ts @@ -0,0 +1,215 @@ +// Roles a created agent carries: the form lists the tenant's platform +// roles, the person checks the ones the agent should hold, and the deploy +// assigns them to the agent's workflow principal. Reads resolve against live +// platform roles (never hardcoded ids); tenant bootstrap seeds the system +// roles, so this module creates none. Every step fails closed: an unknown +// name, a principal that does not exist yet, or a refused write aborts the +// operation instead of leaving a half-roled agent behind. +import { paginatedSchema, RoleResponse } from "@intx/types"; +import { type } from "arktype"; +import type { ArkErrors } from "arktype"; + +import { UnauthenticatedError } from "@/lib/api-query"; + +export class AgentRolesError extends Error {} + +export type RoleOption = { + readonly id: string; + readonly name: string; + readonly isSystem: boolean; +}; + +const RolesPage = paginatedSchema(RoleResponse); +// The principal list's own loose shape, the same as threads-api's: a +// removed principal still lists (so it is filtered, not validated away), +// which the shared PrincipalResponse status enum would reject outright. +const PrincipalsPage = type({ + data: type({ + id: "string", + kind: "string", + refId: "string", + displayName: "string", + status: "string", + }).array(), + nextCursor: "string | null", +}); + +type Validator = (data: unknown) => T | ArkErrors; + +function tenantPath(tenantId: string, suffix: string): string { + return `/api/tenants/${encodeURIComponent(tenantId)}${suffix}`; +} + +async function readPage(response: Response, schema: Validator, what: string): Promise { + if (response.status === 401) { + throw new UnauthenticatedError(); + } + if (!response.ok) { + throw new AgentRolesError(`${what} failed.`); + } + const parsed = schema(await response.json().catch(() => undefined)); + if (parsed instanceof type.errors) { + throw new AgentRolesError(`${what} came back an unexpected shape.`); + } + return parsed; +} + +async function fetchPage( + fetchImpl: typeof fetch, + path: string, + schema: Validator, + what: string, +): Promise { + let response: Response; + try { + response = await fetchImpl(path, { headers: { accept: "application/json" } }); + } catch (cause) { + throw new AgentRolesError(cause instanceof Error ? cause.message : `${what} failed.`); + } + return readPage(response, schema, what); +} + +/** Lists the tenant's platform roles (system and custom), oldest first — + * tenant bootstrap seeds the system roles, so a fresh tenant lists + * owner/admin/member with nothing to create. */ +export async function listTenantRoles( + tenantId: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const roles: RoleOption[] = []; + let cursor: string | null = null; + for (;;) { + const path: string = + cursor === null + ? `${tenantPath(tenantId, "/roles")}?limit=100` + : `${tenantPath(tenantId, "/roles")}?limit=100&cursor=${encodeURIComponent(cursor)}`; + const page = await fetchPage(fetchImpl, path, RolesPage, "Listing this workbench's roles"); + for (const role of page.data) { + roles.push({ id: role.id, name: role.name, isSystem: role.isSystem }); + } + if (page.nextCursor === null) return roles; + cursor = page.nextCursor; + } +} + +/** Resolves selected role names to the target tenant's role ids. Role ids + * are per-tenant, so callers pass names and resolve in the tenant the + * assignment lands in. Fails closed on the first unknown name, before any + * write, so a typo never half-applies a selection. */ +export async function resolveAgentRoleIds( + tenantId: string, + names: readonly string[], + fetchImpl: typeof fetch = fetch, +): Promise { + const wanted = [...new Set(names)]; + if (wanted.length === 0) return []; + const roles = await listTenantRoles(tenantId, fetchImpl); + return wanted.map((name) => { + const found = roles.find((role) => role.name === name); + if (found === undefined) { + throw new AgentRolesError(`This workbench has no role named "${name}".`); + } + return found.id; + }); +} + +export type AgentPrincipalRef = { + /** The install's deployment id: a deployment's anchor run carries this id, + * so the run principal minted at first delivery matches it. */ + readonly deploymentId: string; + /** The agent's run address (`slug@domain`), minted at deploy time: a + * workflow principal's display name renders as `Workflow (
)`. */ + readonly address?: string; +}; + +/** Finds the agent's workflow principal in the tenant: the anchor run + * principal by deployment id, else any live run principal under the agent's + * address. Returns null when the agent has never run — a deployment's + * workflow principal only appears after its first run, so a fresh agent has + * nothing to assign to yet. Removed principals never match. */ +export async function resolveAgentPrincipalId( + tenantId: string, + ref: AgentPrincipalRef, + fetchImpl: typeof fetch = fetch, +): Promise { + const expectedName = ref.address === undefined ? null : `Workflow (${ref.address})`; + let cursor: string | null = null; + for (;;) { + const base = `${tenantPath(tenantId, "/principals")}?kind=workflow&limit=100`; + const path: string = cursor === null ? base : `${base}&cursor=${encodeURIComponent(cursor)}`; + const page = await fetchPage( + fetchImpl, + path, + PrincipalsPage, + "Listing this workbench's people", + ); + for (const principal of page.data) { + if (principal.status === "removed") continue; + if (principal.refId === ref.deploymentId) return principal.id; + if (expectedName !== null && principal.displayName === expectedName) return principal.id; + } + if (page.nextCursor === null) return null; + cursor = page.nextCursor; + } +} + +async function readAssignError(response: Response): Promise { + const body: unknown = await response.json().catch(() => undefined); + const envelope = type({ + error: { code: "string", "message?": "string", "userMessage?": "string" }, + })(body); + if (envelope instanceof type.errors) return `HTTP ${response.status}`; + return envelope.error.userMessage ?? envelope.error.message ?? `HTTP ${response.status}`; +} + +/** Assigns each role to the principal, in order, stopping at the first + * refusal. Assignment is idempotent server-side, so retrying a partial + * apply converges instead of duplicating. */ +export async function assignAgentRoles( + tenantId: string, + principalId: string, + roleIds: readonly string[], + fetchImpl: typeof fetch = fetch, +): Promise { + for (const roleId of roleIds) { + let response: Response; + try { + response = await fetchImpl( + `${tenantPath(tenantId, `/principals/${encodeURIComponent(principalId)}/roles/${encodeURIComponent(roleId)}`)}`, + { method: "POST" }, + ); + } catch (cause) { + throw new AgentRolesError( + cause instanceof Error ? cause.message : "Assigning this role failed.", + ); + } + if (!response.ok) { + throw new AgentRolesError(`Assigning this role failed: ${await readAssignError(response)}`); + } + } +} + +export type AssignRolesToAgentArgs = { + readonly tenantId: string; + /** Role ids already resolved against this tenant; empty is a no-op. */ + readonly roleIds: readonly string[]; + readonly principalRef: AgentPrincipalRef; +}; + +/** Assigns already-resolved role ids to the agent's workflow principal. + * Callers resolve names to ids first (failing fast on unknown names before + * anything deploys), so this runs after the install: a missing principal + * fails with nothing assigned. An empty id list is a no-op. */ +export async function assignRolesToAgent( + args: AssignRolesToAgentArgs, + fetchImpl: typeof fetch = fetch, +): Promise { + if (args.roleIds.length === 0) return; + const principalId = await resolveAgentPrincipalId(args.tenantId, args.principalRef, fetchImpl); + if (principalId === null) { + throw new AgentRolesError( + "This agent hasn't run yet, so its roles can't be assigned. Send it a message, then try again and the roles will apply.", + ); + } + await assignAgentRoles(args.tenantId, principalId, args.roleIds, fetchImpl); +} diff --git a/apps/web/src/pages/create-agent-panel.tsx b/apps/web/src/pages/create-agent-panel.tsx index f506da1d8..7a92661be 100644 --- a/apps/web/src/pages/create-agent-panel.tsx +++ b/apps/web/src/pages/create-agent-panel.tsx @@ -17,6 +17,7 @@ import { useState } from "react"; import { ApiQueryError } from "@/lib/api-query"; import type { DeployedAgent } from "../agent-deploy"; +import { useTenantRoles } from "../agent-roles-query"; import { useDeployAgentMutation } from "../agents-api"; import { useMcpServers } from "../tools/mcp-servers-query"; import "./create-agent-panel.css"; @@ -63,8 +64,13 @@ export function CreateAgentPanel({ // Handles checked below, out of the workspace catalog — each becomes a // definition binding plus a use requirement, the same as Worker's Exa. const [selectedHandles, setSelectedHandles] = useState([]); + // Role names checked below, out of this workbench's roles — each is + // resolved to the workbench's role id and assigned to the agent once it + // deploys. + const [selectedRoles, setSelectedRoles] = useState([]); const deploy = useDeployAgentMutation(tenantId); const catalog = useMcpServers(tenantId); + const roles = useTenantRoles(tenantId); function toggleHandle(handle: string) { setSelectedHandles((current) => @@ -72,10 +78,17 @@ export function CreateAgentPanel({ ); } + function toggleRole(name: string) { + setSelectedRoles((current) => + current.includes(name) ? current.filter((role) => role !== name) : [...current, name], + ); + } + function reset() { setValues(EMPTY_VALUES); setSystemPrompt(""); setSelectedHandles([]); + setSelectedRoles([]); deploy.reset(); } @@ -89,7 +102,13 @@ export function CreateAgentPanel({ ? "Add a name to continue." : systemPrompt.trim() === "" ? "Write a system prompt to continue." - : null; + : roles.kind === "loading" + ? "Roles are still loading." + : roles.kind === "error" + ? "Roles failed to load." + : roles.kind === "unauthenticated" + ? "Sign in again to deploy." + : null; function handleSubmit() { if (blocked !== null) return; @@ -98,6 +117,7 @@ export function CreateAgentPanel({ name: values.name.trim(), systemPrompt: systemPrompt.trim(), ...(selectedHandles.length > 0 ? { mcpHandles: selectedHandles } : {}), + ...(selectedRoles.length > 0 ? { roles: selectedRoles } : {}), }, { onSuccess: (deployment) => { @@ -196,6 +216,44 @@ export function CreateAgentPanel({

)} +
+ + Roles + +

+ This workbench's roles — checked roles are assigned to this agent once it + deploys. +

+ {roles.kind === "loading" ? ( +

Loading workbench roles…

+ ) : roles.kind === "ready" && roles.data.length === 0 ? ( +

No roles in this workbench yet.

+ ) : roles.kind === "ready" ? ( +
    + {roles.data.map((role) => ( +
  • + toggleRole(role.name)} + rowLabel={role.name} + ariaLabel={`Assign the ${role.name} role`} + /> + {role.name} +
  • + ))} +
+ ) : roles.kind === "error" ? ( +

+ Couldn't load the workbench's roles — deploying is blocked until they + load.{" "} + +

+ ) : ( +

Sign in again to see this workbench's roles.

+ )} +
+

+ ) : ( +

+ Sign in again to see this workspace's roles and start a workbench. +

+ )} + `. */ @@ -86,7 +92,16 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise 0) { + try { + await assignRolesToAgent({ + tenantId, + roleIds, + principalRef: { deploymentId: worker.deploymentId, address: `worker@${domain}` }, + }); + } catch (cause) { + throw failure(cause, "roles", tenantId); + } + } } catch (cause) { + if (cause instanceof WorkbenchCreateError) throw cause; throw failure(cause, "deploy", tenantId); }