Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
80233d2
feat(workerd): add LOADER spike validating miniflare for Node plugin …
BenjaminPrice Apr 10, 2026
8febef8
feat(workerd): add WorkerdSandboxRunner with backing service and capn…
BenjaminPrice Apr 10, 2026
8d96472
feat(core): add isHealthy() to SandboxRunner and SandboxUnavailableError
BenjaminPrice Apr 10, 2026
3b0568b
feat(workerd): use core's HTTP access for redirect validation and SSR…
BenjaminPrice Apr 10, 2026
83347fd
feat(core): add sandbox: false escape hatch and improve unavailabilit…
BenjaminPrice Apr 10, 2026
927757f
feat(workerd): add MiniflareDevRunner and extract shared bridge handler
BenjaminPrice Apr 10, 2026
0ad0b8e
test(workerd): add bridge handler conformance test suite
BenjaminPrice Apr 10, 2026
999917f
docs: update sandbox.mdx with Node.js workerd sandboxing instructions
BenjaminPrice Apr 10, 2026
cbb26b4
fix(workerd): rewrite bridge handler for Cloudflare parity
BenjaminPrice Apr 10, 2026
4eed854
test(workerd): add plugin integration tests exercising real plugin op…
BenjaminPrice Apr 10, 2026
6408809
chore: add changeset for SandboxRunner interface changes
BenjaminPrice Apr 10, 2026
a5e3dc6
fix(workerd,core): address multi-round review feedback
BenjaminPrice Apr 10, 2026
bf638f4
fix(workerd,core): address maintainer review feedback
BenjaminPrice May 3, 2026
544ebf0
fix(workerd): fix capnp identifier naming and integration test assert…
BenjaminPrice May 3, 2026
82985f2
docs: add Node.js workerd sandbox setup to restructured docs
BenjaminPrice May 3, 2026
de6243c
fix(workerd): enable Unix socket backing service
BenjaminPrice May 3, 2026
49f4414
Fix sandbox bypass flag
ascorbic May 4, 2026
e370a96
Add emdash module shim
ascorbic May 4, 2026
153b421
fix(workerd): preserve content-type on URLSearchParams body in plugin…
BenjaminPrice May 21, 2026
11d7b3f
fix(workerd): clamp negative limit on bridge list endpoints
BenjaminPrice May 21, 2026
11ca6db
fix(workerd): readiness probe checks every plugin port
BenjaminPrice May 21, 2026
3d5a8bd
fix(workerd): make batch content ops transactional
BenjaminPrice May 21, 2026
47779a3
fix(workerd): gate workerd exit handler on process identity
BenjaminPrice May 21, 2026
bec7033
fix(workerd): surface eager-start failures and feed restart accounting
BenjaminPrice May 21, 2026
3d82462
fix(workerd): clear SIGKILL timer when workerd exits cleanly
BenjaminPrice May 21, 2026
064e90d
fix(workerd): reclaim plugin ports on unload
BenjaminPrice May 21, 2026
9579288
fix(workerd): remove dead tokenToPluginId map from backing service
BenjaminPrice May 21, 2026
845d088
fix(workerd): spawn workerd with a minimal env, not the full parent env
BenjaminPrice May 21, 2026
4f15fc1
merge: upstream/main into feat/node-plugin-isolation
BenjaminPrice May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bumpy-crabs-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"emdash": minor
"@emdash-cms/cloudflare": patch
"@emdash-cms/sandbox-workerd": minor
---

Adds workerd-based plugin sandboxing for Node.js deployments.

- **emdash**: Adds `isHealthy()` to `SandboxRunner` interface, `SandboxUnavailableError` class, `sandbox: false` config option, `mediaStorage` field on `SandboxOptions`, and exports `createHttpAccess`/`createUnrestrictedHttpAccess`/`PluginStorageRepository`/`UserRepository`/`OptionsRepository` for platform adapters.
- **@emdash-cms/cloudflare**: Implements `isHealthy()` on `CloudflareSandboxRunner`. Fixes `storageQuery()` and `storageCount()` to honor `where`, `orderBy`, and `cursor` options (previously ignored, causing infinite pagination loops and incorrect filtered counts). Adds `storageConfig` to `PluginBridgeProps` so `PluginStorageRepository` can use declared indexes.
- **@emdash-cms/sandbox-workerd**: New package. `WorkerdSandboxRunner` for production (workerd child process + capnp config + authenticated HTTP backing service) and `MiniflareDevRunner` for development.
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ When a sandbox runner is active, the runtime enforces:

