Skip to content

Commit 55f55e1

Browse files
authored
Merge pull request #284 from chubes4/fix/runtime-mu-plugin-load-mode
fix: load runtime recipe plugins as mu-plugins
2 parents 6e458d2 + d6ee538 commit 55f55e1

7 files changed

Lines changed: 172 additions & 16 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,8 @@ Dry-run output uses `wp-codebox/recipe-run-dry-run/v1` and includes resolved mou
521521

522522
`inputs.extra_plugins` accepts existing local plugin directory paths and external HTTPS zip sources. Local paths keep the existing behavior: they are resolved relative to the recipe file and mounted read-only under `/wordpress/wp-content/plugins/<slug>`.
523523

524+
Set `loadAs` to `mu-plugin` for runtime substrate that should load as must-use infrastructure instead of appearing as a normal user-managed plugin. WP Codebox mounts those plugins under `/wordpress/wp-content/mu-plugins/wp-codebox-runtime/<slug>` and writes a `wp-codebox-runtime-loader.php` setup loader. Use this for sandbox/runtime plumbing such as Agents API, Data Machine, Data Machine Code, and AI provider bridges. Leave user-visible site plugins as the default `plugin` load mode.
525+
524526
External sources are explicit and CI-safe. WP Codebox validates URL-shaped sources before Playground boots, but it downloads them only when `WP_CODEBOX_ALLOW_NETWORK_DOWNLOADS=1` is set. Supported first-slice forms are WordPress.org plugin zip URLs and generic HTTPS `.zip` URLs:
525527

