Skip to content
Merged
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
40 changes: 34 additions & 6 deletions inc/Cli/Commands/PendingActionsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
* <action_id>
* : Pending action ID.
*
* [--operator-wide]
* : Resolve outside the current caller scope (requires manage permissions).
*
* [--format=<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
*
* <action_id>
* : Pending action ID.
*
* [--operator-wide]
* : Resolve outside the current caller scope (requires manage permissions).
*
* [--format=<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' );
}
Expand All @@ -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 );
Expand Down
17 changes: 14 additions & 3 deletions inc/Engine/AI/Actions/ResolvePendingActionAbility.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
)
);

Expand Down Expand Up @@ -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.',
Expand Down
3 changes: 2 additions & 1 deletion tests/action-scheduler-group-registration-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions tests/agent-bundle-upgrade-planner-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
4 changes: 4 additions & 0 deletions tests/ai-completion-assertion-packet-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
define( 'ABSPATH', __DIR__ . '/' );
}

if ( ! defined( 'DAY_IN_SECONDS' ) ) {
define( 'DAY_IN_SECONDS', 86400 );
}

$__filters = array();

if ( ! function_exists( 'add_filter' ) ) {
Expand Down
6 changes: 6 additions & 0 deletions tests/ai-error-status-propagation-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
75 changes: 75 additions & 0 deletions tests/pending-action-resolver-contract-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
Loading