diff --git a/.changeset/sandbox-plugin-site-context.md b/.changeset/sandbox-plugin-site-context.md new file mode 100644 index 0000000000..e887bdc645 --- /dev/null +++ b/.changeset/sandbox-plugin-site-context.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes sandboxed plugins receiving empty site metadata and relative URLs from `ctx.url()`. diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 987bcb9400..7c97f50129 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -47,7 +47,7 @@ import { import { setI18nConfig } from "../i18n/config.js"; import type { Database, Storage } from "../index.js"; import { createPublicMediaUrlResolver } from "../media/url.js"; -import type { SandboxRunner } from "../plugins/sandbox/types.js"; +import type { SandboxRunnerFactory } from "../plugins/sandbox/types.js"; import type { ResolvedPlugin } from "../plugins/types.js"; import { invalidateUrlPatternCache } from "../query.js"; import { @@ -200,19 +200,7 @@ function buildDependencies(config: EmDashConfig): RuntimeDependencies { sandboxEnabled: sandboxModule.sandboxEnabled as boolean, sandboxBypassed: (sandboxModule.sandboxBypassed as boolean) ?? false, sandboxedPluginEntries: (virtualSandboxedPlugins as SandboxedPluginEntry[]) || [], - createSandboxRunner: sandboxModule.createSandboxRunner as - | ((opts: { - db: Kysely; - mediaStorage?: { - upload(options: { - key: string; - body: Uint8Array; - contentType: string; - }): Promise; - delete(key: string): Promise; - }; - }) => SandboxRunner) - | null, + createSandboxRunner: sandboxModule.createSandboxRunner as SandboxRunnerFactory | null, mediaProviderEntries: (virtualMediaProviders as MediaProviderEntry[]) || [], }; /* eslint-enable typescript-eslint/no-unsafe-type-assertion */ diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index ecf6fa0c82..3dce9be050 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -41,7 +41,12 @@ import { markContentMediaUsageCollectionStale, refreshContentMediaUsageAfterWrite, } from "./media/usage/content-refresh.js"; -import type { SandboxedPluginInstance, SandboxRunner } from "./plugins/sandbox/types.js"; +import { createSandboxRunnerOptions } from "./plugins/sandbox/runner-options.js"; +import type { + SandboxedPluginInstance, + SandboxRunner, + SandboxRunnerFactory, +} from "./plugins/sandbox/types.js"; import type { ResolvedPlugin, MediaItem, @@ -310,16 +315,8 @@ export interface RuntimeDependencies { /** Media provider entries from virtual module */ mediaProviderEntries?: MediaProviderEntry[]; sandboxedPluginEntries: SandboxedPluginEntry[]; - /** Factory function matching SandboxRunnerFactory signature */ - createSandboxRunner: - | ((opts: { - db: Kysely; - mediaStorage?: { - upload(options: { key: string; body: Uint8Array; contentType: string }): Promise; - delete(key: string): Promise; - }; - }) => SandboxRunner) - | null; + /** Factory function supplied by the active platform adapter. */ + createSandboxRunner: SandboxRunnerFactory | null; } /** @@ -1390,7 +1387,7 @@ export class EmDashRuntime { // Load sandboxed plugins (build-time, sandbox runner path) const sandboxedPlugins = await phase("rt.sandbox", "Sandboxed plugins", () => - EmDashRuntime.loadSandboxedPlugins(deps, db, storage), + EmDashRuntime.loadSandboxedPlugins(deps, db, storage, siteInfo), ); // Cold-start: load marketplace- and registry-installed plugins from @@ -1408,6 +1405,7 @@ export class EmDashRuntime { storage, deps, sandboxedPlugins, + siteInfo, ), ), ); @@ -1423,6 +1421,7 @@ export class EmDashRuntime { storage, deps, sandboxedPlugins, + siteInfo, ), ), ); @@ -1832,6 +1831,7 @@ export class EmDashRuntime { deps: RuntimeDependencies, db: Kysely, mediaStorage?: Storage | null, + siteInfo?: { siteName?: string; siteUrl?: string; locale?: string }, ): Promise> { // Return cached plugins if already loaded if (sandboxedPluginCache.size > 0) { @@ -1845,20 +1845,25 @@ export class EmDashRuntime { // Create sandbox runner if not exists if (!sandboxRunner && deps.createSandboxRunner) { - sandboxRunner = deps.createSandboxRunner({ - db, - mediaStorage: mediaStorage - ? { - upload: (opts) => - mediaStorage.upload({ - key: opts.key, - body: opts.body, - contentType: opts.contentType, - }), - delete: (key) => mediaStorage.delete(key), - } - : undefined, - }); + sandboxRunner = deps.createSandboxRunner( + createSandboxRunnerOptions( + { + db, + mediaStorage: mediaStorage + ? { + upload: (opts) => + mediaStorage.upload({ + key: opts.key, + body: opts.body, + contentType: opts.contentType, + }), + delete: (key) => mediaStorage.delete(key), + } + : undefined, + }, + siteInfo, + ), + ); } if (!sandboxRunner) { @@ -1941,22 +1946,28 @@ export class EmDashRuntime { storage: Storage, deps: RuntimeDependencies, cache: Map, + siteInfo?: { siteName?: string; siteUrl?: string; locale?: string }, ): Promise { // Ensure sandbox runner exists with media storage wired up. // (storage here is the media Storage adapter from the runtime.) if (!sandboxRunner && deps.createSandboxRunner) { - sandboxRunner = deps.createSandboxRunner({ - db, - mediaStorage: { - upload: (opts) => - storage.upload({ - key: opts.key, - body: opts.body, - contentType: opts.contentType, - }), - delete: (key) => storage.delete(key), - }, - }); + sandboxRunner = deps.createSandboxRunner( + createSandboxRunnerOptions( + { + db, + mediaStorage: { + upload: (opts) => + storage.upload({ + key: opts.key, + body: opts.body, + contentType: opts.contentType, + }), + delete: (key) => storage.delete(key), + }, + }, + siteInfo, + ), + ); } // In sandbox bypass mode, marketplace plugins are loaded in-process // BEFORE pipeline creation by EmDashRuntime.create(). Skip here. diff --git a/packages/core/src/plugins/sandbox/runner-options.ts b/packages/core/src/plugins/sandbox/runner-options.ts new file mode 100644 index 0000000000..2f4123a863 --- /dev/null +++ b/packages/core/src/plugins/sandbox/runner-options.ts @@ -0,0 +1,16 @@ +import { createSiteInfo, type SiteInfoOptions } from "../context.js"; +import type { SandboxOptions } from "./types.js"; + +/** + * Build platform sandbox options with the same normalized site context used + * by trusted plugin hooks and routes. + */ +export function createSandboxRunnerOptions( + options: Omit, + siteInfo?: SiteInfoOptions, +): SandboxOptions { + return { + ...options, + siteInfo: createSiteInfo(siteInfo ?? {}), + }; +} diff --git a/packages/core/tests/integration/runtime/create.test.ts b/packages/core/tests/integration/runtime/create.test.ts index 9844f0050b..9bea7ac7af 100644 --- a/packages/core/tests/integration/runtime/create.test.ts +++ b/packages/core/tests/integration/runtime/create.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it, vi } from "vitest"; import { DEFAULT_COMMENT_MODERATOR_PLUGIN_ID } from "../../../src/comments/moderator.js"; import { runMigrations } from "../../../src/database/migrations/runner.js"; +import { OptionsRepository } from "../../../src/database/repositories/options.js"; import type { Database as EmDashDatabase } from "../../../src/database/types.js"; import { EmDashRuntime } from "../../../src/emdash-runtime.js"; import type { RuntimeDependencies } from "../../../src/emdash-runtime.js"; @@ -114,6 +115,50 @@ describe("EmDashRuntime.create — cold boot", () => { } }); + it("passes normalized site information to the sandbox runner", async () => { + const sqlite = new Database(":memory:"); + const setupDb = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + }); + await runMigrations(setupDb); + const options = new OptionsRepository(setupDb); + await options.set("emdash:setup_complete", true); + await options.set("emdash:site_title", "Example Site"); + await options.set("emdash:site_url", "https://example.com/"); + await options.set("emdash:locale", "nl"); + + const runner = { + isAvailable: () => true, + isHealthy: () => true, + load: vi.fn(), + setEmailSend: vi.fn(), + terminateAll: vi.fn(), + }; + const createSandboxRunner = vi.fn(() => runner as never); + const deps: RuntimeDependencies = { + ...createDeps(), + createDialect: () => new SqliteDialect({ database: sqlite }), + sandboxEnabled: true, + createSandboxRunner, + }; + + const runtime = await EmDashRuntime.create(deps); + try { + expect(createSandboxRunner).toHaveBeenCalledWith( + expect.objectContaining({ + siteInfo: { + name: "Example Site", + url: "https://example.com", + locale: "nl", + }, + }), + ); + } finally { + await runtime.stopCron(); + await setupDb.destroy(); + } + }); + // The init read phase feeds plugin enablement: a plugin marked inactive in // _plugin_state must be excluded from the pipeline, so its exclusive hook is // never auto-selected. A shared DB seeds the _plugin_state row before diff --git a/packages/core/tests/unit/plugins/sandbox-runner-options.test.ts b/packages/core/tests/unit/plugins/sandbox-runner-options.test.ts new file mode 100644 index 0000000000..b241468a1f --- /dev/null +++ b/packages/core/tests/unit/plugins/sandbox-runner-options.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createSandboxRunnerOptions } from "../../../src/plugins/sandbox/runner-options.js"; + +describe("createSandboxRunnerOptions", () => { + it("normalizes configured site information for sandbox contexts", () => { + const db = {} as never; + const options = createSandboxRunnerOptions( + { db }, + { + siteName: "Example Site", + siteUrl: "https://example.com/", + locale: "nl", + }, + ); + + expect(options).toEqual({ + db, + siteInfo: { + name: "Example Site", + url: "https://example.com", + locale: "nl", + }, + }); + }); + + it("preserves runner options while applying site defaults", () => { + const db = {} as never; + const upload = vi.fn(); + const remove = vi.fn(); + const mediaStorage = { upload, delete: remove }; + const options = createSandboxRunnerOptions({ db, mediaStorage }); + + expect(options.db).toBe(db); + expect(options.mediaStorage).toBe(mediaStorage); + expect(options.siteInfo).toEqual({ name: "", url: "", locale: "en" }); + }); +});