Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/web/src/agent-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ import type { McpServer } from "./mcp-servers";
import { resolveExistingOffering } from "./onboarding/provider-connect-step";
import { isValidSlug, slugify } from "@/lib/slug";

/** A non-default tool permission (`ask` is the stock default) to carry onto
* a redeployed run. */
export type ToolEffect = {
readonly resource: string;
readonly effect: "allow" | "deny";
};

export class AgentDeployError extends Error {}

const TenantDomainShape = type({ domain: "string" });
Expand Down Expand Up @@ -51,6 +58,9 @@ export function buildAgentDefinitionJson(args: {
* requirements Worker carries, so remote tools stay ask-gated except the
* read-only ones the server itself annotates. */
mcpServers: readonly McpServerDeployment[];
/** Tool permissions the person set on the previous run, re-declared so the
* new run materializes them at its first trigger. */
toolEffects: readonly ToolEffect[];
}): unknown {
const stepId = "run";
return {
Expand All @@ -70,6 +80,12 @@ export function buildAgentDefinitionJson(args: {
artifactToolsCredentialUseRequirement(args.hubCredentialId),
memoryToolsCredentialUseRequirement(args.hubCredentialId),
...args.mcpServers.map((server) => mcpServerCredentialUseRequirement(server.credentialId)),
...args.toolEffects.map((tool) => ({
resource: tool.resource,
action: "invoke",
effect: tool.effect,
source: "creator",
})),
],
steps: {
[stepId]: {
Expand Down Expand Up @@ -106,6 +122,7 @@ async function renderAgentSourceTree(
readonly declaredSources: readonly { readonly provider: string; readonly model: string }[];
readonly hubCredentialId: string;
readonly mcpServers: readonly McpServerDeployment[];
readonly toolEffects: readonly ToolEffect[];
},
): Promise<Record<string, string>> {
const { WORKER_BUNDLE_BUILD_EXPORT, WORKER_DIRECTORS_BUNDLE, WORKER_WORKFLOW_BUNDLE } =
Expand Down Expand Up @@ -134,6 +151,7 @@ async function renderAgentSourceTree(
declaredSources: args.declaredSources,
hubCredentialId: args.hubCredentialId,
mcpServers: args.mcpServers,
toolEffects: args.toolEffects,
}),
),
});
Expand Down Expand Up @@ -177,6 +195,9 @@ export type NewAgentInput = {
* row is created targeting this agent's definition, so the ticker mails
* its live run on that cadence. */
readonly schedule?: string;
/** Tool permissions to re-declare on a redeploy, as creator-sourced grant
* requirements. */
readonly toolEffects?: readonly ToolEffect[];
};

/** Maps requested workspace-catalog handles to deployments. Fails closed on
Expand Down Expand Up @@ -335,6 +356,7 @@ export async function deployAgentSource(
fetchImpl,
),
mcpServers,
toolEffects: args.input.toolEffects ?? [],
}),
entry: "./workflow.js",
sourceOfferingIds: offering.sourceOfferingIds,
Expand Down
57 changes: 30 additions & 27 deletions apps/web/src/pages/worker-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ import { isDefaultWorker, listChatAgents, type ChatAgent } from "@/chat/threads-
import { ChatCircle } from "@/lib/icons";
import { QueryView, describeApiError } from "@/lib/api-query";
import { listTopLevelRuns } from "../agents-api";
import { createGrant, revokeGrant, type Grant } from "../settings/tenancy-api";
import { updateGrant, type Grant } from "../settings/tenancy-api";
import { agentSlugFromSourceAssetName, deployAgentSource } from "../agent-deploy";
import { readAgentMcpHandles, readAgentSource } from "../agent-source-read";
import { readGrantSnapshot, saveGrantSnapshot } from "../worker-grants-carry";
import { TOOL_PREFIX, readWorkerToolGrants, workerToolGrantsKey } from "../worker-tool-grants";
import {
TOOL_PREFIX,
effectiveToolGrants,
readWorkerToolGrants,
toolEffectsToCarry,
workerToolGrantsKey,
} from "../worker-tool-grants";
import { useBench } from "../bench-context";
import { Link } from "../navigation";
import { tenantKeys } from "../query-client";
Expand Down Expand Up @@ -58,12 +63,15 @@ function InstructionsCard({
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 },
input: {
name: agent.name,
systemPrompt,
slug,
mcpHandles,
toolEffects: current === null ? [] : toolEffectsToCarry(current.tools),
},
});
},
onSuccess: async () => {
Expand Down Expand Up @@ -144,34 +152,24 @@ function PermissionsCard({
}) {
const queryClient = useQueryClient();
const key = workerToolGrantsKey(tenantId, agent.id);
// 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.
// Every row for the tool moves together, so a redeploy's requirement
// beside the tool's own row can't outrank the choice.
const setMode = useMutation({
mutationFn: async (input: {
readonly principalId: string;
readonly grant: Grant;
readonly grants: readonly Grant[];
readonly resource: string;
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);
const rows = input.grants.filter((grant) => grant.resource === input.resource);
await Promise.all(rows.map((row) => updateGrant(tenantId, row.id, { effect: input.effect })));
},
onSuccess: (_data, input) => {
toast(`${input.grant.resource.slice(TOOL_PREFIX.length)}: ${input.effect}`);
toast(`${input.resource.slice(TOOL_PREFIX.length)}: ${input.effect}`);
},
onError: (cause) => {
reportError(cause, { operation: "worker_permission_set", tenantId });
Expand Down Expand Up @@ -199,9 +197,9 @@ function PermissionsCard({
</p>
) : (
<div className="mt-3">
{query.data.tools.map((grant) => {
{effectiveToolGrants(query.data.tools).map((grant) => {
const name = grant.resource.slice(TOOL_PREFIX.length);
const principalId = query.data?.principalId ?? "";
const all = query.data?.tools ?? [];
return (
<div
key={grant.id}
Expand All @@ -223,7 +221,12 @@ function PermissionsCard({
aria-checked={active}
disabled={setMode.isPending}
onClick={() => {
if (!active) setMode.mutate({ principalId, grant, effect: mode.effect });
if (!active)
setMode.mutate({
grants: all,
resource: grant.resource,
effect: mode.effect,
});
}}
className={`h-[26px] rounded-(--r-sm) px-2.5 text-[12.5px] font-semibold ${
active ? "bg-(--card) text-(--ink) shadow-sm" : "text-(--ink-3)"
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/settings/tenancy-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,17 @@ export function createGrant(tenantId: string, input: CreateGrantInput): Promise<
});
}

export function updateGrant(
tenantId: string,
grantId: string,
patch: { readonly effect: GrantEffect },
): Promise<Grant> {
return request(`/api/tenants/${tenantId}/grants/${grantId}`, GrantResponse, {
method: "PATCH",
body: JSON.stringify(patch),
});
}

export function revokeGrant(tenantId: string, grantId: string): Promise<void> {
return request<void>(`/api/tenants/${tenantId}/grants/${grantId}`, (data) => data as void, {
method: "DELETE",
Expand Down
83 changes: 0 additions & 83 deletions apps/web/src/worker-grants-carry.ts

This file was deleted.

41 changes: 29 additions & 12 deletions apps/web/src/worker-tool-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import { listTopLevelRuns } from "./agents-api";
import type { ChatAgent } from "./chat/threads-api";
import { tenantKeys } from "./query-client";
import { listGrants, listPrincipals } from "./settings/tenancy-api";
import { applyGrantSnapshot } from "./worker-grants-carry";
import { listGrants, listPrincipals, type Grant } from "./settings/tenancy-api";
import type { ToolEffect } from "./agent-deploy";

export function workerToolGrantsKey(tenantId: string, agentId: string) {
return [...tenantKeys.grants(tenantId), "worker", agentId] as const;
Expand All @@ -26,15 +26,32 @@ export async function readWorkerToolGrants(tenantId: string, agent: ChatAgent) {
(candidate) => candidate.kind === "workflow" && candidate.refId === run?.id,
);
if (principal === undefined) return null;
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));
};
let tools = await read();
if (await applyGrantSnapshot(tenantId, agent.assetName, principal.id, tools)) {
tools = await read();
}
const grants = await listGrants(tenantId, { principalId: principal.id });
const tools = grants
.filter((grant) => grant.resource.startsWith(TOOL_PREFIX) && grant.action === "invoke")
.sort((a, b) => a.resource.localeCompare(b.resource));
return { principalId: principal.id, tools };
}

const EFFECT_RANK = { allow: 0, ask: 1, deny: 2 } as const;

/** One row per tool resource, carrying the effect that wins at runtime
* (deny over ask over allow): a redeploy's requirement can sit beside the
* tool's own floor row, and the stronger one decides. */
export function effectiveToolGrants(tools: readonly Grant[]): readonly Grant[] {
const byResource = new Map<string, Grant>();
for (const grant of tools) {
const held = byResource.get(grant.resource);
if (held === undefined || EFFECT_RANK[grant.effect] > EFFECT_RANK[held.effect]) {
byResource.set(grant.resource, grant);
}
}
return [...byResource.values()];
}

/** The non-ask tool effects to re-declare on a redeploy. */
export function toolEffectsToCarry(tools: readonly Grant[]): readonly ToolEffect[] {
return effectiveToolGrants(tools).flatMap((grant) =>
grant.effect === "ask" ? [] : [{ resource: grant.resource, effect: grant.effect }],
);
}
Loading