diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f3482d665d4..f3b23b10b5d 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -19,6 +19,7 @@ services: - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} - ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here} - COPILOT_API_KEY=${COPILOT_API_KEY} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 7e8f86e958c..416779ba00b 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -64,7 +64,8 @@ import { Callout } from 'fumadocs-ui/components/callout' | Variable | Description | |----------|-------------| | `API_ENCRYPTION_KEY` | Encrypts stored API keys (32 hex chars): `openssl rand -hex 32` | -| `COPILOT_API_KEY` | API key for copilot features | +| `COPILOT_API_KEY` | API key for Chat. Without it the Sim Chat block, scheduled prompt jobs, and Inbox cannot run | +| `NEXT_PUBLIC_CHAT_DISABLED` | Set to `true` to hide the Chat module: the workspace lands on your first workflow, with no chats list, scheduled tasks, or editor Chat panel. Chat is shown when unset; `bun run setup` sets it for you if you skip the chat key | | `ADMIN_API_KEY` | Admin API key for GitOps operations | | `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) | | `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) | diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 3c2dcf8f229..db177410995 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -21,6 +21,10 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 # TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. # AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients. +# Chat (Optional) +# COPILOT_API_KEY= # Mint one at https://sim.ai. Without it the Sim Chat block, prompt jobs, and Inbox cannot run +# NEXT_PUBLIC_CHAT_DISABLED=true # Hides the Chat module: the workspace lands on your first workflow, and the chats list, scheduled tasks, and editor Chat panel are absent. Chat is shown when unset; `bun run setup` sets this for you if you skip the chat key + # Security (Required) ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes diff --git a/apps/sim/app/api/mothership/events/route.ts b/apps/sim/app/api/mothership/events/route.ts index 5509358254d..c942c825665 100644 --- a/apps/sim/app/api/mothership/events/route.ts +++ b/apps/sim/app/api/mothership/events/route.ts @@ -11,6 +11,7 @@ import type { NextRequest } from 'next/server' import { mothershipEventsQuerySchema } from '@/lib/api/contracts/mothership-chats' import { validationErrorResponse } from '@/lib/api/server' import { chatPubSub } from '@/lib/copilot/chat-status' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceSSE } from '@/lib/events/sse-endpoint' @@ -37,6 +38,10 @@ const mothershipEventsHandler = createWorkspaceSSE({ }) export const GET = withRouteHandler((request: NextRequest) => { + // Closes streams held by tabs that were open when Chat was turned off; the + // client hook already declines to open new ones. + if (!isChatEnabled) return new Response(null, { status: 404 }) + const validation = mothershipEventsQuerySchema.safeParse( Object.fromEntries(request.nextUrl.searchParams.entries()) ) diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index d738fb326a7..45053f9d53d 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -9,6 +9,8 @@ import { requestUtilsMockFns, resetDbChainMock, resetEnvFlagsMock, + resetEnvMock, + setEnv, setEnvFlags, } from '@sim/testing' import { type NextRequest, NextResponse } from 'next/server' @@ -275,7 +277,10 @@ function createMockRequest(): NextRequest { } as NextRequest } -afterAll(resetEnvFlagsMock) +afterAll(() => { + resetEnvFlagsMock() + resetEnvMock() +}) describe('Scheduled Workflow Execution API Route', () => { beforeEach(() => { @@ -290,6 +295,9 @@ describe('Scheduled Workflow Execution API Route', () => { dbChainMockFns.execute.mockResolvedValue([{ acquired: true }] as never) requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-request-id') setEnvFlags({ isTriggerDevEnabled: false, isHosted: false, isProd: false, isDev: true }) + // Prompt-job claims are skipped without the mothership credential; pin it so + // these cases do not depend on whether the runner happens to have a .env. + setEnv({ COPILOT_API_KEY: 'test-api-key' }) mockShouldExecuteInline.mockReturnValue(false) mockEnqueue.mockReset() mockEnqueue.mockResolvedValue('job-id-1') diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index debf5680af0..de14972d700 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -17,6 +17,7 @@ import { } from '@/lib/billing/core/billing-attribution' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { JOB_STATUS, type Job } from '@/lib/core/async-jobs/types' +import { env } from '@/lib/core/config/env' import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { runDetached } from '@/lib/core/utils/background' @@ -1245,7 +1246,18 @@ export async function runScheduleTick(requestId: string): Promise{children} } diff --git a/apps/sim/app/workspace/[workspaceId]/home/layout.tsx b/apps/sim/app/workspace/[workspaceId]/home/layout.tsx index b8bae2ff0af..3f60d94d8d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/layout.tsx @@ -1,6 +1,25 @@ +import { redirect } from 'next/navigation' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { inter } from '@/app/_styles/fonts/inter/inter' -export default function HomeLayout({ children }: { children: React.ReactNode }) { +/** + * Redirects rather than 404s when Chat is disabled: this path is baked into + * already-delivered invitation emails and into the invitation-accept API + * contract, so it has to keep resolving. `/workspace/{id}` re-resolves the + * landing route server-side, so the visitor lands on a workflow instead. + */ +export default async function HomeLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ workspaceId: string }> +}) { + if (!isChatEnabled) { + const { workspaceId } = await params + redirect(`/workspace/${workspaceId}`) + } + return (
{children} diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx index a1e1febf0fb..b7a6cc4ea95 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx @@ -1,7 +1,9 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch' import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag' @@ -15,6 +17,12 @@ export const metadata: Metadata = { export default async function HomePage({ params }: { params: Promise<{ workspaceId: string }> }) { const { workspaceId } = await params + // The layout redirects too, but pages and layouts resolve concurrently — without + // this the prefetch below still fires on its way out. + if (!isChatEnabled) { + redirect(`/workspace/${workspaceId}`) + } + const queryClient = getQueryClient() const listsPrefetch = prefetchHomeLists(queryClient, workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 1558d9d1ca4..929a673c993 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -7,6 +7,7 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { blockTypeToIconMap, type Integration, @@ -157,11 +158,11 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration Add to Sim ) - ) : ( + ) : isChatEnabled ? ( Add to Sim - )} + ) : null}
{oauthService && ( @@ -243,7 +244,9 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration /> )} - {matchingTemplates.length > 0 && ( + {/* Every template hands its prompt to Chat, so the section has no + destination without it. */} + {isChatEnabled && matchingTemplates.length > 0 && ( - { - storeCuratedPrompt(prompt) - router.push(`/workspace/${workspaceId}/home`) - }} - className='absolute right-0 bottom-0 mx-0' - > - Explore in chat - + {isChatEnabled && ( + { + storeCuratedPrompt(prompt) + router.push(`/workspace/${workspaceId}/home`) + }} + className='absolute right-0 bottom-0 mx-0' + > + Explore in chat + + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 3dd9a4a9d87..5a353e7c0f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -31,6 +31,7 @@ import { createPortal } from 'react-dom' import type { WorkflowLogRow } from '@/lib/api/contracts/logs' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' @@ -397,7 +398,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP * mothership-triggered logs are excluded — `isLikelyExecution` already encodes * "has an executionId and isn't a mothership run". */ - const canTroubleshoot = log.status === 'failed' && isLikelyExecution + const canTroubleshoot = isChatEnabled && log.status === 'failed' && isLikelyExecution /** * Hands the failed run to Chat. When a chat is already mounted (e.g. the run diff --git a/apps/sim/app/workspace/[workspaceId]/not-found.tsx b/apps/sim/app/workspace/[workspaceId]/not-found.tsx index 43dbfbaea36..db69e38864d 100644 --- a/apps/sim/app/workspace/[workspaceId]/not-found.tsx +++ b/apps/sim/app/workspace/[workspaceId]/not-found.tsx @@ -10,7 +10,7 @@ import { ErrorShell } from '@/app/workspace/[workspaceId]/components' export default function WorkspaceNotFound() { const router = useRouter() const { workspaceId } = useParams<{ workspaceId?: string }>() - const homeHref = workspaceId ? `/workspace/${workspaceId}/home` : '/' + const homeHref = workspaceId ? `/workspace/${workspaceId}` : '/' return ( }) { const { workspaceId } = await params - redirect(`/workspace/${workspaceId}/home`) + redirect(`/workspace/${workspaceId}/${isChatEnabled ? 'home' : 'w'}`) } diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 809cae87686..e4c372eb603 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,6 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { listFoldersForWorkspace } from '@/lib/folders/queries' import { listWorkflowsForUser } from '@/lib/workflows/queries' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' @@ -73,14 +74,18 @@ export async function prefetchWorkspaceSidebar( }, staleTime: WORKFLOW_LIST_STALE_TIME, }), - queryClient.prefetchQuery({ - queryKey: mothershipChatKeys.list(workspaceId, 'active'), - queryFn: async () => { - const data = await listMothershipChats(userId, workspaceId) - return data.map(mapChat) - }, - staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, - }), + ...(isChatEnabled + ? [ + queryClient.prefetchQuery({ + queryKey: mothershipChatKeys.list(workspaceId, 'active'), + queryFn: async () => { + const data = await listMothershipChats(userId, workspaceId) + return data.map(mapChat) + }, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, + }), + ] + : []), queryClient.prefetchQuery({ queryKey: folderKeys.list(workspaceId, 'active', 'workflow'), queryFn: async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx index 38da2e2294d..b078ff176db 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx @@ -1,5 +1,7 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { isChatEnabled } from '@/lib/core/config/env-flags' import ScheduledTasksLoading from '@/app/workspace/[workspaceId]/scheduled-tasks/loading' import { ScheduledTasks } from './scheduled-tasks' @@ -14,6 +16,10 @@ export const metadata: Metadata = { * so a suspend never shows a blank frame. */ export default function ScheduledTasksPage() { + // The calendar only surfaces mothership prompt jobs, so with Chat off there is + // nothing this page could ever show. + if (!isChatEnabled) notFound() + return ( }> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index feb46c11b97..df8930fe4ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -9,6 +9,7 @@ import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components' import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' @@ -226,7 +227,12 @@ export function RecentlyDeleted() { const tableFoldersQuery = useFolders(workspaceId, { scope: 'archived', resourceType: 'table' }) const filesQuery = useWorkspaceFiles(workspaceId, 'archived') const workspaceFoldersQuery = useWorkspaceFileFolders(workspaceId, 'archived') - const chatsQuery = useMothershipChats(workspaceId, { scope: 'archived' }) + // Restoring a chat navigates to a route that 404s with Chat off, and this + // query's loading/error state feeds the whole panel's. + const chatsQuery = useMothershipChats(workspaceId, { + scope: 'archived', + enabled: isChatEnabled, + }) const restoreWorkflow = useRestoreWorkflow() const restoreFolder = useRestoreFolder() diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx index 547008947c2..d752241b550 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -69,7 +69,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) { const canManageBilling = canManageWorkspaceBilling(hostContext, session?.user?.id) const handleBack = useCallback(() => { - router.replace(origin ?? `/workspace/${workspaceId}/home`) + router.replace(origin ?? `/workspace/${workspaceId}`) }, [origin, router, workspaceId]) // Enterprise manages billing out-of-band, so there is no plan to pick here. @@ -77,7 +77,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) { // state — page.tsx resolves those before this ever mounts. useEffect(() => { if (canManageBilling && !state.isLoading && state.subscription.isEnterprise) { - router.replace(`/workspace/${workspaceId}/home`) + router.replace(`/workspace/${workspaceId}`) } }, [canManageBilling, state.isLoading, state.subscription.isEnterprise, router, workspaceId]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 1a997731fd8..5b16f42ee9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -40,6 +40,7 @@ import { import { getWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows' import { useSession } from '@/lib/auth/auth-client' import { getWorkspaceUsageLimitAction } from '@/lib/billing/workspace-permissions' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, @@ -122,7 +123,12 @@ export const Panel = memo(function Panel() { const panelRef = useRef(null) const fileInputRef = useRef(null) - const { activeTab, setActiveTab, _hasHydrated, setHasHydrated } = usePanelStore( + const { + activeTab: storedActiveTab, + setActiveTab, + _hasHydrated, + setHasHydrated, + } = usePanelStore( useShallow((state) => ({ activeTab: state.activeTab, setActiveTab: state.setActiveTab, @@ -146,6 +152,16 @@ export const Panel = memo(function Panel() { // Hooks const userPermissions = useUserPermissionsContext() const { config: permissionConfig } = usePermissionConfig() + + /** + * The Chat tab is hidden when the deployment has Chat off, or when the user's + * permission group hides it. Tab bodies stay mounted and are toggled with + * `hidden`, so a persisted `activeTab: 'copilot'` would hide all three and + * paint an empty panel — resolve it to the toolbar instead. + */ + const isCopilotTabAvailable = isChatEnabled && !permissionConfig.hideCopilot + const activeTab: PanelTab = + storedActiveTab === 'copilot' && !isCopilotTabAvailable ? 'toolbar' : storedActiveTab const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId }) const duplicateWorkflowMutation = useDuplicateWorkflowMutation() const { data: workflows = {} } = useWorkflowMap(workspaceId) @@ -257,7 +273,7 @@ export const Panel = memo(function Panel() { ) const { data: copilotChatList = EMPTY_COPILOT_CHATS } = useCopilotChats( - activeWorkflowId ?? undefined + isCopilotTabAvailable ? (activeWorkflowId ?? undefined) : undefined ) const [isCopilotHistoryOpen, setIsCopilotHistoryOpen] = useState(false) @@ -278,7 +294,10 @@ export const Panel = memo(function Panel() { // chat was deleted in another tab). const autoSelectAttemptedForRef = useRef>(new Set()) useEffect(() => { - if (!activeWorkflowId) return + // The list query is skipped when the tab is unavailable, so an empty list + // there means "not fetched", not "deleted elsewhere" — clearing on it would + // discard the selection and latch the ref against ever restoring it. + if (!activeWorkflowId || !isCopilotTabAvailable) return if (copilotChatId && !copilotChatList.find((c) => c.id === copilotChatId)) { setCopilotChatId(undefined) @@ -290,7 +309,7 @@ export const Panel = memo(function Panel() { if (copilotChatList.length === 0) return autoSelectAttemptedForRef.current.add(activeWorkflowId) setCopilotChatId(copilotChatList[0].id) - }, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId]) + }, [copilotChatList, copilotChatId, activeWorkflowId, isCopilotTabAvailable, setCopilotChatId]) useEffect(() => { posthogRef.current = posthog @@ -456,7 +475,15 @@ export const Panel = memo(function Panel() { setHasHydrated(true) }, [setHasHydrated]) + /** + * Only claims handoffs while the Chat tab can actually receive them. The + * handler's `preventDefault()` is what tells `sendMothershipMessage` a host + * consumed the message, so listening with the tab hidden would swallow it and + * skip the caller's own fallback. + */ useEffect(() => { + if (!isCopilotTabAvailable) return + const handler = (e: Event) => { const detail = (e as CustomEvent).detail if (!detail?.message) return @@ -466,7 +493,7 @@ export const Panel = memo(function Panel() { } window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) - }, [setActiveTab, copilotSendMessage]) + }, [isCopilotTabAvailable, setActiveTab, copilotSendMessage]) useEffect(() => { if (activeTab !== 'copilot') return @@ -759,7 +786,7 @@ export const Panel = memo(function Panel() { {/* Tabs */}
- {!permissionConfig.hideCopilot && ( + {isCopilotTabAvailable && ( - )} - - )} -
- )} -
+ })} + {chats.length > 5 && ( + + )} + + )} + + )} + + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts index bb22fb2fa01..2a126ea4df5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts @@ -119,7 +119,7 @@ export function useDeleteSelection({ if (nextWorkflowId) { router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`) } else { - router.push(`/workspace/${workspaceId}/home`) + router.push(`/workspace/${workspaceId}`) } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts index 0e9c5d82cc5..9db4fa5a163 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts @@ -98,7 +98,7 @@ export function useDeleteWorkflow({ if (nextWorkflowId) { router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`) } else { - router.push(`/workspace/${workspaceId}/home`) + router.push(`/workspace/${workspaceId}`) } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx index de85beccd5c..301548f6554 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx @@ -1,52 +1,111 @@ 'use client' import { useEffect } from 'react' +import { Chip } from '@sim/emcn' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' import { ReactFlowProvider } from 'reactflow' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { Panel, Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components' +import { useWorkflowOperations } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useWorkflows } from '@/hooks/queries/workflows' const logger = createLogger('WorkflowsPage') +function Spinner() { + return ( +
+ ) +} + export default function WorkflowsPage() { const router = useRouter() const params = useParams() const workspaceId = params.workspaceId as string const { data: workflows = [], isLoading, isError, isPlaceholderData } = useWorkflows(workspaceId) + const { handleCreateWorkflow, isCreatingWorkflow } = useWorkflowOperations({ workspaceId }) + const { canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() + + // An id rather than the filtered array: `data` defaults to a fresh `[]` while + // the query has no data, so an array dependency would re-fire this on every + // render — exactly during the load this page exists to cover. + const firstWorkflowId = workflows.find((w) => w.workspaceId === workspaceId)?.id + const isResolving = isLoading || isPlaceholderData useEffect(() => { - if (isLoading || isPlaceholderData) return + if (isResolving) return if (isError) { logger.error('Failed to load workflows for workspace') return } - const workspaceWorkflows = workflows.filter((w) => w.workspaceId === workspaceId) - - if (workspaceWorkflows.length > 0) { - router.replace(`/workspace/${workspaceId}/w/${workspaceWorkflows[0].id}`) + if (firstWorkflowId) { + router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`) } - }, [isLoading, isPlaceholderData, workflows, workspaceId, router, isError]) + }, [isResolving, isError, firstWorkflowId, workspaceId, router]) + + /** + * A workspace can legitimately reach zero workflows — deleting the last one, + * archiving them all, or creating a workspace with `skipDefaultWorkflow`. This + * is the terminal state for those paths now that the chat composer is no + * longer a landing option, so it has to offer a way out rather than spin. + */ + const isEmpty = !isResolving && !isError && !firstWorkflowId + const canCreate = !permissionsLoading && canEdit - // Always show loading state until redirect happens - // There should always be a default workflow, so we never show "no workflows found" return (
-
+ {isError ? ( + // This is the landing route now, so a failed list fetch would + // otherwise spin forever with nothing but a log line. +
+
+

Couldn't load workflows

+

Check your connection and try again.

+
+ router.refresh()}> + Retry + +
+ ) : isEmpty ? ( +
+
+

No workflows yet

+

+ {canCreate + ? 'Create one to start building.' + : 'Ask a workspace admin to create one.'} +

+
+ {/* The create mutation navigates optimistically, so offering it + without write access would strand a read-only member on a + workflow the server declined to create. */} + {canCreate && ( + + {isCreatingWorkflow ? 'Creating…' : 'Create workflow'} + + )} +
+ ) : ( + + )}
diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index 5db8905baae..0f9b725ccc2 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -132,7 +132,7 @@ export default function WorkspacePage() { const destinationFor = (id: string) => redirectTarget === 'upgrade' ? buildUpgradeHref(id, isUpgradeReason(rawReason) ? rawReason : undefined) - : `/workspace/${id}/home` + : `/workspace/${id}` const { workspaces, lastActiveWorkspaceId, creationPolicy } = data @@ -253,7 +253,7 @@ async function handleWorkflowRedirect( } catch (error) { logger.error('Error fetching workflow for redirect:', error) } - router.replace(`/workspace/${fallbackWorkspaceId}/home`) + router.replace(`/workspace/${fallbackWorkspaceId}`) } async function handleNoWorkspaces( diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index 364491017b3..69098fae148 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -1,5 +1,6 @@ import '@sim/testing/mocks/executor' +import { resetEnvMock, setEnv } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BlockType } from '@/executor/constants' import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler' @@ -114,6 +115,8 @@ describe('MothershipBlockHandler', () => { mockIsRedisCancellationEnabled.mockReset() mockIsRedisCancellationEnabled.mockReturnValue(false) mockReadUserFileContent.mockReset() + // The handler refuses to run without the mothership credential. + setEnv({ COPILOT_API_KEY: 'test-copilot-key' }) block = { id: 'mothership-block-1', @@ -146,6 +149,7 @@ describe('MothershipBlockHandler', () => { vi.useRealTimers() vi.clearAllMocks() vi.unstubAllGlobals() + resetEnvMock() }) function createNdjsonResponse(events: unknown[]): Response { @@ -222,6 +226,15 @@ describe('MothershipBlockHandler', () => { }) }) + it('rejects execution before the internal request when COPILOT_API_KEY is unset', async () => { + setEnv({ COPILOT_API_KEY: undefined }) + + await expect( + handler.execute(context, block, { prompt: 'Hello from workflow' }) + ).rejects.toThrow('COPILOT_API_KEY is not configured') + expect(fetchMock).not.toHaveBeenCalled() + }) + it('rejects execution before the internal request when billing attribution is missing', async () => { context.metadata.billingAttribution = undefined diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index 866446b2761..7a082b731e1 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -5,6 +5,7 @@ import { BILLING_ATTRIBUTION_HEADER, serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' +import { env } from '@/lib/core/config/env' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' import { readUserFileContent } from '@/lib/execution/payloads/materialization.server' import { @@ -337,6 +338,12 @@ export class MothershipBlockHandler implements BlockHandler { block: SerializedBlock, inputs: Record ): Promise { + // Without the key the mothership rejects every request, so fail with + // something the workflow author can act on instead of a bare 401. + if (!env.COPILOT_API_KEY) { + throw new Error('COPILOT_API_KEY is not configured, so the Sim Chat block cannot run') + } + const prompt = inputs.prompt if (!prompt || typeof prompt !== 'string') { throw new Error('Prompt input is required') diff --git a/apps/sim/hooks/use-mothership-chat-events.ts b/apps/sim/hooks/use-mothership-chat-events.ts index fe49bcb8283..0110e67a535 100644 --- a/apps/sim/hooks/use-mothership-chat-events.ts +++ b/apps/sim/hooks/use-mothership-chat-events.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query' import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats' const logger = createLogger('MothershipChatEvents') @@ -125,12 +126,16 @@ export function handleMothershipChatStatusEvent( /** * Subscribes to chat status SSE events and invalidates chat caches on changes. * The SSE event name remains `task_status` for wire compatibility. + * + * No-ops when Chat is disabled — this is mounted from the persistent sidebar, so + * without the guard every session would hold an open connection to an endpoint + * that cannot serve it. */ export function useMothershipChatEvents(workspaceId: string | undefined) { const queryClient = useQueryClient() useEffect(() => { - if (!workspaceId) return + if (!workspaceId || !isChatEnabled) return const eventSource = new EventSource( `/api/mothership/events?workspaceId=${encodeURIComponent(workspaceId)}` diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index d4306a28b34..5aa70b7cd5f 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -24,6 +24,7 @@ import { hasUsableSubscriptionAccess, USABLE_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' +import { env } from '@/lib/core/config/env' import { isAccessControlEnabled, isBillingEnabled, @@ -622,7 +623,13 @@ async function hasMaxTierWorkspaceAccess(workspaceId: string): Promise * the workspace's organization, or its billed account for personal workspaces, * is on a Max or enterprise plan. * - * Returns true if: + * Always false without `COPILOT_API_KEY` — inbox tasks are executed by the + * mothership and answered with a link to the resulting chat, so neither half + * works without it. That check comes first because the `!isBillingEnabled` + * shortcut below would otherwise hand every self-hosted deployment a broken + * Inbox. + * + * Otherwise returns true if: * - INBOX_ENABLED env var is set (self-hosted override), OR * - billing is disabled, OR * - the workspace belongs to an organization on a Max/enterprise plan (org-mode), OR @@ -630,6 +637,7 @@ async function hasMaxTierWorkspaceAccess(workspaceId: string): Promise */ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise { try { + if (!env.COPILOT_API_KEY) return false if (isInboxEnabled) return true if (!isBillingEnabled) return true return await hasMaxTierWorkspaceAccess(workspaceId) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 484cbfc5820..93d12e032b7 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -59,6 +59,23 @@ export const isCopilotBillingAttributionV1Enabled = isTruthy( */ export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PROTOCOL_REQUIRED) +/** + * Are the Chat module's surfaces shown. On by default, so a deployment that + * already has `COPILOT_API_KEY` keeps Chat without setting anything; the setup + * wizard writes the opt-out when you skip the key. + * + * This governs presentation only. Whether Chat can actually reach the mothership + * is a separate question answered by `COPILOT_API_KEY`, which gates the paths + * that need it (the Sim Chat block, prompt-job claims, inbox execution). Keeping + * them separate is what lets this be a single variable: the secret key could + * never be read in the browser, but `NEXT_PUBLIC_CHAT_DISABLED` can — no twin to + * keep in sync. + * + * Read at module scope or inline during render only. Resolving it through + * `useState`/`useEffect` would render chat surfaces before removing them. + */ +export const isChatEnabled = !isTruthy(getEnv('NEXT_PUBLIC_CHAT_DISABLED')) + /** * Holds tools the catalog marks `requiresApproval` — shell commands, workflow * runs, sandboxed code, deployments, integration calls — behind an explicit diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a06773654b5..9d16e13ab4f 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -605,6 +605,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted + NEXT_PUBLIC_CHAT_DISABLED: z.boolean().optional(), // Hide the Chat module (Chat is shown when unset) NEXT_PUBLIC_SANDBOXES_ENABLED: z.boolean().optional(), // Enable custom sandboxes on self-hosted NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget @@ -649,6 +650,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS, NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API, NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED, + NEXT_PUBLIC_CHAT_DISABLED: process.env.NEXT_PUBLIC_CHAT_DISABLED, NEXT_PUBLIC_SANDBOXES_ENABLED: process.env.NEXT_PUBLIC_SANDBOXES_ENABLED, NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED, NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY, diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index b90d2669a85..ec014758f1c 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -1131,7 +1131,7 @@ describe('acceptInvitation', () => { expect(result.success).toBe(true) if (result.success) { - expect(result.redirectPath).toBe('/workspace/workspace-1/home') + expect(result.redirectPath).toBe('/workspace/workspace-1') } expect(mockAttachOwnedWorkspacesToOrganizationTx).toHaveBeenCalledWith( expect.anything(), @@ -1580,7 +1580,7 @@ describe('acceptInvitation', () => { expect(result.success).toBe(true) if (result.success) { - expect(result.redirectPath).toBe('/workspace/workspace-1/home') + expect(result.redirectPath).toBe('/workspace/workspace-1') } }) diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 13be762010e..bb5f7eea628 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -1328,7 +1328,7 @@ async function acceptLockedInvitation( effects.membershipAlreadyExists = membershipAlreadyExists const redirectPath = - acceptedWorkspaceIds.length > 0 ? `/workspace/${acceptedWorkspaceIds[0]}/home` : '/workspace' + acceptedWorkspaceIds.length > 0 ? `/workspace/${acceptedWorkspaceIds[0]}` : '/workspace' return { success: true, diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index a49cb8c21c2..ae6565b3d13 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -677,7 +677,7 @@ export interface SendWorkspaceAddedEmailInput { export async function sendWorkspaceAddedEmail( input: SendWorkspaceAddedEmailInput ): Promise { - const workspaceLink = `${getBaseUrl()}/workspace/${input.workspaceId}/home` + const workspaceLink = `${getBaseUrl()}/workspace/${input.workspaceId}` const emailHtml = await renderWorkspaceAddedEmail( input.inviterName, input.workspaceName, diff --git a/apps/sim/stores/panel/store.ts b/apps/sim/stores/panel/store.ts index c8257f355c7..80d0c618f4b 100644 --- a/apps/sim/stores/panel/store.ts +++ b/apps/sim/stores/panel/store.ts @@ -1,12 +1,14 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { PANEL_WIDTH } from '@/stores/constants' import type { PanelState, PanelTab } from '@/stores/panel/types' /** - * Default panel tab + * Default panel tab. Falls back to the toolbar when Chat is disabled, since the + * copilot tab is not rendered then and would leave the panel body empty. */ -const DEFAULT_TAB: PanelTab = 'copilot' +const DEFAULT_TAB: PanelTab = isChatEnabled ? 'copilot' : 'toolbar' export const usePanelStore = create()( persist( diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index c21cf67b472..101fc8cc9d5 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -8,6 +8,7 @@ import { type AgentStreamToolTerminalStatus, settleRunningToolCallList, } from '@/components/agent-stream/tool-call-lifecycle' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { redactApiKeys } from '@/lib/core/security/redaction' import { sendMothershipMessage } from '@/lib/mothership/events' import { getQueryClient } from '@/app/_shell/providers/query-provider' @@ -310,10 +311,12 @@ const notifyBlockError = ({ toast.error(displayName, { description: errorMessage, - action: { - label: 'Fix in Chat', - onClick: () => sendMothershipMessage(copilotMessage), - }, + action: isChatEnabled + ? { + label: 'Fix in Chat', + onClick: () => sendMothershipMessage(copilotMessage), + } + : undefined, }) } catch (notificationError) { logger.error('Failed to create block error notification', { diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 78e2f591597..1a4d19df80c 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -23,6 +23,7 @@ services: - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:-dev-internal-api-secret-min-32-chars} - REDIS_URL=${REDIS_URL:-redis://redis:6379} - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} diff --git a/docker-compose.ollama.yml b/docker-compose.ollama.yml index 43f5cdcbff8..e425cb3aa64 100644 --- a/docker-compose.ollama.yml +++ b/docker-compose.ollama.yml @@ -19,6 +19,7 @@ services: - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-sim_auth_secret_$(openssl rand -hex 16)} - ENCRYPTION_KEY=${ENCRYPTION_KEY:-$(openssl rand -hex 32)} - COPILOT_API_KEY=${COPILOT_API_KEY} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=http://ollama:11434 - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index fa187e0aa47..363422c3013 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,6 +32,7 @@ services: - INTERNAL_API_SECRET=${INTERNAL_API_SECRET} - REDIS_URL=${REDIS_URL:-redis://redis:6379} - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} diff --git a/package.json b/package.json index fec0621c543..e6baf996389 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "skills:sync": "bun run scripts/sync-skills.ts", "skills:check": "bun run scripts/sync-skills.ts --check", "setup": "bun install && bun run scripts/setup/index.ts setup", - "sim": "bun run scripts/setup/index.ts", + "sim": "bun install && bun run scripts/setup/index.ts", "doctor": "bun run scripts/setup/index.ts doctor", "agent-stream-docs:generate": "bun run scripts/sync-agent-stream-docs.ts", "agent-stream-docs:check": "bun run scripts/sync-agent-stream-docs.ts --check", diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index d62fd102717..1b413fc52e3 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -3,8 +3,9 @@ import { vi } from 'vitest' /** * Mutable value-export state for the shared `@/lib/core/config/env-flags` mock. * Defaults mirror the real module evaluated under the vitest environment - * (NODE_ENV=test, no feature env vars set): only `isTest` and - * `isEmailPasswordEnabled` are true. + * (NODE_ENV=test, no feature env vars set): only `isTest`, + * `isEmailPasswordEnabled`, and `isChatEnabled` are true — the last because it + * is an opt-out flag, on unless `NEXT_PUBLIC_CHAT_DISABLED` is set. */ export interface EnvFlagsMockState { isProd: boolean @@ -13,6 +14,7 @@ export interface EnvFlagsMockState { isHosted: boolean isCopilotBillingAttributionV1Enabled: boolean isCopilotBillingProtocolRequired: boolean + isChatEnabled: boolean isCopilotToolPermissionsEnabled: boolean isBillingEnabled: boolean isEmailVerificationEnabled: boolean @@ -58,6 +60,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isHosted: false, isCopilotBillingAttributionV1Enabled: false, isCopilotBillingProtocolRequired: false, + isChatEnabled: true, isCopilotToolPermissionsEnabled: false, isBillingEnabled: false, isEmailVerificationEnabled: false, diff --git a/scripts/setup/cli-auth.ts b/scripts/setup/cli-auth.ts index 9ffba88255c..c9f3dfec9ee 100644 --- a/scripts/setup/cli-auth.ts +++ b/scripts/setup/cli-auth.ts @@ -76,7 +76,7 @@ export async function browserKeyFlow(origin: string): Promise { 'Confirm this code in your browser' ) p.log.info( - `Opening your browser — sign in and approve; the key comes back automatically.\n If it doesn't open: ${link(authUrl, authUrl)}` + `Opening your browser — create your account (or sign in) and approve; the key comes back automatically.\n If it doesn't open: ${link(authUrl, authUrl)}` ) openBrowser(authUrl) diff --git a/scripts/setup/modes/compose.ts b/scripts/setup/modes/compose.ts index 6e07706f48d..711cbe77ffd 100644 --- a/scripts/setup/modes/compose.ts +++ b/scripts/setup/modes/compose.ts @@ -7,6 +7,7 @@ import { ensurePortsFree } from '../ports.ts' import { httpHealth, waitFor } from '../probes.ts' import * as p from '../prompter.ts' import { + chatFlagValues, collectSecrets, mothershipOverride, promptCopilotKey, @@ -112,6 +113,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom Object.assign(values, mothershipOverride()) const copilotKey = await promptCopilotKey(root.vars.get('COPILOT_API_KEY')) if (copilotKey) values.COPILOT_API_KEY = copilotKey + Object.assign(values, chatFlagValues(copilotKey)) Object.assign(values, await promptLlmKeys(detection, !quick)) if (!quick) { const storage = await promptStorage(root.vars, true) diff --git a/scripts/setup/modes/dev.ts b/scripts/setup/modes/dev.ts index d0545e064ee..5364c165891 100644 --- a/scripts/setup/modes/dev.ts +++ b/scripts/setup/modes/dev.ts @@ -9,6 +9,7 @@ import { pgProbe } from '../probes.ts' import * as p from '../prompter.ts' import { ensureRedis, resolveRedis } from '../redis.ts' import { + chatFlagValues, collectSecrets, mothershipOverride, promptCopilotKey, @@ -124,6 +125,7 @@ export async function runDevMode( Object.assign(values, mothershipOverride()) const copilotKey = await promptCopilotKey(simAfter.vars.get('COPILOT_API_KEY')) if (copilotKey) values.COPILOT_API_KEY = copilotKey + Object.assign(values, chatFlagValues(copilotKey)) Object.assign(values, await promptLlmKeys(detection, !quick)) // Redis is set up in every mode, quick included. Storage falls back to diff --git a/scripts/setup/modes/k8s.ts b/scripts/setup/modes/k8s.ts index 59d31effd7c..c76e1c6ba80 100644 --- a/scripts/setup/modes/k8s.ts +++ b/scripts/setup/modes/k8s.ts @@ -6,6 +6,7 @@ import { generateSecret, ROOT } from '../env-files.ts' import { SetupError } from '../errors.ts' import { waitFor } from '../probes.ts' import * as p from '../prompter.ts' +import { chatFlagValues, mothershipOverride, promptCopilotKey } from '../steps.ts' import { glyph, theme } from '../theme.ts' const APP_URL = 'http://localhost:3000' @@ -276,15 +277,28 @@ async function helmInstall( } } -function existingReleaseSecrets(context: string): Record | null { +interface ReleaseValues { + app?: { env?: Record } + postgresql?: { auth?: { password?: string } } +} + +function existingReleaseValues(context: string): ReleaseValues | null { const scope = ['--kube-context', context, '-n', NAMESPACE] const status = spawnSync('helm', ['status', RELEASE, ...scope], { stdio: 'ignore' }) if (status.status !== 0) return null - const values = JSON.parse( + return JSON.parse( run('helm', ['get', 'values', RELEASE, ...scope, '-o', 'json'], 'helm get values failed') - ) as { app?: { env?: Record }; postgresql?: { auth?: { password?: string } } } - const env = values.app?.env ?? {} - const password = values.postgresql?.auth?.password + ) as ReleaseValues +} + +/** + * The previous release's secrets, or `null` when any are missing — a partial set + * cannot be reused, since regenerating only some of them invalidates sessions + * and stored credentials encrypted under the originals. + */ +function reusableSecrets(values: ReleaseValues | null): Record | null { + const env = values?.app?.env ?? {} + const password = values?.postgresql?.auth?.password if ( !env.BETTER_AUTH_SECRET || !env.ENCRYPTION_KEY || @@ -323,7 +337,8 @@ export async function runK8sMode(detection: Detection): Promise { // credentials to an unintended cluster. const context = await ensureLocalContext(detection) - const reused = existingReleaseSecrets(context) + const releaseValues = existingReleaseValues(context) + const reused = reusableSecrets(releaseValues) const secrets = reused ?? { BETTER_AUTH_SECRET: generateSecret(), ENCRYPTION_KEY: generateSecret(), @@ -333,6 +348,21 @@ export async function runK8sMode(detection: Detection): Promise { } if (reused) p.log.step('Reusing secrets from the existing release') + // Before the key is minted: a half-set override mints against one environment + // and validates against the other, and warning afterwards is too late — the + // bad key is already deployed. + const overrides = mothershipOverride() + const copilotKey = await promptCopilotKey(releaseValues?.app?.env?.COPILOT_API_KEY) + + // `helm upgrade` without `--reuse-values` keeps only what this document + // carries, so a key the user chose to keep has to be re-supplied here. + const appEnv: Record = { + ...secrets, + ...overrides, + ...(copilotKey ? { COPILOT_API_KEY: copilotKey } : {}), + ...chatFlagValues(copilotKey), + } + const spin = p.spinner() spin.start('helm upgrade --install (first run pulls images — this can take several minutes)…') try { @@ -355,7 +385,7 @@ export async function runK8sMode(detection: Detection): Promise { '--timeout', '15m', ], - secretValues(secrets), + secretValues(appEnv), context, spin ) diff --git a/scripts/setup/steps.ts b/scripts/setup/steps.ts index 457c830675b..1d3edf6afb9 100644 --- a/scripts/setup/steps.ts +++ b/scripts/setup/steps.ts @@ -57,17 +57,33 @@ export async function promptCopilotKey(existing?: string): Promise