fix(cloud): persist configuration and load release-image packages (#18335)

Land the approved Cloud configuration and release-image package stack.

Includes the separately reviewed adapter settings (#18335), external adapters (#18333), bundled plugins (#18336), startup configuration (#18339), and migration/package checks (#18341).

Includes the final image-owned loader and linked-dependency inventory corrections.

Verified stack head: 462716170d.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-30 19:21:33 +00:00
1 parent 36f4422389
commit 8f822bfcf7
57 files changed
+3670 -250

No files matched your search

@@ -0,0 +1,10 @@
import { forbidden } from "../errors.js";
import { isCloudManagedInstance } from "./cloud-instance.js";
export function assertExternalAdapterCodeAllowed(): void {
if (isCloudManagedInstance()) {
throw forbidden("Adapter installation is platform-managed on cloud-managed instances", {
code: "adapter_install_platform_managed",
});
}
}
+12 -2
View File
@@ -15,6 +15,8 @@
import fs from "node:fs";
import path from "node:path";
import { resolvePaperclipHomeDir } from "../home-paths.js";
import { isCloudManagedInstance } from "./cloud-instance.js";
import { assertExternalAdapterCodeAllowed } from "./adapter-code-policy.js";
// ---------------------------------------------------------------------------
// Types
@@ -43,8 +45,8 @@ interface AdapterSettings {
// Paths
// ---------------------------------------------------------------------------
function adapterPluginPaths() {
const paperclipDir = resolvePaperclipHomeDir();
function adapterPluginPaths(homeOverride?: string) {
const paperclipDir = resolvePaperclipHomeDir(homeOverride);
return {
adapterPluginsDir: path.join(paperclipDir, "adapter-plugins"),
adapterPluginsStorePath: path.join(paperclipDir, "adapter-plugins.json"),
@@ -79,6 +81,7 @@ function ensureDirs(): string {
}
function readStore(): AdapterPluginRecord[] {
if (isCloudManagedInstance()) return [];
const { adapterPluginsStorePath } = adapterPluginPaths();
if (storeCache?.path === adapterPluginsStorePath) return storeCache.records;
try {
@@ -130,11 +133,16 @@ function writeSettings(settings: AdapterSettings): void {
// Public API
// ---------------------------------------------------------------------------
export function getAdapterPluginStorePath(homeOverride?: string): string {
return adapterPluginPaths(homeOverride).adapterPluginsStorePath;
}
export function listAdapterPlugins(): AdapterPluginRecord[] {
return readStore();
}
export function addAdapterPlugin(record: AdapterPluginRecord): void {
assertExternalAdapterCodeAllowed();
const store = [...readStore()];
const idx = store.findIndex((r) => r.type === record.type);
if (idx >= 0) {
@@ -146,6 +154,7 @@ export function addAdapterPlugin(record: AdapterPluginRecord): void {
}
export function removeAdapterPlugin(type: string): boolean {
assertExternalAdapterCodeAllowed();
const store = [...readStore()];
const idx = store.findIndex((r) => r.type === type);
if (idx < 0) return false;
@@ -159,6 +168,7 @@ export function getAdapterPluginByType(type: string): AdapterPluginRecord | unde
}
export function getAdapterPluginsDir(): string {
assertExternalAdapterCodeAllowed();
return ensureDirs();
}
@@ -32,8 +32,7 @@ describe("reconcileAdapterAvailability", () => {
]);
expect(result.enabled).toEqual(["opencode_local"]);
expect(result.disabled.sort()).toEqual(["claude_local", "pi_local"]);
expect(setAdapterDisabled).toHaveBeenCalledWith("claude_local", true);
expect(setAdapterDisabled).toHaveBeenCalledWith("opencode_local", false);
expect(setAdapterDisabled).not.toHaveBeenCalled();
});
it("throws when a declared adapter has no installed implementation", () => {
@@ -1,73 +1,8 @@
/**
* Declarative adapter-registry bootstrap.
*
* One source (`PAPERCLIP_ADAPTERS` inline JSON, or `PAPERCLIP_ADAPTERS_FILE` a
* path to a JSON file) feeds two consumers:
* 1. Availability: reconcile the file-backed disabled-set so the picker shows
* exactly the declared, enabled set (runs for any instance).
* 2. k8s runtime: the same registry rides on the Kubernetes environment config
* (see execution-policy-bootstrap) so the plugin resolves runtime
* image/envKeys/allowFqdns/probe/defaultEnv from it.
*
* Parsing is pure + fails loud on malformed/invalid config, mirroring
* execution-policy-bootstrap.
*/
import fs from "node:fs";
import { adapterRegistrySchema, type AdapterRegistryEntryParsed } from "@paperclipai/shared";
import type { AdapterRegistryEntryParsed } from "@paperclipai/shared";
import { logger } from "../middleware/logger.js";
import { listServerAdapters } from "../adapters/registry.js";
import { setAdapterDisabled } from "./adapter-plugin-store.js";
export { parseAdapterRegistryEnv, type AdapterRegistryEnv } from "./adapter-registry-config.js";
export type AdapterRegistryEnv = Record<string, string | undefined>;
/**
* Parse the declarative registry from env. Returns null when unconfigured
* (built-in defaults; local/OSS unchanged). Throws on malformed/invalid input.
*/
export function parseAdapterRegistryEnv(
env: AdapterRegistryEnv = process.env,
): AdapterRegistryEntryParsed[] | null {
const inline = env.PAPERCLIP_ADAPTERS?.trim();
const filePath = env.PAPERCLIP_ADAPTERS_FILE?.trim();
if (!inline && !filePath) return null;
let rawText: string;
if (inline) {
rawText = inline;
} else {
try {
rawText = fs.readFileSync(filePath as string, "utf-8");
} catch (err) {
throw new Error(
`PAPERCLIP_ADAPTERS_FILE could not be read at "${filePath}": ${(err as Error).message}`,
);
}
}
let parsed: unknown;
try {
parsed = JSON.parse(rawText);
} catch (err) {
throw new Error(`PAPERCLIP_ADAPTERS must be valid JSON: ${(err as Error).message}`);
}
const result = adapterRegistrySchema.safeParse(parsed);
if (!result.success) {
throw new Error(
`PAPERCLIP_ADAPTERS failed validation: ${result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ")}`,
);
}
return result.data;
}
/**
* Reconcile availability: every known server adapter NOT enabled in the declared
* registry is disabled; declared+enabled ones are enabled. Throws if a declared
* adapterType has no installed adapter (cannot offer a harness with no
* implementation). No-op when `registry` is null.
*/
export function reconcileAdapterAvailability(
registry: AdapterRegistryEntryParsed[] | null,
): { enabled: string[]; disabled: string[] } {
@@ -88,10 +23,9 @@ export function reconcileAdapterAvailability(
for (const type of knownTypes) {
const entry = declared.get(type);
const shouldEnable = entry !== undefined && entry.enabled !== false;
setAdapterDisabled(type, !shouldEnable);
(shouldEnable ? enabled : disabled).push(type);
}
logger.info({ enabled, disabled }, "reconciled adapter availability from PAPERCLIP_ADAPTERS");
logger.info({ enabled, disabled }, "validated adapter availability from PAPERCLIP_ADAPTERS");
return { enabled, disabled };
}
@@ -0,0 +1,47 @@
import fs from "node:fs";
import { adapterRegistrySchema, type AdapterRegistryEntryParsed } from "@paperclipai/shared";
export type AdapterRegistryEnv = Record<string, string | undefined>;
let cached: { inline: string | undefined; filePath: string | undefined; registry: AdapterRegistryEntryParsed[] } | undefined;
export function parseAdapterRegistryEnv(
env: AdapterRegistryEnv = process.env,
): AdapterRegistryEntryParsed[] | null {
const inline = env.PAPERCLIP_ADAPTERS?.trim();
const filePath = env.PAPERCLIP_ADAPTERS_FILE?.trim();
if (!inline && !filePath) return null;
if (cached && cached.inline === inline && cached.filePath === filePath) return structuredClone(cached.registry);
let rawText: string;
if (inline) {
rawText = inline;
} else {
try {
rawText = fs.readFileSync(filePath as string, "utf-8");
} catch (err) {
throw new Error(
`PAPERCLIP_ADAPTERS_FILE could not be read at "${filePath}": ${(err as Error).message}`,
);
}
}
let parsed: unknown;
try {
parsed = JSON.parse(rawText);
} catch (err) {
throw new Error(`PAPERCLIP_ADAPTERS must be valid JSON: ${(err as Error).message}`);
}
const result = adapterRegistrySchema.safeParse(parsed);
if (!result.success) {
throw new Error(
`PAPERCLIP_ADAPTERS failed validation: ${result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ")}`,
);
}
cached = { inline, filePath, registry: result.data };
return structuredClone(result.data);
}
@@ -0,0 +1,87 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../adapters/registry.js", () => ({ listServerAdapters: () => [{ type: "codex_local" }, { type: "claude_local" }] }));
import { adapterSettingsService, type AdapterSettingsDb } from "./adapter-settings.js";
import { getDisabledAdapterTypes, setAdapterDisabled } from "./adapter-plugin-store.js";
import { parseAdapterRegistryEnv } from "./adapter-registry-config.js";
describe("local adapter settings compatibility", () => {
let directory: string;
beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), "adapter-settings-local-"));
vi.stubEnv("PAPERCLIP_HOME", directory);
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined);
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
vi.stubEnv("PAPERCLIP_ADAPTERS", undefined);
vi.stubEnv("PAPERCLIP_ADAPTERS_FILE", undefined);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
fs.rmSync(directory, { recursive: true, force: true });
});
it("reads and writes the existing file without a database", async () => {
fs.writeFileSync(path.join(directory, "adapter-settings.json"), JSON.stringify({ disabledTypes: ["claude_local"] }));
const service = adapterSettingsService(undefined);
expect(await service.getDisabledTypes()).toEqual(["claude_local"]);
expect(await service.setDisabled("codex_local", true)).toBe(true);
expect(getDisabledAdapterTypes()).toEqual(["claude_local", "codex_local"]);
expect(setAdapterDisabled("claude_local", false)).toBe(true);
expect(await service.getDisabledTypes()).toEqual(["codex_local"]);
expect(JSON.parse(fs.readFileSync(path.join(directory, "adapter-settings.json"), "utf8"))).toEqual({ disabledTypes: ["codex_local"] });
});
it("loads an operator file once and leaves the saved file intact", async () => {
setAdapterDisabled("codex_local", true);
const file = path.join(directory, "operator-adapters.json");
fs.writeFileSync(file, JSON.stringify([{ adapterType: "codex_local", enabled: true }]));
const env = { PAPERCLIP_ADAPTERS_FILE: file };
parseAdapterRegistryEnv(env);
const read = vi.spyOn(fs, "readFileSync");
const service = adapterSettingsService(undefined, { runtimeEnv: env });
expect(await service.getDisabledTypes()).toEqual(["claude_local"]);
expect(await service.getDisabledTypes()).toEqual(["claude_local"]);
expect(read).not.toHaveBeenCalled();
expect(getDisabledAdapterTypes()).toEqual(["codex_local"]);
});
it("restores the saved choice when its audit fails and audits a later retry", async () => {
const transaction = vi.fn(async (callback: (tx: AdapterSettingsDb) => Promise<unknown>) => callback(db));
const db = { transaction } as unknown as AdapterSettingsDb;
const service = adapterSettingsService(db);
const audit = vi.fn().mockRejectedValueOnce(new Error("audit unavailable")).mockResolvedValue(undefined);
await expect(service.setDisabled("codex_local", true, audit)).rejects.toThrow("audit unavailable");
expect(await service.getDisabledTypes()).toEqual([]);
expect(JSON.parse(fs.readFileSync(path.join(directory, "adapter-settings.json"), "utf8"))).toEqual({ disabledTypes: [] });
expect(await service.setDisabled("codex_local", true, audit)).toBe(true);
expect(await service.getDisabledTypes()).toEqual(["codex_local"]);
expect(audit).toHaveBeenCalledTimes(2);
expect(transaction).toHaveBeenCalledTimes(2);
});
it("keeps a failed audit rollback from overwriting a concurrent save", async () => {
const db = { transaction: async (callback: (tx: AdapterSettingsDb) => Promise<unknown>) => callback(db) } as unknown as AdapterSettingsDb;
const first = adapterSettingsService(db);
const second = adapterSettingsService(db);
let failAudit!: (reason: Error) => void;
const auditWait = new Promise<void>((_resolve, reject) => { failAudit = reject; });
const audit = vi.fn(() => auditWait);
const rejected = expect(first.setDisabled("codex_local", true, audit)).rejects.toThrow("audit unavailable");
await vi.waitFor(() => expect(audit).toHaveBeenCalledOnce());
const nextAudit = vi.fn(async () => undefined);
const next = second.setDisabled("codex_local", true, nextAudit);
await Promise.resolve();
expect(nextAudit).not.toHaveBeenCalled();
failAudit(new Error("audit unavailable"));
await rejected;
expect(await next).toBe(true);
expect(await first.getDisabledTypes()).toEqual(["codex_local"]);
expect(nextAudit).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,228 @@
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { activityLog, applyPendingMigrations, companies, createDb, instanceSettings, type Db } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
const legacy = vi.hoisted(() => ({ get: vi.fn(() => [] as string[]), set: vi.fn() }));
vi.mock("./adapter-plugin-store.js", () => ({
getDisabledAdapterTypes: legacy.get,
setAdapterDisabled: legacy.set,
listAdapterPlugins: () => [],
getAdapterPluginByType: () => undefined,
}));
vi.mock("../adapters/registry.js", () => ({
listServerAdapters: () => [{ type: "codex_local" }, { type: "claude_local" }, { type: "paperclip_runner" }],
findServerAdapter: (type: string) => ({ type }),
findActiveServerAdapter: (type: string) => ({ type }),
isOverridePaused: () => false,
hasActiveAdapterOverride: () => false,
waitForExternalAdapters: async () => {},
}));
vi.mock("../adapters/plugin-loader.js", () => ({}));
import { adapterSettingsService, initializeAdapterSettings } from "./adapter-settings.js";
import { adapterRoutes } from "../routes/adapters.js";
import { errorHandler } from "../middleware/error-handler.js";
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
import * as activities from "./activity-log.js";
const externalDatabaseUrl = process.env.PAPERCLIP_ADAPTER_SETTINGS_TEST_DATABASE_URL;
const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("durable managed adapter settings", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
let peer: Db;
let directory: string;
const managed = { runtimeEnv: { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "synthetic-test-token" } };
const service = () => adapterSettingsService(db, managed);
beforeAll(async () => {
database = externalDatabaseUrl
? { connectionString: externalDatabaseUrl, cleanup: async () => {} }
: await startEmbeddedPostgresTestDatabase("adapter-settings-");
if (externalDatabaseUrl) await applyPendingMigrations(externalDatabaseUrl);
db = createDb(database.connectionString);
peer = createDb(database.connectionString);
await db.insert(companies).values({ name: "Adapter settings test", issuePrefix: "AST" });
}, 90_000);
afterAll(async () => {
await Promise.all([db?.$client.end({ timeout: 0 }), peer?.$client.end({ timeout: 0 })]);
await database?.cleanup();
}, 60_000);
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "adapter-settings-import-"));
await db.delete(instanceSettings).where(eq(instanceSettings.singletonKey, "adapter-settings:v1"));
legacy.get.mockClear();
legacy.set.mockClear();
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "synthetic-test-token");
});
afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
await fs.rm(directory, { recursive: true, force: true });
});
function app() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", source: "local_implicit", userId: "local-board", isInstanceAdmin: true };
next();
});
app.use(adapterRoutes({ db }));
app.use(errorHandler);
return app;
}
it("persists across independent connections and reconstructed stores without file reads", async () => {
await expect(service().getDisabledTypes()).rejects.toThrow("not been initialized");
await service().seedIfAbsent({ disabledTypes: [] });
expect(await service().setDisabled("codex_local", true)).toBe(true);
expect(await adapterSettingsService(peer, managed).getDisabledTypes()).toEqual(["codex_local"]);
await db.$client.end({ timeout: 0 });
db = createDb(database.connectionString);
expect(await service().inspectStored()).toEqual({ version: 1, revision: 1, disabledTypes: ["codex_local"] });
expect(legacy.get).not.toHaveBeenCalled();
expect(legacy.set).not.toHaveBeenCalled();
});
it("acknowledges the committed save when its live activity event fails", async () => {
await service().seedIfAbsent({ disabledTypes: [] });
const publication = vi.spyOn(activities, "publishActivity").mockImplementation(() => { throw new Error("event unavailable"); });
const response = await request(app()).patch("/adapters/codex_local").send({ disabled: true });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(publication).toHaveBeenCalled();
expect(await adapterSettingsService(peer, managed).getDisabledTypes()).toEqual(["codex_local"]);
const events = await peer.select().from(activityLog).where(eq(activityLog.action, "instance.adapter.updated"));
expect(events.some((event) => event.details?.adapterType === "codex_local")).toBe(true);
});
it("preserves simultaneous first writes and concurrent removals from other connections", async () => {
const writers = [service(), adapterSettingsService(peer, managed)];
await Promise.all(Array.from({ length: 12 }, (_, index) => writers[index % 2].setDisabled(`adapter_${index}`, true)));
expect((await service().inspectStored())?.disabledTypes).toHaveLength(12);
await Promise.all([
writers[0].setDisabled("adapter_0", false),
writers[1].setDisabled("adapter_1", false),
writers[0].setDisabled("extra", true),
]);
const stored = await service().inspectStored();
expect(stored?.revision).toBe(15);
expect(stored?.disabledTypes).not.toContain("adapter_0");
expect(stored?.disabledTypes).not.toContain("adapter_1");
expect(stored?.disabledTypes).toContain("extra");
expect(await writers[1].setDisabled("extra", true)).toBe(false);
expect((await service().inspectStored())?.revision).toBe(15);
});
it("imports legacy choices once before admission and ignores stale files after the DB record exists", async () => {
const runtimeEnv = { ...managed.runtimeEnv, PAPERCLIP_HOME: directory };
const file = path.join(directory, "adapter-settings.json");
await fs.writeFile(file, JSON.stringify({ disabledTypes: ["codex_local", "future_adapter"] }));
const results = await Promise.all([
initializeAdapterSettings(db, { runtimeEnv }),
initializeAdapterSettings(peer, { runtimeEnv }),
]);
expect(results.filter((result) => result?.seeded)).toHaveLength(1);
expect(await service().getDisabledTypes()).toEqual(["codex_local", "future_adapter"]);
await service().setDisabled("codex_local", false);
await fs.writeFile(file, "invalid stale JSON");
expect((await initializeAdapterSettings(peer, { runtimeEnv }))?.seeded).toBe(false);
expect(await service().getDisabledTypes()).toEqual(["future_adapter"]);
});
it("uses defaults only for an absent legacy file and rejects malformed or oversized input", async () => {
const runtimeEnv = { ...managed.runtimeEnv, PAPERCLIP_HOME: directory };
const file = path.join(directory, "adapter-settings.json");
await fs.writeFile(file, JSON.stringify({ disabledTypes: [42] }));
await expect(initializeAdapterSettings(db, { runtimeEnv })).rejects.toThrow("Persisted adapter settings are invalid");
expect(await service().inspectStored()).toBeNull();
await fs.writeFile(file, Buffer.alloc(65_537));
await expect(initializeAdapterSettings(db, { runtimeEnv })).rejects.toThrow("size limit");
expect(await service().inspectStored()).toBeNull();
await fs.unlink(file);
expect((await initializeAdapterSettings(db, { runtimeEnv }))?.current.disabledTypes).toEqual([]);
expect(await service().getDisabledTypes()).toEqual([]);
});
it("composes atomic import and receipts with the caller transaction", async () => {
await expect(db.transaction(async (tx) => {
expect(await service().seedIfAbsent({ disabledTypes: ["codex_local"] }, tx)).toEqual({
seeded: true, current: { version: 1, revision: 0, disabledTypes: ["codex_local"] },
});
throw new Error("receipt failed");
})).rejects.toThrow("receipt failed");
expect(await service().inspectStored()).toBeNull();
await Promise.all([
service().seedIfAbsent({ disabledTypes: ["codex_local"] }),
adapterSettingsService(peer, managed).seedIfAbsent({ disabledTypes: ["claude_local"] }),
]).then((results) => {
expect(results.filter((result) => result.seeded)).toHaveLength(1);
expect(results[0].current).toEqual(results[1].current);
});
});
it("applies operator restrictions without rewriting saved choices", async () => {
await service().setDisabled("codex_local", true);
const env = { ...managed.runtimeEnv, PAPERCLIP_ADAPTERS: JSON.stringify([{ adapterType: "codex_local", enabled: true }]) };
const overlay = adapterSettingsService(peer, { runtimeEnv: env });
expect(await overlay.getDisabledTypes()).toEqual(["claude_local", "paperclip_runner"]);
expect(await overlay.inspectStored()).toEqual(await service().inspectStored());
await expect(overlay.setDisabled("claude_local", false)).rejects.toThrow("hosting operator");
await expect(overlay.setDisabled("codex_local", true)).rejects.toThrow("hosting operator");
expect(await overlay.getDisabledTypes()).toEqual(["claude_local", "paperclip_runner"]);
expect(await service().getDisabledTypes()).toEqual(["codex_local"]);
});
it("refreshes the disabled harness at each new-agent admission", async () => {
await service().seedIfAbsent({ disabledTypes: [] });
const input = { adapterType: "codex_local", runner: "legacy" as const };
expect((await resolveNewAgentRunnerForCompany(db, "company", input)).adapterType).toBe("codex_local");
await adapterSettingsService(peer, managed).setDisabled("codex_local", true);
await expect(resolveNewAgentRunnerForCompany(db, "company", input)).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } });
await adapterSettingsService(peer, managed).setDisabled("codex_local", false);
expect((await resolveNewAgentRunnerForCompany(db, "company", input)).adapterType).toBe("codex_local");
});
it("rolls back the setting when its audit write fails", async () => {
await expect(service().setDisabled("codex_local", true, async () => { throw new Error("audit failed"); })).rejects.toThrow("audit failed");
expect(await service().inspectStored()).toBeNull();
await expect(adapterSettingsService(peer, managed).getDisabledTypes()).rejects.toThrow("not been initialized");
});
it("publishes catalog changes across stores and records the instance mutation", async () => {
const res = await request(app()).patch("/adapters/codex_local").send({ disabled: true });
expect(res.status).toBe(200);
expect(res.body).toEqual({ type: "codex_local", disabled: true, changed: true });
const catalog = await request(app()).get("/adapters");
expect(catalog.status).toBe(200);
expect(catalog.body.find((entry: { type: string }) => entry.type === "codex_local").disabled).toBe(true);
const [audit] = await peer.select().from(activityLog).where(eq(activityLog.action, "instance.adapter.updated"));
expect(audit.details).toMatchObject({ adapterType: "codex_local", disabled: true, revision: 1 });
});
it("returns failure when PostgreSQL rejects the update and keeps the durable choice", async () => {
await service().seedIfAbsent({ disabledTypes: [] });
await db.execute(sql`CREATE FUNCTION reject_adapter_settings() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'test adapter settings failure'; END $$`);
await db.execute(sql`CREATE TRIGGER reject_adapter_settings BEFORE UPDATE ON instance_settings FOR EACH ROW EXECUTE FUNCTION reject_adapter_settings()`);
try {
const res = await request(app()).patch("/adapters/codex_local").send({ disabled: true });
expect(res.status).toBe(500);
expect(await adapterSettingsService(peer, managed).getDisabledTypes()).toEqual([]);
} finally {
await db.execute(sql`DROP TRIGGER reject_adapter_settings ON instance_settings`);
await db.execute(sql`DROP FUNCTION reject_adapter_settings()`);
}
});
it("fails closed on malformed stored settings and unavailable database", async () => {
await db.insert(instanceSettings).values({ singletonKey: "adapter-settings:v1", general: { version: 2, disabledTypes: [] } });
await expect(service().getDisabledTypes()).rejects.toThrow("Persisted adapter settings are invalid");
await expect(adapterSettingsService(undefined, managed).getDisabledTypes()).rejects.toThrow("database is required");
expect(legacy.get).not.toHaveBeenCalled();
});
});
+149
View File
@@ -0,0 +1,149 @@
import type { Db } from "@paperclipai/db";
import { instanceSettings } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { open } from "node:fs/promises";
import { constants } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { listServerAdapters } from "../adapters/registry.js";
import { forbidden } from "../errors.js";
import { resolvePaperclipHomeDir } from "../home-paths.js";
import { getDisabledAdapterTypes, setAdapterDisabled } from "./adapter-plugin-store.js";
import { parseAdapterRegistryEnv } from "./adapter-registry-config.js";
import { isCloudManagedInstance } from "./cloud-instance.js";
const SINGLETON_KEY = "adapter-settings:v1";
let localSettingsMutation: Promise<unknown> = Promise.resolve();
type Transaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
export type AdapterSettingsDb = Pick<Db | Transaction, "select" | "insert" | "update" | "transaction">;
export type StoredAdapterSettings = { version: 1; revision: number; disabledTypes: string[] };
const storedSettingsSchema = z.object({
version: z.literal(1),
revision: z.number().int().nonnegative(),
disabledTypes: z.array(z.string().refine((type) => type.trim().length > 0)),
});
function parseStored(value: Record<string, unknown>): StoredAdapterSettings {
const parsed = storedSettingsSchema.safeParse(value);
if (!parsed.success) throw new Error("Persisted adapter settings are invalid");
return { ...parsed.data, disabledTypes: [...new Set(parsed.data.disabledTypes)].sort() };
}
export function effectiveDisabledAdapterTypes(saved: string[], knownTypes: string[] | (() => string[]), env: Record<string, string | undefined> = process.env): string[] {
const registry = parseAdapterRegistryEnv(env);
if (!registry) return [...saved];
const enabled = new Set(registry.filter((entry) => entry.enabled !== false).map((entry) => entry.adapterType));
const types = typeof knownTypes === "function" ? knownTypes() : knownTypes;
return [...new Set(types.filter((type) => !enabled.has(type)))].sort();
}
export function adapterSettingsService(db: AdapterSettingsDb | undefined, options: { runtimeEnv?: Record<string, string | undefined> } = {}) {
const env = options.runtimeEnv ?? process.env;
const managed = isCloudManagedInstance(env);
function requireDb(): AdapterSettingsDb {
if (!db) throw new Error("A database is required for managed adapter settings");
return db;
}
async function inspectStored(handle: AdapterSettingsDb = requireDb()): Promise<StoredAdapterSettings | null> {
const [row] = await handle.select({ general: instanceSettings.general }).from(instanceSettings)
.where(eq(instanceSettings.singletonKey, SINGLETON_KEY)).limit(1);
return row ? parseStored(row.general) : null;
}
async function seedIfAbsent(value: { disabledTypes: string[] }, handle: AdapterSettingsDb = requireDb()) {
const snapshot = parseStored({ version: 1, revision: 0, disabledTypes: value.disabledTypes });
const inserted = await handle.insert(instanceSettings).values({ singletonKey: SINGLETON_KEY, general: snapshot })
.onConflictDoNothing({ target: instanceSettings.singletonKey }).returning({ id: instanceSettings.id });
const current = await inspectStored(handle);
if (!current) throw new Error("Adapter settings seed was not persisted");
return { seeded: inserted.length > 0, current };
}
return {
inspectStored,
seedIfAbsent,
async getDisabledTypes(): Promise<string[]> {
const current = managed ? await inspectStored() : null;
if (managed && !current) throw new Error("Managed adapter settings have not been initialized");
const stored = managed ? current!.disabledTypes : getDisabledAdapterTypes();
return effectiveDisabledAdapterTypes(stored, () => listServerAdapters().map((adapter) => adapter.type), env);
},
async setDisabled(type: string, disabled: boolean, afterChange?: (handle: AdapterSettingsDb, current: StoredAdapterSettings) => Promise<void>): Promise<boolean> {
if (!type.trim()) throw new Error("Adapter type is required");
const registry = parseAdapterRegistryEnv(env);
if (registry && disabled === registry.some((entry) => entry.adapterType === type && entry.enabled !== false)) {
throw forbidden("Adapter availability is managed by the hosting operator", { code: "settings_operator_managed" });
}
if (!managed) {
const mutation = localSettingsMutation.then(async () => {
const previous = getDisabledAdapterTypes().includes(type);
const changed = setAdapterDisabled(type, disabled);
if (!changed || !db || !afterChange) return changed;
try {
await db.transaction(async (tx) => {
await afterChange(tx, { version: 1, revision: 0, disabledTypes: getDisabledAdapterTypes() });
});
} catch (error) {
setAdapterDisabled(type, previous);
throw error;
}
return true;
});
localSettingsMutation = mutation.catch(() => undefined);
return mutation;
}
return requireDb().transaction(async (tx) => {
await seedIfAbsent({ disabledTypes: [] }, tx);
const [row] = await tx.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, SINGLETON_KEY)).for("update");
if (!row) throw new Error("Adapter settings are unavailable");
const current = parseStored(row.general);
const types = new Set(current.disabledTypes);
if (types.has(type) === disabled) return false;
if (disabled) types.add(type); else types.delete(type);
const next: StoredAdapterSettings = { version: 1, revision: current.revision + 1, disabledTypes: [...types].sort() };
await tx.update(instanceSettings).set({ general: next, updatedAt: new Date() }).where(eq(instanceSettings.id, row.id));
await afterChange?.(tx, next);
return true;
});
},
};
}
export type AdapterSettingsService = Pick<ReturnType<typeof adapterSettingsService>, "getDisabledTypes" | "setDisabled">;
export async function initializeAdapterSettings(db: AdapterSettingsDb, options: { runtimeEnv?: Record<string, string | undefined> } = {}) {
const env = options.runtimeEnv ?? process.env;
if (!isCloudManagedInstance(env)) return null;
const service = adapterSettingsService(db, options);
const current = await service.inspectStored();
if (current) return { seeded: false, current };
return service.seedIfAbsent(await readLegacyAdapterSettings(env) ?? { disabledTypes: [] });
}
export async function readLegacyAdapterSettings(env: Record<string, string | undefined> = process.env): Promise<{ disabledTypes: string[] } | null> {
const filePath = path.join(resolvePaperclipHomeDir(env.PAPERCLIP_HOME), "adapter-settings.json");
try {
const file = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > 65_536) throw new Error("Legacy adapter settings exceed the size limit or are not a regular file");
const buffer = Buffer.alloc(65_537);
let total = 0;
while (total < buffer.length) {
const { bytesRead } = await file.read(buffer, total, buffer.length - total, total);
if (bytesRead === 0) break;
total += bytesRead;
}
if (total > 65_536) throw new Error("Legacy adapter settings exceed the size limit");
const value = JSON.parse(buffer.subarray(0, total).toString("utf8"));
return { disabledTypes: parseStored({ version: 1, revision: 0, disabledTypes: value?.disabledTypes }).disabledTypes };
} finally {
await file.close();
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
return null;
}
}
@@ -3,6 +3,10 @@ import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperc
const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set<string>(), settings: {} as Record<string, unknown>, getEnvironment: vi.fn(), bindings: vi.fn(), activeTemplate: vi.fn().mockResolvedValue(null), secretPaths: vi.fn().mockResolvedValue(new Set(["apiKey"])), managedEnvironment: vi.fn(), kubernetesEnvironment: vi.fn(), ensureKubernetesEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn(), pluginByKey: vi.fn() }));
vi.mock("../adapters/registry.js", () => ({ findActiveServerAdapter: (type: string) => ({ type }), hasActiveAdapterOverride: (type: string) => state.overridden.has(type), waitForExternalAdapters: async () => {} }));
vi.mock("./adapter-plugin-store.js", () => ({ getDisabledAdapterTypes: () => state.disabled }));
vi.mock("./adapter-settings.js", async (original) => ({
...await original(),
adapterSettingsService: () => ({ getDisabledTypes: async () => state.disabled }),
}));
vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => state.settings }) }));
vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.kubernetesEnvironment, ensureKubernetesEnvironment: state.ensureKubernetesEnvironment }) }));
vi.mock("./environment-custom-images.js", () => ({ environmentCustomImageService: () => ({ getActiveTemplate: state.activeTemplate }) }));
@@ -351,7 +355,7 @@ describe("server-owned Codex runner selection", () => {
state.settings = { general: { executionMode: "kubernetes" } };
const selected = await resolveAgentRunnerTargetForCompany({} as never, "company");
expect(selected).toEqual({ driver: "sandbox" });
expect(agentRunnerAvailability("codex_local", selected)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" });
expect(agentRunnerAvailability("codex_local", selected, state.disabled)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" });
expect(state.kubernetesEnvironment).toHaveBeenCalledWith("company");
expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled();
expect(state.resolveEnvironment).not.toHaveBeenCalled();
@@ -477,14 +481,14 @@ describe("server-owned Codex runner selection", () => {
config: { provider: "daytona", snapshot: "replacement-snapshot" }, metadata: { managedByPaperclip: true } });
state.resolveEnvironment.mockRejectedValue(new Error("Saved provider secret is unavailable."));
const selected = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox");
expect(agentRunnerAvailability(adapterType, selected).defaultRunner).toBe("paperclip");
expect(agentRunnerAvailability(adapterType, selected, state.disabled).defaultRunner).toBe("paperclip");
expect(await resolveNewAgentRunnerForCompany({} as never, "company", {
adapterType, defaultEnvironmentId: "sandbox", adapterConfig: { model: adapterType === "opencode_local" ? "provider/model" : undefined },
})).toMatchObject({ adapterType: "paperclip_runner" });
expect(state.sandboxQualification).not.toHaveBeenCalled();
expect(state.resolveEnvironment).not.toHaveBeenCalled();
state.disabled.push("paperclip_runner");
expect(agentRunnerAvailability(adapterType, selected).defaultRunner).toBe("legacy");
expect(agentRunnerAvailability(adapterType, selected, state.disabled).defaultRunner).toBe("legacy");
});
it.each(["self-hosted", "custom-environment", "captured-template", "stale-marker"])("keeps qualification for a %s sandbox", async kind => {
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", kind === "self-hosted" ? undefined : JSON.stringify({ ...managedCloudConfig,
@@ -495,7 +499,7 @@ describe("server-owned Codex runner selection", () => {
metadata: { managedByPaperclip: kind !== "custom-environment" } });
state.activeTemplate.mockResolvedValue(kind === "captured-template" ? { id: "template", provider: "daytona", templateRef: "custom-snapshot" } : null);
const selected = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox");
expect(agentRunnerAvailability("codex_local", selected).defaultRunner).toBe("legacy");
expect(agentRunnerAvailability("codex_local", selected, state.disabled).defaultRunner).toBe("legacy");
expect(state.sandboxQualification).toHaveBeenCalled();
});
it("ignores a stale captured image and preserves a matching custom image without resolving credentials", async () => {
@@ -507,17 +511,17 @@ describe("server-owned Codex runner selection", () => {
sourceEnvironmentConfigFingerprint: fingerprint({ ...config, snapshot: "previous-snapshot" }, { excludePaths: ["apiKey"] }) };
state.activeTemplate.mockResolvedValue(template);
const current = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox");
expect(agentRunnerAvailability("codex_local", current).defaultRunner).toBe("paperclip");
expect(agentRunnerAvailability("codex_local", current, state.disabled).defaultRunner).toBe("paperclip");
expect(await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" }))
.toMatchObject({ adapterType: "paperclip_runner" });
expect(state.sandboxQualification).not.toHaveBeenCalled();
state.activeTemplate.mockResolvedValue({ ...template, sourceEnvironmentConfigFingerprint: fingerprint(config, { excludePaths: ["apiKey"] }) });
const custom = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox");
expect(agentRunnerAvailability("codex_local", custom).defaultRunner).toBe("legacy");
expect(agentRunnerAvailability("codex_local", custom, state.disabled).defaultRunner).toBe("legacy");
expect(state.sandboxQualification).toHaveBeenCalled();
state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { ...config, timeoutMs: "invalid" }, metadata: { managedByPaperclip: true } });
const invalid = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox");
expect(agentRunnerAvailability("codex_local", invalid).defaultRunner).toBe("legacy");
expect(agentRunnerAvailability("codex_local", invalid, state.disabled).defaultRunner).toBe("legacy");
expect(state.resolveEnvironment).not.toHaveBeenCalled();
});
it("checks company access before trusting a managed Cloud image", async () => {
+23 -10
View File
@@ -5,6 +5,9 @@ import { resolveClaudeModel } from "@paperclipai/adapter-claude-local";
import { DEFAULT_GROK_LOCAL_MODEL } from "@paperclipai/adapter-grok-local";
import { findActiveServerAdapter, hasActiveAdapterOverride, waitForExternalAdapters } from "../adapters/registry.js";
import { getDisabledAdapterTypes } from "./adapter-plugin-store.js";
import { adapterSettingsService, effectiveDisabledAdapterTypes } from "./adapter-settings.js";
import { listServerAdapters } from "../adapters/registry.js";
import { isCloudManagedInstance } from "./cloud-instance.js";
import { claudeRunnerUnsupportedSettings, PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
import { forbidden, unprocessable } from "../errors.js";
import { runSshCommand } from "@paperclipai/adapter-utils/ssh";
@@ -24,6 +27,7 @@ export type RunnerTarget = {
export const AGENT_RUNNER_SSH_PLATFORM_COMMAND = "uname -s; uname -m; if paperclip_glibc_version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$paperclip_glibc_version\" ]; then printf 'glibc\\n%s\\n' \"$paperclip_glibc_version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi";
export interface AgentRunnerSelection {
adapterType?: string | null;
disabledTypes?: string[];
adapterConfig?: Record<string, unknown>;
runner?: AgentRunnerChoice;
target?: RunnerTarget;
@@ -32,8 +36,13 @@ export interface AgentRunnerSelection {
onConfigurationWarnings?: (warnings: AgentConfigurationWarning[]) => void;
}
function runnerAdaptersAvailable(harness: string, driver: string): boolean {
const disabled = new Set(getDisabledAdapterTypes());
function localDisabledTypes(): string[] {
if (isCloudManagedInstance()) throw new Error("Managed runner selection requires a fresh adapter settings snapshot");
return effectiveDisabledAdapterTypes(getDisabledAdapterTypes(), () => listServerAdapters().map((adapter) => adapter.type));
}
function runnerAdaptersAvailable(harness: string, driver: string, disabledTypes: string[]): boolean {
const disabled = new Set(disabledTypes);
return !!paperclipRunnerProfileForHarness(harness)
&& !disabled.has(harness) && !disabled.has("paperclip_runner")
&& !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner")
@@ -41,13 +50,13 @@ function runnerAdaptersAvailable(harness: string, driver: string): boolean {
&& isEnvironmentDriverSupportedForAdapter("paperclip_runner", driver);
}
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability {
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget(), disabledTypes: string[] = localDisabledTypes()): AgentRunnerAvailability {
if (target.runnerTasks || target.managedCloudImage) {
return runnerAdaptersAvailable(harness, target.driver)
return runnerAdaptersAvailable(harness, target.driver, disabledTypes)
? { supportedRunners: target.runnerTasks ? ["paperclip"] : ["paperclip", "legacy"], defaultRunner: "paperclip" }
: { supportedRunners: ["legacy"], defaultRunner: "legacy" };
}
const supported = runnerAdaptersAvailable(harness, target.driver)
const supported = runnerAdaptersAvailable(harness, target.driver, disabledTypes)
&& (target.driver !== "sandbox" || Boolean(target.platform && target.architecture
&& (target.platform !== "linux" || (target.libc === "glibc" && target.glibcVersion))))
&& (["claude_local", "grok_local"].includes(harness)
@@ -166,11 +175,12 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
function resolveAgentRunner(input: AgentRunnerSelection): { adapterType: string; adapterConfig: Record<string, unknown> } {
const adapterType = input.adapterType ?? "process";
let config = { ...input.adapterConfig };
const disabledTypes = input.disabledTypes ?? localDisabledTypes();
const harness = agentHarnessType(adapterType, config);
const native = adapterType === "paperclip_runner";
if (paperclipRunnerProfileForHarness(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "grok_local" ? "Grok Build" : adapterType === "pi_local" ? "Pi" : adapterType === "opencode_local" ? "OpenCode" : adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" });
if (paperclipRunnerProfileForHarness(adapterType) && disabledTypes.includes(adapterType)) throw unprocessable(`${adapterType === "grok_local" ? "Grok Build" : adapterType === "pi_local" ? "Pi" : adapterType === "opencode_local" ? "OpenCode" : adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" });
if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
const availability = agentRunnerAvailability(harness, input.target);
const availability = agentRunnerAvailability(harness, input.target, disabledTypes);
const choice = input.runner ?? "auto";
const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip");
if (!useNative) {
@@ -318,13 +328,14 @@ function resolveAgentRunner(input: AgentRunnerSelection): { adapterType: string;
}
export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) {
input = { ...input, disabledTypes: await adapterSettingsService(db).getDisabledTypes() };
if (!paperclipRunnerProfileForHarness(agentHarnessType(input.adapterType ?? "process", input.adapterConfig))
|| input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input);
const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId);
const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId, input.disabledTypes);
return resolveNewAgentRunner({ ...input, target });
}
export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null): Promise<RunnerTarget> {
export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null, disabledTypes?: string[]): Promise<RunnerTarget> {
await waitForExternalAdapters();
const { environment, driver } = await resolveAgentRunnerEnvironmentForCompany(db, companyId, defaultEnvironmentId);
let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget()
@@ -356,7 +367,9 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
const qualified = await readSandboxPlatformQualification(db, environment, companyId);
if (qualified) target = { driver, ...qualified };
}
if (driver === "ssh" && environment && ["codex_local", "claude_local", "opencode_local", "pi_local", "grok_local"].some(harness => runnerAdaptersAvailable(harness, driver))) {
const disabledForSsh = driver === "ssh" && environment
? disabledTypes ?? await adapterSettingsService(db).getDisabledTypes() : [];
if (driver === "ssh" && environment && ["codex_local", "claude_local", "opencode_local", "pi_local", "grok_local"].some(harness => runnerAdaptersAvailable(harness, driver, disabledForSsh))) {
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable.");
let output: string;
@@ -0,0 +1,108 @@
import fs from "node:fs";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import type { PaperclipPluginManifestV1, PluginRecord } from "@paperclipai/shared";
import type { ResolvedBundledPlugin } from "./bundled-plugins.js";
import { assertDistributionManifestCapabilities } from "./distribution-plugin-catalog.js";
export type BundledPluginRegistration = Pick<PluginRecord, "pluginKey" | "packageName" | "version" | "manifestJson">;
export interface BundledPluginPackageIdentity {
kind: "bundled_image";
catalogKey: string;
pluginKey: string;
packageName: string;
version: string;
digest?: string;
}
export type BundledPluginPackageInspection =
| { compatible: true; identity: BundledPluginPackageIdentity }
| { compatible: false; pluginKey: string; code: "incompatible_bundled_package" | "missing_manifest_evidence"; reason: string };
function containedEntrypoint(root: string, declared: unknown, directory: boolean): string {
const relative = typeof declared === "string" ? declared.replace(/^\.\//, "").replace(/\/$/, "") : "";
if (!relative || path.isAbsolute(relative) || path.win32.isAbsolute(relative) || relative.includes("\\") || relative.split("/").length > 32 || relative.split("/").some((part) => !part || part === "." || part === "..")) {
throw new Error("Bundled plugin entrypoint must stay inside its package");
}
const target = fs.realpathSync(path.join(root, relative));
const resolvedRelative = path.relative(root, target);
if (!resolvedRelative || resolvedRelative.startsWith("..") || path.isAbsolute(resolvedRelative)) {
throw new Error("Bundled plugin entrypoint escapes its package");
}
const stat = fs.statSync(target);
if (directory ? !stat.isDirectory() : !stat.isFile()) throw new Error("Bundled plugin entrypoint is not prebuilt");
return relative;
}
export function createBundledPluginPackageResolver(installs: readonly ResolvedBundledPlugin[]) {
const byKey = new Map(installs.map((install) => [install.pluginKey, install]));
function read(install: ResolvedBundledPlugin) {
const root = fs.realpathSync(install.localPath);
if (root !== path.resolve(install.localPath)) throw new Error("Bundled plugin package root changed after catalog resolution");
containedEntrypoint(root, "package.json", false);
const metadataPath = path.join(root, "package.json");
if (fs.statSync(metadataPath).size > 128 * 1024) throw new Error("Bundled plugin package metadata exceeds verification limits");
const pkg = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
if (typeof pkg.name !== "string" || typeof pkg.version !== "string" || !pkg.paperclipPlugin) {
throw new Error("Bundled plugin package identity or entrypoints are missing");
}
if (install.distribution && pkg.version !== install.distribution.version) throw new Error("Bundled plugin package version does not match the catalog");
const manifest = containedEntrypoint(root, pkg.paperclipPlugin.manifest, false);
const worker = containedEntrypoint(root, pkg.paperclipPlugin.worker, false);
const ui = pkg.paperclipPlugin.ui === undefined ? undefined : containedEntrypoint(root, pkg.paperclipPlugin.ui, true);
if (install.distribution && (worker !== install.distribution.entrypoints.worker || ui !== install.distribution.entrypoints.ui)) {
throw new Error("Bundled plugin entrypoints do not match the verified package");
}
return { root, pkg, manifest, worker, ui };
}
function assertManifest(install: ResolvedBundledPlugin, manifest: PaperclipPluginManifestV1, previousManifest?: PaperclipPluginManifestV1) {
const bundle = read(install);
if (manifest.id !== install.pluginKey || manifest.version !== bundle.pkg.version) throw new Error("Bundled plugin manifest identity/version does not match the image");
const worker = containedEntrypoint(bundle.root, manifest.entrypoints.worker, false);
const ui = manifest.entrypoints.ui === undefined ? undefined : containedEntrypoint(bundle.root, manifest.entrypoints.ui, true);
if (worker !== bundle.worker || ui !== bundle.ui) throw new Error("Bundled plugin manifest entrypoints do not match the image");
assertDistributionManifestCapabilities(manifest);
if (previousManifest) {
const approved = new Set(previousManifest.capabilities);
if (manifest.capabilities.some((capability) => !approved.has(capability))) throw new Error("Bundled plugin capabilities require approval");
}
return bundle;
}
function resolve(plugin: BundledPluginRegistration): string {
const install = byKey.get(plugin.pluginKey);
if (!install) throw new Error(`Bundled plugin is absent or not selected in this deployment: ${plugin.pluginKey}`);
const bundle = assertManifest(install, plugin.manifestJson);
if (plugin.packageName !== bundle.pkg.name || plugin.version !== bundle.pkg.version) throw new Error("Bundled plugin registration identity/version does not match the image");
return bundle.root;
}
return {
resolve,
inspect(plugin: BundledPluginRegistration, verifiedManifest?: PaperclipPluginManifestV1): BundledPluginPackageInspection {
try {
resolve(plugin);
const install = byKey.get(plugin.pluginKey)!;
if (!verifiedManifest) return { compatible: false, pluginKey: plugin.pluginKey, code: "missing_manifest_evidence", reason: "Image manifest evidence is required" };
assertManifest(install, verifiedManifest);
if (!isDeepStrictEqual(verifiedManifest, plugin.manifestJson)) throw new Error("Approved plugin manifest does not match the image");
return { compatible: true, identity: {
kind: "bundled_image", catalogKey: install.key, pluginKey: plugin.pluginKey,
packageName: plugin.packageName, version: plugin.version,
...(install.distribution ? { digest: install.distribution.digest } : {}),
} };
} catch (error) {
return { compatible: false, pluginKey: plugin.pluginKey, code: "incompatible_bundled_package", reason: error instanceof Error ? error.message : String(error) };
}
},
assertActivation(input: { pluginKey?: string; packageRoot: string; manifest?: PaperclipPluginManifestV1; previousManifest?: PaperclipPluginManifestV1 }) {
const install = input.pluginKey ? byKey.get(input.pluginKey) : installs.find((candidate) => candidate.localPath === input.packageRoot);
if (!install || path.resolve(input.packageRoot) !== install.localPath) throw new Error("Bundled plugin is absent or not selected in this deployment");
read(install);
if (input.manifest) assertManifest(install, input.manifest, input.previousManifest);
},
};
}
@@ -0,0 +1,37 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export function resolveBundledPluginRuntimeLoader(
moduleDirectory = path.dirname(fileURLToPath(import.meta.url)),
): string {
let directory = fs.realpathSync(moduleDirectory);
let imageRoot: string | undefined;
for (let depth = 0; depth < 5; depth++) {
const metadataPath = path.join(directory, "package.json");
const metadata = fs.lstatSync(metadataPath, { throwIfNoEntry: false });
if (metadata?.isFile() && metadata.size <= 128 * 1024
&& JSON.parse(fs.readFileSync(metadataPath, "utf8")).name === "@paperclipai/server") {
imageRoot = path.dirname(directory);
break;
}
const parent = path.dirname(directory);
if (parent === directory) break;
directory = parent;
}
if (!imageRoot) throw new Error("Managed plugin server package is missing from the current image");
const declared = path.join(imageRoot, "cli/node_modules/tsx/dist/loader.mjs");
let loader: string;
try {
loader = fs.realpathSync(declared);
if (!fs.statSync(loader).isFile()) throw new Error("not a file");
} catch {
throw new Error("Managed plugin runtime loader is missing from the current image");
}
const roots = [path.join(imageRoot, "cli"), path.join(imageRoot, "node_modules")];
if (!roots.some((root) => {
const relative = path.relative(root, loader);
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
})) throw new Error("Managed plugin runtime loader escapes the current image");
return loader;
}
+13 -4
View File
@@ -1,6 +1,7 @@
import path from "node:path";
import fs from "node:fs";
import { isDeepStrictEqual } from "node:util";
import { isCloudManagedInstance } from "./cloud-instance.js";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { assertDistributionManifestCapabilities, readDistributionPluginCatalog, type DistributionPlugin } from "./distribution-plugin-catalog.js";
@@ -118,6 +119,13 @@ export const BUNDLED_PLUGIN_CATALOG: readonly BundledPluginCatalogEntry[] = [
*/
export const SELF_HOSTED_AUTO_INSTALL_KEYS: readonly string[] = ["kubernetes"];
export function resolveManagedBundledPluginKeys(
keys: readonly string[] | null | undefined,
env: Record<string, string | undefined> = process.env,
): readonly string[] | null {
return keys ?? (isCloudManagedInstance(env) ? [] : null);
}
export function resolveBundledCatalogRoot(
env: Record<string, string | undefined>,
): string {
@@ -203,7 +211,7 @@ export function resolveBundledPluginInstalls(
`bundled plugin "${key}" resolves to "${localPath}", outside the bundled catalog root "${opts.catalogRoot}"; refusing to start`,
);
}
resolved.push({ key: entry.key, pluginKey: entry.pluginKey, localPath });
resolved.push({ key: entry.key, pluginKey: entry.pluginKey, localPath: opts.enforceCatalogRoot ? canonicalize(localPath) : localPath });
}
return resolved;
}
@@ -269,6 +277,7 @@ async function reconcileBundledPluginManifest(
deps: BundledPluginProvisionerDeps,
bundleManifestExists: (localPath: string) => boolean,
verifiedManifest?: PaperclipPluginManifestV1,
preservePackagePath = false,
): Promise<"upgrade_pending" | undefined> {
try {
// Managed reinstalls take the install path instead; this branch means the
@@ -283,7 +292,7 @@ async function reconcileBundledPluginManifest(
const approved = new Set(existing.manifestJson.capabilities ?? []);
requiresApproval = bundleManifest.capabilities.some((capability) => !approved.has(capability));
}
const rebindPackage = install.distribution && existing.packagePath !== install.localPath && existing.status !== "uninstalled";
const rebindPackage = !preservePackagePath && install.distribution && existing.packagePath !== install.localPath && existing.status !== "uninstalled";
const refreshDistribution = install.distribution && !isDeepStrictEqual(bundleManifest, existing.manifestJson);
if (bundleManifest.version === existing.version && !rebindPackage && !requiresApproval && !refreshDistribution) return;
await deps.registry.update(existing.id, {
@@ -389,7 +398,7 @@ async function reenableErroredBundledPlugin(
export async function ensureBundledPlugins(
installs: readonly ResolvedBundledPlugin[],
deps: BundledPluginProvisionerDeps,
opts: { reinstallUninstalled: boolean },
opts: { reinstallUninstalled: boolean; preservePackagePath?: boolean },
): Promise<void> {
const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists;
for (const install of installs) {
@@ -411,7 +420,7 @@ export async function ensureBundledPlugins(
// existing install, because the auto-install below skips a present
// plugin. Distribution entries also replace a legacy/npm package path
// before loadAll resolves the worker. Configuration and status stay put.
const reconciledStatus = await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists, verifiedManifest);
const reconciledStatus = await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists, verifiedManifest, opts.preservePackagePath);
if (reconciledStatus === "upgrade_pending") continue;
if (existing.status === "error") {
await reenableErroredBundledPlugin(existing, install, deps);
@@ -0,0 +1,95 @@
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { cloudConfigurationDigest } from "./cloud-configuration-evidence.js";
export function cloudBundledPackageDigest(packageRoot: string, options: { catalogRoot?: string } = {}): string {
const root = fs.realpathSync(packageRoot);
const roots = [{ key: "package", directory: root }];
let imageNodeModules: string | undefined;
if (options.catalogRoot) {
const catalogRoot = fs.realpathSync(options.catalogRoot);
const relative = path.relative(catalogRoot, root);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Image package is outside the selected catalog root");
imageNodeModules = path.resolve(catalogRoot, "../../node_modules");
roots.push(
{ key: "plugin-sdk", directory: path.join(catalogRoot, "sdk") },
{ key: "shared", directory: path.resolve(catalogRoot, "../shared") },
{ key: "image-node-modules", directory: imageNodeModules },
);
}
roots.sort((left, right) => right.directory.length - left.directory.length);
const inventory = new Map<string, [string, string, string, number]>();
const directories = new Set<string>();
const files = new Set<string>();
const buffer = Buffer.alloc(65_536);
let bytes = 0;
let entries = 0;
function logical(file: string): string {
for (const candidate of roots) {
const relative = path.relative(candidate.directory, file);
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
if (relative.split(path.sep).length > 32) throw new Error("Image package exceeds the directory depth limit");
return `${candidate.key}/${relative.split(path.sep).join("/")}`;
}
}
throw new Error(options.catalogRoot ? "Image dependency escapes its authorized image roots" : "Image package symlink escapes its package");
}
function follow(target: string) {
logical(target);
if (imageNodeModules) {
const relative = path.relative(imageNodeModules, target).split(path.sep);
if ((relative.length === 1 && relative[0] === "")
|| (relative[0] === ".pnpm" && (relative.length === 1 || (relative.length === 2 && relative[1] === "node_modules")))) {
throw new Error("Image dependency must select an individual package");
}
if (relative[0] === ".pnpm" && relative.length > 2 && relative[1] && relative[1] !== "node_modules") {
walk(path.join(imageNodeModules, ".pnpm", relative[1]));
return;
}
}
walk(target);
}
function walk(file: string) {
const name = logical(file);
const stat = fs.lstatSync(file);
if (stat.isSymbolicLink()) {
const target = fs.realpathSync(file);
inventory.set(name, [name, "link", logical(target), stat.mode & 0o777]);
follow(target);
} else if (stat.isDirectory()) {
const real = fs.realpathSync(file);
logical(real);
if (directories.has(real)) return;
directories.add(real);
for (const child of fs.readdirSync(real).sort()) {
if (++entries > 50_000) throw new Error("Image package exceeds the entry limit");
walk(path.join(real, child));
}
} else if (stat.isFile()) {
const real = fs.realpathSync(file);
if (files.has(real)) return;
files.add(real);
if (path.basename(file) === "package.json" && stat.size > 128 * 1024) throw new Error("Image package metadata exceeds the byte limit");
if (bytes + stat.size > 256 * 1024 * 1024) throw new Error("Image package exceeds the byte limit");
const handle = fs.openSync(file, "r");
const hash = createHash("sha256");
try {
while (true) {
const count = fs.readSync(handle, buffer);
if (!count) break;
bytes += count;
if (bytes > 256 * 1024 * 1024) throw new Error("Image package exceeds the byte limit");
hash.update(buffer.subarray(0, count));
}
} finally { fs.closeSync(handle); }
inventory.set(name, [name, "file", `sha256:${hash.digest("hex")}`, stat.mode & 0o777]);
} else throw new Error("Image package contains a special file");
}
walk(root);
if (!inventory.size) throw new Error("Image package is empty");
return cloudConfigurationDigest([...inventory.values()].sort((left, right) => left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0));
}
@@ -0,0 +1,74 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { open } from "node:fs/promises";
import { z } from "zod";
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, child]) => [key, canonical(child)]));
}
return value;
}
export function cloudConfigurationDigest(value: unknown): string {
return `sha256:${createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex")}`;
}
export const externalRegistrationEvidenceSchema = z.object({
sourceLocatorDigest: z.string(),
sourceDigest: z.string(),
state: z.enum(["missing", "parsed", "invalid", "unreadable"]),
adapterTypes: z.array(z.string()),
blocker: z.enum(["unsupported_external_adapters", "invalid_external_registration_metadata", "unreadable_external_registration_metadata"]).nullable(),
}).strict();
export type ExternalRegistrationEvidence = z.infer<typeof externalRegistrationEvidenceSchema>;
const legacyRegistrationSchema = z.array(z.object({
type: z.string().regex(/^[a-zA-Z0-9_.-]{1,128}$/),
packageName: z.string().min(1).max(1024),
version: z.string().max(128).optional(),
localPath: z.string().max(4096).optional(),
disabled: z.boolean().optional(),
}).passthrough()).max(100);
export async function inspectLegacyExternalRegistrations(filePath: string): Promise<ExternalRegistrationEvidence> {
const sourceLocatorDigest = cloudConfigurationDigest(filePath);
try {
const file = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
let raw: unknown;
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > 65_536) throw new Error("invalid legacy registration file");
const buffer = Buffer.alloc(65_537);
let total = 0;
while (total < buffer.length) {
const { bytesRead } = await file.read(buffer, total, buffer.length - total, total);
if (!bytesRead) break;
total += bytesRead;
}
if (total > 65_536) throw new Error("oversized legacy registration file");
raw = JSON.parse(buffer.subarray(0, total).toString("utf8"));
} finally {
await file.close();
}
const records = legacyRegistrationSchema.safeParse(raw);
if (!records.success) {
return { sourceLocatorDigest, sourceDigest: cloudConfigurationDigest({ state: "invalid" }), state: "invalid", adapterTypes: [], blocker: "invalid_external_registration_metadata" };
}
const metadata = records.data.map((record) => ({ type: record.type, packageName: record.packageName, version: record.version, disabled: record.disabled, hasLocalPath: record.localPath !== undefined }));
return {
sourceLocatorDigest, sourceDigest: cloudConfigurationDigest(metadata), state: "parsed",
adapterTypes: [...new Set(metadata.map((record) => record.type))].sort(),
blocker: metadata.length ? "unsupported_external_adapters" : null,
};
} catch (error) {
const absent = (error as NodeJS.ErrnoException).code === "ENOENT";
const invalid = error instanceof SyntaxError || (error instanceof Error && /legacy registration file/.test(error.message));
const state = absent ? "missing" : invalid ? "invalid" : "unreadable";
return {
sourceLocatorDigest, sourceDigest: cloudConfigurationDigest({ state }), state, adapterTypes: [],
blocker: absent ? null : invalid ? "invalid_external_registration_metadata" : "unreadable_external_registration_metadata",
};
}
}
@@ -0,0 +1,193 @@
import { instanceSettings, plugins, type Db } from "@paperclipai/db";
import { eq, inArray, ne } from "drizzle-orm";
import { z } from "zod";
import { adapterSettingsService, readLegacyAdapterSettings } from "./adapter-settings.js";
import { getAdapterPluginStorePath } from "./adapter-plugin-store.js";
import { cloudRuntimeConfigService, readLegacyCloudRuntimeConfig } from "./cloud-runtime-config.js";
import { isCloudManagedInstance, type CloudInstanceEnv } from "./cloud-instance.js";
import { cloudConfigurationDigest, externalRegistrationEvidenceSchema, inspectLegacyExternalRegistrations } from "./cloud-configuration-evidence.js";
import { cloudPackageEvidenceSchema, inspectCloudPackages, qualificationBlockerSchema } from "./cloud-package-qualification.js";
import type { ResolvedBundledPlugin } from "./bundled-plugins.js";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
const RECEIPT_KEY = "cloud-configuration-migration:v1";
const CONFIGURATION_KEYS = ["adapter-settings:v1", "cloud-runtime-config:v1"];
type Transaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
export type CloudConfigurationDb = Pick<Db | Transaction, "select" | "insert" | "update" | "transaction">;
const sourceSchema = z.object({
authority: z.enum(["database", "legacy_file", "default"]), sourceDigest: z.string(),
importedRevision: z.number().int().nonnegative(),
}).strict();
const qualificationSchema = z.object({
scope: z.literal("configuration_and_packages"), checkedAt: z.string(),
adapterRevision: z.number().int().nonnegative(), configRevision: z.number().int().nonnegative(),
configurationDigest: z.string(), packageEvidenceDigest: z.string(),
packageInspectionDurationMs: z.number().nonnegative(),
expectedPackageEvidenceDigest: z.string().nullable(),
configurationPackagesEligible: z.boolean(), samePackageOverlapEligible: z.boolean(),
packages: z.array(cloudPackageEvidenceSchema), blockers: z.array(qualificationBlockerSchema),
overlapBlockers: z.array(qualificationBlockerSchema),
}).strict();
const receiptSchema = z.object({
version: z.literal(1), revision: z.number().int().nonnegative(),
migration: z.object({ completedAt: z.string(), adapter: sourceSchema, config: sourceSchema, externalRegistrations: externalRegistrationEvidenceSchema }).strict(),
qualification: qualificationSchema.nullable(), lastPackageMismatch: qualificationSchema.nullable(),
}).strict();
export type CloudConfigurationReceipt = z.infer<typeof receiptSchema>;
function parseReceipt(value: unknown): CloudConfigurationReceipt {
const result = receiptSchema.safeParse(value);
if (!result.success) throw new Error("Persisted Cloud configuration migration receipt is invalid");
return result.data;
}
export function cloudConfigurationMigrationService(db: CloudConfigurationDb, options: { runtimeEnv?: CloudInstanceEnv; now?: () => Date } = {}) {
const env = options.runtimeEnv ?? process.env;
const now = () => (options.now?.() ?? new Date()).toISOString();
const adapters = adapterSettingsService(db, { runtimeEnv: env });
const config = cloudRuntimeConfigService(db);
function assertManaged() {
if (!isCloudManagedInstance(env)) throw new Error("Cloud configuration qualification requires a managed instance");
}
async function readRow(handle: CloudConfigurationDb, lock = false) {
const query = handle.select().from(instanceSettings).where(eq(instanceSettings.singletonKey, RECEIPT_KEY));
const [row] = lock ? await query.for("update") : await query;
return row ?? null;
}
async function currentConfiguration(handle: CloudConfigurationDb) {
await handle.select({ id: instanceSettings.id }).from(instanceSettings).where(inArray(instanceSettings.singletonKey, CONFIGURATION_KEYS))
.orderBy(instanceSettings.singletonKey).for("update");
const adapter = await adapters.inspectStored(handle);
const runtime = await config.inspectStored(handle);
if (!adapter || !runtime) throw new Error("Cloud configuration authority is incomplete");
return { adapter, runtime };
}
async function registrationSnapshot(handle: CloudConfigurationDb, lock = false) {
const query = handle.select({
pluginKey: plugins.pluginKey, packageName: plugins.packageName, version: plugins.version,
manifestJson: plugins.manifestJson, status: plugins.status,
}).from(plugins).where(ne(plugins.status, "uninstalled")).orderBy(plugins.pluginKey);
return lock ? query.for("share") : query;
}
async function save(handle: CloudConfigurationDb, id: string, receipt: CloudConfigurationReceipt) {
const validated = parseReceipt(receipt);
const updated = await handle.update(instanceSettings).set({ general: validated, updatedAt: new Date() }).where(eq(instanceSettings.id, id)).returning({ id: instanceSettings.id });
if (updated.length !== 1) throw new Error("Cloud configuration migration receipt was not persisted");
return validated;
}
return {
async inspectStored(): Promise<CloudConfigurationReceipt | null> {
const row = await readRow(db);
return row ? parseReceipt(row.general) : null;
},
async migrate(): Promise<CloudConfigurationReceipt> {
assertManaged();
return db.transaction(async (tx) => {
const inserted = await tx.insert(instanceSettings).values({ singletonKey: RECEIPT_KEY, general: { version: 1 } })
.onConflictDoNothing({ target: instanceSettings.singletonKey }).returning({ id: instanceSettings.id });
const row = await readRow(tx, true);
if (!row) throw new Error("Cloud configuration migration receipt is unavailable");
if (!inserted.length) {
const receipt = parseReceipt(row.general);
await currentConfiguration(tx);
return receipt;
}
await tx.select({ id: instanceSettings.id }).from(instanceSettings).where(inArray(instanceSettings.singletonKey, CONFIGURATION_KEYS))
.orderBy(instanceSettings.singletonKey).for("update");
const storedAdapter = await adapters.inspectStored(tx);
const storedConfig = await config.inspectStored(tx);
const adapterCandidate = storedAdapter ? null : await readLegacyAdapterSettings(env);
const configCandidate = storedConfig ? null : await readLegacyCloudRuntimeConfig(env);
const adapterSeed = storedAdapter ? null : await adapters.seedIfAbsent({ disabledTypes: adapterCandidate?.disabledTypes ?? [] }, tx);
const configSeed = storedConfig ? null : await config.seedIfAbsent(configCandidate, tx);
const adapter = storedAdapter ?? adapterSeed!.current;
const runtime = storedConfig ?? configSeed!.current;
const adapterFromDatabase = Boolean(storedAdapter || !adapterSeed?.seeded);
const configFromDatabase = Boolean(storedConfig || !configSeed?.seeded);
const receipt: CloudConfigurationReceipt = {
version: 1, revision: 0,
migration: {
completedAt: now(),
adapter: { authority: adapterFromDatabase ? "database" : adapterCandidate ? "legacy_file" : "default", sourceDigest: cloudConfigurationDigest(adapterFromDatabase ? { disabledTypes: adapter.disabledTypes } : adapterCandidate), importedRevision: adapter.revision },
config: { authority: configFromDatabase ? "database" : configCandidate ? "legacy_file" : "default", sourceDigest: cloudConfigurationDigest(configFromDatabase ? runtime.config : configCandidate), importedRevision: runtime.revision },
externalRegistrations: await inspectLegacyExternalRegistrations(getAdapterPluginStorePath(env.PAPERCLIP_HOME)),
},
qualification: null, lastPackageMismatch: null,
};
return save(tx, row.id, receipt);
});
},
async recheckLegacyExternalRegistrations(expectedRevision: number): Promise<CloudConfigurationReceipt> {
assertManaged();
return db.transaction(async (tx) => {
const row = await readRow(tx, true);
if (!row) throw new Error("Cloud configuration migration has not completed");
const receipt = parseReceipt(row.general);
if (receipt.revision !== expectedRevision) throw new Error("Cloud configuration migration receipt revision conflict");
const filePath = getAdapterPluginStorePath(env.PAPERCLIP_HOME);
if (cloudConfigurationDigest(filePath) !== receipt.migration.externalRegistrations.sourceLocatorDigest) {
throw new Error("External registration recheck requires the original legacy source location");
}
const evidence = await inspectLegacyExternalRegistrations(filePath);
if (evidence.state !== "parsed") throw new Error("External registration recheck requires a present, valid registration file");
receipt.migration.externalRegistrations = evidence;
receipt.revision += 1;
receipt.qualification = null;
return save(tx, row.id, receipt);
});
},
async qualify(input: {
installs: readonly ResolvedBundledPlugin[];
catalogRoot?: string;
loadManifest: (packageRoot: string) => Promise<PaperclipPluginManifestV1 | null>;
expectedPackageEvidenceDigest?: string;
imageCatalogUnavailable?: boolean;
}) {
assertManaged();
const adapter = await adapters.inspectStored();
const runtime = await config.inspectStored();
if (!adapter || !runtime) throw new Error("Cloud configuration authority is incomplete");
const configurationBefore = cloudConfigurationDigest({ adapter, runtime });
const registrationsBefore = await registrationSnapshot(db);
const evidence = await inspectCloudPackages({ ...input, registrations: registrationsBefore });
return db.transaction(async (tx) => {
const row = await readRow(tx, true);
if (!row) throw new Error("Cloud configuration migration has not completed");
const receipt = parseReceipt(row.general);
const current = await currentConfiguration(tx);
const registrations = await registrationSnapshot(tx, true);
const blockers = [...evidence.blockers];
if (configurationBefore !== cloudConfigurationDigest(current) || cloudConfigurationDigest(registrationsBefore) !== cloudConfigurationDigest(registrations)) {
blockers.push({ code: "configuration_changed_during_inspection", reason: "Configuration or plugin registration changed during image inspection; run qualification again" });
}
if (input.imageCatalogUnavailable) blockers.push({ code: "current_image_catalog_unavailable", reason: "The current image package catalog could not be verified" });
const external = receipt.migration.externalRegistrations;
if (external.blocker) blockers.push({ code: external.blocker, reason: "Legacy external adapter registration metadata requires an explicit operator resolution" });
const overlapBlockers = input.expectedPackageEvidenceDigest && input.expectedPackageEvidenceDigest !== evidence.packageEvidenceDigest
? [{ code: "package_evidence_changed", reason: "Current packages do not match the expected package evidence digest" }] : [];
const qualification: z.infer<typeof qualificationSchema> = {
scope: "configuration_and_packages", checkedAt: now(),
adapterRevision: current.adapter.revision, configRevision: current.runtime.revision,
configurationDigest: cloudConfigurationDigest(current), packageEvidenceDigest: evidence.packageEvidenceDigest,
packageInspectionDurationMs: evidence.packageInspectionDurationMs,
expectedPackageEvidenceDigest: input.expectedPackageEvidenceDigest ?? null,
configurationPackagesEligible: blockers.length === 0,
samePackageOverlapEligible: Boolean(input.expectedPackageEvidenceDigest && blockers.length === 0 && overlapBlockers.length === 0),
packages: evidence.packages, blockers, overlapBlockers,
};
receipt.revision += 1;
receipt.qualification = qualification;
if (evidence.blockers.length || input.imageCatalogUnavailable || overlapBlockers.length) receipt.lastPackageMismatch = qualification;
await save(tx, row.id, receipt);
return { receiptRevision: receipt.revision, ...qualification };
});
},
};
}
export async function migrateCloudConfiguration(db: CloudConfigurationDb, options: { runtimeEnv?: CloudInstanceEnv } = {}) {
if (!isCloudManagedInstance(options.runtimeEnv ?? process.env)) return null;
return cloudConfigurationMigrationService(db, options).migrate();
}
@@ -0,0 +1,33 @@
import type { Db } from "@paperclipai/db";
import { BUNDLED_PLUGIN_CATALOG, resolveBundledCatalogRoot, resolveBundledPluginInstalls, resolveManagedBundledPluginKeys, type ResolvedBundledPlugin } from "./bundled-plugins.js";
import { readDistributionPluginCatalog } from "./distribution-plugin-catalog.js";
import { createBundledPluginPackageResolver } from "./bundled-plugin-package.js";
import { cloudConfigurationMigrationService } from "./cloud-configuration-migration.js";
import { isCloudManagedInstance, type CloudInstanceEnv } from "./cloud-instance.js";
import { getManagedInstanceConfig } from "./managed-config.js";
import { pluginLoader } from "./plugin-loader.js";
export async function qualifyCloudConfigurationForCurrentImage(db: Db, options: {
runtimeEnv?: CloudInstanceEnv;
expectedPackageEvidenceDigest?: string;
} = {}) {
const env = options.runtimeEnv ?? process.env;
if (!isCloudManagedInstance(env)) return null;
const service = cloudConfigurationMigrationService(db, { runtimeEnv: env });
const catalogRoot = resolveBundledCatalogRoot(env);
let installs: ResolvedBundledPlugin[];
try {
const distribution = readDistributionPluginCatalog(catalogRoot, BUNDLED_PLUGIN_CATALOG);
installs = resolveBundledPluginInstalls(resolveManagedBundledPluginKeys(getManagedInstanceConfig(env)?.plugins.autoInstall, env) ?? [], {
catalogRoot, env, enforceCatalogRoot: true, distributionPlugins: distribution,
});
} catch {
return service.qualify({ installs: [], loadManifest: async () => null, imageCatalogUnavailable: true, expectedPackageEvidenceDigest: options.expectedPackageEvidenceDigest });
}
const resolver = createBundledPluginPackageResolver(installs);
const loader = pluginLoader(db, {
prebuiltOnly: true, enableLocalFilesystem: false, enableNpmDiscovery: false,
assertPackageActivation: resolver.assertActivation, resolvePackageRoot: resolver.resolve,
});
return service.qualify({ installs, catalogRoot, loadManifest: (root) => loader.loadManifest(root), expectedPackageEvidenceDigest: options.expectedPackageEvidenceDigest });
}
@@ -0,0 +1,72 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { z } from "zod";
import { createBundledPluginPackageResolver, type BundledPluginRegistration } from "./bundled-plugin-package.js";
import type { ResolvedBundledPlugin } from "./bundled-plugins.js";
import { distributionBundleDigest } from "./distribution-plugin-catalog.js";
import { cloudConfigurationDigest } from "./cloud-configuration-evidence.js";
import { cloudBundledPackageDigest } from "./cloud-bundled-package-digest.js";
export const qualificationBlockerSchema = z.object({
code: z.string(), pluginKey: z.string().optional(), reason: z.string(),
registeredVersion: z.string().optional(), imageVersion: z.string().optional(),
}).strict();
export type QualificationBlocker = z.infer<typeof qualificationBlockerSchema>;
export const cloudPackageEvidenceSchema = z.object({
catalogKey: z.string(), pluginKey: z.string(), packageDigest: z.string().nullable(),
imageManifestDigest: z.string().nullable(), approvedManifestDigest: z.string().nullable(),
}).strict();
export type CloudPackageEvidence = z.infer<typeof cloudPackageEvidenceSchema>;
export async function inspectCloudPackages(input: {
installs: readonly ResolvedBundledPlugin[];
registrations: readonly BundledPluginRegistration[];
catalogRoot?: string;
loadManifest: (packageRoot: string) => Promise<PaperclipPluginManifestV1 | null>;
}) {
const started = performance.now();
const resolver = createBundledPluginPackageResolver(input.installs);
const registrations = new Map(input.registrations.map((record) => [record.pluginKey, record]));
const selected = new Set(input.installs.map((install) => install.pluginKey));
const blockers: QualificationBlocker[] = input.registrations.filter((record) => !selected.has(record.pluginKey)).map((record) => ({
code: "unsupported_plugin_registration", pluginKey: record.pluginKey, reason: "An installed plugin is not selected from the current image", registeredVersion: record.version,
}));
const packages: CloudPackageEvidence[] = [];
for (const install of [...input.installs].sort((left, right) => left.pluginKey < right.pluginKey ? -1 : left.pluginKey > right.pluginKey ? 1 : 0)) {
const registration = registrations.get(install.pluginKey);
const evidence: CloudPackageEvidence = {
catalogKey: install.key, pluginKey: install.pluginKey, packageDigest: null,
imageManifestDigest: null, approvedManifestDigest: registration ? cloudConfigurationDigest(registration.manifestJson) : null,
};
packages.push(evidence);
let stage = "missing_bundled_package";
try {
resolver.assertActivation({ packageRoot: install.localPath });
stage = "unverified_package_integrity";
evidence.packageDigest = install.distribution ? distributionBundleDigest(install.localPath) : cloudBundledPackageDigest(install.localPath, { catalogRoot: input.catalogRoot });
if (install.distribution && evidence.packageDigest !== install.distribution.digest) {
blockers.push({ code: "package_integrity_mismatch", pluginKey: install.pluginKey, reason: "Current package bytes do not match the verified distribution digest" });
continue;
}
stage = "missing_manifest_evidence";
const manifest = await input.loadManifest(install.localPath);
if (!manifest) {
blockers.push({ code: stage, pluginKey: install.pluginKey, reason: "The current image has no verified plugin manifest" });
continue;
}
evidence.imageManifestDigest = cloudConfigurationDigest(manifest);
if (!registration) {
blockers.push({ code: "missing_plugin_registration", pluginKey: install.pluginKey, reason: "The current image package is not registered in the database", imageVersion: manifest.version });
continue;
}
const inspected = resolver.inspect(registration, manifest);
if (!inspected.compatible) blockers.push({
code: inspected.code, pluginKey: install.pluginKey, reason: inspected.reason,
registeredVersion: registration.version, imageVersion: manifest.version,
});
} catch {
blockers.push({ code: stage, pluginKey: install.pluginKey, reason: "The current image package could not be verified" });
}
}
const packageEvidenceDigest = cloudConfigurationDigest(packages.map(({ approvedManifestDigest: _approved, ...evidence }) => evidence));
return { packages, blockers, packageEvidenceDigest, packageInspectionDurationMs: Math.round(performance.now() - started) };
}
@@ -0,0 +1,129 @@
import {
authConfigSchema,
databaseBackupConfigSchema,
llmConfigSchema,
loggingConfigSchema,
paperclipConfigSchema,
secretsConfigSchema,
secretsLocalEncryptedConfigSchema,
serverConfigSchema,
storageConfigSchema,
storageLocalDiskConfigSchema,
storageS3ConfigSchema,
type PaperclipConfig,
} from "@paperclipai/shared";
import { z } from "zod";
const publicUrl = z.string().url().refine((value) => {
const url = new URL(value);
return !url.username && !url.password && !url.search && !url.hash;
});
export const cloudRuntimeConfigSchema = z.object({
server: serverConfigSchema.strict(),
auth: authConfigSchema.extend({ publicBaseUrl: publicUrl.optional() }).strict(),
backup: databaseBackupConfigSchema.strict(),
storage: storageConfigSchema.extend({
localDisk: storageLocalDiskConfigSchema.strict(),
s3: storageS3ConfigSchema.extend({ endpoint: publicUrl.optional() }).strict(),
}).strict(),
secrets: secretsConfigSchema.extend({
localEncrypted: secretsLocalEncryptedConfigSchema.strict(),
}).strict(),
telemetry: z.object({ enabled: z.boolean() }).strict(),
logging: loggingConfigSchema.strict(),
updates: z.object({ checkEnabled: z.boolean() }).strict().optional(),
llm: llmConfigSchema.omit({ apiKey: true }).strict().optional(),
}).strict();
export type CloudRuntimeConfig = z.infer<typeof cloudRuntimeConfigSchema>;
export type StoredCloudRuntimeConfig = { version: 1; revision: number; config: CloudRuntimeConfig | null };
const storedSchema = z.object({
version: z.literal(1),
revision: z.number().int().nonnegative(),
config: cloudRuntimeConfigSchema.nullable(),
}).strict();
export function parseStoredCloudRuntimeConfig(value: unknown): StoredCloudRuntimeConfig {
const parsed = storedSchema.safeParse(value);
if (!parsed.success || Buffer.byteLength(JSON.stringify(parsed.data), "utf8") > 65_536) {
throw new Error("Persisted Cloud runtime configuration is invalid");
}
const full = parsed.data.config === null ? { success: true } : paperclipConfigSchema.safeParse(toPaperclipConfig(parsed.data.config));
if (!full.success) throw new Error("Persisted Cloud runtime configuration is invalid");
return parsed.data;
}
export function toPaperclipConfig(config: CloudRuntimeConfig | null): PaperclipConfig | null {
if (config === null) return null;
const { backup, ...settings } = structuredClone(config);
return {
$meta: { version: 1, updatedAt: "1970-01-01T00:00:00.000Z", source: "configure" },
database: {
mode: "postgres",
embeddedPostgresDataDir: "",
embeddedPostgresPort: 54329,
backup,
},
...settings,
};
}
export function projectCloudRuntimeConfig(source: PaperclipConfig): CloudRuntimeConfig {
const raw = {
server: {
deploymentMode: source.server.deploymentMode,
exposure: source.server.exposure,
bind: source.server.bind,
customBindHost: source.server.customBindHost,
host: source.server.host,
port: source.server.port,
allowedHostnames: source.server.allowedHostnames,
serveUi: source.server.serveUi,
},
auth: {
baseUrlMode: source.auth.baseUrlMode,
publicBaseUrl: source.auth.publicBaseUrl,
disableSignUp: source.auth.disableSignUp,
},
backup: {
enabled: source.database.backup.enabled,
intervalMinutes: source.database.backup.intervalMinutes,
retentionDays: source.database.backup.retentionDays,
dir: source.database.backup.dir,
},
storage: {
provider: source.storage.provider,
localDisk: { baseDir: source.storage.localDisk.baseDir },
s3: {
bucket: source.storage.s3.bucket,
region: source.storage.s3.region,
endpoint: source.storage.s3.endpoint,
prefix: source.storage.s3.prefix,
forcePathStyle: source.storage.s3.forcePathStyle,
},
},
secrets: {
provider: source.secrets.provider,
strictMode: source.secrets.strictMode,
localEncrypted: { keyFilePath: source.secrets.localEncrypted.keyFilePath },
},
telemetry: { enabled: source.telemetry.enabled },
logging: { mode: source.logging.mode, logDir: source.logging.logDir },
updates: source.updates ? { checkEnabled: source.updates.checkEnabled } : undefined,
llm: source.llm ? { provider: source.llm.provider } : undefined,
};
return parseStoredCloudRuntimeConfig({ version: 1, revision: 0, config: raw }).config!;
}
let snapshot: StoredCloudRuntimeConfig | null = null;
export function setCloudRuntimeConfigSnapshot(value: StoredCloudRuntimeConfig | null): void {
snapshot = value ? parseStoredCloudRuntimeConfig(value) : null;
}
export function getCloudRuntimeConfigSnapshot(): StoredCloudRuntimeConfig {
if (!snapshot) throw new Error("Cloud runtime configuration has not been initialized");
return structuredClone(snapshot);
}
+105
View File
@@ -0,0 +1,105 @@
import { constants } from "node:fs";
import { open } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import { instanceSettings, type Db } from "@paperclipai/db";
import { paperclipConfigSchema } from "@paperclipai/shared";
import { resolvePaperclipConfigPathForInstance } from "@paperclipai/shared/home-paths";
import { isCloudManagedInstance, type CloudInstanceEnv } from "./cloud-instance.js";
import {
parseStoredCloudRuntimeConfig,
projectCloudRuntimeConfig,
setCloudRuntimeConfigSnapshot,
type CloudRuntimeConfig,
type StoredCloudRuntimeConfig,
} from "./cloud-runtime-config-snapshot.js";
export const CLOUD_RUNTIME_CONFIG_KEY = "cloud-runtime-config:v1";
type Transaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
export type CloudRuntimeConfigDb = Pick<Db | Transaction, "select" | "insert" | "update" | "transaction">;
export function cloudRuntimeConfigService(db: CloudRuntimeConfigDb) {
async function inspectStored(handle: CloudRuntimeConfigDb = db): Promise<StoredCloudRuntimeConfig | null> {
const [row] = await handle.select({ general: instanceSettings.general }).from(instanceSettings)
.where(eq(instanceSettings.singletonKey, CLOUD_RUNTIME_CONFIG_KEY)).limit(1);
return row ? parseStoredCloudRuntimeConfig(row.general) : null;
}
async function seedIfAbsent(config: CloudRuntimeConfig | null, handle: CloudRuntimeConfigDb = db) {
const next = parseStoredCloudRuntimeConfig({ version: 1, revision: 0, config });
const inserted = await handle.insert(instanceSettings)
.values({ singletonKey: CLOUD_RUNTIME_CONFIG_KEY, general: next })
.onConflictDoNothing({ target: instanceSettings.singletonKey }).returning({ id: instanceSettings.id });
const current = await inspectStored(handle);
if (!current) throw new Error("Cloud runtime configuration seed was not persisted");
return { seeded: inserted.length > 0, current };
}
return {
inspectStored,
seedIfAbsent,
async replace(config: CloudRuntimeConfig | null, expectedRevision: number, handle: CloudRuntimeConfigDb = db) {
const validated = parseStoredCloudRuntimeConfig({ version: 1, revision: expectedRevision, config });
return handle.transaction(async (tx) => {
const [row] = await tx.select().from(instanceSettings)
.where(eq(instanceSettings.singletonKey, CLOUD_RUNTIME_CONFIG_KEY)).for("update");
if (!row) throw new Error("Cloud runtime configuration has not been initialized");
const current = parseStoredCloudRuntimeConfig(row.general);
if (current.revision !== expectedRevision) throw new Error("Cloud runtime configuration revision conflict");
const next = { ...validated, revision: current.revision + 1 };
await tx.update(instanceSettings).set({ general: next, updatedAt: new Date() }).where(eq(instanceSettings.id, row.id));
return next;
});
},
};
}
export function legacyCloudRuntimeConfigPath(env: CloudInstanceEnv = process.env): string {
return env.PAPERCLIP_CONFIG
? path.resolve(env.PAPERCLIP_CONFIG)
: resolvePaperclipConfigPathForInstance({ homeDir: env.PAPERCLIP_HOME, instanceId: env.PAPERCLIP_INSTANCE_ID });
}
export async function readLegacyCloudRuntimeConfig(env: CloudInstanceEnv = process.env): Promise<CloudRuntimeConfig | null> {
let raw: unknown;
try {
const file = await open(legacyCloudRuntimeConfigPath(env), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > 65_536) throw new Error("invalid file");
const buffer = Buffer.alloc(65_537);
let total = 0;
while (total < buffer.length) {
const { bytesRead } = await file.read(buffer, total, buffer.length - total, total);
if (bytesRead === 0) break;
total += bytesRead;
}
if (total > 65_536) throw new Error("oversized file");
raw = JSON.parse(buffer.subarray(0, total).toString("utf8"));
} finally {
await file.close();
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("Legacy Cloud runtime configuration could not be read safely");
return null;
}
const parsed = paperclipConfigSchema.safeParse(raw);
if (!parsed.success) throw new Error("Legacy Cloud runtime configuration is invalid");
if (parsed.data.llm?.provider === "openai" && parsed.data.llm.apiKey?.trim() && !env.OPENAI_API_KEY?.trim()) {
throw new Error("Move the legacy OpenAI API key to OPENAI_API_KEY before initializing Cloud runtime configuration");
}
return projectCloudRuntimeConfig(parsed.data);
}
export async function initializeCloudRuntimeConfig(db: CloudRuntimeConfigDb, options: { runtimeEnv?: CloudInstanceEnv } = {}) {
const env = options.runtimeEnv ?? process.env;
if (!isCloudManagedInstance(env)) return null;
setCloudRuntimeConfigSnapshot(null);
const service = cloudRuntimeConfigService(db);
const current = await service.inspectStored();
const result = current
? { seeded: false, current }
: await service.seedIfAbsent(await readLegacyCloudRuntimeConfig(env));
setCloudRuntimeConfigSnapshot(result.current);
return result;
}
+21 -17
View File
@@ -1,3 +1,4 @@
import { resolveBundledPluginRuntimeLoader } from "./bundled-plugin-runtime.js";
import { finishServiceShutdown } from "../shutdown.js";
/**
* PluginLoader — discovery, installation, and runtime activation of plugins.
@@ -31,7 +32,7 @@ import { execFile } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { isDeepStrictEqual, promisify } from "node:util";
import type { Db } from "@paperclipai/db";
import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import type {
@@ -271,6 +272,8 @@ function getDeclaredPageRoutePaths(manifest: PaperclipPluginManifestV1): string[
* Options for the plugin loader service.
*/
export interface PluginLoaderOptions {
resolvePackageRoot?: (plugin: PluginRecord) => string;
prebuiltOnly?: boolean;
/** Image-owned deployment policy, checked before importing code and starting workers. */
assertPackageActivation?: (input: {
pluginKey?: string;
@@ -1157,6 +1160,8 @@ export function pluginLoader(
enableLocalFilesystem = true,
enableNpmDiscovery = true,
assertPackageActivation,
resolvePackageRoot,
prebuiltOnly = false,
} = options;
const registry = pluginRegistryService(db);
@@ -1211,6 +1216,7 @@ export function pluginLoader(
installOptions: PluginInstallOptions,
): Promise<DiscoveredPlugin> {
const { packageName, localPath, version, installDir } = installOptions;
if (prebuiltOnly && !localPath) throw new Error("Managed plugins must come from the current image catalog");
if (!packageName && !localPath) {
throw new Error("Either packageName or localPath must be provided");
@@ -1286,7 +1292,7 @@ export function pluginLoader(
const pkgJson = await readPackageJson(resolvedPackagePath);
if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`);
if (localPath) {
if (localPath && !prebuiltOnly) {
await ensureLocalPluginBuilt(resolvedPackagePath, pkgJson);
}
@@ -1398,7 +1404,7 @@ export function pluginLoader(
}
assertPackageActivation?.({ packageRoot, installedPackagePath: plugin.packagePath, pluginKey: plugin.pluginKey, manifest, previousManifest: plugin.manifestJson });
if (JSON.stringify(manifest) === JSON.stringify(plugin.manifestJson)) {
if (isDeepStrictEqual(manifest, plugin.manifestJson)) {
return plugin;
}
@@ -1749,6 +1755,7 @@ export function pluginLoader(
packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined,
},
manifest,
{ idempotent: prebuiltOnly },
);
if (!installed) {
@@ -1803,12 +1810,7 @@ export function pluginLoader(
newManifest: PaperclipPluginManifestV1;
discovered: DiscoveredPlugin;
}> {
const plugin = (await registry.getById(pluginId)) as {
id: string;
packageName: string;
packagePath: string | null;
manifestJson: PaperclipPluginManifestV1;
} | null;
const plugin = await registry.getById(pluginId);
if (!plugin) throw new Error(`Plugin not found: ${pluginId}`);
const oldManifest = plugin.manifestJson;
@@ -1817,7 +1819,7 @@ export function pluginLoader(
// For local-path installs, fall back to the stored packagePath so
// `upgradePlugin` can re-read the manifest from disk without needing
// the caller to re-supply the path every time.
localPath = plugin.packagePath ?? undefined,
localPath = resolvePackageRoot ? resolvePackageRoot(plugin) : plugin.packagePath ?? undefined,
version,
} = upgradeOptions;
@@ -1887,6 +1889,7 @@ export function pluginLoader(
// -----------------------------------------------------------------------
async cleanupInstallArtifacts(plugin: PluginRecord): Promise<void> {
if (prebuiltOnly) return;
const managedTargets = new Set<string>();
const managedNodeModulesDir = resolveManagedInstallPackageDir(localPluginDir, plugin.packageName);
const directManagedDir = path.join(localPluginDir, plugin.packageName);
@@ -2262,12 +2265,14 @@ export function pluginLoader(
// ------------------------------------------------------------------
// 1. Resolve worker entrypoint
// ------------------------------------------------------------------
const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir);
const packageRoot = resolvePackageRoot ? resolvePackageRoot(activePlugin) : resolvePluginPackageRoot(activePlugin, localPluginDir);
assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath });
activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot);
manifest = activePlugin.manifestJson;
assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath, manifest });
const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir);
const workerEntrypoint = resolvePackageRoot
? path.resolve(packageRoot, manifest.entrypoints.worker)
: resolveWorkerEntrypoint(activePlugin, localPluginDir);
// ------------------------------------------------------------------
// 2. Apply restricted database migrations before worker startup
@@ -2334,7 +2339,7 @@ export function pluginLoader(
env: buildPluginWorkerEnv({
manifest,
packageName: activePlugin.packageName,
packagePath: activePlugin.packagePath,
packagePath: resolvePackageRoot ? packageRoot : activePlugin.packagePath,
instanceInfo,
}),
// Authorize the worker to act on each configured company from its
@@ -2345,10 +2350,9 @@ export function pluginLoader(
proactiveCompanyScopes: configRows.map((row) => row.companyId),
};
// Repo-local plugin installs can resolve workspace TS sources at runtime
// (for example @paperclipai/shared exports). Run those workers through
// the tsx loader so first-party example plugins work in development.
if (activePlugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) {
if (prebuiltOnly) {
workerOptions.execArgv = ["--import", resolveBundledPluginRuntimeLoader()];
} else if (activePlugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) {
workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH];
}
+17 -6
View File
@@ -1,3 +1,4 @@
import { isDeepStrictEqual } from "node:util";
import { writePluginJobChange } from "./plugin-job-work-notifications.js";
import { asc, eq, isNull, ne, sql, and } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -31,6 +32,10 @@ import type {
import { conflict, notFound } from "../errors.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
function sameBundledPluginRegistration(existing: typeof plugins.$inferSelect, packageName: string, manifest: PaperclipPluginManifestV1): boolean {
return existing.pluginKey === manifest.id && existing.packageName === packageName && existing.version === manifest.version && isDeepStrictEqual(existing.manifestJson, manifest);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -140,10 +145,11 @@ export function pluginRegistryService(db: Db) {
* manifest from the package. This method persists the plugin row and
* assigns the next install order.
*/
install: async (input: InstallPlugin, manifest: PaperclipPluginManifestV1) => {
install: async (input: InstallPlugin, manifest: PaperclipPluginManifestV1, options: { idempotent?: boolean } = {}) => {
const existing = await getByKey(manifest.id);
if (existing) {
if (existing.status !== "uninstalled") {
if (options.idempotent && sameBundledPluginRegistration(existing, input.packageName, manifest)) return existing;
throw conflict(`Plugin already installed: ${manifest.id}`);
}
@@ -153,7 +159,7 @@ export function pluginRegistryService(db: Db) {
.update(plugins)
.set({
packageName: input.packageName,
packagePath: input.packagePath ?? null,
packagePath: options.idempotent ? existing.packagePath : input.packagePath ?? null,
version: manifest.version,
apiVersion: manifest.apiVersion,
categories: manifest.categories,
@@ -170,7 +176,7 @@ export function pluginRegistryService(db: Db) {
const installOrder = await nextInstallOrder();
try {
const rows = await db
const insertion = db
.insert(plugins)
.values({
pluginKey: manifest.id,
@@ -182,9 +188,14 @@ export function pluginRegistryService(db: Db) {
status: "installed" as PluginStatus,
installOrder,
packagePath: input.packagePath ?? null,
})
.returning();
return rows[0];
});
const rows = await (options.idempotent
? insertion.onConflictDoNothing({ target: plugins.pluginKey })
: insertion).returning();
if (rows[0]) return rows[0];
const concurrent = await getByKey(manifest.id);
if (concurrent && concurrent.status !== "uninstalled" && sameBundledPluginRegistration(concurrent, input.packageName, manifest)) return concurrent;
throw conflict(`Plugin registration differs from the current image: ${manifest.id}`);
} catch (error) {
if (isPluginKeyConflict(error)) {
throw conflict(`Plugin already installed: ${manifest.id}`);