From 0a42af0082f5565588023642c0b87b49e23e36ca Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 2 Aug 2026 13:17:09 -0700 Subject: [PATCH 1/3] improvement(misc): settings organization, chat agent deploy request keys --- .../settings/[section]/settings.tsx | 1 + .../credit-usage/credit-usage-view.tsx | 2 +- .../settings/billing/credit-usage/loading.tsx | 2 +- .../components/billing/billing.test.tsx | 24 +- .../settings/components/billing/billing.tsx | 16 +- .../[workspaceId]/settings/navigation.test.ts | 104 ++++++--- .../[workspaceId]/settings/navigation.ts | 9 +- .../settings-sidebar/settings-sidebar.tsx | 4 +- .../components/settings/navigation.test.ts | 34 ++- apps/sim/components/settings/navigation.ts | 106 +++++---- .../standalone-settings-shell.test.ts | 11 + apps/sim/lib/api/contracts/deployments.ts | 1 + apps/sim/lib/api/contracts/v1/workflows.ts | 4 +- .../copilot/request/tools/executor.test.ts | 41 ++++ .../sim/lib/copilot/request/tools/executor.ts | 20 +- .../copilot/request/tools/permission.test.ts | 12 + .../lib/copilot/request/tools/permission.ts | 16 +- apps/sim/lib/copilot/request/types.ts | 2 + apps/sim/lib/copilot/tool-executor/types.ts | 4 + .../tools/handlers/deployment/context.test.ts | 49 ++++ .../tools/handlers/deployment/context.ts | 43 ++++ .../tools/handlers/deployment/deploy.test.ts | 212 ++++++++++++++++++ .../tools/handlers/deployment/deploy.ts | 28 +++ .../tools/handlers/deployment/manage.test.ts | 80 +++++++ .../tools/handlers/deployment/manage.ts | 13 +- .../orchestration/chat-deploy.test.ts | 20 ++ .../workflows/orchestration/chat-deploy.ts | 16 ++ .../workflows/orchestration/deploy.test.ts | 101 +++++++++ .../sim/lib/workflows/orchestration/deploy.ts | 60 ++++- 29 files changed, 926 insertions(+), 109 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/context.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index b9a95f6564c..9409ccc55e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -168,6 +168,7 @@ export function SettingsPage({ section }: SettingsPageProps) { )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx index 2c9f36ab1db..18402ab42ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx @@ -151,7 +151,7 @@ export function CreditUsageView({ backHref = '/account/settings/billing' }: Cred return ( router.push(backHref), }} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx index 934c025f57f..6071a86e11a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx @@ -17,7 +17,7 @@ export function CreditUsageLoading({ backHref }: CreditUsageLoadingProps) { return ( router.push(backHref), }} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx index 05da1f82dbe..1676ee4f0f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx @@ -161,7 +161,12 @@ vi.mock( ) vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ - SettingsPanel: ({ children }: { children: ReactNode }) =>
{children}
, + SettingsPanel: ({ children, description }: { children: ReactNode; description?: string }) => ( +
+ {description &&

{description}

} + {children} +
+ ), })) vi.mock( @@ -259,7 +264,13 @@ describe('Billing payer scope', () => { it('uses the target organization DTO for annual, canceled, credit, cap, and link state', async () => { await act(async () => { - root.render() + root.render( + + ) }) expect(mockUseSubscriptionData).toHaveBeenCalledWith( @@ -270,6 +281,9 @@ describe('Billing payer scope', () => { container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent ).toBe('Explore organization plans') expect(container.textContent).toContain('Organization Max for Teams plan') + expect(container.textContent).toContain( + 'Target organization’s subscription governs Production.' + ) expect(container.textContent).toContain('billed annually') expect(container.textContent).toContain('Access until') expect(container.textContent).toContain('Subscription canceled') @@ -293,13 +307,16 @@ describe('Billing payer scope', () => { it('uses a guaranteed personal payer workspace for account upgrades', async () => { await act(async () => { - root.render() + root.render() }) expect( container.querySelector('a[href="/workspace/personal-workspace/upgrade"]')?.textContent ).toBe('Explore personal plans') expect(container.textContent).toContain('Personal Pro plan') + expect(container.textContent).toContain( + 'Your personal subscription governs Personal workspace.' + ) }) it('renders an explicit free organization state without subscription controls', async () => { @@ -325,6 +342,7 @@ describe('Billing payer scope', () => { expect(container.textContent).toContain('Organization Free plan') expect(container.textContent).toContain('No active organization subscription') expect(container.textContent).not.toContain('Payment method') + expect(container.querySelector('main > p')).toBeNull() }) it('renders lapsed organization plans as ended rather than active', async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 851bc668d9c..2268a79ee4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -102,9 +102,15 @@ interface BillingProps { scope: 'account' | 'organization' organizationId?: string creditUsageHref?: string + governingWorkspaceName?: string } -export function Billing({ scope, organizationId, creditUsageHref }: BillingProps) { +export function Billing({ + scope, + organizationId, + creditUsageHref, + governingWorkspaceName, +}: BillingProps) { const router = useRouter() const isOrganizationScope = scope === 'organization' @@ -447,9 +453,15 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps const explorePlansLabel = isOrganizationScope ? 'Explore organization plans' : 'Explore personal plans' + const subscriptionOwner = isOrganizationScope + ? `${organizationBilling?.organizationName ?? 'The organization'}’s subscription` + : 'Your personal subscription' + const settingsDescription = governingWorkspaceName + ? `${subscriptionOwner} governs ${governingWorkspaceName}.` + : undefined return ( - +
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index 5c2a5bfe3e8..ed53da7b24d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -21,50 +21,88 @@ import { } from '@/app/workspace/[workspaceId]/settings/navigation' describe('unified settings navigation', () => { - it('preserves the original settings groups', () => { + it('groups settings by the scope they affect', () => { expect(sectionConfig).toEqual([ { key: 'account', title: 'Account' }, - { key: 'tools', title: 'Tools' }, - { key: 'subscription', title: 'Subscription' }, - { key: 'system', title: 'System' }, - { key: 'desktop', title: 'Desktop' }, - { key: 'enterprise', title: 'Enterprise' }, - { key: 'superuser', title: 'Superuser' }, + { key: 'workspace', title: 'Workspace' }, + { key: 'organization', title: 'Organization' }, + { key: 'platform', title: 'Platform' }, ]) }) it('keeps account, workspace, organization, and platform settings in one catalog', () => { expect(allNavigationItems.map(({ id, label, section }) => ({ id, label, section }))).toEqual([ { id: 'general', label: 'General', section: 'account' }, - { id: 'desktop', label: 'Desktop', section: 'desktop' }, - { id: 'browser', label: 'Browser', section: 'desktop' }, - { id: 'terminal', label: 'Terminal', section: 'desktop' }, - { id: 'access-control', label: 'Access control', section: 'enterprise' }, - { id: 'audit-logs', label: 'Audit logs', section: 'enterprise' }, - { id: 'forks', label: 'Workspace Forks', section: 'enterprise' }, - { id: 'billing', label: 'Billing', section: 'subscription' }, - { id: 'teammates', label: 'Teammates', section: 'subscription' }, - { id: 'organization', label: 'Organization', section: 'subscription' }, - { id: 'secrets', label: 'Secrets', section: 'account' }, - { id: 'custom-tools', label: 'Custom tools', section: 'tools' }, - { id: 'mcp', label: 'MCP tools', section: 'tools' }, - { id: 'apikeys', label: 'Sim API keys', section: 'system' }, - { id: 'workflow-mcp-servers', label: 'MCP servers', section: 'system' }, - { id: 'byok', label: 'BYOK', section: 'system' }, - { id: 'sandboxes', label: 'Sandboxes', section: 'system' }, - { id: 'inbox', label: 'Sim mailer', section: 'system' }, - { id: 'recently-deleted', label: 'Recently deleted', section: 'system' }, - { id: 'sso', label: 'Single sign-on', section: 'enterprise' }, - { id: 'sessions', label: 'Session policies', section: 'enterprise' }, - { id: 'data-retention', label: 'Data retention', section: 'enterprise' }, - { id: 'data-drains', label: 'Data drains', section: 'enterprise' }, - { id: 'whitelabeling', label: 'Whitelabeling', section: 'enterprise' }, - { id: 'custom-blocks', label: 'Custom blocks', section: 'enterprise' }, - { id: 'admin', label: 'Admin', section: 'superuser' }, - { id: 'mothership', label: 'Mothership', section: 'superuser' }, + { id: 'desktop', label: 'Desktop', section: 'account' }, + { id: 'browser', label: 'Browser', section: 'account' }, + { id: 'terminal', label: 'Terminal', section: 'account' }, + { id: 'access-control', label: 'Permission groups', section: 'organization' }, + { id: 'audit-logs', label: 'Audit logs', section: 'organization' }, + { id: 'forks', label: 'Workspace forks', section: 'organization' }, + { id: 'billing', label: 'Subscription', section: 'account' }, + { id: 'teammates', label: 'Teammates', section: 'workspace' }, + { id: 'organization', label: 'Members', section: 'organization' }, + { id: 'secrets', label: 'Secrets', section: 'workspace' }, + { id: 'custom-tools', label: 'Custom tools', section: 'workspace' }, + { id: 'mcp', label: 'MCP tools', section: 'workspace' }, + { id: 'apikeys', label: 'Sim API keys', section: 'workspace' }, + { id: 'workflow-mcp-servers', label: 'MCP servers', section: 'workspace' }, + { id: 'byok', label: 'BYOK', section: 'workspace' }, + { id: 'sandboxes', label: 'Sandboxes', section: 'workspace' }, + { id: 'inbox', label: 'Sim Mailer', section: 'workspace' }, + { id: 'recently-deleted', label: 'Recently deleted', section: 'workspace' }, + { id: 'sso', label: 'Single sign-on', section: 'organization' }, + { id: 'sessions', label: 'Session policies', section: 'organization' }, + { id: 'data-retention', label: 'Data retention', section: 'organization' }, + { id: 'data-drains', label: 'Data drains', section: 'organization' }, + { id: 'whitelabeling', label: 'White-labeling', section: 'organization' }, + { id: 'custom-blocks', label: 'Custom blocks', section: 'organization' }, + { id: 'admin', label: 'Admin', section: 'platform' }, + { id: 'mothership', label: 'Mothership', section: 'platform' }, ]) }) + it('orders each scope around its primary settings', () => { + const idsForSection = (section: (typeof sectionConfig)[number]['key']) => + allNavigationItems + .filter((item) => item.section === section) + .sort((left, right) => left.order - right.order) + .map(({ id }) => id) + + expect(idsForSection('account')).toEqual([ + 'general', + 'billing', + 'desktop', + 'browser', + 'terminal', + ]) + expect(idsForSection('workspace')).toEqual([ + 'teammates', + 'secrets', + 'mcp', + 'custom-tools', + 'byok', + 'inbox', + 'workflow-mcp-servers', + 'apikeys', + 'sandboxes', + 'recently-deleted', + ]) + expect(idsForSection('organization')).toEqual([ + 'organization', + 'custom-blocks', + 'forks', + 'access-control', + 'audit-logs', + 'whitelabeling', + 'sso', + 'sessions', + 'data-retention', + 'data-drains', + ]) + expect(idsForSection('platform')).toEqual(['admin', 'mothership']) + }) + it('derives every unified item from exactly one registry entry', () => { expect(allNavigationItems).toHaveLength( SETTINGS_SECTION_REGISTRY.filter(({ unified }) => unified).length diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index bcd36c0299e..d659a983c0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -16,12 +16,9 @@ export const isBillingEnabled = SETTINGS_NAVIGATION_BILLING_ENABLED export const sectionConfig: { key: NavigationSection; title: string }[] = [ { key: 'account', title: 'Account' }, - { key: 'tools', title: 'Tools' }, - { key: 'subscription', title: 'Subscription' }, - { key: 'system', title: 'System' }, - { key: 'desktop', title: 'Desktop' }, - { key: 'enterprise', title: 'Enterprise' }, - { key: 'superuser', title: 'Superuser' }, + { key: 'workspace', title: 'Workspace' }, + { key: 'organization', title: 'Organization' }, + { key: 'platform', title: 'Platform' }, ] export const allNavigationItems: NavigationItem[] = buildUnifiedSettingsNavigation() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 64c756ea880..5654c2e6cb1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -316,7 +316,9 @@ export function SettingsSidebar({ .map(({ key, title }) => ({ key, title, - items: navigationItems.filter((item) => item.section === key), + items: navigationItems + .filter((item) => item.section === key) + .sort((left, right) => left.order - right.order), })) .filter(({ items }) => items.length > 0) .map(({ key, title, items: sectionItems }, index) => ( diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 0bf912e7df4..ca1b92fd241 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -191,7 +191,7 @@ describe('settings navigation boundaries', () => { expect(organizationSso?.docsLink).toBe(unifiedSso?.docsLink) }) - it('keeps scope-specific labels only where the surface genuinely differs', () => { + it('uses scope-specific labels consistently across settings surfaces', () => { const organizationMembers = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'members') const unifiedOrganization = buildUnifiedSettingsNavigation().find( ({ id }) => id === 'organization' @@ -199,7 +199,37 @@ describe('settings navigation boundaries', () => { expect(organizationMembers?.label).toBe('Members') expect(organizationMembers?.description).toBe('Manage organization members, roles, and seats.') - expect(unifiedOrganization?.label).toBe('Organization') + expect(unifiedOrganization?.label).toBe('Members') + }) + + it('keeps self-host settings on their standalone account projection', () => { + expect( + SELFHOST_SETTINGS_ITEMS.map(({ id, label, description, group }) => ({ + id, + label, + description, + group, + })) + ).toEqual([ + { + id: 'general', + label: 'General', + description: 'Manage your profile, appearance, and preferences.', + group: 'account', + }, + { + id: 'billing', + label: 'Subscription', + description: 'Manage your personal plan, usage, and invoices.', + group: 'account', + }, + { + id: 'chat-keys', + label: 'Chat keys', + description: 'Manage the model-provider keys that power Chat.', + group: 'developer', + }, + ]) }) it('builds canonical settings hrefs across all three planes', () => { diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 3557160e1d1..1e9a2c79c79 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -120,14 +120,7 @@ export type UnifiedSettingsSection = | 'mothership' | 'recently-deleted' -export type UnifiedNavigationSection = - | 'account' - | 'subscription' - | 'tools' - | 'system' - | 'desktop' - | 'enterprise' - | 'superuser' +export type UnifiedNavigationSection = 'account' | 'workspace' | 'organization' | 'platform' /** * A bridge surface the desktop shell must expose for a section to be worth @@ -142,6 +135,7 @@ export interface UnifiedSettingsNavigationItem { description: string icon: ComponentType<{ className?: string }> section: UnifiedNavigationSection + order: number hideWhenBillingDisabled?: boolean requiresTeam?: boolean requiresEnterprise?: boolean @@ -384,6 +378,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'general', description: 'Manage your profile, appearance, and preferences.', group: 'account', + order: 0, }, planes: { account: { id: 'general', group: 'account', order: 0 }, @@ -396,7 +391,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'desktop', description: 'Manage notifications, startup, local folders, and updates.', - group: 'desktop', + group: 'account', + order: 2, requiresDesktopSurface: 'settings', }, }, @@ -406,7 +402,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'browser', description: 'Control the browser Chat drives and the data it keeps.', - group: 'desktop', + group: 'account', + order: 3, requiresDesktopSurface: 'browser', }, }, @@ -416,18 +413,20 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'terminal', description: 'Control the shells Chat runs commands in.', - group: 'desktop', + group: 'account', + order: 4, requiresDesktopSurface: 'terminal', }, }, { - label: 'Access control', + label: 'Permission groups', icon: ShieldCheck, docsLink: 'https://docs.sim.ai/platform/enterprise/access-control', unified: { id: 'access-control', description: 'Manage permission groups across your organization.', - group: 'enterprise', + group: 'organization', + order: 3, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, @@ -443,7 +442,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'audit-logs', description: 'Review activity and changes across your organization.', - group: 'enterprise', + group: 'organization', + order: 4, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, @@ -453,25 +453,27 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, { - label: 'Workspace Forks', + label: 'Workspace forks', icon: Shuffle, docsLink: 'https://docs.sim.ai/platform/enterprise/forks', unified: { id: 'forks', description: 'Fork this workspace and sync changes with its parent.', - group: 'enterprise', + group: 'organization', + order: 2, }, planes: { workspace: { id: 'forks', group: 'enterprise', order: 10 }, }, }, { - label: 'Billing', + label: 'Subscription', icon: ClipboardList, unified: { id: 'billing', description: 'Manage your plan, pricing, and invoices.', - group: 'subscription', + group: 'account', + order: 1, hideWhenBillingDisabled: true, }, planes: { @@ -501,19 +503,21 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'teammates', description: 'Manage your teammates in this workspace.', - group: 'subscription', + group: 'workspace', + order: 0, }, planes: { workspace: { id: 'teammates', group: 'workspace', order: 0 }, }, }, { - label: 'Organization', + label: 'Members', icon: Users, unified: { id: 'organization', description: "Manage your organization's members and seats.", - group: 'subscription', + group: 'organization', + order: 0, hideWhenBillingDisabled: true, requiresHosted: true, requiresTeam: true, @@ -521,7 +525,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] planes: { organization: { id: 'members', - label: 'Members', description: 'Manage organization members, roles, and seats.', group: 'organization', order: 0, @@ -534,7 +537,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'secrets', description: 'Store environment variables for your workflows.', - group: 'account', + group: 'workspace', + order: 1, }, planes: { workspace: { id: 'secrets', group: 'workspace', order: 1 }, @@ -546,7 +550,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'custom-tools', description: 'Create and manage custom tools for your agents.', - group: 'tools', + group: 'workspace', + order: 3, }, planes: { workspace: { id: 'custom-tools', group: 'tools', order: 4 }, @@ -557,8 +562,9 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] icon: McpIcon, unified: { id: 'mcp', - description: 'Connect MCP servers and use their tools in workflows.', - group: 'tools', + description: 'Connect external MCP servers and use their tools in this workspace.', + group: 'workspace', + order: 2, }, planes: { workspace: { id: 'mcp', group: 'tools', order: 5 }, @@ -570,7 +576,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'apikeys', description: 'Create and manage API keys for the Sim API.', - group: 'system', + group: 'workspace', + order: 7, }, planes: { account: { @@ -592,8 +599,9 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] icon: Server, unified: { id: 'workflow-mcp-servers', - description: 'Expose your workflows as tools on an MCP server.', - group: 'system', + description: 'Expose workflows from this workspace as tools on an MCP server.', + group: 'workspace', + order: 6, }, planes: { workspace: { id: 'workflow-mcp-servers', group: 'tools', order: 6 }, @@ -605,7 +613,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'byok', description: 'Bring your own model-provider API keys.', - group: 'system', + group: 'workspace', + order: 4, requiresHosted: true, }, planes: { @@ -619,7 +628,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'sandboxes', description: 'Install Python or npm packages for Function blocks to import.', - group: 'system', + group: 'workspace', + order: 8, requiresMax: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sandboxes, showWhenLocked: true, @@ -641,12 +651,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, { - label: 'Sim mailer', + label: 'Sim Mailer', icon: Send, unified: { id: 'inbox', description: 'Trigger and process workflows from incoming email.', - group: 'system', + group: 'workspace', + order: 5, requiresMax: true, requiresHosted: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.inbox, @@ -662,7 +673,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'recently-deleted', description: 'Restore items deleted in the last 30 days.', - group: 'system', + group: 'workspace', + order: 9, }, planes: { workspace: { id: 'recently-deleted', group: 'system', order: 9 }, @@ -675,7 +687,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'sso', description: 'Configure single sign-on for your organization.', - group: 'enterprise', + group: 'organization', + order: 6, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso, @@ -691,7 +704,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'sessions', description: 'Limit session lifetimes and sign out members org-wide.', - group: 'enterprise', + group: 'organization', + order: 7, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, @@ -708,7 +722,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'data-retention', description: 'Control data retention windows and PII redaction. Workspaces without an override inherit the organization defaults.', - group: 'enterprise', + group: 'organization', + order: 8, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, @@ -724,7 +739,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'data-drains', description: 'Stream your logs and events to external destinations.', - group: 'enterprise', + group: 'organization', + order: 9, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, @@ -734,13 +750,14 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, { - label: 'Whitelabeling', + label: 'White-labeling', icon: Palette, docsLink: 'https://docs.sim.ai/platform/enterprise/whitelabeling', unified: { id: 'whitelabeling', description: 'Customize your workspace branding and appearance.', - group: 'enterprise', + group: 'organization', + order: 5, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, @@ -756,7 +773,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'custom-blocks', description: 'Publish workflows as reusable blocks for your organization.', - group: 'enterprise', + group: 'organization', + order: 1, requiresHosted: true, requiresEnterprise: true, allowNonOrgAdmin: true, @@ -772,7 +790,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'admin', description: 'Superuser administration and workspace tools.', - group: 'superuser', + group: 'platform', + order: 0, requiresAdminRole: true, }, planes: { @@ -785,7 +804,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] unified: { id: 'mothership', description: 'Internal Sim operations and license management.', - group: 'superuser', + group: 'platform', + order: 1, requiresAdminRole: true, }, planes: { diff --git a/apps/sim/components/settings/standalone-settings-shell.test.ts b/apps/sim/components/settings/standalone-settings-shell.test.ts index 6927d523def..720bcdfff56 100644 --- a/apps/sim/components/settings/standalone-settings-shell.test.ts +++ b/apps/sim/components/settings/standalone-settings-shell.test.ts @@ -8,6 +8,7 @@ import { ORGANIZATION_SETTINGS_ITEMS, ORGANIZATION_SETTINGS_PATH_ALIASES, parseSettingsPathSection, + SELFHOST_SETTINGS_ITEMS, } from '@/components/settings/navigation' describe('standalone settings section resolution', () => { @@ -32,4 +33,14 @@ describe('standalone settings section resolution', () => { }) ).toBe('audit-logs') }) + + it('keeps Subscription active for the self-host billing route', () => { + expect( + parseSettingsPathSection({ + path: '/selfhost/settings/billing', + items: SELFHOST_SETTINGS_ITEMS, + defaultSection: 'general', + }) + ).toBe('billing') + }) }) diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index db8e82adb6b..197ebec5c22 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -80,6 +80,7 @@ export const deploymentOperationSummarySchema = z.object({ version: z.number().int().positive(), action: z.enum(DEPLOYMENT_OPERATION_ACTIONS), status: deploymentOperationStatusSchema, + isCurrent: z.boolean().optional().default(true), readiness: deploymentReadinessSchema, requestedAt: z.string(), activatedAt: z.string().nullable().optional(), diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts index 10ef1c35205..8f15312ffaf 100644 --- a/apps/sim/lib/api/contracts/v1/workflows.ts +++ b/apps/sim/lib/api/contracts/v1/workflows.ts @@ -102,7 +102,9 @@ const v1DeploymentStateSchema = z.object({ * accepted, while `isDeployed` reflects whether a version is actually live. * `latestDeploymentAttempt` carries the lifecycle status * (preparing/activating/active/failed/superseded) so API consumers can poll - * to a terminal state instead of guessing from `isDeployed` alone. + * to a terminal state instead of guessing from `isDeployed` alone. Its + * `isCurrent` field is false when the operation is historical and no longer + * describes the active deployment. */ const v1DeploymentLifecycleSchema = v1DeploymentStateSchema.extend({ activeDeployment: activeDeploymentSummarySchema.nullable(), diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 656feb72663..c1395d56cdb 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -3,9 +3,11 @@ import '@sim/testing/mocks/executor' import { describe, expect, it } from 'vitest' import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants' import { + buildToolExecutionContext, pendingToolWaitBudgetMs, toolWatchdogTimeoutMs, } from '@/lib/copilot/request/tools/executor' +import type { ExecutionContext } from '@/lib/copilot/request/types' describe('toolWatchdogTimeoutMs', () => { it('gives request-scoped MCP tools the long-running watchdog', () => { @@ -32,3 +34,42 @@ describe('pendingToolWaitBudgetMs', () => { ) }) }) + +describe('buildToolExecutionContext', () => { + it('threads logical tool-call identity and server approval into the handler context', () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + runId: 'run-1', + } + + expect( + buildToolExecutionContext( + { + id: 'call-1', + parentToolCallId: 'parent-1', + userApproved: true, + }, + executionContext + ) + ).toMatchObject({ + runId: 'run-1', + toolCallId: 'call-1', + parentToolCallId: 'parent-1', + userApprovedToolCall: true, + }) + }) + + it('does not inherit approval from the turn-scoped context', () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + userApprovedToolCall: true, + } + + expect(buildToolExecutionContext({ id: 'call-2' }, executionContext)).toMatchObject({ + toolCallId: 'call-2', + userApprovedToolCall: false, + }) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 17106c64ce3..e5ac0176fc2 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -255,6 +255,19 @@ class ToolExecutionTimeoutError extends Error { } } +/** Builds the per-call context from the turn-scoped execution context. */ +export function buildToolExecutionContext( + toolCall: Pick, + execContext: ExecutionContext +): ExecutionContext { + return { + ...execContext, + toolCallId: toolCall.id, + userApprovedToolCall: toolCall.userApproved === true, + ...(toolCall.parentToolCallId ? { parentToolCallId: toolCall.parentToolCallId } : {}), + } +} + /** * Execute a tool with a hard settlement guarantee. If the handler neither * resolves nor rejects within the tool's watchdog cap, throw a timeout error @@ -265,12 +278,7 @@ class ToolExecutionTimeoutError extends Error { */ async function executeToolWithWatchdog(toolCall: ToolCallState, execContext: ExecutionContext) { const timeoutMs = toolWatchdogTimeoutMs(toolCall.name) - // Thread the invoking subagent's channel id per call (execContext is shared - // across the whole turn, so the channel id can't live on it) — server tools - // use it to scope the workspace_file -> edit_content intent handoff. - const toolContext = toolCall.parentToolCallId - ? { ...execContext, parentToolCallId: toolCall.parentToolCallId } - : execContext + const toolContext = buildToolExecutionContext(toolCall, execContext) const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext) let timer: ReturnType | undefined try { diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 4cd2d3f7140..a9b5ba1e086 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -94,6 +94,17 @@ describe('toolCallNeedsApproval', () => { expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false) }) + it.each(['deploy_api', 'deploy_chat', 'deploy_mcp'])( + 'always gates a %s undeploy even when the tool was previously allowed', + (toolName) => { + const context = makeContext() + context.toolPermissions.autoAllowed.add(toolName) + + expect(toolCallNeedsApproval(toolName, context, {}, false, { action: 'undeploy' })).toBe(true) + expect(toolCallNeedsApproval(toolName, context, {}, false, { action: 'deploy' })).toBe(false) + } + ) + it('applies the normal saved permission to code with a secret reference', () => { const context = makeContext() context.toolPermissions.autoAllowed.add('function_execute') @@ -305,6 +316,7 @@ describe('runGatedToolExecution', () => { await gate(context, toolCall, execute, []) expect(execute).toHaveBeenCalledTimes(1) + expect(toolCall.userApproved).toBe(true) expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) }) diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts index a9829c8c155..56ae5ca9d51 100644 --- a/apps/sim/lib/copilot/request/tools/permission.ts +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -43,6 +43,17 @@ function terminalOperationNeedsApproval(args: Record | undefine return args?.operation === 'run' } +/** Destructive deployment calls always require approval for the exact call. */ +function callRequiresFreshApproval( + toolName: string, + args: Record | undefined +): boolean { + return ( + (toolName === 'deploy_api' || toolName === 'deploy_chat' || toolName === 'deploy_mcp') && + args?.action === 'undeploy' + ) +} + /** * A human can take as long as they like to answer, so the wait is bounded only * by the overall orchestration budget rather than a per-tool watchdog. @@ -89,7 +100,9 @@ export function toolCallNeedsApproval( } } - return !context.toolPermissions.autoAllowed.has(toolName) + return ( + callRequiresFreshApproval(toolName, args) || !context.toolPermissions.autoAllowed.has(toolName) + ) } function skipOutput(toolName: string) { @@ -300,6 +313,7 @@ export function runGatedToolExecution( return { status: MothershipStreamV1ToolOutcome.success, message: output.message } } + toolCall.userApproved = true await emitApprovedCall(toolCallId, toolName, executor, args, options) const execution = execute() diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index bf4908896db..cdfc1b7b66d 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -43,6 +43,8 @@ export interface ToolCallState { * for main-lane tool calls. */ parentToolCallId?: string + /** Set only after the server-side permission gate approves this exact call. */ + userApproved?: boolean } export type ToolCallResult = ToolExecutionResult & { diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 93db4b4eb23..7e8ecfb65d0 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -11,6 +11,10 @@ export interface ToolExecutionContext { messageId?: string executionId?: string runId?: string + /** Stable identity of the individual tool call being executed. */ + toolCallId?: string + /** True only after the server-side permission gate approved this exact call. */ + userApprovedToolCall?: boolean billingAttribution?: BillingAttributionSnapshot copilotToolExecution?: boolean requestMode?: string diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts new file mode 100644 index 00000000000..c40c02acfd6 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getCopilotDeploymentIdempotencyKey, + getHistoricalDeploymentAttemptError, + getUnapprovedUndeployError, +} from '@/lib/copilot/tools/handlers/deployment/context' + +describe('getCopilotDeploymentIdempotencyKey', () => { + it('is stable for a replay of the same logical tool call', () => { + const context = { executionId: 'execution-1', runId: 'run-1', toolCallId: 'call-1' } + + expect(getCopilotDeploymentIdempotencyKey(context)).toBe( + getCopilotDeploymentIdempotencyKey(context) + ) + }) + + it('separates different tool calls within the same Mothership execution', () => { + expect( + getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1', toolCallId: 'call-1' }) + ).not.toBe( + getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1', toolCallId: 'call-2' }) + ) + }) + + it('does not derive a turn-wide key when the tool-call identity is unavailable', () => { + expect(getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1' })).toBeUndefined() + }) +}) + +describe('getUnapprovedUndeployError', () => { + it('fails closed unless the server approved this exact tool call', () => { + expect(getUnapprovedUndeployError({ userApprovedToolCall: false })).toContain( + 'requires explicit approval' + ) + expect(getUnapprovedUndeployError({ userApprovedToolCall: true })).toBeNull() + }) +}) + +describe('getHistoricalDeploymentAttemptError', () => { + it('requires a new tool call when the persisted attempt is no longer current', () => { + expect(getHistoricalDeploymentAttemptError({ isCurrent: false }, 'redeploy')).toContain( + 'Start a new tool call' + ) + expect(getHistoricalDeploymentAttemptError({ isCurrent: true }, 'redeploy')).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.ts new file mode 100644 index 00000000000..ed8e0ad5e89 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.ts @@ -0,0 +1,43 @@ +import type { ToolExecutionContext } from '@/lib/copilot/tool-executor/types' + +type DeploymentToolContext = Pick< + ToolExecutionContext, + 'executionId' | 'messageId' | 'runId' | 'toolCallId' +> + +interface DeploymentAttemptCurrentState { + isCurrent?: boolean +} + +/** + * Builds a replay-stable idempotency key for one logical Copilot tool call. + * The orchestration layer generates a fresh key when legacy callers do not + * provide a tool-call identity. + */ +export function getCopilotDeploymentIdempotencyKey( + context: DeploymentToolContext +): string | undefined { + if (!context.toolCallId) return undefined + + const executionScope = context.executionId ?? context.runId ?? context.messageId + return executionScope + ? `copilot:${executionScope}:tool-call:${context.toolCallId}` + : `copilot:tool-call:${context.toolCallId}` +} + +/** Returns the error used when an undeploy did not receive per-call user approval. */ +export function getUnapprovedUndeployError( + context: Pick +): string | null { + if (context.userApprovedToolCall === true) return null + return 'Undeploy requires explicit approval for this exact interactive Copilot call. Never undeploy to recover a failed deploy or redeploy; a failed redeploy already leaves the prior live version active.' +} + +/** Rejects a replay whose persisted operation no longer describes production. */ +export function getHistoricalDeploymentAttemptError( + attempt: DeploymentAttemptCurrentState | null | undefined, + action: string +): string | null { + if (attempt?.isCurrent !== false) return null + return `The ${action} operation associated with this tool call is historical and no longer describes production. Start a new tool call to create a new logical deployment operation.` +} diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts new file mode 100644 index 00000000000..8e1d6701aee --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts @@ -0,0 +1,212 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnsureWorkflowAccess, mockPerformFullDeploy, mockPerformFullUndeploy } = vi.hoisted( + () => ({ + mockEnsureWorkflowAccess: vi.fn(), + mockPerformFullDeploy: vi.fn(), + mockPerformFullUndeploy: vi.fn(), + }) +) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performChatDeploy: vi.fn(), + performChatUndeploy: vi.fn(), + performFullDeploy: mockPerformFullDeploy, + performFullUndeploy: mockPerformFullUndeploy, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateWorkflowMcpTool: vi.fn(), + performDeleteWorkflowMcpTool: vi.fn(), + performUpdateWorkflowMcpTool: vi.fn(), +})) + +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + getDeployedWorkflowInputFormat: vi.fn(), +})) + +vi.mock('@/lib/mcp/workflow-tool-schema', () => ({ + applyDescriptionOverrides: vi.fn(), + generateToolInputSchema: vi.fn(), + sanitizeToolName: vi.fn(), +})) + +vi.mock('@/app/api/chat/utils', () => ({ + checkChatAccess: vi.fn(), + checkWorkflowAccessForChatCreation: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + ChatDeployAuthNotAllowedError: class ChatDeployAuthNotAllowedError extends Error {}, + validateChatDeployAuth: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkflowAccess: mockEnsureWorkflowAccess, +})) + +import { + executeDeployApi, + executeDeployChat, + executeDeployMcp, + executeRedeploy, +} from '@/lib/copilot/tools/handlers/deployment/deploy' + +describe('executeDeployApi', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnsureWorkflowAccess.mockResolvedValue({ + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + }) + }) + + it('refuses undeploy without approval for the exact tool call', async () => { + const result = await executeDeployApi( + { workflowId: 'workflow-1', action: 'undeploy' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + toolCallId: 'call-1', + userApprovedToolCall: false, + } + ) + + expect(result).toMatchObject({ success: false, error: expect.stringContaining('approval') }) + expect(mockPerformFullUndeploy).not.toHaveBeenCalled() + }) + + it('allows an explicitly approved undeploy', async () => { + mockPerformFullUndeploy.mockResolvedValue({ success: true }) + + const result = await executeDeployApi( + { workflowId: 'workflow-1', action: 'undeploy' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + toolCallId: 'call-1', + userApprovedToolCall: true, + } + ) + + expect(result.success).toBe(true) + expect(mockPerformFullUndeploy).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + userId: 'user-1', + }) + }) + + it('uses the tool-call identity for deployment idempotency', async () => { + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'preparing' }, + }) + + await executeDeployApi( + { + workflowId: 'workflow-1', + action: 'deploy', + versionName: 'Safe deploy', + versionDescription: 'Deploy the latest workflow changes', + }, + { + userId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } + ) + + expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + idempotencyKey: 'copilot:execution-1:tool-call:call-1', + }) + ) + }) + + it('rejects a replay whose active deployment attempt became historical', async () => { + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + }) + + const result = await executeDeployApi( + { + workflowId: 'workflow-1', + action: 'deploy', + versionName: 'Safe deploy', + versionDescription: 'Deploy the latest workflow changes', + }, + { + userId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } + ) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) + + it('does not report a historical active attempt as a successful redeploy', async () => { + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + }) + + const result = await executeRedeploy( + { + workflowId: 'workflow-1', + versionName: 'Safe redeploy', + versionDescription: 'Redeploy the latest workflow changes', + }, + { + userId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } + ) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) + + it('refuses chat undeploy without exact-call approval', async () => { + const result = await executeDeployChat( + { workflowId: 'workflow-1', action: 'undeploy' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + toolCallId: 'call-1', + userApprovedToolCall: false, + } + ) + + expect(result).toMatchObject({ success: false, error: expect.stringContaining('approval') }) + }) + + it('refuses MCP undeploy without exact-call approval', async () => { + const result = await executeDeployMcp( + { workflowId: 'workflow-1', serverId: 'server-1', action: 'undeploy' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + toolCallId: 'call-1', + userApprovedToolCall: false, + } + ) + + expect(result).toMatchObject({ success: false, error: expect.stringContaining('approval') }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index f0d14b9ba29..ec4686974f1 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -28,6 +28,11 @@ import { } from '@/ee/access-control/utils/permission-check' import { ensureWorkflowAccess } from '../access' import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' +import { + getCopilotDeploymentIdempotencyKey, + getHistoricalDeploymentAttemptError, + getUnapprovedUndeployError, +} from './context' function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { return `${baseUrl}/api/workflows/${workflowId}/execute` @@ -135,6 +140,9 @@ export async function executeDeployApi( ) if (action === 'undeploy') { + const approvalError = getUnapprovedUndeployError(context) + if (approvalError) return { success: false, error: approvalError } + const result = await performFullUndeploy({ workflowId, userId: context.userId }) if (!result.success) { return { success: false, error: result.error || 'Failed to undeploy workflow' } @@ -190,10 +198,16 @@ export async function executeDeployApi( userId: context.userId, versionDescription, versionName, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { return { success: false, error: result.error || 'Failed to deploy workflow' } } + const historicalAttemptError = getHistoricalDeploymentAttemptError( + result.latestDeploymentAttempt, + 'deploy' + ) + if (historicalAttemptError) return { success: false, error: historicalAttemptError } const baseUrl = getBaseUrl() const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) @@ -249,6 +263,9 @@ export async function executeDeployChat( const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' if (action === 'undeploy') { + const approvalError = getUnapprovedUndeployError(context) + if (approvalError) return { success: false, error: approvalError } + const baseUrl = getBaseUrl() const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) @@ -451,6 +468,7 @@ export async function executeDeployChat( includeThinking: resolvedIncludeThinking, includeToolCalls: resolvedIncludeToolCalls, workspaceId: workflowRecord.workspaceId, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { @@ -536,6 +554,10 @@ export async function executeDeployMcp( if (!workflowId) { return { success: false, error: 'workflowId is required' } } + if (params.action === 'undeploy') { + const approvalError = getUnapprovedUndeployError(context) + if (approvalError) return { success: false, error: approvalError } + } const { workflow: workflowRecord } = await ensureWorkflowAccess( workflowId, @@ -827,10 +849,16 @@ export async function executeRedeploy( userId: context.userId, versionDescription, versionName, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { return { success: false, error: result.error || 'Failed to redeploy workflow' } } + const historicalAttemptError = getHistoricalDeploymentAttemptError( + result.latestDeploymentAttempt, + 'redeploy' + ) + if (historicalAttemptError) return { success: false, error: historicalAttemptError } const baseUrl = getBaseUrl() const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index eb2cc11467b..fd8ba3c06d2 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -169,12 +169,18 @@ describe('executePromoteToLive', () => { performActivateVersionMock.mockResolvedValue({ success: true, deployedAt: new Date('2026-05-30T00:00:00.000Z'), + activeDeployment: { + deploymentVersionId: 'dv-3', + version: 3, + deployedAt: '2026-05-30T00:00:00.000Z', + }, latestDeploymentAttempt: { id: 'op-1', deploymentVersionId: 'dv-3', version: 3, action: 'activate', status: 'active', + isCurrent: true, readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, requestedAt: '2026-05-30T00:00:00.000Z', activatedAt: '2026-05-30T00:00:00.000Z', @@ -185,6 +191,8 @@ describe('executePromoteToLive', () => { const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, { userId: 'user-1', workflowId: 'wf-1', + executionId: 'execution-1', + toolCallId: 'call-1', } as ExecutionContext) expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') @@ -192,6 +200,7 @@ describe('executePromoteToLive', () => { workflowId: 'wf-1', version: 3, userId: 'user-1', + idempotencyKey: 'copilot:execution-1:tool-call:call-1', }) expect(result.success).toBe(true) expect(result.output).toMatchObject({ @@ -203,6 +212,37 @@ describe('executePromoteToLive', () => { }) }) + it('does not report a historical active operation as a successful promotion', async () => { + performActivateVersionMock.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { + id: 'op-old', + deploymentVersionId: 'dv-3', + version: 3, + action: 'activate', + status: 'active', + isCurrent: false, + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, + requestedAt: '2026-05-30T00:00:00.000Z', + activatedAt: '2026-05-30T00:00:00.000Z', + error: null, + }, + }) + + const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, { + userId: 'user-1', + workflowId: 'wf-1', + executionId: 'execution-1', + toolCallId: 'call-1', + } as ExecutionContext) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) + it('rejects a non-numeric version like "live"', async () => { const result = await executePromoteToLive({ workflowId: 'wf-1', version: 'live' as never }, { userId: 'user-1', @@ -376,4 +416,44 @@ describe('executeCheckDeploymentStatus', () => { }, }) }) + + it('separates a historical active attempt from the current undeployed state', async () => { + getWorkflowDeploymentSummaryMock.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: { + id: 'op-historical', + deploymentVersionId: 'dv-old', + version: 1, + action: 'deploy', + status: 'active', + isCurrent: false, + readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, + requestedAt: '2026-05-28T00:00:00.000Z', + activatedAt: '2026-05-28T00:00:00.000Z', + error: null, + }, + warnings: ['The latest successful deployment attempt is historical.'], + }) + queueTableRows(schemaMock.workflow, [{ deployedAt: null }]) + + const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + isDeployed: false, + api: { + isDeployed: false, + activeDeployment: null, + latestDeploymentAttempt: { + status: 'active', + isCurrent: false, + }, + currentDeploymentAttempt: null, + warnings: [expect.stringContaining('historical')], + }, + }) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index ff61d1762d1..4224a0d0ce5 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -32,6 +32,7 @@ import type { UpdateDeploymentVersionParams, UpdateWorkspaceMcpServerParams, } from '../param-types' +import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' import { resolveWorkflowStateRef } from './state-refs' export async function executeCheckDeploymentStatus( @@ -79,6 +80,9 @@ export async function executeCheckDeploymentStatus( */ const isApiDeployed = deploymentSummary.activeDeployment !== null const needsRedeployment = isApiDeployed ? await checkNeedsRedeployment(workflowId) : false + const currentDeploymentAttempt = deploymentSummary.latestDeploymentAttempt?.isCurrent + ? deploymentSummary.latestDeploymentAttempt + : null const apiDetails = { isDeployed: isApiDeployed, deployedAt: apiDeploy[0]?.deployedAt || null, @@ -87,6 +91,7 @@ export async function executeCheckDeploymentStatus( needsRedeployment, activeDeployment: deploymentSummary.activeDeployment, latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, + currentDeploymentAttempt, warnings: deploymentSummary.warnings ?? [], } @@ -557,13 +562,19 @@ export async function executePromoteToLive( workflowId, version, userId: context.userId, + idempotencyKey: getCopilotDeploymentIdempotencyKey(context), }) if (!result.success) { return { success: false, error: result.error || 'Failed to promote version' } } + const historicalAttemptError = getHistoricalDeploymentAttemptError( + result.latestDeploymentAttempt, + 'promotion' + ) + if (historicalAttemptError) return { success: false, error: historicalAttemptError } - const isActive = result.latestDeploymentAttempt?.status === 'active' + const isActive = result.activeDeployment?.version === version return { success: true, output: { diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts index 7b48bab36e1..9ee006b26be 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts @@ -89,4 +89,24 @@ describe('performChatDeploy password guards', () => { error: 'Password is required when using password protection', }) }) + + it('does not create a chat from a historical active deployment attempt', async () => { + mockGetWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + warnings: [], + }) + mockPerformFullDeploy.mockResolvedValue({ + success: true, + activeDeployment: null, + latestDeploymentAttempt: { status: 'active', isCurrent: false }, + }) + + const result = await performChatDeploy(basePayload) + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('historical'), + }) + }) }) diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 106cb090df0..cdfffe62237 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -35,6 +35,8 @@ export interface ChatDeployPayload { /** When true, public SSE may expose tool lifecycle if the client opts into agent-events-v1. */ includeToolCalls?: boolean workspaceId?: string | null + /** Stable identity for the underlying workflow deployment operation. */ + idempotencyKey?: string } export interface PerformChatDeployResult { @@ -114,10 +116,18 @@ export async function performChatDeploy( userId, versionDescription: params.versionDescription, versionName: params.versionName, + idempotencyKey: params.idempotencyKey, }) if (!deployResult.success) { return { success: false, error: deployResult.error || 'Failed to deploy workflow' } } + if (deployResult.latestDeploymentAttempt?.isCurrent === false) { + return { + success: false, + error: + 'The workflow deployment attempt is historical and no longer describes production. Retry chat deployment as a new tool call.', + } + } if (deployResult.latestDeploymentAttempt?.status !== 'active') { return { success: false, @@ -126,6 +136,12 @@ export async function performChatDeploy( 'Workflow deployment is still preparing. Retry chat deployment after it becomes active.', } } + if (!deployResult.activeDeployment) { + return { + success: false, + error: 'Workflow deployment reported active without a live deployment version.', + } + } } let encryptedPassword: string | null = null diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index e70aea0eba0..4d194892e17 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -101,6 +101,7 @@ vi.mock('@/lib/workflows/schedules', () => ({ // Resolves to the global @sim/platform-authz/workflow mock, so instanceof matches. import { WorkflowLockedError } from '@sim/platform-authz/workflow' import { + getWorkflowDeploymentSummary, performActivateVersion, performFullDeploy, performFullUndeploy, @@ -262,6 +263,50 @@ describe('performFullDeploy workspace event emission', () => { }) }) + it('marks the latest active operation historical when no matching version is live', async () => { + const now = new Date('2026-07-14T08:00:00.000Z') + mockGetWorkflowDeploymentStatus.mockResolvedValueOnce({ + activeDeployment: null, + latestOperation: { + id: 'operation-historical', + workflowId: 'workflow-1', + deploymentVersionId: 'dv-old', + version: 3, + previousActiveVersionId: null, + action: 'deploy', + protocolVersion: 2, + generation: 1, + status: 'active', + componentReadiness: { + webhooks: { status: 'ready', updatedAt: now.toISOString() }, + schedules: { status: 'ready', updatedAt: now.toISOString() }, + mcp: { status: 'ready', updatedAt: now.toISOString() }, + }, + errorCode: null, + errorMessage: null, + idempotencyKey: 'request-historical', + requestHash: 'hash', + actorId: 'user-1', + completedAt: now, + createdAt: now, + updatedAt: now, + }, + }) + + const result = await getWorkflowDeploymentSummary('workflow-1') + + expect(result).toMatchObject({ + activeDeployment: null, + latestDeploymentAttempt: { + id: 'operation-historical', + status: 'active', + isCurrent: false, + error: null, + }, + warnings: [expect.stringContaining('historical')], + }) + }) + it('always admits deploys through v2 without legacy immediate activation', async () => { const result = await performFullDeploy({ workflowId: 'workflow-1', @@ -277,6 +322,62 @@ describe('performFullDeploy workspace event emission', () => { expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() }) + it('does not reuse a correlation request ID as an implicit idempotency key', async () => { + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + ]) + + const params = { workflowId: 'workflow-1', userId: 'user-1', requestId: 'request-1' } + await performFullDeploy(params) + await performFullDeploy(params) + + const firstKey = mockPrepareWorkflowDeployment.mock.calls[0][0].idempotencyKey + const secondKey = mockPrepareWorkflowDeployment.mock.calls[1][0].idempotencyKey + expect(firstKey).toEqual(expect.any(String)) + expect(secondKey).toEqual(expect.any(String)) + expect(firstKey).not.toBe('request-1') + expect(firstKey).not.toBe(secondKey) + }) + + it('keeps the request hash stable across snapshot timestamps and edge order', async () => { + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + ]) + const baseState = { + blocks: {}, + edges: [ + { id: 'edge-b', source: 'block-2', target: 'block-3' }, + { id: 'edge-a', source: 'block-1', target: 'block-2' }, + ], + loops: {}, + parallels: {}, + variables: {}, + lastSaved: 1, + } + mockLoadWorkflowDeploymentSnapshot.mockResolvedValueOnce(baseState).mockResolvedValueOnce({ + ...baseState, + edges: [...baseState.edges].reverse(), + lastSaved: 2, + }) + + const params = { + workflowId: 'workflow-1', + userId: 'user-1', + idempotencyKey: 'copilot:execution-1:tool-call:call-1', + } + await performFullDeploy(params) + await performFullDeploy(params) + + expect(mockPrepareWorkflowDeployment.mock.calls[0][0].requestHash).toBe( + mockPrepareWorkflowDeployment.mock.calls[1][0].requestHash + ) + expect(mockPrepareWorkflowDeployment.mock.calls[0][0].idempotencyKey).toBe( + 'copilot:execution-1:tool-call:call-1' + ) + }) + it('keeps a first deploy pending without claiming an active deployment', async () => { const now = new Date('2026-07-14T08:00:00.000Z') const operation = { diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 2abd4e04e67..5ab57a53b0d 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' @@ -11,6 +12,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { captureServerEvent } from '@/lib/posthog/server' import { validateTriggerWebhookConfigForDeploy } from '@/lib/webhooks/deploy' +import { normalizedStringify } from '@/lib/workflows/comparison/normalize' import { DEPLOYMENT_ERROR_CODES, type DeploymentComponentStatus, @@ -59,6 +61,8 @@ export interface DeploymentAttemptResult { version: number action: 'deploy' | 'activate' status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + /** Whether this attempt still describes the workflow's current deployment lifecycle. */ + isCurrent: boolean readiness: { webhooks: DeploymentReadinessSummaryStatus schedules: DeploymentReadinessSummaryStatus @@ -88,6 +92,9 @@ export interface PerformFullDeployParams { * endpoint, so it stays optional here. */ versionName?: string + /** Stable identity for one logical deployment operation. */ + idempotencyKey?: string + /** Correlation ID for logging and outbox tracing. */ requestId?: string /** * Override the actor ID used in audit logs and the `deployedBy` field. @@ -125,7 +132,9 @@ export interface PerformFullDeployResult { /** * Admits a deployment through the v2 prepare/activate protocol. The candidate - * version remains inactive until every required side effect is ready. + * version remains inactive until every required side effect is ready. Callers + * that can replay a logical operation must provide a stable `idempotencyKey`; + * `requestId` is correlation metadata only. */ export async function performFullDeploy( params: PerformFullDeployParams @@ -133,6 +142,7 @@ export async function performFullDeploy( const { workflowId, userId } = params const actorId = params.actorId ?? userId const requestId = params.requestId ?? generateRequestId() + const idempotencyKey = params.idempotencyKey ?? generateId() // Backstop for every caller — routes may assert first to render their own 423, // but the copilot deploy tools call this directly. @@ -154,6 +164,7 @@ export async function performFullDeploy( params, actorId, requestId, + idempotencyKey, }) } catch (error) { logger.error(`[${requestId}] Deployment preparation failed`, { workflowId, error }) @@ -169,6 +180,7 @@ async function performStableFullDeploy(params: { params: PerformFullDeployParams actorId: string requestId: string + idempotencyKey: string }): Promise { const workflowState = await loadWorkflowDeploymentSnapshot(params.params.workflowId) if (!workflowState) { @@ -190,11 +202,11 @@ async function performStableFullDeploy(params: { action: 'deploy', workflowId: params.params.workflowId, userId: params.params.userId, - workflowState, + workflowState: canonicalizeDeploymentWorkflowState(workflowState), versionName: params.params.versionName ?? null, versionDescription: params.params.versionDescription ?? null, }), - idempotencyKey: params.requestId, + idempotencyKey: params.idempotencyKey, workflowState, name: params.params.versionName, description: params.params.versionDescription, @@ -291,8 +303,22 @@ async function validateDeploymentState( return { success: true } } +function canonicalizeDeploymentWorkflowState( + workflowState: WorkflowState +): Record { + const { lastSaved: _lastSaved, edges, ...stableState } = workflowState + const sortedEdges = [...edges].sort((left, right) => { + if (left.id !== right.id) return left.id < right.id ? -1 : 1 + const normalizedLeft = normalizedStringify(left) + const normalizedRight = normalizedStringify(right) + if (normalizedLeft === normalizedRight) return 0 + return normalizedLeft < normalizedRight ? -1 : 1 + }) + return { ...stableState, edges: sortedEdges } +} + function createDeploymentRequestHash(value: Record): string { - return sha256Hex(JSON.stringify(value)) + return sha256Hex(normalizedStringify(value)) } function mapPrepareFailureCode( @@ -337,7 +363,10 @@ function buildStableDeploymentResult( deployedAt: status.activeDeployment.deployedAt.toISOString(), } : null - const latestDeploymentAttempt = summarizeDeploymentOperation(status.latestOperation) + const latestDeploymentAttempt = summarizeDeploymentOperation( + status.latestOperation, + status.activeDeployment?.deploymentVersionId ?? null + ) const warning = getStableDeploymentWarning( latestDeploymentAttempt, processResult, @@ -375,7 +404,8 @@ export async function getWorkflowDeploymentSummary(workflowId: string): Promise< } function summarizeDeploymentOperation( - operation: WorkflowDeploymentOperation | null + operation: WorkflowDeploymentOperation | null, + activeDeploymentVersionId: string | null ): DeploymentAttemptResult | null { if (!operation) return null if ( @@ -395,6 +425,10 @@ function summarizeDeploymentOperation( version: operation.version, action: operation.action, status: operation.status, + isCurrent: + operation.status === 'active' + ? operation.deploymentVersionId === activeDeploymentVersionId + : operation.status !== 'superseded', readiness: { webhooks: componentStatus('webhooks'), schedules: componentStatus('schedules'), @@ -420,6 +454,9 @@ function getStableDeploymentWarning( hasActiveDeployment: boolean ): string | undefined { if (!attempt) return undefined + if (attempt.status === 'active' && !attempt.isCurrent) { + return 'The latest successful deployment attempt is historical; no matching deployment version is currently active.' + } if (attempt.status === 'preparing' || attempt.status === 'activating') { if (processResult === 'processing_error') { return hasActiveDeployment @@ -547,6 +584,9 @@ export interface PerformActivateVersionParams { workflowId: string version: number userId: string + /** Stable identity for one logical activation operation. */ + idempotencyKey?: string + /** Correlation ID for logging and outbox tracing. */ requestId?: string /** Override the actor ID used in audit logs. Defaults to `userId`. */ actorId?: string @@ -582,7 +622,8 @@ export interface PerformRevertToVersionResult { } /** - * Admits an existing version through the v2 prepare/activate protocol. + * Admits an existing version through the v2 prepare/activate protocol. Callers + * that can replay a logical operation must provide a stable `idempotencyKey`. */ export async function performActivateVersion( params: PerformActivateVersionParams @@ -590,6 +631,7 @@ export async function performActivateVersion( const { workflowId, version, userId } = params const actorId = params.actorId ?? userId const requestId = params.requestId ?? generateRequestId() + const idempotencyKey = params.idempotencyKey ?? generateId() const lockDenial = await workflowLockDenial(workflowId) if (lockDenial) return { success: false, error: lockDenial, errorCode: 'validation' } @@ -665,6 +707,7 @@ export async function performActivateVersion( userId, actorId, requestId, + idempotencyKey, }) } catch (error) { logger.error(`[${requestId}] Version activation preparation failed`, { @@ -687,6 +730,7 @@ async function performStableVersionActivation(params: { userId: string actorId: string requestId: string + idempotencyKey: string }): Promise { let outboxEventId: string | undefined const prepared = await prepareWorkflowVersionActivation({ @@ -700,7 +744,7 @@ async function performStableVersionActivation(params: { version: params.version, userId: params.userId, }), - idempotencyKey: params.requestId, + idempotencyKey: params.idempotencyKey, readinessComponents: DEPLOYMENT_READINESS_COMPONENTS, onPrepareTransaction: async (tx, operation) => { if (!operation.deploymentVersionId || operation.version === null) { From f61bd37da8fadd6e9f985b47ecbaacc2ba490321 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 2 Aug 2026 13:34:39 -0700 Subject: [PATCH 2/3] fix subtitle --- .../components/billing/billing.test.tsx | 36 +++++++++++++++++-- .../settings/components/billing/billing.tsx | 7 ++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx index 1676ee4f0f5..a00cc5a639d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx @@ -319,6 +319,25 @@ describe('Billing payer scope', () => { ) }) + it('does not show a governing subscription description for a free personal workspace', async () => { + mockPersonalQuery.current = { + data: { + success: true, + context: 'user', + data: { ...PERSONAL_DATA, plan: 'free', status: 'active' }, + }, + isLoading: false, + refetch: vi.fn(), + } + + await act(async () => { + root.render() + }) + + expect(container.textContent).toContain('Personal Free plan') + expect(container.querySelector('main > p')).toBeNull() + }) + it('renders an explicit free organization state without subscription controls', async () => { mockOrganizationQuery.current = { data: organizationResponse({ @@ -336,7 +355,13 @@ describe('Billing payer scope', () => { } await act(async () => { - root.render() + root.render( + + ) }) expect(container.textContent).toContain('Organization Free plan') @@ -360,12 +385,19 @@ describe('Billing payer scope', () => { } await act(async () => { - root.render() + root.render( + + ) }) expect(container.textContent).toContain('Organization Max for Teams plan ended') expect(container.textContent).toContain('Choose a new plan for this organization') expect(container.textContent).not.toContain('Cancel subscription') + expect(container.querySelector('main > p')).toBeNull() expect( container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent ).toBe('Explore organization plans') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 2268a79ee4b..76c36f517f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -456,9 +456,10 @@ export function Billing({ const subscriptionOwner = isOrganizationScope ? `${organizationBilling?.organizationName ?? 'The organization'}’s subscription` : 'Your personal subscription' - const settingsDescription = governingWorkspaceName - ? `${subscriptionOwner} governs ${governingWorkspaceName}.` - : undefined + const settingsDescription = + governingWorkspaceName && subscription.isPaid + ? `${subscriptionOwner} governs ${governingWorkspaceName}.` + : undefined return ( From 9b21b032b352cd04023b947702421c8bf8fff165 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 2 Aug 2026 13:59:14 -0700 Subject: [PATCH 3/3] adjust perms --- .../copilot/request/tools/executor.test.ts | 17 +--- .../sim/lib/copilot/request/tools/executor.ts | 3 +- .../copilot/request/tools/permission.test.ts | 8 +- .../lib/copilot/request/tools/permission.ts | 16 +--- apps/sim/lib/copilot/request/types.ts | 2 - apps/sim/lib/copilot/tool-executor/types.ts | 2 - .../tools/handlers/deployment/context.test.ts | 10 -- .../tools/handlers/deployment/context.ts | 8 -- .../tools/handlers/deployment/deploy.test.ts | 93 ++++++++++++------- .../tools/handlers/deployment/deploy.ts | 16 +--- 10 files changed, 67 insertions(+), 108 deletions(-) diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index c1395d56cdb..8d60e889a05 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -36,7 +36,7 @@ describe('pendingToolWaitBudgetMs', () => { }) describe('buildToolExecutionContext', () => { - it('threads logical tool-call identity and server approval into the handler context', () => { + it('threads logical tool-call identity into the handler context', () => { const executionContext: ExecutionContext = { userId: 'user-1', workflowId: 'workflow-1', @@ -48,7 +48,6 @@ describe('buildToolExecutionContext', () => { { id: 'call-1', parentToolCallId: 'parent-1', - userApproved: true, }, executionContext ) @@ -56,20 +55,6 @@ describe('buildToolExecutionContext', () => { runId: 'run-1', toolCallId: 'call-1', parentToolCallId: 'parent-1', - userApprovedToolCall: true, - }) - }) - - it('does not inherit approval from the turn-scoped context', () => { - const executionContext: ExecutionContext = { - userId: 'user-1', - workflowId: 'workflow-1', - userApprovedToolCall: true, - } - - expect(buildToolExecutionContext({ id: 'call-2' }, executionContext)).toMatchObject({ - toolCallId: 'call-2', - userApprovedToolCall: false, }) }) }) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index e5ac0176fc2..10f0f84402c 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -257,13 +257,12 @@ class ToolExecutionTimeoutError extends Error { /** Builds the per-call context from the turn-scoped execution context. */ export function buildToolExecutionContext( - toolCall: Pick, + toolCall: Pick, execContext: ExecutionContext ): ExecutionContext { return { ...execContext, toolCallId: toolCall.id, - userApprovedToolCall: toolCall.userApproved === true, ...(toolCall.parentToolCallId ? { parentToolCallId: toolCall.parentToolCallId } : {}), } } diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index a9b5ba1e086..5c75b645730 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -95,13 +95,14 @@ describe('toolCallNeedsApproval', () => { }) it.each(['deploy_api', 'deploy_chat', 'deploy_mcp'])( - 'always gates a %s undeploy even when the tool was previously allowed', + 'honors the saved permission for a %s undeploy', (toolName) => { const context = makeContext() context.toolPermissions.autoAllowed.add(toolName) - expect(toolCallNeedsApproval(toolName, context, {}, false, { action: 'undeploy' })).toBe(true) - expect(toolCallNeedsApproval(toolName, context, {}, false, { action: 'deploy' })).toBe(false) + expect(toolCallNeedsApproval(toolName, context, {}, false, { action: 'undeploy' })).toBe( + false + ) } ) @@ -316,7 +317,6 @@ describe('runGatedToolExecution', () => { await gate(context, toolCall, execute, []) expect(execute).toHaveBeenCalledTimes(1) - expect(toolCall.userApproved).toBe(true) expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true) }) diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts index 56ae5ca9d51..a9829c8c155 100644 --- a/apps/sim/lib/copilot/request/tools/permission.ts +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -43,17 +43,6 @@ function terminalOperationNeedsApproval(args: Record | undefine return args?.operation === 'run' } -/** Destructive deployment calls always require approval for the exact call. */ -function callRequiresFreshApproval( - toolName: string, - args: Record | undefined -): boolean { - return ( - (toolName === 'deploy_api' || toolName === 'deploy_chat' || toolName === 'deploy_mcp') && - args?.action === 'undeploy' - ) -} - /** * A human can take as long as they like to answer, so the wait is bounded only * by the overall orchestration budget rather than a per-tool watchdog. @@ -100,9 +89,7 @@ export function toolCallNeedsApproval( } } - return ( - callRequiresFreshApproval(toolName, args) || !context.toolPermissions.autoAllowed.has(toolName) - ) + return !context.toolPermissions.autoAllowed.has(toolName) } function skipOutput(toolName: string) { @@ -313,7 +300,6 @@ export function runGatedToolExecution( return { status: MothershipStreamV1ToolOutcome.success, message: output.message } } - toolCall.userApproved = true await emitApprovedCall(toolCallId, toolName, executor, args, options) const execution = execute() diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index cdfc1b7b66d..bf4908896db 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -43,8 +43,6 @@ export interface ToolCallState { * for main-lane tool calls. */ parentToolCallId?: string - /** Set only after the server-side permission gate approves this exact call. */ - userApproved?: boolean } export type ToolCallResult = ToolExecutionResult & { diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 7e8ecfb65d0..f25e27e24c2 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -13,8 +13,6 @@ export interface ToolExecutionContext { runId?: string /** Stable identity of the individual tool call being executed. */ toolCallId?: string - /** True only after the server-side permission gate approved this exact call. */ - userApprovedToolCall?: boolean billingAttribution?: BillingAttributionSnapshot copilotToolExecution?: boolean requestMode?: string diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts index c40c02acfd6..3fb9d61ea97 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from 'vitest' import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError, - getUnapprovedUndeployError, } from '@/lib/copilot/tools/handlers/deployment/context' describe('getCopilotDeploymentIdempotencyKey', () => { @@ -30,15 +29,6 @@ describe('getCopilotDeploymentIdempotencyKey', () => { }) }) -describe('getUnapprovedUndeployError', () => { - it('fails closed unless the server approved this exact tool call', () => { - expect(getUnapprovedUndeployError({ userApprovedToolCall: false })).toContain( - 'requires explicit approval' - ) - expect(getUnapprovedUndeployError({ userApprovedToolCall: true })).toBeNull() - }) -}) - describe('getHistoricalDeploymentAttemptError', () => { it('requires a new tool call when the persisted attempt is no longer current', () => { expect(getHistoricalDeploymentAttemptError({ isCurrent: false }, 'redeploy')).toContain( diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.ts index ed8e0ad5e89..c4affb106a6 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/context.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.ts @@ -25,14 +25,6 @@ export function getCopilotDeploymentIdempotencyKey( : `copilot:tool-call:${context.toolCallId}` } -/** Returns the error used when an undeploy did not receive per-call user approval. */ -export function getUnapprovedUndeployError( - context: Pick -): string | null { - if (context.userApprovedToolCall === true) return null - return 'Undeploy requires explicit approval for this exact interactive Copilot call. Never undeploy to recover a failed deploy or redeploy; a failed redeploy already leaves the prior live version active.' -} - /** Rejects a replay whose persisted operation no longer describes production. */ export function getHistoricalDeploymentAttemptError( attempt: DeploymentAttemptCurrentState | null | undefined, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts index 8e1d6701aee..e0595796572 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts @@ -1,26 +1,35 @@ /** * @vitest-environment node */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockEnsureWorkflowAccess, mockPerformFullDeploy, mockPerformFullUndeploy } = vi.hoisted( - () => ({ - mockEnsureWorkflowAccess: vi.fn(), - mockPerformFullDeploy: vi.fn(), - mockPerformFullUndeploy: vi.fn(), - }) -) +const { + mockCheckChatAccess, + mockEnsureWorkflowAccess, + mockPerformChatUndeploy, + mockPerformDeleteWorkflowMcpTool, + mockPerformFullDeploy, + mockPerformFullUndeploy, +} = vi.hoisted(() => ({ + mockCheckChatAccess: vi.fn(), + mockEnsureWorkflowAccess: vi.fn(), + mockPerformChatUndeploy: vi.fn(), + mockPerformDeleteWorkflowMcpTool: vi.fn(), + mockPerformFullDeploy: vi.fn(), + mockPerformFullUndeploy: vi.fn(), +})) vi.mock('@/lib/workflows/orchestration', () => ({ performChatDeploy: vi.fn(), - performChatUndeploy: vi.fn(), + performChatUndeploy: mockPerformChatUndeploy, performFullDeploy: mockPerformFullDeploy, performFullUndeploy: mockPerformFullUndeploy, })) vi.mock('@/lib/mcp/orchestration', () => ({ performCreateWorkflowMcpTool: vi.fn(), - performDeleteWorkflowMcpTool: vi.fn(), + performDeleteWorkflowMcpTool: mockPerformDeleteWorkflowMcpTool, performUpdateWorkflowMcpTool: vi.fn(), })) @@ -35,7 +44,7 @@ vi.mock('@/lib/mcp/workflow-tool-schema', () => ({ })) vi.mock('@/app/api/chat/utils', () => ({ - checkChatAccess: vi.fn(), + checkChatAccess: mockCheckChatAccess, checkWorkflowAccessForChatCreation: vi.fn(), })) @@ -55,30 +64,16 @@ import { executeRedeploy, } from '@/lib/copilot/tools/handlers/deployment/deploy' -describe('executeDeployApi', () => { +describe('deployment handlers', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockEnsureWorkflowAccess.mockResolvedValue({ workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, }) }) - it('refuses undeploy without approval for the exact tool call', async () => { - const result = await executeDeployApi( - { workflowId: 'workflow-1', action: 'undeploy' }, - { - userId: 'user-1', - workflowId: 'workflow-1', - toolCallId: 'call-1', - userApprovedToolCall: false, - } - ) - - expect(result).toMatchObject({ success: false, error: expect.stringContaining('approval') }) - expect(mockPerformFullUndeploy).not.toHaveBeenCalled() - }) - - it('allows an explicitly approved undeploy', async () => { + it('undeploys the API without approval context when permission gating is disabled', async () => { mockPerformFullUndeploy.mockResolvedValue({ success: true }) const result = await executeDeployApi( @@ -87,7 +82,6 @@ describe('executeDeployApi', () => { userId: 'user-1', workflowId: 'workflow-1', toolCallId: 'call-1', - userApprovedToolCall: true, } ) @@ -182,31 +176,62 @@ describe('executeDeployApi', () => { }) }) - it('refuses chat undeploy without exact-call approval', async () => { + it('undeploys chat without approval context when permission gating is disabled', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'chat-1', + identifier: 'production-helper', + title: 'Production Helper', + description: null, + authType: 'public', + allowedEmails: [], + outputConfigs: [], + includeThinking: false, + includeToolCalls: false, + customizations: null, + }, + ]) + mockCheckChatAccess.mockResolvedValue({ hasAccess: true, workspaceId: 'workspace-1' }) + mockPerformChatUndeploy.mockResolvedValue({ success: true }) + const result = await executeDeployChat( { workflowId: 'workflow-1', action: 'undeploy' }, { userId: 'user-1', workflowId: 'workflow-1', toolCallId: 'call-1', - userApprovedToolCall: false, } ) - expect(result).toMatchObject({ success: false, error: expect.stringContaining('approval') }) + expect(result.success).toBe(true) + expect(mockPerformChatUndeploy).toHaveBeenCalledWith({ + chatId: 'chat-1', + userId: 'user-1', + workspaceId: 'workspace-1', + }) }) - it('refuses MCP undeploy without exact-call approval', async () => { + it('undeploys MCP without approval context when permission gating is disabled', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ id: 'server-1', name: 'Production MCP' }]) + .mockResolvedValueOnce([{ id: 'tool-1' }]) + mockPerformDeleteWorkflowMcpTool.mockResolvedValue({ success: true }) + const result = await executeDeployMcp( { workflowId: 'workflow-1', serverId: 'server-1', action: 'undeploy' }, { userId: 'user-1', workflowId: 'workflow-1', toolCallId: 'call-1', - userApprovedToolCall: false, } ) - expect(result).toMatchObject({ success: false, error: expect.stringContaining('approval') }) + expect(result.success).toBe(true) + expect(mockPerformDeleteWorkflowMcpTool).toHaveBeenCalledWith({ + serverId: 'server-1', + toolId: 'tool-1', + workspaceId: 'workspace-1', + userId: 'user-1', + }) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index ec4686974f1..a1db42f3e4c 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -28,11 +28,7 @@ import { } from '@/ee/access-control/utils/permission-check' import { ensureWorkflowAccess } from '../access' import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' -import { - getCopilotDeploymentIdempotencyKey, - getHistoricalDeploymentAttemptError, - getUnapprovedUndeployError, -} from './context' +import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { return `${baseUrl}/api/workflows/${workflowId}/execute` @@ -140,9 +136,6 @@ export async function executeDeployApi( ) if (action === 'undeploy') { - const approvalError = getUnapprovedUndeployError(context) - if (approvalError) return { success: false, error: approvalError } - const result = await performFullUndeploy({ workflowId, userId: context.userId }) if (!result.success) { return { success: false, error: result.error || 'Failed to undeploy workflow' } @@ -263,9 +256,6 @@ export async function executeDeployChat( const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' if (action === 'undeploy') { - const approvalError = getUnapprovedUndeployError(context) - if (approvalError) return { success: false, error: approvalError } - const baseUrl = getBaseUrl() const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) @@ -554,10 +544,6 @@ export async function executeDeployMcp( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - if (params.action === 'undeploy') { - const approvalError = getUnapprovedUndeployError(context) - if (approvalError) return { success: false, error: approvalError } - } const { workflow: workflowRecord } = await ensureWorkflowAccess( workflowId,