diff --git a/.mise-tasks/zero/hosts.sh b/.mise-tasks/zero/hosts.sh new file mode 100755 index 0000000000..b1aaa08ffe --- /dev/null +++ b/.mise-tasks/zero/hosts.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# MISE description='Ensure setup.localhost is in /etc/hosts for enterprise onboarding dev.' +# MISE hide=true + +ENTRY="127.0.0.1 setup.localhost" + +if grep -q 'setup\.localhost' /etc/hosts 2>/dev/null; then + echo "✅ setup.localhost already in /etc/hosts" + exit 0 +fi + +echo "Adding setup.localhost to /etc/hosts (requires sudo)" +sudo sh -c "echo '$ENTRY' >> /etc/hosts" +echo "✅ Added setup.localhost to /etc/hosts" diff --git a/.mise-tasks/zero/tls.sh b/.mise-tasks/zero/tls.sh index 853a113cce..cfa0ef1281 100755 --- a/.mise-tasks/zero/tls.sh +++ b/.mise-tasks/zero/tls.sh @@ -117,6 +117,7 @@ cert_names() { "$server_host" \ "$assistant_host" \ "localhost" \ + "setup.localhost" \ "127.0.0.1" \ "::1" \ "gram.local" \ diff --git a/client/dashboard/src/App.tsx b/client/dashboard/src/App.tsx index bc6aacb23d..0cf482a653 100644 --- a/client/dashboard/src/App.tsx +++ b/client/dashboard/src/App.tsx @@ -31,6 +31,8 @@ import { usePageTitle } from "./hooks/use-page-title"; import CliCallback from "./pages/cli/CliCallback"; import SlackRegister from "./pages/slackapp/SlackRegister"; import { AppRoute, useRoutes, useOrgRoutes } from "./routes"; +import SetupPage from "./pages/setup/Setup"; +import { isSetupDomain } from "./lib/utils"; export default function App() { const [theme, setTheme] = useState<"light" | "dark">("light"); @@ -246,6 +248,8 @@ const RouteProvider = () => { } /> }> + {/* On the setup subdomain, render the wizard at "/" so the org slug stays hidden */} + {isSetupDomain() && } />} {routesWithSubroutes(outsideStructureRoutes)} @@ -257,6 +261,7 @@ const RouteProvider = () => { /> {routesWithSubroutes(authenticatedRoutes)} + } /> }> {orgHomeRoute?.component && ( } /> diff --git a/client/dashboard/src/components/app-layout.tsx b/client/dashboard/src/components/app-layout.tsx index 70c4e2f824..ea9e248058 100644 --- a/client/dashboard/src/components/app-layout.tsx +++ b/client/dashboard/src/components/app-layout.tsx @@ -2,10 +2,12 @@ import { useIsAdmin, useOrganization, useSession } from "@/contexts/Auth.tsx"; import { useSdkClient } from "@/contexts/Sdk.tsx"; import { useRBAC } from "@/hooks/useRBAC"; import { useObservabilityMcpConfig } from "@/hooks/useObservabilityMcpConfig"; +import { isSetupDomain } from "@/lib/utils"; import { Icon, Modal, ModalProvider } from "@speakeasy-api/moonshine"; import { ShieldAlert } from "lucide-react"; import { useCallback, useMemo } from "react"; import { Navigate, Outlet, useLocation } from "react-router"; +import Login from "@/pages/login/Login"; import { AppSidebar } from "./app-sidebar.tsx"; import { BrandGradientLine } from "./brand-gradient-line.tsx"; import { InsightsProvider } from "./insights-sidebar.tsx"; @@ -19,6 +21,10 @@ export const LoginCheck = () => { const location = useLocation(); if (session.session === "") { + // On setup domain, render login inline — no redirect, no URL change + if (isSetupDomain()) { + return ; + } const redirectTo = encodeURIComponent(location.pathname + location.search); return ; } diff --git a/client/dashboard/src/contexts/AuthProvider.tsx b/client/dashboard/src/contexts/AuthProvider.tsx index af4aa6f0a8..97523b14fa 100644 --- a/client/dashboard/src/contexts/AuthProvider.tsx +++ b/client/dashboard/src/contexts/AuthProvider.tsx @@ -29,6 +29,7 @@ import { useSearchParams, } from "react-router"; import { orgRoutePaths } from "@/routes"; +import { isSetupDomain } from "@/lib/utils"; import { useSlugs } from "./Sdk"; import { useCaptureUserAuthorizationEvent, @@ -85,7 +86,9 @@ const AuthHandler = ({ children }: { children: React.ReactNode }) => { // skeleton flash for logged-out users before the redirect to /login fires. if ( location.pathname === "/" || - UNAUTHENTICATED_PATHS.some((p) => location.pathname.startsWith(p)) + UNAUTHENTICATED_PATHS.some((p) => location.pathname.startsWith(p)) || + location.pathname.endsWith("/setup") || + isSetupDomain() ) { return null; } @@ -148,6 +151,22 @@ const AuthHandler = ({ children }: { children: React.ReactNode }) => { // Handle initial navigation const redirectParam = searchParams.get("redirect"); + + // On the setup subdomain, always funnel authenticated users to the setup wizard. + // The setup domain renders the wizard at "/" so the org slug stays out of the URL. + // Early-return after the redirect so we never hit the slug-based navigation below + // (which would redirect to /:orgSlug and cause a loop). + if (isSetupDomain() && session.organization) { + if (location.pathname !== "/") { + return ; + } + return ( + + {children} + + ); + } + if (redirectParam) { return ; } else if (isSlugExempt) { diff --git a/client/dashboard/src/lib/utils.ts b/client/dashboard/src/lib/utils.ts index 0061a3e6a6..e5e1ecc983 100644 --- a/client/dashboard/src/lib/utils.ts +++ b/client/dashboard/src/lib/utils.ts @@ -11,6 +11,12 @@ export function cn(...inputs: ClassValue[]) { // Use everywhere except the playground — MCP configs, callback URL // displays, anything operator-facing. export function getServerURL(): string { + // On the setup subdomain, route API calls through the current origin + // (Vite proxy in dev, same-origin in prod) so that session cookies — + // which are scoped to the setup host — are included by the browser. + if (isSetupDomain()) { + return window.location.origin; + } return __GRAM_SERVER_URL__ ?? window.location.origin; } @@ -23,7 +29,10 @@ export function getPlaygroundMcpBaseURL(): string { } export function buildLoginRedirectURL(redirectTo: string | null): string { - let href = `${getServerURL()}/rpc/auth.login`; + // On the setup subdomain, route auth through the current origin so the + // session cookie is scoped to setup.* (Vite proxies /rpc to the server). + const base = isSetupDomain() ? window.location.origin : getServerURL(); + let href = `${base}/rpc/auth.login`; if (redirectTo) href += `?redirect=${encodeURIComponent(redirectTo)}`; return href; } @@ -42,6 +51,16 @@ export function assert(condition: unknown, message: string): asserts condition { } } +/** + * Returns true when the dashboard is served from the setup subdomain + * (setup.getgram.ai in prod, setup.dev.getgram.ai in dev, + * setup.localhost in local dev, setup-pr-*.dev.getgram.ai in preview deploys). + */ +export function isSetupDomain(): boolean { + const hostname = window.location.hostname; + return hostname.startsWith("setup.") || hostname.startsWith("setup-"); +} + export function getCustomDomainCNAME(): string { try { const url = new URL(getServerURL()); diff --git a/client/dashboard/src/pages/login/Login.tsx b/client/dashboard/src/pages/login/Login.tsx index badddee444..362c2e737f 100644 --- a/client/dashboard/src/pages/login/Login.tsx +++ b/client/dashboard/src/pages/login/Login.tsx @@ -1,6 +1,6 @@ import { useSession } from "@/contexts/Auth"; import { useRoutes } from "@/routes"; -import { buildLoginRedirectURL } from "@/lib/utils"; +import { buildLoginRedirectURL, isSetupDomain } from "@/lib/utils"; import { JourneyDemo } from "./components/journey-demo"; import { LoginSection } from "./components/login-section"; import { useSearchParams, useNavigate } from "react-router"; @@ -14,9 +14,14 @@ export default function Login() { const explicitRedirect = searchParams.get("redirect"); const disposition = searchParams.get("disposition"); - const redirectTo = - explicitRedirect ?? - (disposition ? `/?disposition=${encodeURIComponent(disposition)}` : null); + // On setup domain, redirect back to the setup origin after auth so the + // server doesn't send us to the default GRAM_SITE_URL (app domain). + const redirectTo = isSetupDomain() + ? window.location.origin + : (explicitRedirect ?? + (disposition + ? `/?disposition=${encodeURIComponent(disposition)}` + : null)); useEffect(() => { if (session.session !== "") { // Disposition signups must reach the server-side Callback so @@ -26,6 +31,11 @@ export default function Login() { window.location.href = buildLoginRedirectURL(redirectTo); return; } + // On setup domain, always navigate to the setup wizard after auth + if (isSetupDomain()) { + navigate("/", { replace: true }); + return; + } if (redirectTo) { navigate(redirectTo, { replace: true }); } else { diff --git a/client/dashboard/src/pages/setup/Setup.tsx b/client/dashboard/src/pages/setup/Setup.tsx new file mode 100644 index 0000000000..e0d9ab7b93 --- /dev/null +++ b/client/dashboard/src/pages/setup/Setup.tsx @@ -0,0 +1,5 @@ +import { EnterpriseSetupWizard } from "./components/onboarding-wizard"; + +export default function SetupPage() { + return ; +} diff --git a/client/dashboard/src/pages/setup/components/onboarding-footer.tsx b/client/dashboard/src/pages/setup/components/onboarding-footer.tsx new file mode 100644 index 0000000000..2d6c892da0 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/onboarding-footer.tsx @@ -0,0 +1,33 @@ +export function OnboardingFooter() { + return ( + + ); +} diff --git a/client/dashboard/src/pages/setup/components/onboarding-header.tsx b/client/dashboard/src/pages/setup/components/onboarding-header.tsx new file mode 100644 index 0000000000..fcb2985f20 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/onboarding-header.tsx @@ -0,0 +1,47 @@ +import { RotateCcw, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Logo } from "@speakeasy-api/moonshine"; + +interface OnboardingHeaderProps { + onRestart?: () => void; + onLeave?: () => void; +} + +export function OnboardingHeader({ + onRestart, + onLeave, +}: OnboardingHeaderProps) { + return ( +
+
+
+ +
+ + Set up workspace + +
+
+ + +
+
+
+ ); +} diff --git a/client/dashboard/src/pages/setup/components/onboarding-stepper.tsx b/client/dashboard/src/pages/setup/components/onboarding-stepper.tsx new file mode 100644 index 0000000000..3a5ae2ac7c --- /dev/null +++ b/client/dashboard/src/pages/setup/components/onboarding-stepper.tsx @@ -0,0 +1,90 @@ +import { Check } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface Step { + id: string; + title: string; + description: string; +} + +interface OnboardingStepperProps { + steps: Step[]; + currentStep: number; + onStepClick?: (index: number) => void; +} + +export function OnboardingStepper({ + steps, + currentStep, + onStepClick, +}: OnboardingStepperProps) { + return ( + + ); +} diff --git a/client/dashboard/src/pages/setup/components/onboarding-wizard.tsx b/client/dashboard/src/pages/setup/components/onboarding-wizard.tsx new file mode 100644 index 0000000000..81c5afc6db --- /dev/null +++ b/client/dashboard/src/pages/setup/components/onboarding-wizard.tsx @@ -0,0 +1,136 @@ +import { useState, useCallback } from "react"; +import { useNavigate } from "react-router"; +import { OnboardingHeader } from "./onboarding-header"; +import { OnboardingFooter } from "./onboarding-footer"; +import { OnboardingStepper, type Step } from "./onboarding-stepper"; +import { + ConnectIdpStep, + DirectorySyncStep, + InstrumentAgentsStep, + AddSourcesStep, + ConfirmTrafficStep, +} from "./steps"; + +const STEPS: Step[] = [ + { + id: "connect-idp", + title: "Connect identity provider", + description: "Link SSO for authentication", + }, + { + id: "directory-sync", + title: "Directory sync", + description: "Confirm users and roles", + }, + { + id: "instrument-agents", + title: "Instrument agents", + description: "Connect AI coding assistants", + }, + { + id: "add-sources", + title: "Add MCP sources", + description: "Configure tools and data access", + }, + { + id: "confirm-traffic", + title: "Confirm traffic", + description: "Verify connectivity and compliance", + }, +]; + +export function EnterpriseSetupWizard() { + const navigate = useNavigate(); + const [currentStep, setCurrentStep] = useState(0); + + const goToStep = useCallback( + (index: number) => { + if (index < currentStep) { + setCurrentStep(index); + } + }, + [currentStep], + ); + + const completeCurrentStep = useCallback(() => { + const nextIndex = currentStep + 1; + if (nextIndex < STEPS.length) { + setCurrentStep(nextIndex); + } else { + // All steps completed - redirect to org home + navigate(".."); + } + }, [currentStep, navigate]); + + const goBack = useCallback(() => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }, [currentStep]); + + const handleRestart = () => { + setCurrentStep(0); + }; + + const handleLeave = () => { + navigate(".."); + }; + + const renderStep = () => { + switch (currentStep) { + case 0: + return ; + case 1: + return ( + + ); + case 2: + return ( + + ); + case 3: + return ( + + ); + case 4: + return ( + + ); + default: + return null; + } + }; + + return ( +
+ {/* Header */} + + + {/* Main content */} +
+
+ {/* Left: Stepper */} +
+ +
+ + {/* Right: Step content */} +
{renderStep()}
+
+
+ + {/* Footer */} + +
+ ); +} diff --git a/client/dashboard/src/pages/setup/components/step-container.tsx b/client/dashboard/src/pages/setup/components/step-container.tsx new file mode 100644 index 0000000000..4200ba2c0c --- /dev/null +++ b/client/dashboard/src/pages/setup/components/step-container.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from "react"; +import { ArrowRight, ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface StepContainerProps { + icon: ReactNode; + title: string; + description: string; + children: ReactNode; + onContinue?: () => void; + onBack?: () => void; + continueLabel?: string; + showBack?: boolean; + isLoading?: boolean; + canContinue?: boolean; +} + +export function StepContainer({ + icon, + title, + description, + children, + onContinue, + onBack, + continueLabel = "Continue", + showBack = false, + isLoading = false, + canContinue = true, +}: StepContainerProps) { + return ( +
+ {/* Icon */} +
{icon}
+ + {/* Header */} +

+ {title} +

+

{description}

+ + {/* Content */} +
{children}
+ + {/* Divider */} +
+ + {/* Actions */} +
+
+ {showBack && ( + + )} +
+ +
+
+ ); +} diff --git a/client/dashboard/src/pages/setup/components/steps/add-sources-step.tsx b/client/dashboard/src/pages/setup/components/steps/add-sources-step.tsx new file mode 100644 index 0000000000..51bbc24bf8 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/steps/add-sources-step.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { Database, Search } from "lucide-react"; +import { StepContainer } from "../step-container"; +import { MCP_SOURCES } from "../../mock-data"; +import type { McpSource } from "../../types"; +import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +interface AddSourcesStepProps { + onComplete: () => void; + onBack: () => void; +} + +export function AddSourcesStep({ onComplete, onBack }: AddSourcesStepProps) { + const [sources, setSources] = useState(MCP_SOURCES); + const [searchQuery, setSearchQuery] = useState(""); + const [activeTab, setActiveTab] = useState<"all" | "1st-party" | "3rd-party">( + "all", + ); + + const toggleSource = (sourceId: string) => { + setSources((prev) => + prev.map((s) => (s.id === sourceId ? { ...s, enabled: !s.enabled } : s)), + ); + }; + + const filteredSources = sources.filter((source) => { + const matchesSearch = source.name + .toLowerCase() + .includes(searchQuery.toLowerCase()); + const matchesTab = activeTab === "all" || source.type === activeTab; + return matchesSearch && matchesTab; + }); + + const enabledCount = sources.filter((s) => s.enabled).length; + const firstPartyEnabled = sources.filter( + (s) => s.type === "1st-party" && s.enabled, + ).length; + const thirdPartyEnabled = sources.filter( + (s) => s.type === "3rd-party" && s.enabled, + ).length; + + return ( + + +
+ } + title="Add MCP sources" + description="Configure which tools and data sources your agents can access. Sanctioned servers will be distributed to your team." + onContinue={onComplete} + continueLabel="Continue" + showBack + onBack={onBack} + > +
+ {/* Stats */} +
+
+

+ {enabledCount} +

+

Total enabled

+
+
+

+ {firstPartyEnabled} +

+

1st party

+
+
+

+ {thirdPartyEnabled} +

+

3rd party

+
+
+ + {/* Search and filter */} +
+
+ + setSearchQuery(value)} + className="pl-9" + /> +
+
+ {(["all", "1st-party", "3rd-party"] as const).map((tab) => ( + + ))} +
+
+ + {/* Sources grid */} +
+ {filteredSources.map((source) => ( +
+
+ + {source.name.charAt(0)} + +
+
+
+

+ {source.name} +

+ + {source.type === "1st-party" ? "1st" : "3rd"} + +
+

+ {source.description} +

+
+ toggleSource(source.id)} + /> +
+ ))} +
+
+ + ); +} diff --git a/client/dashboard/src/pages/setup/components/steps/confirm-traffic-step.tsx b/client/dashboard/src/pages/setup/components/steps/confirm-traffic-step.tsx new file mode 100644 index 0000000000..d26fbf0e82 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/steps/confirm-traffic-step.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect } from "react"; +import { + Activity, + Check, + Loader2, + TrendingUp, + TrendingDown, + Minus, + PartyPopper, +} from "lucide-react"; +import { StepContainer } from "../step-container"; +import { MOCK_TRAFFIC_METRICS } from "../../mock-data"; +import type { TrafficMetric } from "../../types"; +import { cn } from "@/lib/utils"; + +interface ConfirmTrafficStepProps { + onComplete: () => void; + onBack: () => void; +} + +export function ConfirmTrafficStep({ + onComplete, + onBack, +}: ConfirmTrafficStepProps) { + const [checking, setChecking] = useState(true); + const [metrics, setMetrics] = useState([]); + const [allHealthy, setAllHealthy] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + setMetrics(MOCK_TRAFFIC_METRICS); + setChecking(false); + setAllHealthy(MOCK_TRAFFIC_METRICS.every((m) => m.healthy)); + }, 2500); + return () => clearTimeout(timer); + }, []); + + const TrendIcon = ({ trend }: { trend: TrafficMetric["trend"] }) => { + if (trend === "up") return ; + if (trend === "down") + return ; + return ; + }; + + if (checking) { + return ( + + + + } + title="Verifying traffic" + description="Checking that everything is connected and working properly..." + onContinue={() => {}} + showBack + onBack={onBack} + canContinue={false} + isLoading + > +
+
+
+
+ +
+
+

+ Verifying connectivity and compliance +

+
+ + ); + } + + return ( + + +
+ } + title="Confirm traffic" + description="Verify that traffic is flowing and your team is compliant with configured policies." + onContinue={onComplete} + continueLabel="Go to Dashboard" + showBack + onBack={onBack} + > +
+ {/* Health status banner */} +
+
+
+ +
+
+

