mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The instance settings surface (Access, Plugins, Adapters, General,
Experimental) assumes the person at the keyboard operates the whole
instance
> - Operators who host Paperclip for others — a managed cloud or an
internal shared server — expose settings pages and toggles that do not
apply to their deployment, and the related mutation APIs stay open
> - A hosted tenant can open Plugins or Adapters, try an action, and hit
a confusing failure, because only a few hardcoded platform floors exist
> - This pull request adds a generic, operator-configured visibility
mechanism: one env var hides declared settings surfaces in the UI and
floors their mutation routes with a stable 403 code
> - The benefit is a clean hosted-tenant settings surface for any
operator, with zero behavior change for normal self-hosted instances
## Linked Issues or Issue Description
**Subsystem affected**
Instance settings (server routes and UI), the shared settings registry
in `packages/shared`, and the `/api/health` bootstrap payload.
**Problem or motivation**
An operator who hosts Paperclip for other people cannot hide settings
surfaces that the platform manages. Tenants see Access, Plugins, and
Adapters pages, backup retention, and host-level experimental toggles
that do nothing useful for them. The mutation APIs behind these surfaces
also stay open, so a tenant admin can attempt actions the platform must
control. ROADMAP.md names a cleaner shared deployment story as a goal
("Teams should be able to run the same product in hosted or semi-hosted
environments without changing the mental model").
**Proposed solution**
Add a declarative registry of hideable settings surfaces and one env
var, `PAPERCLIP_HIDDEN_SETTINGS`. The server parses the list at boot,
reports it on `/api/health`, and rejects value-changing writes to hidden
surfaces with a stable `settings_operator_managed` 403 code. The UI
reads the list from the health payload and removes the hidden pages,
sections, and toggles from navigation, routes, and page content. Unknown
keys warn and are ignored, so one list can roll across a fleet with
mixed app versions. With the variable unset, behavior is byte-identical
to today.
**Alternatives considered**
- Hardcode the hidden set for cloud instances in this repo: rejected,
because each hosting operator needs a different policy, and policy does
not belong in shared code.
- Deliver the hidden set through the managed-config document: rejected,
because that channel is cloud-specific and fail-closed on unknown
fields; a plain env var works for any operator, including self-hosted
shared servers.
- Lock the controls with a badge instead of hiding them: rejected for
these surfaces, because they are meaningless to tenants, not merely
platform-controlled; the existing managed-overlay lock stays the right
tool for controlled flags.
**Roadmap alignment**
Supports the "shared deployment story" item in ROADMAP.md: hosted and
semi-hosted deployments keep the same product with a settings surface
that matches what the tenant can actually do.
## What Changed
- New `packages/shared/src/settings-visibility.ts`: registry of hideable
surfaces (every instance settings page — profile, environments, access,
heartbeats, experimental, plugins, adapters; every Instance → General
section; every experimental flag as `instance.experimental.<key>`), the
`PAPERCLIP_HIDDEN_SETTINGS` parser, and the `settings_operator_managed`
error code. The General page stays visible as the settings root and
redirect target.
- New `server/src/services/settings-visibility.ts`: parse-once accessor;
unknown keys log one warning and are ignored.
- `/api/health` reports `hiddenSettings` on every response shape; the
field is omitted when nothing is hidden.
- Server floors on hidden surfaces, with same-value echo tolerance (the
`executionMode` precedent): field-backed general sections and
experimental keys reject value-changing PATCHes, and hiding the whole
Experimental page floors every toggle; plugin lifecycle and config
writes, adapter management writes, and the Access admin routes (reads
included) return 403 `settings_operator_managed`. Reads the app itself
needs (plugin `ui-contributions`, adapter metadata, plugin job trigger)
stay open. Pages without instance-scoped mutation routes are hidden in
the UI only.
- UI: new `useHiddenSettings` hook and `HiddenSettingsPageGate` route
gate (hidden pages redirect to the settings root); the settings sidebar
and tab bar drop hidden entries; remembered settings paths remap to the
default page; `InstanceGeneralSettings` skips hidden sections; every
`ExperimentalToggleCard` now carries its flag key and renders nothing
when hidden.
- Removed the dead `InstanceSidebar` component (referenced only by its
own test).
- Docs: `docs/deploy/environment-variables.md` documents the variable
and the key registry.
## Verification
- `pnpm vitest run` over the new and extended suites: shared registry
and parser, representative floor tests per route class (changed-value
403, same-value echo 200, unset env 200, page-level Experimental
hiding), the health field, the route gate, nav filtering, and
section/card hiding with one hidden example per surface kind — 168 tests
pass.
- Full root `pnpm typecheck` passes.
- Manual: booted a server with the variable set. `/api/health` lists the
keys; an unknown key logs one warning and the server boots; hidden pages
redirect; hidden sections and cards do not render; hidden-field PATCH
returns 403 with `details.code = "settings_operator_managed"`; a
same-value echo returns 200. Unset the variable: the full settings
surface returns and responses are byte-identical to master.
## Risks
- Low risk for self-hosted instances: with the variable unset, the
hidden set is empty, the health field is omitted, and no floor
activates.
- Flooring plugin config writes assumes hosted deployments configure
plugins through the platform. If a future bundled plugin needs
tenant-entered config, the floor needs a narrow carve-out.
- Hidden-key floors tolerate same-value echoes, so API clients that
round-trip full GET responses keep working.
- Hiding a toggle does not change its value; operators pair hiding with
the desired default where the value matters.
## Model Used
Claude Fable 5 (Anthropic, `claude-fable-5`) with extended thinking and
agentic tool use, driven through the Claude Code CLI (file edits, test
execution, and live-server verification loops).
## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
778 lines
28 KiB
TypeScript
778 lines
28 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 type {
|
|
AdapterLoginPanelMode,
|
|
AdapterLoginSandboxTransport,
|
|
AdapterLoginTimeoutPolicy,
|
|
} from "@paperclipai/adapter-utils";
|
|
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { forbidden } from "../errors.js";
|
|
import { isCloudManagedInstance } from "../services/cloud-instance.js";
|
|
import { getHiddenSettings } from "../services/settings-visibility.js";
|
|
import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js";
|
|
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/**
|
|
* Floor: on cloud-managed instances adapter code is bundled into the platform
|
|
* image; fetching and loading external adapter packages at runtime stays off
|
|
* for every actor, including instance admins. Adapter code executes in the
|
|
* server process, so a runtime install would let an instance admin read the
|
|
* platform trust anchors from the process environment (mirrors the
|
|
* bundled-only plugin install floor in plugin-install-guard.ts).
|
|
*/
|
|
function assertAdapterCodeInstallAllowed() {
|
|
if (isCloudManagedInstance()) {
|
|
throw forbidden("Adapter installation is platform-managed on cloud-managed instances", {
|
|
code: "adapter_install_platform_managed",
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, the sandbox transport, 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.
|
|
*/
|
|
interface AdapterLoginProjection {
|
|
panelMode: AdapterLoginPanelMode;
|
|
sandboxTransport: AdapterLoginSandboxTransport;
|
|
timeoutPolicy: AdapterLoginTimeoutPolicy;
|
|
}
|
|
|
|
interface AdapterCapabilities {
|
|
supportsInstructionsBundle: boolean;
|
|
supportsSkills: boolean;
|
|
supportsLocalAgentJwt: boolean;
|
|
requiresMaterializedRuntimeSkills: boolean;
|
|
supportsModelProfiles: 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 {
|
|
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,
|
|
supportsModelProfiles: Boolean(adapter.modelProfiles?.length || adapter.listModelProfiles),
|
|
supportsAcp: Boolean(adapter.acp),
|
|
...(login
|
|
? {
|
|
login: {
|
|
panelMode: login.panelMode,
|
|
sandboxTransport: login.sandboxTransport,
|
|
timeoutPolicy: login.timeoutPolicy,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
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),
|
|
...(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<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);
|
|
assertAdapterCodeInstallAllowed();
|
|
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(getDisabledAdapterTypes());
|
|
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 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);
|
|
|
|
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);
|
|
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);
|
|
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);
|
|
assertAdapterCodeInstallAllowed();
|
|
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<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;
|
|
}
|