mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:15:21 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The execution subsystem runs those agents in environments (local, ssh, sandbox), and sandbox-provider plugins let an environment materialize per-run sandboxes > - Stage 1 (#5790) contributed a first-party Kubernetes sandbox-provider plugin, but the server core has no way to adopt it operationally: no per-run adapter selection, no way to force an instance onto sandboxed execution, no declarative adapter/model configuration, and the plugin must be installed by hand > - Without this, a multi-tenant or security-conscious deployment cannot guarantee that agent runs never execute on the host, and a single environment cannot serve agents with different harnesses > - This pull request adds the server + SDK integration: per-run adapterType on the lease protocol, an env-gated forced-Kubernetes execution policy with provisioning and a per-run allowlist guard, a declarative adapter registry and model list, in-cluster env passthrough for sandbox plugin workers, fail-safe auto-install of the bundled plugin, and the matching UI affordance > - The benefit is that sandbox-provider plugins become fully usable for Kubernetes execution: operators configure everything via environment variables and GitOps, while self-hosters who set none of the variables see exactly the behavior they have today ## Linked Issues or Issue Description Refs #5790 (stage 1 of 3: the Kubernetes sandbox-provider plugin package). No existing issue. Feature description: the server core lacks the integration seams to operate a sandbox-provider plugin as the mandatory execution path of an instance. This PR is stage 2 of 3 of the staged Kubernetes contribution; stage 3 will contribute the agent runtime images and their build pipeline. ## What Changed One line per piece: - `packages/plugins/sdk/protocol.ts`: optional `adapterType` on `PluginEnvironmentAcquireLeaseParams` so a provider can select the runtime image per run; existing providers simply ignore it - `server/services/environment-runtime.ts` + `environment-run-orchestrator.ts`: thread the agent's adapter type into both lease-acquiring drivers, including the heartbeat path (the two call sites have historically drifted, hence the pinned test) - `server/services/environments.ts`: `ensureKubernetesEnvironment` / `findKubernetesEnvironment`, an idempotent managed Kubernetes environment per company, identified by a metadata marker and refreshed (not recreated) on config change; `timeoutMs` rides on the config for slow cold-start leases - `server/services/execution-allowlist.ts`: pure (driver, provider, policy) -> allow/deny guard; `executionMode=kubernetes` only allows the kubernetes sandbox provider - `server/services/execution-policy-bootstrap.ts` + startup hook in `server/index.ts`: parse `PAPERCLIP_EXECUTION_MODE` / `PAPERCLIP_K8S_*`, persist `executionMode` into instance general settings, and provision the managed environment for every company; fails loud on misconfiguration - `server/services/heartbeat.ts`: when the policy forces Kubernetes, pin run selection to the managed environment (also overriding any persisted workspace environment id), refuse to fall back to local, and re-check the actually acquired environment against the allowlist as defense in depth - `server/services/adapter-registry-bootstrap.ts` + shared `AdapterRegistryEntry` type/validator: declarative `PAPERCLIP_ADAPTERS` registry (inline JSON or file) that reconciles adapter availability at startup and rides on the Kubernetes environment config - `server/services/adapter-models-env.ts` + `adapters/registry.ts`: `PAPERCLIP_ADAPTER_MODELS` lets an operator declare picker model lists the server cannot CLI-discover - `server/services/plugin-loader.ts`: pass `KUBERNETES_SERVICE_HOST/PORT(_HTTPS)` through to plugin workers that register environment drivers, so in-cluster API clients can be constructed; all other host env stays stripped - `server/app.ts`: fail-safe auto-install of the bundled kubernetes plugin at boot; no-ops when the bundle is absent and never blocks startup on error - `packages/shared` types/validators: `InstanceExecutionMode` on general settings (optional, strict schema) - `ui/lib/forced-kubernetes-environment.ts` + `AgentConfigForm`: when the policy is active, show a read-only Kubernetes environment instead of the environment picker and default new agents onto the managed environment - Tests for every new module plus the adapterType pin in `heartbeat-plugin-environment` and the managed-environment lifecycle in `environment-service` Everything is gated: with `PAPERCLIP_EXECUTION_MODE`, `PAPERCLIP_ADAPTERS`, and `PAPERCLIP_ADAPTER_MODELS` unset (and no bundled plugin present), every code path reduces to current behavior. The per-run `adapterType` is an optional SDK parameter that existing providers ignore. ## Verification - `cd server && npx tsc --noEmit`: clean (0 errors); `ui` typecheck also clean - Targeted suites all green (11 files, 90 tests): `npx vitest run server/src/__tests__/heartbeat-plugin-environment.test.ts server/src/__tests__/environment-service.test.ts server/src/__tests__/environment-runtime.test.ts server/src/__tests__/environment-run-orchestrator.test.ts server/src/__tests__/plugin-database.test.ts server/src/services/execution-policy-bootstrap.test.ts server/src/services/execution-allowlist.test.ts server/src/services/adapter-registry-bootstrap.test.ts server/src/services/adapter-registry-bootstrap.reconcile.test.ts server/src/services/adapter-models-env.test.ts packages/shared/src/validators/adapter-registry.test.ts` - `npx vitest run ui/src/components/AgentConfigForm.test.ts`: green (6 tests) - Full `npx vitest run server/src/__tests__`: 2323 passed, 1 skipped; the only failures (heartbeat-process-recovery pid-retry, workspace-runtime symbolic-ref/git tests) reproduce identically on pristine `master` in the same environment, so they are machine-environment issues unrelated to this change; `server-startup-feedback-export` needed its `services/index.js` mock extended with the new export and is green - This integration has been running in production on a hosted multi-tenant deployment, where it executes agent runs across five different harnesses through the stage 1 plugin ## Risks - Low for existing deployments: every behavior is env-gated and the defaults preserve current semantics; the auto-install block is wrapped fail-safe and skips silently when the plugin bundle is absent - `executionMode` is a new optional field on a strict zod schema; absent input normalizes exactly as before - The forced policy intentionally fails runs loudly (rather than falling back to local) when no managed Kubernetes environment exists; this only affects instances that explicitly set `PAPERCLIP_EXECUTION_MODE=kubernetes` ## Model Used Claude Opus 4.8 (claude-opus-4-8, 1M context), extended thinking, agentic tool use via Claude Code. ## UI screenshots The UI change is a new read-only "Execution" section in `AgentConfigForm`, shown only when the instance execution policy forces Kubernetes (`executionMode=kubernetes`); there is no "before" state for it (the section did not exist, and instances without the forced policy render the existing picker unchanged). Captured from the new Storybook stories added in this PR (`Product/Agent Management`): Managed Kubernetes environment present (read-only display, no local/SSH picker):  No managed environment available yet (warning notice, no silent local fallback):  ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
563 lines
21 KiB
TypeScript
563 lines
21 KiB
TypeScript
import express, { Router, type Request as ExpressRequest } from "express";
|
|
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { Db } from "@paperclipai/db";
|
|
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
|
import type { StorageService } from "./storage/types.js";
|
|
import { httpLogger, errorHandler } from "./middleware/index.js";
|
|
import { actorMiddleware } from "./middleware/auth.js";
|
|
import { boardMutationGuard } from "./middleware/board-mutation-guard.js";
|
|
import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middleware/private-hostname-guard.js";
|
|
import { healthRoutes } from "./routes/health.js";
|
|
import { companyRoutes } from "./routes/companies.js";
|
|
import { companySkillRoutes } from "./routes/company-skills.js";
|
|
import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
|
|
import { agentRoutes } from "./routes/agents.js";
|
|
import { projectRoutes } from "./routes/projects.js";
|
|
import { issueRoutes } from "./routes/issues.js";
|
|
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
|
|
import { fileResourceRoutes } from "./routes/file-resources.js";
|
|
import { routineRoutes } from "./routes/routines.js";
|
|
import { environmentRoutes } from "./routes/environments.js";
|
|
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
|
|
import { goalRoutes } from "./routes/goals.js";
|
|
import { approvalRoutes } from "./routes/approvals.js";
|
|
import { secretRoutes } from "./routes/secrets.js";
|
|
import { costRoutes } from "./routes/costs.js";
|
|
import { activityRoutes } from "./routes/activity.js";
|
|
import { dashboardRoutes } from "./routes/dashboard.js";
|
|
import { userProfileRoutes } from "./routes/user-profiles.js";
|
|
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
|
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
|
import { resourceMembershipRoutes } from "./routes/resource-memberships.js";
|
|
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
|
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
|
import { openApiRoutes } from "./routes/openapi.js";
|
|
import {
|
|
instanceDatabaseBackupRoutes,
|
|
type InstanceDatabaseBackupService,
|
|
} from "./routes/instance-database-backups.js";
|
|
import { llmRoutes } from "./routes/llms.js";
|
|
import { authRoutes } from "./routes/auth.js";
|
|
import { assetRoutes } from "./routes/assets.js";
|
|
import { accessRoutes } from "./routes/access.js";
|
|
import { pluginRoutes } from "./routes/plugins.js";
|
|
import { adapterRoutes } from "./routes/adapters.js";
|
|
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
|
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
|
|
import { applyUiBranding } from "./ui-branding.js";
|
|
import { logger } from "./middleware/logger.js";
|
|
import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js";
|
|
import { createPluginWorkerManager, type PluginWorkerManager } from "./services/plugin-worker-manager.js";
|
|
import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js";
|
|
import { pluginJobStore } from "./services/plugin-job-store.js";
|
|
import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js";
|
|
import { pluginLifecycleManager } from "./services/plugin-lifecycle.js";
|
|
import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js";
|
|
import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js";
|
|
import { createPluginEventBus } from "./services/plugin-event-bus.js";
|
|
import { setPluginEventBus } from "./services/activity-log.js";
|
|
import { createPluginDevWatcher } from "./services/plugin-dev-watcher.js";
|
|
import { createPluginHostServiceCleanup } from "./services/plugin-host-service-cleanup.js";
|
|
import { pluginRegistryService } from "./services/plugin-registry.js";
|
|
import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
|
|
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
|
import { createCachedViteHtmlRenderer } from "./vite-html-renderer.js";
|
|
import { DEFAULT_JSON_BODY_LIMIT, PORTABLE_JSON_BODY_LIMIT } from "./http/body-limits.js";
|
|
import { COMPANY_IMPORT_API_PATH } from "./routes/company-import-paths.js";
|
|
|
|
type UiMode = "none" | "static" | "vite-dev";
|
|
const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000;
|
|
const VITE_DEV_ASSET_PREFIXES = [
|
|
"/@fs/",
|
|
"/@id/",
|
|
"/@react-refresh",
|
|
"/@vite/",
|
|
"/assets/",
|
|
"/node_modules/",
|
|
"/src/",
|
|
];
|
|
const VITE_DEV_STATIC_PATHS = new Set([
|
|
"/apple-touch-icon.png",
|
|
"/favicon-16x16.png",
|
|
"/favicon-32x32.png",
|
|
"/favicon.ico",
|
|
"/favicon.svg",
|
|
"/site.webmanifest",
|
|
"/sw.js",
|
|
]);
|
|
|
|
export function isDatabaseConnectionUnavailableError(err: unknown): boolean {
|
|
const error = err as { code?: unknown; message?: unknown; cause?: unknown };
|
|
if (error?.code === "ECONNREFUSED") return true;
|
|
return Boolean(error?.cause && isDatabaseConnectionUnavailableError(error.cause));
|
|
}
|
|
|
|
export function resolveViteHmrPort(serverPort: number): number {
|
|
if (serverPort <= 55_535) {
|
|
return serverPort + 10_000;
|
|
}
|
|
return Math.max(1_024, serverPort - 10_000);
|
|
}
|
|
|
|
export function resolveViteHmrHost(bindHost: string): string | undefined {
|
|
const normalized = bindHost.trim().toLowerCase();
|
|
if (normalized === "0.0.0.0" || normalized === "::") return undefined;
|
|
return bindHost;
|
|
}
|
|
|
|
export function shouldServeViteDevHtml(req: ExpressRequest): boolean {
|
|
const pathname = req.path;
|
|
if (VITE_DEV_STATIC_PATHS.has(pathname)) return false;
|
|
if (VITE_DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false;
|
|
return req.accepts(["html"]) === "html";
|
|
}
|
|
|
|
export function shouldEnablePrivateHostnameGuard(opts: {
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
}): boolean {
|
|
return (
|
|
opts.deploymentExposure === "private" &&
|
|
(opts.deploymentMode === "local_trusted" || opts.deploymentMode === "authenticated")
|
|
);
|
|
}
|
|
|
|
export async function createApp(
|
|
db: Db,
|
|
opts: {
|
|
uiMode: UiMode;
|
|
serverPort: number;
|
|
storageService: StorageService;
|
|
feedbackExportService?: {
|
|
flushPendingFeedbackTraces(input?: {
|
|
companyId?: string;
|
|
traceId?: string;
|
|
limit?: number;
|
|
now?: Date;
|
|
}): Promise<unknown>;
|
|
};
|
|
databaseBackupService?: InstanceDatabaseBackupService;
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
allowedHostnames: string[];
|
|
bindHost: string;
|
|
authReady: boolean;
|
|
companyDeletionEnabled: boolean;
|
|
instanceId?: string;
|
|
hostVersion?: string;
|
|
localPluginDir?: string;
|
|
pluginMigrationDb?: Db;
|
|
pluginWorkerManager?: PluginWorkerManager;
|
|
betterAuthHandler?: express.RequestHandler;
|
|
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
|
},
|
|
) {
|
|
const app = express();
|
|
const captureRawBody = (req: express.Request, _res: express.Response, buf: Buffer) => {
|
|
(req as unknown as { rawBody: Buffer }).rawBody = buf;
|
|
};
|
|
|
|
app.use(COMPANY_IMPORT_API_PATH, express.json({
|
|
limit: PORTABLE_JSON_BODY_LIMIT,
|
|
verify: captureRawBody,
|
|
}));
|
|
app.use(express.json({
|
|
limit: DEFAULT_JSON_BODY_LIMIT,
|
|
verify: captureRawBody,
|
|
}));
|
|
app.use(httpLogger);
|
|
const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
});
|
|
const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({
|
|
allowedHostnames: opts.allowedHostnames,
|
|
bindHost: opts.bindHost,
|
|
});
|
|
app.use(
|
|
privateHostnameGuard({
|
|
enabled: privateHostnameGateEnabled,
|
|
allowedHostnames: opts.allowedHostnames,
|
|
bindHost: opts.bindHost,
|
|
}),
|
|
);
|
|
app.use(
|
|
actorMiddleware(db, {
|
|
deploymentMode: opts.deploymentMode,
|
|
resolveSession: opts.resolveSession,
|
|
}),
|
|
);
|
|
app.use("/api/auth", authRoutes(db));
|
|
if (opts.betterAuthHandler) {
|
|
app.all("/api/auth/{*authPath}", opts.betterAuthHandler);
|
|
}
|
|
app.use(llmRoutes(db));
|
|
|
|
const hostServicesDisposers = new Map<string, () => void>();
|
|
const workerManager = opts.pluginWorkerManager ?? createPluginWorkerManager();
|
|
|
|
// Mount API routes
|
|
const api = Router();
|
|
api.use(boardMutationGuard());
|
|
api.use(
|
|
"/health",
|
|
healthRoutes(db, {
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
authReady: opts.authReady,
|
|
companyDeletionEnabled: opts.companyDeletionEnabled,
|
|
}),
|
|
);
|
|
api.use(openApiRoutes());
|
|
api.use("/companies", companyRoutes(db, opts.storageService));
|
|
api.use(llmRoutes(db));
|
|
api.use(companySkillRoutes(db));
|
|
api.use(teamsCatalogRoutes(db));
|
|
api.use(agentRoutes(db, { pluginWorkerManager: workerManager }));
|
|
api.use(assetRoutes(db, opts.storageService));
|
|
api.use(projectRoutes(db));
|
|
api.use(issueRoutes(db, opts.storageService, {
|
|
feedbackExportService: opts.feedbackExportService,
|
|
pluginWorkerManager: workerManager,
|
|
}));
|
|
api.use(issueTreeControlRoutes(db));
|
|
api.use(fileResourceRoutes(db));
|
|
api.use(routineRoutes(db, { pluginWorkerManager: workerManager }));
|
|
api.use(environmentRoutes(db, { pluginWorkerManager: workerManager }));
|
|
api.use(executionWorkspaceRoutes(db));
|
|
api.use(goalRoutes(db));
|
|
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
|
|
api.use(secretRoutes(db));
|
|
api.use(costRoutes(db, { pluginWorkerManager: workerManager }));
|
|
api.use(activityRoutes(db));
|
|
api.use(dashboardRoutes(db));
|
|
api.use(userProfileRoutes(db));
|
|
api.use(sidebarBadgeRoutes(db));
|
|
api.use(sidebarPreferenceRoutes(db));
|
|
api.use(resourceMembershipRoutes(db));
|
|
api.use(inboxDismissalRoutes(db));
|
|
api.use(instanceSettingsRoutes(db));
|
|
if (opts.databaseBackupService) {
|
|
api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService));
|
|
}
|
|
const pluginRegistry = pluginRegistryService(db);
|
|
const eventBus = createPluginEventBus();
|
|
setPluginEventBus(eventBus);
|
|
const jobStore = pluginJobStore(db);
|
|
const lifecycle = pluginLifecycleManager(db, { workerManager });
|
|
const scheduler = createPluginJobScheduler({
|
|
db,
|
|
jobStore,
|
|
workerManager,
|
|
});
|
|
const toolDispatcher = createPluginToolDispatcher({
|
|
workerManager,
|
|
lifecycleManager: lifecycle,
|
|
db,
|
|
});
|
|
const jobCoordinator = createPluginJobCoordinator({
|
|
db,
|
|
lifecycle,
|
|
scheduler,
|
|
jobStore,
|
|
});
|
|
const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers);
|
|
let viteHtmlRenderer: ReturnType<typeof createCachedViteHtmlRenderer> | null = null;
|
|
const loader = pluginLoader(
|
|
db,
|
|
{
|
|
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
|
migrationDb: opts.pluginMigrationDb,
|
|
},
|
|
{
|
|
workerManager,
|
|
eventBus,
|
|
jobScheduler: scheduler,
|
|
jobStore,
|
|
toolDispatcher,
|
|
lifecycleManager: lifecycle,
|
|
instanceInfo: {
|
|
instanceId: opts.instanceId ?? "default",
|
|
hostVersion: opts.hostVersion ?? "0.0.0",
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
},
|
|
buildHostHandlers: (pluginId, manifest) => {
|
|
const notifyWorker = (method: string, params: unknown) => {
|
|
const handle = workerManager.getWorker(pluginId);
|
|
if (handle) handle.notify(method, params);
|
|
};
|
|
const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker, {
|
|
pluginWorkerManager: workerManager,
|
|
manifest,
|
|
});
|
|
hostServicesDisposers.set(pluginId, () => services.dispose());
|
|
return createHostClientHandlers({
|
|
pluginId,
|
|
capabilities: manifest.capabilities,
|
|
services,
|
|
});
|
|
},
|
|
},
|
|
);
|
|
api.use(
|
|
pluginRoutes(
|
|
db,
|
|
loader,
|
|
{ scheduler, jobStore },
|
|
{ workerManager },
|
|
{ toolDispatcher },
|
|
{ workerManager },
|
|
),
|
|
);
|
|
api.use(adapterRoutes());
|
|
api.use(
|
|
accessRoutes(db, {
|
|
deploymentMode: opts.deploymentMode,
|
|
deploymentExposure: opts.deploymentExposure,
|
|
bindHost: opts.bindHost,
|
|
allowedHostnames: opts.allowedHostnames,
|
|
}),
|
|
);
|
|
app.use("/api", api);
|
|
app.use("/api", (_req, res) => {
|
|
res.status(404).json({ error: "API route not found" });
|
|
});
|
|
app.use(pluginUiStaticRoutes(db, {
|
|
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
|
}));
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
if (opts.uiMode === "static") {
|
|
// Try published location first (server/ui-dist/), then monorepo dev location (../../ui/dist)
|
|
const candidates = [
|
|
path.resolve(__dirname, "../ui-dist"),
|
|
path.resolve(__dirname, "../../ui/dist"),
|
|
];
|
|
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
|
|
if (uiDist) {
|
|
// Hashed asset files (Vite emits them under /assets/<name>.<hash>.<ext>)
|
|
// never change once built, so they can be cached aggressively.
|
|
app.use(
|
|
"/assets",
|
|
express.static(path.join(uiDist, "assets"), {
|
|
maxAge: "1y",
|
|
immutable: true,
|
|
}),
|
|
);
|
|
// Non-hashed static files (favicon.ico, manifest, robots.txt, etc.):
|
|
// short cache so operators who swap them out see the new version
|
|
// reasonably fast. Override for `index.html` specifically — it is
|
|
// served by this middleware for `/` and `/index.html`, and it must
|
|
// never outlive the asset hashes it points at.
|
|
app.use(
|
|
express.static(uiDist, {
|
|
maxAge: "1h",
|
|
setHeaders(res, filePath) {
|
|
if (path.basename(filePath) === "index.html") {
|
|
res.set("Cache-Control", "no-cache");
|
|
}
|
|
},
|
|
}),
|
|
);
|
|
// SPA fallback. Only for non-asset routes — if the browser asks for
|
|
// /assets/something.js that doesn't exist, we must NOT serve the HTML
|
|
// shell: the browser would try to load it as a JavaScript module, fail
|
|
// with a MIME-type error, and cache that broken response. Return 404
|
|
// instead. The index.html response itself is no-cache so a subsequent
|
|
// deploy's updated asset hashes are picked up on next load.
|
|
app.get(/.*/, (req, res) => {
|
|
if (req.path.startsWith("/assets/")) {
|
|
res.status(404).end();
|
|
return;
|
|
}
|
|
res
|
|
.status(200)
|
|
.set("Content-Type", "text/html")
|
|
.set("Cache-Control", "no-cache")
|
|
.end(readBrandedStaticIndexHtml(uiDist));
|
|
});
|
|
} else {
|
|
console.warn("[paperclip] UI dist not found; running in API-only mode");
|
|
}
|
|
}
|
|
|
|
if (opts.uiMode === "vite-dev") {
|
|
const uiRoot = path.resolve(__dirname, "../../ui");
|
|
const publicUiRoot = path.resolve(uiRoot, "public");
|
|
const hmrPort = resolveViteHmrPort(opts.serverPort);
|
|
const hmrHost = resolveViteHmrHost(opts.bindHost);
|
|
const { createServer: createViteServer } = await import("vite");
|
|
const vite = await createViteServer({
|
|
root: uiRoot,
|
|
appType: "custom",
|
|
server: {
|
|
middlewareMode: true,
|
|
hmr: {
|
|
...(hmrHost ? { host: hmrHost } : {}),
|
|
port: hmrPort,
|
|
clientPort: hmrPort,
|
|
},
|
|
allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined,
|
|
},
|
|
});
|
|
viteHtmlRenderer = createCachedViteHtmlRenderer({
|
|
vite,
|
|
uiRoot,
|
|
brandHtml: applyUiBranding,
|
|
});
|
|
const renderViteHtml = viteHtmlRenderer;
|
|
|
|
if (fs.existsSync(publicUiRoot)) {
|
|
app.use(express.static(publicUiRoot, { index: false }));
|
|
}
|
|
app.get(/.*/, async (req, res, next) => {
|
|
if (!shouldServeViteDevHtml(req)) {
|
|
next();
|
|
return;
|
|
}
|
|
try {
|
|
const html = await renderViteHtml.render(req.originalUrl);
|
|
res.status(200).set({ "Content-Type": "text/html" }).end(html);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
app.use(vite.middlewares);
|
|
}
|
|
|
|
app.use(errorHandler);
|
|
|
|
jobCoordinator.start();
|
|
scheduler.start();
|
|
let feedbackExportShuttingDown = false;
|
|
let feedbackExportTimer: ReturnType<typeof setInterval> | null = null;
|
|
const disableFeedbackExportFlushes = () => {
|
|
feedbackExportShuttingDown = true;
|
|
if (feedbackExportTimer) {
|
|
clearInterval(feedbackExportTimer);
|
|
feedbackExportTimer = null;
|
|
}
|
|
};
|
|
const flushPendingFeedbackExports = async () => {
|
|
if (feedbackExportShuttingDown) return;
|
|
try {
|
|
await opts.feedbackExportService?.flushPendingFeedbackTraces();
|
|
} catch (err) {
|
|
if (isDatabaseConnectionUnavailableError(err)) {
|
|
disableFeedbackExportFlushes();
|
|
logger.warn({ err }, "Disabling pending feedback export flushes because the database is unavailable");
|
|
return;
|
|
}
|
|
logger.error({ err }, "Failed to flush pending feedback exports");
|
|
}
|
|
};
|
|
|
|
feedbackExportTimer = opts.feedbackExportService
|
|
? setInterval(() => {
|
|
void flushPendingFeedbackExports();
|
|
}, FEEDBACK_EXPORT_FLUSH_INTERVAL_MS)
|
|
: null;
|
|
feedbackExportTimer?.unref?.();
|
|
if (opts.feedbackExportService) {
|
|
void flushPendingFeedbackExports();
|
|
}
|
|
void toolDispatcher.initialize().catch((err) => {
|
|
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
|
|
});
|
|
const devWatcher = createPluginDevWatcher(
|
|
lifecycle,
|
|
async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null,
|
|
);
|
|
// Auto-install the bundled kubernetes sandbox-provider plugin so the
|
|
// "kubernetes" sandbox provider is registered for agent runs. The plugin is
|
|
// excluded from the pnpm workspace and built standalone into the image (see
|
|
// Dockerfile), then installed here from its local path. This runs BEFORE
|
|
// loadAll() so loadAll() can activate it in the same startup pass.
|
|
//
|
|
// SAFETY (invariant B): this is fully fail-safe. Any failure (missing path,
|
|
// install error, load error) is caught, logged, and swallowed so the server
|
|
// ALWAYS finishes booting. A degraded boot (no kubernetes provider, agents
|
|
// cannot run) is strictly preferable to a crash loop.
|
|
const ensureBundledKubernetesPlugin = async (): Promise<void> => {
|
|
const KUBERNETES_PLUGIN_KEY = "paperclip.kubernetes-sandbox-provider";
|
|
const pluginPath =
|
|
process.env["PAPERCLIP_KUBERNETES_PLUGIN_PATH"] ??
|
|
"/app/packages/plugins/sandbox-providers/kubernetes";
|
|
try {
|
|
// Idempotent: skip if already installed (any non-uninstalled status).
|
|
const existing = await pluginRegistry.getByKey(KUBERNETES_PLUGIN_KEY);
|
|
if (existing) {
|
|
logger.info(
|
|
{ pluginKey: KUBERNETES_PLUGIN_KEY, status: existing.status },
|
|
"kubernetes sandbox plugin already installed; skipping auto-install",
|
|
);
|
|
return;
|
|
}
|
|
// Skip silently when the bundle is absent (e.g. local dev or an image
|
|
// built without the plugin). Not an error condition.
|
|
if (!fs.existsSync(path.join(pluginPath, "dist", "manifest.js"))) {
|
|
logger.info(
|
|
{ pluginPath },
|
|
"kubernetes sandbox plugin bundle not present; skipping auto-install",
|
|
);
|
|
return;
|
|
}
|
|
logger.info({ pluginPath }, "auto-installing bundled kubernetes sandbox plugin");
|
|
const discovered = await loader.installPlugin({ localPath: pluginPath });
|
|
if (!discovered.manifest) {
|
|
logger.error("kubernetes sandbox plugin installed but manifest is missing");
|
|
return;
|
|
}
|
|
// Transition installed -> ready and activate the worker.
|
|
const installed = await pluginRegistry.getByKey(discovered.manifest.id);
|
|
if (installed) {
|
|
await lifecycle.load(installed.id);
|
|
logger.info(
|
|
{ pluginId: installed.id, pluginKey: installed.pluginKey },
|
|
"kubernetes sandbox plugin auto-installed and loaded",
|
|
);
|
|
} else {
|
|
logger.error("kubernetes sandbox plugin installed but not found in registry");
|
|
}
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err },
|
|
"Failed to auto-install the kubernetes sandbox plugin; continuing boot (degraded: kubernetes provider unavailable)",
|
|
);
|
|
}
|
|
};
|
|
void ensureBundledKubernetesPlugin()
|
|
.then(() => loader.loadAll())
|
|
.then((result) => {
|
|
if (!result) return;
|
|
for (const loaded of result.results) {
|
|
if (devWatcher && loaded.success && loaded.plugin.packagePath) {
|
|
devWatcher.watch(loaded.plugin.id, loaded.plugin.packagePath);
|
|
}
|
|
}
|
|
}).catch((err) => {
|
|
logger.error({ err }, "Failed to load ready plugins on startup");
|
|
});
|
|
let appServicesShutdown = false;
|
|
const shutdownAppServices = () => {
|
|
if (appServicesShutdown) return;
|
|
appServicesShutdown = true;
|
|
disableFeedbackExportFlushes();
|
|
devWatcher?.close();
|
|
viteHtmlRenderer?.dispose();
|
|
hostServiceCleanup.disposeAll();
|
|
hostServiceCleanup.teardown();
|
|
};
|
|
app.locals.paperclipShutdown = shutdownAppServices;
|
|
|
|
process.once("exit", shutdownAppServices);
|
|
process.once("beforeExit", () => {
|
|
void flushPluginLogBuffer();
|
|
});
|
|
|
|
return app;
|
|
}
|