+ {allHealthy ? "All systems healthy" : "Attention required"} +

+

+ {allHealthy + ? "Traffic is flowing and compliance is verified" + : "Some metrics require your attention"} +

+
+
+
+ + {/* Metrics grid */} +
+ {metrics.map((metric, index) => ( +
+
+ + {metric.label} + + +
+

+ {metric.value} +

+
+ ))} +
+ + {/* Live activity */} +
+
+ + Recent activity + + + + Live + +
+
+ {[ + { + user: "sarah.chen@acme.com", + action: "Accessed GitHub MCP", + time: "2s ago", + status: "allowed", + }, + { + user: "marcus.j@acme.com", + action: "Tool call: read_file", + time: "5s ago", + status: "allowed", + }, + { + user: "e.rodriguez@acme.com", + action: "Requested: npm_install", + time: "12s ago", + status: "pending", + }, + { + user: "d.kim@acme.com", + action: "Accessed Slack MCP", + time: "18s ago", + status: "allowed", + }, + ].map((event, i) => ( +
+ + + {event.user} + + {" "} + - {event.action} + + + + {event.time} + +
+ ))} +
+
+ + {/* Success message */} + {allHealthy && ( +
+
+
+ +
+
+

+ Setup complete! +

+

+ Your organization is ready to use Speakeasy. +

+
+
+
+ )} +
+
+ ); +} diff --git a/client/dashboard/src/pages/setup/components/steps/connect-idp-step.tsx b/client/dashboard/src/pages/setup/components/steps/connect-idp-step.tsx new file mode 100644 index 0000000000..12bb6c05c2 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/steps/connect-idp-step.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { KeyRound, Check, ExternalLink } from "lucide-react"; +import { StepContainer } from "../step-container"; +import { IDP_PROVIDERS } from "../../mock-data"; +import { cn } from "@/lib/utils"; + +interface ConnectIdpStepProps { + onComplete: () => void; +} + +export function ConnectIdpStep({ onComplete }: ConnectIdpStepProps) { + const [selectedProvider, setSelectedProvider] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const [isConnected, setIsConnected] = useState(false); + + const handleConnect = async () => { + if (!selectedProvider) return; + + setIsConnecting(true); + // Simulate connection + await new Promise((resolve) => setTimeout(resolve, 1500)); + setIsConnecting(false); + setIsConnected(true); + }; + + const handleContinue = () => { + if (isConnected) { + onComplete(); + } else { + handleConnect(); + } + }; + + return ( + + + + } + title="Connect identity provider" + description="Connect your SSO provider to enable secure authentication for your team. This allows employees to sign in with their existing credentials." + onContinue={handleContinue} + continueLabel={ + isConnected ? "Continue" : isConnecting ? "Connecting..." : "Connect" + } + isLoading={isConnecting} + canContinue={!!selectedProvider} + > +
+
+ +
+ {IDP_PROVIDERS.map((provider) => ( + + ))} +
+
+ + {selectedProvider && !isConnected && ( +
+
+
+ +
+
+

+ {"You'll"} be redirected to complete setup +

+

+ After clicking Connect, {"you'll"} be taken to your identity + provider to authorize the connection. +

+
+
+
+ )} + + {isConnected && ( +
+
+
+ +
+
+

+ Successfully connected +

+

+ Your identity provider is now linked. Directory sync will + begin automatically. +

+
+
+
+ )} +
+
+ ); +} diff --git a/client/dashboard/src/pages/setup/components/steps/directory-sync-step.tsx b/client/dashboard/src/pages/setup/components/steps/directory-sync-step.tsx new file mode 100644 index 0000000000..eb929d1726 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/steps/directory-sync-step.tsx @@ -0,0 +1,209 @@ +import { useState, useEffect } from "react"; +import { Users, Check, ChevronDown, ChevronUp, Loader2 } from "lucide-react"; +import { StepContainer } from "../step-container"; +import { MOCK_DIRECTORY_USERS } from "../../mock-data"; +import type { DirectoryUser } from "../../types"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +interface DirectorySyncStepProps { + onComplete: () => void; + onBack: () => void; +} + +export function DirectorySyncStep({ + onComplete, + onBack, +}: DirectorySyncStepProps) { + const [syncing, setSyncing] = useState(true); + const [users, setUsers] = useState([]); + const [showAllMembers, setShowAllMembers] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + setUsers(MOCK_DIRECTORY_USERS); + setSyncing(false); + }, 2000); + return () => clearTimeout(timer); + }, []); + + const admins = users.filter((u) => u.role === "admin"); + const members = users.filter((u) => u.role === "member"); + const displayedMembers = showAllMembers ? members : members.slice(0, 3); + + const toggleUserRole = (userId: string) => { + setUsers((prev) => + prev.map((u) => + u.id === userId + ? { ...u, role: u.role === "admin" ? "member" : "admin" } + : u, + ), + ); + }; + + if (syncing) { + return ( + + + + } + title="Syncing directory" + description="Fetching users and groups from your identity provider..." + onContinue={() => {}} + showBack + onBack={onBack} + canContinue={false} + > +
+ +

+ This may take a moment +

+
+
+ ); + } + + return ( + + + + } + title="Confirm directory sync" + description="Review the users synced from your identity provider. Toggle roles as needed - admins can manage policies and settings." + onContinue={onComplete} + continueLabel="Continue" + showBack + onBack={onBack} + > +
+ {/* Summary stats */} +
+
+

