diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index f903ed949bb..27fa5f94a28 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1488,7 +1488,9 @@ Paperclip also persists an empty `OPENAI_API_KEY` override for those agents so a If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary). -Local adapters require their corresponding CLI/session setup on the machine running Paperclip. External adapters are installed through the adapter/plugin flow and should not require hardcoded imports in `server/` or `ui/`. +Local adapters require their corresponding CLI/session setup on the machine running Paperclip. Self-hosted instances can install external adapters through the adapter/plugin flow without hardcoded imports in `server/` or `ui/`. + +Cloud-managed instances use only adapter implementations supplied by the release image. A tenant server token or the presence of `PAPERCLIP_MANAGED_CONFIG` enforces this boundary. Startup and UI parser requests do not read external adapter registrations or packages. Install, reinstall, reload, remove, and override operations return `403` with `adapter_install_platform_managed`, including for instance admins. Legacy `adapter-plugins.json` files stay untouched and do not change the built-in registry. ### Filesystem access boundary (2026-10-20) diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index cf775968280..bc842d65d9e 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -55,7 +55,14 @@ Policy: Company model discovery requires company access; account-backed discover also enforces its account/pool and target-agent rules. Instance adapter inventory and installation retain board/admin gates. Delegated managed AI-account login operations are enumerated under `model-accounts`; they do not -grant raw host administration or execution-session reset. +grant raw host administration or execution-session reset. On Cloud-managed +instances, external adapter install, reinstall, reload, removal and override +operations return `403` with `adapter_install_platform_managed`, including for +instance admins. Built-in metadata remains available to board users. Startup +and UI parser reads do not consume saved external registrations or package code. +[Local fixture controls](../../server/src/adapters/cloud-external-adapters.test.ts) +and [route controls](../../server/src/__tests__/adapter-routes-authz.test.ts) +cover this boundary; they do not establish live agent or provider qualification. Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L6608) at the reviewed commit above. diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 21c853d8501..f9a92a8811d 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -170,6 +170,15 @@ controls do not establish live provider acceptance. ### `adapter-setup` +Cloud-managed instances load adapters from the release image. Saved external +registrations cannot replace built-ins or supply UI parser code. Install, +reinstall, reload, remove, and override operations remain platform-managed. +The [fixture loader controls](../../server/src/adapters/cloud-external-adapters.test.ts) +exercise real packages, import side effects, both managed-instance signals and +self-hosted compatibility. The [route authorization controls](../../server/src/__tests__/adapter-routes-authz.test.ts) +check managed denials before package or registration access. These are local +controls, not live Cloud provider qualification. + Automated: [adapter validation routes](../../server/src/__tests__/agent-adapter-validation-routes.test.ts) and [model refresh](../../server/src/__tests__/adapter-model-refresh-routes.test.ts) cover server behavior. Claude native setup uses the existing Anthropic API-key or subscription connection diff --git a/server/src/__tests__/adapter-routes-authz.test.ts b/server/src/__tests__/adapter-routes-authz.test.ts index 22aa7b2c268..d9ad9d557de 100644 --- a/server/src/__tests__/adapter-routes-authz.test.ts +++ b/server/src/__tests__/adapter-routes-authz.test.ts @@ -373,35 +373,31 @@ describe("adapter management route authorization", () => { }, ); - describe("cloud-managed adapter code install floor", () => { - beforeEach(() => { - process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; + describe("cloud-managed adapter code floor", () => { + afterEach(() => vi.unstubAllEnvs()); + + it.each( + ["PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "PAPERCLIP_MANAGED_CONFIG"].flatMap((signal) => + ["install", "reinstall", "reload", "override", "delete"].map((routeName) => [signal, routeName]), + ), + )("blocks %s adapter %s before package or registration access", async (signal, routeName) => { + seedInstalledExternalAdapter(); + vi.clearAllMocks(); + vi.stubEnv(signal, signal === "PAPERCLIP_MANAGED_CONFIG" ? "" : "test-server-token"); + const app = createApp(instanceAdmin); + + const res = await sendMutatingRequest(app, routeName); + + expect(res.status, `${routeName}: ${JSON.stringify(res.body)}`).toBe(403); + expect(res.body.details).toMatchObject({ code: "adapter_install_platform_managed" }); + expect(mocks.execFile).not.toHaveBeenCalled(); + expect(mocks.loadExternalAdapterPackage).not.toHaveBeenCalled(); + expect(mocks.reloadExternalAdapter).not.toHaveBeenCalled(); + expect(mocks.getAdapterPluginByType).not.toHaveBeenCalled(); + expect(mocks.addAdapterPlugin).not.toHaveBeenCalled(); + expect(mocks.removeAdapterPlugin).not.toHaveBeenCalled(); + expect(mocks.externalRecords.get(EXTERNAL_ADAPTER_TYPE)).toEqual(installedRecord()); }); - afterEach(() => { - delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; - }); - - it.each(["install", "reinstall"] as const)( - "floors adapter %s off for instance admins on cloud-managed instances", - async (routeName) => { - resetInstalledExternalAdapterState(); - if (routeName !== "install") { - seedInstalledExternalAdapter(); - } - const app = createApp(instanceAdmin); - - const res = await sendMutatingRequest(app, routeName); - - expect(res.status, `${routeName}: ${JSON.stringify(res.body)}`).toBe( - 403, - ); - expect(res.body.details).toMatchObject({ - code: "adapter_install_platform_managed", - }); - expect(mocks.execFile).not.toHaveBeenCalled(); - expect(mocks.loadExternalAdapterPackage).not.toHaveBeenCalled(); - }, - ); }); describe("operator-hidden adapter management floor", () => { diff --git a/server/src/adapters/cloud-external-adapters.test.ts b/server/src/adapters/cloud-external-adapters.test.ts new file mode 100644 index 00000000000..e504b537a93 --- /dev/null +++ b/server/src/adapters/cloud-external-adapters.test.ts @@ -0,0 +1,136 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let home: string; +let localPackage: string; +let npmPackage: string; +let storePath: string; +let sentinel: string; +const parserSource = "export function parseStdoutLine() { return { message: 'external parser' }; }"; +const managedError = { status: 403, details: { code: "adapter_install_platform_managed" } }; + +function writePackage(packageDir: string, type: string): void { + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, "package.json"), JSON.stringify({ + name: `fixture-${type}`, + type: "module", + exports: { ".": "./index.mjs", "./ui-parser": "./ui-parser.mjs" }, + paperclip: { adapterUiParser: "1.0.0" }, + })); + fs.writeFileSync(path.join(packageDir, "index.mjs"), ` + import fs from "node:fs"; + fs.appendFileSync(${JSON.stringify(sentinel)}, ${JSON.stringify(type + "\n")}); + export function createServerAdapter() { + return { + type: ${JSON.stringify(type)}, + label: "External fixture", + models: [], + execute: async () => ({ exitCode: 41, signal: null, timedOut: false }), + testEnvironment: async () => ({ status: "pass", checks: [] }), + }; + } + `); + fs.writeFileSync(path.join(packageDir, "ui-parser.mjs"), parserSource); +} + +beforeEach(() => { + vi.resetModules(); + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined); + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined); + home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-adapters-")); + vi.stubEnv("PAPERCLIP_HOME", home); + sentinel = path.join(home, "external-code-executed"); + localPackage = path.join(home, "local-package"); + npmPackage = path.join(home, "adapter-plugins", "node_modules", "fixture-npm"); + storePath = path.join(home, "adapter-plugins.json"); + writePackage(localPackage, "codex_local"); + writePackage(npmPackage, "fixture_npm"); + fs.writeFileSync(storePath, JSON.stringify([ + { packageName: "fixture-local", localPath: localPackage, type: "codex_local", installedAt: "2026-01-01" }, + { packageName: "fixture-npm", type: "fixture_npm", installedAt: "2026-01-01" }, + ])); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe("Cloud image adapter boundary", () => { + it.each(["PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "PAPERCLIP_MANAGED_CONFIG"])( + "cold boots built-ins without reading or executing persisted packages with %s", + async (signal) => { + vi.stubEnv(signal, signal === "PAPERCLIP_MANAGED_CONFIG" ? "" : "test-token"); + const registrationBefore = fs.readFileSync(storePath, "utf8"); + const reads = vi.spyOn(fs, "readFileSync"); + const registry = await import("./registry.js"); + await registry.waitForExternalAdapters(); + const loader = await import("./plugin-loader.js"); + const store = await import("../services/adapter-plugin-store.js"); + + expect(registry.requireServerAdapter("codex_local").label).not.toBe("External fixture"); + expect(registry.requireServerAdapter("process").execute).toBeTypeOf("function"); + expect(registry.findServerAdapter("fixture_npm")).toBeNull(); + expect(registry.hasActiveAdapterOverride("codex_local")).toBe(false); + expect(await loader.buildExternalAdapters()).toEqual([]); + expect(store.listAdapterPlugins()).toEqual([]); + expect(store.getAdapterPluginByType("codex_local")).toBeUndefined(); + expect(loader.getUiParserSource("codex_local")).toBeUndefined(); + expect(loader.getOrExtractUiParserSource("codex_local")).toBeUndefined(); + await expect(loader.loadExternalAdapterPackage("fixture-local", localPackage)).rejects.toMatchObject(managedError); + await expect(loader.loadExternalAdapterPackage("fixture-npm")).rejects.toMatchObject(managedError); + await expect(loader.reloadExternalAdapter("codex_local")).rejects.toMatchObject(managedError); + expect(() => registry.registerServerAdapter(registry.requireServerAdapter("codex_local"))).toThrow(); + expect(() => registry.unregisterServerAdapter("codex_local")).toThrow(); + expect(() => registry.setOverridePaused("codex_local", false)).toThrow(); + expect(() => store.addAdapterPlugin({ packageName: "fixture", type: "fixture", installedAt: "2026-01-01" })).toThrow(); + expect(() => store.removeAdapterPlugin("codex_local")).toThrow(); + expect(() => store.getAdapterPluginsDir()).toThrow(); + expect(store.getAdapterPluginStorePath()).toBe(storePath); + expect(reads.mock.calls.map(([file]) => String(file)).filter(file => + file === storePath || file.startsWith(localPackage + path.sep) || file.startsWith(npmPackage + path.sep), + )).toEqual([]); + reads.mockRestore(); + expect(fs.existsSync(sentinel)).toBe(false); + expect(fs.readFileSync(storePath, "utf8")).toBe(registrationBefore); + }, + ); + + it("preserves local and npm package loading, overrides, reload, and lazy UI parsing on self-hosted instances", async () => { + const loader = await import("./plugin-loader.js"); + expect(loader.getOrExtractUiParserSource("codex_local")).toBe(parserSource); + const registry = await import("./registry.js"); + await registry.waitForExternalAdapters(); + + const override = registry.requireServerAdapter("codex_local"); + expect(override.label).toBe("External fixture"); + expect(registry.hasActiveAdapterOverride("codex_local")).toBe(true); + expect(registry.requireServerAdapter("fixture_npm").label).toBe("External fixture"); + expect(await override.execute({} as never)).toMatchObject({ exitCode: 41 }); + expect(fs.readFileSync(sentinel, "utf8").trim().split("\n")).toEqual(["codex_local", "fixture_npm"]); + expect(loader.getUiParserSource("fixture_npm")).toBe(parserSource); + const reloaded = await loader.reloadExternalAdapter("codex_local"); + expect(reloaded?.type).toBe("codex_local"); + expect(await reloaded?.execute({} as never)).toMatchObject({ exitCode: 41 }); + expect(registry.setOverridePaused("codex_local", true)).toBe(true); + expect(registry.findActiveServerAdapter("codex_local")?.label).not.toBe("External fixture"); + expect(registry.setOverridePaused("codex_local", false)).toBe(true); + registry.unregisterServerAdapter("codex_local"); + expect(registry.requireServerAdapter("codex_local").label).not.toBe("External fixture"); + }); + + it("does not expose a cached registration or parser after a managed signal becomes present", async () => { + const loader = await import("./plugin-loader.js"); + const store = await import("../services/adapter-plugin-store.js"); + await loader.loadExternalAdapterPackage("fixture-local", localPackage); + expect(loader.getUiParserSource("codex_local")).toBe(parserSource); + expect(store.listAdapterPlugins()).toHaveLength(2); + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", "{}"); + expect(store.listAdapterPlugins()).toEqual([]); + expect(loader.getUiParserSource("codex_local")).toBeUndefined(); + expect(loader.getOrExtractUiParserSource("codex_local")).toBeUndefined(); + }); +}); diff --git a/server/src/adapters/plugin-loader.ts b/server/src/adapters/plugin-loader.ts index 236e8ddd50f..2777d813af1 100644 --- a/server/src/adapters/plugin-loader.ts +++ b/server/src/adapters/plugin-loader.ts @@ -15,6 +15,8 @@ import { pathToFileURL } from "node:url"; import type { ServerAdapterModule } from "./types.js"; import { validateAdapterLoginCapability } from "@paperclipai/adapter-utils"; import { logger } from "../middleware/logger.js"; +import { isCloudManagedInstance } from "../services/cloud-instance.js"; +import { assertExternalAdapterCodeAllowed } from "../services/adapter-code-policy.js"; import { listAdapterPlugins, @@ -30,6 +32,7 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js"; const uiParserCache = new Map(); export function getUiParserSource(adapterType: string): string | undefined { + if (isCloudManagedInstance()) return undefined; return uiParserCache.get(adapterType); } @@ -38,6 +41,7 @@ export function getUiParserSource(adapterType: string): string | undefined { * Makes the ui-parser.js endpoint self-healing. */ export function getOrExtractUiParserSource(adapterType: string): string | undefined { + if (isCloudManagedInstance()) return undefined; const cached = uiParserCache.get(adapterType); if (cached) return cached; @@ -182,6 +186,7 @@ export async function loadExternalAdapterPackage( packageName: string, localPath?: string, ): Promise { + assertExternalAdapterCodeAllowed(); const packageDir = localPath ? path.resolve(localPath) : path.resolve(getAdapterPluginsDir(), "node_modules", packageName); @@ -221,6 +226,7 @@ async function loadFromRecord(record: AdapterPluginRecord): Promise { + assertExternalAdapterCodeAllowed(); const record = getAdapterPluginByType(type); if (!record) return null; @@ -271,6 +277,7 @@ export async function reloadExternalAdapter( * Build all external adapter modules from the plugin store. */ export async function buildExternalAdapters(): Promise { + if (isCloudManagedInstance()) return []; const results: ServerAdapterModule[] = []; const storeRecords = listAdapterPlugins(); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index cbf2c2dd8e3..e4c6dddf53d 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -136,6 +136,8 @@ import { import { agentConfigurationDoc as piAgentConfigurationDoc } from "@paperclipai/adapter-pi-local"; import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js"; import { buildExternalAdapters } from "./plugin-loader.js"; +import { isCloudManagedInstance } from "../services/cloud-instance.js"; +import { assertExternalAdapterCodeAllowed } from "../services/adapter-code-policy.js"; import { NativeEnvironmentProbeCleanupError } from "../vendor/paperclip-runner/index.js"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; @@ -1028,6 +1030,7 @@ export function resolveExternalAdapterRegistration( * and avoid racing against the loading window. */ const externalAdaptersReady: Promise = (async () => { + if (isCloudManagedInstance()) return; try { const externalAdapters = await buildExternalAdapters(); for (const externalAdapter of externalAdapters) { @@ -1063,6 +1066,7 @@ export function waitForExternalAdapters(): Promise { } export function registerServerAdapter(adapter: ServerAdapterModule): void { + assertExternalAdapterCodeAllowed(); if (BUILTIN_ADAPTER_TYPES.has(adapter.type) && !builtinFallbacks.has(adapter.type)) { const existing = adaptersByType.get(adapter.type); if (existing) { @@ -1073,6 +1077,7 @@ export function registerServerAdapter(adapter: ServerAdapterModule): void { } export function unregisterServerAdapter(type: string): void { + assertExternalAdapterCodeAllowed(); if (type === processAdapter.type || type === httpAdapter.type) return; if (builtinFallbacks.has(type)) { pausedOverrides.delete(type); @@ -1220,6 +1225,7 @@ export async function detectAdapterModel( * an override or was already in the requested state. */ export function setOverridePaused(type: string, paused: boolean): boolean { + assertExternalAdapterCodeAllowed(); if (!builtinFallbacks.has(type)) return false; const wasPaused = pausedOverrides.has(type); if (paused && !wasPaused) { diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 406b59f16a4..7a02043644b 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -49,7 +49,7 @@ import type { import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; import { logger } from "../middleware/logger.js"; import { badRequest, forbidden } from "../errors.js"; -import { isCloudManagedInstance } from "../services/cloud-instance.js"; +import { assertExternalAdapterCodeAllowed } from "../services/adapter-code-policy.js"; import { getHiddenSettings } from "../services/settings-visibility.js"; import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js"; @@ -60,22 +60,6 @@ import { resolveAntigravityCandidateAdmission } from "../services/native-runtime const execFileAsync = promisify(execFile); let warnedMalformedAntigravityAdmission = false; -/** - * Floor: on cloud-managed instances adapter code is bundled into the platform - * image; fetching and loading external adapter packages at runtime stays off - * for every actor, including instance admins. Adapter code executes in the - * server process, so a runtime install would let an instance admin read the - * platform trust anchors from the process environment (mirrors the - * bundled-only plugin install floor in plugin-install-guard.ts). - */ -function assertAdapterCodeInstallAllowed() { - if (isCloudManagedInstance()) { - throw forbidden("Adapter installation is platform-managed on cloud-managed instances", { - code: "adapter_install_platform_managed", - }); - } -} - /** * Floor: when the hosting operator hides the Adapters settings surface * (`instance.adapters` in PAPERCLIP_HIDDEN_SETTINGS), adapter management @@ -345,7 +329,7 @@ export function adapterRoutes(options: { */ router.post("/adapters/install", async (req, res) => { assertInstanceAdmin(req); - assertAdapterCodeInstallAllowed(); + assertExternalAdapterCodeAllowed(); assertAdapterManagementVisible(); const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest; @@ -551,6 +535,7 @@ export function adapterRoutes(options: { */ router.patch("/adapters/:type/override", async (req, res) => { assertInstanceAdmin(req); + assertExternalAdapterCodeAllowed(); assertAdapterManagementVisible(); @@ -581,6 +566,7 @@ export function adapterRoutes(options: { */ router.delete("/adapters/:type", async (req, res) => { assertInstanceAdmin(req); + assertExternalAdapterCodeAllowed(); assertAdapterManagementVisible(); const adapterType = req.params.type; @@ -658,6 +644,7 @@ export function adapterRoutes(options: { */ router.post("/adapters/:type/reload", async (req, res) => { assertInstanceAdmin(req); + assertExternalAdapterCodeAllowed(); assertAdapterManagementVisible(); const type = req.params.type; @@ -711,7 +698,7 @@ export function adapterRoutes(options: { // package name, but without the risk of losing the store record. router.post("/adapters/:type/reinstall", async (req, res) => { assertInstanceAdmin(req); - assertAdapterCodeInstallAllowed(); + assertExternalAdapterCodeAllowed(); assertAdapterManagementVisible(); const type = req.params.type; diff --git a/server/src/services/adapter-code-policy.ts b/server/src/services/adapter-code-policy.ts new file mode 100644 index 00000000000..49c1e822f80 --- /dev/null +++ b/server/src/services/adapter-code-policy.ts @@ -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", + }); + } +} diff --git a/server/src/services/adapter-plugin-store.ts b/server/src/services/adapter-plugin-store.ts index 13369323d4f..3559b4f0036 100644 --- a/server/src/services/adapter-plugin-store.ts +++ b/server/src/services/adapter-plugin-store.ts @@ -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 @@ -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(): string { + return adapterPluginPaths().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(); }