From 695dc0e864a799a81b87673a2566c63403ee20da Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 31 Aug 2026 15:59:00 -0400 Subject: [PATCH 1/4] feat: add host process capability contract --- docs/wordpress-integration-package.md | 4 +++- templates/wp-coding-agents-cli-transport.php | 11 ++++++++--- tests/smoke-cli-transport.php | 6 ++++++ tests/wordpress-integration-package.php | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/wordpress-integration-package.md b/docs/wordpress-integration-package.md index b075770..5ca1bcb 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 owning host integration declares supported capabilities by returning `true`. Consumers inspect these filters directly and do not depend on this package's PHP namespace. 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/templates/wp-coding-agents-cli-transport.php b/templates/wp-coding-agents-cli-transport.php index 7a63d6f..ee02e19 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.' ); @@ -454,6 +454,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' ); diff --git a/tests/smoke-cli-transport.php b/tests/smoke-cli-transport.php index f77fdeb..d7f96fc 100644 --- a/tests/smoke-cli-transport.php +++ b/tests/smoke-cli-transport.php @@ -337,6 +337,12 @@ 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(); +add_filter( 'wp_coding_agents_host_can_execute_processes', static fn( bool $available ): bool => true ); + $claim_known = WpCodingAgents_Cli_Channel_Transport::maybe_claim( null, array( 'channel' => 'sync-true' ) ); $assert( 'claims registered channel', is_callable( $claim_known ) ); $assert( 'declines unknown channel', null === WpCodingAgents_Cli_Channel_Transport::maybe_claim( null, array( 'channel' => 'unknown' ) ) ); diff --git a/tests/wordpress-integration-package.php b/tests/wordpress-integration-package.php index 6d82066..354d828 100644 --- a/tests/wordpress-integration-package.php +++ b/tests/wordpress-integration-package.php @@ -31,6 +31,20 @@ function apply_filters(string $hook, $value) { assert(true === apply_filters('intelligence_host_has_shell', true)); assert(true === apply_filters('intelligence_host_has_writable_content_directory', false)); +// Host integrations declare these independently; no declaration is support. +assert(false === apply_filters('wp_coding_agents_host_can_execute_processes', false)); +assert(false === apply_filters('wp_coding_agents_host_has_writable_process_workspace', false)); +add_filter('wp_coding_agents_host_can_execute_processes', static fn(bool $available): bool => false); +add_filter('wp_coding_agents_host_has_writable_process_workspace', static fn(bool $available): bool => false); +assert(false === apply_filters('wp_coding_agents_host_can_execute_processes', false)); +assert(false === apply_filters('wp_coding_agents_host_has_writable_process_workspace', false)); +$GLOBALS['wp_coding_agents_test_filters']['wp_coding_agents_host_can_execute_processes'] = array(); +$GLOBALS['wp_coding_agents_test_filters']['wp_coding_agents_host_has_writable_process_workspace'] = array(); +add_filter('wp_coding_agents_host_can_execute_processes', static fn(bool $available): bool => true); +add_filter('wp_coding_agents_host_has_writable_process_workspace', static fn(bool $available): bool => true); +assert(true === apply_filters('wp_coding_agents_host_can_execute_processes', false)); +assert(true === apply_filters('wp_coding_agents_host_has_writable_process_workspace', false)); + $available = static fn(string $function_name): bool => in_array($function_name, array('exec', 'shell_exec', 'proc_open'), true); $success = static fn(string $command): array => array( 'output' => array('__wp_coding_agents_shell_ok__'), From e7aa0c03e63f7992422a208e029a503aad41149c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 31 Aug 2026 16:12:18 -0400 Subject: [PATCH 2/4] fix: declare installed host process support --- .../src/HostCapabilities.php | 31 +++++++++++++++++++ .../wp-coding-agents-integration.php | 18 ++++++++++- docs/wordpress-integration-package.md | 2 +- lib/integration-adapters.sh | 2 +- tests/integration-adapters.sh | 2 +- tests/smoke-cli-transport.php | 9 +++++- tests/wordpress-integration-package.php | 27 ++++++++-------- 7 files changed, 73 insertions(+), 18 deletions(-) diff --git a/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php b/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php index 178a28c..8d5d009 100644 --- a/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php +++ b/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php @@ -23,6 +23,17 @@ 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 { + $diagnostic = self::shell_diagnostic(); + return true === $diagnostic['ok'] && + true === $diagnostic['proc_open_available'] && + function_exists('posix_kill') && + self::has_session_launcher(); + } + /** * @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 +92,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..c05318e 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,20 @@ 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 { + return $available || 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 +49,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 5ca1bcb..a8ec49b 100644 --- a/docs/wordpress-integration-package.md +++ b/docs/wordpress-integration-package.md @@ -4,6 +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 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 owning host integration declares supported capabilities by returning `true`. Consumers inspect these filters directly and do not depend on this package's PHP namespace. No product, Studio, or host-profile logic belongs in the contract. +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/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 d7f96fc..7fbe038 100644 --- a/tests/smoke-cli-transport.php +++ b/tests/smoke-cli-transport.php @@ -341,9 +341,16 @@ static function ( array $channels ) use ( $echo_bin ): array { 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(); -add_filter( 'wp_coding_agents_host_can_execute_processes', static fn( bool $available ): bool => true ); +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 ) ); +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 354d828..9410ed3 100644 --- a/tests/wordpress-integration-package.php +++ b/tests/wordpress-integration-package.php @@ -22,8 +22,18 @@ 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'][0] +); +assert( + 'WpCodingAgents\\Integration\\provide_writable_process_workspace_capability' + === $GLOBALS['wp_coding_agents_test_filters']['wp_coding_agents_host_has_writable_process_workspace'][0] +); assert( 'WpCodingAgents\\Integration\\provide_intelligence_shell_capability' === $GLOBALS['wp_coding_agents_test_filters']['intelligence_host_has_shell'][0] @@ -31,19 +41,10 @@ function apply_filters(string $hook, $value) { assert(true === apply_filters('intelligence_host_has_shell', true)); assert(true === apply_filters('intelligence_host_has_writable_content_directory', false)); -// Host integrations declare these independently; no declaration is support. -assert(false === apply_filters('wp_coding_agents_host_can_execute_processes', false)); -assert(false === apply_filters('wp_coding_agents_host_has_writable_process_workspace', false)); -add_filter('wp_coding_agents_host_can_execute_processes', static fn(bool $available): bool => false); -add_filter('wp_coding_agents_host_has_writable_process_workspace', static fn(bool $available): bool => false); -assert(false === apply_filters('wp_coding_agents_host_can_execute_processes', false)); -assert(false === apply_filters('wp_coding_agents_host_has_writable_process_workspace', false)); -$GLOBALS['wp_coding_agents_test_filters']['wp_coding_agents_host_can_execute_processes'] = array(); -$GLOBALS['wp_coding_agents_test_filters']['wp_coding_agents_host_has_writable_process_workspace'] = array(); -add_filter('wp_coding_agents_host_can_execute_processes', static fn(bool $available): bool => true); -add_filter('wp_coding_agents_host_has_writable_process_workspace', static fn(bool $available): bool => true); -assert(true === apply_filters('wp_coding_agents_host_can_execute_processes', false)); -assert(true === apply_filters('wp_coding_agents_host_has_writable_process_workspace', false)); +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)); $available = static fn(string $function_name): bool => in_array($function_name, array('exec', 'shell_exec', 'proc_open'), true); $success = static fn(string $command): array => array( From 30d10c9d899a2bc486af9ff39df889692e2fdefd Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 31 Aug 2026 17:00:59 -0400 Subject: [PATCH 3/4] fix: require complete CLI process contract --- .../src/HostCapabilities.php | 48 ++++++++++++++++-- tests/smoke-cli-transport.php | 5 ++ tests/wordpress-integration-package.php | 49 ++++++++++++++++--- 3 files changed, 89 insertions(+), 13 deletions(-) diff --git a/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php b/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php index 8d5d009..44c4de0 100644 --- a/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php +++ b/carried-plugins/wp-coding-agents-integration/src/HostCapabilities.php @@ -27,11 +27,49 @@ public static function has_shell(): bool { * The CLI transport requires a process API and a session-safe cleanup path. */ public static function can_execute_processes(): bool { - $diagnostic = self::shell_diagnostic(); - return true === $diagnostic['ok'] && - true === $diagnostic['proc_open_available'] && - function_exists('posix_kill') && - self::has_session_launcher(); + 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; } /** diff --git a/tests/smoke-cli-transport.php b/tests/smoke-cli-transport.php index 7fbe038..71cfcd8 100644 --- a/tests/smoke-cli-transport.php +++ b/tests/smoke-cli-transport.php @@ -346,6 +346,11 @@ static function ( array $channels ) use ( $echo_bin ): array { $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 preserves an explicit capable declaration', true === 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(); if ( ! $installed_host_can_execute ) { echo " [SKIP] installed host cannot meet the session-safe process contract\n"; exit( 0 ); diff --git a/tests/wordpress-integration-package.php b/tests/wordpress-integration-package.php index 9410ed3..9704fff 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; } @@ -28,15 +32,15 @@ function apply_filters(string $hook, $value) { 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'][0] + === $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'][0] + === $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)); @@ -46,7 +50,8 @@ function apply_filters(string $hook, $value) { 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)); -$available = static fn(string $function_name): bool => in_array($function_name, array('exec', 'shell_exec', 'proc_open'), true); +$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, @@ -57,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']); @@ -69,4 +97,9 @@ function apply_filters(string $hook, $value) { assert(false === $failed['ok']); assert('probe_failed' === $failed['reason']); +assert(true === 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"; From fb4d41d737ba1433bbc476df215c6b8b7107efeb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 31 Aug 2026 17:27:35 -0400 Subject: [PATCH 4/4] fix: fail closed process capability --- .../wp-coding-agents-integration.php | 3 ++- templates/wp-coding-agents-cli-transport.php | 8 ++++++-- tests/smoke-cli-transport.php | 10 +++++++++- tests/wordpress-integration-package.php | 5 ++++- 4 files changed, 21 insertions(+), 5 deletions(-) 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 c05318e..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 @@ -28,7 +28,8 @@ * Declare child-process support only after the installed host probe succeeds. */ function provide_process_execution_capability(bool $available): bool { - return $available || HostCapabilities::can_execute_processes(); + unset($available); + return HostCapabilities::can_execute_processes(); } /** diff --git a/templates/wp-coding-agents-cli-transport.php b/templates/wp-coding-agents-cli-transport.php index ee02e19..8826a98 100644 --- a/templates/wp-coding-agents-cli-transport.php +++ b/templates/wp-coding-agents-cli-transport.php @@ -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.' ); } @@ -480,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/smoke-cli-transport.php b/tests/smoke-cli-transport.php index 71cfcd8..f4385f6 100644 --- a/tests/smoke-cli-transport.php +++ b/tests/smoke-cli-transport.php @@ -346,11 +346,19 @@ static function ( array $channels ) use ( $echo_bin ): array { $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 preserves an explicit capable declaration', true === apply_filters( 'wp_coding_agents_host_can_execute_processes', true ) ); +$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 ); diff --git a/tests/wordpress-integration-package.php b/tests/wordpress-integration-package.php index 9704fff..16916a5 100644 --- a/tests/wordpress-integration-package.php +++ b/tests/wordpress-integration-package.php @@ -97,7 +97,10 @@ function apply_filters(string $hook, $value) { assert(false === $failed['ok']); assert('probe_failed' === $failed['reason']); -assert(true === apply_filters('wp_coding_agents_host_can_execute_processes', true)); +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));