diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index f276d3dfbc7..07c8020d747 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -66,6 +66,10 @@ The governing rule is: **skill permissions are opt-in restrictions, not opt-in c Core Paperclip owns the skill runtime, company-boundary enforcement, policy evaluation contract, API denials, validation, path containment, secret redaction, and activity logging. Those safety invariants cannot be disabled by policy. Open-by-default skill work never authorizes arbitrary host-path reads, unsafe executable content, or policy edits: local imports and scans must stay within Paperclip-known workspace or managed-skill roots, remote sources must resolve to validated immutable content, and platform safety denials must stay distinct from optional administrative restrictions. Paperclip EE may provide detailed administration for per-agent, per-role, per-action, per-source, and protected-skill rules, but EE is not required to use skills and is not an enforcement boundary. Without EE, companies remain open by default and any already-configured restrictions continue to be enforced by core. +### Optional Apps Administration + +Named MCP Gateways and Access Profile administration are Paperclip EE product surfaces. Core owns their stored data, runtime policy evaluation, and internal tool-gateway sessions so disabling or uninstalling EE cannot erase configuration or weaken enforcement. The canonical Paperclip EE plugin may make these administration surfaces available only while its lifecycle state is ready. In Community Edition, and during every non-ready EE lifecycle state, the server fails closed: navigation and direct routes are hidden, and management APIs return a stable feature-unavailable response. Re-enabling EE restores access to the preserved rows. + An explicit restricted policy may deny selected operations or switch to a default-deny preset with explicit allow rules. Core exposes a stable versioned policy API so EE and other administrative clients configure and simulate the same evaluator used by skill mutation routes. Core Skill Studio only needs to perform normal skill work, explain an explicit denial, and point administrators to EE when its richer policy UI is available; it must not recreate a partial enterprise permission editor. ## Principles diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 5a4984b1bb4..09df2d1c08e 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -638,6 +638,14 @@ The approved term set is: | Inbox management | Responsible agent may archive/unarchive its responsible user's Mine items under a default-open user policy; explicit cross-user access requires saved target-user opt-in or `inbox:manage`; all mutations are audited | Policy administration UX, organization presets, simulations, bulk controls, and richer audit/reporting surfaces | | Escalation | Escalate from agent to manager to board; board approval/budget gates remain authoritative | Escalation routing and SLA windows | +### 9.6.1 Server-authoritative host features + +Core owns the closed, versioned host-feature keys `apps.named_mcp_gateways` and `apps.access_profiles`. Only the canonical `paperclipai.paperclip-ee` plugin may declare them in `PaperclipPluginManifestV1.hostFeatures`, and only a stored plugin record in `ready` state enables its valid declaration. Missing, installed, disabled, error, upgrade-pending, uninstalled, malformed, or non-canonical providers resolve to an empty feature set. + +The server injects the non-sensitive effective feature snapshot into both static and Vite-served HTML before React starts, and exposes the same snapshot through an authenticated API for lifecycle revalidation. UI visibility is advisory; server routes remain authoritative. Named-gateway management, token, metadata, public, and ID-based MCP routes require `apps.named_mcp_gateways`. Profile administration routes require `apps.access_profiles`. Unavailable management routes return `404` with `code: "feature_unavailable"`. + +Core profile list/effective reads, internal tool-gateway sessions, and runtime policy enforcement stay available in all editions. Feature disable and plugin uninstall do not delete gateway, profile, binding, entry, token, or policy rows; returning the canonical provider to `ready` restores the management surfaces over the existing data. + ## 9.7 Recommended first-slice implementation order 1. Lock route-level checks for existing company boundaries, actor extraction, and approval/budget gates. diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index b541ff5caa4..9b028cb1a9c 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -6,6 +6,8 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i > **New to plugins?** Start with the short [Local Plugin Development guide](./LOCAL_PLUGIN_DEVELOPMENT.md) — it walks the CLI happy path (`plugin init` → `pnpm dev` → `plugin install `) end to end. Come back here for the full manifest surface, worker capabilities, and UI components. +> **Host features are reserved.** The optional versioned `hostFeatures` manifest field is not an extension point for third-party plugins. Core accepts its closed Gateways and Profiles keys only from the canonical `paperclipai.paperclip-ee` plugin, and enables them only while that plugin is ready. Other plugins must use capabilities, API routes, tools, and UI slots for their integrations. + ## Current reality - Treat plugin workers and plugin UI as trusted code. diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 45fba6be867..1d1590ec791 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -346,6 +346,10 @@ export interface PaperclipPluginManifestV1 { routines?: PluginManagedRoutineDeclaration[]; skills?: PluginManagedSkillDeclaration[]; localFolders?: PluginLocalFolderDeclaration[]; + hostFeatures?: { + schemaVersion: 1; + features: Array<"apps.named_mcp_gateways" | "apps.access_profiles">; + }; /** Legacy top-level launcher declarations. Prefer `ui.launchers` for new manifests. */ launchers?: PluginLauncherDeclaration[]; ui?: { @@ -387,6 +391,12 @@ Rules: - `minimumHostVersion` is preferred, with `minimumPaperclipVersion` retained for backwards compatibility - `capabilities` must be static and install-time visible +- `hostFeatures` is a closed host-product contract, not a general extension + mechanism. Only the canonical `paperclipai.paperclip-ee` manifest may declare + it. Unknown keys, duplicates, unsupported schema versions, and declarations + from any other plugin are rejected. The host enables a valid declaration only + while that plugin's stored lifecycle status is `ready`; every other state + fails closed. - config schema must be JSON Schema compatible - `entrypoints.ui` points to the directory containing the built UI bundle - `ui.slots` declares which extension slots the plugin fills, so the host knows what to mount without loading the bundle eagerly; each slot references an `exportName` from the UI bundle diff --git a/ui/index.html b/ui/index.html index 8c2cbaa6f54..de16f862748 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,6 +8,7 @@ Paperclip + diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 920f192baf8..b22d0d409fd 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CloudAccessGate } from "./components/CloudAccessGate"; +import { legacyToolsRedirectTarget } from "./App"; import appSource from "./App.tsx?raw"; const mockHealthApi = vi.hoisted(() => ({ @@ -252,6 +253,17 @@ describe("Apps routes", () => { expect(appSource).toContain('} />'); expect(appSource).toContain('} />'); }); + + it("fails closed for legacy Gateways and Profiles links", () => { + const disabled = () => false; + const enabled = () => true; + + expect(legacyToolsRedirectTarget(undefined, disabled)).toBe("/apps/connections"); + expect(legacyToolsRedirectTarget("profiles", disabled)).toBe("/apps/connections"); + expect(legacyToolsRedirectTarget("gateways", disabled)).toBe("/apps/connections"); + expect(legacyToolsRedirectTarget("profiles", enabled)).toBe("/apps/advanced/profiles"); + expect(legacyToolsRedirectTarget("gateways", enabled)).toBe("/apps/advanced/gateways"); + }); }); describe("Decisions routes", () => { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 161c2b915f7..b357d91fde1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,6 +11,7 @@ import { StatusCardsExperimentalGate } from "./components/StatusCardsExperimenta import { AppsExperimentalGate } from "./components/AppsExperimentalGate"; import { HiddenSettingsPageGate } from "./components/HiddenSettingsPageGate"; import { useHiddenSettings } from "./hooks/useHiddenSettings"; +import { HostFeatureGate } from "./components/HostFeatureGate"; import { Cases } from "./pages/Cases"; import { CaseDetail } from "./pages/CaseDetail"; import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant"; @@ -90,6 +91,7 @@ import { InviteLandingPage } from "./pages/InviteLanding"; import { JoinRequestQueue } from "./pages/JoinRequestQueue"; import { NotFoundPage } from "./pages/NotFound"; import { useCompany } from "./context/CompanyContext"; +import { useRuntimeFeatures } from "./context/RuntimeFeaturesContext"; import { useDialogActions, useDialogState } from "./context/DialogContext"; import { loadLastInboxTab } from "./lib/inbox"; import { @@ -142,13 +144,19 @@ function boardRoutes() { } /> {/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */} } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> } /> } /> @@ -408,19 +416,26 @@ function LegacySettingsRedirect() { function LegacyToolsSettingsRedirect() { const { tab } = useParams<{ tab?: string }>(); - return ; + const { hasFeature } = useRuntimeFeatures(); + return ; } // The developer "Tools" surface moved under the Apps "Advanced setup" door // (PAP-10862). `/tools` and `/tools/:tab` redirect to their new home. function LegacyToolsRedirect() { const { tab } = useParams<{ tab?: string }>(); - return ; + const { hasFeature } = useRuntimeFeatures(); + return ; } -function legacyToolsRedirectTarget(tab?: string) { - if (!tab) return "/apps/advanced/profiles"; +export function legacyToolsRedirectTarget( + tab: string | undefined, + hasFeature: ReturnType["hasFeature"], +) { + if (!tab) return hasFeature("apps.access_profiles") ? "/apps/advanced/profiles" : "/apps/connections"; if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps/connections"; + if (tab === "profiles" && !hasFeature("apps.access_profiles")) return "/apps/connections"; + if (tab === "gateways" && !hasFeature("apps.named_mcp_gateways")) return "/apps/connections"; return `/apps/advanced/${tab}`; } diff --git a/ui/src/api/runtimeFeatures.ts b/ui/src/api/runtimeFeatures.ts new file mode 100644 index 00000000000..1495498ab08 --- /dev/null +++ b/ui/src/api/runtimeFeatures.ts @@ -0,0 +1,6 @@ +import type { HostFeaturesSnapshotV1 } from "@paperclipai/shared"; +import { api } from "./client"; + +export const runtimeFeaturesApi = { + get: () => api.get("/runtime-features"), +}; diff --git a/ui/src/components/AppsSidebar.test.tsx b/ui/src/components/AppsSidebar.test.tsx index ff0c9e403b4..ae55d4201f3 100644 --- a/ui/src/components/AppsSidebar.test.tsx +++ b/ui/src/components/AppsSidebar.test.tsx @@ -11,6 +11,11 @@ const mockToolsApi = vi.hoisted(() => ({ listRuntimeSlots: vi.fn(), listActionRequests: vi.fn(), })); +const enabledFeatures = vi.hoisted(() => new Set()); + +vi.mock("@/context/RuntimeFeaturesContext", () => ({ + useRuntimeFeatures: () => ({ hasFeature: (feature: string) => enabledFeatures.has(feature) }), +})); vi.mock("@/lib/router", () => ({ Link: ({ @@ -96,12 +101,15 @@ describe("AppsSidebar", () => { ], }); mockToolsApi.listActionRequests.mockResolvedValue({ actionRequests: [] }); + enabledFeatures.add("apps.named_mcp_gateways"); + enabledFeatures.add("apps.access_profiles"); }); afterEach(() => { container.remove(); document.body.innerHTML = ""; vi.clearAllMocks(); + enabledFeatures.clear(); }); it("renders Apps and Developer sections in one sidebar", async () => { @@ -165,4 +173,26 @@ describe("AppsSidebar", () => { root.unmount(); }); }); + + it("fails closed for Gateways and Profiles while leaving core developer tabs visible", async () => { + enabledFeatures.clear(); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Gateways" })); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Profiles" })); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ label: "Rules" })); + expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ label: "Health" })); + + await act(async () => root.unmount()); + }); }); diff --git a/ui/src/components/AppsSidebar.tsx b/ui/src/components/AppsSidebar.tsx index f339f8e9bc6..6fcd248296a 100644 --- a/ui/src/components/AppsSidebar.tsx +++ b/ui/src/components/AppsSidebar.tsx @@ -8,6 +8,7 @@ import { toolsApi } from "@/api/tools"; import { DEVELOPER_TABS, advancedTabHref, isExperimentalToolTab } from "@/pages/tools/tool-tabs"; import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled"; import { useReviewCount } from "@/pages/apps/useReviewCount"; +import { useRuntimeFeatures } from "@/context/RuntimeFeaturesContext"; import { SidebarNavItem } from "./SidebarNavItem"; /** @@ -33,8 +34,12 @@ export function AppsSidebar() { const reviewCount = useReviewCount(); const { enabled: smokeLabEnabled } = useSmokeLabEnabled(); + const { hasFeature } = useRuntimeFeatures(); const developerTabs = DEVELOPER_TABS.filter( - (tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled, + (tab) => + (!isExperimentalToolTab(tab.key) || smokeLabEnabled) + && (tab.key !== "gateways" || hasFeature("apps.named_mcp_gateways")) + && (tab.key !== "profiles" || hasFeature("apps.access_profiles")), ); const runtimeSlots = useQuery({ diff --git a/ui/src/components/HostFeatureGate.test.tsx b/ui/src/components/HostFeatureGate.test.tsx new file mode 100644 index 00000000000..3522ee40a2a --- /dev/null +++ b/ui/src/components/HostFeatureGate.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HostFeatureGate } from "./HostFeatureGate"; + +const enabledFeatures = vi.hoisted(() => new Set()); + +vi.mock("@/context/RuntimeFeaturesContext", () => ({ + useRuntimeFeatures: () => ({ hasFeature: (feature: string) => enabledFeatures.has(feature) }), +})); + +vi.mock("@/lib/router", () => ({ + Outlet: () =>
feature route
, + Navigate: ({ to }: { to: string }) =>
redirect
, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("HostFeatureGate", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + enabledFeatures.clear(); + container.remove(); + }); + + it("redirects unavailable feature routes and restores them when enabled", () => { + flushSync(() => root.render()); + expect(container.querySelector("[data-navigate]")?.getAttribute("data-navigate")).toBe("/apps/connections"); + + enabledFeatures.add("apps.access_profiles"); + flushSync(() => root.render()); + expect(container.textContent).toBe("feature route"); + }); +}); diff --git a/ui/src/components/HostFeatureGate.tsx b/ui/src/components/HostFeatureGate.tsx new file mode 100644 index 00000000000..db6a0b21654 --- /dev/null +++ b/ui/src/components/HostFeatureGate.tsx @@ -0,0 +1,14 @@ +import type { HostFeatureKey } from "@paperclipai/shared"; +import { Navigate, Outlet } from "@/lib/router"; +import { useRuntimeFeatures } from "@/context/RuntimeFeaturesContext"; + +export function HostFeatureGate({ + feature, + fallback = "/apps/connections", +}: { + feature: HostFeatureKey; + fallback?: string; +}) { + const { hasFeature } = useRuntimeFeatures(); + return hasFeature(feature) ? : ; +} diff --git a/ui/src/context/RuntimeFeaturesContext.test.tsx b/ui/src/context/RuntimeFeaturesContext.test.tsx new file mode 100644 index 00000000000..43d939bc0bb --- /dev/null +++ b/ui/src/context/RuntimeFeaturesContext.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { queryKeys } from "@/lib/queryKeys"; +import { RuntimeFeaturesProvider, useRuntimeFeatures } from "./RuntimeFeaturesContext"; + +const getRuntimeFeatures = vi.hoisted(() => vi.fn()); +vi.mock("@/api/runtimeFeatures", () => ({ + runtimeFeaturesApi: { get: getRuntimeFeatures }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function Probe() { + const { hasFeature } = useRuntimeFeatures(); + return
{hasFeature("apps.access_profiles") ? "profiles enabled" : "profiles disabled"}
; +} + +describe("RuntimeFeaturesProvider", () => { + let container: HTMLDivElement; + let root: ReturnType; + let queryClient: QueryClient; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + queryClient.clear(); + container.remove(); + vi.clearAllMocks(); + }); + + it("uses bootstrap state on first render and revalidates after lifecycle invalidation", async () => { + queryClient.setQueryData( + queryKeys.runtimeFeatures, + { schemaVersion: 1, hostFeatures: ["apps.access_profiles"] }, + { updatedAt: Date.now() }, + ); + getRuntimeFeatures.mockResolvedValue({ schemaVersion: 1, hostFeatures: [] }); + + flushSync(() => { + root.render( + + + , + ); + }); + + expect(container.textContent).toBe("profiles enabled"); + expect(getRuntimeFeatures).not.toHaveBeenCalled(); + + await queryClient.invalidateQueries({ queryKey: queryKeys.runtimeFeatures }); + await vi.waitFor(() => expect(container.textContent).toBe("profiles disabled")); + expect(getRuntimeFeatures).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/src/context/RuntimeFeaturesContext.tsx b/ui/src/context/RuntimeFeaturesContext.tsx new file mode 100644 index 00000000000..77cf927a11f --- /dev/null +++ b/ui/src/context/RuntimeFeaturesContext.tsx @@ -0,0 +1,33 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { HostFeatureKey } from "@paperclipai/shared"; +import { runtimeFeaturesApi } from "@/api/runtimeFeatures"; +import { queryKeys } from "@/lib/queryKeys"; + +type RuntimeFeaturesContextValue = { + hasFeature(feature: HostFeatureKey): boolean; +}; + +const RuntimeFeaturesContext = createContext({ + hasFeature: () => false, +}); + +export function RuntimeFeaturesProvider({ children }: { children: ReactNode }) { + const query = useQuery({ + queryKey: queryKeys.runtimeFeatures, + queryFn: runtimeFeaturesApi.get, + staleTime: 30_000, + refetchOnWindowFocus: true, + }); + const features = new Set(query.data?.hostFeatures ?? []); + + return ( + features.has(feature) }}> + {children} + + ); +} + +export function useRuntimeFeatures() { + return useContext(RuntimeFeaturesContext); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index b4680ed1899..8a43ea49446 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -1,4 +1,5 @@ export const queryKeys = { + runtimeFeatures: ["runtime-features"] as const, companies: { /** * Prefix for everything company-shaped. Matches the list, details and stats diff --git a/ui/src/lib/runtime-features.test.ts b/ui/src/lib/runtime-features.test.ts new file mode 100644 index 00000000000..6f855d457a0 --- /dev/null +++ b/ui/src/lib/runtime-features.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vitest"; +import { EMPTY_RUNTIME_FEATURES, readRuntimeFeaturesBootstrap } from "./runtime-features"; + +describe("runtime feature bootstrap", () => { + afterEach(() => { + document.head.innerHTML = ""; + }); + + function setSnapshot(value: string) { + const script = document.createElement("script"); + script.id = "paperclip-runtime-features"; + script.type = "application/json"; + script.textContent = value; + document.head.appendChild(script); + } + + it("accepts the closed versioned feature declaration", () => { + setSnapshot(JSON.stringify({ + schemaVersion: 1, + hostFeatures: ["apps.named_mcp_gateways", "apps.access_profiles"], + })); + expect(readRuntimeFeaturesBootstrap()).toEqual({ + schemaVersion: 1, + hostFeatures: ["apps.named_mcp_gateways", "apps.access_profiles"], + }); + }); + + it.each([ + "not json", + JSON.stringify({ schemaVersion: 2, hostFeatures: ["apps.access_profiles"] }), + JSON.stringify({ schemaVersion: 1, hostFeatures: ["apps.unknown"] }), + JSON.stringify({ schemaVersion: 1, hostFeatures: ["apps.access_profiles", "apps.access_profiles"] }), + ])("fails closed for invalid bootstrap data", (value) => { + setSnapshot(value); + expect(readRuntimeFeaturesBootstrap()).toEqual(EMPTY_RUNTIME_FEATURES); + }); +}); diff --git a/ui/src/lib/runtime-features.ts b/ui/src/lib/runtime-features.ts new file mode 100644 index 00000000000..15ea4b5eb62 --- /dev/null +++ b/ui/src/lib/runtime-features.ts @@ -0,0 +1,30 @@ +import type { HostFeatureKey, HostFeaturesSnapshotV1 } from "@paperclipai/shared"; +import { HOST_FEATURE_KEYS } from "@paperclipai/shared"; + +export const EMPTY_RUNTIME_FEATURES: HostFeaturesSnapshotV1 = { + schemaVersion: 1, + hostFeatures: [], +}; + +/** Read the server-rendered feature snapshot before React mounts. */ +export function readRuntimeFeaturesBootstrap(doc: Document = document): HostFeaturesSnapshotV1 { + const element = doc.getElementById("paperclip-runtime-features"); + if (!element?.textContent) return EMPTY_RUNTIME_FEATURES; + + try { + const parsed = JSON.parse(element.textContent) as Record; + if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.hostFeatures)) { + return EMPTY_RUNTIME_FEATURES; + } + const allowed = new Set(HOST_FEATURE_KEYS); + const hostFeatures = parsed.hostFeatures.filter( + (feature): feature is HostFeatureKey => typeof feature === "string" && allowed.has(feature), + ); + if (hostFeatures.length !== parsed.hostFeatures.length || new Set(hostFeatures).size !== hostFeatures.length) { + return EMPTY_RUNTIME_FEATURES; + } + return { schemaVersion: 1, hostFeatures }; + } catch { + return EMPTY_RUNTIME_FEATURES; + } +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 2766c51e1e2..f8252132129 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -15,12 +15,15 @@ import { DialogProvider } from "./context/DialogContext"; import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext"; import { ToastProvider } from "./context/ToastContext"; import { ThemeProvider } from "./context/ThemeContext"; +import { RuntimeFeaturesProvider } from "./context/RuntimeFeaturesContext"; import { TooltipProvider } from "@/components/ui/tooltip"; import { initPluginBridge } from "./plugins/bridge-init"; import { PluginLauncherProvider } from "./plugins/launchers"; import { startPerfMeasureReaper } from "./lib/perf-measure-reaper"; import "@mdxeditor/editor/style.css"; import "./index.css"; +import { queryKeys } from "./lib/queryKeys"; +import { readRuntimeFeaturesBootstrap } from "./lib/runtime-features"; initPluginBridge(React, ReactDOM); @@ -47,6 +50,9 @@ const queryClient = new QueryClient({ }, }, }); +queryClient.setQueryData(queryKeys.runtimeFeatures, readRuntimeFeaturesBootstrap(), { + updatedAt: Date.now(), +}); function CompanyAwareBreadcrumbProvider({ children }: { children: React.ReactNode }) { const { selectedCompany } = useCompany(); @@ -57,31 +63,33 @@ createRoot(document.getElementById("root")!).render( - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/pages/AgentToolsTab.tsx b/ui/src/pages/AgentToolsTab.tsx index 3b22837d7ec..750027c7ebe 100644 --- a/ui/src/pages/AgentToolsTab.tsx +++ b/ui/src/pages/AgentToolsTab.tsx @@ -22,6 +22,7 @@ import { import { cn } from "../lib/utils"; import { brandChipBadge } from "../lib/status-colors"; import { installPayload, installStateFrom, isAgentInstalled, INSTALLED_HINT } from "../lib/tool-installs"; +import { useRuntimeFeatures } from "../context/RuntimeFeaturesContext"; /** Normalize a selector value (string or string[]) into a flat string list. */ function selectorStringList(value: unknown): string[] { @@ -228,6 +229,8 @@ const DENIED_TOOLS_DISPLAY_LIMIT = 30; */ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord; companyId: string }) { const queryClient = useQueryClient(); + const { hasFeature } = useRuntimeFeatures(); + const profileAdministrationEnabled = hasFeature("apps.access_profiles"); const [installDraft, setInstallDraft] = useState>({}); const lastSavedInstallRef = useRef>({}); const skipNextInstallAutosaveRef = useRef(true); @@ -525,12 +528,14 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord;
Access profiles
- - Check access - + {profileAdministrationEnabled ? ( + + Check access + + ) : null} {profiles.length === 0 ? (

@@ -540,12 +545,16 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord; profiles.map((profile) => { return (

- - {profile.name} - + {profileAdministrationEnabled ? ( + + {profile.name} + + ) : ( + {profile.name} + )} {profile.summary.isCompanyDefault ? (
diff --git a/ui/src/pages/PluginManager.tsx b/ui/src/pages/PluginManager.tsx index e13ab4ce287..2c0f748d6ab 100644 --- a/ui/src/pages/PluginManager.tsx +++ b/ui/src/pages/PluginManager.tsx @@ -79,8 +79,8 @@ function ExperimentalBadge() { * * Data flow: * - Reads from `GET /api/plugins` via `pluginsApi.list()`. - * - Mutations (install / uninstall / enable / disable) invalidate - * `queryKeys.plugins.all` so the list refreshes automatically. + * - Mutations (install / uninstall / enable / disable) invalidate plugin data + * and the host-feature snapshot so feature routes revalidate immediately. * * @see PluginSettings — linked from the Settings icon on each plugin row. * @see doc/plugins/PLUGIN_SPEC.md §3 — Plugin Lifecycle for status semantics. @@ -119,6 +119,7 @@ export function PluginManager() { queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all }); queryClient.invalidateQueries({ queryKey: queryKeys.plugins.examples }); queryClient.invalidateQueries({ queryKey: queryKeys.plugins.uiContributions }); + queryClient.invalidateQueries({ queryKey: queryKeys.runtimeFeatures }); }; const installMutation = useMutation({ diff --git a/ui/src/pages/apps/gateways/AppsSubNav.tsx b/ui/src/pages/apps/gateways/AppsSubNav.tsx index 4977902141e..2f547a6b6d6 100644 --- a/ui/src/pages/apps/gateways/AppsSubNav.tsx +++ b/ui/src/pages/apps/gateways/AppsSubNav.tsx @@ -1,5 +1,6 @@ import { Link } from "@/lib/router"; import { cn } from "@/lib/utils"; +import { useRuntimeFeatures } from "@/context/RuntimeFeaturesContext"; type SubNavKey = "connected" | "gateways" | "activity"; @@ -15,9 +16,13 @@ const ITEMS: { key: SubNavKey; label: string; href: string }[] = [ * design of record, rather than buried under the Advanced developer door. */ export function AppsSubNav({ active }: { active: SubNavKey }) { + const { hasFeature } = useRuntimeFeatures(); + const items = ITEMS.filter( + (item) => item.key !== "gateways" || hasFeature("apps.named_mcp_gateways"), + ); return (