diff --git a/cloudflare/plugins.ts b/cloudflare/plugins.ts index ddb3de5..9ec3023 100644 --- a/cloudflare/plugins.ts +++ b/cloudflare/plugins.ts @@ -1,68 +1,27 @@ -import type { ArtifactPlugin } from "../src/plugins/config"; -import type { PluginRequest, PluginUser } from "../src/plugins/types"; +import type { PluginInfo, PluginRequest } from "../src/plugins/types"; import installed from "../dist/cloudflare/plugin-server"; import { validatePluginInput, validatePluginOutput } from "../dist/cloudflare/plugin-validators.js"; import catalog from "../dist/cloudflare/plugin-catalog.json"; +import { assertPluginRequest, createPluginDispatcher, pluginJson, PluginError, type PluginInvocationContext } from "../src/plugins/dispatch"; +import { callRuntimePlugin, runtimePlugin, runtimePluginCatalog, type RuntimePluginEnvironment } from "./runtime-plugins"; -export const PLUGIN_JSON_LIMIT = 256 * 1024; +export { createPluginDispatcher, PluginError, PLUGIN_JSON_LIMIT, type PluginInvocationContext } from "../src/plugins/dispatch"; export const PLUGIN_GUIDE = `Deployment plugins provide installed browser modules and authenticated server functions. Call plugins_list to discover operation names, schemas, and read-only hints. Call artifact_plugin_call with {plugin, operation, input}, or use pluginCall from sidequery/artifacts in a hosted artifact. Calls run as the authenticated deployment user, independently of artifact, version, workspace, or library selectors. Plugins may restrict users and must check row-level access in their handlers. Public standalone artifacts cannot call plugins. Provider credentials stay on the server. Artifact backends and scripts do not receive this bridge.`; export const pluginCatalog = catalog; -export type PluginInvocationContext = { user: PluginUser; env: object }; -export class PluginError extends Error { - constructor(message: string, readonly status: 400 | 401 | 403 | 404 | 413 | 500 | 504) { super(message); } -} -function jsonValue(value: unknown, output = false): unknown { - let serialized: string | undefined; - try { - serialized = JSON.stringify(value, (_key, part) => { - if (typeof part === "number" && !Number.isFinite(part) || ["undefined", "function", "symbol", "bigint"].includes(typeof part)) throw new Error(); - return part; - }); - } catch { throw new PluginError(output ? "Plugin returned invalid JSON" : "Input must be JSON", output ? 500 : 400); } - if (serialized === undefined) throw new PluginError(output ? "Plugin returned invalid JSON" : "Input must be JSON", output ? 500 : 400); - if (new TextEncoder().encode(serialized).byteLength > PLUGIN_JSON_LIMIT) throw new PluginError(output ? "Plugin result exceeds 256 KiB" : "Plugin input exceeds 256 KiB", output ? 500 : 413); - return JSON.parse(serialized); +const installedNames = new Set(installed.map(plugin => plugin.name)); +const dispatchInstalled = createPluginDispatcher(installed, { input: validatePluginInput, output: validatePluginOutput }); + +/** Build-time plugins take precedence; a published plugin cannot shadow one. */ +export async function dispatchPlugin(request: PluginRequest, context?: PluginInvocationContext): Promise { + assertPluginRequest(request, context); + if (installedNames.has(request.plugin)) return dispatchInstalled(request, context); + const record = await runtimePlugin(context.env as RuntimePluginEnvironment, request.plugin); + if (!record?.operations.some(operation => operation.name === request.operation)) throw new PluginError("Plugin operation not found", 404); + return callRuntimePlugin(record, request.operation, pluginJson(request.input), context.user, context); } -export function createPluginDispatcher(plugins: readonly ArtifactPlugin[], validators: { - input: typeof validatePluginInput; output: typeof validatePluginOutput; -}, timeoutMs = 30_000) { - return async (request: PluginRequest, context?: PluginInvocationContext): Promise => { - if (!context?.user.subject || !context.user.authority) throw new PluginError("Plugin sign-in required", 401); - if (!request || typeof request !== "object" || typeof request.plugin !== "string" || typeof request.operation !== "string" || !Object.hasOwn(request, "input") || Object.keys(request).some(key => !["plugin", "operation", "input"].includes(key))) throw new PluginError("Invalid plugin request", 400); - const plugin = plugins.find(plugin => plugin.name === request.plugin); - const operation = plugin?.operations && Object.hasOwn(plugin.operations, request.operation) ? plugin.operations[request.operation] : undefined; - if (!plugin || !operation) throw new PluginError("Plugin operation not found", 404); - const input = jsonValue(request.input); - if (!validators.input(request.plugin, request.operation, input)) throw new PluginError("Invalid plugin input", 400); - // Handlers are trusted deployment code. The signal cancels cooperative I/O; - // the deadline bounds asynchronous work even if it ignores cancellation. - const controller = new AbortController(); - let timer: ReturnType; - const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { controller.abort(); reject(new PluginError("Plugin operation timed out", 504)); }, timeoutMs); }); - try { - return await Promise.race([timeout, (async () => { - const user = Object.freeze({ subject: context.user.subject, authority: context.user.authority }); - let authorized: boolean; - try { authorized = operation.authorize ? await operation.authorize(user) : true; } - catch { throw new PluginError("Plugin authorization failed", 500); } - if (!authorized) throw new PluginError("Plugin operation is not allowed", 403); - if (controller.signal.aborted) throw new PluginError("Plugin operation timed out", 504); - const secrets: Record = Object.create(null); - for (const name of plugin.secrets ?? []) { - const value = Object.hasOwn(context.env, name) ? (context.env as Record)[name] : undefined; - if (typeof value !== "string") throw new PluginError("Plugin configuration unavailable", 500); - secrets[name] = value; - } - let result: unknown; - try { result = await operation.handler(input, { user, secrets: Object.freeze(secrets), signal: controller.signal }); } - catch { throw new PluginError("Plugin operation failed", 500); } - const output = jsonValue(result, true); - if (!validators.output(request.plugin, request.operation, output)) throw new PluginError("Plugin returned invalid output", 500); - return output; - })()]); - } finally { clearTimeout(timer!); } - }; +export async function pluginCatalogFor(env: object): Promise { + const published = await runtimePluginCatalog(env as RuntimePluginEnvironment); + return [...catalog, ...published.filter(plugin => !installedNames.has(plugin.name))]; } -export const dispatchPlugin = createPluginDispatcher(installed, { input: validatePluginInput, output: validatePluginOutput }); diff --git a/cloudflare/runtime-plugins-celld.test.ts b/cloudflare/runtime-plugins-celld.test.ts new file mode 100644 index 0000000..c3d224b --- /dev/null +++ b/cloudflare/runtime-plugins-celld.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { bundleRuntimePlugin } from "../scripts/bundle-plugin"; +import { prepareCelldConfig } from "../scripts/prepare-celld"; +import { spawnCelld, stopCelld, waitForReady, type RunningCelld } from "../e2e/celld-auth-runtime"; + +const root = resolve(import.meta.dir, ".."); +const enabled = process.env.CELLD_INTEGRATION === "1"; +const celldTest = enabled ? test : test.skip; +const plugins = { binding: "PLUGINS", bucket_name: "artifacts-plugins" }; +let project = ""; +let origin = ""; +let runtime: RunningCelld | undefined; + +function reservePort() { + const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const selected = listener.port; + listener.stop(true); + return selected; +} + +beforeAll(async () => { + if (!enabled) return; + const binary = process.env.CELLD_BIN ?? Bun.which("celld"); + const esbuild = process.env.CELLD_ESBUILD ?? resolve(root, "node_modules/.bin/esbuild"); + if (!binary) throw new Error("celld was not found; install celld or set CELLD_BIN to its executable"); + if (!existsSync(join(root, "dist/worker-app/worker.js"))) throw new Error("missing dist/worker-app/worker.js; run bun run build:cloudflare"); + const record = await bundleRuntimePlugin(join(root, "e2e/runtime-plugin-fixture.ts")); + project = await mkdtemp(join(tmpdir(), "artifact-celld-plugins-")); + await cp(join(root, "dist/worker-app"), join(project, "dist/worker-app"), { recursive: true }); + await cp(join(root, "dist/cloudflare/assets"), join(project, "dist/cloudflare/assets"), { recursive: true }); + const configPath = join(project, "wrangler.jsonc"); + const config = await prepareCelldConfig(join(root, "wrangler.jsonc"), configPath); + config.r2_buckets = [...(config.r2_buckets as unknown[] ?? []), plugins]; + config.vars = { ...(config.vars as Record), PLUGIN_WAREHOUSE_TOKEN: "warehouse-test-token", HOST_ONLY_SECRET: "host-only-value" }; + const port = reservePort(); + origin = `http://127.0.0.1:${port}`; + // Publish through the binding into the project's persistent local storage, + // as the auth runtime bootstraps D1, then serve Artifacts from that storage. + await writeFile(join(project, "seed.js"), 'export default { async fetch(request, env) { if (request.method === "PUT") await env.PLUGINS.put(new URL(request.url).searchParams.get("key"), await request.text()); return new Response("ready"); } };\n'); + await writeFile(configPath, JSON.stringify({ name: config.name, main: "seed.js", compatibility_date: config.compatibility_date, r2_buckets: [plugins] })); + runtime = spawnCelld(binary, esbuild, project, port); + await waitForReady(runtime, origin); + const seeded = await fetch(`${origin}/?key=plugins/test-warehouse.json`, { method: "PUT", body: JSON.stringify(record) }); + if (!seeded.ok) throw new Error(`Seeding failed: ${seeded.status}\n${runtime.logs()}`); + await stopCelld(runtime); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); + runtime = spawnCelld(binary, esbuild, project, port); + await waitForReady(runtime, `${origin}/health`); +}, 120_000); + +afterAll(async () => { + await stopCelld(runtime); + if (project) await rm(project, { recursive: true, force: true }); +}); + +celldTest("a published plugin runs in its own isolate on celld", async () => { + const client = new Client({ name: "runtime-plugin-celld", version: "1" }); + try { + await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=test`))); + const listed = await client.callTool({ name: "plugins_list", arguments: {} }); + expect((listed.structuredContent as { plugins: { name: string }[] }).plugins.map(plugin => plugin.name)).toContain("test-warehouse"); + const called = await client.callTool({ name: "artifact_plugin_call", arguments: { plugin: "test-warehouse", operation: "query", input: { sql: "select 1" } } }); + expect(called.isError, JSON.stringify(called.content) + runtime?.logs()).not.toBe(true); + const result = (called.structuredContent as { result: Record }).result; + expect(result).toMatchObject({ sql: "select 1", user: { subject: "local", authority: "local" }, secrets: { PLUGIN_WAREHOUSE_TOKEN: "warehouse-test-token" } }); + expect(result.processEnv).not.toContain("HOST_ONLY_SECRET"); + const invalid = await client.callTool({ name: "artifact_plugin_call", arguments: { plugin: "test-warehouse", operation: "query", input: {} } }); + expect(invalid.structuredContent).toMatchObject({ error: "Invalid plugin input", status: 400 }); + } finally { await client.close(); } +}, 60_000); diff --git a/cloudflare/runtime-plugins.test.ts b/cloudflare/runtime-plugins.test.ts new file mode 100644 index 0000000..ca01641 --- /dev/null +++ b/cloudflare/runtime-plugins.test.ts @@ -0,0 +1,124 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { Miniflare } from "miniflare"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { bundleRuntimePlugin } from "../scripts/bundle-plugin"; +import { parseRuntimePlugin, type RuntimePluginRecord } from "../src/plugins/runtime"; + +const fixture = join(import.meta.dir, "../e2e/runtime-plugin-fixture.ts"); +let runtime: Miniflare; +let client: Client; +let origin: string; +let record: RuntimePluginRecord; +// Matches the host's per-isolate record cache. +const CACHE_MS = 5_000; + +async function publish(key: string, body: string) { + await (await runtime.getR2Bucket("PLUGINS")).put(key, body); +} +async function call(plugin: string, operation: string, input: unknown) { + const result = await client.callTool({ name: "artifact_plugin_call", arguments: { plugin, operation, input } }); + return { result, payload: JSON.parse((result.content as { text: string }[])[0]!.text) }; +} + +beforeAll(async () => { + record = await bundleRuntimePlugin(fixture); + const modulesRoot = join(import.meta.dir, "../dist/worker-app"); + const modules: Record }> = {}; + for (const name of await readdir(modulesRoot)) { + if (name.endsWith(".js")) modules[name] = { type: "esm", contents: await readFile(join(modulesRoot, name), "utf8") }; + if (name.endsWith(".html")) modules[name] = { type: "text", contents: await readFile(join(modulesRoot, name), "utf8") }; + if (name.endsWith(".wasm")) modules[name] = { type: "wasm", contents: await readFile(join(modulesRoot, name)) }; + } + runtime = new Miniflare({ cf: false, port: 0, workers: [{ config: { + name: "runtime-plugin-test", type: "worker", compatibilityDate: "2026-09-06", compatibilityFlags: ["nodejs_compat"], + manifest: { mainModule: "worker.js", modulesRoot, modules }, + env: { + ENVIRONMENT: { type: "text", value: "local" }, + PLUGIN_WAREHOUSE_TOKEN: { type: "text", value: "warehouse-test-token" }, + HOST_ONLY_SECRET: { type: "text", value: "host-only-value" }, + LIBRARIES: { type: "durable-object", worker: "runtime-plugin-test", exportName: "ArtifactLibrary" }, + BACKENDS: { type: "durable-object", worker: "runtime-plugin-test", exportName: "ArtifactBackend" }, + LINKS: { type: "durable-object", worker: "runtime-plugin-test", exportName: "ArtifactLinks" }, + SCRIPTS: { type: "durable-object", worker: "runtime-plugin-test", exportName: "ScriptLibrary" }, + SCRIPT_BACKENDS: { type: "durable-object", worker: "runtime-plugin-test", exportName: "ScriptBackend" }, + FILE_BACKENDS: { type: "durable-object", worker: "runtime-plugin-test", exportName: "ArtifactFiles" }, + FILES: { type: "r2", name: "runtime-plugin-test-files" }, + PLUGINS: { type: "r2", name: "runtime-plugin-test-plugins" }, + LOADER: { type: "worker-loader" }, + ASSETS: { type: "assets" }, + }, + exports: { ArtifactFiles: { type: "durable-object", storage: "sqlite" }, ArtifactLinks: { type: "durable-object", storage: "sqlite" }, ScriptLibrary: { type: "durable-object", storage: "sqlite" }, ScriptBackend: { type: "durable-object", storage: "sqlite" }, ArtifactLibrary: { type: "durable-object", storage: "sqlite" }, ArtifactBackend: { type: "durable-object", storage: "sqlite" } }, + assets: { directory: join(import.meta.dir, "../dist/cloudflare/assets"), hasUserWorker: true, runWorkerFirst: true, htmlHandling: "none" }, + }, dev: {} }] }); + origin = (await runtime.ready).origin; + await publish("plugins/test-warehouse.json", JSON.stringify(record)); + await publish("plugins/test-misconfigured.json", JSON.stringify({ ...record, name: "test-misconfigured", secrets: ["PLUGIN_MISSING"] })); + await publish("plugins/test-corrupt.json", "{ not json"); + await publish("plugins/test-host-secret.json", JSON.stringify({ ...record, name: "test-host-secret", secrets: ["HOST_ONLY_SECRET"] })); + client = new Client({ name: "runtime-plugin-test", version: "1" }); + await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=test`))); +}, 60000); +afterAll(async () => { await client?.close(); await runtime?.dispose(); }); + +test("the bundle is one self-describing record with schemas and no browser module", () => { + expect(record).toMatchObject({ format: 1, name: "test-warehouse", secrets: ["PLUGIN_WAREHOUSE_TOKEN"] }); + expect(record.operations.map(operation => [operation.name, operation.readOnly])).toEqual([["query", true], ["restricted", false], ["broken", false]]); + expect(record.module).not.toContain("warehouse-test-token"); + expect(() => parseRuntimePlugin(record, "other-name")).toThrow("does not match"); + expect(() => parseRuntimePlugin({ ...record, hash: "x" })).toThrow("hash"); +}); + +test("published plugins are listed beside build-time ones; corrupt records are left out", async () => { + const result = await client.callTool({ name: "plugins_list", arguments: {} }); + const plugins = (result.structuredContent as { plugins: { name: string; browser: boolean; operations: { name: string }[] }[] }).plugins; + expect(plugins.map(plugin => plugin.name).sort()).toEqual(["test-misconfigured", "test-warehouse"]); + expect(plugins.find(plugin => plugin.name === "test-warehouse")).toMatchObject({ browser: false, operations: [{ name: "query" }, { name: "restricted" }, { name: "broken" }] }); +}); + +test("a call runs in the plugin's own isolate with the caller's identity and only its declared secrets", async () => { + const { result, payload } = await call("test-warehouse", "query", { sql: "select 1" }); + expect(result.isError, JSON.stringify(payload)).not.toBe(true); + expect(payload.result).toEqual({ + sql: "select 1", user: { subject: "local", authority: "local" }, secrets: { PLUGIN_WAREHOUSE_TOKEN: "warehouse-test-token" }, + version: "fixture-v1", processEnv: expect.not.arrayContaining(["HOST_ONLY_SECRET", "ENVIRONMENT", "LIBRARIES", "PLUGINS"]), + }); + // The gallery and hosted-artifact bridge use the same dispatcher. + const http = await fetch(`${origin}/api/plugins/call?workspace=test`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ plugin: "test-warehouse", operation: "query", input: { sql: "select 2" } }) }); + expect(http.status).toBe(200); + expect((await http.json() as { result: { sql: string } }).result.sql).toBe("select 2"); +}); + +test("schema, authorization and handler failures keep the build-time plugin semantics", async () => { + const cases: [string, string, unknown, string, number][] = [ + ["test-warehouse", "query", {}, "Invalid plugin input", 400], + ["test-warehouse", "restricted", {}, "Plugin operation is not allowed", 403], + ["test-warehouse", "broken", {}, "Plugin operation failed", 500], + ["test-warehouse", "missing", {}, "Plugin operation not found", 404], + ["test-warehouse", "toString", {}, "Plugin operation not found", 404], + ["not-published", "query", {}, "Plugin operation not found", 404], + ["test-misconfigured", "query", { sql: "select 1" }, "Plugin configuration unavailable", 500], + ["test-corrupt", "query", { sql: "select 1" }, "Plugin configuration unavailable", 500], + // A published record cannot ask for the host's own variables. + ["test-host-secret", "query", { sql: "select 1" }, "Plugin configuration unavailable", 500], + ]; + for (const [plugin, operation, input, error, status] of cases) { + const { result, payload } = await call(plugin, operation, input); + expect(result.isError, `${plugin}/${operation}`).toBe(true); + expect(payload, `${plugin}/${operation}`).toMatchObject({ error, status }); + expect(JSON.stringify(payload)).not.toContain("warehouse-test-token"); + expect(JSON.stringify(payload)).not.toContain("host-only-value"); + } +}); + +test("republishing the record replaces the running plugin without a host deploy", async () => { + const module = record.module.replace('"fixture-v1"', '"fixture-v2"'); + expect(module).not.toBe(record.module); + const hash = new Bun.CryptoHasher("sha256").update(module).digest("hex"); + await publish("plugins/test-warehouse.json", JSON.stringify({ ...record, module, hash })); + await Bun.sleep(CACHE_MS + 500); + const { payload } = await call("test-warehouse", "query", { sql: "select 1" }); + expect(payload.result.version).toBe("fixture-v2"); +}, 20000); diff --git a/cloudflare/runtime-plugins.ts b/cloudflare/runtime-plugins.ts new file mode 100644 index 0000000..78a0123 --- /dev/null +++ b/cloudflare/runtime-plugins.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { PluginError, PLUGIN_JSON_LIMIT, type PluginInvocationContext } from "../src/plugins/dispatch"; +import { + parseRuntimePlugin, runtimePluginInfo, runtimePluginKey, PLUGIN_NAME, RUNTIME_PLUGIN_MAX_BYTES, RUNTIME_PLUGIN_PREFIX, + type RuntimePluginRecord, +} from "../src/plugins/runtime"; +import type { PluginInfo, PluginUser } from "../src/plugins/types"; +import { scriptRuntimeConfig } from "./script-runtime"; + +/** Without a `PLUGINS` binding there are no runtime plugins; build-time plugins are unaffected. */ +export type RuntimePluginEnvironment = { PLUGINS?: R2Bucket; LOADER?: WorkerLoader; ARTIFACTS_RUNTIME?: string }; + +// Short, per-isolate caches: a republished plugin is picked up within seconds +// on every node without a host redeploy, and a busy dashboard does not read +// the bucket on every call. +const CACHE_MS = 5_000; +const ERROR_STATUSES = new Set([400, 403, 404, 413, 500, 504]); +const records = new Map(); +let catalog: { at: number; plugins: PluginInfo[] } | undefined; + +async function readRecord(bucket: R2Bucket, key: string, name: string): Promise { + const object = await bucket.get(key); + if (!object) return null; + if (object.size > RUNTIME_PLUGIN_MAX_BYTES) throw new Error("Plugin record exceeds 16 MiB"); + return parseRuntimePlugin(JSON.parse(await object.text()), name); +} + +export async function runtimePlugin(env: RuntimePluginEnvironment, name: string): Promise { + if (!env.PLUGINS || !PLUGIN_NAME.test(name)) return null; + const cached = records.get(name); + if (cached && Date.now() - cached.at < CACHE_MS) return cached.record; + let record: RuntimePluginRecord | null; + try { record = await readRecord(env.PLUGINS, runtimePluginKey(name), name); } + catch (error) { + console.error(`Runtime plugin ${name} could not be loaded`, error); + throw new PluginError("Plugin configuration unavailable", 500); + } + records.set(name, { at: Date.now(), record }); + return record; +} + +/** Catalog entries for every valid published plugin. An invalid record is + * logged and left out of the listing; calling it still reports the error. */ +export async function runtimePluginCatalog(env: RuntimePluginEnvironment): Promise { + if (!env.PLUGINS) return []; + if (catalog && Date.now() - catalog.at < CACHE_MS) return catalog.plugins; + const keys: string[] = []; + let cursor: string | undefined; + do { + const page = await env.PLUGINS.list({ prefix: RUNTIME_PLUGIN_PREFIX, cursor }); + keys.push(...page.objects.map(object => object.key).filter(key => key.endsWith(".json"))); + cursor = page.truncated ? page.cursor : undefined; + } while (cursor && keys.length <= 64); + const plugins: PluginInfo[] = []; + for (const key of keys.slice(0, 64)) { + const name = key.slice(RUNTIME_PLUGIN_PREFIX.length, -".json".length); + try { + const record = await readRecord(env.PLUGINS, key, name); + if (record) plugins.push(runtimePluginInfo(record)); + } catch (error) { console.error(`Runtime plugin ${name} is invalid`, error); } + } + catalog = { at: Date.now(), plugins }; + return plugins; +} + +/** Runs one operation in the plugin's own isolate. Its env holds only the + * secrets the record declares, read from the host's bindings; host credentials, + * Durable Objects and storage bindings never enter it. */ +export async function callRuntimePlugin(record: RuntimePluginRecord, operation: string, input: unknown, user: PluginUser, context: PluginInvocationContext, timeoutMs = 30_000): Promise { + const env = context.env as RuntimePluginEnvironment & Record; + if (!env.LOADER) throw new PluginError("Plugin configuration unavailable", 500); + const secrets: Record = {}; + for (const name of record.secrets) { + const value = Object.hasOwn(env, name) ? env[name] : undefined; + if (typeof value !== "string") throw new PluginError("Plugin configuration unavailable", 500); + secrets[name] = value; + } + const config = scriptRuntimeConfig(env.ARTIFACTS_RUNTIME); + const key = createHash("sha256").update(JSON.stringify(["runtime-plugin", record.name, record.hash, secrets, config])).digest("hex"); + const worker = env.LOADER.get(key, async () => ({ ...config, mainModule: "plugin.js", modules: { "plugin.js": record.module }, env: secrets })); + // The isolate enforces the same deadline; this bounds a module that never answers. + const signal = AbortSignal.timeout(timeoutMs + 1_000); + let text: string; + try { + const response = await worker.getEntrypoint().fetch(new Request("https://plugin.invalid/call", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ operation, input, user }), signal, + })); + text = await response.text(); + } catch (error) { + if (signal.aborted) throw new PluginError("Plugin operation timed out", 504); + console.error(`Runtime plugin ${record.name} failed to run`, error); + throw new PluginError("Plugin operation failed", 500); + } + if (new TextEncoder().encode(text).byteLength > PLUGIN_JSON_LIMIT + 1024) throw new PluginError("Plugin result exceeds 256 KiB", 500); + let reply: { result?: unknown; error?: unknown; status?: unknown }; + try { reply = JSON.parse(text); } catch { throw new PluginError("Plugin returned invalid JSON", 500); } + if (reply && Object.hasOwn(reply, "result")) return reply.result; + const status = typeof reply?.status === "number" && ERROR_STATUSES.has(reply.status) ? reply.status as 400 | 403 | 404 | 413 | 500 | 504 : 500; + throw new PluginError(typeof reply?.error === "string" ? reply.error.slice(0, 200) : "Plugin operation failed", status); +} diff --git a/cloudflare/service.ts b/cloudflare/service.ts index 2500768..19ecca5 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -28,7 +28,7 @@ import { workspaceTool } from "./workspace-tools"; import { galleryAction, gallerySource } from "./gallery-tools"; import { ARTIFACTS_SETTINGS_TOOLS, settingsTool } from "../src/mcp/settings-contract"; -import { dispatchPlugin, pluginCatalog, PLUGIN_GUIDE, type PluginInvocationContext } from "./plugins"; +import { dispatchPlugin, pluginCatalogFor, PLUGIN_GUIDE, type PluginInvocationContext } from "./plugins"; import type { PluginRequest } from "../src/plugins/types"; type Snapshot = { @@ -75,7 +75,10 @@ export class CloudArtifactService { const result = await this.fileRequest(args as { name?: string; version_id?: string }, args.request as ArtifactFileRequest); return { ...text({ result }), structuredContent: { result } }; } - case "plugins_list": return { ...text({ plugins: pluginCatalog }), structuredContent: { plugins: pluginCatalog } }; + case "plugins_list": { + const plugins = await pluginCatalogFor(this.plugins?.env ?? {}); + return { ...text({ plugins }), structuredContent: { plugins } }; + } case "plugin_guide": return text(PLUGIN_GUIDE); case "artifact_plugin_call": { const result = await this.pluginCall(args as PluginRequest); diff --git a/docs/runtime-plugins.md b/docs/runtime-plugins.md index 82c914b..b208fff 100644 --- a/docs/runtime-plugins.md +++ b/docs/runtime-plugins.md @@ -79,6 +79,76 @@ Inputs and outputs are JSON, limited to 256 KiB. Inputs are validated against `i Server-side plugin functions follow deployment upgrades. Browser libraries and the Artifacts SDK are included in the compiled revision saved on a successful edit; reopening that revision reuses its bundle across restarts and upgrades. A subsequent edit or explicit compilation of the working artifact uses the current build configuration. Keep server APIs compatible with saved browser bundles. This foundation does not manage provider OAuth, install packages at runtime, add artifact permission manifests or implement routing. +## Publish a plugin without rebuilding + +A plugin that only provides server functions can also be published into a running +deployment. A published plugin needs no rebuild or redeploy of Artifacts: replacing its +file replaces the plugin. Add an R2 binding named `PLUGINS` to the deployment's +Wrangler configuration: + +```json +"r2_buckets": [ + { "binding": "FILES", "bucket_name": "canvas-files" }, + { "binding": "PLUGINS", "bucket_name": "artifacts-plugins" } +] +``` + +Write the plugin as one module whose default export is `definePlugin(...)`. It takes +the same fields as an entry in `artifacts.plugins.ts`, except `browser` and `types`: + +```ts +import { definePlugin } from "@sidequery/artifacts/plugins"; + +export default definePlugin({ + name: "warehouse", + description: "Read-only warehouse queries", + secrets: ["PLUGIN_WAREHOUSE_TOKEN"], + operations: { + query: { + description: "Run a read-only SQL query", + readOnly: true, + inputSchema: { type: "object", properties: { sql: { type: "string", minLength: 1 } }, required: ["sql"], additionalProperties: false }, + async handler(input: { sql: string }, { secrets, signal }) { + // Call the warehouse with secrets.PLUGIN_WAREHOUSE_TOKEN. + }, + }, + }, +}); +``` + +Bundle it from a checkout of this repository, then upload the result to +`plugins/.json` in that bucket: + +```sh +bun run scripts/bundle-plugin.ts warehouse.plugin.ts --out warehouse.json +# celld: writes the fleet bucket directly; no node needs to be running. +celld r2 put artifacts-plugins plugins/warehouse.json --path warehouse.json --content-type application/json --bucket s3:// +# Cloudflare +bun x wrangler r2 object put artifacts-plugins/plugins/warehouse.json --file warehouse.json --remote +``` + +The bundle is one file: the handlers, their dependencies, and the input/output +validators. Each node uses a new or replaced file within about five seconds. Delete +the object to remove the plugin. + +A published plugin works like a build-time plugin. It appears in `plugins_list` and +is called with `artifact_plugin_call` or `pluginCall`. It has the same schemas, +`authorize`, 30 second limit, 256 KiB limit and generic errors. It differs in these +ways: + +- **Isolation.** It runs in its own isolate. Its environment contains only the secrets + it declares, read from deployment variables with the same names. Other variables, + bindings and storage are unavailable to it. +- **Secrets.** It can declare only variables named `PLUGIN_*`. Write access to the + bucket therefore never exposes the deployment's own credentials. Adding a secret + still means changing the deployment's variables. +- **Server only.** Browser libraries still require a deployment build. Artifacts call + published plugins with `pluginCall` directly. +- **Names.** A build-time plugin with the same name takes precedence. + +Anyone who can write `plugins/` in that bucket can run code with the `PLUGIN_*` secrets +it declares. Give that write access only to your release pipeline. + ## Complete example The [runner-status example](../examples/runner-status/README.md) adapts an existing GitHub Actions microapp into a private routed artifact and a read-only MCP operation. It includes a shared hosted collector, explicit caller authorization, persisted snapshots, setup instructions and credential-free tests. diff --git a/e2e/celld-auth-runtime.ts b/e2e/celld-auth-runtime.ts index 29d4b02..48aabee 100644 --- a/e2e/celld-auth-runtime.ts +++ b/e2e/celld-auth-runtime.ts @@ -16,7 +16,7 @@ type RuntimeOptions = { bindings: Record; }; -type RunningCelld = { +export type RunningCelld = { process: ReturnType; logs: () => string; drained: Promise; @@ -83,7 +83,7 @@ async function capture(stream: ReadableStream | number | undefined, } } -function spawnCelld(binary: string, esbuild: string, project: string, port: number): RunningCelld { +export function spawnCelld(binary: string, esbuild: string, project: string, port: number): RunningCelld { let output = ""; const process = Bun.spawn({ cmd: [binary, "dev", project, "--host", "127.0.0.1", "--port", String(port), "--no-watch", "--logs"], @@ -97,7 +97,7 @@ function spawnCelld(binary: string, esbuild: string, project: string, port: numb return { process, logs: () => output, drained }; } -async function waitForReady(runtime: RunningCelld, url: string) { +export async function waitForReady(runtime: RunningCelld, url: string) { const deadline = Date.now() + START_TIMEOUT_MS; let lastResponse = "no HTTP response"; while (Date.now() < deadline) { @@ -117,7 +117,7 @@ async function waitForReady(runtime: RunningCelld, url: string) { throw new Error(`celld did not become ready within ${START_TIMEOUT_MS}ms (${lastResponse}):\n${runtime.logs()}`); } -async function stopCelld(runtime: RunningCelld | undefined) { +export async function stopCelld(runtime: RunningCelld | undefined) { if (!runtime) return; if (runtime.process.exitCode === null) runtime.process.kill("SIGINT"); const stopped = await Promise.race([ diff --git a/e2e/runtime-plugin-fixture.ts b/e2e/runtime-plugin-fixture.ts new file mode 100644 index 0000000..1f8bc00 --- /dev/null +++ b/e2e/runtime-plugin-fixture.ts @@ -0,0 +1,33 @@ +import { definePlugin } from "@sidequery/artifacts/plugins"; + +const VERSION = "fixture-v1"; + +/** Published at runtime by cloudflare/runtime-plugins.test.ts. */ +export default definePlugin({ + name: "test-warehouse", + description: "Runtime plugin test fixture", + secrets: ["PLUGIN_WAREHOUSE_TOKEN"], + operations: { + query: { + description: "Echo a query with the caller and what this isolate can see", + readOnly: true, + inputSchema: { type: "object", properties: { sql: { type: "string", minLength: 1 } }, required: ["sql"], additionalProperties: false }, + outputSchema: { type: "object", required: ["sql", "user", "secrets", "version", "processEnv"] }, + handler(input: { sql: string }, { user, secrets }) { + const processEnv = typeof process === "undefined" ? [] : Object.keys(process.env ?? {}).sort(); + return { sql: input.sql, user, secrets: { ...secrets }, version: VERSION, processEnv }; + }, + }, + restricted: { + description: "Allowed for nobody", + inputSchema: { type: "object" }, + authorize: user => user.subject === "nobody", + handler: () => ({ reached: true }), + }, + broken: { + description: "Throws with provider details", + inputSchema: { type: "object" }, + handler(_input, { secrets }) { throw new Error(`provider failed with ${secrets.PLUGIN_WAREHOUSE_TOKEN}`); }, + }, + }, +}); diff --git a/package.json b/package.json index 2b1c6eb..c24e081 100644 --- a/package.json +++ b/package.json @@ -168,7 +168,7 @@ "build:cloudflare-compiler": "wrangler deploy --config cloudflare/wrangler.compiler.jsonc --dry-run --outdir ../dist/worker", "typecheck:cloudflare": "tsc -p cloudflare/tsconfig.json --noEmit && tsc -p examples/runner-status/tsconfig.worker.json --noEmit", "test:cloudflare": "bun run build:cloudflare-compiler && bun run build:cloudflare && bun test cloudflare --timeout 60000", - "test:celld": "bun run build:cloudflare && bun run prepare:celld && CELLD_INTEGRATION=1 CELLD_AUTH_INTEGRATION=1 CELLD_BACKEND_INTEGRATION=1 CELLD_SCHEDULING_INTEGRATION=1 bun test cloudflare/backend.test.ts cloudflare/celld.test.ts cloudflare/auth-flow.test.ts cloudflare/scheduling-celld.test.ts --timeout 180000", + "test:celld": "bun run build:cloudflare && bun run prepare:celld && CELLD_INTEGRATION=1 CELLD_AUTH_INTEGRATION=1 CELLD_BACKEND_INTEGRATION=1 CELLD_SCHEDULING_INTEGRATION=1 bun test cloudflare/backend.test.ts cloudflare/celld.test.ts cloudflare/auth-flow.test.ts cloudflare/scheduling-celld.test.ts cloudflare/runtime-plugins-celld.test.ts --timeout 180000", "deploy:cloudflare": "wrangler deploy", "seed:cloudflare": "bun run scripts/seed-cloudflare.ts", "example:runner-status": "bun run examples/runner-status/local.ts", diff --git a/scripts/bundle-plugin.ts b/scripts/bundle-plugin.ts new file mode 100644 index 0000000..1c2bf6a --- /dev/null +++ b/scripts/bundle-plugin.ts @@ -0,0 +1,61 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; +import { pluginInfo, pluginValidatorModule, validatePlugins } from "./prepare-plugins"; +import { parseRuntimePlugin, runtimePluginKey, type RuntimePluginRecord } from "../src/plugins/runtime"; + +const root = resolve(import.meta.dir, ".."); + +/** + * Builds the self-contained record a deployment loads from `plugins/.json` + * in its `PLUGINS` bucket. The plugin module's default export is one + * `definePlugin(...)` with server operations. Its handlers, its dependencies, the + * shared dispatcher and standalone schema validators are bundled into one ES + * module, so the host never compiles or evaluates anything but that module, and + * only inside the plugin's own isolate. + */ +export async function bundleRuntimePlugin(entry: string): Promise { + const entryPath = resolve(entry); + const [plugin] = validatePlugins([(await import(pathToFileURL(entryPath).href)).default]); + if (plugin!.browser || plugin!.types) throw new Error(`${plugin!.name}: published plugins provide server operations only; browser modules need a deployment build`); + if (!Object.keys(plugin!.operations ?? {}).length) throw new Error(`${plugin!.name} has no server operations`); + const info = pluginInfo(plugin!); + const work = await mkdtemp(join(tmpdir(), "artifacts-plugin-")); + try { + await writeFile(join(work, "validators.js"), pluginValidatorModule([info])); + await writeFile(join(work, "entry.js"), [ + `import plugin from ${JSON.stringify(entryPath)};`, + 'import { validatePluginInput, validatePluginOutput } from "./validators.js";', + `import { runtimePluginHandler } from ${JSON.stringify(join(root, "src/plugins/runtime-entry.ts"))};`, + "export default runtimePluginHandler(plugin, { input: validatePluginInput, output: validatePluginOutput });", + ].join("\n")); + const bundle = await build({ + entryPoints: [join(work, "entry.js")], bundle: true, write: false, format: "esm", target: "es2022", + platform: "browser", conditions: ["workerd", "worker", "browser"], external: ["node:*", "cloudflare:*"], + // Validators import Ajv's runtime helpers; plugins may import the typed helper without installing the package. + nodePaths: [join(root, "node_modules")], + alias: { "@sidequery/artifacts/plugins": join(root, "src/plugins/config.ts") }, + define: { "process.env.NODE_ENV": '"production"' }, legalComments: "none", logLevel: "silent", + }); + if (bundle.outputFiles.length !== 1) throw new Error(`${plugin!.name} must bundle into one module`); + const module = bundle.outputFiles[0]!.text; + return parseRuntimePlugin({ + format: 1, name: plugin!.name, description: plugin!.description, secrets: [...(plugin!.secrets ?? [])], + operations: info.operations, module, hash: createHash("sha256").update(module).digest("hex"), + }); + } finally { await rm(work, { recursive: true, force: true }); } +} + +if (import.meta.main) { + const [entry, flag, out] = process.argv.slice(2); + if (!entry || flag !== "--out" || !out) { + console.error("usage: bun run scripts/bundle-plugin.ts --out "); + process.exit(2); + } + const record = await bundleRuntimePlugin(entry); + await writeFile(out, JSON.stringify(record)); + console.log(JSON.stringify({ name: record.name, key: runtimePluginKey(record.name), hash: record.hash, operations: record.operations.map(operation => operation.name), secrets: record.secrets })); +} diff --git a/scripts/prepare-plugins.ts b/scripts/prepare-plugins.ts index 0db2616..7825ea8 100644 --- a/scripts/prepare-plugins.ts +++ b/scripts/prepare-plugins.ts @@ -40,21 +40,18 @@ export function validatePlugins(value: unknown): readonly ArtifactPlugin[] { return value as ArtifactPlugin[]; } -export async function preparePlugins(root: string, output: string, selectedConfig = process.env.ARTIFACTS_PLUGINS_CONFIG ?? process.env.CANVAS_PLUGINS_CONFIG) { - const defaultConfig = existsSync(resolve(root, "artifacts.plugins.ts")) ? "artifacts.plugins.ts" - : existsSync(resolve(root, "canvas.plugins.ts")) ? "canvas.plugins.ts" : "artifacts.plugins.ts"; - const configPath = resolve(root, selectedConfig ?? defaultConfig); - if (selectedConfig && !existsSync(configPath)) throw new Error("ARTIFACTS_PLUGINS_CONFIG does not exist"); - const configured = existsSync(configPath); - const plugins = validatePlugins(configured ? (await import(pathToFileURL(configPath).href)).default : []); - const browser = await prepareBrowserPlugins(plugins, configured ? dirname(configPath) : root); - const catalog: PluginInfo[] = plugins.map(plugin => ({ +export function pluginInfo(plugin: ArtifactPlugin): PluginInfo { + return { name: plugin.name, description: plugin.description, browser: Boolean(plugin.browser), operations: Object.entries(plugin.operations ?? {}).map(([name, operation]) => ({ name, description: operation.description, inputSchema: operation.inputSchema, ...(operation.outputSchema ? { outputSchema: operation.outputSchema } : {}), readOnly: operation.readOnly === true, })), - })); + }; +} + +/** Standalone Ajv validators as an ES module, so no schema is compiled at request time. */ +export function pluginValidatorModule(catalog: readonly PluginInfo[]): string { const ajv = new Ajv({ allErrors: true, strict: false, code: { source: true, esm: true } }); const exports: Record = {}; const inputs: string[] = [], outputs: string[] = []; @@ -69,9 +66,21 @@ export async function preparePlugins(root: string, output: string, selectedConfi outputs.push(`${key}:${outputName}`); } else outputs.push(`${key}:()=>true`); } - const validation = standaloneCode(ajv, exports) + `\nconst inputs={${inputs.join(",")}},outputs={${outputs.join(",")}};\n` + return standaloneCode(ajv, exports) + `\nconst inputs={${inputs.join(",")}},outputs={${outputs.join(",")}};\n` + 'export function validatePluginInput(plugin,operation,input){const key=plugin+"\\0"+operation;return Object.hasOwn(inputs,key)&&inputs[key](input);}\n' + 'export function validatePluginOutput(plugin,operation,output){const key=plugin+"\\0"+operation;return Object.hasOwn(outputs,key)&&outputs[key](output);}\n'; +} + +export async function preparePlugins(root: string, output: string, selectedConfig = process.env.ARTIFACTS_PLUGINS_CONFIG ?? process.env.CANVAS_PLUGINS_CONFIG) { + const defaultConfig = existsSync(resolve(root, "artifacts.plugins.ts")) ? "artifacts.plugins.ts" + : existsSync(resolve(root, "canvas.plugins.ts")) ? "canvas.plugins.ts" : "artifacts.plugins.ts"; + const configPath = resolve(root, selectedConfig ?? defaultConfig); + if (selectedConfig && !existsSync(configPath)) throw new Error("ARTIFACTS_PLUGINS_CONFIG does not exist"); + const configured = existsSync(configPath); + const plugins = validatePlugins(configured ? (await import(pathToFileURL(configPath).href)).default : []); + const browser = await prepareBrowserPlugins(plugins, configured ? dirname(configPath) : root); + const catalog = plugins.map(pluginInfo); + const validation = pluginValidatorModule(catalog); await mkdir(output, { recursive: true }); let configImport = relative(output, configPath).replaceAll("\\", "/"); if (!configImport.startsWith(".") && !isAbsolute(configImport)) configImport = `./${configImport}`; diff --git a/src/plugins/config.ts b/src/plugins/config.ts index 079f6d1..475868b 100644 --- a/src/plugins/config.ts +++ b/src/plugins/config.ts @@ -33,3 +33,8 @@ export type ArtifactPlugin = { export function definePlugins(plugins: T): T { return plugins; } + +/** One plugin per module, for publishing at runtime with scripts/bundle-plugin.ts. */ +export function definePlugin(plugin: T): T { + return plugin; +} diff --git a/src/plugins/dispatch.ts b/src/plugins/dispatch.ts new file mode 100644 index 0000000..06dda3d --- /dev/null +++ b/src/plugins/dispatch.ts @@ -0,0 +1,71 @@ +import type { ArtifactPlugin } from "./config"; +import type { PluginRequest, PluginUser } from "./types"; + +/** Dependency-free so both the host and a runtime plugin's own isolate (see + * ./runtime-entry.ts) run the same validation, authorization and error rules. */ +export const PLUGIN_JSON_LIMIT = 256 * 1024; +export type PluginInvocationContext = { user: PluginUser; env: object }; +export type PluginValidators = { + input: (plugin: string, operation: string, input: unknown) => boolean; + output: (plugin: string, operation: string, output: unknown) => boolean; +}; +export class PluginError extends Error { + constructor(message: string, readonly status: 400 | 401 | 403 | 404 | 413 | 500 | 504) { super(message); } +} + +export function pluginJson(value: unknown, output = false): unknown { + let serialized: string | undefined; + try { + serialized = JSON.stringify(value, (_key, part) => { + if (typeof part === "number" && !Number.isFinite(part) || ["undefined", "function", "symbol", "bigint"].includes(typeof part)) throw new Error(); + return part; + }); + } catch { throw new PluginError(output ? "Plugin returned invalid JSON" : "Input must be JSON", output ? 500 : 400); } + if (serialized === undefined) throw new PluginError(output ? "Plugin returned invalid JSON" : "Input must be JSON", output ? 500 : 400); + if (new TextEncoder().encode(serialized).byteLength > PLUGIN_JSON_LIMIT) throw new PluginError(output ? "Plugin result exceeds 256 KiB" : "Plugin input exceeds 256 KiB", output ? 500 : 413); + return JSON.parse(serialized); +} + +/** Authentication and request shape, checked before a plugin is looked up. */ +export function assertPluginRequest(request: PluginRequest, context?: PluginInvocationContext): asserts context is PluginInvocationContext { + if (!context?.user.subject || !context.user.authority) throw new PluginError("Plugin sign-in required", 401); + if (!request || typeof request !== "object" || typeof request.plugin !== "string" || typeof request.operation !== "string" || !Object.hasOwn(request, "input") || Object.keys(request).some(key => !["plugin", "operation", "input"].includes(key))) throw new PluginError("Invalid plugin request", 400); +} + +export function createPluginDispatcher(plugins: readonly ArtifactPlugin[], validators: PluginValidators, timeoutMs = 30_000) { + return async (request: PluginRequest, context?: PluginInvocationContext): Promise => { + assertPluginRequest(request, context); + const plugin = plugins.find(plugin => plugin.name === request.plugin); + const operation = plugin?.operations && Object.hasOwn(plugin.operations, request.operation) ? plugin.operations[request.operation] : undefined; + if (!plugin || !operation) throw new PluginError("Plugin operation not found", 404); + const input = pluginJson(request.input); + if (!validators.input(request.plugin, request.operation, input)) throw new PluginError("Invalid plugin input", 400); + // Handlers are trusted deployment code. The signal cancels cooperative I/O; + // the deadline bounds asynchronous work even if it ignores cancellation. + const controller = new AbortController(); + let timer: ReturnType; + const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { controller.abort(); reject(new PluginError("Plugin operation timed out", 504)); }, timeoutMs); }); + try { + return await Promise.race([timeout, (async () => { + const user = Object.freeze({ subject: context.user.subject, authority: context.user.authority }); + let authorized: boolean; + try { authorized = operation.authorize ? await operation.authorize(user) : true; } + catch { throw new PluginError("Plugin authorization failed", 500); } + if (!authorized) throw new PluginError("Plugin operation is not allowed", 403); + if (controller.signal.aborted) throw new PluginError("Plugin operation timed out", 504); + const secrets: Record = Object.create(null); + for (const name of plugin.secrets ?? []) { + const value = Object.hasOwn(context.env, name) ? (context.env as Record)[name] : undefined; + if (typeof value !== "string") throw new PluginError("Plugin configuration unavailable", 500); + secrets[name] = value; + } + let result: unknown; + try { result = await operation.handler(input, { user, secrets: Object.freeze(secrets), signal: controller.signal }); } + catch { throw new PluginError("Plugin operation failed", 500); } + const output = pluginJson(result, true); + if (!validators.output(request.plugin, request.operation, output)) throw new PluginError("Plugin returned invalid output", 500); + return output; + })()]); + } finally { clearTimeout(timer!); } + }; +} diff --git a/src/plugins/runtime-entry.ts b/src/plugins/runtime-entry.ts new file mode 100644 index 0000000..49b36a5 --- /dev/null +++ b/src/plugins/runtime-entry.ts @@ -0,0 +1,29 @@ +import type { ArtifactPlugin } from "./config"; +import { createPluginDispatcher, PluginError, type PluginValidators } from "./dispatch"; +import type { PluginUser } from "./types"; + +/** + * The default export of a runtime plugin's bundled module (see + * scripts/bundle-plugin.ts). It runs in the plugin's own isolate, whose `env` + * holds only the plugin's declared secrets, and applies the same dispatcher as + * build-time plugins: input/output schemas, `authorize`, timeout, and fixed, + * provider-free error messages. The host reads `{ result }` or `{ error, status }`. + */ +export function runtimePluginHandler(plugin: ArtifactPlugin, validators: PluginValidators) { + const dispatch = createPluginDispatcher([plugin], validators); + return { + async fetch(request: Request, env: Record): Promise { + let call: { operation?: unknown; input?: unknown; user?: PluginUser }; + try { call = await request.json(); } + catch { return Response.json({ error: "Invalid plugin request", status: 400 }); } + const user = call?.user && typeof call.user.subject === "string" && typeof call.user.authority === "string" + ? { subject: call.user.subject, authority: call.user.authority } : undefined; + try { + const result = await dispatch({ plugin: plugin.name, operation: call.operation as string, input: call.input }, user && { user, env }); + return Response.json({ result }); + } catch (error) { + return Response.json(error instanceof PluginError ? { error: error.message, status: error.status } : { error: "Plugin operation failed", status: 500 }); + } + }, + }; +} diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts new file mode 100644 index 0000000..bc8d856 --- /dev/null +++ b/src/plugins/runtime.ts @@ -0,0 +1,59 @@ +import type { PluginInfo, PluginOperationInfo } from "./types"; + +/** + * A plugin published at runtime: one self-contained object per plugin under + * `plugins/` in the deployment's optional `PLUGINS` R2 bucket. `module` is the + * bundled server entry (scripts/bundle-plugin.ts); the host runs it in its own + * isolate and reads only the metadata here. Replacing the object publishes a + * new version; nothing is compiled by the host. + */ +export type RuntimePluginRecord = { + format: 1; + name: string; + description: string; + secrets: string[]; + operations: PluginOperationInfo[]; + module: string; + /** SHA-256 of `module`; part of the isolate cache key. */ + hash: string; +}; + +export const RUNTIME_PLUGIN_PREFIX = "plugins/"; +export const RUNTIME_PLUGIN_MAX_BYTES = 16 * 1024 * 1024; +export const PLUGIN_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +/** Published plugins read only `PLUGIN_*` deployment variables, so write + * access to the bucket never reaches the host's own credentials (auth secret, + * provider and fleet tokens). Build-time plugins are unrestricted. */ +export const RUNTIME_PLUGIN_SECRET = /^PLUGIN_[A-Z0-9_]{1,64}$/; +const OPERATION_NAME = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; + +export function runtimePluginKey(name: string): string { + if (!PLUGIN_NAME.test(name)) throw new Error("Invalid plugin name"); + return `${RUNTIME_PLUGIN_PREFIX}${name}.json`; +} + +const isSchema = (value: unknown) => !!value && typeof value === "object" && !Array.isArray(value); + +/** Validates a stored record. A record that fails is a configuration error, never an empty plugin. */ +export function parseRuntimePlugin(value: unknown, expectedName?: string): RuntimePluginRecord { + const record = value as Partial | null; + if (!record || typeof record !== "object" || record.format !== 1) throw new Error("Unsupported plugin record format"); + if (typeof record.name !== "string" || !PLUGIN_NAME.test(record.name) || (expectedName !== undefined && record.name !== expectedName)) throw new Error("Plugin record name does not match its key"); + if (typeof record.description !== "string" || record.description.length > 8192) throw new Error("Invalid plugin description"); + if (!Array.isArray(record.secrets) || record.secrets.length > 32 || record.secrets.some(name => typeof name !== "string" || !RUNTIME_PLUGIN_SECRET.test(name))) throw new Error("Published plugin secrets must be named PLUGIN_*"); + if (!Array.isArray(record.operations) || !record.operations.length || record.operations.length > 128) throw new Error("Invalid plugin operations"); + const names = new Set(); + for (const operation of record.operations) { + if (!operation || typeof operation.name !== "string" || !OPERATION_NAME.test(operation.name) || names.has(operation.name) + || typeof operation.description !== "string" || operation.description.length > 8192 || typeof operation.readOnly !== "boolean" + || !isSchema(operation.inputSchema) || (operation.outputSchema !== undefined && !isSchema(operation.outputSchema))) throw new Error("Invalid plugin operation"); + names.add(operation.name); + } + if (typeof record.module !== "string" || !record.module) throw new Error("Plugin record has no module"); + if (typeof record.hash !== "string" || !/^[0-9a-f]{64}$/.test(record.hash)) throw new Error("Invalid plugin module hash"); + return record as RuntimePluginRecord; +} + +export function runtimePluginInfo(record: RuntimePluginRecord): PluginInfo { + return { name: record.name, description: record.description, browser: false, operations: record.operations }; +}