From 75e1bb632656fb0c718749b078d8773220eafa94 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:29:19 +0000 Subject: [PATCH 1/2] fix(pending-actions): let operators resolve system-staged actions via --operator-wide (#3668) accept/reject now forward --operator-wide, and execute() carries it across the Agents API hop as context.operator_wide. Still gated by can_manage(). CLI resolver is user: so grantless kinds (event submissions) resolve. Co-Authored-By: Extra Chill Bot (AI) --- inc/Cli/Commands/PendingActionsCommand.php | 40 ++++++++-- .../Actions/ResolvePendingActionAbility.php | 17 ++++- ...pending-action-resolver-contract-smoke.php | 75 +++++++++++++++++++ 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/inc/Cli/Commands/PendingActionsCommand.php b/inc/Cli/Commands/PendingActionsCommand.php index a46909f05..b1f805654 100644 --- a/inc/Cli/Commands/PendingActionsCommand.php +++ b/inc/Cli/Commands/PendingActionsCommand.php @@ -19,12 +19,38 @@ */ class PendingActionsCommand extends BaseCommand { - /** Resolve a pending action through the canonical resolver. */ + /** + * Accept a pending action through the canonical resolver. + * + * ## OPTIONS + * + * + * : Pending action ID. + * + * [--operator-wide] + * : Resolve outside the current caller scope (requires manage permissions). + * + * [--format=] + * : Output format. Use json to include the resolver result. + */ public function accept( array $args, array $assoc_args ): void { $this->resolve( $args, $assoc_args, 'accepted' ); } - /** Resolve a pending action through the canonical resolver. */ + /** + * Reject a pending action through the canonical resolver. + * + * ## OPTIONS + * + * + * : Pending action ID. + * + * [--operator-wide] + * : Resolve outside the current caller scope (requires manage permissions). + * + * [--format=] + * : Output format. Use json to include the resolver result. + */ public function reject( array $args, array $assoc_args ): void { $this->resolve( $args, $assoc_args, 'rejected' ); } @@ -35,10 +61,12 @@ private function resolve( array $args, array $assoc_args, string $decision ): vo WP_CLI::error( 'action_id is required.' ); } $result = \DataMachine\Engine\AI\Actions\ResolvePendingActionAbility::execute( array( - 'action_id' => $action_id, - 'decision' => $decision, - 'resolver' => 'cli:' . get_current_user_id(), - 'context' => array( 'resolution_transport' => 'cli' ), + 'action_id' => $action_id, + 'decision' => $decision, + // WP-CLI --user is a human resolver; retain CLI transport for auditing. + 'resolver' => 'user:' . get_current_user_id(), + 'context' => array( 'resolution_transport' => 'cli' ), + 'operator_wide' => ! empty( $assoc_args['operator-wide'] ), ) ); if ( is_wp_error( $result ) ) { $result = \DataMachine\Engine\AI\Actions\ResolvePendingActionAbility::error_result( $result ); diff --git a/inc/Engine/AI/Actions/ResolvePendingActionAbility.php b/inc/Engine/AI/Actions/ResolvePendingActionAbility.php index 9f8d92182..ac8420bca 100644 --- a/inc/Engine/AI/Actions/ResolvePendingActionAbility.php +++ b/inc/Engine/AI/Actions/ResolvePendingActionAbility.php @@ -138,7 +138,7 @@ public static function handle_rest( \WP_REST_Request $request ) { /** * Resolve through the canonical Agents API ability. * - * @param array $input { action_id, decision }. + * @param array $input { action_id, decision, operator_wide?, context? }. * @return array|\WP_Error */ public static function execute( array $input ): array|\WP_Error { @@ -150,13 +150,19 @@ public static function execute( array $input ): array|\WP_Error { return new \WP_Error( 'pending_action_input_required', 'action_id, decision, and resolver are required.', array( 'status' => 400 ) ); } + $context = isset( $input['context'] ) && is_array( $input['context'] ) ? $input['context'] : array(); + if ( ! empty( $input['operator_wide'] ) ) { + // The Agents API resolver forwards context, not arbitrary top-level input. + $context['operator_wide'] = true; + } + $result = \AgentsAPI\AI\Approvals\agents_resolve_pending_action( array( 'action_id' => $action_id, 'decision' => $decision_value, 'resolver' => $resolver, 'payload' => isset( $input['payload'] ) && is_array( $input['payload'] ) ? $input['payload'] : array(), - 'context' => isset( $input['context'] ) && is_array( $input['context'] ) ? $input['context'] : array(), + 'context' => $context, ) ); @@ -257,7 +263,12 @@ public static function resolve_with_datamachine_handlers( array $input ): array ); } - if ( ! PendingActionScope::can_access_payload( $payload, $input ) ) { + $scope_input = $input; + if ( ! empty( $input['context']['operator_wide'] ) ) { + $scope_input['operator_wide'] = true; + } + // Operator intent never grants permission by itself; scope checks can_manage(). + if ( ! PendingActionScope::can_access_payload( $payload, $scope_input ) ) { return array( 'success' => false, 'error' => 'You do not have permission to resolve this pending action.', diff --git a/tests/pending-action-resolver-contract-smoke.php b/tests/pending-action-resolver-contract-smoke.php index 812e3072b..ffef5b873 100644 --- a/tests/pending-action-resolver-contract-smoke.php +++ b/tests/pending-action-resolver-contract-smoke.php @@ -745,6 +745,81 @@ static function ( array $handlers ) use ( &$origin_handler_blogs ) { resolver_smoke_assert( array( 7 ) === $origin_handler_blogs, 'forged origin does not execute the handler', $failures, $passes ); resolver_smoke_assert( 2 === get_current_blog_id(), 'forged origin denial also restores the caller blog', $failures, $passes ); +// Regression #3668: exercise CLI and execute() through the real Agents API hop. +if ( ! function_exists( 'is_user_logged_in' ) ) { + function is_user_logged_in() { return get_current_user_id() > 0; } +} +if ( ! function_exists( 'user_can' ) ) { + function user_can( $user_id, $capability ) { + return ! empty( $GLOBALS['__resolver_manage'] ) && 'manage_options' === $capability; + } +} +if ( ! class_exists( 'WP_CLI_Command' ) ) { + class WP_CLI_Command {} +} +if ( ! class_exists( 'WP_CLI' ) ) { + class WP_CLI { + public static array $lines = array(); + public static function error( $message ) { throw new RuntimeException( $message ); } + public static function success( $message ) { unset( $message ); } + public static function line( $message ) { self::$lines[] = $message; } + } +} +require_once dirname( __DIR__ ) . '/inc/Cli/BaseCommand.php'; +require_once dirname( __DIR__ ) . '/inc/Cli/Commands/PendingActionsCommand.php'; + +$command = new \DataMachine\Cli\Commands\PendingActionsCommand(); +foreach ( array( 'ability', 'cli' ) as $transport ) { + foreach ( array( 'accepted', 'rejected' ) as $decision ) { + foreach ( array( 'manager_wide', 'manager_scoped', 'nonmanager_wide', 'nonmanager_context_wide', 'manager_context_wide' ) as $scenario ) { + $GLOBALS['__resolver_manage'] = str_starts_with( $scenario, 'manager_' ); + $wide = str_ends_with( $scenario, '_wide' ); + $context_wide = str_contains( $scenario, '_context_' ); + if ( 'cli' === $transport && $context_wide ) { + continue; + } + $expected = $GLOBALS['__resolver_manage'] && $wide; + $id = 'act_system_' . $transport . '_' . $decision . '_' . $scenario; + PendingActionStore::store( $id, array( + 'kind' => 'contract_kind', + 'summary' => 'System-created action regression #3668.', + 'created_by' => 0, + 'creator' => null, + 'agent' => null, + 'apply_input' => array( 'target' => 'system-action' ), + ) ); + $calls_before = count( $handler_calls ); + if ( 'ability' === $transport ) { + $input = array( 'action_id' => $id, 'decision' => $decision ); + if ( $context_wide ) { + $input['context'] = array( 'operator_wide' => $wide ); + } else { + $input['operator_wide'] = $wide; + } + $result = ResolvePendingActionAbility::execute( $input ); + $success = ! is_wp_error( $result ) && ! empty( $result['success'] ); + } else { + $success = true; + try { + $method = 'accepted' === $decision ? 'accept' : 'reject'; + $command->$method( array( $id ), array( 'operator-wide' => $wide, 'format' => 'json' ) ); + } catch ( RuntimeException $error ) { + $success = false; + } + if ( $success ) { + $json = json_decode( end( WP_CLI::$lines ), true ); + resolver_smoke_assert( true === ( $json['success'] ?? false ) && $decision === ( $json['decision'] ?? null ), 'CLI JSON reports successful ' . $decision, $failures, $passes ); + } + } + resolver_smoke_assert( $expected === $success, "$transport $decision $scenario enforces explicit manager-only system-action resolution", $failures, $passes ); + if ( ! $expected ) { + resolver_smoke_assert( null !== PendingActionStore::get( $id ) && $calls_before === count( $handler_calls ), 'refused resolution leaves action pending and never calls handler', $failures, $passes ); + } + } + } +} +$GLOBALS['__resolver_manage'] = false; + if ( ! empty( $failures ) ) { echo "\nFailures:\n"; foreach ( $failures as $failure ) { From bb528cb2302d09b2ca22da1034ab93b9b1a1cc73 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:29:20 +0000 Subject: [PATCH 2/2] test: repair stale smoke-test stubs Co-Authored-By: Extra Chill Bot (AI) --- ...ion-scheduler-group-registration-smoke.php | 3 ++- tests/agent-bundle-upgrade-planner-smoke.php | 24 +++++++++++++++++++ .../ai-completion-assertion-packet-smoke.php | 4 ++++ tests/ai-error-status-propagation-smoke.php | 6 +++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/action-scheduler-group-registration-smoke.php b/tests/action-scheduler-group-registration-smoke.php index 1afa9939c..6e0a33b07 100644 --- a/tests/action-scheduler-group-registration-smoke.php +++ b/tests/action-scheduler-group-registration-smoke.php @@ -83,7 +83,8 @@ function wp_insert_term( string $term, string $taxonomy, array $args = array() ) $plugin_src = file_get_contents( $root . '/data-machine.php' ) ?: ''; $provider_src = file_get_contents( $root . '/inc/Core/Bootstrap/AlwaysOnServiceProvider.php' ) ?: ''; $group_src = file_get_contents( $root . '/inc/Core/ActionScheduler/GroupRegistrar.php' ) ?: ''; -$drain_src = file_get_contents( $root . '/inc/Cli/Commands/DrainCommand.php' ) ?: ''; +// Claiming moved from the CLI command into the shared drain service. +$drain_src = file_get_contents( $root . '/inc/Core/ActionScheduler/ScopedDrainService.php' ) ?: ''; require_once $root . '/inc/Core/ActionScheduler/GroupRegistrar.php'; $assertions = 0; diff --git a/tests/agent-bundle-upgrade-planner-smoke.php b/tests/agent-bundle-upgrade-planner-smoke.php index 7f93b0de6..6cc4cd1a5 100644 --- a/tests/agent-bundle-upgrade-planner-smoke.php +++ b/tests/agent-bundle-upgrade-planner-smoke.php @@ -20,6 +20,30 @@ $GLOBALS['__bundle_upgrade_filters'] = array(); $GLOBALS['__bundle_upgrade_transients'] = array(); $GLOBALS['__bundle_upgrade_actions'] = array(); +$GLOBALS['__bundle_upgrade_options'] = array(); + +// Resolution fences use atomic option insertion, even with transient storage. +if ( ! function_exists( 'add_option' ) ) { + function add_option( $key, $value, $deprecated = '', $autoload = true ) { + if ( array_key_exists( $key, $GLOBALS['__bundle_upgrade_options'] ) ) { + return false; + } + $GLOBALS['__bundle_upgrade_options'][ $key ] = $value; + return true; + } +} +if ( ! function_exists( 'get_option' ) ) { + function get_option( $key, $default = false ) { + return $GLOBALS['__bundle_upgrade_options'][ $key ] ?? $default; + } +} +if ( ! function_exists( 'delete_option' ) ) { + function delete_option( $key ) { + $exists = array_key_exists( $key, $GLOBALS['__bundle_upgrade_options'] ); + unset( $GLOBALS['__bundle_upgrade_options'][ $key ] ); + return $exists; + } +} if ( ! function_exists( 'wp_json_encode' ) ) { function wp_json_encode( $data, $options = 0, $depth = 512 ) { diff --git a/tests/ai-completion-assertion-packet-smoke.php b/tests/ai-completion-assertion-packet-smoke.php index fd9492c2a..cc7690bf0 100644 --- a/tests/ai-completion-assertion-packet-smoke.php +++ b/tests/ai-completion-assertion-packet-smoke.php @@ -11,6 +11,10 @@ define( 'ABSPATH', __DIR__ . '/' ); } +if ( ! defined( 'DAY_IN_SECONDS' ) ) { + define( 'DAY_IN_SECONDS', 86400 ); +} + $__filters = array(); if ( ! function_exists( 'add_filter' ) ) { diff --git a/tests/ai-error-status-propagation-smoke.php b/tests/ai-error-status-propagation-smoke.php index 7dbb86305..5f8c1c4da 100644 --- a/tests/ai-error-status-propagation-smoke.php +++ b/tests/ai-error-status-propagation-smoke.php @@ -11,6 +11,12 @@ define( 'ABSPATH', __DIR__ . '/' ); } +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook, $value, ...$args ) { + return $value; + } +} + function datamachine_empty_action_log(): array { return getenv( 'DATAMACHINE_SMOKE_ACTION_SEED' ) ? array( array( 'hook' => '', 'args' => array() ) ) : array(); }