4. **No host bindings.** Sandboxed plugins don't see environment variables, the filesystem, or any platform bindings — even if your host worker has them. The plugin runtime is a clean isolate with only the bridge and the declared capabilities.

5. **Resource limits.** The runner can enforce CPU, subrequest, wall-clock, and memory limits per invocation. The exact limits depend on which runner you're using; the Cloudflare runner uses the platform's Worker Loader limits (50ms CPU per invocation, 10 subrequests, 30 second wall-clock, ~128MB memory). Hooks that exceed the runner's limits are aborted; the EmDash hook timeout (`timeout` in the hook config) enforces a stricter ceiling on top of that.
5. **Resource limits.** The runner can enforce CPU, subrequest, wall-clock, and memory limits per invocation. The exact limits depend on which runner you're using; the Cloudflare runner uses the platform's Worker Loader limits (50ms CPU per invocation, 10 subrequests, 30 second wall-clock, ~128MB memory). The Node.js workerd runner (`@emdash-cms/sandbox-workerd`) enforces wall-clock time via `Promise.race`; CPU and memory limits are Cloudflare platform features and are not enforced by standalone workerd. Hooks that exceed the runner's limits are aborted; the EmDash hook timeout (`timeout` in the hook config) enforces a stricter ceiling on top of that.

</Steps>

