Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion .github/scripts/run-agent-task/execute-native-agent-task.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -927,7 +927,14 @@ const result = {
success,
request_path: requestPath,
runtime_input_path: ".codebox/native-agent-task-input.json",
execution: { stdout_truncated: execution.stdout_truncated, stderr_truncated: execution.stderr_truncated },
execution: {
stdout_truncated: execution.stdout_truncated,
stderr_truncated: execution.stderr_truncated,
...(execution.code !== 0 ? {
stdout_tail: bounded(redact(execution.stdout), MAX_WORKFLOW_OUTPUT_BYTES),
stderr_tail: bounded(redact(execution.stderr), MAX_WORKFLOW_OUTPUT_BYTES),
} : {}),
},
runtime_result: redact(runtimeRecord),
...(reviewerEvidence ? { reviewer_evidence: reviewerEvidence } : {}),
verification,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@
"test:runtime-overlay-descriptors": "tsx tests/runtime-overlay-descriptors.test.ts",
"test:composer-package-overlay-revision": "tsx scripts/composer-backed-source-hydration-smoke.ts",
"test:composer-package-overlay-autoload-layout": "tsx scripts/composer-package-overlay-autoload-layout-smoke.ts",
"test:composer-overlay-projected-runtime": "tsx tests/composer-overlay-projected-runtime.integration.test.ts",
"test:composer-installed-versions-loader-order": "tsx scripts/composer-installed-versions-loader-order-smoke.ts",
"test:recipe-extra-plugin-composer-autoloaders": "tsx tests/recipe-extra-plugin-composer-autoloaders.test.ts",
"test:recipe-extra-plugin-local-zip": "tsx tests/recipe-extra-plugin-local-zip.test.ts",
Expand Down
5 changes: 5 additions & 0 deletions packages/runtime-core/src/recipe-source-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,11 @@ export function prepareRecipeSourcePackageSync(options: PreparedRecipeSourcePack
mkdirSync(preparedSource, { recursive: true })
}
const originalPluginSource = join(copySource, sourceSubpath)
if (pathExists(join(originalPluginSource, "vendor", "autoload.php"))) {
preserveExistingComposerVendor(originalPluginSource, preparedPluginSource)
bridgePackageAutoloaderToComposerAutoload(preparedPluginSource)
return preparedPluginSource
}
if (!pathExists(join(preparedPluginSource, "composer.json"))) {
preserveExistingComposerVendor(originalPluginSource, preparedPluginSource)
return preparedPluginSource
Expand Down
46 changes: 29 additions & 17 deletions packages/runtime-playground/src/mount-materialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,32 +123,44 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
async function prepareReadonlyDirectory(mount: MountSpec, mountIndex: number, sourceRoot: string, destination: string, diagnostics: MaterializationDiagnostic[]): Promise<ReadonlyMountPreparation> {
const startedAt = Date.now()
const generation = await readonlyMountSourceGeneration(mount, mountIndex, sourceRoot, diagnostics)
const cacheRoot = join(tmpdir(), "wp-codebox-readonly-mount-cache-v1")
const cacheRoot = join(tmpdir(), "wp-codebox-readonly-mount-cache-v2")
const cachePath = join(cacheRoot, generation.fingerprint)
await mkdir(cacheRoot, { recursive: true, mode: 0o700 })

let mode: ReadonlyMountPreparation["mode"] = "cache-hit"
await withPlaygroundArchiveCacheLock(cacheRoot, `readonly-mount-${generation.fingerprint}`, async () => {
if (!await directoryExists(cachePath)) {
mode = "cache-miss"
const temporary = await mkdtemp(join(cacheRoot, ".prepare-"))
try {
const prepared = join(temporary, "tree")
await stageReadonlyDirectory(mount, mountIndex, sourceRoot, prepared, [])
const verified = await readonlyMountSourceGeneration(mount, mountIndex, sourceRoot, [])
if (verified.fingerprint !== generation.fingerprint) {
throw new Error(`Readonly mount source changed while preparing snapshot: ${mount.target}`)
for (let attempt = 0; attempt < 2; attempt++) {
if (!await directoryExists(cachePath)) {
mode = "cache-miss"
const temporary = await mkdtemp(join(cacheRoot, ".prepare-"))
try {
const prepared = join(temporary, "tree")
await stageReadonlyDirectory(mount, mountIndex, sourceRoot, prepared, [])
const verified = await readonlyMountSourceGeneration(mount, mountIndex, sourceRoot, [])
if (verified.fingerprint !== generation.fingerprint) {
throw new Error(`Readonly mount source changed while preparing snapshot: ${mount.target}`)
}
await rename(prepared, cachePath)
} finally {
await rm(temporary, { recursive: true, force: true })
}
await rename(prepared, cachePath)
} finally {
await rm(temporary, { recursive: true, force: true })
await retainReadonlyMountCache(cacheRoot, cachePath)
}
// Cache retention can evict a different entry. Clone while holding the
// shared lock so another preparation cannot evict this tree first.
try {
await cp(cachePath, destination, { recursive: true, dereference: true, mode: constants.COPYFILE_FICLONE })
return
} catch (error) {
if (attempt !== 0 || (error as { code?: unknown }).code !== "ENOENT") throw error
// A prior interrupted or raced preparation can leave a cache root with
// missing descendants. Rebuild it once while still holding the lock.
mode = "cache-miss"
await rm(cachePath, { recursive: true, force: true })
await rm(destination, { recursive: true, force: true })
}
await retainReadonlyMountCache(cacheRoot, cachePath)
}
})
// Clone the immutable cache tree for this writable Playground mount. APFS and
// other supporting filesystems make this copy-on-write; other filesystems copy safely.
await cp(cachePath, destination, { recursive: true, dereference: true, mode: constants.COPYFILE_FICLONE })
return { mode, bytes: generation.bytes, files: generation.files, elapsedMs: Date.now() - startedAt }
}

Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-playground/src/playground-cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ export function classifyManagedDatabaseMysqliError(errorCode: number): "authenti
}

export function managedDatabaseDiagnosticsPhp(spec: RuntimeCreateSpec): string {
if (spec.environment.databaseSetup !== "external") return ""
if (spec.environment.databaseSetup !== "external" || (!spec.runtimeEnv?.DB_HOST && !spec.runtimeEnv?.DB_PORT)) return ""
const services = Array.isArray(spec.metadata?.managedRuntimeServices) ? spec.metadata.managedRuntimeServices : []
const mysql = services.find((service): service is Record<string, unknown> => typeof service === "object" && service !== null && (service as { kind?: unknown }).kind === "mysql")
const receipt = mysql ? {
Expand All @@ -507,7 +507,7 @@ if ($wpcb_db_endpoint['host_class'] === 'absent' || !$wpcb_db_endpoint['port']['
if (!$wpcb_db_diagnostic['transport']['stream_socket_client'] || !$wpcb_db_diagnostic['transport']['mysqli']) $wpcb_db_fail('transport_unavailable', 'Use a runtime with TCP sockets and the mysqli extension enabled.');
$wpcb_db_target = strpos($wpcb_db_host, ':') !== false ? 'tcp://[' . $wpcb_db_host . ']:' . $wpcb_db_port : 'tcp://' . $wpcb_db_host . ':' . $wpcb_db_port;
$wpcb_db_diagnostic['tcp']['attempted'] = true; $wpcb_db_socket = @stream_socket_client($wpcb_db_target, $wpcb_db_tcp_errno, $wpcb_db_tcp_error, 2, STREAM_CLIENT_CONNECT); if (!$wpcb_db_socket) { $wpcb_db_diagnostic['tcp']['error_code'] = (int) $wpcb_db_tcp_errno; $wpcb_db_fail('endpoint_unreachable', 'Verify runtime network access to the managed database endpoint.'); } fclose($wpcb_db_socket); $wpcb_db_diagnostic['tcp']['connected'] = true;
$wpcb_db_diagnostic['mysqli']['attempted'] = true; $wpcb_db = @mysqli_init(); if (!$wpcb_db) $wpcb_db_fail('transport_unavailable', 'The mysqli client could not initialize in this runtime.'); @mysqli_options($wpcb_db, MYSQLI_OPT_CONNECT_TIMEOUT, 2); $wpcb_db_connected = @mysqli_real_connect($wpcb_db, $wpcb_db_host, getenv('DB_USER'), getenv('DB_PASSWORD'), getenv('DB_NAME'), (int) $wpcb_db_port); if (!$wpcb_db_connected) { $wpcb_db_errno = (int) mysqli_connect_errno(); $wpcb_db_diagnostic['mysqli']['error_code'] = $wpcb_db_errno; $wpcb_db_fail($wpcb_db_errno === 1045 ? 'authentication_failed' : ($wpcb_db_errno === 1049 ? 'database_missing' : 'endpoint_unreachable'), 'Verify the managed database credentials and selected database.'); } mysqli_close($wpcb_db); $wpcb_db_diagnostic['mysqli']['connected'] = true;
$wpcb_db_diagnostic['mysqli']['attempted'] = true; $wpcb_db = @mysqli_init(); if (!$wpcb_db) $wpcb_db_fail('transport_unavailable', 'The mysqli client could not initialize in this runtime.'); @mysqli_options($wpcb_db, MYSQLI_OPT_CONNECT_TIMEOUT, 2); $wpcb_db_user = getenv('DB_USER') ?: 'root'; $wpcb_db_name = getenv('DB_NAME') ?: 'runtime'; $wpcb_db_connected = @mysqli_real_connect($wpcb_db, $wpcb_db_host, $wpcb_db_user, getenv('DB_PASSWORD'), $wpcb_db_name, (int) $wpcb_db_port); if (!$wpcb_db_connected) { $wpcb_db_errno = (int) mysqli_connect_errno(); $wpcb_db_diagnostic['mysqli']['error_code'] = $wpcb_db_errno; $wpcb_db_fail($wpcb_db_errno === 1045 ? 'authentication_failed' : ($wpcb_db_errno === 1049 ? 'database_missing' : 'endpoint_unreachable'), 'Verify the managed database credentials and selected database.'); } mysqli_close($wpcb_db); $wpcb_db_diagnostic['mysqli']['connected'] = true;
`
}

Expand Down
117 changes: 117 additions & 0 deletions tests/composer-overlay-projected-runtime.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import assert from "node:assert/strict"
import { execFile as execFileCallback } from "node:child_process"
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { promisify } from "node:util"

import { buildAgentTaskRecipe } from "../packages/runtime-core/src/agent-task-recipe.js"
import { normalizeTaskInput } from "../packages/runtime-core/src/task-input.js"

const execFile = promisify(execFileCallback)
const root = await mkdtemp(join(tmpdir(), "wp-codebox-composer-overlay-projected-runtime-"))
const consumer = join(root, "consumer-plugin")
const overlay = join(root, "overlay-package")
const artifacts = join(root, "artifacts")
const reference = "0123456789abcdef0123456789abcdef01234567"

async function writePackage(source: string, marker: string): Promise<void> {
await mkdir(join(source, "src"), { recursive: true })
await mkdir(join(source, "website", "js"), { recursive: true })
await writeFile(join(source, "composer.json"), JSON.stringify({
name: "acme/asset",
autoload: { "psr-4": { "Acme\\Asset\\": "src/" } },
}))
await writeFile(join(source, "src", "Asset.php"), `<?php
namespace Acme\\Asset;
final class Asset {
public static function marker(): string {
$html = file_get_contents(__DIR__ . '/../website/index.html');
preg_match('/src="([^"]+)"/', $html, $matches);
return trim(file_get_contents(__DIR__ . '/../website/' . $matches[1]));
}
}
`)
await writeFile(join(source, "website", "index.html"), '<script src="js/site.js"></script>\n')
await writeFile(join(source, "website", "js", "site.js"), `${marker}\n`)
}

try {
await writePackage(join(consumer, "vendor", "acme", "asset"), "base-package")
await mkdir(join(consumer, "vendor", "composer"), { recursive: true })
await writeFile(join(consumer, "composer.json"), JSON.stringify({ name: "acme/consumer-plugin" }))
await writeFile(join(consumer, "vendor", "composer", "installed.json"), JSON.stringify({ packages: [{
name: "acme/asset",
"install-path": "../acme/asset",
autoload: { "psr-4": { "Acme\\Asset\\": "src/" } },
}] }))
await writeFile(join(consumer, "vendor", "composer", "autoload_psr4.php"), `<?php
return array('Acme\\\\Asset\\\\' => array($vendorDir . '/acme/asset/src'));
`)
await writeFile(join(consumer, "vendor", "autoload.php"), `<?php
$vendorDir = __DIR__;
$prefixes = require __DIR__ . '/composer/autoload_psr4.php';
spl_autoload_register(static function (string $class) use ($prefixes): void {
foreach ($prefixes as $prefix => $directories) {
if (!str_starts_with($class, $prefix)) continue;
$relative = str_replace('\\\\', '/', substr($class, strlen($prefix))) . '.php';
foreach ($directories as $directory) {
$file = $directory . '/' . $relative;
if (is_file($file)) { require_once $file; return; }
}
}
});
`)
await writeFile(join(consumer, "consumer-plugin.php"), `<?php
/** Plugin Name: Projected Composer Consumer */
require_once __DIR__ . '/vendor/autoload.php';
file_put_contents(WP_CONTENT_DIR . '/projected-composer-overlay.txt', \\Acme\\Asset\\Asset::marker());
`)

await writePackage(overlay, "selected-overlay")
await mkdir(join(overlay, "vendor", "composer"), { recursive: true })
await writeFile(join(overlay, "vendor", "composer", "installed.json"), JSON.stringify({ packages: [{
name: "acme/asset",
autoload: { "psr-4": { "Acme\\Asset\\": "src/" } },
}] }))

const recipe = buildAgentTaskRecipe({
artifacts_path: artifacts,
component_contracts: [{
path: consumer,
slug: "consumer-plugin",
pluginFile: "consumer-plugin.php",
loadAs: "plugin",
activate: true,
}],
dependency_overlays: [{
kind: "composer-package",
package: "acme/asset",
source: overlay,
reference,
consumer: "consumer-plugin",
}],
}, normalizeTaskInput({ goal: "Execute projected Composer overlay" }), "latest")
recipe.workflow = {
steps: [{ command: "wordpress.run-php", args: ["code=echo file_get_contents( WP_CONTENT_DIR . '/projected-composer-overlay.txt' );"] }],
}
assert.equal(recipe.inputs?.dependency_overlays?.[0]?.reference, reference, "the projected recipe retains the immutable overlay reference")
const recipePath = join(root, "recipe.json")
await writeFile(recipePath, `${JSON.stringify(recipe)}\n`)

const { stdout } = await execFile(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", recipePath, "--json"], {
cwd: process.cwd(),
timeout: 300_000,
maxBuffer: 2 * 1024 * 1024,
})
const output = JSON.parse(stdout) as { executions?: Array<{ command?: string; stdout?: string }> }
const execution = output.executions?.filter((candidate) => candidate.command === "wordpress.run-php").at(-1)
assert.equal(execution?.stdout?.trim(), "selected-overlay", "the projected Playground runtime must execute the selected dependency overlay")

const projectedVendor = await readFile(join(artifacts, "prepared-plugins", "consumer-plugin", "vendor", "autoload.php"), "utf8")
assert.match(projectedVendor, /autoload_psr4/, "component projection preserves the hydrated Composer implementation")
} finally {
await rm(root, { recursive: true, force: true })
}

console.log("composer overlay projected runtime: ok")
2 changes: 1 addition & 1 deletion tests/disposable-mysql-mysqli.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ if (!await dockerAvailable()) {
const mariaDbCode = "if (!function_exists('mysqli_init')) { throw new RuntimeException('mysqli is unavailable'); } mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); $connect = static function (string $port): mysqli { $db = mysqli_init(); if (!mysqli_real_connect($db, getenv('DB_HOST'), getenv('DB_USER'), getenv('DB_PASSWORD'), getenv('DB_NAME'), (int) $port)) { throw new RuntimeException(mysqli_connect_error()); } return $db; }; $db = $connect((string) getenv('DB_PORT')); $compatibility = $connect((string) getenv('TC_MYSQL_PORT')); mysqli_query($db, 'CREATE TABLE mariadb_bridge (id INT PRIMARY KEY, value VARCHAR(32) NOT NULL) ENGINE=InnoDB'); mysqli_query($db, \"INSERT INTO mariadb_bridge (id, value) VALUES (1, 'reachable')\"); $row = mysqli_fetch_assoc(mysqli_query($compatibility, 'SELECT value FROM mariadb_bridge WHERE id = 1')); if (($row['value'] ?? null) !== 'reachable') { throw new RuntimeException('MariaDB read failed'); } mysqli_query($db, 'DROP TABLE mariadb_bridge'); echo getenv('DB_PORT') . ':' . getenv('TC_MYSQL_PORT');"
await writeFile(mariaDbRecipePath, JSON.stringify({
schema: "wp-codebox/workspace-recipe/v1",
runtime: { php: "8.4" },
runtime: { phpVersion: "8.4" },
inputs: {
services: [{ id: "mariadb", kind: "mysql", configuration: { engine: "mariadb", rootAuthentication: "empty-password" }, outputs: { host: "DB_HOST", port: ["DB_PORT", "TC_MYSQL_PORT"], username: "DB_USER", password: "DB_PASSWORD", database: "DB_NAME" } }],
},
Expand Down
9 changes: 8 additions & 1 deletion tests/execute-native-agent-task-playground-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,14 @@ add_filter( 'pre_http_request', static function( $preempt, $args, $url ) {
assert.equal(seedProvenance.files, 2, "only README and the explicit .env.example template are copied")
assert.equal(seedProvenance.excluded.files, 7)
assert.deepEqual(seedProvenance.excluded.categories, [{ category: "credentials", count: 2 }, { category: "environment", count: 1 }, { category: "generated-tree", count: 3 }, { category: "private-key", count: 1 }])
assert.equal(execution.code, 0, `${execution.stderr ?? ""}\n${JSON.stringify(result)}`)
assert.equal(execution.code, 0, JSON.stringify({
execution: result.execution,
runtimeError: result.runtime_result?.error,
agentError: result.runtime_result?.agent_task_run_result?.error,
agentResult: result.runtime_result?.agent_result,
agentTaskResult: result.runtime_result?.agent_task_run_result,
failure: result.failure,
}))
assert.equal(result.success, true)
assert.equal(await readFile(join(workspace, "README.md"), "utf8"), "after\n")
assert.equal(result.runtime_result.agent_result?.changedFiles?.count, 1)
Expand Down
16 changes: 15 additions & 1 deletion tests/playground-cli-runner-bootstrap-ini.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ try {
const sharedAutoPrepend = await readFile(sharedAutoPrependPath as string, "utf8")
assert.match(sharedAutoPrepend, /require_once '\/internal\/shared\/auto_prepend_file\.php'/)
assert.match(sharedAutoPrepend, /putenv\("TC_MYSQL_PORT=33060"\);/)
assert.doesNotMatch(sharedAutoPrepend, /secret|DB_PASSWORD/)
assert.match(sharedAutoPrepend, /getenv\('DB_USER'\) \?: 'root'/)
assert.match(sharedAutoPrepend, /getenv\('DB_NAME'\) \?: 'runtime'/)
assert.doesNotMatch(sharedAutoPrepend, /secret/)
assert.equal(runs[0]?.env?.DB_PASSWORD, undefined)
const requestWorkerPath = calls[0]["mount-before-install"]?.[3]?.hostPath
assert.equal(typeof requestWorkerPath, "string")
Expand All @@ -142,6 +144,18 @@ try {
assert.equal(calls[0]?.["mount-before-install"]?.some((mount) => mount.vfsPath === "/internal/wp-codebox"), true, "passwordless external databases retain isolated request workers")
assert.equal(calls[0]?.["mount-before-install"]?.some((mount) => /^\/wordpress\/wp-codebox-execute-[a-f0-9]{24}\.php$/.test(mount.vfsPath)), true)

calls.length = 0
const customDatabaseServer = await startPlaygroundCliServer({
...spec,
runtimeEnv: { TC_MYSQL_PORT: "33060" },
secretEnv: { DB_PASSWORD: "secret" },
secretEnvTargets: { DB_PASSWORD: "DB_PASSWORD" },
}, [], { cliModule })
await customDatabaseServer[Symbol.asyncDispose]()
const customDatabaseAutoPrependPath = calls[0]?.["mount-before-install"]?.[1]?.hostPath
assert.equal(typeof customDatabaseAutoPrependPath, "string")
assert.doesNotMatch(await readFile(customDatabaseAutoPrependPath as string, "utf8"), /WP_CODEBOX_MANAGED_DB_DIAGNOSTIC/, "custom database mappings bypass the canonical endpoint diagnostic")

calls.length = 0
const defaultRuntimeIniSpec: RuntimeCreateSpec = {
...spec,
Expand Down
Loading