526528
```json
@@ -536,6 +538,13 @@ External sources are explicit and CI-safe. WP Codebox validates URL-shaped sourc
536538
"source": "https://example.com/acme-helper.zip",
537539
"slug": "acme-helper",
538540
"pluginFile": "acme-helper/acme-helper.php"
541+
},
542+
{
543+
"source": "../agents-api",
544+
"slug": "agents-api",
545+
"pluginFile": "agents-api/agents-api.php",
546+
"activate": false,
547+
"loadAs": "mu-plugin"
539548
}
540549
]
541550
}

packages/cli/src/agent-code.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,22 @@ $plugins = array_merge(array(
406406
'data-machine-code/data-machine-code.php',
407407
), wp_codebox_provider_plugin_entries(json_decode(${JSON.stringify(JSON.stringify(providerPlugins))}, true)));
408408
409+
function wp_codebox_plugin_entry_path(string $plugin): ?array {
410+
$plugin = ltrim($plugin, '/');
411+
if ('' === $plugin || str_contains($plugin, '..') || !str_ends_with($plugin, '.php')) {
412+
return null;
413+
}
414+
$normal_path = WP_PLUGIN_DIR . '/' . $plugin;
415+
if (file_exists($normal_path)) {
416+
return array('path' => $normal_path, 'load_as' => 'plugin');
417+
}
418+
$mu_path = WPMU_PLUGIN_DIR . '/wp-codebox-runtime/' . $plugin;
419+
if (file_exists($mu_path)) {
420+
return array('path' => $mu_path, 'load_as' => 'mu-plugin');
421+
}
422+
return null;
423+
}
424+
409425
function wp_codebox_provider_plugin_entries(array $provider_plugins): array {
410426
$entries = array();
411427
foreach ($provider_plugins as $plugin) {
@@ -415,7 +431,7 @@ function wp_codebox_provider_plugin_entries(array $provider_plugins): array {
415431
}
416432
$candidates = array($slug . '/plugin.php', $slug . '/' . $slug . '.php');
417433
foreach ($candidates as $candidate) {
418-
if (file_exists(WP_PLUGIN_DIR . '/' . $candidate)) {
434+
if (wp_codebox_plugin_entry_path($candidate)) {
419435
$entries[] = $candidate;
420436
break;
421437
}
@@ -427,9 +443,20 @@ function wp_codebox_provider_plugin_entries(array $provider_plugins): array {
427443
$activation_results = array();
428444
429445
foreach ($plugins as $plugin) {
430-
$result = activate_plugin($plugin);
446+
$entry = wp_codebox_plugin_entry_path($plugin);
447+
if (!$entry) {
448+
$activation_results[$plugin] = array('active' => false, 'error' => 'Plugin is not mounted.');
449+
continue;
450+
}
451+
$result = null;
452+
if ('mu-plugin' === $entry['load_as']) {
453+
require_once $entry['path'];
454+
} else {
455+
$result = activate_plugin($plugin);
456+
}
431457
$activation_results[$plugin] = array(
432-
'active' => is_plugin_active($plugin),
458+
'active' => 'mu-plugin' === $entry['load_as'] ? true : is_plugin_active($plugin),
459+
'load_as' => $entry['load_as'],
433460
'error' => is_wp_error($result) ? $result->get_error_message() : null,
434461
);
435462
}
@@ -491,6 +518,22 @@ $plugins = array_merge(array(
491518
'data-machine-code/data-machine-code.php',
492519
), wp_codebox_provider_plugin_entries(json_decode(${JSON.stringify(JSON.stringify(providerPlugins))}, true)));
493520
521+
function wp_codebox_plugin_entry_path(string $plugin): ?array {
522+
$plugin = ltrim($plugin, '/');
523+
if ('' === $plugin || str_contains($plugin, '..') || !str_ends_with($plugin, '.php')) {
524+
return null;
525+
}
526+
$normal_path = WP_PLUGIN_DIR . '/' . $plugin;
527+
if (file_exists($normal_path)) {
528+
return array('path' => $normal_path, 'load_as' => 'plugin');
529+
}
530+
$mu_path = WPMU_PLUGIN_DIR . '/wp-codebox-runtime/' . $plugin;
531+
if (file_exists($mu_path)) {
532+
return array('path' => $mu_path, 'load_as' => 'mu-plugin');
533+
}
534+
return null;
535+
}
536+
494537
function wp_codebox_provider_plugin_entries(array $provider_plugins): array {
495538
$entries = array();
496539
foreach ($provider_plugins as $plugin) {
@@ -500,7 +543,7 @@ function wp_codebox_provider_plugin_entries(array $provider_plugins): array {
500543
}
501544
$candidates = array($slug . '/plugin.php', $slug . '/' . $slug . '.php');
502545
foreach ($candidates as $candidate) {
503-
if (file_exists(WP_PLUGIN_DIR . '/' . $candidate)) {
546+
if (wp_codebox_plugin_entry_path($candidate)) {
504547
$entries[] = $candidate;
505548
break;
506549
}
@@ -512,9 +555,20 @@ function wp_codebox_provider_plugin_entries(array $provider_plugins): array {
512555
$activation_results = array();
513556
514557
foreach ($plugins as $plugin) {
515-
$result = activate_plugin($plugin);
558+
$entry = wp_codebox_plugin_entry_path($plugin);
559+
if (!$entry) {
560+
$activation_results[$plugin] = array('active' => false, 'error' => 'Plugin is not mounted.');
561+
continue;
562+
}
563+
$result = null;
564+
if ('mu-plugin' === $entry['load_as']) {
565+
require_once $entry['path'];
566+
} else {
567+
$result = activate_plugin($plugin);
568+
}
516569
$activation_results[$plugin] = array(
517-
'active' => is_plugin_active($plugin),
570+
'active' => 'mu-plugin' === $entry['load_as'] ? true : is_plugin_active($plugin),
571+
'load_as' => $entry['load_as'],
518572
'error' => is_wp_error($result) ? $result->get_error_message() : null,
519573
);
520574
}

packages/cli/src/index.ts

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ interface RecipeDryRunExtraPlugin {
426426
target: string
427427
pluginFile: string
428428
activate: boolean
429+
loadAs: "plugin" | "mu-plugin"
429430
provenance: RecipeSourceProvenance
430431
}
431432

@@ -534,6 +535,7 @@ interface PreparedExtraPlugin {
534535
target: string
535536
pluginFile: string
536537
activate: boolean
538+
loadAs: "plugin" | "mu-plugin"
537539
cleanupPaths: string[]
538540
provenance: RecipeSourceProvenance
539541
}
@@ -1428,6 +1430,11 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
14281430
interruption?.throwIfInterrupted()
14291431
}
14301432

1433+
const muPluginInstallCode = installMuPluginsCode(extraPlugins)
1434+
if (muPluginInstallCode) {
1435+
executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: [`code=${muPluginInstallCode}`] }), "setup", -2))
1436+
}
1437+
14311438
const pluginActivationCode = activateExtraPluginsCode(extraPlugins)
14321439
if (pluginActivationCode) {
14331440
executions.push(withRecipeExecutionPhase(await awaitRecipe(runtime.execute({ command: "wordpress.run-php", args: [`code=${pluginActivationCode}`] })), "setup", -1))
@@ -3247,6 +3254,10 @@ function parseWorkspaceRecipe(raw: string, recipePath: string): WorkspaceRecipe
32473254
if (plugin.slug && !/^[a-z0-9][a-z0-9-_]*$/i.test(plugin.slug)) {
32483255
throw new Error(`Recipe extra_plugins slug must be a plugin-directory slug: ${recipePath}`)
32493256
}
3257+
3258+
if (plugin.loadAs && plugin.loadAs !== "plugin" && plugin.loadAs !== "mu-plugin") {
3259+
throw new Error(`Recipe extra_plugins loadAs must be plugin or mu-plugin: ${recipePath}`)
3260+
}
32503261
}
32513262

32523263
const siteSeeds = recipe.inputs?.siteSeeds ?? []
@@ -3639,13 +3650,18 @@ async function recipeDryRunSteps(recipe: WorkspaceRecipe, recipeDirectory: strin
36393650
return {
36403651
source: plugin.source,
36413652
slug,
3642-
target: `/wordpress/wp-content/plugins/${slug}`,
3653+
target: pluginTarget(slug, plugin.loadAs ?? "plugin"),
36433654
pluginFile: await resolveRecipeExtraPluginFile(plugin, recipeDirectory),
36443655
activate: plugin.activate !== false,
3656+
loadAs: plugin.loadAs ?? "plugin",
36453657
cleanupPaths: [],
36463658
provenance: recipeSourceProvenance(recipeSource(plugin.source, plugin.sha256), recipeDirectory),
36473659
}
36483660
}))
3661+
const muPluginInstallCode = installMuPluginsCode(dryRunExtraPlugins)
3662+
if (muPluginInstallCode) {
3663+
steps.push(recipeDryRunStep({ command: "wordpress.run-php", args: [`code=${muPluginInstallCode}`] }, recipeDirectory, policy, "setup", -2, "install-mu-plugins"))
3664+
}
36493665
const pluginActivationCode = activateExtraPluginsCode(dryRunExtraPlugins)
36503666
if (pluginActivationCode) {
36513667
steps.push(recipeDryRunStep({ command: "wordpress.run-php", args: [`code=${pluginActivationCode}`] }, recipeDirectory, policy, "setup", -1, "activate-extra-plugins"))
@@ -3725,8 +3741,10 @@ function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, workspac
37253741
const extraPluginMetadata = extraPlugins.map((plugin) => ({
37263742
source: plugin.source,
37273743
slug: plugin.slug,
3744+
target: plugin.target,
37283745
pluginFile: plugin.pluginFile,
37293746
activate: plugin.activate,
3747+
loadAs: plugin.loadAs,
37303748
provenance: plugin.provenance,
37313749
}))
37323750
const siteSeedProvenance = recipeDryRunSiteSeeds(recipe, dirname(recipePath))
@@ -3828,9 +3846,10 @@ function recipeDryRunExtraPlugins(recipe: WorkspaceRecipe, recipeDirectory: stri
38283846
sourceRef: plugin.source,
38293847
sourceType: source.type,
38303848
slug,
3831-
target: `/wordpress/wp-content/plugins/${slug}`,
3849+
target: pluginTarget(slug, plugin.loadAs ?? "plugin"),
38323850
pluginFile: recipeExtraPluginFile(plugin),
38333851
activate: plugin.activate !== false,
3852+
loadAs: plugin.loadAs ?? "plugin",
38343853
provenance,
38353854
}
38363855
})
@@ -4433,13 +4452,15 @@ async function prepareRecipeExtraPlugins(recipe: WorkspaceRecipe, recipeDirector
44334452
const slug = recipeExtraPluginSlug(plugin)
44344453
const resolved = await prepareRecipeSource(plugin.source, recipeDirectory, slug, plugin.sha256)
44354454
const pluginFile = await resolveRecipeExtraPluginFile(plugin, recipeDirectory)
4455+
const loadAs = plugin.loadAs ?? "plugin"
44364456
await assertPreparedPluginFileExists(resolved.source, pluginFile.slice(slug.length + 1), plugin.source)
44374457
plugins.push({
44384458
source: resolved.source,
44394459
slug,
4440-
target: `/wordpress/wp-content/plugins/${slug}`,
4460+
target: pluginTarget(slug, loadAs),
44414461
pluginFile,
44424462
activate: plugin.activate !== false,
4463+
loadAs,
44434464
cleanupPaths: resolved.cleanupPaths,
44444465
provenance: resolved.provenance,
44454466
})
@@ -4854,6 +4875,14 @@ function recipeExtraPluginFile(plugin: WorkspaceRecipeExtraPlugin): string {
48544875
return plugin.pluginFile ?? `${slug}/${slug}.php`
48554876
}
48564877

4878+
function pluginTarget(slug: string, loadAs: PreparedExtraPlugin["loadAs"]): string {
4879+
if (loadAs === "mu-plugin") {
4880+
return `/wordpress/wp-content/mu-plugins/wp-codebox-runtime/${slug}`
4881+
}
4882+
4883+
return `/wordpress/wp-content/plugins/${slug}`
4884+
}
4885+
48574886
async function resolveRecipeExtraPluginFile(plugin: WorkspaceRecipeExtraPlugin, recipeDirectory: string): Promise<string> {
48584887
const slug = recipeExtraPluginSlug(plugin)
48594888
if (plugin.pluginFile) {
@@ -4880,7 +4909,7 @@ async function resolveRecipeExtraPluginFile(plugin: WorkspaceRecipeExtraPlugin,
48804909

48814910
function activateExtraPluginsCode(extraPlugins: PreparedExtraPlugin[]): string | null {
48824911
const pluginFiles = extraPlugins
4883-
.filter((plugin) => plugin.activate !== false)
4912+
.filter((plugin) => plugin.loadAs === "plugin" && plugin.activate !== false)
48844913
.map((plugin) => plugin.pluginFile)
48854914

48864915
if (pluginFiles.length === 0) {
@@ -4906,6 +4935,55 @@ foreach ($plugins as $plugin) {
49064935
echo wp_json_encode(array('command' => 'activate-extra-plugins', 'plugins' => $activated), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);`
49074936
}
49084937
4938+
function installMuPluginsCode(extraPlugins: PreparedExtraPlugin[]): string | null {
4939+
const muPlugins = extraPlugins
4940+
.filter((plugin) => plugin.loadAs === "mu-plugin")
4941+
.map((plugin) => plugin.pluginFile)
4942+
4943+
if (muPlugins.length === 0) {
4944+
return null
4945+
}
4946+
4947+
return `$plugins = ${JSON.stringify(muPlugins)};
4948+
$runtime_dir = WPMU_PLUGIN_DIR . '/wp-codebox-runtime';
4949+
if (!is_dir(WPMU_PLUGIN_DIR) && !mkdir(WPMU_PLUGIN_DIR, 0777, true) && !is_dir(WPMU_PLUGIN_DIR)) {
4950+
throw new RuntimeException('Could not create mu-plugins directory.');
4951+
}
4952+
if (!is_dir($runtime_dir)) {
4953+
throw new RuntimeException('WP Codebox runtime mu-plugin directory is not mounted.');
4954+
}
4955+
$loader = WPMU_PLUGIN_DIR . '/wp-codebox-runtime-loader.php';
4956+
$lines = array(
4957+
'<?php',
4958+
'/**',
4959+
' * Plugin Name: WP Codebox Runtime Loader',
4960+
' * Description: Loads WP Codebox runtime substrate as must-use plugins.',
4961+
' */',
4962+
'',
4963+
"defined( 'ABSPATH' ) || exit;",
4964+
'',
4965+
"if ( ! defined( 'DATAMACHINE_WORKSPACE_PATH' ) ) {",
4966+
" define( 'DATAMACHINE_WORKSPACE_PATH', ${JSON.stringify(SANDBOX_WORKSPACE_ROOT)} );",
4967+
"}",
4968+
"add_filter( 'datamachine_should_load_full_runtime', '__return_true', 1 );",
4969+
'',
4970+
);
4971+
foreach ($plugins as $plugin) {
4972+
if ('' === $plugin || str_starts_with($plugin, '/') || str_contains($plugin, '..') || !str_ends_with($plugin, '.php')) {
4973+
throw new RuntimeException('Unsafe WP Codebox runtime mu-plugin entry.');
4974+
}
4975+
$plugin_file = $runtime_dir . '/' . $plugin;
4976+
if (!file_exists($plugin_file)) {
4977+
throw new RuntimeException(sprintf('WP Codebox runtime mu-plugin is not mounted: %s', $plugin));
4978+
}
4979+
$lines[] = "require_once WPMU_PLUGIN_DIR . '/wp-codebox-runtime/" . str_replace("'", "\\'", $plugin) . "';";
4980+
}
4981+
if (false === file_put_contents($loader, implode("\\n", $lines) . "\\n")) {
4982+
throw new RuntimeException('Could not write WP Codebox runtime mu-plugin loader.');
4983+
}
4984+
echo wp_json_encode(array('command' => 'install-mu-plugins', 'plugins' => $plugins, 'loader' => $loader), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);`
4985+
}
4986+
49094987
function parseMount(value: string): RunOptions["mounts"][number] {
49104988
const [source, target, mode = "readwrite"] = value.split(":")
49114989

packages/runtime-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,7 @@ export interface WorkspaceRecipeExtraPlugin {
494494
pluginFile?: string
495495
activate?: boolean
496496
sha256?: string
497+
loadAs?: "plugin" | "mu-plugin"
497498
}
498499

499500
export type WorkspaceRecipeSiteSeedType = "fixture" | "parent_site"

packages/wordpress-plugin/src/class-wp-codebox-agent-sandbox-runner.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1806,6 +1806,7 @@ private function component_plugins( array $paths ): array {
18061806
'source' => $paths[ $key ],
18071807
'slug' => $slug,
18081808
'activate' => false,
1809+
'loadAs' => 'mu-plugin',
18091810
);
18101811
}
18111812

