Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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');
4 changes: 3 additions & 1 deletion docs/wordpress-integration-package.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion lib/integration-adapters.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 14 additions & 5 deletions templates/wp-coding-agents-cli-transport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.' );
Expand All @@ -445,15 +445,19 @@ 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.' );
}

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' );
Expand All @@ -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.
*
Expand Down
2 changes: 1 addition & 1 deletion tests/integration-adapters.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
26 changes: 26 additions & 0 deletions tests/smoke-cli-transport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
63 changes: 57 additions & 6 deletions tests/wordpress-integration-package.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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,
Expand All @@ -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']);
Expand All @@ -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";
Loading