+ {admins.length} +

+

Administrators

+
+
+

+ {members.length} +

+

Members

+
+
+ + {/* Admins */} +
+ +
+ {admins.map((user) => ( + toggleUserRole(user.id)} + /> + ))} +
+
+ + {/* Members */} +
+ +
+ {displayedMembers.map((user) => ( + toggleUserRole(user.id)} + /> + ))} +
+ {members.length > 3 && ( + + )} +
+ + {/* Sync complete notice */} +
+
+
+ +
+
+

+ Directory sync complete +

+

+ {users.length} users imported. Roles will sync automatically + going forward. +

+
+
+
+
+
+ ); +} + +function UserRow({ + user, + onToggleRole, +}: { + user: DirectoryUser; + onToggleRole: () => void; +}) { + return ( +
+
+ + {user.name + .split(" ") + .map((n) => n[0]) + .join("")} + +
+
+

+ {user.name} +

+

{user.email}

+
+ +
+ ); +} diff --git a/client/dashboard/src/pages/setup/components/steps/index.ts b/client/dashboard/src/pages/setup/components/steps/index.ts new file mode 100644 index 0000000000..82981ad0c4 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/steps/index.ts @@ -0,0 +1,5 @@ +export { ConnectIdpStep } from "./connect-idp-step"; +export { DirectorySyncStep } from "./directory-sync-step"; +export { InstrumentAgentsStep } from "./instrument-agents-step"; +export { AddSourcesStep } from "./add-sources-step"; +export { ConfirmTrafficStep } from "./confirm-traffic-step"; diff --git a/client/dashboard/src/pages/setup/components/steps/instrument-agents-step.tsx b/client/dashboard/src/pages/setup/components/steps/instrument-agents-step.tsx new file mode 100644 index 0000000000..286af34fac --- /dev/null +++ b/client/dashboard/src/pages/setup/components/steps/instrument-agents-step.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { Terminal, Check, Copy, ExternalLink } from "lucide-react"; +import { StepContainer } from "../step-container"; +import { AGENT_PLATFORMS } from "../../mock-data"; +import type { AgentPlatform } from "../../types"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface InstrumentAgentsStepProps { + onComplete: () => void; + onBack: () => void; +} + +const MOCK_WEBHOOK_URL = "https://api.speakeasy.com/hooks/acme-corp/agents"; +const MOCK_API_KEY = "sk_live_speakeasy_xxxxxxxxxxxxx"; + +export function InstrumentAgentsStep({ + onComplete, + onBack, +}: InstrumentAgentsStepProps) { + const [platforms, setPlatforms] = useState(AGENT_PLATFORMS); + const [copiedField, setCopiedField] = useState(null); + + const connectedCount = platforms.filter((p) => p.connected).length; + + const togglePlatform = (platformId: string) => { + setPlatforms((prev) => + prev.map((p) => + p.id === platformId ? { ...p, connected: !p.connected } : p, + ), + ); + }; + + const copyToClipboard = async (text: string, field: string) => { + await navigator.clipboard.writeText(text); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + }; + + return ( + + + + } + title="Instrument agent platforms" + description="Connect the AI coding assistants used by your team. This enables traffic monitoring and policy enforcement." + onContinue={onComplete} + continueLabel="Continue" + showBack + onBack={onBack} + > +
+ {/* Credentials */} +
+ +

+ Use these credentials to configure your agent platforms. +

+
+
+ +
+ + {MOCK_WEBHOOK_URL} + + +
+
+
+ +
+ + {MOCK_API_KEY} + + +
+
+
+
+ + {/* Platforms */} +
+
+ + + {connectedCount} of {platforms.length} enabled + +
+
+ {platforms.map((platform) => ( +
+
+ + {platform.name.charAt(0)} + +
+
+

+ {platform.name} +

+

+ {platform.description} +

+
+ + togglePlatform(platform.id)} + /> +
+ ))} +
+
+
+
+ ); +} diff --git a/client/dashboard/src/pages/setup/mock-data.ts b/client/dashboard/src/pages/setup/mock-data.ts new file mode 100644 index 0000000000..4ccfa28288 --- /dev/null +++ b/client/dashboard/src/pages/setup/mock-data.ts @@ -0,0 +1,180 @@ +import type { + IdpProvider, + DirectoryUser, + AgentPlatform, + McpSource, + TrafficMetric, +} from "./types"; + +export const IDP_PROVIDERS: IdpProvider[] = [ + { + id: "okta", + name: "Okta", + icon: "O", + type: "SAML 2.0 / OIDC", + connected: false, + }, + { + id: "azure", + name: "Microsoft Entra ID", + icon: "M", + type: "SAML 2.0 / OIDC", + connected: false, + }, + { + id: "google", + name: "Google Workspace", + icon: "G", + type: "OIDC", + connected: false, + }, + { + id: "onelogin", + name: "OneLogin", + icon: "1", + type: "SAML 2.0", + connected: false, + }, + { + id: "jumpcloud", + name: "JumpCloud", + icon: "J", + type: "SAML 2.0 / OIDC", + connected: false, + }, +]; + +export const MOCK_DIRECTORY_USERS: DirectoryUser[] = [ + { id: "1", name: "Sarah Chen", email: "sarah.chen@acme.com", role: "admin" }, + { + id: "2", + name: "Marcus Johnson", + email: "marcus.j@acme.com", + role: "admin", + }, + { + id: "3", + name: "Emily Rodriguez", + email: "e.rodriguez@acme.com", + role: "member", + }, + { id: "4", name: "David Kim", email: "d.kim@acme.com", role: "member" }, + { + id: "5", + name: "Lisa Thompson", + email: "l.thompson@acme.com", + role: "member", + }, + { id: "6", name: "James Wilson", email: "j.wilson@acme.com", role: "member" }, + { + id: "7", + name: "Anna Martinez", + email: "a.martinez@acme.com", + role: "member", + }, + { id: "8", name: "Michael Brown", email: "m.brown@acme.com", role: "member" }, +]; + +export const AGENT_PLATFORMS: AgentPlatform[] = [ + { + id: "cursor", + name: "Cursor", + description: "AI-powered code editor", + icon: "cursor", + connected: false, + }, + { + id: "windsurf", + name: "Windsurf", + description: "Codeium IDE", + icon: "windsurf", + connected: false, + }, + { + id: "claude-desktop", + name: "Claude Desktop", + description: "Anthropic desktop app", + icon: "claude", + connected: false, + }, + { + id: "vscode", + name: "VS Code + Copilot", + description: "GitHub Copilot integration", + icon: "vscode", + connected: false, + }, + { + id: "jetbrains", + name: "JetBrains IDEs", + description: "IntelliJ, PyCharm, etc.", + icon: "jetbrains", + connected: false, + }, +]; + +export const MCP_SOURCES: McpSource[] = [ + { + id: "github", + name: "GitHub", + type: "1st-party", + description: "Code repositories and issues", + enabled: false, + }, + { + id: "slack", + name: "Slack", + type: "1st-party", + description: "Team messaging and channels", + enabled: false, + }, + { + id: "linear", + name: "Linear", + type: "1st-party", + description: "Issue tracking and projects", + enabled: false, + }, + { + id: "notion", + name: "Notion", + type: "1st-party", + description: "Documents and wikis", + enabled: false, + }, + { + id: "jira", + name: "Jira", + type: "3rd-party", + description: "Project management", + enabled: false, + }, + { + id: "confluence", + name: "Confluence", + type: "3rd-party", + description: "Documentation platform", + enabled: false, + }, + { + id: "datadog", + name: "Datadog", + type: "3rd-party", + description: "Monitoring and analytics", + enabled: false, + }, + { + id: "sentry", + name: "Sentry", + type: "3rd-party", + description: "Error tracking", + enabled: false, + }, +]; + +export const MOCK_TRAFFIC_METRICS: TrafficMetric[] = [ + { label: "Active Users", value: "24", trend: "up", healthy: true }, + { label: "Tool Requests", value: "1,247", trend: "up", healthy: true }, + { label: "Blocked Calls", value: "38", trend: "down", healthy: true }, + { label: "Compliance Rate", value: "96.8%", trend: "stable", healthy: true }, +]; diff --git a/client/dashboard/src/pages/setup/types.ts b/client/dashboard/src/pages/setup/types.ts new file mode 100644 index 0000000000..658ce8abdc --- /dev/null +++ b/client/dashboard/src/pages/setup/types.ts @@ -0,0 +1,86 @@ +export type OnboardingStep = + | "connect-idp" + | "directory-sync" + | "instrument-agents" + | "add-sources" + | "confirm-traffic"; + +export interface StepConfig { + id: OnboardingStep; + title: string; + description: string; + icon: string; +} + +export const ONBOARDING_STEPS: StepConfig[] = [ + { + id: "connect-idp", + title: "Connect Identity Provider", + description: "Link your SSO provider", + icon: "shield", + }, + { + id: "directory-sync", + title: "Directory Sync", + description: "Confirm roles and admins", + icon: "users", + }, + { + id: "instrument-agents", + title: "Instrument Agents", + description: "Setup agent platform hooks", + icon: "cpu", + }, + { + id: "add-sources", + title: "Add MCP Sources", + description: "Configure 1st & 3rd party sources", + icon: "database", + }, + { + id: "confirm-traffic", + title: "Confirm Traffic", + description: "Verify compliance", + icon: "activity", + }, +]; + +// Mock data types +export interface IdpProvider { + id: string; + name: string; + icon: string; + type: string; + connected: boolean; +} + +export interface DirectoryUser { + id: string; + name: string; + email: string; + role: "admin" | "member"; + avatarUrl?: string; +} + +export interface AgentPlatform { + id: string; + name: string; + description: string; + icon: string; + connected: boolean; +} + +export interface McpSource { + id: string; + name: string; + type: "1st-party" | "3rd-party"; + description: string; + enabled: boolean; +} + +export interface TrafficMetric { + label: string; + value: string; + trend: "up" | "down" | "stable"; + healthy: boolean; +} diff --git a/client/dashboard/vite.config.ts b/client/dashboard/vite.config.ts index f9a740cb9d..cd2d88f8ce 100644 --- a/client/dashboard/vite.config.ts +++ b/client/dashboard/vite.config.ts @@ -92,7 +92,7 @@ export default defineConfig(({ command }) => { }, server: { host: true, - allowedHosts: ["localhost", "127.0.0.1", "devbox"], + allowedHosts: ["localhost", "127.0.0.1", "devbox", "setup.localhost"], https: key && cert ? { key, cert } : void 0, // Setting these up to side-step cors issues experienced during // development. Specifically, the Vercel AI SDK does not forward cookies diff --git a/mise.toml b/mise.toml index 32b886e359..ac6e3352a3 100644 --- a/mise.toml +++ b/mise.toml @@ -106,6 +106,11 @@ GRAM_ENVIRONMENT = "local" GRAM_HOST = "localhost" # Only needed for local development. In production, you should set GRAM_SERVER_URL and GRAM_SITE_URL directly. GRAM_SITE_PORT = "5173" GRAM_SITE_URL = "https://{{env.GRAM_HOST}}:{{env.GRAM_SITE_PORT}}" +GRAM_SETUP_SITE_URL = "https://setup.{{env.GRAM_HOST}}:{{env.GRAM_SITE_PORT}}" +## In production set this to the apex domain (e.g. getgram.ai) so session +## cookies are shared across subdomains (app.*, setup.*). Leave blank for +## local dev — "localhost" is a reserved TLD and Chrome rejects Domain=localhost. +# GRAM_COOKIE_DOMAIN = "" GRAM_SERVER_URL = "https://{{env.GRAM_HOST}}:{{env.GRAM_SERVER_PORT}}" GRAM_ASSETS_BACKEND = "fs" GRAM_ASSETS_URI = ".assets" diff --git a/server/cmd/gram/start.go b/server/cmd/gram/start.go index c7319e1ef8..84e5a6c8b6 100644 --- a/server/cmd/gram/start.go +++ b/server/cmd/gram/start.go @@ -154,6 +154,16 @@ func newStartCommand() *cli.Command { EnvVars: []string{"GRAM_SITE_URL"}, Required: true, }, + &cli.StringFlag{ + Name: "setup-site-url", + Usage: "Origin of the enterprise setup subdomain (e.g. https://setup.getgram.ai)", + EnvVars: []string{"GRAM_SETUP_SITE_URL"}, + }, + &cli.StringFlag{ + Name: "cookie-domain", + Usage: "Domain attribute for session cookies (e.g. getgram.ai, localhost)", + EnvVars: []string{"GRAM_COOKIE_DOMAIN"}, + }, &cli.StringFlag{ Name: "database-url", Usage: "Database URL", @@ -916,7 +926,13 @@ func newStartCommand() *cli.Command { mux.Use(middleware.NewHTTPLoggingMiddleware(logger)) mux.Use(middleware.NewRecovery(logger)) mux.Use(middleware.CORSMiddleware(c.String("environment"), c.String("server-url"), chatSessionsManager)) - mux.Use(customdomains.Middleware(logger, db, c.String("environment"), serverURL)) + var setupSiteURL *url.URL + if raw := c.String("setup-site-url"); raw != "" { + if parsed, err := url.Parse(raw); err == nil { + setupSiteURL = parsed + } + } + mux.Use(customdomains.Middleware(logger, db, c.String("environment"), serverURL, setupSiteURL)) mux.Use(middleware.SessionMiddleware) mux.Use(middleware.AdminOverrideMiddleware) mux.Use(middleware.RBACOverrideMiddleware()) @@ -968,6 +984,8 @@ func newStartCommand() *cli.Command { IDPBaseURL: c.String("idp-base-url"), GramServerURL: c.String("server-url"), SignInRedirectURL: auth.FormSignInRedirectURL(c.String("site-url")), + SetupSiteURL: c.String("setup-site-url"), + CookieDomain: c.String("cookie-domain"), Environment: c.String("environment"), }, authzEngine, diff --git a/server/internal/auth/impl.go b/server/internal/auth/impl.go index a1ca824041..22eab9e0ec 100644 --- a/server/internal/auth/impl.go +++ b/server/internal/auth/impl.go @@ -88,6 +88,8 @@ type AuthConfigurations struct { IDPBaseURL string GramServerURL string SignInRedirectURL string + SetupSiteURL string // Origin of the enterprise setup subdomain (e.g. https://setup.getgram.ai) + CookieDomain string // Domain attribute for session cookies (e.g. "getgram.ai", "localhost") Environment string } @@ -168,9 +170,53 @@ func Attach(mux goahttp.Muxer, service *Service) { // context so validateAuthNonce() can verify it. server.Callback = callbackNonceBindingMiddleware(server.Callback) + // If a cookie domain is configured, rewrite gram_session cookies to + // include Domain= so they're shared across subdomains (e.g. app.getgram.ai + // and setup.getgram.ai both under getgram.ai). + if service.cfg.CookieDomain != "" { + server.Login = cookieDomainMiddleware(service.cfg.CookieDomain)(server.Login) + server.Callback = cookieDomainMiddleware(service.cfg.CookieDomain)(server.Callback) + server.SwitchScopes = cookieDomainMiddleware(service.cfg.CookieDomain)(server.SwitchScopes) + server.Logout = cookieDomainMiddleware(service.cfg.CookieDomain)(server.Logout) + server.Info = cookieDomainMiddleware(service.cfg.CookieDomain)(server.Info) + } + srv.Mount(mux, server) } +// cookieDomainMiddleware rewrites Set-Cookie headers for auth cookies +// (gram_session, gram_auth_nonce) to include the specified Domain attribute. +// This is needed because the Goa generated code and nonce middleware set +// cookies without a Domain, making them host-only. +func cookieDomainMiddleware(domain string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wrapped := &cookieDomainWriter{ResponseWriter: w, domain: domain} + next.ServeHTTP(wrapped, r) + }) + } +} + +type cookieDomainWriter struct { + http.ResponseWriter + domain string +} + +func (w *cookieDomainWriter) WriteHeader(statusCode int) { + headers := w.Header() + cookies := headers.Values("Set-Cookie") + if len(cookies) > 0 { + headers.Del("Set-Cookie") + for _, c := range cookies { + if strings.HasPrefix(c, "gram_session=") || strings.HasPrefix(c, nonceBindingCookie+"=") { + c += "; Domain=" + w.domain + } + headers.Add("Set-Cookie", c) + } + } + w.ResponseWriter.WriteHeader(statusCode) +} + // loginNonceBindingMiddleware generates a random nonce-binding token, sets it // as an HttpOnly/SameSite cookie, and stores it in the request context. func loginNonceBindingMiddleware(env string) func(http.Handler) http.Handler { @@ -1008,6 +1054,27 @@ func (s *Service) buildCallbackURL(ctx context.Context) string { returnAddress = "https://" + requestCtx.Host } + // If the login request came from the setup subdomain, route the IDP + // callback through the setup origin so the nonce-binding cookie is + // scoped correctly. + if s.cfg.SetupSiteURL != "" { + if requestCtx, ok := contextvalues.GetRequestContext(ctx); ok && requestCtx != nil { + setupURL, err := url.Parse(s.cfg.SetupSiteURL) + if err == nil { + // In local dev the Vite proxy rewrites Host, so check + // Referer. In deployed envs (dev/prod) the Host header + // arrives unmodified. + host := requestCtx.Host + if s.cfg.Environment == "local" { + host = requestCtx.RefererHost + } + if host == setupURL.Host { + returnAddress = strings.TrimRight(s.cfg.SetupSiteURL, "/") + } + } + } + } + return returnAddress + "/rpc/auth.callback" } @@ -1015,28 +1082,43 @@ func (s *Service) buildCallbackURL(ctx context.Context) string { var validOrgNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\s-_]+$`) // callbackRedirectURL determines the redirect location after authentication. It -// only allows relative URLs to prevent open redirect attacks (see relativeURL). +// only allows relative URLs to prevent open redirect attacks (see relativeURL), +// with the exception of the trusted setup subdomain origin (SetupSiteURL). // If no redirect is found, fall back to SignInRedirectURL. func (s *Service) callbackRedirectURL( ctx context.Context, payload *gen.CallbackPayload, ) string { - var location string - if state := decodeStateParam(payload); state != nil { - location = relativeURL(state.FinalDestinationURL) - } - - if location != "" { - msg := fmt.Sprintf("Found destination URL in state: '%s'", location) - s.logger.InfoContext(ctx, msg) + // Allow absolute redirects to the trusted setup subdomain. + if s.cfg.SetupSiteURL != "" && isTrustedSetupRedirect(state.FinalDestinationURL, s.cfg.SetupSiteURL) { + s.logger.InfoContext(ctx, fmt.Sprintf("Redirecting to setup domain: '%s'", state.FinalDestinationURL)) + return state.FinalDestinationURL + } - return location + if location := relativeURL(state.FinalDestinationURL); location != "" { + s.logger.InfoContext(ctx, fmt.Sprintf("Found destination URL in state: '%s'", location)) + return location + } } return s.cfg.SignInRedirectURL } +// isTrustedSetupRedirect returns true if destURL is an absolute URL whose +// origin (scheme + host) matches the trusted setup site URL. +func isTrustedSetupRedirect(destURL, setupSiteURL string) bool { + dest, err := url.Parse(destURL) + if err != nil || dest.Host == "" { + return false + } + trusted, err := url.Parse(setupSiteURL) + if err != nil || trusted.Host == "" { + return false + } + return dest.Scheme == trusted.Scheme && dest.Host == trusted.Host +} + // relativeURL converts any URL to a safe relative URL by extracting only the // path, query, and fragment components. // diff --git a/server/internal/customdomains/middleware.go b/server/internal/customdomains/middleware.go index fb5d4145d8..3333e0b38e 100644 --- a/server/internal/customdomains/middleware.go +++ b/server/internal/customdomains/middleware.go @@ -15,7 +15,7 @@ import ( "github.com/speakeasy-api/gram/server/internal/oops" ) -func Middleware(logger *slog.Logger, db *pgxpool.Pool, env string, serverURL *url.URL) func(next http.Handler) http.Handler { +func Middleware(logger *slog.Logger, db *pgxpool.Pool, env string, serverURL *url.URL, setupSiteURL *url.URL) func(next http.Handler) http.Handler { domainsRepo := domainsRepo.New(db) logger = logger.With(attr.SlogComponent("custom_domains_middleware")) @@ -50,6 +50,11 @@ func Middleware(logger *slog.Logger, db *pgxpool.Pool, env string, serverURL *ur return } + if setupSiteURL != nil && host == setupSiteURL.Host { + next.ServeHTTP(w, r) + return + } + domain, err := domainsRepo.GetCustomDomainByDomain(ctx, host) switch { case errors.Is(err, pgx.ErrNoRows): diff --git a/server/internal/customdomains/middleware_test.go b/server/internal/customdomains/middleware_test.go index 098d43f0ad..6a6e91617e 100644 --- a/server/internal/customdomains/middleware_test.go +++ b/server/internal/customdomains/middleware_test.go @@ -214,7 +214,7 @@ func TestCustomDomainsMiddleware(t *testing.T) { } // Create the middleware - middlewareFunc := customdomains.Middleware(logger, instance.conn, tt.env, serverURL) + middlewareFunc := customdomains.Middleware(logger, instance.conn, tt.env, serverURL, nil) // Create a test handler that captures context and responds var capturedCtx context.Context @@ -278,7 +278,7 @@ func TestCustomDomainsMiddleware_DeletedDomain(t *testing.T) { err = instance.domainsRepo.DeleteCustomDomain(ctx, "org-deleted") require.NoError(t, err) - middlewareFunc := customdomains.Middleware(logger, instance.conn, "prod", serverURL) + middlewareFunc := customdomains.Middleware(logger, instance.conn, "prod", serverURL, nil) testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("Handler should not be called for deleted domain") @@ -310,7 +310,7 @@ func TestCustomDomainsMiddleware_DatabaseErrors(t *testing.T) { require.NoError(t, err) closedConn.Close() - middlewareFunc := customdomains.Middleware(logger, closedConn, "prod", serverURL) + middlewareFunc := customdomains.Middleware(logger, closedConn, "prod", serverURL, nil) testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("Handler should not be called when database error occurs") @@ -339,7 +339,7 @@ func TestCustomDomainsMiddleware_MissingHost(t *testing.T) { serverURL, err := url.Parse("https://api.speakeasyapi.dev") require.NoError(t, err) - middlewareFunc := customdomains.Middleware(logger, instance.conn, "prod", serverURL) + middlewareFunc := customdomains.Middleware(logger, instance.conn, "prod", serverURL, nil) testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("Handler should not be called with empty host") diff --git a/zero b/zero index 07675954a7..4a1d48ccad 100755 --- a/zero +++ b/zero @@ -114,6 +114,8 @@ interactive_only mise run zero:melange interactive_only mise run zero:fly mise run zero:encryption mise run zero:tls +mise run zero:hosts + assistant_runtime_provider="$(mise env GRAM_ASSISTANT_RUNTIME_PROVIDER 2>/dev/null | sed -n 's/^export GRAM_ASSISTANT_RUNTIME_PROVIDER=//p')" assistant_runtime_provider="${assistant_runtime_provider:-local}" if [[ "$assistant_runtime_provider" == "local" ]]; then