[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>
This commit is contained in:
Dotta 2026-05-26 08:32:45 -05:00 committed by GitHub
parent 9aea3e3d35
commit f0ddd24d61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 609 additions and 93 deletions

View file

@ -250,6 +250,7 @@ export function executionWorkspaceRoutes(db: Db) {
repoUrl: existing.repoUrl,
baseRef: existing.baseRef,
branchName: existing.branchName,
metadata: existing.metadata as Record<string, unknown> | null,
config: {
...existing.config,
provisionCommand:

View file

@ -18,7 +18,7 @@
* @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification
*/
import { existsSync } from "node:fs";
import { access, readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
@ -114,13 +114,14 @@ interface PluginInstallRequest {
isLocalPath?: boolean;
}
interface AvailablePluginExample {
interface AvailableBundledPlugin {
packageName: string;
pluginKey: string;
displayName: string;
description: string;
localPath: string;
tag: "example" | "first-party";
experimental: boolean;
}
/** Response body for GET /api/plugins/:pluginId/health */
@ -150,58 +151,166 @@ const PLUGIN_SCOPED_API_RESPONSE_HEADER_ALLOWLIST = new Set([
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "../../..");
const EXPERIMENTAL_BUNDLED_PLUGIN_PACKAGE_NAMES = new Set([
"@paperclipai/plugin-llm-wiki",
"@paperclipai/plugin-modal",
"@paperclipai/plugin-workspace-diff",
]);
let bundledPluginsCache: Promise<AvailableBundledPlugin[]> | null = null;
const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [
{
packageName: "@paperclipai/plugin-workspace-diff",
pluginKey: "paperclip.workspace-diff",
displayName: "Workspace Changes",
description: "First-party workspace Changes tab backed by plugin-local Git diff computation.",
localPath: "packages/plugins/plugin-workspace-diff",
tag: "first-party",
},
{
packageName: "@paperclipai/plugin-hello-world-example",
pluginKey: "paperclip.hello-world-example",
displayName: "Hello World Widget (Example)",
description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.",
localPath: "packages/plugins/examples/plugin-hello-world-example",
tag: "example",
},
{
packageName: "@paperclipai/plugin-file-browser-example",
pluginKey: "paperclip-file-browser-example",
displayName: "File Browser (Example)",
description: "Example plugin that adds a Files link in project navigation plus a project detail file browser.",
localPath: "packages/plugins/examples/plugin-file-browser-example",
tag: "example",
},
{
packageName: "@paperclipai/plugin-kitchen-sink-example",
pluginKey: "paperclip-kitchen-sink-example",
displayName: "Kitchen Sink (Example)",
description: "Reference plugin that demonstrates the current Paperclip plugin API surface, bridge flows, UI extension surfaces, jobs, webhooks, tools, streams, and trusted local workspace/process demos.",
localPath: "packages/plugins/examples/plugin-kitchen-sink-example",
tag: "example",
},
{
packageName: "@paperclipai/plugin-orchestration-smoke-example",
pluginKey: "paperclipai.plugin-orchestration-smoke-example",
displayName: "Orchestration Smoke (Example)",
description: "Acceptance fixture for scoped plugin routes, restricted database namespaces, issue orchestration, documents, wakeups, summaries, and UI status surfaces.",
localPath: "packages/plugins/examples/plugin-orchestration-smoke-example",
tag: "example",
},
];
function titleCasePluginName(packageName: string): string {
const localName = packageName.split("/").pop() ?? packageName;
return localName
.replace(/^paperclip-plugin-/, "")
.replace(/^plugin-/, "")
.split("-")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function listBundledPluginExamples(): AvailablePluginExample[] {
return BUNDLED_PLUGIN_EXAMPLES.flatMap((plugin) => {
const absoluteLocalPath = path.resolve(REPO_ROOT, plugin.localPath);
if (!existsSync(absoluteLocalPath)) return [];
return [{ ...plugin, localPath: absoluteLocalPath }];
async function fileExists(filePath: string): Promise<boolean> {
return access(filePath).then(() => true, () => false);
}
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
try {
return JSON.parse(await readFile(filePath, "utf8")) as Record<string, unknown>;
} catch {
return null;
}
}
async function findPackageJsonFiles(root: string, maxDepth = 4): Promise<string[]> {
if (!(await fileExists(root))) return [];
const packageJsonFiles: string[] = [];
const walk = async (dir: string, depth: number): Promise<void> => {
if (depth > maxDepth) return;
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.name === "node_modules" || entry.name === "dist") continue;
const entryPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name === "package.json") {
packageJsonFiles.push(entryPath);
} else if (entry.isDirectory()) {
await walk(entryPath, depth + 1);
}
}
};
await walk(root, 0);
return packageJsonFiles;
}
function manifestSourcePath(packageRoot: string, pkgJson: Record<string, unknown>): string | null {
const paperclipPlugin = pkgJson.paperclipPlugin;
if (
!paperclipPlugin
|| typeof paperclipPlugin !== "object"
|| Array.isArray(paperclipPlugin)
) {
return null;
}
const manifestPath = (paperclipPlugin as Record<string, unknown>).manifest;
if (typeof manifestPath !== "string") return null;
const sourcePath = manifestPath
.replace(/^\.\/dist\//, "./src/")
.replace(/\.js$/, ".ts");
return path.resolve(packageRoot, sourcePath);
}
function firstStringLiteral(source: string, key: string): string | null {
const match = source.match(
new RegExp(`${key}:\\s*(?:"([^"]*)"|'([^']*)'|\`([^\`]*)\`)`, "s"),
);
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
}
async function bundledPluginMetadata(
packageRoot: string,
pkgJson: Record<string, unknown>,
): Promise<{ pluginKey?: string; displayName?: string; description?: string }> {
const sourcePath = manifestSourcePath(packageRoot, pkgJson);
if (!sourcePath || !(await fileExists(sourcePath))) return {};
try {
const source = await readFile(sourcePath, "utf8");
const pluginId = source
.match(/(?:export\s+)?const\s+PLUGIN_ID\s*=\s*(?:"([^"]*)"|'([^']*)'|`([^`]*)`)/)
?.slice(1)
.find(Boolean)
?? firstStringLiteral(source, "id")
?? null;
return {
pluginKey: pluginId ?? undefined,
displayName: firstStringLiteral(source, "displayName") ?? undefined,
description: firstStringLiteral(source, "description") ?? undefined,
};
} catch {
return {};
}
}
function isExperimentalBundledPlugin(packageRoot: string, packageName: string): boolean {
return (
EXPERIMENTAL_BUNDLED_PLUGIN_PACKAGE_NAMES.has(packageName)
|| packageRoot.includes(`${path.sep}sandbox-providers${path.sep}`)
|| packageName.includes("sandbox")
);
}
async function discoverBundledPlugins(): Promise<AvailableBundledPlugin[]> {
const pluginRoot = path.resolve(REPO_ROOT, "packages/plugins");
const bundledPlugins: AvailableBundledPlugin[] = [];
for (const packageJsonPath of await findPackageJsonFiles(pluginRoot)) {
const packageRoot = path.dirname(packageJsonPath);
const pkgJson = await readJsonFile(packageJsonPath);
const paperclipPlugin = pkgJson?.paperclipPlugin;
if (
!pkgJson
|| !paperclipPlugin
|| typeof paperclipPlugin !== "object"
|| Array.isArray(paperclipPlugin)
) {
continue;
}
const packageName = pkgJson.name;
if (typeof packageName !== "string" || packageName.length === 0) continue;
const metadata = await bundledPluginMetadata(packageRoot, pkgJson);
const tag = packageRoot.includes(`${path.sep}examples${path.sep}`) ? "example" : "first-party";
bundledPlugins.push({
packageName,
pluginKey: metadata.pluginKey ?? packageName,
displayName: metadata.displayName ?? titleCasePluginName(packageName),
description: metadata.description
?? `Bundled Paperclip plugin from ${path.relative(REPO_ROOT, packageRoot)}.`,
localPath: packageRoot,
tag,
experimental: isExperimentalBundledPlugin(packageRoot, packageName),
});
}
return bundledPlugins.sort((left, right) => {
if (left.tag !== right.tag) return left.tag === "first-party" ? -1 : 1;
return left.displayName.localeCompare(right.displayName);
});
}
async function listBundledPlugins(): Promise<AvailableBundledPlugin[]> {
bundledPluginsCache ??= discoverBundledPlugins().catch((error: unknown) => {
bundledPluginsCache = null;
throw error;
});
return bundledPluginsCache;
}
/**
* Resolve a plugin by either database ID or plugin key.
*
@ -677,12 +786,12 @@ export function pluginRoutes(
/**
* GET /api/plugins/examples
*
* Return first-party example plugins bundled in this repo, if present.
* Return plugin packages bundled in this repo, if present.
* These can be installed through the normal local-path install flow.
*/
router.get("/plugins/examples", async (req, res) => {
assertBoardOrgAccess(req);
res.json(listBundledPluginExamples());
res.json(await listBundledPlugins());
});
// IMPORTANT: Static routes must come before parameterized routes