scripts/recipe-dry-run-smoke.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ writeFileSync(recipePath, `${JSON.stringify({
4545
slug: "simple-plugin",
4646
pluginFile: "simple-plugin/simple-plugin.php",
4747
},
48+
{
49+
source: "../../examples/simple-plugin",
50+
slug: "simple-runtime",
51+
pluginFile: "simple-runtime/simple-plugin.php",
52+
activate: false,
53+
loadAs: "mu-plugin",
54+
},
4855
],
4956
siteSeeds: [
5057
{
@@ -203,6 +210,9 @@ assert.equal(output.plan.workspaces[0].sourceMode, "site-backed")
203210
assert.equal(output.plan.workspaces[0].metadata.workspaceRoot, "/workspace")
204211
assert.equal(output.plan.workspaces[0].metadata.sourceMode, "site-backed")
205212
assert.equal(output.plan.extra_plugins[0].target, "/wordpress/wp-content/plugins/simple-plugin")
213+
assert.equal(output.plan.extra_plugins[0].loadAs, "plugin")
214+
assert.equal(output.plan.extra_plugins[1].target, "/wordpress/wp-content/mu-plugins/wp-codebox-runtime/simple-runtime")
215+
assert.equal(output.plan.extra_plugins[1].loadAs, "mu-plugin")
206216
assert.equal(output.plan.siteSeeds.length, 2)
207217
assert.equal(output.plan.siteSeeds[0].source, fixtureSeedPath)
208218
assert.equal(output.plan.siteSeeds[0].dryRunOnly, false)
@@ -216,13 +226,15 @@ assert.equal(output.plan.siteSeeds[1].privacy.importsIntoSandbox, false)
216226
assert.equal(output.plan.secretEnv[0].name, "DRY_RUN_TOKEN")
217227
assert.equal(Object.prototype.hasOwnProperty.call(output.plan.secretEnv[0], "value"), false)
218228
assert.equal(output.plan.secretEnv[0].available, true)
219-
assert.equal(output.plan.workflow.steps.length, 3)
220-
assert.equal(output.plan.workflow.steps[0].command, "activate-extra-plugins")
229+
assert.equal(output.plan.workflow.steps.length, 4)
230+
assert.equal(output.plan.workflow.steps[0].command, "install-mu-plugins")
221231
assert.equal(output.plan.workflow.steps[0].policy.status, "allowed")
222-
assert.equal(output.plan.workflow.steps[1].resolvedCommand, "wordpress.run-php")
223-
assert.equal(output.plan.workflow.steps[1].resolvedParsedArgs.code, "echo 'dry run';")
224-
assert.equal(output.plan.workflow.steps[2].parsedArgs.command, "option get home")
225-
assert.equal(output.plan.workflow.steps[2].policy.status, "allowed")
232+
assert.equal(output.plan.workflow.steps[1].command, "activate-extra-plugins")
233+
assert.equal(output.plan.workflow.steps[1].policy.status, "allowed")
234+
assert.equal(output.plan.workflow.steps[2].resolvedCommand, "wordpress.run-php")
235+
assert.equal(output.plan.workflow.steps[2].resolvedParsedArgs.code, "echo 'dry run';")
236+
assert.equal(output.plan.workflow.steps[3].parsedArgs.command, "option get home")
237+
assert.equal(output.plan.workflow.steps[3].policy.status, "allowed")
226238
assert.equal(output.runtime, undefined)
227239
assert.equal(output.executions, undefined)
228240
assert.equal(output.artifacts, undefined)

tests/smoke-wordpress-plugin.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,7 @@ function get_users( array $args ): array { return array( new WP_User( 11, 'Priva
750750
$assert( 'runner recipe passes default provider', str_contains( $captured_recipe, 'openai' ) );
751751
$assert( 'runner recipe passes default model', str_contains( $captured_recipe, 'gpt-5.5' ) );
752752
$assert( 'runner recipe passes provider plugin path', str_contains( $captured_recipe, 'ai-provider-test' ) );
753+
$assert( 'runner recipe loads runtime components as mu-plugins', str_contains( $captured_recipe, '"slug":"agents-api","activate":false,"loadAs":"mu-plugin"' ) && str_contains( $captured_recipe, '"slug":"data-machine","activate":false,"loadAs":"mu-plugin"' ) && str_contains( $captured_recipe, '"slug":"data-machine-code","activate":false,"loadAs":"mu-plugin"' ) );
753754
$assert( 'runner recipe passes generic mount metadata', str_contains( $captured_recipe, 'example/editable-plugin' ) && str_contains( $captured_recipe, 'repo_root_relative_to_mount' ) );
754755
$assert( 'runner recipe passes secret env name only', str_contains( $captured_recipe, 'GITHUB_TOKEN' ) && ! str_contains( $captured_recipe, 'GITHUB_TOKEN=' ) );
755756
$assert( 'runner does not pass raw code options', ! str_contains( $captured_command, '--code ' ) && ! str_contains( $captured_command, '--code-file' ) );

0 commit comments

Comments
 (0)