diff --git a/apps/web/src/pages/worker-page.tsx b/apps/web/src/pages/worker-page.tsx
index 34dfe651c..1a59a7a42 100644
--- a/apps/web/src/pages/worker-page.tsx
+++ b/apps/web/src/pages/worker-page.tsx
@@ -1,15 +1,24 @@
-// One worker: its instructions (editable), its model, and the workbench it
-// lives on. Permissions and delete arrive with their own screens.
+// One worker: its instructions (editable), its model, its tool permissions,
+// and the workbench it lives on.
import { useState } from "react";
import { Button, Card, CardDescription, CardTitle, PageShell, toast } from "@corbits/react-ui";
import { reportError } from "@corbits/error-sink";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import type { GrantEffect } from "@intx/types";
import { WorkbenchAvatar } from "@/chat/avatar";
import { isMyraAgent, listChatAgents, type ChatAgent } from "@/chat/threads-api";
import { ChatCircle } from "@/lib/icons";
import { QueryView, describeApiError } from "@/lib/api-query";
+import { listTopLevelRuns } from "../agents-api";
+import {
+ createGrant,
+ listGrants,
+ listPrincipals,
+ revokeGrant,
+ type Grant,
+} from "../settings/tenancy-api";
import { agentSlugFromSourceAssetName, deployAgentSource } from "../agent-deploy";
import { readAgentMcpHandles, readAgentSource } from "../agent-source-read";
import { useBench } from "../bench-context";
@@ -118,6 +127,136 @@ function InstructionsCard({
);
}
+const MODES: readonly { readonly effect: GrantEffect; readonly label: string }[] = [
+ { effect: "allow", label: "Allow" },
+ { effect: "ask", label: "Ask" },
+ { effect: "deny", label: "Deny" },
+];
+
+const TOOL_PREFIX = "tool:";
+
+/** The worker's live run principal: runtime grants belong to it, and it only
+ * exists once the worker has been triggered. */
+async function readWorkerToolGrants(tenantId: string, agent: ChatAgent) {
+ if (agent.liveAddress === null) return null;
+ const [runs, principals] = await Promise.all([
+ listTopLevelRuns(tenantId),
+ listPrincipals(tenantId),
+ ]);
+ const run = runs.find((candidate) => candidate.address === agent.liveAddress);
+ const principal = principals.find(
+ (candidate) => candidate.kind === "workflow" && candidate.refId === run?.id,
+ );
+ if (principal === undefined) return null;
+ const grants = await listGrants(tenantId, { principalId: principal.id });
+ return {
+ principalId: principal.id,
+ tools: grants
+ .filter((grant) => grant.resource.startsWith(TOOL_PREFIX) && grant.action === "invoke")
+ .sort((a, b) => a.resource.localeCompare(b.resource)),
+ };
+}
+
+function PermissionsCard({
+ tenantId,
+ agent,
+}: {
+ readonly tenantId: string;
+ readonly agent: ChatAgent;
+}) {
+ const queryClient = useQueryClient();
+ const key = [...tenantKeys.grants(tenantId), "worker", agent.id] as const;
+ const query = useQuery({ queryKey: key, queryFn: () => readWorkerToolGrants(tenantId, agent) });
+
+ // Create the new grant before revoking the old one so a failure never
+ // leaves the tool ungoverned.
+ const setMode = useMutation({
+ mutationFn: async (input: {
+ readonly principalId: string;
+ readonly grant: Grant;
+ readonly effect: GrantEffect;
+ }) => {
+ await createGrant(tenantId, {
+ principalId: input.principalId,
+ resource: input.grant.resource,
+ action: "invoke",
+ effect: input.effect,
+ origin: "creator",
+ });
+ await revokeGrant(tenantId, input.grant.id);
+ },
+ onSuccess: (_data, input) => {
+ toast(`${input.grant.resource.slice(TOOL_PREFIX.length)}: ${input.effect}`);
+ },
+ onError: (cause) => {
+ reportError(cause, { operation: "worker_permission_set", tenantId });
+ toast(describeApiError(cause, "change this permission"));
+ },
+ onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
+ });
+
+ return (
+ Loading permissions…
+ {describeApiError(query.error, "load these permissions")}
+
+ Permissions appear here once this worker has started its first run.
+