Expand Down
21 changes: 21 additions & 0 deletions docs/src/content/docs/plugins/installing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ To install marketplace plugins, your site needs:
});
```

On **Cloudflare Workers**, sandboxing uses the Dynamic Worker Loader API (no additional setup needed). On **Node.js**, install the workerd sandbox runner:

```bash
npm install @emdash-cms/sandbox-workerd
```

Then pass the runner explicitly:

```typescript title="astro.config.mjs"
emdash({
marketplace: "https://marketplace.emdashcms.com",
sandboxRunner: "@emdash-cms/sandbox-workerd/sandbox",
})
```

In development, install `miniflare` as a dev dependency for faster sandbox startup:

```bash
npm install -D miniflare
```

2. **Admin access** — Only administrators can install or remove plugins.

### Browse and Install
Expand Down
80 changes: 55 additions & 25 deletions packages/cloudflare/src/sandbox/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
*
*/

import type { D1Database } from "@cloudflare/workers-types";
import { WorkerEntrypoint } from "cloudflare:workers";
import type { SandboxEmailSendCallback } from "emdash";
import { ulid } from "emdash";
import { ulid, PluginStorageRepository } from "emdash";
import { Kysely } from "kysely";
import { D1Dialect } from "kysely-d1";

import { sandboxHttpFetch } from "./bridge-http.js";

Expand Down Expand Up @@ -127,6 +130,11 @@ export interface PluginBridgeProps {
capabilities: string[];
allowedHosts: string[];
storageCollections: string[];
/** Per-collection storage config (matches manifest.storage entries) */
storageConfig?: Record<
string,
{ indexes?: Array<string | string[]>; uniqueIndexes?: Array<string | string[]> }
>;
}

/**
Expand All @@ -141,6 +149,28 @@ export interface PluginBridgeProps {
* 3. Plugins call bridge methods which validate and proxy to the database
*/
export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridgeProps> {
/**
* Construct a PluginStorageRepository for the requested collection.
* Uses the indexes from the plugin's storage config (if provided) so
* query/count operations support WHERE/ORDER BY/cursor pagination
* matching in-process and workerd sandbox plugins.
*/
private getStorageRepo(collection: string): PluginStorageRepository {
const { pluginId, storageConfig } = this.ctx.props;
const config = storageConfig?.[collection];
// Merge unique indexes into the indexes list since both are queryable
const allIndexes: Array<string | string[]> = [
...(config?.indexes ?? []),
...(config?.uniqueIndexes ?? []),
];
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- D1 is the kysely-d1 dialect database type
const db = new Kysely<unknown>({
dialect: new D1Dialect({ database: this.env.DB as D1Database }),
});
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely<unknown> is compatible with PluginStorageRepository's expected db
return new PluginStorageRepository(db as never, pluginId, collection, allIndexes);
}

// =========================================================================
// KV Operations - scoped to plugin namespace
// =========================================================================
Expand Down Expand Up @@ -242,45 +272,45 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge

async storageQuery(
collection: string,
opts: { limit?: number; cursor?: string } = {},
opts: {
limit?: number;
cursor?: string;
where?: Record<string, unknown>;
orderBy?: Record<string, "asc" | "desc">;
} = {},
): Promise<{
items: Array<{ id: string; data: unknown }>;
hasMore: boolean;
cursor?: string;
}> {
const { pluginId, storageCollections } = this.ctx.props;
const { storageCollections } = this.ctx.props;
if (!storageCollections.includes(collection)) {
throw new Error(`Storage collection not declared: ${collection}`);
}
const limit = Math.min(opts.limit ?? 50, 1000);
const results = await this.env.DB.prepare(
"SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? LIMIT ?",
)
.bind(pluginId, collection, limit + 1)
.all<{ id: string; data: string }>();

const items = (results.results ?? []).slice(0, limit).map((row) => ({
id: row.id,
data: JSON.parse(row.data),
}));
// Delegate to PluginStorageRepository for proper WHERE/ORDER BY/cursor support
const repo = this.getStorageRepo(collection);
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- WhereClause is structurally Record<string, unknown>
const result = await repo.query({
where: opts.where as never,
orderBy: opts.orderBy,
limit: opts.limit,
cursor: opts.cursor,
});
return {
items,
hasMore: (results.results ?? []).length > limit,
cursor: items.length > 0 ? items.at(-1)!.id : undefined,
items: result.items,
hasMore: result.hasMore,
cursor: result.cursor,
};
}

async storageCount(collection: string): Promise<number> {
const { pluginId, storageCollections } = this.ctx.props;
async storageCount(collection: string, where?: Record<string, unknown>): Promise<number> {
const { storageCollections } = this.ctx.props;
if (!storageCollections.includes(collection)) {
throw new Error(`Storage collection not declared: ${collection}`);
}
const result = await this.env.DB.prepare(
"SELECT COUNT(*) as count FROM _plugin_storage WHERE plugin_id = ? AND collection = ?",
)
.bind(pluginId, collection)
.first<{ count: number }>();
return result?.count ?? 0;
const repo = this.getStorageRepo(collection);
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- WhereClause is structurally Record<string, unknown>
return repo.count(where as never);
}

async storageGetMany(collection: string, ids: string[]): Promise<Map<string, unknown>> {
Expand Down
20 changes: 20 additions & 0 deletions packages/cloudflare/src/sandbox/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export interface PluginBridgeProps {
capabilities: string[];
allowedHosts: string[];
storageCollections: string[];
storageConfig?: Record<
string,
{ indexes?: Array<string | string[]>; uniqueIndexes?: Array<string | string[]> }
>;
}

/**
Expand Down Expand Up @@ -125,6 +129,13 @@ export class CloudflareSandboxRunner implements SandboxRunner {
return !!getLoader() && !!getPluginBridge();
}

/**
* Worker Loader runs in-process, always healthy if available.
*/
isHealthy(): boolean {
return this.isAvailable();
}

/**
* Load a sandboxed plugin.
*
Expand Down Expand Up @@ -243,6 +254,15 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance {
capabilities: normalizeCapabilities(this.manifest.capabilities || []),
allowedHosts: this.manifest.allowedHosts || [],
storageCollections: Object.keys(this.manifest.storage || {}),
storageConfig: this.manifest.storage as
| Record<
string,
{
indexes?: Array<string | string[]>;
uniqueIndexes?: Array<string | string[]>;
}
>
| undefined,
},
});

Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/api/handlers/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,18 @@ export async function handleMarketplaceInstall(
sandboxRunner: SandboxRunner | null,
marketplaceUrl: string | undefined,
pluginId: string,
opts?: { version?: string; configuredPluginIds?: Set<string>; siteOrigin?: string },
opts?: {
version?: string;
configuredPluginIds?: Set<string>;
siteOrigin?: string;
/**
* When true, sandbox: false bypass mode is active. The sandbox runner
* is the noop runner (isAvailable() === false) but the runtime will
* load the marketplace plugin in-process via syncMarketplacePlugins().
* Skip the SANDBOX_NOT_AVAILABLE gate so the install can proceed.
*/
sandboxBypassed?: boolean;
},
): Promise<ApiResult<MarketplaceInstallResult>> {
const client = getClient(marketplaceUrl, opts?.siteOrigin);
if (!client) {
Expand All @@ -343,7 +354,9 @@ export async function handleMarketplaceInstall(
};
}

if (!sandboxRunner || !sandboxRunner.isAvailable()) {
// Sandbox availability check: skip when sandbox: false bypass is active.
// The runtime's syncMarketplacePlugins() will load the plugin in-process.
if (!opts?.sandboxBypassed && (!sandboxRunner || !sandboxRunner.isAvailable())) {
return {
success: false,
error: {
Expand Down Expand Up @@ -524,6 +537,13 @@ export async function handleMarketplaceUpdate(
version?: string;
confirmCapabilityChanges?: boolean;
confirmRouteVisibilityChanges?: boolean;
/**
* When true, sandbox: false bypass mode is active. The sandbox runner
* is the noop runner (isAvailable() === false) but the runtime will
* load the marketplace plugin in-process via syncMarketplacePlugins().
* Skip the SANDBOX_NOT_AVAILABLE gate so the update can proceed.
*/
sandboxBypassed?: boolean;
},
): Promise<ApiResult<MarketplaceUpdateResult>> {
const client = getClient(marketplaceUrl);
Expand All @@ -539,7 +559,9 @@ export async function handleMarketplaceUpdate(
error: { code: "STORAGE_NOT_CONFIGURED", message: "Storage is required" },
};
}
if (!sandboxRunner || !sandboxRunner.isAvailable()) {
// Sandbox availability check: skip when sandbox: false bypass is active.
// The runtime's syncMarketplacePlugins() will load the plugin in-process.
if (!opts?.sandboxBypassed && (!sandboxRunner || !sandboxRunner.isAvailable())) {
return {
success: false,
error: { code: "SANDBOX_NOT_AVAILABLE", message: "Sandbox runner is required" },
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/astro/integration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,17 @@ export interface EmDashConfig {
*/
sandboxRunner?: string;

/**
* Explicitly disable plugin sandboxing, even if a sandbox runner is configured.
* Use this as a debugging escape hatch to determine whether a bug is in your
* plugin code or in the sandbox runtime.
*
* When set to `false`, all plugins run in-process without isolation.
*
* @default true (sandboxing enabled if sandboxRunner is configured)
*/
sandbox?: boolean;

/**
* Authentication configuration
*
Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/astro/integration/virtual-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,14 @@ ${entries.join("\n")}
/**
* Generates the sandbox runner module.
* Imports the configured sandbox runner factory or provides a noop default.
*
* When sandbox is explicitly false (debugging escape hatch), we still mark
* sandboxEnabled = true so sandboxed plugin entries are loaded, but we use
* the noop runner which falls through to in-process loading via adaptSandboxEntry.
*/
export function generateSandboxRunnerModule(sandboxRunner?: string): string {
export function generateSandboxRunnerModule(sandboxRunner?: string, sandbox?: boolean): string {
if (!sandboxRunner) {
// No sandbox runner configured - use noop
// No sandbox runner configured - sandboxed plugins disabled
return `
// No sandbox runner configured - sandboxed plugins disabled
import { createNoopSandboxRunner } from "emdash";
Expand All @@ -296,6 +300,19 @@ export const sandboxEnabled = false;
`;
}

if (sandbox === false) {
// sandbox: false escape hatch - plugins are loaded but run in-process
// (no isolation, for debugging)
return `
// Sandbox explicitly disabled (sandbox: false) - plugins run in-process
import { createNoopSandboxRunner } from "emdash";

