diff --git a/apps/web/src/chat/strings.ts b/apps/web/src/chat/strings.ts
index 5465a4f08..8c39b2fa8 100644
--- a/apps/web/src/chat/strings.ts
+++ b/apps/web/src/chat/strings.ts
@@ -141,10 +141,10 @@ export const CHAT_STRINGS = {
blockApproveAction: "Approve",
blockDenyAction: "Deny",
blockApproveAllowOnce: "Allow once",
- blockApproveAlways: "Always allow here",
+ blockApproveAlways: "Always allow for this worker",
blockApproveAskEvery: "Ask every time",
blockApproveResolvedOnce: "Allowed once",
- blockApproveResolvedAlways: "Always allowed in this workbench",
+ blockApproveResolvedAlways: "Always allowed for this worker",
blockApproveResolvedAskEvery: "Allowed once, will ask every time",
blockApproveResolvedDenied: "Denied",
blockFormSubmit: "Submit",
diff --git a/apps/web/src/pages/worker-page.tsx b/apps/web/src/pages/worker-page.tsx
index 100def6db..48e00c835 100644
--- a/apps/web/src/pages/worker-page.tsx
+++ b/apps/web/src/pages/worker-page.tsx
@@ -21,6 +21,7 @@ import {
} from "../settings/tenancy-api";
import { agentSlugFromSourceAssetName, deployAgentSource } from "../agent-deploy";
import { readAgentMcpHandles, readAgentSource } from "../agent-source-read";
+import { applyGrantSnapshot, readGrantSnapshot, saveGrantSnapshot } from "../worker-grants-carry";
import { useBench } from "../bench-context";
import { Link } from "../navigation";
import { tenantKeys } from "../query-client";
@@ -60,6 +61,10 @@ function InstructionsCard({
const slug = agentSlugFromSourceAssetName(agent.assetName);
if (slug === null) throw new Error(`${agent.assetName} is not an editable worker`);
const mcpHandles = await readAgentMcpHandles(tenantId, agent.id, agent.assetName);
+ const current = await readWorkerToolGrants(tenantId, agent);
+ if (current !== null) {
+ saveGrantSnapshot(tenantId, agent.assetName, current.principalId, current.tools);
+ }
await deployAgentSource({
tenantId,
input: { name: agent.name, systemPrompt, slug, mcpHandles },
@@ -149,13 +154,17 @@ async function readWorkerToolGrants(tenantId: string, agent: ChatAgent) {
(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
+ const read = async () => {
+ const grants = await listGrants(tenantId, { principalId: principal.id });
+ return grants
.filter((grant) => grant.resource.startsWith(TOOL_PREFIX) && grant.action === "invoke")
- .sort((a, b) => a.resource.localeCompare(b.resource)),
+ .sort((a, b) => a.resource.localeCompare(b.resource));
};
+ let tools = await read();
+ if (await applyGrantSnapshot(tenantId, agent.assetName, principal.id, tools)) {
+ tools = await read();
+ }
+ return { principalId: principal.id, tools };
}
function PermissionsCard({
@@ -167,7 +176,14 @@ function PermissionsCard({
}) {
const queryClient = useQueryClient();
const key = [...tenantKeys.grants(tenantId), "worker", agent.id] as const;
- const query = useQuery({ queryKey: key, queryFn: () => readWorkerToolGrants(tenantId, agent) });
+ // While a redeploy's permissions are waiting for the new run to start,
+ // keep looking for its principal.
+ const carrying = readGrantSnapshot(tenantId, agent.assetName) !== null;
+ const query = useQuery({
+ queryKey: key,
+ queryFn: () => readWorkerToolGrants(tenantId, agent),
+ refetchInterval: carrying ? 5000 : false,
+ });
// Create the new grant before revoking the old one so a failure never
// leaves the tool ungoverned.
@@ -201,6 +217,7 @@ function PermissionsCard({
Loading permissions…
diff --git a/apps/web/src/worker-grants-carry.ts b/apps/web/src/worker-grants-carry.ts new file mode 100644 index 000000000..0dd8876dc --- /dev/null +++ b/apps/web/src/worker-grants-carry.ts @@ -0,0 +1,83 @@ +// A redeploy mints a new run, and runtime tool grants live on the run's +// principal, so non-default effects are snapshotted before the redeploy and +// re-applied once the new run's principal exists. + +import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; +import type { GrantEffect } from "@intx/types"; + +import { createGrant, revokeGrant, type Grant } from "./settings/tenancy-api"; + +const TOOL_PREFIX = "tool:"; + +const Snapshot = type({ + fromPrincipalId: "string", + effects: type({ resource: "string", effect: "'allow' | 'deny'" }).array(), +}); +export type GrantSnapshot = typeof Snapshot.infer; + +const storageKey = (tenantId: string, assetName: string) => + `workbench:worker-grants:${tenantId}:${assetName}`; + +export function readGrantSnapshot(tenantId: string, assetName: string): GrantSnapshot | null { + try { + const raw = localStorage.getItem(storageKey(tenantId, assetName)); + if (raw === null) return null; + const parsed = Snapshot(JSON.parse(raw)); + return parsed instanceof type.errors ? null : parsed; + } catch (cause) { + reportError(cause, { operation: "worker_grants_snapshot_read", tenantId }); + return null; + } +} + +function writeGrantSnapshot(tenantId: string, assetName: string, snapshot: GrantSnapshot | null) { + try { + if (snapshot === null) localStorage.removeItem(storageKey(tenantId, assetName)); + else localStorage.setItem(storageKey(tenantId, assetName), JSON.stringify(snapshot)); + } catch (cause) { + reportError(cause, { operation: "worker_grants_snapshot_write", tenantId }); + } +} + +/** Stock grants default to "ask"; only allow/deny differ from the default. */ +export function saveGrantSnapshot( + tenantId: string, + assetName: string, + principalId: string, + tools: readonly Grant[], +) { + const effects = tools.flatMap((grant) => + grant.resource.startsWith(TOOL_PREFIX) && (grant.effect === "allow" || grant.effect === "deny") + ? [{ resource: grant.resource, effect: grant.effect }] + : [], + ); + if (effects.length === 0) return; + writeGrantSnapshot(tenantId, assetName, { fromPrincipalId: principalId, effects }); +} + +/** Re-applies the snapshot onto a new run principal (create-then-revoke), + * then clears it. Returns true when grants were applied. */ +export async function applyGrantSnapshot( + tenantId: string, + assetName: string, + principalId: string, + tools: readonly Grant[], +): Promise