/** * @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 { companies, type Db } from "@paperclipai/db"; import { adapterSettingsService } from "../services/adapter-settings.js"; import { logActivity, publishActivity, type ActivityPublication } from "../services/activity-log.js"; import { listServerAdapters, findServerAdapter, findActiveServerAdapter, registerServerAdapter, resolveExternalAdapterRegistration, unregisterServerAdapter, isOverridePaused, setOverridePaused, waitForExternalAdapters, } from "../adapters/registry.js"; import { listAdapterPlugins, addAdapterPlugin, removeAdapterPlugin, getAdapterPluginByType, getAdapterPluginsDir, } from "../services/adapter-plugin-store.js"; import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js"; import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js"; import type { AdapterLoginPanelMode, AdapterLoginTimeoutPolicy, } from "@paperclipai/adapter-utils"; import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; import { logger } from "../middleware/logger.js"; import { badRequest, forbidden } from "../errors.js"; import { assertExternalAdapterCodeAllowed } from "../services/adapter-code-policy.js"; import { getHiddenSettings } from "../services/settings-visibility.js"; import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js"; import { agentRunnerAvailability, type RunnerTarget } from "../services/agent-runner-selection.js"; import type { AgentRunnerAvailability } from "@paperclipai/shared"; import { resolveAntigravityCandidateAdmission } from "../services/native-runtime/acpx-qualification.js"; const execFileAsync = promisify(execFile); let warnedMalformedAntigravityAdmission = false; /** * Floor: when the hosting operator hides the Adapters settings surface * (`instance.adapters` in PAPERCLIP_HIDDEN_SETTINGS), adapter management * writes are rejected alongside it. Reads stay open — adapter metadata is * consumed by agent-creation UIs outside the hidden page. */ function assertAdapterManagementVisible() { if (getHiddenSettings().has("instance.adapters")) { throw forbidden("Adapter management is managed by the hosting operator on this instance", { code: "settings_operator_managed", }); } } // --------------------------------------------------------------------------- // 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; } /** * The safe scalar login fields the adapter listing projects to the client. It * carries only the panel mode and the timeout policy. It carries no function * member and no secret. The user interface reads it to pick the login flow and * the login panel. Every login runs on a real pseudo-terminal, so the projection * carries no transport field. */ interface AdapterLoginProjection { panelMode: AdapterLoginPanelMode; timeoutPolicy: AdapterLoginTimeoutPolicy; } interface AdapterCapabilities { supportsInstructionsBundle: boolean; supportsSkills: boolean; supportsLocalAgentJwt: boolean; requiresMaterializedRuntimeSkills: boolean; supportsAcp: boolean; /** * The projected login capability. It is present only when the adapter * declares an interactive login capability. It is absent otherwise. */ login?: AdapterLoginProjection; } interface AdapterInfo extends AgentRunnerAvailability { candidateHarnesses?: Array<{ agent: "antigravity"; model: string }>; type: string; label: string; source: "builtin" | "external"; modelsCount: number; loaded: boolean; disabled: boolean; capabilities: AdapterCapabilities; acp?: ServerAdapterModule["acp"]; /** 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; } } /** * Build the client capability view for one adapter. The login projection carries * only the safe scalar fields; it drops the function members and the completion * claim, so the response holds no secret and no code. */ export function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities { const login = adapter.loginCapability; return { supportsInstructionsBundle: adapter.supportsInstructionsBundle ?? false, supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills), supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false, requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false, supportsAcp: Boolean(adapter.acp), ...(login ? { login: { panelMode: login.panelMode, timeoutPolicy: login.timeoutPolicy, }, } : {}), }; } function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set, target?: RunnerTarget): 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), ...agentRunnerAvailability(adapter.type, target, [...disabledSet]), ...(adapter.acp ? { acp: adapter.acp } : {}), 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 { // 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(options: { db?: Db; getNativeRunnerEnabled?: () => Promise; getOpenAiDotEnabled?: () => Promise; resolveRunnerTarget?: (companyId: string, environmentId?: string) => Promise; } = {}) { const router = Router(); const settings = adapterSettingsService(options.db); /** * 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 companyId = _req.query.companyId; const environmentId = _req.query.environmentId; if ((companyId !== undefined && (typeof companyId !== "string" || !companyId.trim())) || (environmentId !== undefined && (typeof environmentId !== "string" || !environmentId.trim()))) { throw badRequest("companyId and environmentId must be nonempty strings."); } if (environmentId !== undefined && companyId === undefined) throw badRequest("companyId is required when selecting an environment."); let target: RunnerTarget | undefined; if (typeof companyId === "string") { assertCompanyAccess(_req, companyId); if (!options.resolveRunnerTarget) throw badRequest("Scoped adapter discovery is unavailable."); target = await options.resolveRunnerTarget(companyId, typeof environmentId === "string" ? environmentId : undefined); } await waitForExternalAdapters(); const registeredAdapters = listServerAdapters(); const externalRecords = new Map( listAdapterPlugins().map((r) => [r.type, r]), ); const disabledSet = new Set(await settings.getDisabledTypes()); // One shared implementation, with independent provider rollouts. Explicit // adapter-admin disabling still applies to both choices. let candidate: ReturnType; if (typeof companyId === "string" && target?.driver === "local") { // An expired optional preview must not hide the ordinary adapter catalog. // Execution and model discovery still perform strict admission checks. try { candidate = resolveAntigravityCandidateAdmission({ companyId }); } catch (error) { candidate = undefined; // Expiry is ordinary catalog state. Invalid trusted startup JSON needs // one operator diagnostic, without logging its payload or error. if (!warnedMalformedAntigravityAdmission && error instanceof Error && error.message === "Invalid Antigravity candidate admission") { warnedMalformedAntigravityAdmission = true; logger.warn({ code: "ANTIGRAVITY_CANDIDATE_ADMISSION_INVALID" }, "Invalid Google Antigravity preview authorization"); } } } const result: AdapterInfo[] = registeredAdapters.map((adapter) => ({ ...buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet, target), ...(candidate && adapter.type === "paperclip_runner" && !disabledSet.has(adapter.type) && !externalRecords.has(adapter.type) ? { candidateHarnesses: [{ agent: "antigravity" as const, model: candidate.model }] } : {}), })).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); assertExternalAdapterCodeAllowed(); assertAdapterManagementVisible(); 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); // External adapters may intentionally override built-in adapter types. // registerServerAdapter preserves the built-in as a fallback so pausing or // removing the override restores the original implementation. // Check if already registered (indicates a reinstall/update). // For built-in types the registry always returns the built-in, so we // additionally require an existing external plugin record to // distinguish a true reinstall from a first-time override. const existing = findServerAdapter(adapterModule.type); const isReinstall = existing !== null && !!getAdapterPluginByType(adapterModule.type); 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}` }); } } }); router.get("/adapters/:type", async (req, res) => { assertBoardOrgAccess(req); const adapterType = req.params.type; const adapter = findServerAdapter(adapterType); if (!adapter) { res.status(404).json({ error: `Adapter "${adapterType}" is not registered.` }); return; } const externalRecord = getAdapterPluginByType(adapterType); const disabledSet = new Set(await settings.getDisabledTypes()); res.json(buildAdapterInfo(adapter, externalRecord, disabledSet)); }); /** * 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); assertAdapterManagementVisible(); 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 publications: ActivityPublication[] = []; const changed = await settings.setDisabled(adapterType, disabled, async (tx, current) => { const actor = getActorInfo(req); const companyIds = await tx.select({ id: companies.id }).from(companies); for (const company of companyIds) { await logActivity(tx as Db, { companyId: company.id, ...actor, action: "instance.adapter.updated", entityType: "instance_settings", entityId: "adapter-settings:v1", details: { adapterType, disabled, revision: current.revision }, }, publications); } }); for (const publication of publications) { try { publishActivity(publication); } catch (error) { logger.error({ err: error, companyId: publication.companyId }, "failed to publish adapter settings activity"); } } 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); assertExternalAdapterCodeAllowed(); assertAdapterManagementVisible(); 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); assertExternalAdapterCodeAllowed(); assertAdapterManagementVisible(); const adapterType = req.params.type; if (!adapterType) { res.status(400).json({ error: "Adapter type is required." }); return; } // Prevent removal of built-in adapters, unless this built-in type is // currently backed by an external adapter plugin override. if (BUILTIN_ADAPTER_TYPES.has(adapterType) && !getAdapterPluginByType(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); assertExternalAdapterCodeAllowed(); assertAdapterManagementVisible(); 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); assertExternalAdapterCodeAllowed(); assertAdapterManagementVisible(); 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(); 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; }