export const createSandboxRunner = createNoopSandboxRunner;
export const sandboxEnabled = true;
export const sandboxBypassed = true;
`;
}

return `
// Auto-generated sandbox runner module
import { createSandboxRunner as _createSandboxRunner } from "${sandboxRunner}";
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/astro/integration/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin {
}
// Generate sandbox runner module
if (id === RESOLVED_VIRTUAL_SANDBOX_RUNNER_ID) {
return generateSandboxRunnerModule(resolvedConfig.sandboxRunner);
return generateSandboxRunnerModule(resolvedConfig.sandboxRunner, resolvedConfig.sandbox);
}
// Generate sandboxed plugins config module
if (id === RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID) {
Expand Down
28 changes: 20 additions & 8 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,8 @@ import type { RequestScopedDbOpts } from "virtual:emdash/dialect";
import { mediaProviders as virtualMediaProviders } from "virtual:emdash/media-providers";
// @ts-ignore - virtual module
import { plugins as virtualPlugins } from "virtual:emdash/plugins";
import {
createSandboxRunner as virtualCreateSandboxRunner,
sandboxEnabled as virtualSandboxEnabled,
// @ts-ignore - virtual module
} from "virtual:emdash/sandbox-runner";
// @ts-ignore - virtual module
import * as virtualSandboxRunnerModule from "virtual:emdash/sandbox-runner";
// @ts-ignore - virtual module
import { sandboxedPlugins as virtualSandboxedPlugins } from "virtual:emdash/sandboxed-plugins";
// @ts-ignore - virtual module
Expand Down Expand Up @@ -124,12 +121,26 @@ function buildDependencies(config: EmDashConfig): RuntimeDependencies {
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import is untyped (@ts-ignore above)
createStorage: virtualCreateStorage as ((config: Record<string, unknown>) => Storage) | null,
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import is untyped (@ts-ignore above)
sandboxEnabled: virtualSandboxEnabled as boolean,
sandboxEnabled: (virtualSandboxRunnerModule as Record<string, unknown>)
.sandboxEnabled as boolean,
sandboxBypassed:
((virtualSandboxRunnerModule as Record<string, unknown>).sandboxBypassed as boolean) ?? false,
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import is untyped (@ts-ignore above)
sandboxedPluginEntries: (virtualSandboxedPlugins as SandboxedPluginEntry[]) || [],
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import is untyped (@ts-ignore above)
createSandboxRunner: virtualCreateSandboxRunner as
| ((opts: { db: Kysely<Database> }) => SandboxRunner)
createSandboxRunner: (virtualSandboxRunnerModule as Record<string, unknown>)
.createSandboxRunner as
| ((opts: {
db: Kysely<Database>;
mediaStorage?: {
upload(options: {
key: string;
body: Uint8Array;
contentType: string;
}): Promise<unknown>;
delete(key: string): Promise<unknown>;
};
}) => SandboxRunner)
| null,
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import is untyped (@ts-ignore above)
mediaProviderEntries: (virtualMediaProviders as MediaProviderEntry[]) || [],
Expand Down Expand Up @@ -523,6 +534,7 @@ export const onRequest = defineMiddleware(async (context, next) => {

// Sandbox runner (for marketplace plugin install/update)
getSandboxRunner: runtime.getSandboxRunner.bind(runtime),
isSandboxBypassed: runtime.isSandboxBypassed.bind(runtime),

// Sync marketplace plugin states (after install/update/uninstall)
syncMarketplacePlugins: runtime.syncMarketplacePlugins.bind(runtime),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
version: body.version,
confirmCapabilityChanges: body.confirmCapabilityChanges,
confirmRouteVisibilityChanges: body.confirmRouteVisibilityChanges,
sandboxBypassed: emdash.isSandboxBypassed(),
},
);

Expand Down
Loading
Loading