From 25f214fa7b309c889f339b7b20e1c1ca91724d56 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 6 Jun 2026 13:28:08 -0400 Subject: [PATCH 1/2] refactor: centralize runtime capability probes --- inc/Abilities/WorkspaceAbilities.php | 6 +- inc/Environment.php | 88 ++--------- inc/Support/GitRunner.php | 41 +---- inc/Support/ProcessRunner.php | 16 +- inc/Support/RuntimeCapabilities.php | 208 +++++++++++++++++++++++++ inc/Workspace/WorktreeBootstrapper.php | 12 +- 6 files changed, 244 insertions(+), 127 deletions(-) create mode 100644 inc/Support/RuntimeCapabilities.php diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 13b87295..604c8c3e 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -21,12 +21,16 @@ use DataMachineCode\Workspace\WorkspaceReader; use DataMachineCode\Workspace\WorkspaceWriter; use DataMachineCode\Support\GitRunner; +use DataMachineCode\Support\RuntimeCapabilities; defined('ABSPATH') || exit; if ( ! class_exists(AbilityRegistry::class) ) { require_once __DIR__ . '/AbilityRegistry.php'; } +if ( ! class_exists(RuntimeCapabilities::class) ) { + require_once dirname(__DIR__) . '/Support/RuntimeCapabilities.php'; +} class WorkspaceAbilities { @@ -2327,7 +2331,7 @@ public static function getCapabilities( array $input ): array { // phpcs:ignor $workspace = new Workspace(); $diagnostic = GitRunner::diagnose(); $backend = RemoteWorkspaceBackend::should_handle() ? 'github_api' : 'local_git'; - $remediation = 'Run workspace abilities in a host runtime with local git access, or provide a Data Machine Code workspace backend that executes these operations outside the constrained PHP sandbox.'; + $remediation = RuntimeCapabilities::workspace_remediation(); return array_merge( $diagnostic, diff --git a/inc/Environment.php b/inc/Environment.php index 079e971d..4e81e333 100644 --- a/inc/Environment.php +++ b/inc/Environment.php @@ -30,21 +30,20 @@ namespace DataMachineCode; +use DataMachineCode\Support\RuntimeCapabilities; + if ( ! defined('ABSPATH') ) { exit; } +if ( ! class_exists(RuntimeCapabilities::class) ) { + require_once __DIR__ . '/Support/RuntimeCapabilities.php'; +} + class Environment { - /** - * Memoized shell capability diagnostic. Null until the first probe. - * - * @var array{ok: bool, reason: string, output?: string, exit_code?: int|null}|null - */ - private static $shell_diagnostic_cache = null; - /** * Is Data Machine Code available on this install? * @@ -85,33 +84,10 @@ public static function has_shell(): bool { * * @since 0.24.0 * - * @return array{ok: bool, reason: string, output?: string, exit_code?: int|null} + * @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 shell_diagnostic(): array { - if ( null !== self::$shell_diagnostic_cache ) { - return self::$shell_diagnostic_cache; - } - - self::$shell_diagnostic_cache = self::evaluate_shell_capability( - static function ( string $function_name ): bool { - return 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 -- Environment capability probe. - exec($command, $output, $exit_code); - - return array( - 'output' => $output, - 'exit_code' => $exit_code, - ); - } - ); - - return self::$shell_diagnostic_cache; + return RuntimeCapabilities::shell_diagnostic(); } /** @@ -122,54 +98,10 @@ static function ( string $command ): array { * @param callable $function_exists Callback receiving a function name and returning availability. * @param string $disabled_functions Raw `disable_functions` value. * @param callable $command_runner Callback receiving command and returning output plus exit code. - * @return array{ok: bool, reason: string, output?: string, exit_code?: int|null} + * @return array{ok: bool, reason: string, exec_available: bool, shell_exec_available: bool, proc_open_available: bool, output?: string, exit_code?: int|null} */ private static function evaluate_shell_capability( callable $function_exists, string $disabled_functions, callable $command_runner ): array { - $required_functions = array( 'exec', 'shell_exec' ); - $disabled = array_filter(array_map('trim', explode(',', $disabled_functions))); - - foreach ( $required_functions as $function_name ) { - if ( ! $function_exists($function_name) ) { - return array( - 'ok' => false, - 'reason' => $function_name . '_missing', - ); - } - - if ( in_array($function_name, $disabled, true) ) { - return array( - 'ok' => false, - 'reason' => $function_name . '_disabled', - ); - } - } - - $marker = '__datamachine_code_shell_ok__'; - - /** - * @var array{output: array, exit_code: int|null} $result -*/ - $result = $command_runner('printf ' . escapeshellarg($marker) . ' 2>&1'); - - $output = $result['output']; - $exit_code = $result['exit_code']; - - $actual_output = trim(implode("\n", array_map('strval', $output))); - if ( 0 !== $exit_code || $marker !== $actual_output ) { - return array( - 'ok' => false, - 'reason' => 'probe_failed', - 'output' => $actual_output, - 'exit_code' => $exit_code, - ); - } - - return array( - 'ok' => true, - 'reason' => 'ok', - 'output' => $actual_output, - 'exit_code' => $exit_code, - ); + return RuntimeCapabilities::evaluate_shell_capability($function_exists, $disabled_functions, $command_runner); } /** diff --git a/inc/Support/GitRunner.php b/inc/Support/GitRunner.php index f0105eb7..3027163b 100644 --- a/inc/Support/GitRunner.php +++ b/inc/Support/GitRunner.php @@ -21,50 +21,21 @@ if ( ! class_exists(ProcessRunner::class) ) { require_once __DIR__ . '/ProcessRunner.php'; } +if ( ! class_exists(RuntimeCapabilities::class) ) { + require_once __DIR__ . '/RuntimeCapabilities.php'; +} final class GitRunner { - /** - * Cached runtime capability probe. - * - * @var array|null - */ - private static ?array $diagnostic = null; - /** * Inspect whether the current PHP runtime can execute local git commands. * * @return array */ public static function diagnose(): array { - if ( null !== self::$diagnostic ) { - return self::$diagnostic; - } - - $exec_available = function_exists('exec'); - $proc_open_available = function_exists('proc_open'); - $output = array(); - $exit_code = 127; - $git_path = ''; - - if ( $exec_available ) { - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec('command -v git 2>/dev/null', $output, $exit_code); - $git_path = trim( (string) ( $output[0] ?? '' )); - } - - self::$diagnostic = array( - 'backend' => 'local_git', - 'exec_available' => $exec_available, - 'proc_open_available' => $proc_open_available, - 'git_available' => 0 === $exit_code && '' !== $git_path, - 'git_path' => $git_path, - 'probe_exit_code' => $exit_code, - ); - - return self::$diagnostic; + return RuntimeCapabilities::git_diagnostic(); } /** @@ -92,7 +63,7 @@ public static function supports_streaming(): bool { */ public static function unavailable_error( string $operation = 'Git workspace operation', bool $requires_streaming = false ): \WP_Error { $diagnostic = self::diagnose(); - $reason = empty($diagnostic['exec_available']) + $reason = empty($diagnostic['exec_available']) ? 'PHP exec() is unavailable.' : 'The git binary is unavailable to the PHP runtime.'; @@ -100,7 +71,7 @@ public static function unavailable_error( string $operation = 'Git workspace ope $reason = 'PHP proc_open() is unavailable for streaming git operations.'; } - $remediation = 'Run workspace abilities in a host runtime with local git access, or provide a Data Machine Code workspace backend that executes these operations outside the constrained PHP sandbox.'; + $remediation = RuntimeCapabilities::workspace_remediation(); return new \WP_Error( 'datamachine_workspace_git_unavailable', diff --git a/inc/Support/ProcessRunner.php b/inc/Support/ProcessRunner.php index 46a1b6e7..e1cc2fec 100644 --- a/inc/Support/ProcessRunner.php +++ b/inc/Support/ProcessRunner.php @@ -13,6 +13,10 @@ defined('ABSPATH') || exit; +if ( ! class_exists(RuntimeCapabilities::class) ) { + require_once __DIR__ . '/RuntimeCapabilities.php'; +} + final class ProcessRunner { @@ -35,13 +39,12 @@ public static function run( string $command, array $options = array() ): array|\ return self::run_via_exec($command, $options, $output_cap); } - if ( ! function_exists('proc_open') ) { + $shell = RuntimeCapabilities::shell_diagnostic(); + if ( empty($shell['proc_open_available']) ) { return self::error( $options, 0 === $timeout_seconds ? 'Process command failed to start.' : sprintf('Process command timed out after %d second(s).', $timeout_seconds), - array( - 'proc_open_available' => false, - ) + $shell ); } @@ -53,8 +56,9 @@ public static function run( string $command, array $options = array() ): array|\ * @return array{success: bool, output: string, exit_code: int}|\WP_Error */ private static function run_via_exec( string $command, array $options, int $output_cap ): array|\WP_Error { - if ( ! function_exists('exec') ) { - return self::error($options, 'Process command failed to start.', array( 'exec_available' => false )); + $shell = RuntimeCapabilities::shell_diagnostic(); + if ( empty($shell['exec_available']) ) { + return self::error($options, 'Process command failed to start.', $shell); } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec diff --git a/inc/Support/RuntimeCapabilities.php b/inc/Support/RuntimeCapabilities.php new file mode 100644 index 00000000..3677c8f2 --- /dev/null +++ b/inc/Support/RuntimeCapabilities.php @@ -0,0 +1,208 @@ +|null + */ + private static ?array $shell_diagnostic = null; + + /** + * @var array> + */ + private static array $binary_diagnostics = array(); + + /** + * Return the shell capability diagnostic for this request. + * + * @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 shell_diagnostic(): array { + if ( null !== self::$shell_diagnostic ) { + return self::$shell_diagnostic; + } + + self::$shell_diagnostic = self::evaluate_shell_capability( + static function ( string $function_name ): bool { + return 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 -- Runtime capability probe. + exec($command, $output, $exit_code); + + return array( + 'output' => $output, + 'exit_code' => $exit_code, + ); + } + ); + + return self::$shell_diagnostic; + } + + /** + * Evaluate shell capability with injectable probes for tests. + * + * @param callable $function_exists Callback receiving a function name and returning availability. + * @param string $disabled_functions Raw `disable_functions` value. + * @param callable $command_runner Callback receiving command and returning output plus exit code. + * @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_shell_capability( callable $function_exists, string $disabled_functions, callable $command_runner ): array { + $disabled = array_filter(array_map('trim', explode(',', $disabled_functions))); + $exec_available = self::function_available('exec', $function_exists, $disabled); + $shell_exec_available = self::function_available('shell_exec', $function_exists, $disabled); + $proc_open_available = self::function_available('proc_open', $function_exists, $disabled); + + $base = array( + 'exec_available' => $exec_available, + 'shell_exec_available' => $shell_exec_available, + 'proc_open_available' => $proc_open_available, + ); + + if ( ! $exec_available ) { + $reason = $function_exists('exec') ? 'exec_disabled' : 'exec_missing'; + return array_merge($base, array( 'ok' => false, 'reason' => $reason )); + } + + if ( ! $shell_exec_available ) { + $reason = $function_exists('shell_exec') ? 'shell_exec_disabled' : 'shell_exec_missing'; + return array_merge($base, array( 'ok' => false, 'reason' => $reason )); + } + + $marker = '__datamachine_code_shell_ok__'; + + /** @var array{output: array, exit_code: int|null} $result */ + $result = $command_runner('printf ' . escapeshellarg($marker) . ' 2>&1'); + + $output = $result['output']; + $exit_code = $result['exit_code']; + $actual_output = trim(implode("\n", array_map('strval', $output))); + if ( 0 !== $exit_code || $marker !== $actual_output ) { + return array_merge( + $base, + array( + 'ok' => false, + 'reason' => 'probe_failed', + 'output' => $actual_output, + 'exit_code' => $exit_code, + ) + ); + } + + return array_merge( + $base, + array( + 'ok' => true, + 'reason' => 'ok', + 'output' => $actual_output, + 'exit_code' => $exit_code, + ) + ); + } + + /** + * Whether a named binary is available on PATH. + */ + public static function binary_available( string $binary, ?string $path = null ): bool { + $diagnostic = self::binary_diagnostic($binary, $path); + return ! empty($diagnostic['available']); + } + + /** + * Return a binary lookup diagnostic. + * + * @return array{binary: string, available: bool, path: string, exec_available: bool, exit_code: int|null} + */ + public static function binary_diagnostic( string $binary, ?string $path = null ): array { + $cache_key = $binary . "\0" . (string) $path; + if ( isset(self::$binary_diagnostics[ $cache_key ]) ) { + return self::$binary_diagnostics[ $cache_key ]; + } + + $diagnostic = array( + 'binary' => $binary, + 'available' => false, + 'path' => '', + 'exec_available' => self::shell_diagnostic()['exec_available'], + 'exit_code' => null, + ); + + if ( '' === $binary || ! preg_match('/^[a-zA-Z0-9_.\-]+$/', $binary) || empty($diagnostic['exec_available']) ) { + self::$binary_diagnostics[ $cache_key ] = $diagnostic; + return $diagnostic; + } + + $prefix = null !== $path && '' !== $path ? sprintf('PATH=%s ', escapeshellarg($path)) : ''; + $output = array(); + $exit = null; + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Runtime capability probe. + exec(sprintf('%scommand -v %s 2>/dev/null', $prefix, escapeshellarg($binary)), $output, $exit); + + $binary_path = trim((string) ( $output[0] ?? '' )); + $diagnostic = array( + 'binary' => $binary, + 'available' => 0 === $exit && '' !== $binary_path, + 'path' => $binary_path, + 'exec_available' => true, + 'exit_code' => $exit, + ); + + self::$binary_diagnostics[ $cache_key ] = $diagnostic; + return $diagnostic; + } + + /** + * Return the local git diagnostic expected by workspace callers. + * + * @return array + */ + public static function git_diagnostic(): array { + $shell = self::shell_diagnostic(); + $git = self::binary_diagnostic('git'); + + return array( + 'backend' => 'local_git', + 'exec_available' => (bool) $shell['exec_available'], + 'shell_exec_available' => (bool) $shell['shell_exec_available'], + 'proc_open_available' => (bool) $shell['proc_open_available'], + 'git_available' => (bool) $git['available'], + 'git_path' => (string) $git['path'], + 'probe_exit_code' => $git['exit_code'], + 'shell_reason' => (string) $shell['reason'], + ); + } + + /** + * Standard remediation for shell-backed workspace operations. + */ + public static function workspace_remediation(): string { + return 'Run workspace abilities in a host runtime with local git access, or provide a Data Machine Code workspace backend that executes these operations outside the constrained PHP sandbox.'; + } + + /** + * @param string[] $disabled + */ + private static function function_available( string $function_name, callable $function_exists, array $disabled ): bool { + return $function_exists($function_name) && ! in_array($function_name, $disabled, true); + } +} diff --git a/inc/Workspace/WorktreeBootstrapper.php b/inc/Workspace/WorktreeBootstrapper.php index b77b60b2..6376c266 100644 --- a/inc/Workspace/WorktreeBootstrapper.php +++ b/inc/Workspace/WorktreeBootstrapper.php @@ -44,12 +44,16 @@ namespace DataMachineCode\Workspace; use DataMachineCode\Support\ProcessRunner; +use DataMachineCode\Support\RuntimeCapabilities; defined('ABSPATH') || exit; if ( ! class_exists(ProcessRunner::class) ) { require_once dirname(__DIR__) . '/Support/ProcessRunner.php'; } +if ( ! class_exists(RuntimeCapabilities::class) ) { + require_once dirname(__DIR__) . '/Support/RuntimeCapabilities.php'; +} final class WorktreeBootstrapper { @@ -419,13 +423,7 @@ private static function detect_package_manager( string $worktree_path ): ?string * bash/zsh/dash — `which` is not POSIX. */ private static function binary_available( string $binary ): bool { - if ( '' === $binary || ! preg_match('/^[a-zA-Z0-9_.\-]+$/', $binary) ) { - return false; - } - $env = self::shell_env_prefix(); - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec(sprintf('%scommand -v %s 2>/dev/null', $env, escapeshellarg($binary)), $_unused, $exit); - return 0 === $exit; + return RuntimeCapabilities::binary_available($binary, self::augmented_path()); } /** From 6cae98311465b7d4a828ef0b1f93fb1edd6e6db7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 6 Jun 2026 13:44:55 -0400 Subject: [PATCH 2/2] fix: align runtime capability lint --- inc/Support/GitRunner.php | 2 +- inc/Support/RuntimeCapabilities.php | 24 ++++++++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/inc/Support/GitRunner.php b/inc/Support/GitRunner.php index 3027163b..4ba4ec7f 100644 --- a/inc/Support/GitRunner.php +++ b/inc/Support/GitRunner.php @@ -63,7 +63,7 @@ public static function supports_streaming(): bool { */ public static function unavailable_error( string $operation = 'Git workspace operation', bool $requires_streaming = false ): \WP_Error { $diagnostic = self::diagnose(); - $reason = empty($diagnostic['exec_available']) + $reason = empty($diagnostic['exec_available']) ? 'PHP exec() is unavailable.' : 'The git binary is unavailable to the PHP runtime.'; diff --git a/inc/Support/RuntimeCapabilities.php b/inc/Support/RuntimeCapabilities.php index 3677c8f2..3c3e6aad 100644 --- a/inc/Support/RuntimeCapabilities.php +++ b/inc/Support/RuntimeCapabilities.php @@ -68,10 +68,10 @@ static function ( string $command ): array { * @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_shell_capability( callable $function_exists, string $disabled_functions, callable $command_runner ): array { - $disabled = array_filter(array_map('trim', explode(',', $disabled_functions))); - $exec_available = self::function_available('exec', $function_exists, $disabled); + $disabled = array_filter(array_map('trim', explode(',', $disabled_functions))); + $exec_available = self::function_available('exec', $function_exists, $disabled); $shell_exec_available = self::function_available('shell_exec', $function_exists, $disabled); - $proc_open_available = self::function_available('proc_open', $function_exists, $disabled); + $proc_open_available = self::function_available('proc_open', $function_exists, $disabled); $base = array( 'exec_available' => $exec_available, @@ -81,12 +81,24 @@ public static function evaluate_shell_capability( callable $function_exists, str if ( ! $exec_available ) { $reason = $function_exists('exec') ? 'exec_disabled' : 'exec_missing'; - return array_merge($base, array( 'ok' => false, 'reason' => $reason )); + return array_merge( + $base, + array( + 'ok' => false, + 'reason' => $reason, + ) + ); } if ( ! $shell_exec_available ) { $reason = $function_exists('shell_exec') ? 'shell_exec_disabled' : 'shell_exec_missing'; - return array_merge($base, array( 'ok' => false, 'reason' => $reason )); + return array_merge( + $base, + array( + 'ok' => false, + 'reason' => $reason, + ) + ); } $marker = '__datamachine_code_shell_ok__'; @@ -158,7 +170,7 @@ public static function binary_diagnostic( string $binary, ?string $path = null ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Runtime capability probe. exec(sprintf('%scommand -v %s 2>/dev/null', $prefix, escapeshellarg($binary)), $output, $exit); - $binary_path = trim((string) ( $output[0] ?? '' )); + $binary_path = trim( (string) ( $output[0] ?? '' ) ); $diagnostic = array( 'binary' => $binary, 'available' => 0 === $exit && '' !== $binary_path,