diff --git a/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php b/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php index 178a28c..44c4de0 100644 --- a/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php +++ b/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php @@ -23,6 +23,55 @@ public static function has_shell(): bool { return true === self::shell_diagnostic()['ok']; } + /** + * The CLI transport requires a process API and a session-safe cleanup path. + */ + public static function can_execute_processes(): bool { + return true === self::evaluate_process_capability( + static fn(string $function_name): bool => function_exists($function_name), + (string) ini_get('disable_functions'), + static function (string $command): array { + $output = array(); + $exit_code = null; + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Capability probe. + exec($command, $output, $exit_code); + return array('output' => $output, 'exit_code' => $exit_code); + }, + static fn(): bool => self::has_session_launcher() + )['ok']; + } + + /** + * Verify every PHP primitive the CLI transport invokes after claiming work. + * + * @param callable $function_exists Receives a function name and returns its availability. + * @param callable $command_runner Receives a command and returns output plus exit code. + * @param callable $has_session_launcher Returns whether a usable setsid launcher exists. + * @return array{ok: bool, reason: string, exec_available: bool, shell_exec_available: bool, proc_open_available: bool, output?: string, exit_code?: int|null} + */ + public static function evaluate_process_capability(callable $function_exists, string $disabled_functions, callable $command_runner, callable $has_session_launcher): array { + $diagnostic = self::evaluate_shell_capability($function_exists, $disabled_functions, $command_runner); + if (true !== $diagnostic['ok']) { + return $diagnostic; + } + + $disabled = array_filter(array_map('trim', explode(',', $disabled_functions))); + foreach (array('proc_open', 'proc_get_status', 'proc_close', 'proc_terminate', 'posix_kill') as $function_name) { + if (!self::function_available($function_name, $function_exists, $disabled)) { + return array_merge($diagnostic, array( + 'ok' => false, + 'reason' => $function_exists($function_name) ? $function_name . '_disabled' : $function_name . '_missing', + )); + } + } + + if (!$has_session_launcher()) { + return array_merge($diagnostic, array('ok' => false, 'reason' => 'setsid_missing')); + } + + return $diagnostic; + } + /** * @return array{ok: bool, reason: string, exec_available: bool, shell_exec_available: bool, proc_open_available: bool, output?: string, exit_code?: int|null} */ @@ -81,6 +130,26 @@ public static function has_writable_content_directory(): bool { return defined('WP_CONTENT_DIR') && is_writable(WP_CONTENT_DIR); } + private static function has_session_launcher(): bool { + $candidates = array('/usr/bin/setsid', '/bin/setsid'); + $path = getenv('PATH'); + if (is_string($path)) { + foreach (explode(PATH_SEPARATOR, $path) as $directory) { + if ('' !== $directory) { + $candidates[] = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'setsid'; + } + } + } + + foreach (array_unique($candidates) as $candidate) { + if (is_file($candidate) && is_executable($candidate)) { + return true; + } + } + + return false; + } + /** * @param string[] $disabled */ diff --git a/carried-plugins/wp-coding-agents-integration/wp-coding-agents-integration.php b/carried-plugins/wp-coding-agents-integration/wp-coding-agents-integration.php index 6576805..3e7563c 100644 --- a/carried-plugins/wp-coding-agents-integration/wp-coding-agents-integration.php +++ b/carried-plugins/wp-coding-agents-integration/wp-coding-agents-integration.php @@ -24,6 +24,21 @@ require_once __DIR__ . '/src/HostCapabilities.php'; +/** + * Declare child-process support only after the installed host probe succeeds. + */ +function provide_process_execution_capability(bool $available): bool { + unset($available); + return HostCapabilities::can_execute_processes(); +} + +/** + * Declare the local process workspace only when the installed host can write it. + */ +function provide_writable_process_workspace_capability(bool $available): bool { + return $available || HostCapabilities::has_writable_content_directory(); +} + /** * Supply shell availability through Intelligence's provider-neutral contract. */ @@ -35,8 +50,10 @@ function provide_intelligence_shell_capability(bool $available): bool { * Supply writable content-directory availability through Intelligence's contract. */ function provide_intelligence_writable_content_capability(bool $available): bool { - return $available || HostCapabilities::has_writable_content_directory(); + return $available || apply_filters('wp_coding_agents_host_has_writable_process_workspace', false); } +add_filter('wp_coding_agents_host_can_execute_processes', __NAMESPACE__ . '\\provide_process_execution_capability'); +add_filter('wp_coding_agents_host_has_writable_process_workspace', __NAMESPACE__ . '\\provide_writable_process_workspace_capability'); add_filter('intelligence_host_has_shell', __NAMESPACE__ . '\\provide_intelligence_shell_capability'); add_filter('intelligence_host_has_writable_content_directory', __NAMESPACE__ . '\\provide_intelligence_writable_content_capability'); diff --git a/docs/wordpress-integration-package.md b/docs/wordpress-integration-package.md index b075770..a8ec49b 100644 --- a/docs/wordpress-integration-package.md +++ b/docs/wordpress-integration-package.md @@ -4,4 +4,6 @@ Generated MU-plugins remain limited to installation governance that must always load, such as runtime registration, channel transport, inbound events, and source reconciliation. Host installation, repository policy, and Homeboy lifecycle stay outside the WordPress package. -The initial package keeps `WpCodingAgents\Integration\HostCapabilities` private to the provider package and adapts its probes onto Intelligence-owned `intelligence_host_has_shell` and `intelligence_host_has_writable_content_directory` filters. Intelligence and Data Machine do not reference this package or namespace. No DMC workspace, GitHub, ability, flow, task, or lifecycle implementation is included. +The package owns two generic, fail-closed host-execution contracts: `wp_coding_agents_host_can_execute_processes` and `wp_coding_agents_host_has_writable_process_workspace`. Each filter receives `false`; the installed integration declares process support only after its shell probe and the CLI transport's `proc_open`, POSIX cleanup, and session-launcher requirements succeed. Consumers inspect these filters directly and do not depend on this package's PHP namespace. The package's Intelligence adapters consume the same declarations, including the writable-workspace declaration. No product, Studio, or host-profile logic belongs in the contract. + +`WpCodingAgents\Integration\HostCapabilities` remains private to the provider package and adapts its probes onto Intelligence-owned `intelligence_host_has_shell` and `intelligence_host_has_writable_content_directory` filters. Intelligence and Data Machine do not reference this package or namespace. No DMC workspace, GitHub, ability, flow, task, or lifecycle implementation is included. diff --git a/lib/integration-adapters.sh b/lib/integration-adapters.sh index 705b1cf..e0ae255 100644 --- a/lib/integration-adapters.sh +++ b/lib/integration-adapters.sh @@ -118,7 +118,7 @@ _integration_adapter_verify_carried_plugins() { [ -d "$target_dir" ] || return 1 wp_cmd plugin is-active "$slug" >/dev/null 2>&1 || return 1 if [ "$slug" = wp-coding-agents-integration ]; then - wp_cmd eval 'exit(false !== has_filter("intelligence_host_has_shell", "WpCodingAgents\\Integration\\provide_intelligence_shell_capability") && false !== has_filter("intelligence_host_has_writable_content_directory", "WpCodingAgents\\Integration\\provide_intelligence_writable_content_capability") ? 0 : 1);' >/dev/null 2>&1 || return 1 + wp_cmd eval 'exit(false !== has_filter("wp_coding_agents_host_can_execute_processes", "WpCodingAgents\\Integration\\provide_process_execution_capability") && false !== has_filter("wp_coding_agents_host_has_writable_process_workspace", "WpCodingAgents\\Integration\\provide_writable_process_workspace_capability") && false !== has_filter("intelligence_host_has_shell", "WpCodingAgents\\Integration\\provide_intelligence_shell_capability") && false !== has_filter("intelligence_host_has_writable_content_directory", "WpCodingAgents\\Integration\\provide_intelligence_writable_content_capability") ? 0 : 1);' >/dev/null 2>&1 || return 1 fi elif carried_plugin_is_managed "$target_dir"; then return 1 diff --git a/templates/wp-coding-agents-cli-transport.php b/templates/wp-coding-agents-cli-transport.php index 7a63d6f..8826a98 100644 --- a/templates/wp-coding-agents-cli-transport.php +++ b/templates/wp-coding-agents-cli-transport.php @@ -290,7 +290,7 @@ public static function maybe_claim( $existing, $input ) { return $existing; } - if ( ! is_array( $input ) || ! function_exists( 'proc_open' ) ) { + if ( ! is_array( $input ) || ! self::can_execute_processes() ) { return $existing; } @@ -433,8 +433,8 @@ private static function dispatch_sync( string $channel, string $recipient, array * @return resource|WP_Error */ private static function open_process( array $argv, array $descriptors, ?string $cwd, ?array $env, array &$pipes = array() ) { - if ( ! function_exists( 'proc_open' ) ) { - return new WP_Error( 'wp_coding_agents_cli_dispatch_no_proc_open', 'proc_open is not available on this host.' ); + if ( ! self::can_execute_processes() ) { + return new WP_Error( 'wp_coding_agents_cli_dispatch_process_execution_unavailable', 'Child-process execution is not available on this host.' ); } if ( ! function_exists( 'posix_kill' ) ) { return new WP_Error( 'wp_coding_agents_cli_dispatch_no_posix_kill', 'The POSIX process extension is required for bounded CLI process-tree cleanup.' ); @@ -445,8 +445,7 @@ private static function open_process( array $argv, array $descriptors, ?string $ return new WP_Error( 'wp_coding_agents_cli_dispatch_no_session_launcher', 'A POSIX setsid executable is required for bounded CLI process-tree cleanup.' ); } - array_unshift( $argv, $session_launcher, '--' ); - $process = @proc_open( $argv, $descriptors, $pipes, $cwd, $env ); + $process = @proc_open( self::session_launcher_argv( $session_launcher, $argv ), $descriptors, $pipes, $cwd, $env ); if ( ! is_resource( $process ) ) { return new WP_Error( 'wp_coding_agents_cli_dispatch_spawn_failed', 'Failed to spawn CLI process.' ); } @@ -454,6 +453,11 @@ private static function open_process( array $argv, array $descriptors, ?string $ return $process; } + /** Return the host integration's fail-closed child-process declaration. */ + private static function can_execute_processes(): bool { + return function_exists( 'apply_filters' ) && true === apply_filters( 'wp_coding_agents_host_can_execute_processes', false ); + } + /** Locate the POSIX session launcher without invoking a shell. */ private static function find_session_launcher(): ?string { $candidates = array( '/usr/bin/setsid', '/bin/setsid' ); @@ -475,6 +479,11 @@ private static function find_session_launcher(): ?string { return null; } + /** Build the setsid PROGRAM [ARGS...] contract shared by GNU and BusyBox. */ + private static function session_launcher_argv( string $session_launcher, array $argv ): array { + return array_merge( array( $session_launcher ), $argv ); + } + /** * Drain currently available child output without blocking. * diff --git a/tests/integration-adapters.sh b/tests/integration-adapters.sh index 1e11af4..b5352fd 100644 --- a/tests/integration-adapters.sh +++ b/tests/integration-adapters.sh @@ -22,7 +22,7 @@ wp_cmd() { case "$1 $2" in 'option list') printf '0\n' ;; 'plugin is-active') return 0 ;; - 'eval exit(false !== has_filter("intelligence_host_has_shell", "WpCodingAgents\\Integration\\provide_intelligence_shell_capability") && false !== has_filter("intelligence_host_has_writable_content_directory", "WpCodingAgents\\Integration\\provide_intelligence_writable_content_capability") ? 0 : 1);') [ "${HOST_CAPABILITIES_AVAILABLE:-true}" = true ] ;; + 'eval exit(false !== has_filter("wp_coding_agents_host_can_execute_processes", "WpCodingAgents\\Integration\\provide_process_execution_capability") && false !== has_filter("wp_coding_agents_host_has_writable_process_workspace", "WpCodingAgents\\Integration\\provide_writable_process_workspace_capability") && false !== has_filter("intelligence_host_has_shell", "WpCodingAgents\\Integration\\provide_intelligence_shell_capability") && false !== has_filter("intelligence_host_has_writable_content_directory", "WpCodingAgents\\Integration\\provide_intelligence_writable_content_capability") ? 0 : 1);') [ "${HOST_CAPABILITIES_AVAILABLE:-true}" = true ] ;; *) return 1 ;; esac } diff --git a/tests/smoke-cli-transport.php b/tests/smoke-cli-transport.php index f77fdeb..f4385f6 100644 --- a/tests/smoke-cli-transport.php +++ b/tests/smoke-cli-transport.php @@ -337,7 +337,33 @@ static function ( array $channels ) use ( $echo_bin ): array { ), ); +$assert( 'undeclared host declines process-backed channel', null === WpCodingAgents_Cli_Channel_Transport::maybe_claim( null, array( 'channel' => 'sync-true' ) ) ); +add_filter( 'wp_coding_agents_host_can_execute_processes', static fn( bool $available ): bool => false ); +$assert( 'unavailable host declines process-backed channel', null === WpCodingAgents_Cli_Channel_Transport::maybe_claim( null, array( 'channel' => 'sync-true' ) ) ); +$wp_coding_agents_test_filters['wp_coding_agents_host_can_execute_processes'] = array(); +require_once __DIR__ . '/../carried-plugins/wp-coding-agents-integration/wp-coding-agents-integration.php'; + +$installed_host_can_execute = \WpCodingAgents\Integration\HostCapabilities::can_execute_processes(); $claim_known = WpCodingAgents_Cli_Channel_Transport::maybe_claim( null, array( 'channel' => 'sync-true' ) ); +$assert( 'installed host claim reflects executable capability', $installed_host_can_execute === is_callable( $claim_known ) ); +$assert( 'installed provider rechecks an explicit capable declaration', $installed_host_can_execute === apply_filters( 'wp_coding_agents_host_can_execute_processes', true ) ); +add_filter( 'wp_coding_agents_host_can_execute_processes', static fn( bool $available ): bool => false, 20 ); +$assert( 'later denial overrides an explicit capable declaration', false === apply_filters( 'wp_coding_agents_host_can_execute_processes', true ) ); +$assert( 'later integration-owned denial overrides installed provider', null === WpCodingAgents_Cli_Channel_Transport::maybe_claim( null, array( 'channel' => 'sync-true' ) ) ); +$wp_coding_agents_test_filters['wp_coding_agents_host_can_execute_processes'][20] = array(); +$portable_argv = Closure::bind( + static function (): array { + return self::session_launcher_argv( '/fixture/setsid', array( '/bin/true', '--child-option' ) ); + }, + null, + WpCodingAgents_Cli_Channel_Transport::class +)(); +$assert( 'portable setsid invocation keeps the child command first', array( '/fixture/setsid', '/bin/true', '--child-option' ) === $portable_argv ); +if ( ! $installed_host_can_execute ) { + echo " [SKIP] installed host cannot meet the session-safe process contract\n"; + exit( 0 ); +} + $assert( 'claims registered channel', is_callable( $claim_known ) ); $assert( 'declines unknown channel', null === WpCodingAgents_Cli_Channel_Transport::maybe_claim( null, array( 'channel' => 'unknown' ) ) ); $prior = static fn() => null; diff --git a/tests/wordpress-integration-package.php b/tests/wordpress-integration-package.php index 6d82066..16916a5 100644 --- a/tests/wordpress-integration-package.php +++ b/tests/wordpress-integration-package.php @@ -6,13 +6,17 @@ define('WP_CONTENT_DIR', __DIR__); $GLOBALS['wp_coding_agents_test_filters'] = array(); -function add_filter(string $hook, callable $callback): void { - $GLOBALS['wp_coding_agents_test_filters'][$hook][] = $callback; +function add_filter(string $hook, callable $callback, int $priority = 10): void { + $GLOBALS['wp_coding_agents_test_filters'][$hook][$priority][] = $callback; } function apply_filters(string $hook, $value) { - foreach ($GLOBALS['wp_coding_agents_test_filters'][$hook] ?? array() as $callback) { - $value = $callback($value); + $filters = $GLOBALS['wp_coding_agents_test_filters'][$hook] ?? array(); + ksort($filters); + foreach ($filters as $callbacks) { + foreach ($callbacks as $callback) { + $value = $callback($value); + } } return $value; } @@ -22,16 +26,32 @@ function apply_filters(string $hook, $value) { use WpCodingAgents\Integration\HostCapabilities; assert(class_exists(HostCapabilities::class)); +assert(isset($GLOBALS['wp_coding_agents_test_filters']['wp_coding_agents_host_can_execute_processes'])); +assert(isset($GLOBALS['wp_coding_agents_test_filters']['wp_coding_agents_host_has_writable_process_workspace'])); assert(isset($GLOBALS['wp_coding_agents_test_filters']['intelligence_host_has_shell'])); assert(isset($GLOBALS['wp_coding_agents_test_filters']['intelligence_host_has_writable_content_directory'])); +assert( + 'WpCodingAgents\\Integration\\provide_process_execution_capability' + === $GLOBALS['wp_coding_agents_test_filters']['wp_coding_agents_host_can_execute_processes'][10][0] +); +assert( + 'WpCodingAgents\\Integration\\provide_writable_process_workspace_capability' + === $GLOBALS['wp_coding_agents_test_filters']['wp_coding_agents_host_has_writable_process_workspace'][10][0] +); assert( 'WpCodingAgents\\Integration\\provide_intelligence_shell_capability' - === $GLOBALS['wp_coding_agents_test_filters']['intelligence_host_has_shell'][0] + === $GLOBALS['wp_coding_agents_test_filters']['intelligence_host_has_shell'][10][0] ); assert(true === apply_filters('intelligence_host_has_shell', true)); assert(true === apply_filters('intelligence_host_has_writable_content_directory', false)); -$available = static fn(string $function_name): bool => in_array($function_name, array('exec', 'shell_exec', 'proc_open'), true); +assert(HostCapabilities::can_execute_processes() === apply_filters('wp_coding_agents_host_can_execute_processes', false)); +assert(HostCapabilities::has_writable_content_directory() === apply_filters('wp_coding_agents_host_has_writable_process_workspace', false)); +assert(HostCapabilities::has_shell() === apply_filters('intelligence_host_has_shell', false)); +assert(HostCapabilities::has_writable_content_directory() === apply_filters('intelligence_host_has_writable_content_directory', false)); + +$required_functions = array('exec', 'shell_exec', 'proc_open', 'proc_get_status', 'proc_close', 'proc_terminate', 'posix_kill'); +$available = static fn(string $function_name): bool => in_array($function_name, $required_functions, true); $success = static fn(string $command): array => array( 'output' => array('__wp_coding_agents_shell_ok__'), 'exit_code' => 0, @@ -42,6 +62,29 @@ function apply_filters(string $hook, $value) { assert(true === $diagnostic['proc_open_available']); assert(true === HostCapabilities::has_writable_content_directory()); +$process = HostCapabilities::evaluate_process_capability($available, '', $success, static fn(): bool => true); +assert(true === $process['ok']); +assert('ok' === $process['reason']); + +foreach ($required_functions as $function_name) { + $disabled_process = HostCapabilities::evaluate_process_capability($available, $function_name, $success, static fn(): bool => true); + assert(false === $disabled_process['ok']); + assert($function_name . '_disabled' === $disabled_process['reason']); + + $missing_process = HostCapabilities::evaluate_process_capability( + static fn(string $candidate): bool => $candidate !== $function_name && $available($candidate), + '', + $success, + static fn(): bool => true + ); + assert(false === $missing_process['ok']); + assert($function_name . '_missing' === $missing_process['reason']); +} + +$missing_launcher = HostCapabilities::evaluate_process_capability($available, '', $success, static fn(): bool => false); +assert(false === $missing_launcher['ok']); +assert('setsid_missing' === $missing_launcher['reason']); + $disabled = HostCapabilities::evaluate_shell_capability($available, 'shell_exec', $success); assert(false === $disabled['ok']); assert('shell_exec_disabled' === $disabled['reason']); @@ -54,4 +97,12 @@ function apply_filters(string $hook, $value) { assert(false === $failed['ok']); assert('probe_failed' === $failed['reason']); +assert( + HostCapabilities::can_execute_processes() + === apply_filters('wp_coding_agents_host_can_execute_processes', true) +); +add_filter('wp_coding_agents_host_can_execute_processes', static fn(bool $available): bool => false, 20); +assert(false === apply_filters('wp_coding_agents_host_can_execute_processes', true)); +assert(true === apply_filters('wp_coding_agents_host_has_writable_process_workspace', true)); + echo "PASS: focused WordPress integration host capabilities\n";