Files
paperclip/ui/src/pages/PluginManager.tsx
T
Dotta f0ddd24d61 [codex] Show bundled plugins in plugin manager (#6734)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The plugin system is how Paperclip exposes optional capabilities and
integrations without bloating the control plane.
> - Operators need the Instance Settings plugin manager to show both
installed external plugins and bundled built-in plugins.
> - Bundled plugins were available in the server/UI surface but were not
represented consistently in the plugin manager list.
> - Workspace runtime reuse also needed to stay pinned to the current
branch/base so the plugin manager can be validated from the intended
checkout.
> - This pull request shows bundled plugins in the manager, marks
experimental bundled plugins clearly, and tightens runtime/worktree
reuse guards.
> - The benefit is that operators can discover bundled plugins from the
same management screen as installed plugins without stale workspace
sessions hiding the latest branch state.

## What Changed

- Lists bundled monorepo plugin packages through the plugin routes API,
including plugin status and install metadata needed by the UI.
- Updates the plugin manager UI/API client to render bundled plugins and
display experimental badges based on installed plugin records.
- Adds server authorization coverage around plugin routes so board and
agent access stay company-scoped.
- Guards execution workspace/runtime reuse against stale base refs and
defaults new worktrees to the fetched target base.
- Expands workspace runtime tests for service reuse, stale workspace
prevention, and controlled runtime stops.
- Addressed Greptile feedback by respecting `origin/HEAD`, using async
cached bundled-plugin discovery, and avoiding duplicated UI experimental
plugin lists.

## Verification

- `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/workspace-runtime.test.ts
server/src/__tests__/heartbeat-workspace-session.test.ts`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/plugin-sdk build && pnpm --filter
@paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `gh pr checks 6734 --repo paperclipai/paperclip` reports all checks
passing on `10e1ba9e0f505637cd913713fb28c2c99ae92011`.
- Greptile Review reports 5/5 on
`10e1ba9e0f505637cd913713fb28c2c99ae92011`.
- Confirmed the branch is rebased onto `public-gh/master` and the PR
diff does not include `pnpm-lock.yaml` or `.github/workflows` changes.
- UI screenshots were not captured in this PR-creation pass because the
available local board runtime is authenticated; the visible UI path is
covered by the plugin manager code changes and server/API tests above.

## Risks

- Medium risk: this touches shared plugin listing behavior and workspace
runtime reuse, so regressions could affect plugin manager visibility or
service reuse across execution workspaces.
- No database migrations.
- No lockfile or GitHub workflow changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex, coding-agent workflow with shell/tool use in a
local Paperclip worktree. Context window not surfaced by the runtime;
reasoning mode not externally reported.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-26 07:32:45 -06:00

552 lines
24 KiB
TypeScript

/**
* @fileoverview Plugin Manager page — admin UI for discovering,
* installing, enabling/disabling, and uninstalling plugins.
*
* @see PLUGIN_SPEC.md §9 — Plugin Marketplace / Manager
*/
import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { PluginRecord } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { AlertTriangle, FlaskConical, Plus, Power, Puzzle, Settings, Trash } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToastActions } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value
.split(/\r?\n/)
.map((entry) => entry.trim())
.find(Boolean);
return line ?? null;
}
function getPluginErrorSummary(plugin: PluginRecord): string {
return firstNonEmptyLine(plugin.lastError) ?? "Plugin entered an error state without a stored error message.";
}
function isExperimentalPluginIdentity(input: {
packageName?: string | null;
packagePath?: string | null;
manifestJson?: PluginRecord["manifestJson"] | null;
bundledExperimental?: boolean;
}) {
if (input.bundledExperimental) return true;
const packageName = input.packageName ?? "";
const packagePath = input.packagePath ?? "";
if (packageName.includes("sandbox") || packagePath.includes("sandbox")) return true;
return input.manifestJson?.environmentDrivers?.some((driver) => driver.kind === "sandbox_provider") === true;
}
function ExperimentalBadge() {
return (
<Badge
variant="outline"
className="border-amber-500/30 bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-200"
>
Experimental
</Badge>
);
}
/**
* PluginManager page component.
*
* Provides a management UI for the Paperclip plugin system:
* - Lists all installed plugins with their status, version, and category badges.
* - Allows installing new plugins by npm package name.
* - Provides per-plugin actions: enable, disable, navigate to settings.
* - Uninstall with a two-step confirmation dialog to prevent accidental removal.
*
* Data flow:
* - Reads from `GET /api/plugins` via `pluginsApi.list()`.
* - Mutations (install / uninstall / enable / disable) invalidate
* `queryKeys.plugins.all` so the list refreshes automatically.
*
* @see PluginSettings — linked from the Settings icon on each plugin row.
* @see doc/plugins/PLUGIN_SPEC.md §3 — Plugin Lifecycle for status semantics.
*/
export function PluginManager() {
const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [installPackage, setInstallPackage] = useState("");
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [uninstallPluginId, setUninstallPluginId] = useState<string | null>(null);
const [uninstallPluginName, setUninstallPluginName] = useState<string>("");
const [errorDetailsPlugin, setErrorDetailsPlugin] = useState<PluginRecord | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/instance/settings/heartbeats" },
{ label: "Plugins" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const { data: plugins, isLoading, error } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
});
const bundledQuery = useQuery({
queryKey: queryKeys.plugins.examples,
queryFn: () => pluginsApi.listBundled(),
});
const invalidatePluginQueries = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all });
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.examples });
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.uiContributions });
};
const installMutation = useMutation({
mutationFn: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
pluginsApi.install(params),
onSuccess: () => {
invalidatePluginQueries();
setInstallDialogOpen(false);
setInstallPackage("");
pushToast({ title: "Plugin installed successfully", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to install plugin", body: err.message, tone: "error" });
},
});
const uninstallMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.uninstall(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin uninstalled successfully", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to uninstall plugin", body: err.message, tone: "error" });
},
});
const enableMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.enable(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin enabled", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to enable plugin", body: err.message, tone: "error" });
},
});
const disableMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.disable(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin disabled", tone: "info" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to disable plugin", body: err.message, tone: "error" });
},
});
const installedPlugins = plugins ?? [];
const bundledPlugins = bundledQuery.data ?? [];
const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin]));
const bundledByPackageName = new Map(bundledPlugins.map((plugin) => [plugin.packageName, plugin]));
const errorSummaryByPluginId = useMemo(
() =>
new Map(
installedPlugins.map((plugin) => [plugin.id, getPluginErrorSummary(plugin)])
),
[installedPlugins]
);
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading plugins...</div>;
if (error) return <div className="p-4 text-sm text-destructive">Failed to load plugins.</div>;
return (
<div className="space-y-6 max-w-5xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Puzzle className="h-6 w-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">Plugin Manager</h1>
</div>
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-2">
<Plus className="h-4 w-4" />
Install Plugin
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Install Plugin</DialogTitle>
<DialogDescription>
Enter the npm package name of the plugin you wish to install.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="packageName">npm Package Name</Label>
<Input
id="packageName"
placeholder="@paperclipai/plugin-example"
value={installPackage}
onChange={(e) => setInstallPackage(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInstallDialogOpen(false)}>Cancel</Button>
<Button
onClick={() => installMutation.mutate({ packageName: installPackage })}
disabled={!installPackage || installMutation.isPending}
>
{installMutation.isPending ? "Installing..." : "Install"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">Plugins are alpha.</p>
<p className="text-muted-foreground">
The plugin runtime and API surface are still changing. Expect breaking changes while this feature settles.
</p>
</div>
</div>
</div>
<section className="space-y-3">
<div className="flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Available Plugins</h2>
<Badge variant="outline">Bundled</Badge>
</div>
{bundledQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Loading bundled plugins...</div>
) : bundledQuery.error ? (
<div className="text-sm text-destructive">Failed to load bundled plugins.</div>
) : bundledPlugins.length === 0 ? (
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
No bundled plugins were found in this checkout.
</div>
) : (
<ul className="divide-y rounded-md border bg-card">
{bundledPlugins.map((bundledPlugin) => {
const installedPlugin = installedByPackageName.get(bundledPlugin.packageName);
const installPending =
installMutation.isPending &&
installMutation.variables?.isLocalPath &&
installMutation.variables.packageName === bundledPlugin.localPath;
return (
<li key={bundledPlugin.packageName}>
<div className="flex items-center gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{bundledPlugin.displayName}</span>
<Badge variant="outline">
{bundledPlugin.tag === "first-party" ? "First-party" : "Example"}
</Badge>
{isExperimentalPluginIdentity({
packageName: bundledPlugin.packageName,
packagePath: bundledPlugin.localPath,
bundledExperimental: bundledPlugin.experimental,
}) && <ExperimentalBadge />}
{installedPlugin ? (
<Badge
variant={installedPlugin.status === "ready" ? "default" : "secondary"}
className={installedPlugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""}
>
{installedPlugin.status}
</Badge>
) : (
<Badge variant="secondary">Not installed</Badge>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">{bundledPlugin.description}</p>
<p className="mt-1 text-xs text-muted-foreground">{bundledPlugin.packageName}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{installedPlugin ? (
<>
{installedPlugin.status !== "ready" && (
<Button
variant="outline"
size="sm"
disabled={enableMutation.isPending}
onClick={() => enableMutation.mutate(installedPlugin.id)}
>
Enable
</Button>
)}
<Button variant="outline" size="sm" asChild>
<Link to={`/instance/settings/plugins/${installedPlugin.id}`}>
{installedPlugin.status === "ready" ? "Open Settings" : "Review"}
</Link>
</Button>
</>
) : (
<Button
size="sm"
disabled={installPending || installMutation.isPending}
onClick={() =>
installMutation.mutate({
packageName: bundledPlugin.localPath,
isLocalPath: true,
})
}
>
{installPending ? "Installing..." : "Install"}
</Button>
)}
</div>
</div>
</li>
);
})}
</ul>
)}
</section>
<section className="space-y-3">
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Installed Plugins</h2>
</div>
{!installedPlugins.length ? (
<Card className="bg-muted/30">
<CardContent className="flex flex-col items-center justify-center py-10">
<Puzzle className="h-10 w-10 text-muted-foreground mb-4" />
<p className="text-sm font-medium">No plugins installed</p>
<p className="text-xs text-muted-foreground mt-1">
Install a plugin to extend functionality.
</p>
</CardContent>
</Card>
) : (
<ul className="divide-y rounded-md border bg-card">
{installedPlugins.map((plugin) => (
<li key={plugin.id}>
<div className="flex items-start gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/instance/settings/plugins/${plugin.id}`}
className="font-medium hover:underline truncate block"
title={plugin.manifestJson.displayName ?? plugin.packageName}
>
{plugin.manifestJson.displayName ?? plugin.packageName}
</Link>
{bundledByPackageName.has(plugin.packageName) && (
<Badge variant="outline">
{bundledByPackageName.get(plugin.packageName)?.tag === "first-party"
? "First-party"
: "Example"}
</Badge>
)}
{isExperimentalPluginIdentity({
packageName: plugin.packageName,
packagePath: plugin.packagePath,
manifestJson: plugin.manifestJson,
bundledExperimental: bundledByPackageName.get(plugin.packageName)?.experimental,
}) && <ExperimentalBadge />}
</div>
<div>
<p className="text-xs text-muted-foreground mt-0.5 truncate" title={plugin.packageName}>
{plugin.packageName} · v{plugin.manifestJson.version ?? plugin.version}
</p>
</div>
<p className="text-sm text-muted-foreground truncate mt-0.5" title={plugin.manifestJson.description}>
{plugin.manifestJson.description || "No description provided."}
</p>
{plugin.status === "error" && (
<div className="mt-3 rounded-md border border-red-500/25 bg-red-500/[0.06] px-3 py-2">
<div className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-red-700 dark:text-red-300">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>Plugin error</span>
</div>
<p
className="mt-1 text-sm text-red-700/90 dark:text-red-200/90 break-words"
title={plugin.lastError ?? undefined}
>
{errorSummaryByPluginId.get(plugin.id)}
</p>
</div>
<Button
variant="outline"
size="sm"
className="border-red-500/30 bg-background/60 text-red-700 hover:bg-red-500/10 hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
onClick={() => setErrorDetailsPlugin(plugin)}
>
View full error
</Button>
</div>
</div>
)}
</div>
<div className="flex shrink-0 self-center">
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
<Badge
variant={
plugin.status === "ready"
? "default"
: plugin.status === "error"
? "destructive"
: "secondary"
}
className={cn(
"shrink-0",
plugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""
)}
>
{plugin.status}
</Badge>
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8"
title={plugin.status === "ready" ? "Disable" : "Enable"}
onClick={() => {
if (plugin.status === "ready") {
disableMutation.mutate(plugin.id);
} else {
enableMutation.mutate(plugin.id);
}
}}
disabled={enableMutation.isPending || disableMutation.isPending}
>
<Power className={cn("h-4 w-4", plugin.status === "ready" ? "text-green-600" : "")} />
</Button>
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8 text-destructive hover:text-destructive"
title="Uninstall"
onClick={() => {
setUninstallPluginId(plugin.id);
setUninstallPluginName(plugin.manifestJson.displayName ?? plugin.packageName);
}}
disabled={uninstallMutation.isPending}
>
<Trash className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" className="mt-2 h-8" asChild>
<Link to={`/instance/settings/plugins/${plugin.id}`}>
<Settings className="h-4 w-4" />
Configure
</Link>
</Button>
</div>
</div>
</div>
</li>
))}
</ul>
)}
</section>
<Dialog
open={uninstallPluginId !== null}
onOpenChange={(open) => { if (!open) setUninstallPluginId(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Uninstall Plugin</DialogTitle>
<DialogDescription>
Are you sure you want to uninstall <strong>{uninstallPluginName}</strong>? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setUninstallPluginId(null)}>Cancel</Button>
<Button
variant="destructive"
disabled={uninstallMutation.isPending}
onClick={() => {
if (uninstallPluginId) {
uninstallMutation.mutate(uninstallPluginId, {
onSettled: () => setUninstallPluginId(null),
});
}
}}
>
{uninstallMutation.isPending ? "Uninstalling..." : "Uninstall"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={errorDetailsPlugin !== null}
onOpenChange={(open) => { if (!open) setErrorDetailsPlugin(null); }}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Error Details</DialogTitle>
<DialogDescription>
{errorDetailsPlugin?.manifestJson.displayName ?? errorDetailsPlugin?.packageName ?? "Plugin"} hit an error state.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-red-500/25 bg-red-500/[0.06] px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-700 dark:text-red-300" />
<div className="space-y-1 text-sm">
<p className="font-medium text-red-700 dark:text-red-300">
What errored
</p>
<p className="text-red-700/90 dark:text-red-200/90 break-words">
{errorDetailsPlugin ? getPluginErrorSummary(errorDetailsPlugin) : "No error summary available."}
</p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Full error output</p>
<pre className="max-h-[50vh] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-5 whitespace-pre-wrap break-words">
{errorDetailsPlugin?.lastError ?? "No stored error message."}
</pre>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setErrorDetailsPlugin(null)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}