diff --git a/.github/scripts/run-agent-task/execute-native-agent-task.mjs b/.github/scripts/run-agent-task/execute-native-agent-task.mjs index 352c33136..fc9abcdc4 100644 --- a/.github/scripts/run-agent-task/execute-native-agent-task.mjs +++ b/.github/scripts/run-agent-task/execute-native-agent-task.mjs @@ -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, diff --git a/package.json b/package.json index 4a2e27153..7b3835fa4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/runtime-core/src/recipe-source-packages.ts b/packages/runtime-core/src/recipe-source-packages.ts index f01c5190a..09d1979d6 100644 --- a/packages/runtime-core/src/recipe-source-packages.ts +++ b/packages/runtime-core/src/recipe-source-packages.ts @@ -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 diff --git a/packages/runtime-playground/src/mount-materialization.ts b/packages/runtime-playground/src/mount-materialization.ts index 63487936c..ddc5bd6cc 100644 --- a/packages/runtime-playground/src/mount-materialization.ts +++ b/packages/runtime-playground/src/mount-materialization.ts @@ -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 { 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 } } diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 3efe0be1b..29529f498 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -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 => typeof service === "object" && service !== null && (service as { kind?: unknown }).kind === "mysql") const receipt = mysql ? { @@ -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; ` } diff --git a/tests/composer-overlay-projected-runtime.integration.test.ts b/tests/composer-overlay-projected-runtime.integration.test.ts new file mode 100644 index 000000000..961bb1f27 --- /dev/null +++ b/tests/composer-overlay-projected-runtime.integration.test.ts @@ -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 { + 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"), `\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"), ` array($vendorDir . '/acme/asset/src')); +`) + await writeFile(join(consumer, "vendor", "autoload.php"), ` $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"), ` } + 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") diff --git a/tests/disposable-mysql-mysqli.integration.test.ts b/tests/disposable-mysql-mysqli.integration.test.ts index a158cd00b..9f0ff773b 100644 --- a/tests/disposable-mysql-mysqli.integration.test.ts +++ b/tests/disposable-mysql-mysqli.integration.test.ts @@ -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" } }], }, diff --git a/tests/execute-native-agent-task-playground-e2e.test.ts b/tests/execute-native-agent-task-playground-e2e.test.ts index bf0ee7ca6..3371b27f4 100644 --- a/tests/execute-native-agent-task-playground-e2e.test.ts +++ b/tests/execute-native-agent-task-playground-e2e.test.ts @@ -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) diff --git a/tests/playground-cli-runner-bootstrap-ini.test.ts b/tests/playground-cli-runner-bootstrap-ini.test.ts index 4464181b2..1c2946dd9 100644 --- a/tests/playground-cli-runner-bootstrap-ini.test.ts +++ b/tests/playground-cli-runner-bootstrap-ini.test.ts @@ -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") @@ -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,