mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
feat(adapters): external adapter plugin system with dynamic UI parser
- Plugin loader: install/reload/remove/reinstall external adapters from npm packages or local directories - Plugin store persisted at ~/.paperclip/adapter-plugins.json - Self-healing UI parser resolution with version caching - UI: Adapter Manager page, dynamic loader, display registry with humanized names for unknown adapter types - Dev watch: exclude adapter-plugins dir from tsx watcher to prevent mid-request server restarts during reinstall - All consumer fallbacks use getAdapterLabel() for consistent display - AdapterTypeDropdown uses controlled open state for proper close behavior - Remove hermes-local from built-in UI (externalized to plugin) - Add docs for external adapters and UI parser contract
This commit is contained in:
parent
f8452a4520
commit
14d59da316
72 changed files with 4102 additions and 585 deletions
578
server/src/routes/adapters.ts
Normal file
578
server/src/routes/adapters.ts
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
/**
|
||||
* @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
|
||||
*
|
||||
* All routes require board-level authentication (assertBoard middleware).
|
||||
*
|
||||
* @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,
|
||||
listEnabledServerAdapters,
|
||||
registerServerAdapter,
|
||||
unregisterServerAdapter,
|
||||
} from "../adapters/registry.js";
|
||||
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
|
||||
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 } from "../adapters/types.js";
|
||||
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { assertBoard } from "./authz.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Known built-in adapter types (cannot be removed via the API)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BUILTIN_ADAPTER_TYPES = new Set([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
"openclaw_gateway",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"process",
|
||||
"http",
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 AdapterInfo {
|
||||
type: string;
|
||||
label: string;
|
||||
source: "builtin" | "external";
|
||||
modelsCount: number;
|
||||
loaded: boolean;
|
||||
disabled: 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 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),
|
||||
// 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 adapter module into the server registry, filling in
|
||||
* sessionManagement from the host.
|
||||
*/
|
||||
function registerWithSessionManagement(adapter: ServerAdapterModule): void {
|
||||
const wrapped: ServerAdapterModule = {
|
||||
...adapter,
|
||||
sessionManagement: getAdapterSessionManagement(adapter.type) ?? undefined,
|
||||
};
|
||||
registerServerAdapter(wrapped);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) => {
|
||||
assertBoard(_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),
|
||||
);
|
||||
|
||||
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) => {
|
||||
assertBoard(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) => {
|
||||
assertBoard(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 });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/adapters/:type
|
||||
*
|
||||
* Unregister an external adapter. Built-in adapters cannot be removed.
|
||||
*/
|
||||
router.delete("/adapters/:type", async (req, res) => {
|
||||
assertBoard(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) => {
|
||||
assertBoard(req);
|
||||
|
||||
const type = req.params.type;
|
||||
|
||||
// Built-in adapters cannot be reloaded
|
||||
if (BUILTIN_ADAPTER_TYPES.has(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);
|
||||
|
||||
// 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) => {
|
||||
assertBoard(req);
|
||||
|
||||
const type = req.params.type;
|
||||
|
||||
if (BUILTIN_ADAPTER_TYPES.has(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);
|
||||
|
||||
// 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/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) => {
|
||||
assertBoard(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;
|
||||
}
|
||||
|
|
@ -46,7 +46,12 @@ import {
|
|||
} from "../services/index.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js";
|
||||
import {
|
||||
detectAdapterModel,
|
||||
findServerAdapter,
|
||||
listAdapterModels,
|
||||
requireServerAdapter,
|
||||
} from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
|
||||
|
|
@ -69,6 +74,7 @@ export function agentRoutes(db: Db) {
|
|||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||
claude_local: "instructionsFilePath",
|
||||
codex_local: "instructionsFilePath",
|
||||
droid_local: "instructionsFilePath",
|
||||
gemini_local: "instructionsFilePath",
|
||||
opencode_local: "instructionsFilePath",
|
||||
cursor: "instructionsFilePath",
|
||||
|
|
@ -322,6 +328,21 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
}
|
||||
|
||||
function assertKnownAdapterType(type: string | null | undefined): string {
|
||||
const adapterType = typeof type === "string" ? type.trim() : "";
|
||||
if (!adapterType) {
|
||||
throw unprocessable("Adapter type is required");
|
||||
}
|
||||
if (!findServerAdapter(adapterType)) {
|
||||
throw unprocessable(`Unknown adapter type: ${adapterType}`);
|
||||
}
|
||||
return adapterType;
|
||||
}
|
||||
|
||||
function hasOwn(value: object, key: string): boolean {
|
||||
return Object.hasOwn(value, key);
|
||||
}
|
||||
|
||||
async function resolveCompanyIdForAgentReference(req: Request): Promise<string | null> {
|
||||
const companyIdQuery = req.query.companyId;
|
||||
const requestedCompanyId =
|
||||
|
|
@ -743,7 +764,7 @@ export function agentRoutes(db: Db) {
|
|||
router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = req.params.type as string;
|
||||
const type = assertKnownAdapterType(req.params.type as string);
|
||||
const models = await listAdapterModels(type);
|
||||
res.json(models);
|
||||
});
|
||||
|
|
@ -751,7 +772,7 @@ export function agentRoutes(db: Db) {
|
|||
router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = req.params.type as string;
|
||||
const type = assertKnownAdapterType(req.params.type as string);
|
||||
|
||||
const detected = await detectAdapterModel(type);
|
||||
res.json(detected);
|
||||
|
|
@ -762,14 +783,10 @@ export function agentRoutes(db: Db) {
|
|||
validate(testAdapterEnvironmentSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const type = req.params.type as string;
|
||||
const type = assertKnownAdapterType(req.params.type as string);
|
||||
await assertCanReadConfigurations(req, companyId);
|
||||
|
||||
const adapter = findServerAdapter(type);
|
||||
if (!adapter) {
|
||||
res.status(404).json({ error: `Unknown adapter type: ${type}` });
|
||||
return;
|
||||
}
|
||||
const adapter = requireServerAdapter(type);
|
||||
|
||||
const inputAdapterConfig =
|
||||
(req.body?.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
|
|
@ -1265,6 +1282,7 @@ export function agentRoutes(db: Db) {
|
|||
sourceIssueIds: _sourceIssueIds,
|
||||
...hireInput
|
||||
} = req.body;
|
||||
hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
hireInput.adapterType,
|
||||
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
|
||||
|
|
@ -1429,6 +1447,7 @@ export function agentRoutes(db: Db) {
|
|||
desiredSkills: requestedDesiredSkills,
|
||||
...createInput
|
||||
} = req.body;
|
||||
createInput.adapterType = assertKnownAdapterType(createInput.adapterType);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
createInput.adapterType,
|
||||
((createInput.adapterConfig ?? {}) as Record<string, unknown>),
|
||||
|
|
@ -1807,7 +1826,7 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
await assertCanUpdateAgent(req, existing);
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, "permissions")) {
|
||||
if (hasOwn(req.body as object, "permissions")) {
|
||||
res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" });
|
||||
return;
|
||||
}
|
||||
|
|
@ -1815,7 +1834,7 @@ export function agentRoutes(db: Db) {
|
|||
const patchData = { ...(req.body as Record<string, unknown>) };
|
||||
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
|
||||
delete patchData.replaceAdapterConfig;
|
||||
if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
|
||||
if (hasOwn(patchData, "adapterConfig")) {
|
||||
const adapterConfig = asRecord(patchData.adapterConfig);
|
||||
if (!adapterConfig) {
|
||||
res.status(422).json({ error: "adapterConfig must be an object" });
|
||||
|
|
@ -1830,16 +1849,17 @@ export function agentRoutes(db: Db) {
|
|||
patchData.adapterConfig = adapterConfig;
|
||||
}
|
||||
|
||||
const requestedAdapterType =
|
||||
typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType;
|
||||
const requestedAdapterType = hasOwn(patchData, "adapterType")
|
||||
? assertKnownAdapterType(patchData.adapterType as string | null | undefined)
|
||||
: existing.adapterType;
|
||||
const touchesAdapterConfiguration =
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
||||
hasOwn(patchData, "adapterType") ||
|
||||
hasOwn(patchData, "adapterConfig");
|
||||
if (touchesAdapterConfiguration) {
|
||||
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
|
||||
const changingAdapterType =
|
||||
typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType;
|
||||
const requestedAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
||||
const requestedAdapterConfig = hasOwn(patchData, "adapterConfig")
|
||||
? (asRecord(patchData.adapterConfig) ?? {})
|
||||
: null;
|
||||
if (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue