diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 57cd8b2953..1a468ac33a 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -110,13 +110,14 @@ import { UserDraft } from '$lib/userDraft.svelte' import { setOpenInSessionHandoff } from './sessions/openInSessionContext' import { getEditorStoragePath, setEditorStoragePath } from './editorStoragePathContext' - import { useOperatorBuilderFlows, useTriggerLock } from '$lib/operatorWriteRights' + import { useEditRights, useOperatorBuilderFlows, useTriggerLock } from '$lib/operatorWriteRights' import { useOperatingUser, useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const triggerLock = useTriggerLock() const operatorBuilderFlows = useOperatorBuilderFlows() + const editRights = useEditRights() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() @@ -556,8 +557,14 @@ // flag that answer for it are per workspace, so the nav user would answer for the wrong // membership when a session editor operates on another workspace. const user = await getUserExt(opWorkspace ?? '').catch(() => undefined) + const operatorSettings = $userWorkspaces.find( + (w) => w.id === opWorkspace + )?.operator_settings agentCanWrite = Object.fromEntries( - draftAgents.map((a) => [a.path, agentDraftCanWrite(a, user ?? $userStore ?? undefined)]) + draftAgents.map((a) => [ + a.path, + agentDraftCanWrite(a, user ?? $userStore ?? undefined, operatorSettings) + ]) ) // The path is passed, so a draft that renames the agent is refused here too: a rename is // the agent editor's to deploy, and this dialog lists the agent under the path the flow @@ -1560,7 +1567,7 @@ {#key renderCount} - {#if !actingUser?.operator || $operatorBuilderFlows} + {#if editRights.roleCanAuthor('flow')} {#if $pathStore} {/if} @@ -1585,8 +1592,7 @@ // Gated like the summary's own editor: a host that allows neither field keeps the // band's segment, which copies the path. pathTrigger: - customUi?.topBar?.editablePath != false || - customUi?.topBar?.editableSummary != false + customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false ? flowPathTrigger : undefined }} diff --git a/frontend/src/lib/components/GroupEditor.svelte b/frontend/src/lib/components/GroupEditor.svelte index f8d0445840..b4229cf9f6 100644 --- a/frontend/src/lib/components/GroupEditor.svelte +++ b/frontend/src/lib/components/GroupEditor.svelte @@ -15,7 +15,7 @@ import Row from './table/Row.svelte' import Cell from './table/Cell.svelte' import { sendUserToast } from '$lib/toast' - import { canWrite } from '$lib/utils' + import { useEditRights } from '$lib/operatorWriteRights' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import Label from './Label.svelte' @@ -42,6 +42,7 @@ const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() const actingUser = $derived(operatingUser.current) + const editRights = useEditRights() const ROLE_TOOLTIPS = { member: @@ -92,7 +93,7 @@ // Derived for the same reason as the trigger editors: the acting user's role can land after // the group does, and a snapshot taken first would stay read-only. const can_write = $derived( - writeVerdict ?? (groupPerms !== undefined && canWrite(name, groupPerms, actingUser)) + writeVerdict ?? (groupPerms !== undefined && editRights.canEditItem('group', name, groupPerms)) ) let group: Group | undefined let instance_group: InstanceGroup | undefined = $state() diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 9847454aca..bd9d51638d 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -7,7 +7,8 @@ type Resource, type ResourceType } from '$lib/gen' - import { canWrite } from '$lib/utils' + import { canEditItem } from '$lib/editRights' + import { userWorkspaces } from '$lib/stores' import { createEventDispatcher, onDestroy, untrack } from 'svelte' import { sendUserToast } from '$lib/toast' import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte' @@ -261,8 +262,16 @@ if (!initialPath || !selected) return true const r = fetchedResources[selected] if (!r || !acting.resolved(selected)) return undefined - return canWrite(current?.path ?? initialPath, r.extra_perms ?? {}, acting.in(selected)) + return canEditResourceIn(selected, current?.path ?? initialPath, r.extra_perms) }) + function canEditResourceIn( + ws: string, + path: string, + extraPerms: Record | undefined + ): boolean { + const settings = $userWorkspaces.find((w) => w.id === ws)?.operator_settings + return canEditItem('resource', path, extraPerms, acting.in(ws), settings) + } const dirtyWorkspaces = $derived( Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws])) @@ -423,9 +432,7 @@ const dirtyCanWrite = $derived( dirtyWorkspaces.every((ws) => { const r = fetchedResources[ws] - return ( - !r || canWrite(states[ws]?.draft?.path ?? initialPath, r.extra_perms ?? {}, acting.in(ws)) - ) + return !r || canEditResourceIn(ws, states[ws]?.draft?.path ?? initialPath, r.extra_perms) }) ) diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index f69cf37f1d..4defe97af5 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -128,8 +128,9 @@ import WacExportDrawer from './scripts/WacExportDrawer.svelte' import { UserDraft } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights' const triggerLock = useTriggerLock() + const editRights = useEditRights() let { script = $bindable(), @@ -1385,7 +1386,7 @@ onCanceled={() => (perpetualRunsToConfirm = undefined)} /> -{#if !actingUser?.operator} +{#if editRights.roleCanAuthor('script')} ): boolean { + const settings = $userWorkspaces.find((w) => w.id === ws)?.operator_settings + return canEditItem('variable', editPath ?? '', perms, acting.in(ws), settings) + } const dirtyWorkspaces = $derived( Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws])) ) @@ -301,7 +306,7 @@ const dirtyCanWrite = $derived( dirtyWorkspaces.every((ws) => { const perms = extraPerms[ws] - return !perms || canWrite(editPath ?? '', perms, acting.in(ws)) + return !perms || canEditVariableIn(ws, perms) }) ) diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index cb56b37498..0d493790d9 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -20,15 +20,12 @@ import { Button } from '$lib/components/common' import { AppService, OpenAPI } from '$lib/gen' import { userStore, type UserExt } from '$lib/stores' - import { canWrite } from '$lib/utils' import { getUserExt } from '$lib/user' import { ExternalLink, Pen } from 'lucide-svelte' import { page } from '$app/state' import { isMenuHidden } from '$lib/components/sessions/sessionMode.svelte' - import { - setOperatingWorkspace, - useOperatingUser - } from '$lib/components/operatingWorkspace.svelte' + import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useEditRights } from '$lib/operatorWriteRights' let { workspace, @@ -58,7 +55,6 @@ // The app, its permission check and everything this viewer renders belong to `workspace`, // which on a session preview tab is the session's and not the one the nav points at. setOperatingWorkspace(() => workspace) - const operatingUser = useOperatingUser() /** This workspace's membership for the viewer, which is what the app's `ctx.username` / * `ctx.groups` must describe — they sit beside `ctx.workspace` in the same object. */ @@ -72,8 +68,9 @@ let appPerms = $state<{ path: string; extraPerms: Record } | undefined>( undefined ) + const editRights = useEditRights() const canWriteApp = $derived( - !!appPerms && canWrite(appPerms.path, appPerms.extraPerms, operatingUser.current) + !!appPerms && editRights.canEditItem('app', appPerms.path, appPerms.extraPerms) ) /** Raw vs low-code, read from the app itself rather than from the route: * both kinds render here and either route serves either kind (links to a raw diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index ca8648baed..8dfaac8896 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -9,7 +9,7 @@ import type ShareModal from '$lib/components/ShareModal.svelte' import { FlowService, type Flow } from '$lib/gen' import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useEditRights } from '$lib/operatorWriteRights' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Badge from '../badge/Badge.svelte' @@ -43,7 +43,7 @@ import EditInForkButton from './EditInForkButton.svelte' import { isCloudHosted } from '$lib/cloud' - const operatorBuilderFlows = useOperatorBuilderFlows() + const editRights = useEditRights() interface Props { flow: Flow & { @@ -125,7 +125,7 @@ let scheduleEditor: ScheduleEditor | undefined = $state(undefined) let flowHistory: FlowHistory | undefined = $state(undefined) - let hideForOperator = $derived($userStore?.operator && !$operatorBuilderFlows) + let hideForOperator = $derived(!editRights.roleCanAuthor('flow')) {#if menuOpen} diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index bcea3a26d6..41ea788fac 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -50,6 +50,7 @@ type AttachedBlob } from './blobUtils' import { MessageDraft } from './messageDraft.svelte' + import { useSessionPermissions } from '$lib/components/sessions/sessionPermissions.svelte' import ExpandableImage, { isImageViewerOpen } from '$lib/components/common/image/ExpandableImage.svelte' @@ -134,10 +135,28 @@ 'Refactor a script to add error handling', 'List my workspace flows and scripts' ] - const globalSuggestion = - GLOBAL_PLACEHOLDER_SUGGESTIONS[ - Math.floor(Math.random() * GLOBAL_PLACEHOLDER_SUGGESTIONS.length) - ] + // An operator authors nothing, so nothing above is theirs to ask for; one with the + // workspace's builder right can compose flows out of deployed scripts as well. + const OPERATOR_PLACEHOLDER_SUGGESTIONS = [ + 'List my workspace flows and scripts', + 'Show the runs that failed today and why', + 'Find the script that sends a report and run it', + 'Rerun the last failed run of a flow' + ] + const BUILDER_PLACEHOLDER_SUGGESTIONS = [ + ...OPERATOR_PLACEHOLDER_SUGGESTIONS, + 'Compose a flow that chains two deployed scripts' + ] + const permissions = useSessionPermissions(() => chatHost.operatingWorkspace) + const suggestionPick = Math.random() + const globalSuggestion = $derived.by(() => { + const pool = permissions.canOpenEditor('script') + ? GLOBAL_PLACEHOLDER_SUGGESTIONS + : permissions.canOpenEditor('flow') + ? BUILDER_PLACEHOLDER_SUGGESTIONS + : OPERATOR_PLACEHOLDER_SUGGESTIONS + return pool[Math.floor(suggestionPick * pool.length)] + }) // Generate mode-specific placeholder const modePlaceholder = $derived.by(() => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index b04493355e..8cfa0ffffe 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -26,7 +26,7 @@ import { import { createTwoFilesPatch } from 'diff' import { deepEqual } from 'fast-equals' import { promptSafe, type ArtifactVersionTarget } from '$lib/components/sessions/previewRouter' -import { canWrite } from '$lib/utils' +import { canDraftItem, roleCanDraft } from '$lib/editRights' import { $ScriptLang } from '$lib/gen/schemas.gen' import type { AppWithLastVersion, @@ -212,10 +212,13 @@ import { invalidateWorkspaceComparison } from '$lib/workspaceComparison' import type { UserDraftItemKind } from '$lib/gen' import { bundleRawAppDraft } from './rawAppBundlerBridge' import { + canOpenEditor, DEPLOY, NONE, + RUN_FLOW_PREVIEW, RUN_PREVIEW, WRITE_DRAFT, + WRITE_FLOW_DRAFT, type SessionAccess, type SessionTool, type SessionToolPolicy @@ -763,7 +766,7 @@ const listRunsSchema = z.object({ // superadmin caller; every other caller gets the hedge even where nothing is hidden. const NO_WORKERS_VISIBLE_MESSAGE = 'No workers came back. This does NOT establish that no workers are running: an instance can hide workers from callers without the devops role, and it does so by returning an empty list rather than an error. ' + - 'Tell the user you cannot see any workers and that worker visibility may be restricted for your account, and suggest they check the Workers page themselves. Never state that no workers are online or that the instance has none.' + 'Tell the user you cannot see any workers and that worker visibility may be restricted for your account, and suggest they check the Workers page themselves if they can open it, or ask a workspace admin otherwise. Never state that no workers are online or that the instance has none.' const NO_WORKERS_CONNECTED_MESSAGE = 'No workers are connected to this instance (none pinged in the last 5 minutes). Queued runs will stay queued until a worker starts.' const WORKER_PAGE_SIZE = 100 @@ -1184,7 +1187,14 @@ const openPreviewSchema = z.object({ * it can open, so it is not offered. Per-PATH write permission is unknowable here — * a workspace capability says nothing about `f/finance/*` — and is resolved in the * handler instead. Mirrors `open_page` re-narrowing its page enum per user. */ +const openPreviewNoPipelineKind = z + .enum(['script', 'flow', 'raw_app']) + .describe( + 'Item kind to preview. Use "raw_app" for code-based apps. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' + ) + const openPreviewViewOnlySchema = openPreviewSchema.extend({ + kind: openPreviewNoPipelineKind, mode: z .literal('view') .optional() @@ -1193,6 +1203,17 @@ const openPreviewViewOnlySchema = openPreviewSchema.extend({ ) }) +/** A builder operator: the flow editor, and the deployed page for everything else. */ +const openPreviewFlowEditorSchema = openPreviewSchema.extend({ + kind: openPreviewNoPipelineKind, + mode: z + .enum(['edit', 'view']) + .optional() + .describe( + 'Which side to show. "edit" opens the flow editor and works for flows only: the only editor you can open in this workspace. Pass "view" for a script or app, the deployed page with its run form, triggers and past runs.' + ) +}) + const OPEN_PREVIEW_DESCRIPTION = 'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.' @@ -1407,7 +1428,14 @@ const buildGlobalSystemPrompt = ( // Each `can*` mirrors the capability the matching tools declare in `requires`, so a // rule cannot outlive the tool it describes. An unresolved profile keeps every block. const canWriteDraft = !access || access.has('write_draft') + const canWriteFlowDraft = !access || access.has('write_flow_draft') + // A builder operator: flow drafts and nothing else. Blocks about drafting in general + // follow `canWriteAnyDraft`; blocks naming scripts, apps or the other kinds keep `canWriteDraft`. + const flowsOnly = canWriteFlowDraft && !canWriteDraft + const canWriteAnyDraft = canWriteDraft || canWriteFlowDraft const canRunPreview = !access || access.has('run_preview') + const canManageSchedules = !access || access.has('manage_schedules') + const canManageTriggers = !access || access.has('manage_triggers') // The deploy tools take their kind as an argument, so `deploy` gates only the folder text. const canCreateFolder = !access || access.has('deploy') // Each gated block carries its own leading newline, so dropping one leaves no blank @@ -1448,9 +1476,12 @@ The current user's workspace username is "${username}".${instanceLine} ${ canWriteDraft ? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.' - : "Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit scripts, flows, apps, schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused scripts, flows and apps outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor." + : `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${flowsOnly ? '' : 'flows, '}scripts, apps, schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${flowsOnly ? 'scripts and apps' : 'scripts, flows and apps'} outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor. Resources and variables they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when( + flowsOnly, + '\n\nFlows are the exception: this workspace lets them compose flows out of scripts and flows that are already deployed. Write flow drafts with write_flow and patch_flow_json, using only steps that call a deployed script or flow by path. The server refuses inline code anywhere in the flow, so never add a rawscript step or inline code; when the flow needs logic no deployed script provides, say so and suggest asking a workspace admin for that script.' + )}` }${when( - canWriteDraft, + canWriteAnyDraft, ` Path conventions: @@ -1463,25 +1494,35 @@ Path conventions: '; create one with `create_folder` only when the user explicitly asks for a new folder' )}.${folderGuidanceBlock}` )}${when( - canCreateFolder && !canWriteDraft, + canCreateFolder && !canWriteAnyDraft, '\n- You can create a shared folder with `create_folder` when the user explicitly asks for one. You cannot create anything inside it here, so do not offer to.' )} Rules:${when( - canWriteDraft, + canWriteAnyDraft, ` - Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.` )} - Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. - If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule}${when( - canWriteDraft, + canWriteAnyDraft, ` - Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace. - To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.` )}${when( - !canWriteDraft, + !canWriteAnyDraft, ` -- Three changes are still open to you where the server allows them: discard_local_draft drops a draft this user left behind, deploy_workspace_item deploys one when the user asks, and delete_workspace_item removes an item already deployed in the workspace. You cannot create or edit one.` +- Three changes are still open to you where the server allows them: discard_local_draft drops a draft this user left behind, deploy_workspace_item deploys one when the user asks, and delete_workspace_item removes an item already deployed in the workspace — each only for the kinds its type lists. You cannot create or edit one.` + )}${when( + !canManageSchedules || !canManageTriggers, + ` +- This workspace does not let this user change ${ + !canManageSchedules && !canManageTriggers + ? 'schedules or triggers' + : !canManageSchedules + ? 'schedules' + : 'triggers' + }, here or in the editor, so never ask them to create or edit one: suggest asking a workspace admin.` )} - Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.${when( canWriteDraft, @@ -1499,6 +1540,10 @@ ${pipelineBullet}` ` - After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment. - Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.` + )}${when( + flowsOnly && (!access || access.has('run_flow_preview')), + ` +- After creating or editing a flow draft, run test_run_flow with representative args before reporting that it works. It prefers drafts, so testing does not require deployment.` )} - Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run. - get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars). @@ -1523,7 +1568,11 @@ ${pipelineBullet}` ? `${when( !canWriteDraft, ` -- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. You cannot open item editors in this workspace, so it only ever opens the deployed page.` +- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. ${ + flowsOnly + ? 'The flow editor is the only editor you can open here: after writing or editing a flow draft, show it with open_preview(kind="flow", path, mode="edit").' + : 'You cannot open item editors in this workspace, so it only ever opens the deployed page.' + }` )}${when( canWriteDraft, ` @@ -1555,7 +1604,7 @@ Documentation: Flows: - read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.". - Use read_flow_module_code${when(canWriteDraft, ' and set_flow_module_code')} for inline script bodies.${when( - canWriteDraft, + canWriteFlowDraft, ` - Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.` )} @@ -2547,8 +2596,13 @@ export function getSessionContextPromptSection( // Concatenated onto an already capability-gated prompt, so it has to honour the same // profile rather than assume the gating happened upstream. const canDeploy = !access || access.has('deploy') - const canWriteDraft = !access || access.has('write_draft') - const canRunPreview = !access || access.has('run_preview') + const canWriteDraft = !access || access.has('write_draft') || access.has('write_flow_draft') + const canRunPreview = !access || access.has('run_preview') || access.has('run_flow_preview') + // The kinds no deploy rule reaches, unless the workspace withdrew them from this user. + const unruledKinds = [ + (!access || access.has('manage_schedules')) && 'schedules', + (!access || access.has('manage_triggers')) && 'triggers' + ].filter((k): k is string => !!k) const targets = ['reads', canWriteDraft && 'drafts', canRunPreview && 'test runs', 'deploys'] .filter(Boolean) .join(', ') @@ -2587,7 +2641,13 @@ export function getSessionContextPromptSection( // promotes the rest instead. The rules gate deletes too, so it is owed to every such profile. if (!canDeploy) { lines.push( - "- This workspace refuses direct deployment for this user, except for schedules and triggers — those are the only kinds deploy_workspace_item and delete_workspace_item can still act on. Scripts, flows, apps, resources and variables must be promoted from the session's deploy panel (fork or pull request); do not offer to deploy or delete them directly." + `- This workspace refuses direct deployment for this user${ + unruledKinds.length === 2 + ? ', except for schedules and triggers — those are the only kinds deploy_workspace_item and delete_workspace_item can still act on' + : unruledKinds.length === 1 + ? `, except for ${unruledKinds[0]} — the only kind deploy_workspace_item and delete_workspace_item can still act on` + : '' + }. Scripts, flows, apps, resources and variables must be promoted from the session's deploy panel (fork or pull request); do not offer to deploy or delete them directly.` ) } return lines.join('\n') @@ -3431,9 +3491,14 @@ export const globalTools: SessionTool<{}>[] = [ // Every subject is authoring guidance written around the draft tools by name, and a // tool result is the one place the toolset filter cannot reach. const access = (ctx.helpers as GlobalToolHelpers | undefined)?.access - if (access && !access.has('write_draft')) { - const message = - 'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.' + const canAuthor = + !access || + access.has('write_draft') || + (parsed.subject === 'flow' && access.has('write_flow_draft')) + if (!canAuthor) { + const message = access?.has('write_flow_draft') + ? 'This session can only compose flows, so flow is the one subject with authoring guidance here. Tell the user plainly that you cannot create or edit this kind of item.' + : 'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.' toolCallbacks.setToolStatus(toolId, { content: 'No authoring guidance' }) return message } @@ -3781,7 +3846,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_FLOW_DRAFT, def: createToolDef(writeFlowSchema, 'write_flow', 'Create or overwrite a draft flow.'), showDetails: true, streamArguments: true, @@ -3915,7 +3980,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_FLOW_DRAFT, def: createToolDef( patchFlowJsonSchema, 'patch_flow_json', @@ -3965,7 +4030,7 @@ export const globalTools: SessionTool<{}>[] = [ autoCollapseDetails: false }, { - requires: RUN_PREVIEW, + requires: RUN_FLOW_PREVIEW, def: testRunFlowToolDef, fn: async (ctx) => { const parsed = testRunFlowSchema.parse(ctx.args) @@ -4145,16 +4210,17 @@ export const globalTools: SessionTool<{}>[] = [ // turns on the kind. Each kind's handler, reached through `deployDraft`'s switch: requires: NONE, kindRequires: { - // scripts.rs `create_script_internal`, flows.rs `create_flow`/`update_flow` and - // apps.rs `create_app_raw`/`update_app_raw` refuse operators; all run the rules. + // scripts.rs `create_script_internal` and apps.rs `create_app_raw`/`update_app_raw` + // refuse operators, flows.rs `create_flow`/`update_flow` those without builder + // rights; all run the rules. script: ['deploy', 'manage_code'], - flow: ['deploy', 'manage_code'], + flow: ['deploy', 'manage_flows'], app: ['deploy', 'manage_code'], resource: DEPLOY, variable: DEPLOY, - // The schedule and trigger handlers check neither. - schedule: NONE, - trigger: NONE + // No deploy rules here, but `gate_operator_writes` on their routers. + schedule: ['manage_schedules'], + trigger: ['manage_triggers'] } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, def: createToolDef( deployWorkspaceItemSchema, @@ -4172,8 +4238,17 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - // Rebasing writes a fresh draft, so drafts.rs `require_can_write_path` applies. - requires: WRITE_DRAFT, + // Rebasing writes a fresh draft, so drafts.rs `require_can_write_path` applies per kind. + requires: NONE, + kindRequires: { + script: WRITE_DRAFT, + flow: WRITE_FLOW_DRAFT, + app: WRITE_DRAFT, + resource: WRITE_DRAFT, + variable: WRITE_DRAFT, + schedule: WRITE_DRAFT, + trigger: WRITE_DRAFT + } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, def: createToolDef( rebaseDraftSchema, 'rebase_draft', @@ -4218,13 +4293,14 @@ export const globalTools: SessionTool<{}>[] = [ kindRequires: { // scripts.rs `delete_script_by_path` calls `require_admin`; a non-admin archives. script: ['admin'], - // flows.rs `delete_flow_by_path` and apps.rs `delete_app` refuse operators. - flow: ['deploy', 'manage_code'], + // apps.rs `delete_app` refuses operators, flows.rs `delete_flow_by_path` those + // without builder rights. + flow: ['deploy', 'manage_flows'], app: ['deploy', 'manage_code'], resource: DEPLOY, variable: DEPLOY, - schedule: NONE, - trigger: NONE + schedule: ['manage_schedules'], + trigger: ['manage_triggers'] } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, def: createToolDef( deleteWorkspaceItemSchema, @@ -4495,17 +4571,20 @@ export const globalTools: SessionTool<{}>[] = [ { requires: NONE, def: createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION), - // Withhold the editor side from a session that cannot write drafts, so the model - // never offers the user a panel it would not get. + // Withhold the editors a session cannot save, so the model never offers the user a + // panel it would not get. schemaFor: async (helpers) => { const access = (helpers as GlobalToolHelpers | undefined)?.access - return access && !access.has('write_draft') - ? createToolDef( + if (canOpenEditor(access, 'script')) { + return createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION) + } + return canOpenEditor(access, 'flow') + ? createToolDef(openPreviewFlowEditorSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION) + : createToolDef( openPreviewViewOnlySchema, 'open_preview', OPEN_PREVIEW_VIEW_ONLY_DESCRIPTION ) - : createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION) }, fn: async (ctx) => { const parsed = openPreviewSchema.parse(ctx.args) @@ -4841,22 +4920,25 @@ async function openSessionPreview( if (!openPreviewHandler) { return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.' } - // A pipeline has only an editor, and its own capability gates already cover it. + // A pipeline has only an editor, so a session that cannot save its nodes has nothing to open. if (args.kind === 'pipeline') { + if (!canOpenEditor(access, 'pipeline')) { + return 'Error: you cannot open the pipeline editor in this workspace: this user cannot save drafts. Show its scripts with open_preview mode "view" instead.' + } return await openPreviewHandler({ ...args, mode: undefined, sessionId }) } // Resolved before the gates, not after: the handler reads a missing mode as the editor, so // gating on `args.mode === 'edit'` would wave through every call that simply omitted it — // which is what a model whose schema only offers 'view' does. const mode: 'edit' | 'view' = args.mode ?? 'edit' - // The advertised schema already withholds 'edit' from a session that cannot write - // drafts; re-check so a model asking outside the enum can't act outside it either. - if (mode === 'edit' && access && !access.has('write_draft')) { + // The advertised schema already narrows 'edit' to what this session can save; re-check so + // a model asking outside the enum can't act outside it either. + if (mode === 'edit' && !canOpenEditor(access, args.kind)) { const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId }) - return `${opened}\nOpened the deployed page instead: you cannot open item editors in this workspace.` + return `${opened}\nOpened the deployed page instead: you cannot open ${args.kind === 'flow' ? 'flow' : 'item'} editors in this workspace.` } - // `write_draft` is a workspace capability and says nothing about this path, so an - // edit that survived it is still checked against the item's own permissions. + // The draft capability is workspace-wide and says nothing about this path, so an edit + // that survived it is still checked against the item's own permissions. if (mode === 'edit' && workspace) { const verdict = await canEditItemPath(workspace, args.kind, args.path) if (verdict !== 'allowed') { @@ -4884,10 +4966,12 @@ async function canEditItemPath( const role = await roleForWorkspace(workspace) if (role.kind === 'not_a_member') return 'denied' if (role.kind !== 'resolved' || !role.user) return 'unverified' - if (role.user.operator) return 'denied' + const user = role.user + const settings = get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings + if (!roleCanDraft(kind, user, settings)) return 'denied' // Folder and ownership rules answer most calls without a request. An item's own // `extra_perms` can only widen them, so the item is fetched only to overturn a denial. - if (canWrite(path, {}, role.user)) return 'allowed' + if (canDraftItem(kind, path, {}, user, settings)) return 'allowed' try { const extraPerms = kind === 'script' @@ -4895,7 +4979,7 @@ async function canEditItemPath( : kind === 'flow' ? (await FlowService.getFlowByPath({ workspace, path })).extra_perms : (await AppService.getAppByPath({ workspace, path })).extra_perms - return canWrite(path, extraPerms ?? {}, role.user) ? 'allowed' : 'denied' + return canDraftItem(kind, path, extraPerms, user, settings) ? 'allowed' : 'denied' } catch (e) { // Nothing deployed means no sharing to widen the folder rules with. return (e as { status?: number } | null | undefined)?.status === 404 ? 'denied' : 'unverified' diff --git a/frontend/src/lib/components/copilot/chat/global/gate.ts b/frontend/src/lib/components/copilot/chat/global/gate.ts index d1dafe29ed..506f230027 100644 --- a/frontend/src/lib/components/copilot/chat/global/gate.ts +++ b/frontend/src/lib/components/copilot/chat/global/gate.ts @@ -25,14 +25,35 @@ export function isGlobalAiEnabled(): boolean { } } +const OPERATOR_SESSIONS_KEY = 'wm_operator_ai_sessions' + +/** + * Operators get AI sessions only in a browser that opted in, until the operator experience + * has been tested; everywhere else they keep the docked chat and `/sessions` refuses them. + * Set with `localStorage.setItem('wm_operator_ai_sessions', '1')` and reload. To ship it, + * delete this and `sessionsAllowedFor`, and drop the `isOperator` argument from every caller. + */ +function operatorSessionsEnabled(): boolean { + if (typeof localStorage === 'undefined') return false + try { + return localStorage.getItem(OPERATOR_SESSIONS_KEY) === '1' + } catch { + return false + } +} + +/** Whether this user's role may use AI sessions at all. */ +export function sessionsAllowedFor(isOperator: boolean | undefined): boolean { + return !isOperator || operatorSessionsEnabled() +} + /** * Whether an AI entry point hands off to a session instead of driving the docked * chat. Deliberately the same condition as the root layout's `disableAi`, so a * caller falling back on `false` always has a mounted pane to fall back to. - * Operators keep that pane (`/sessions` refuses them) until the operator chat ships. */ export function prefersSessionHandoff(isOperator: boolean | undefined): boolean { - return isGlobalAiEnabled() && !isOperator + return isGlobalAiEnabled() && sessionsAllowedFor(isOperator) } /** Clear the opt-out, then hard-reload so every gated site re-reads it. */ diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts index 82ad8162d3..2e28375cee 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts @@ -8,7 +8,7 @@ const { whoami, deployRules } = vi.hoisted(() => ({ vi.mock('$lib/gen', () => ({ UserService: { whoami } })) vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployRules: deployRules })) -import { resolveSessionAccess } from './sessionAccess' +import { capabilitiesForRole, resolveSessionAccess } from './sessionAccess' import { clearWorkspaceRoleCache } from '$lib/user' type WhoamiOverrides = { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean } @@ -45,20 +45,30 @@ describe('resolveSessionAccess', () => { it('gives a developer every capability but admin', async () => { const caps = await capabilitiesFor({}) - expect([...caps].sort()).toEqual(['deploy', 'manage_code', 'run_preview', 'write_draft']) + expect([...caps].sort()).toEqual([ + 'deploy', + 'manage_code', + 'manage_flows', + 'manage_schedules', + 'manage_triggers', + 'run_flow_preview', + 'run_preview', + 'write_draft', + 'write_flow_draft' + ]) }) // The folder, resource and variable handlers run the rules but have no operator check, // so withholding `deploy` from an operator would be stricter than the server. it('leaves an operator the deploy-rule capability, and nothing their token refuses', async () => { const caps = await capabilitiesFor({ operator: true }) - expect([...caps]).toEqual(['deploy']) + expect([...caps].sort()).toEqual(['deploy', 'manage_schedules', 'manage_triggers']) }) it('takes deploy from the rules alone, so a rule blocks an operator too', async () => { deployRules.mockResolvedValue({ ok: false, refusedBy: 'DisableDirectDeployment' }) const caps = await capabilitiesFor({ operator: true }) - expect([...caps]).toEqual([]) + expect([...caps].sort()).toEqual(['manage_schedules', 'manage_triggers']) }) // Both spellings of `authed.is_admin`, which the draft path honours and the handlers @@ -67,7 +77,14 @@ describe('resolveSessionAccess', () => { 'lets an admin who is also an operator draft and deploy, but not preview or manage code (%o)', async (role) => { const caps = await capabilitiesFor({ ...role, operator: true }) - expect([...caps].sort()).toEqual(['admin', 'deploy', 'write_draft']) + expect([...caps].sort()).toEqual([ + 'admin', + 'deploy', + 'manage_schedules', + 'manage_triggers', + 'write_draft', + 'write_flow_draft' + ]) } ) @@ -82,6 +99,37 @@ describe('resolveSessionAccess', () => { expect(caps.has('write_draft')).toBe(true) }) + // flows.rs, drafts.rs and run_preview_flow all let a builder through for flows, and + // nothing else moves: scripts, apps and their previews stay refused. + it("gives a builder operator the flow capabilities and nothing a script's handler refuses", () => { + const caps = capabilitiesForRole({ + isAdmin: false, + operator: true, + deployRulesPass: true, + operatorSettings: { builder_flows: true } + }) + expect([...caps].sort()).toEqual([ + 'deploy', + 'manage_flows', + 'manage_schedules', + 'manage_triggers', + 'run_flow_preview', + 'write_flow_draft' + ]) + }) + + // A withdrawable right: only `false` withdraws it, as `gate_operator_writes` reads it. + it('withholds schedule and trigger writes only where the workspace set them to false', () => { + const caps = capabilitiesForRole({ + isAdmin: false, + operator: true, + deployRulesPass: true, + operatorSettings: { manage_schedules: false } + }) + expect(caps.has('manage_schedules')).toBe(false) + expect(caps.has('manage_triggers')).toBe(true) + }) + it('grants everything when the role cannot be resolved', async () => { whoami.mockRejectedValueOnce(new Error('network')) const access = await resolveSessionAccess('ws') diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts index 1ac13136a4..2f1e00c09b 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -1,12 +1,26 @@ +import { get } from 'svelte/store' +import { userWorkspaces } from '$lib/stores' +import type { OperatorSettings } from '$lib/gen' import { getWorkspaceRole } from '$lib/user' +import { roleCanAuthor, roleCanDraft } from '$lib/editRights' import { checkDeployRules } from '$lib/utils_workspace_deploy' import type { SessionAccess, SessionCapability } from '../sessionCapabilities' +type OperatorRights = Pick< + NonNullable, + 'builder_flows' | 'manage_schedules' | 'manage_triggers' +> + const ALL_CAPABILITIES: SessionCapability[] = [ 'write_draft', + 'write_flow_draft', 'run_preview', + 'run_flow_preview', 'deploy', 'manage_code', + 'manage_flows', + 'manage_schedules', + 'manage_triggers', 'admin' ] @@ -25,19 +39,35 @@ export function capabilitiesForRole(role: { isAdmin: boolean operator: boolean deployRulesPass: boolean + /** The workspace's `operator_settings`, null for a non-operator. */ + operatorSettings?: OperatorRights | null }): SessionAccess { const capabilities = new Set() - // Per-capability precedence, NOT a role ladder: drafts.rs `require_can_write_path` - // returns Ok on `authed.is_admin` BEFORE its operator branch, while jobs.rs - // `run_preview_*` and the script/flow/app handlers refuse `authed.is_operator` with no - // admin escape — and on the session path that flag is never cleared for an admin. - if (role.isAdmin || !role.operator) { + const user = { operator: role.operator, is_admin: role.isAdmin, is_super_admin: false } + const authorsCode = roleCanAuthor('script', user, role.operatorSettings) + const authorsFlows = roleCanAuthor('flow', user, role.operatorSettings) + // Drafts take the admin precedence `roleCanDraft` encodes; previews and the item handlers + // do not — and on the session path the operator flag is never cleared for an admin. + if (roleCanDraft('script', user, role.operatorSettings)) { capabilities.add('write_draft') } - if (!role.operator) { + if (roleCanDraft('flow', user, role.operatorSettings)) { + capabilities.add('write_flow_draft') + } + if (authorsCode) { capabilities.add('run_preview') capabilities.add('manage_code') } + if (authorsFlows) { + capabilities.add('run_flow_preview') + capabilities.add('manage_flows') + } + if (roleCanAuthor('schedule', user, role.operatorSettings)) { + capabilities.add('manage_schedules') + } + if (roleCanAuthor('trigger', user, role.operatorSettings)) { + capabilities.add('manage_triggers') + } // No operator term: the rules are their own gate, and the handlers that also refuse // operators say so through `manage_code`. Admins bypass the rules inside the check. if (role.deployRulesPass) { @@ -63,6 +93,7 @@ export async function resolveSessionAccess(workspace: string): Promise w.id === workspace)?.operator_settings }) } diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts index 143def2fc8..0c0b8d8a7d 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts @@ -75,10 +75,22 @@ const REACHABLE_PROFILES = [ [false, true].flatMap((isAdmin) => [false, true].flatMap((operator) => // `checkDeployRules` bypasses on admin, so an admin has only the one outcome. - (isAdmin ? [true] : [false, true]).map((deployRulesPass) => { - const access = capabilitiesForRole({ isAdmin, operator, deployRulesPass }) - return [[...access].sort().join(',') || 'none', access] as const - }) + (isAdmin ? [true] : [false, true]).flatMap((deployRulesPass) => + // The operator rights a workspace can set: none, the builder right, both locks. + [ + undefined, + { builder_flows: true }, + { manage_schedules: false, manage_triggers: false } + ].map((operatorSettings) => { + const access = capabilitiesForRole({ + isAdmin, + operator, + deployRulesPass, + operatorSettings + }) + return [[...access].sort().join(',') || 'none', access] as const + }) + ) ) ) ).entries() @@ -122,13 +134,24 @@ describe('session tool policies', () => { const kindsOf = (name: string, access: SessionAccess) => (shipped(name, access).def.function.parameters as any).properties.type.enum + const developerCaps: SessionCapability[] = [ + 'write_draft', + 'write_flow_draft', + 'run_preview', + 'run_flow_preview', + 'manage_code', + 'manage_flows', + 'manage_schedules', + 'manage_triggers' + ] + // A developer a protection rule refuses keeps the two kinds no rule reaches. - const refused = accessWith(['write_draft', 'run_preview', 'manage_code']) + const refused = accessWith(developerCaps) expect(kindsOf('deploy_workspace_item', refused)).toEqual(['schedule', 'trigger']) expect(kindsOf('delete_workspace_item', refused)).toEqual(['schedule', 'trigger']) // An operator where no rule applies: only the code handlers refuse them. - const operator = accessWith(['deploy']) + const operator = accessWith(['deploy', 'manage_schedules', 'manage_triggers']) expect(kindsOf('deploy_workspace_item', operator)).toEqual([ 'schedule', 'trigger', @@ -136,8 +159,17 @@ describe('session tool policies', () => { 'variable' ]) + // A workspace that withdrew schedule writes from operators: `gate_operator_writes`. + const locked = accessWith(['deploy', 'manage_triggers']) + expect(kindsOf('delete_workspace_item', locked)).toEqual(['trigger', 'resource', 'variable']) + + // A builder operator deploys flows too, and still no script or app. + const builder = accessWith(['deploy', 'manage_flows', 'manage_schedules', 'manage_triggers']) + expect(kindsOf('deploy_workspace_item', builder)).toContain('flow') + expect(kindsOf('deploy_workspace_item', builder)).not.toContain('script') + // Deleting a script is admin-only, where deploying one is not. - const developer = accessWith(['write_draft', 'run_preview', 'manage_code', 'deploy']) + const developer = accessWith([...developerCaps, 'deploy']) expect(kindsOf('deploy_workspace_item', developer)).toContain('script') expect(kindsOf('delete_workspace_item', developer)).not.toContain('script') diff --git a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts index fc77b3e1c4..f96e89dddc 100644 --- a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts +++ b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts @@ -12,14 +12,27 @@ export type SessionCapability = /** Passes drafts.rs `require_can_write_path` — for a draft of any kind, schedules and * triggers included, though an operator may create those directly. */ | 'write_draft' + /** Passes `require_can_write_path` for a flow draft, which an operator also passes where the + * workspace grants `builder_flows`. The server then refuses a draft carrying inline code. */ + | 'write_flow_draft' /** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */ | 'run_preview' + /** Passes `run_preview_flow`'s `check_operator_can_build_flows`: composed flows only, for an + * operator with `builder_flows`. */ + | 'run_flow_preview' /** Passes `check_deploy_rules`. */ | 'deploy' - /** Passes the operator refusal the script, flow and app handlers make before creating or + /** Passes the operator refusal the script and app handlers make before creating or * deleting one. The same condition as `run_preview` today, but another handler family's * check, so each follows its own gate if the two ever diverge. */ | 'manage_code' + /** Passes the flow handlers' `check_operator_can_build_flows`. */ + | 'manage_flows' + /** Passes `gate_operator_writes` on the schedule router: withheld only from an operator whose + * workspace set `operator_settings.manage_schedules` to false. */ + | 'manage_schedules' + /** The same gate on the trigger routers, read from `manage_triggers`. */ + | 'manage_triggers' /** Passes `require_admin`. */ | 'admin' @@ -36,9 +49,22 @@ export type SessionToolPolicy = readonly SessionCapability[] export const NONE: SessionToolPolicy = [] export const WRITE_DRAFT: SessionToolPolicy = ['write_draft'] +export const WRITE_FLOW_DRAFT: SessionToolPolicy = ['write_flow_draft'] export const RUN_PREVIEW: SessionToolPolicy = ['run_preview'] +export const RUN_FLOW_PREVIEW: SessionToolPolicy = ['run_flow_preview'] export const DEPLOY: SessionToolPolicy = ['deploy'] +/** Editors a session can open. Every editor autosaves a draft, so it needs the draft + * capability of its kind; a pipeline's nodes are script drafts. */ +export type EditorKind = 'script' | 'flow' | 'raw_app' | 'pipeline' + +/** Whether `access` can save what the `kind` editor writes. `undefined` access (not + * resolved yet, or not a session) answers true, as the toolset filter does. */ +export function canOpenEditor(access: SessionAccess | undefined, kind: EditorKind): boolean { + if (!access) return true + return access.has(kind === 'flow' ? 'write_flow_draft' : 'write_draft') +} + /** * A tool that can reach an AI session's toolset. `requires` is mandatory, and declared * beside the `fn` whose call is what proves it right. diff --git a/frontend/src/lib/components/copilot/chat/skills/skillResources.ts b/frontend/src/lib/components/copilot/chat/skills/skillResources.ts index 7a80705d4e..d28a4d32e8 100644 --- a/frontend/src/lib/components/copilot/chat/skills/skillResources.ts +++ b/frontend/src/lib/components/copilot/chat/skills/skillResources.ts @@ -1,6 +1,7 @@ import { ResourceService } from '$lib/gen' -import { canWrite } from '$lib/utils' -import type { UserExt } from '$lib/stores' +import { canEditItem } from '$lib/editRights' +import { userWorkspaces, type UserExt } from '$lib/stores' +import { get } from 'svelte/store' /** * Skills are resources of this type: a file resource (`format_extension = 'md'`) @@ -53,9 +54,10 @@ export type SkillListing = { skills: SkillResource[]; truncated: boolean } * `user` decides which rows the drawer offers to edit rather than only view; pass * the account the workspace is being browsed as. Ownership is mostly implicit in * the path (`u//…`, a folder the user owns), which is why this goes through - * the shared `canWrite` rather than reading `extra_perms` alone. */ + * the shared `canEditItem` rather than reading `extra_perms` alone. */ export async function listSkillResources(workspace: string, user?: UserExt): Promise { if (!workspace) return { skills: [], truncated: false } + const operatorSettings = get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings const rows: SkillResource[] = [] for (let page = 1; page <= MAX_SKILLS_PAGES; page++) { const resources = await ResourceService.listResource({ @@ -70,7 +72,7 @@ export async function listSkillResources(workspace: string, user?: UserExt): Pro name: skillNameFromPath(r.path), description: r.description ?? '', editedAt: r.edited_at, - canWrite: canWrite(r.path, r.extra_perms ?? {}, user) + canWrite: canEditItem('resource', r.path, r.extra_perms, user, operatorSettings) })) ) if (resources.length < SKILLS_PAGE_SIZE) return { skills: rows, truncated: false } diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index ce7378ede4..d3406721de 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -29,7 +29,7 @@ import RunForm from '$lib/components/RunForm.svelte' import ShareModal from '$lib/components/ShareModal.svelte' import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useEditRights } from '$lib/operatorWriteRights' import { sendUserToast } from '$lib/toast' import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte' @@ -131,7 +131,7 @@ // A ⌘-click opens these in a new tab, which only the query names the workspace for. const operatingHref = useOperatingWorkspaceHref() const actingUser = $derived(operatingUser.current) - const operatorBuilderFlows = useOperatorBuilderFlows() + const editRights = useEditRights() let flow: Flow | undefined = $state() // Derived, not assigned during the load: in a fork the acting user is looked up @@ -345,9 +345,7 @@ } } - // Operators with the builder right author flows out of deployed runnables; every other - // operator is read-only here. - let canAuthorFlow = $derived(!actingUser?.operator || $operatorBuilderFlows) + let canAuthorFlow = $derived(editRights.roleCanAuthor('flow')) let moveDrawer: MoveDrawer | undefined = $state() let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state() diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index f33f00fb8f..e8536aed91 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -104,6 +104,7 @@ useOperatingUser, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' + import { useEditRights } from '$lib/operatorWriteRights' import { buildForkEditUrl, editInForkAllowed, @@ -173,6 +174,9 @@ script.workspace_id == workspace && canWrite(script.path, script.extra_perms!, actingUser) ) + const editRights = useEditRights() + // The edit actions also need a role that authors scripts, which no operator has. + const canEdit = $derived(can_write && editRights.roleCanAuthor('script')) let deploymentInProgress = $state(false) let expandedModuleLocks: Record = $state({}) let expandedModuleCode: Record = $state({}) @@ -576,7 +580,7 @@ } }) - if (!script || actingUser?.operator || !can_write) { + if (!script || !canEdit) { return buttons } @@ -1079,7 +1083,7 @@ {#if Object.keys(script?.schema?.properties ?? {}).length > 0} onNavigate(`/scripts/edit/${script?.path}?metadata_open=true`) : undefined} runnableType="script" diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts index 81dcabed2e..cb6081ef46 100644 --- a/frontend/src/lib/components/flows/agentDraft.svelte.ts +++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts @@ -3,8 +3,8 @@ import { get } from 'svelte/store' import { deepEqual } from 'fast-equals' import { ResourceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' -import { canWrite } from '$lib/utils' -import { userStore } from '$lib/stores' +import { canEditItem, roleCanAuthor } from '$lib/editRights' +import { userStore, userWorkspaces } from '$lib/stores' import { getUserExt } from '$lib/user' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { UserDraft } from '$lib/userDraft.svelte' @@ -339,10 +339,12 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { // Same rule the generic resource editor applies. The backend refuses the write // either way, but without this the editor would invite edits it cannot save and // autosave a draft on every keystroke against a resource the reader cannot deploy. - canWriteResource = canWrite( + canWriteResource = canEditItem( + 'agent', r.path, - r.extra_perms ?? {}, - user ?? get(userStore) ?? undefined + r.extra_perms, + user ?? get(userStore) ?? undefined, + get(userWorkspaces).find((w) => w.id === ws)?.operator_settings ) // An agent that exists only as a draft has no deployed value to compare against or // fall back to, and the response's is a synthetic echo of the draft. Leaving the @@ -382,7 +384,11 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { if (loadedFor !== key) return noDeployed = true deployed = undefined - canWriteResource = true + canWriteResource = roleCanAuthor( + 'agent', + get(userStore) ?? undefined, + get(userWorkspaces).find((w) => w.id === ws)?.operator_settings + ) state = { path: name, description: '', diff --git a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte index dc7c0948eb..3359f20080 100644 --- a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte +++ b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte @@ -8,8 +8,8 @@ import ResourcePathHint from '$lib/components/ResourcePathHint.svelte' import { ResourceService, type InputTransform, type Resource } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { userStore } from '$lib/stores' - import { canWrite } from '$lib/utils' + import { userStore, userWorkspaces } from '$lib/stores' + import { canEditItem } from '$lib/editRights' import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte' import { AGENT_BRAIN_KEYS, @@ -235,8 +235,13 @@ let unavailable = $derived(linkedInfo?.unavailable ?? false) let canEditAgent = $derived( !!agent && - !$userStore?.operator && - canWrite(agent, linkedInfo?.extraPerms ?? {}, $userStore ?? undefined) + canEditItem( + 'agent', + agent, + linkedInfo?.extraPerms, + $userStore ?? undefined, + $userWorkspaces.find((w) => w.id === ws)?.operator_settings + ) ) // The hint flips on the first keystroke in the agent editor, so the badge does not wait for the // debounced autosave and the refetch behind it; the fetched answer covers a draft written diff --git a/frontend/src/lib/components/flows/linkedAgentDrafts.ts b/frontend/src/lib/components/flows/linkedAgentDrafts.ts index 4a9aef24b1..9367b21b12 100644 --- a/frontend/src/lib/components/flows/linkedAgentDrafts.ts +++ b/frontend/src/lib/components/flows/linkedAgentDrafts.ts @@ -3,11 +3,12 @@ import { type FlowModule, type FlowValue, type InputTransform, + type OperatorSettings, type Resource } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' -import { canWrite } from '$lib/utils' +import { canEditItem } from '$lib/editRights' import type { UserExt } from '$lib/stores' import { dfs } from './dfs' import { flowLocalInputs, type AIAgentConfig } from './agentResourceUtils' @@ -122,11 +123,15 @@ export interface LinkedAgentDraft { extraPerms: Record } -/** Whether `user` may write this agent's resource. Split from the load so that resolving the - * drafts of a whole flow costs no `whoami` — only the deploy dialog needs the answer, and it - * looks the user up once for every agent it lists. */ -export function agentDraftCanWrite(draft: LinkedAgentDraft, user: UserExt | undefined): boolean { - return canWrite(draft.path, draft.extraPerms, user) +/** Whether `user` may write this agent's resource, in a workspace with these `operator_settings`. + * Split from the load so that resolving the drafts of a whole flow costs no `whoami` — only the + * deploy dialog needs the answer, and it looks the user up once for every agent it lists. */ +export function agentDraftCanWrite( + draft: LinkedAgentDraft, + user: UserExt | undefined, + operatorSettings: OperatorSettings | undefined +): boolean { + return canEditItem('agent', draft.path, draft.extraPerms, user, operatorSettings) } /** A link that cannot resolve for the user rather than because something went wrong: the agent was diff --git a/frontend/src/lib/components/home/CreateActionsMenu.svelte b/frontend/src/lib/components/home/CreateActionsMenu.svelte index 3193438067..7a7615546a 100644 --- a/frontend/src/lib/components/home/CreateActionsMenu.svelte +++ b/frontend/src/lib/components/home/CreateActionsMenu.svelte @@ -27,13 +27,13 @@ import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' import { importStore } from '$lib/components/apps/store' import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useEditRights } from '$lib/operatorWriteRights' import { createDropdownMenu, melt } from '@melt-ui/svelte' import YAML from 'yaml' import type { Snippet } from 'svelte' import { logFeatureUsage } from '$lib/utils/featureUsage' - const operatorBuilderFlows = useOperatorBuilderFlows() + const editRights = useEditRights() interface Props { /** Replaces the default `New` button, e.g. with an inline text link. */ @@ -314,13 +314,10 @@ } } - // A builder composes runnables that already exist, so only flows are offered: everything else - // here writes code, which the backend refuses from an operator. + // Only the kinds this user's role authors: a builder operator gets flows alone. // Derived, not computed once: switching workspace only sets `workspaceStore`, it does not // remount this component, so a snapshot would keep the previous workspace's kinds. - const options: Option[] = $derived( - $operatorBuilderFlows ? allOptions.filter((o) => o.key === 'flow') : allOptions - ) + const options: Option[] = $derived(allOptions.filter((o) => editRights.roleCanAuthor(o.key))) // the doc panel only shows while an option is hovered or focused, so the menu opens compact let activeKey: string | undefined = $state(undefined) diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index 62479b70d1..503b4c2dca 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -16,6 +16,24 @@ prompt: 'Generate a weekly sales report from postgres and post it to Slack every Monday' } ] + + /** An operator's session can run what is deployed, read runs and call MCP servers, but + * cannot author scripts or apps. It composes flows only where the workspace grants + * `operator_settings.builder_flows`, so none of these asks it to build anything. */ + export const homeAIOperatorExamples: { label: string; prompt: string }[] = [ + { + label: 'Run a flow', + prompt: 'Find the flow that onboards a new employee and run it for Jane Doe, starting Monday' + }, + { + label: 'Rerun failed jobs', + prompt: 'List the jobs that failed today, tell me why, and rerun the ones that timed out' + }, + { + label: 'Summarize GitHub issues', + prompt: "Read this week's open GitHub issues and summarize the ones that mention billing" + } + ]