paperclip/server/src/routes/adapters.ts
Dotta a3de1d764d
Add cheap model profiles for local adapters (#4881)
## Thinking Path

> - Paperclip is a control plane for autonomous AI companies, where
adapters are the boundary between the board, agents, and execution
runtimes.
> - Local adapters currently expose a primary runtime configuration, but
operators often need a cheaper model lane for routine or low-risk work.
> - That cheap lane has to stay adapter-owned: runtime profile settings
should not mutate the primary adapter config or bypass existing
auth/secret mediation.
> - Issue creation also needs an ergonomic way to request primary,
cheap, or custom model behavior for a selected assignee.
> - This pull request adds a first-class `cheap` model profile contract
across adapter capabilities, heartbeat config resolution, agent
configuration, and issue creation.
> - The benefit is cheaper task execution can be configured and
requested explicitly while preserving adapter boundaries, secret
handling, and audit visibility.

## What Changed

- Added adapter model-profile capability metadata and a `cheap` profile
contract for supported local adapters.
- Applied `runtimeConfig.modelProfiles.cheap.adapterConfig` during
heartbeat config resolution, including requested/applied/fallback run
metadata.
- Added agent configuration UI for cheap model profile settings without
writing those settings into primary `adapterConfig`.
- Added New Issue assignee model lane controls for Primary / Cheap /
Custom and request payload handling.
- Added run ledger profile badges and Storybook stories for the new
cheap-lane UI states.
- Added tests for validators, heartbeat model profile application,
permission/secret mediation, UI payload helpers, and run ledger
rendering.
- Added committed UI verification screenshots under
`docs/pr-screenshots/pap-2837/`.
- Addressed Greptile review feedback around cheap-profile defaults,
shared profile types, and fallback test data.

## Verification

Local:

- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
server/src/__tests__/adapter-registry.test.ts
server/src/__tests__/agent-permissions-routes.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/lib/agent-config-patch.test.ts
ui/src/lib/issue-assignee-overrides.test.ts
ui/src/lib/new-agent-runtime-config.test.ts` — passed, 8 files / 103
tests.
- `pnpm exec vitest run ui/src/lib/new-agent-runtime-config.test.ts
ui/src/components/IssueRunLedger.test.tsx` — passed after
Greptile/rebase follow-up, 2 files / 17 tests.
- `pnpm --filter @paperclipai/ui typecheck` — passed after
Greptile/rebase follow-up.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `pnpm test:run` — did not complete successfully in this local
worktree: it stopped in pre-existing `@paperclipai/adapter-utils`
sandbox/SSH fixture suites outside this PR diff. Failures were 5s local
timeouts plus `git init -b` unsupported by this machine's Git 2.21.0.
The branch-specific targeted suites above passed.
- Branch was fetched/rebased onto `public-gh/master`; `git rev-list
--left-right --count public-gh/master...HEAD` reports `0 9`.

Remote PR checks on latest head
`e30bf399146451c86cee98ed528d51d33fa5af5a`:

- `policy` — passed.
- `verify` — passed.
- `e2e` — passed.
- `Greptile Review` — passed, confidence score 5/5; Greptile review
threads resolved.
- `security/snyk (cryppadotta)` — passed.

Screenshots:

- [New issue cheap lane
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-cheap-desktop.png)
- [New issue custom lane
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-custom-desktop.png)
- [New issue unsupported adapter
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-unsupported-desktop.png)
- [Run ledger model profile badges
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/runledger-profile-badges-desktop.png)
- Mobile variants are also in `docs/pr-screenshots/pap-2837/`.

## Risks

- Medium: heartbeat config mediation now merges runtime model profiles
into adapter configs, so adapter secret normalization and host-command
restrictions must keep covering nested config paths.
- Medium: the UI adds another issue creation choice; unsupported
adapters must keep hiding the cheap lane and preserve primary behavior.
- Low migration risk: no database migration is included.

> 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 Codex coding agent using GPT-5-class reasoning with repo tool use
and command execution. Exact served model/context window was not exposed
by the runtime.

## 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
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 15:32:04 -05:00

678 lines
24 KiB
TypeScript

/**
* @fileoverview Adapter management REST API routes
*
* This module provides Express routes for managing external adapter plugins:
* - Listing all registered adapters (built-in + external)
* - Installing external adapters from npm packages or local paths
* - Unregistering external adapters
*
* Read-only routes require board org access. Mutating adapter management
* routes require instance-admin access because they can install, reload, or
* toggle server-side adapter code for the whole Paperclip instance.
*
* @module server/routes/adapters
*/
import { execFile } from "node:child_process";
import fs from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { Router } from "express";
import {
listServerAdapters,
findServerAdapter,
findActiveServerAdapter,
listEnabledServerAdapters,
registerServerAdapter,
resolveExternalAdapterRegistration,
unregisterServerAdapter,
isOverridePaused,
setOverridePaused,
} from "../adapters/registry.js";
import {
listAdapterPlugins,
addAdapterPlugin,
removeAdapterPlugin,
getAdapterPluginByType,
getAdapterPluginsDir,
getDisabledAdapterTypes,
setAdapterDisabled,
} from "../services/adapter-plugin-store.js";
import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js";
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
import { logger } from "../middleware/logger.js";
import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js";
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Request / Response types
// ---------------------------------------------------------------------------
interface AdapterInstallRequest {
/** npm package name (e.g., "droid-paperclip-adapter") or local path */
packageName: string;
/** True if packageName is a local filesystem path */
isLocalPath?: boolean;
/** Target version for npm packages (optional, defaults to latest) */
version?: string;
}
interface AdapterCapabilities {
supportsInstructionsBundle: boolean;
supportsSkills: boolean;
supportsLocalAgentJwt: boolean;
requiresMaterializedRuntimeSkills: boolean;
supportsModelProfiles: boolean;
}
interface AdapterInfo {
type: string;
label: string;
source: "builtin" | "external";
modelsCount: number;
loaded: boolean;
disabled: boolean;
capabilities: AdapterCapabilities;
/** True when an external plugin has replaced a built-in adapter of the same type. */
overriddenBuiltin?: boolean;
/** True when the external override for a builtin type is currently paused. */
overridePaused?: boolean;
version?: string;
packageName?: string;
isLocalPath?: boolean;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Resolve the adapter package directory (same rules as plugin-loader).
*/
function resolveAdapterPackageDir(record: AdapterPluginRecord): string {
return record.localPath
? path.resolve(record.localPath)
: path.resolve(getAdapterPluginsDir(), "node_modules", record.packageName);
}
/**
* Read `version` from the adapter's package.json on disk.
* This is the source of truth for what is actually installed (npm or local path).
*/
function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string | undefined {
try {
const pkgDir = resolveAdapterPackageDir(record);
const raw = fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8");
const v = JSON.parse(raw).version;
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
} catch {
return undefined;
}
}
function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities {
return {
supportsInstructionsBundle: adapter.supportsInstructionsBundle ?? false,
supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills),
supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false,
requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false,
supportsModelProfiles: Boolean(adapter.modelProfiles?.length || adapter.listModelProfiles),
};
}
function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set<string>): AdapterInfo {
const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined;
return {
type: adapter.type,
label: adapter.type, // ServerAdapterModule doesn't have a separate "label" field; type serves as label
source: externalRecord ? "external" : "builtin",
modelsCount: (adapter.models ?? []).length,
loaded: true, // If it's in the registry, it's loaded
disabled: disabledSet.has(adapter.type),
capabilities: buildAdapterCapabilities(adapter),
overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined,
overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined,
// Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields.
version: fromDisk ?? externalRecord?.version,
packageName: externalRecord?.packageName,
isLocalPath: externalRecord?.localPath ? true : undefined,
};
}
/**
* Normalize a local path that may be a Windows path into a WSL-compatible path.
*
* - Windows paths (e.g., "C:\\Users\\...") are converted via `wslpath -u`.
* - Paths already starting with `/mnt/` or `/` are returned as-is.
*/
async function normalizeLocalPath(rawPath: string): Promise<string> {
// Already a POSIX path (WSL or native Linux)
if (rawPath.startsWith("/")) {
return rawPath;
}
// Windows path detection: C:\ or C:/ pattern
if (/^[A-Za-z]:[\\/]/.test(rawPath)) {
try {
const { stdout } = await execFileAsync("wslpath", ["-u", rawPath]);
return stdout.trim();
} catch (err) {
logger.warn({ err, rawPath }, "wslpath conversion failed; using path as-is");
return rawPath;
}
}
return rawPath;
}
/**
* Register an external adapter module into the server registry via the
* hot-install path, resolving `sessionManagement` identically to how the
* init-time IIFE does. Module-provided `sessionManagement` is honored first,
* with fallback to the host registry by type for builtin-type overrides.
*
* Keeps the hot-install and init-time paths at parity so an adapter installed
* via `POST /api/adapters/install` has the same shape in the registry as the
* same adapter loaded on the next server restart.
*/
function registerWithSessionManagement(adapter: ServerAdapterModule): void {
registerServerAdapter(resolveExternalAdapterRegistration(adapter));
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
export function adapterRoutes() {
const router = Router();
/**
* GET /api/adapters
*
* List all registered adapters (built-in + external).
* Each entry includes whether the adapter is built-in or external,
* its model count, and load status.
*/
router.get("/adapters", async (_req, res) => {
// Adapter inventory is needed by ordinary board members when creating or
// editing company agents. Mutating adapter management routes below remain
// instance-admin only because they affect the whole server runtime.
assertBoardOrgAccess(_req);
const registeredAdapters = listServerAdapters();
const externalRecords = new Map(
listAdapterPlugins().map((r) => [r.type, r]),
);
const disabledSet = new Set(getDisabledAdapterTypes());
const result: AdapterInfo[] = registeredAdapters.map((adapter) =>
buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet),
).sort((a, b) => a.type.localeCompare(b.type));
res.json(result);
});
/**
* POST /api/adapters/install
*
* Install an external adapter from an npm package or local path.
*
* Request body:
* - packageName: string (required) — npm package name or local path
* - isLocalPath?: boolean (default false)
* - version?: string — target version for npm packages
*/
router.post("/adapters/install", async (req, res) => {
assertInstanceAdmin(req);
const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest;
if (!packageName || typeof packageName !== "string") {
res.status(400).json({ error: "packageName is required and must be a string." });
return;
}
// Strip version suffix if the UI sends "pkg@1.2.3" instead of separating it
// e.g. "@henkey/hermes-paperclip-adapter@0.3.0" → packageName + version
let canonicalName = packageName;
let explicitVersion = version;
const versionSuffix = packageName.match(/@(\d+\.\d+\.\d+.*)$/);
if (versionSuffix) {
// For scoped packages: "@scope/name@1.2.3" → "@scope/name" + "1.2.3"
// For unscoped: "name@1.2.3" → "name" + "1.2.3"
const lastAtIndex = packageName.lastIndexOf("@");
if (lastAtIndex > 0 && !explicitVersion) {
canonicalName = packageName.slice(0, lastAtIndex);
explicitVersion = versionSuffix[1];
}
}
try {
let installedVersion: string | undefined;
let moduleLocalPath: string | undefined;
if (!isLocalPath) {
// npm install into the managed directory
const pluginsDir = getAdapterPluginsDir();
const spec = explicitVersion ? `${canonicalName}@${explicitVersion}` : canonicalName;
logger.info({ spec, pluginsDir }, "Installing adapter package via npm");
await execFileAsync("npm", ["install", "--no-save", spec], {
cwd: pluginsDir,
timeout: 120_000,
});
// Read installed version from package.json
try {
const pkgJsonPath = path.join(pluginsDir, "node_modules", canonicalName, "package.json");
const pkgContent = await import("node:fs/promises");
const pkgRaw = await pkgContent.readFile(pkgJsonPath, "utf-8");
const pkg = JSON.parse(pkgRaw);
const v = pkg.version;
installedVersion =
typeof v === "string" && v.trim().length > 0 ? v.trim() : explicitVersion;
} catch {
installedVersion = explicitVersion;
}
} else {
// Local path — normalize (e.g., Windows → WSL) and use the resolved path
moduleLocalPath = path.resolve(await normalizeLocalPath(packageName));
try {
const pkgRaw = await readFile(path.join(moduleLocalPath, "package.json"), "utf-8");
const v = JSON.parse(pkgRaw).version;
if (typeof v === "string" && v.trim().length > 0) {
installedVersion = v.trim();
}
} catch {
// leave installedVersion undefined if package.json is missing
}
}
// Load and register the adapter (use canonicalName for path resolution)
const adapterModule = await loadExternalAdapterPackage(canonicalName, moduleLocalPath);
// Check if this type conflicts with a built-in adapter
if (BUILTIN_ADAPTER_TYPES.has(adapterModule.type)) {
res.status(409).json({
error: `Adapter type "${adapterModule.type}" is a built-in adapter and cannot be overwritten.`,
});
return;
}
// Check if already registered (indicates a reinstall/update)
const existing = findServerAdapter(adapterModule.type);
const isReinstall = existing !== null;
if (existing) {
unregisterServerAdapter(adapterModule.type);
logger.info({ type: adapterModule.type }, "Unregistered existing adapter for replacement");
}
// Register the new adapter
registerWithSessionManagement(adapterModule);
// Persist the record (use canonicalName without version suffix)
const record: AdapterPluginRecord = {
packageName: canonicalName,
localPath: moduleLocalPath,
version: installedVersion ?? explicitVersion,
type: adapterModule.type,
installedAt: new Date().toISOString(),
};
addAdapterPlugin(record);
logger.info(
{ type: adapterModule.type, packageName: canonicalName },
"External adapter installed and registered",
);
res.status(201).json({
type: adapterModule.type,
packageName: canonicalName,
version: installedVersion ?? explicitVersion,
installedAt: record.installedAt,
requiresRestart: isReinstall,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, packageName }, "Failed to install external adapter");
// Distinguish npm errors from load errors
if (message.includes("npm") || message.includes("ERR!")) {
res.status(500).json({ error: `npm install failed: ${message}` });
} else {
res.status(500).json({ error: `Failed to install adapter: ${message}` });
}
}
});
/**
* PATCH /api/adapters/:type
*
* Enable or disable an adapter. Disabled adapters are hidden from agent
* creation menus but remain functional for existing agents.
*
* Request body: { "disabled": boolean }
*/
router.patch("/adapters/:type", async (req, res) => {
assertInstanceAdmin(req);
const adapterType = req.params.type;
const { disabled } = req.body as { disabled?: boolean };
if (typeof disabled !== "boolean") {
res.status(400).json({ error: "Request body must include { \"disabled\": true|false }." });
return;
}
// Check that the adapter exists in the registry
const existing = findServerAdapter(adapterType);
if (!existing) {
res.status(404).json({ error: `Adapter "${adapterType}" is not registered.` });
return;
}
const changed = setAdapterDisabled(adapterType, disabled);
if (changed) {
logger.info({ type: adapterType, disabled }, "Adapter enabled/disabled");
}
res.json({ type: adapterType, disabled, changed });
});
/**
* PATCH /api/adapters/:type/override
*
* Pause or resume an external adapter's override of a builtin type.
* When paused, the server returns the builtin adapter for all new requests
* (execute, listModels, config schema, etc.). Already-running sessions
* keep the adapter they started with.
*/
router.patch("/adapters/:type/override", async (req, res) => {
assertInstanceAdmin(req);
const adapterType = req.params.type;
const { paused } = req.body as { paused?: boolean };
if (typeof paused !== "boolean") {
res.status(400).json({ error: "\"paused\" (boolean) is required in request body." });
return;
}
if (!BUILTIN_ADAPTER_TYPES.has(adapterType)) {
res.status(400).json({ error: `Type "${adapterType}" is not a builtin adapter.` });
return;
}
const changed = setOverridePaused(adapterType, paused);
logger.info({ type: adapterType, paused, changed }, "Adapter override toggle");
res.json({ type: adapterType, paused, changed });
});
/**
* DELETE /api/adapters/:type
*
* Unregister an external adapter. Built-in adapters cannot be removed.
*/
router.delete("/adapters/:type", async (req, res) => {
assertInstanceAdmin(req);
const adapterType = req.params.type;
if (!adapterType) {
res.status(400).json({ error: "Adapter type is required." });
return;
}
// Prevent removal of built-in adapters
if (BUILTIN_ADAPTER_TYPES.has(adapterType)) {
res.status(403).json({
error: `Cannot remove built-in adapter "${adapterType}".`,
});
return;
}
// Check that the adapter exists in the registry
const existing = findServerAdapter(adapterType);
if (!existing) {
res.status(404).json({
error: `Adapter "${adapterType}" is not registered.`,
});
return;
}
// Check that it's an external adapter
const externalRecord = getAdapterPluginByType(adapterType);
if (!externalRecord) {
res.status(404).json({
error: `Adapter "${adapterType}" is not an externally installed adapter.`,
});
return;
}
// If installed via npm (has packageName but no localPath), run npm uninstall
if (externalRecord.packageName && !externalRecord.localPath) {
try {
const pluginsDir = getAdapterPluginsDir();
await execFileAsync("npm", ["uninstall", externalRecord.packageName], {
cwd: pluginsDir,
timeout: 60_000,
});
logger.info(
{ type: adapterType, packageName: externalRecord.packageName },
"npm uninstall completed for external adapter",
);
} catch (err) {
logger.warn(
{ err, type: adapterType, packageName: externalRecord.packageName },
"npm uninstall failed for external adapter; continuing with unregister",
);
}
}
// Unregister from the runtime registry
unregisterServerAdapter(adapterType);
// Remove from the persistent store
removeAdapterPlugin(adapterType);
logger.info({ type: adapterType }, "External adapter unregistered and removed");
res.json({ type: adapterType, removed: true });
});
/**
* POST /api/adapters/:type/reload
*
* Reload an external adapter at runtime (for dev iteration without server restart).
* Busts the ESM module cache, re-imports the adapter, and re-registers it.
*
* Cannot be used on built-in adapter types.
*/
router.post("/adapters/:type/reload", async (req, res) => {
assertInstanceAdmin(req);
const type = req.params.type;
// Built-in adapters cannot be reloaded unless overridden by an external one
if (BUILTIN_ADAPTER_TYPES.has(type) && !getAdapterPluginByType(type)) {
res.status(400).json({ error: "Cannot reload built-in adapter." });
return;
}
// Reload the adapter module (busts ESM cache, re-imports)
try {
const newModule = await reloadExternalAdapter(type);
// Not found in the external adapter store
if (!newModule) {
res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` });
return;
}
// Swap in the reloaded module
unregisterServerAdapter(type);
registerWithSessionManagement(newModule);
configSchemaCache.delete(type);
// Sync store.version from package.json (store may be missing version for local installs).
const record = getAdapterPluginByType(type);
let newVersion: string | undefined;
if (record) {
newVersion = readAdapterPackageVersionFromDisk(record);
if (newVersion) {
addAdapterPlugin({ ...record, version: newVersion });
}
}
logger.info({ type, version: newVersion }, "External adapter reloaded at runtime");
res.json({ type, version: newVersion, reloaded: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, type }, "Failed to reload external adapter");
res.status(500).json({ error: `Failed to reload adapter: ${message}` });
}
});
// ── POST /api/adapters/:type/reinstall ──────────────────────────────────
// Reinstall an npm-sourced external adapter (pulls latest from registry).
// Local-path adapters cannot be reinstalled — use Reload instead.
//
// This is a convenience shortcut for remove + install with the same
// package name, but without the risk of losing the store record.
router.post("/adapters/:type/reinstall", async (req, res) => {
assertInstanceAdmin(req);
const type = req.params.type;
if (BUILTIN_ADAPTER_TYPES.has(type) && !getAdapterPluginByType(type)) {
res.status(400).json({ error: "Cannot reinstall built-in adapter." });
return;
}
const record = getAdapterPluginByType(type);
if (!record) {
res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` });
return;
}
if (record.localPath) {
res.status(400).json({ error: "Local-path adapters cannot be reinstalled. Use Reload instead." });
return;
}
try {
const pluginsDir = getAdapterPluginsDir();
logger.info({ type, packageName: record.packageName }, "Reinstalling adapter package via npm");
await execFileAsync("npm", ["install", "--no-save", record.packageName], {
cwd: pluginsDir,
timeout: 120_000,
});
// Reload the freshly installed adapter
const newModule = await reloadExternalAdapter(type);
if (!newModule) {
res.status(500).json({ error: "npm install succeeded but adapter reload failed." });
return;
}
unregisterServerAdapter(type);
registerWithSessionManagement(newModule);
configSchemaCache.delete(type);
// Sync store version from disk
let newVersion: string | undefined;
const updatedRecord = getAdapterPluginByType(type);
if (updatedRecord) {
newVersion = readAdapterPackageVersionFromDisk(updatedRecord);
if (newVersion) {
addAdapterPlugin({ ...updatedRecord, version: newVersion });
}
}
logger.info({ type, version: newVersion }, "Adapter reinstalled from npm");
res.json({ type, version: newVersion, reinstalled: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, type }, "Failed to reinstall adapter");
res.status(500).json({ error: `Reinstall failed: ${message}` });
}
});
// ── GET /api/adapters/:type/config-schema ────────────────────────────────
// Serve a declarative config schema for an adapter's UI form fields.
// The adapter's getConfigSchema() resolves all options (static and dynamic)
// so the UI receives a fully hydrated schema in a single fetch.
const configSchemaCache = new Map<string, {
adapter: ServerAdapterModule;
schema: AdapterConfigSchema;
fetchedAt: number;
}>();
const CONFIG_SCHEMA_TTL_MS = 30_000;
router.get("/adapters/:type/config-schema", async (req, res) => {
// Config schemas are read-only form metadata used when org members create
// or edit agents; they do not install or execute new adapter code.
assertBoardOrgAccess(req);
const { type } = req.params;
const adapter = findActiveServerAdapter(type);
if (!adapter) {
res.status(404).json({ error: `Adapter "${type}" is not registered.` });
return;
}
if (!adapter.getConfigSchema) {
res.status(404).json({ error: `Adapter "${type}" does not provide a config schema.` });
return;
}
const cached = configSchemaCache.get(type);
if (cached && cached.adapter === adapter && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) {
res.json(cached.schema);
return;
}
try {
const schema = await adapter.getConfigSchema();
configSchemaCache.set(type, { adapter, schema, fetchedAt: Date.now() });
res.json(schema);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, type }, "Failed to resolve config schema");
res.status(500).json({ error: `Failed to resolve config schema: ${message}` });
}
});
// ── GET /api/adapters/:type/ui-parser.js ─────────────────────────────────
// Serve the self-contained UI parser JS for an adapter type.
// This allows external adapters to provide custom run-log parsing
// without modifying Paperclip's source code.
//
// The adapter package must export a "./ui-parser" entry in package.json
// pointing to a self-contained ESM module with zero runtime dependencies.
router.get("/adapters/:type/ui-parser.js", (req, res) => {
// UI parsers are read-only assets for displaying existing run output.
// Runtime-changing adapter management routes above require instance admin.
assertBoardOrgAccess(req);
const { type } = req.params;
const source = getOrExtractUiParserSource(type);
if (!source) {
res.status(404).json({ error: `No UI parser available for adapter "${type}".` });
return;
}
res.type("application/javascript").send(source);
});
return router;
}