feat(ui): gate Gateways and Profiles from host bootstrap

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-22 19:38:17 +00:00
1 parent 89358f38bf
commit f7f21e585e
23 files changed
+429 -52

No files matched your search

+4
View File
@@ -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
+8
View File
@@ -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.
+2
View File
@@ -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 <path>`) 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.
+10
View File
@@ -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
+1
View File
@@ -8,6 +8,7 @@
<title>Paperclip</title>
<!-- PAPERCLIP_RUNTIME_BRANDING_START -->
<!-- PAPERCLIP_RUNTIME_BRANDING_END -->
<!-- PAPERCLIP_RUNTIME_FEATURES -->
<!-- PAPERCLIP_FAVICON_START -->
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
+12
View File
@@ -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('<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />');
});
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", () => {
+25 -10
View File
@@ -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() {
<Route path="apps/review" element={<AppsReview />} />
{/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */}
<Route path="apps/attention" element={<Navigate to="/apps/connections" replace />} />
<Route path="apps/gateways" element={<GatewaysList />} />
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
<Route element={<HostFeatureGate feature="apps.named_mcp_gateways" />}>
<Route path="apps/gateways" element={<GatewaysList />} />
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
<Route path="apps/advanced/gateways" element={<AdvancedToolsRoute />} />
</Route>
<Route path="apps/advanced" element={<AdvancedToolsRoute />} />
<Route path="apps/advanced/profiles/new" element={<ProfileWizardRoute mode="new" />} />
<Route path="apps/advanced/profiles/:profileId/edit" element={<ProfileWizardRoute mode="edit" />} />
<Route path="apps/advanced/profiles/:profileId" element={<ProfileDetailRoute />} />
<Route element={<HostFeatureGate feature="apps.access_profiles" />}>
<Route path="apps/advanced/profiles" element={<AdvancedToolsRoute />} />
<Route path="apps/advanced/profiles/new" element={<ProfileWizardRoute mode="new" />} />
<Route path="apps/advanced/profiles/:profileId/edit" element={<ProfileWizardRoute mode="edit" />} />
<Route path="apps/advanced/profiles/:profileId" element={<ProfileDetailRoute />} />
</Route>
<Route path="apps/advanced/:tab" element={<AdvancedToolsRoute />} />
<Route path="apps/app/:applicationId" element={<AppNotConnected />} />
<Route path="apps/app/:applicationId/:tab" element={<AppNotConnected />} />
@@ -408,19 +416,26 @@ function LegacySettingsRedirect() {
function LegacyToolsSettingsRedirect() {
const { tab } = useParams<{ tab?: string }>();
return <Navigate to={legacyToolsRedirectTarget(tab)} replace />;
const { hasFeature } = useRuntimeFeatures();
return <Navigate to={legacyToolsRedirectTarget(tab, hasFeature)} replace />;
}
// 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 <Navigate to={legacyToolsRedirectTarget(tab)} replace />;
const { hasFeature } = useRuntimeFeatures();
return <Navigate to={legacyToolsRedirectTarget(tab, hasFeature)} replace />;
}
function legacyToolsRedirectTarget(tab?: string) {
if (!tab) return "/apps/advanced/profiles";
export function legacyToolsRedirectTarget(
tab: string | undefined,
hasFeature: ReturnType<typeof useRuntimeFeatures>["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}`;
}
+6
View File
@@ -0,0 +1,6 @@
import type { HostFeaturesSnapshotV1 } from "@paperclipai/shared";
import { api } from "./client";
export const runtimeFeaturesApi = {
get: () => api.get<HostFeaturesSnapshotV1>("/runtime-features"),
};
+30
View File
@@ -11,6 +11,11 @@ const mockToolsApi = vi.hoisted(() => ({
listRuntimeSlots: vi.fn(),
listActionRequests: vi.fn(),
}));
const enabledFeatures = vi.hoisted(() => new Set<string>());
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(
<QueryClientProvider client={queryClient}>
<AppsSidebar />
</QueryClientProvider>,
);
});
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());
});
});
+6 -1
View File
@@ -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({
@@ -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<string>());
vi.mock("@/context/RuntimeFeaturesContext", () => ({
useRuntimeFeatures: () => ({ hasFeature: (feature: string) => enabledFeatures.has(feature) }),
}));
vi.mock("@/lib/router", () => ({
Outlet: () => <div>feature route</div>,
Navigate: ({ to }: { to: string }) => <div data-navigate={to}>redirect</div>,
}));
// 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<typeof createRoot>;
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(<HostFeatureGate feature="apps.access_profiles" />));
expect(container.querySelector("[data-navigate]")?.getAttribute("data-navigate")).toBe("/apps/connections");
enabledFeatures.add("apps.access_profiles");
flushSync(() => root.render(<HostFeatureGate feature="apps.access_profiles" />));
expect(container.textContent).toBe("feature route");
});
});
+14
View File
@@ -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) ? <Outlet /> : <Navigate to={fallback} replace />;
}
@@ -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 <div>{hasFeature("apps.access_profiles") ? "profiles enabled" : "profiles disabled"}</div>;
}
describe("RuntimeFeaturesProvider", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
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(
<QueryClientProvider client={queryClient}>
<RuntimeFeaturesProvider><Probe /></RuntimeFeaturesProvider>
</QueryClientProvider>,
);
});
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);
});
});
+33
View File
@@ -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<RuntimeFeaturesContextValue>({
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 (
<RuntimeFeaturesContext.Provider value={{ hasFeature: (feature) => features.has(feature) }}>
{children}
</RuntimeFeaturesContext.Provider>
);
}
export function useRuntimeFeatures() {
return useContext(RuntimeFeaturesContext);
}
+1
View File
@@ -1,4 +1,5 @@
export const queryKeys = {
runtimeFeatures: ["runtime-features"] as const,
companies: {
/**
* Prefix for everything company-shaped. Matches the list, details and stats
+39
View File
@@ -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);
});
});
+30
View File
@@ -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<string, unknown>;
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.hostFeatures)) {
return EMPTY_RUNTIME_FEATURES;
}
const allowed = new Set<string>(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;
}
}
+33 -25
View File
@@ -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(
<StrictMode>
<AppErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<BrowserRouter>
<CompanyProvider>
<EditorAutocompleteProvider>
<ToastProvider>
<LiveUpdatesProvider>
<TooltipProvider>
<CompanyAwareBreadcrumbProvider>
<SidebarProvider>
<PanelProvider>
<PluginLauncherProvider>
<DialogProvider>
<App />
</DialogProvider>
</PluginLauncherProvider>
</PanelProvider>
</SidebarProvider>
</CompanyAwareBreadcrumbProvider>
</TooltipProvider>
</LiveUpdatesProvider>
</ToastProvider>
</EditorAutocompleteProvider>
</CompanyProvider>
</BrowserRouter>
</ThemeProvider>
<RuntimeFeaturesProvider>
<ThemeProvider>
<BrowserRouter>
<CompanyProvider>
<EditorAutocompleteProvider>
<ToastProvider>
<LiveUpdatesProvider>
<TooltipProvider>
<CompanyAwareBreadcrumbProvider>
<SidebarProvider>
<PanelProvider>
<PluginLauncherProvider>
<DialogProvider>
<App />
</DialogProvider>
</PluginLauncherProvider>
</PanelProvider>
</SidebarProvider>
</CompanyAwareBreadcrumbProvider>
</TooltipProvider>
</LiveUpdatesProvider>
</ToastProvider>
</EditorAutocompleteProvider>
</CompanyProvider>
</BrowserRouter>
</ThemeProvider>
</RuntimeFeaturesProvider>
</QueryClientProvider>
</AppErrorBoundary>
</StrictMode>
+21 -12
View File
@@ -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<Record<string, boolean>>({});
const lastSavedInstallRef = useRef<Record<string, boolean>>({});
const skipNextInstallAutosaveRef = useRef(true);
@@ -525,12 +528,14 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord;
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Access profiles
</div>
<Link
to={`${profilesHref}?check=1`}
className="text-(length:--text-micro) font-medium text-primary hover:underline"
>
Check access
</Link>
{profileAdministrationEnabled ? (
<Link
to={`${profilesHref}?check=1`}
className="text-(length:--text-micro) font-medium text-primary hover:underline"
>
Check access
</Link>
) : null}
</div>
{profiles.length === 0 ? (
<p className="text-xs text-muted-foreground">
@@ -540,12 +545,16 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord;
profiles.map((profile) => {
return (
<div key={profile.id} className="rounded-md border border-border/70 px-2.5 py-2">
<Link
to={`${profilesHref}/${profile.id}`}
className="text-xs font-medium text-primary hover:underline"
>
{profile.name}
</Link>
{profileAdministrationEnabled ? (
<Link
to={`${profilesHref}/${profile.id}`}
className="text-xs font-medium text-primary hover:underline"
>
{profile.name}
</Link>
) : (
<span className="text-xs font-medium text-foreground">{profile.name}</span>
)}
{profile.summary.isCompanyDefault ? (
<div className="mt-1">
<span className="rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
+3 -2
View File
@@ -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({
+6 -1
View File
@@ -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 (
<nav className="flex items-center gap-6 border-b border-border text-sm" aria-label="Apps sections">
{ITEMS.map((item) => {
{items.map((item) => {
const isActive = item.key === active;
return (
<Link
+21
View File
@@ -7,6 +7,11 @@ import { ToolsAccess } from "./ToolsAccess";
const mockParams = vi.hoisted(() => ({ tab: undefined as string | undefined }));
const navigateMock = vi.hoisted(() => vi.fn(({ to }: { to: string }) => <div data-navigate={to} />));
const enabledFeatures = vi.hoisted(() => new Set<string>());
vi.mock("@/context/RuntimeFeaturesContext", () => ({
useRuntimeFeatures: () => ({ hasFeature: (feature: string) => enabledFeatures.has(feature) }),
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
@@ -74,6 +79,8 @@ describe("ToolsAccess", () => {
document.body.appendChild(container);
root = createRoot(container);
mockParams.tab = undefined;
enabledFeatures.add("apps.named_mcp_gateways");
enabledFeatures.add("apps.access_profiles");
});
afterEach(() => {
@@ -81,6 +88,7 @@ describe("ToolsAccess", () => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
enabledFeatures.clear();
});
async function render() {
@@ -113,4 +121,17 @@ describe("ToolsAccess", () => {
expect(container.textContent).toContain("Developer tools");
expect(container.textContent).toContain("Tool profiles");
});
it.each([
["profiles", "apps.access_profiles"],
["gateways", "apps.named_mcp_gateways"],
])("redirects the %s direct tab when its host feature is unavailable", async (tab, feature) => {
enabledFeatures.delete(feature);
mockParams.tab = tab;
await render();
expect(navigateMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/connections", replace: true }),
);
});
});
+13 -1
View File
@@ -4,6 +4,7 @@ import { Link, Navigate, useParams } from "@/lib/router";
import { cn } from "@/lib/utils";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useRuntimeFeatures } from "@/context/RuntimeFeaturesContext";
import { ProfilesIndex } from "./profiles/ProfilesIndex";
import { PoliciesTab } from "./PoliciesTab";
import { RuntimeTab } from "./RuntimeTab";
@@ -46,6 +47,7 @@ export function ToolsAccess() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const params = useParams<{ tab?: string }>();
const { hasFeature } = useRuntimeFeatures();
const activeTab = (TOOL_TABS.find((t) => t.key === params.tab)?.key ?? "run-your-own") as ToolTabKey;
const advanced = isAdvancedSetupTab(activeTab);
const tabLabel = TOOL_TABS.find((t) => t.key === activeTab)?.label;
@@ -68,6 +70,13 @@ export function ToolsAccess() {
return <div className="p-6 text-sm text-muted-foreground">Select a company to open advanced setup.</div>;
}
if (
(activeTab === "profiles" && !hasFeature("apps.access_profiles"))
|| (activeTab === "gateways" && !hasFeature("apps.named_mcp_gateways"))
) {
return <Navigate to="/apps/connections" replace />;
}
// Retired developer tabs (PAP-10915/PAP-10928) — keep old links working.
if (
params.tab === "applications" ||
@@ -122,7 +131,10 @@ export function ToolsAccess() {
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Wrench className="h-3.5 w-3.5" />
Looking for the developer surface?{" "}
<Link to={advancedTabHref("profiles")} className="font-medium text-primary hover:underline">
<Link
to={hasFeature("apps.access_profiles") ? advancedTabHref("profiles") : advancedTabHref("policies")}
className="font-medium text-primary hover:underline"
>
Open developer tools
</Link>
</p>