mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip helps people manage AI agents at work. > - Cursor Cloud hosts execution behind its API. > - Product setup still expected a Paperclip Computer. > - Managed-only tenants therefore rejected valid setup. > - This admits the validated API-owned controller. > - Authority, Stop proof, budgets and cleanup remain enforced. ## Linked Issues or Issue Description Refs #18068 and #18069; both dependencies have merged. **What happened?** Managed-only setup still requested a Computer. Live tests exposed missing Cursor Stop proof and duplicate replay. **Expected behavior** Use Cursor’s API controller. Verify provider/controller termination and replay each log identity once. **Steps to reproduce** Enable native Runner/managed-only. Configure Cursor model/repository/key without a Computer, then Test, finish setup, run and Stop. ## What Changed - Derive API-only placement from validated native Cursor configuration; reject placement/command/workspace/home overrides and keep Kubernetes-only execution closed. - Skip Computers/checkout/workspace realization. Keep scoped tools, completion, private staging, budgets, leases and recovery. Persist placement; reject contradictions. - Align Cloud cards, Test, Save and Finish setup; hold unready drafts. Legacy keeps its Computer, credential and policy checks. - Verify Stop from exact API ownership, audited intent, provider-terminal proof, no Computer/process ownership and controller retirement. Recheck immediate/deferred retirement; wake existing recovery on durable evidence. - Clear warnings when the actual stopped target later has authoritative confirmation. Keep missing/other-run negatives and the existing predicate. - Publish safe status refresh after full-proof ACK. Retain bounded native/physical cancellation history; add no poller or weaker proof path. ## Verification Core merged as `3910352c923a`; Product merged as `27d35314be8e`. Public branch `5f86eb66b081` has tree `67344151d303`. Its exact-head Greptile summary is 5/5; the fresh audit found zero unresolved threads and complete pagination. The controlled workspace retry and required summary passed. All 59 current-head contexts are terminal and clean. Prior green-36 evidence keeps its identity. [Current CI](https://github.com/paperclipai/paperclip/actions/runs/41680511731) first failed workspaces-a 1/2: the CLI company-import/export beforeAll 30-second runtime-info/child-PID/HTTP readiness wait expired. All 431 UI files/4,714 tests and 285 other CLI tests passed (one skip). The cause remains UNKNOWN. Child output records startup recovery complete, but buffered CI prefixes do not establish its relation to the waiter deadline; runtime-file/PID/last-health state was not captured. Both unchanged local probes were RED. The second observed dependency preflight still pending at the deadline; observer overhead was not quantified. Neither proves the CI cause. After all functional jobs finished, only the queued metadata summary was cancelled; the other 48 records stayed unchanged. Attempt 1 retains 46 successes, the original failure, one skip and the cancelled summary. One same-head debug retry was admitted at 18:42 UTC on November 3. Its workspace job passed at 19:23 UTC on the same checkout: UI 431 files/4,714 tests and CLI 33 files/286 tests, including company import/export. Test caching was disabled. The new `ci / verify` summary passed; workflow attempt 2 completed SUCCESS. Required `ci / e2e` also passed. The final check at 19:53 UTC on November 3 found all 59 current-head contexts terminal and clean: zero pending and zero failing. The 47 inherited completed/skipped rows are not new executions. No code, assertion or timeout changed; success does not explain the original failure or erase the local REDs. CI checkout is `1ea9d8608ced`/tree `78cb1ec13d59` (parents `2e37c73c05fe`, 5f86); both workflows use 2e37. Its 20 extra paths add low-memory build/Rust task-memory. Scripts cohere; later matrix changes only labels, with no newer debug-cache steps. Static audit found no blocker. Supported API arguments omit the memory flag: no default monitor/instruction changes. Flagged external launchers can change Cursor instructions/digests and remain unqualified. The merge added 41 master-identical paths, retained the green-36 inbox hunk and found UI fixtures matching upstream. Startup/backup, recovery projection, sleep diagnostics and binary backup change production. Static audit found eight Stop/ACK owner blobs and 37 sleep-work SQL templates unchanged, with sleep fences/single-flight accounting intact. Exact precommit tree: 416/416 across eight server files, binary backup 1/1, server types PASS; repeated scopes are not summed. The deferred-retirement P1 reproduced three real-DB/owned-process failures; missing proof stayed unverified. Recovery passed 47 process cases/33 proof negatives. Warning repair binds actual targets to same-run proof. Validation passed 729 scoped UI cases, a separate 359-case thread run, 111 backend cases (84 CAS, 6 assembled, 21 activity/privacy), types/tokens. Proof validators/predicate are unchanged; no new poller or increased timeout. The d8d3 attempt stays FAILED: 45 passes, two workspaces-a failures, derived verify failure and one skip. Both shards shared three failures: two successful mocks omitted the account guard’s required userId. The two-line fixture repair changed no guard, assertion or timeout. Three failures reproduced; 54 access/auth tests then passed, including account-change/denial negatives, plus UI types/tokens. The 69285 attempt stays FAILED: 49 jobs, 46 passed, serialized shard 9 failed, its verify aggregate failed and one selection job skipped. The shard passed 575/576 cases. Its aborted-snapshot case expected nine computes but saw eight: a final HTTP 200 coalesced while seven ordinary reads had not proved the aborted compute settled. Earlier CI and the unmodified local baseline passed with a miss; no local RED is claimed. The repair awaits the snapshot, allows exactly one extra GET if coalesced, then requires a miss and retains nine computes/all HTTP429 assertions. No production code, hook, loop, sleep or timeout changed. Six inbox and seven cache/admission cases passed locally, plus server types; 30 unrelated cases were filtered. Green 36 passed 576/576 without taking the extra-GET branch; local runs also missed that schedule. Current CI event merge 1ea9 now passed 576/576 and naturally exercised the former failure: coalesced (count 2, 1ms), then exactly one fresh miss (count 1, 16ms), with strict nine-compute/HTTP429 assertions intact. No instrumentation or retry was added. Earlier compatibility passed scoped remote/inbox, routine, API-placement and UI tests, plus types/migrations/snapshots/maps/tokens. The 89-path adoption restored fleet routing without workflow overrides or added skips. Each result retains its source identity. Live qualification remains actual `9af1a5de42a2`: unchanged Artifact grader PASSED 17/17 and active-Stop grader PASSED 21/21. All three provider test resources were deleted canonically with GET404; post-delete download returned the exact 27 bytes. Histories stayed stable (Artifact 121 and Stop 67 canonical identities), with 21 company runs, zero active work and no accounting/cancellation/reservation debt or integrity findings. Saved settings reopened; no-prompt Test/Linux admission passed with `claude-sonnet-4-5`. No new Save is claimed. The first Stop sampling case stays FAILED on a 10.373-second gap. Unchanged retry kept all 24 samples: 35.076 seconds quiet, maximum gap 4.584 seconds, plus a separate CANCELLED/artifact-absent read after 180 seconds. Initial grading rejected locator spelling before functional checks; metadata changed only after same-file/inode/byte proof, with raw evidence/oracles unchanged. Original Question/Plan/reconnect and historical deleted-artifact results keep their identities. Git trees verified 11 history-only conflicts and six earlier mainline paths. Hosted 9af to the public BRANCH differs in 222 paths; 13,500 other mode/blob leaves match. Runner runtime implementations and six observer/driver pins match 9af on that branch only; inherited `drivers/acpx/runtime-host.test.ts` differs. No all-Runner/src, CI/latest-master runtime or image equality is claimed. Later static audits through `8ab89f41e499` found no concrete API-only blocker. That base adds completed-history sleep proofs and an MCP cleanup migration; protected Stop/ACK owners are unchanged. These later changes are outside this CI and have no live qualification here. Host duplicate-card and later recovery/UI repairs retain separate proof, not deployed 9af identity. Direct-key coverage requires absent aiConnection; API-only placement skips inherited remote-workspace disk-full paths, and config-read gates match their parent. Other retained failures: stale fixture 132/133; E8 48/135 with 87 reset-hook failures; Mac 17 SQL failures/skips; title/startup/custody/Stop/deletion unknown causes and bounded recoveries; superseded warning/50 duplicate identities; pre-repair ACK/Core CI failures. Fixture RED/GREEN passed 7/7 browser and 121/121 shutdown tests with a failing broken-unsubscribe control; stable WSP wrapper passed 128 after physical teardown failure. No full local-suite pass is inferred. All paid attempts remain recorded; vendor dollar cost is UNKNOWN. ## Risks Experimental: Cursor independently acknowledges neither the effective model nor instruction digest before the first prompt. Explicit values are submitted and behavior checked live; unpriced provider dollar cost stays UNKNOWN. The placement exception applies only to validated native Cursor API execution. Other local work remains denied under managed-only policy. Missing/stale Stop or cleanup proof stays pending; damaged archives remain failed. Explicit Legacy keeps its existing policy owners. ## Model Used OpenAI Codex/GPT-6 family: tools, execution, browser checks, delegated review. API ID/context window unavailable. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used and the limits of available model metadata - [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 linked public issues or described the issue in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch describes the change and contains no internal ticket ID - [x] I have run the scoped local tests reported above; retained failures are stated separately - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented the risks - [x] All required CI gates pass on the current head - [x] Fresh Greptile is 5/5 with no unresolved findings on the current head - [x] I will address all reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
840 lines
32 KiB
TypeScript
840 lines
32 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 { 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, HttpError } from "../errors.js";
|
|
import { isCloudManagedInstance } from "../services/cloud-instance.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<string>, 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<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(options: {
|
|
db?: Db;
|
|
getNativeRunnerEnabled?: () => Promise<boolean>;
|
|
getOpenAiDotEnabled?: () => Promise<boolean>;
|
|
resolveRunnerTarget?: (companyId: string, environmentId?: string) => Promise<RunnerTarget>;
|
|
} = {}) {
|
|
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.");
|
|
try {
|
|
target = await options.resolveRunnerTarget(companyId, typeof environmentId === "string" ? environmentId : undefined);
|
|
} catch (error) {
|
|
// API controllers do not need a Computer. Keep their catalog visible
|
|
// when only the implicit managed Computer is unavailable. This target
|
|
// grants no local execution support to any workspace harness.
|
|
if (environmentId !== undefined || !(error instanceof HttpError) || error.status !== 422
|
|
|| (error.details as { code?: unknown } | undefined)?.code !== "managed_sandbox_unavailable") throw error;
|
|
target = { driver: "unavailable", ...((error.details as { executionMode?: unknown }).executionMode === "kubernetes"
|
|
? { executionMode: "kubernetes" as const } : {}) };
|
|
}
|
|
}
|
|
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<typeof resolveAntigravityCandidateAdmission>;
|
|
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<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;
|
|
}
|