diff --git a/src/Channels/register-agents-chat-ability.php b/src/Channels/register-agents-chat-ability.php index f675d54..b8e881c 100644 --- a/src/Channels/register-agents-chat-ability.php +++ b/src/Channels/register-agents-chat-ability.php @@ -178,10 +178,14 @@ function agents_chat_dispatch( array $input ) { $agent = agents_chat_optional_string( $input['agent'] ?? null ) ?? ''; if ( null !== $session_id ) { try { - WP_Agent_Chat_Run_Control::start_run( $run_id, $session_id, array( 'agent' => $agent ), $run_context['workspace'], $run_context['owner'] ); + $started = WP_Agent_Chat_Run_Control::start_run( $run_id, $session_id, array( 'agent' => $agent ), $run_context['workspace'], $run_context['owner'], $run_context['conversation_store'] ); } catch ( \RuntimeException $error ) { return new \WP_Error( 'agents_chat_run_workspace_unsupported', $error->getMessage() ); } + if ( is_wp_error( $started ) ) { + do_action( 'agents_chat_dispatch_failed', $started->get_error_code(), $input ); + return $started; + } } $result = call_user_func( $handler, $input ); @@ -220,7 +224,11 @@ function agents_chat_dispatch( array $input ) { $resolved_session_id = agents_chat_optional_string( $result['session_id'] ?? null ) ?? $session_id; if ( null !== $resolved_session_id ) { if ( null === $session_id ) { - WP_Agent_Chat_Run_Control::start_run( $result_run_id, $resolved_session_id, array( 'agent' => $agent ), $run_context['workspace'], $run_context['owner'] ); + $started = WP_Agent_Chat_Run_Control::start_run( $result_run_id, $resolved_session_id, array( 'agent' => $agent ), $run_context['workspace'], $run_context['owner'], $run_context['conversation_store'] ); + if ( is_wp_error( $started ) ) { + do_action( 'agents_chat_dispatch_failed', $started->get_error_code(), $input ); + return $started; + } } $status = WP_Agent_Run_Outcome::run_control_status( $result ); diff --git a/src/Channels/register-agents-chat-run-control-abilities.php b/src/Channels/register-agents-chat-run-control-abilities.php index 484580f..3335512 100644 --- a/src/Channels/register-agents-chat-run-control-abilities.php +++ b/src/Channels/register-agents-chat-run-control-abilities.php @@ -232,13 +232,23 @@ function agents_queue_chat_message( array $input ) { if ( $context['workspace'] instanceof WP_Agent_Workspace_Scope ) { $input['workspace'] = $context['workspace']->to_array(); } + try { + if ( ! WP_Agent_Chat_Run_Control::can_queue_message( $input, $context['workspace'], $context['owner'], $context['conversation_store'] ) ) { + return agents_chat_run_control_no_handler( 'agents_chat_run_not_found', 'No chat run was found for the requested session owner.' ); + } + } catch ( \RuntimeException $error ) { + return new \WP_Error( 'agents_chat_run_workspace_unsupported', $error->getMessage() ); + } + if ( is_array( $context['owner'] ) ) { + $input['session_owner'] = $context['owner']; + } $handler = apply_filters( 'wp_agent_chat_message_queue_handler', null, $input ); if ( is_callable( $handler ) ) { $result = agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_message_queue_invalid_result' ); } else { try { - $result = WP_Agent_Chat_Run_Control::queue_message( $input, $context['workspace'], $context['owner'] ); + $result = WP_Agent_Chat_Run_Control::queue_message( $input, $context['workspace'], $context['owner'], $context['conversation_store'] ); } catch ( \InvalidArgumentException|\RuntimeException $error ) { return new \WP_Error( 'agents_chat_message_queue_invalid_result', $error->getMessage() ); } @@ -280,6 +290,17 @@ function agents_chat_run_read_permission( array $input ): bool { /** @param array $input Ability input. */ function agents_chat_run_enqueue_permission( array $input ): bool { $allowed = agents_chat_run_write_permission( $input ); + $context = WP_Agent_Chat_Run_Control::context_from_input( $input ); + if ( $allowed && ! is_wp_error( $context ) ) { + try { + $allowed = WP_Agent_Chat_Run_Control::can_queue_message( $input, $context['workspace'], $context['owner'], $context['conversation_store'] ); + } catch ( \RuntimeException $error ) { + unset( $error ); + $allowed = false; + } + } else { + $allowed = false; + } $allowed = (bool) apply_filters( 'agents_chat_run_enqueue_permission', $allowed, $input ); return (bool) apply_filters( 'agents_chat_run_control_permission', $allowed, $input ); } diff --git a/src/Channels/register-default-agents-chat-handler.php b/src/Channels/register-default-agents-chat-handler.php index 3930b91..a17631d 100644 --- a/src/Channels/register-default-agents-chat-handler.php +++ b/src/Channels/register-default-agents-chat-handler.php @@ -184,6 +184,7 @@ public static function execute( array $input ) { 'agent_slug' => $agent_slug, 'run_id' => $runtime_context['run_id'], 'principal' => $input['principal'] ?? null, + 'conversation_store' => $store, 'tool_executor_registry' => $executor_registry, ), ); diff --git a/src/Runtime/class-wp-agent-chat-run-control.php b/src/Runtime/class-wp-agent-chat-run-control.php index 6f02a2e..5253420 100644 --- a/src/Runtime/class-wp-agent-chat-run-control.php +++ b/src/Runtime/class-wp-agent-chat-run-control.php @@ -7,6 +7,8 @@ namespace AgentsAPI\AI; +use AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Sessions; +use AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Store; use AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope; defined( 'ABSPATH' ) || exit; @@ -47,10 +49,10 @@ public static function statuses(): array { } /** - * Resolve and bind canonical workspace and principal ownership. + * Resolve canonical workspace, principal, and conversation store context. * * @param array $input Ability input. - * @return array{workspace:?WP_Agent_Workspace_Scope,owner:?array{type:string,key:string}}|\WP_Error + * @return array{workspace:?WP_Agent_Workspace_Scope,owner:?array{type:string,key:string},conversation_store:?WP_Agent_Conversation_Store}|\WP_Error */ public static function context_from_input( array $input ) { $workspace = null; @@ -112,8 +114,9 @@ public static function context_from_input( array $input ) { } return array( - 'workspace' => $workspace, - 'owner' => $owner, + 'workspace' => $workspace, + 'owner' => $owner, + 'conversation_store' => WP_Agent_Conversation_Sessions::get_store( WP_Agent_Run_Control::string_keyed_array( $input ) ), ); } @@ -182,16 +185,27 @@ public static function normalize_status( mixed $status ): string { * @param string $session_id Session ID. * @param array $metadata Run metadata. * @param array|null $owner Canonical conversation owner. - * @return array Normalized run. + * @return array|\WP_Error Normalized run. */ - public static function start_run( string $run_id, string $session_id, array $metadata = array(), ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null ): array { + public static function start_run( string $run_id, string $session_id, array $metadata = array(), ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null, ?WP_Agent_Conversation_Store $conversation_store = null ) { + $canonical_owner = self::session_owner_fingerprint( $session_id, $workspace, $owner, $conversation_store ); + if ( $canonical_owner instanceof \WP_Error ) { + return $canonical_owner; + } + + $state = self::state( $workspace ); + $existing = $state['runs'][ $run_id ] ?? null; + if ( is_array( $existing ) && ( $session_id !== self::string_value( $existing['session_id'] ?? '' ) || ! self::fingerprint_matches( $existing['_owner'] ?? null, $canonical_owner ) ) ) { + return new \WP_Error( 'agents_chat_run_owner_forbidden', 'The run_id is already bound to another session or owner.' ); + } + return self::normalize_run( WP_Agent_Run_Control::start_run( self::OPTION_KEY, $run_id, array( 'session_id' => $session_id, 'metadata' => $metadata, - '_owner' => self::owner_fingerprint( $owner ), + '_owner' => $canonical_owner, ), $workspace ) ); @@ -265,15 +279,20 @@ public static function cancellation_interrupt_for_run( string $run_id, string $s * * @param array $input Canonical queue input. * @param array|null $owner Canonical conversation owner. - * @return array Queue result. + * @return array|\WP_Error Queue result. */ - public static function queue_message( array $input, ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null ): array { + public static function queue_message( array $input, ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null, ?WP_Agent_Conversation_Store $conversation_store = null ) { $session_id = trim( self::string_value( $input['session_id'] ?? null ) ); - $run_id = trim( self::string_value( $input['run_id'] ?? null ) ); if ( '' === $session_id ) { throw new \InvalidArgumentException( 'session_id must be a non-empty string' ); } + $target = self::queue_target( $input, $workspace, $owner, $conversation_store ); + if ( $target instanceof \WP_Error ) { + return $target; + } + $run_id = $target['run_id']; + $queued_id = 'queued_' . str_replace( 'run_', '', self::generate_run_id() ); $item = array( 'queued_message_id' => $queued_id, @@ -284,7 +303,7 @@ public static function queue_message( array $input, ?WP_Agent_Workspace_Scope $w 'attachments' => is_array( $input['attachments'] ?? null ) ? $input['attachments'] : array(), 'client_context' => is_array( $input['client_context'] ?? null ) ? $input['client_context'] : array(), 'created_at' => self::now(), - '_owner' => self::owner_fingerprint( $owner ), + '_owner' => $target['owner_fingerprint'], ); $state = self::state( $workspace ); @@ -294,7 +313,7 @@ public static function queue_message( array $input, ?WP_Agent_Workspace_Scope $w self::save_state( $state, $workspace ); return self::normalize_run( array( - 'run_id' => '' !== $run_id ? $run_id : self::generate_run_id(), + 'run_id' => $run_id, 'session_id' => $session_id, 'status' => self::STATUS_QUEUED, 'updated_at' => self::now(), @@ -310,20 +329,32 @@ public static function queue_message( array $input, ?WP_Agent_Workspace_Scope $w * @param array|null $owner Canonical conversation owner. * @return array> Queued items. */ - public static function claim_queued_messages( string $session_id, ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null ): array { + public static function claim_queued_messages( string $session_id, ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null, ?WP_Agent_Conversation_Store $conversation_store = null ): array { + $canonical_owner = self::session_owner_fingerprint( $session_id, $workspace, $owner, $conversation_store ); + if ( $canonical_owner instanceof \WP_Error ) { + return array(); + } + $state = self::state( $workspace ); $items = array_values( array_filter( $state['queues'][ $session_id ] ?? array(), - static fn( array $item ): bool => self::owner_matches( $item['_owner'] ?? '', $owner ) + static fn( array $item ): bool => self::fingerprint_matches( $item['_owner'] ?? null, $canonical_owner ) ) ); - if ( count( $items ) !== count( $state['queues'][ $session_id ] ?? array() ) ) { - return array(); - } unset( $state['queues'][ $session_id ] ); self::save_state( $state, $workspace ); return array_map( array( self::class, 'public_queue_item' ), $items ); } + /** + * Check whether queue input resolves to a session owned by the principal. + * + * @param array $input Canonical queue input. + * @param array|null $owner Canonical conversation owner. + */ + public static function can_queue_message( array $input, ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null, ?WP_Agent_Conversation_Store $conversation_store = null ): bool { + return ! ( self::queue_target( $input, $workspace, $owner, $conversation_store ) instanceof \WP_Error ); + } + /** * List lifecycle events for a principal-owned run. * @@ -399,7 +430,127 @@ private static function owner_fingerprint( ?array $owner ): string { /** @param array|null $owner */ private static function owner_matches( mixed $stored, ?array $owner ): bool { $stored = is_string( $stored ) ? $stored : ''; - return '' === $stored || ( '' !== self::owner_fingerprint( $owner ) && hash_equals( $stored, self::owner_fingerprint( $owner ) ) ); + $fingerprint = self::owner_fingerprint( $owner ); + return '' === $stored ? '' === $fingerprint : '' !== $fingerprint && hash_equals( $stored, $fingerprint ); + } + + private static function fingerprint_matches( mixed $stored, string $expected ): bool { + return is_string( $stored ) && hash_equals( $expected, $stored ); + } + + /** + * Resolve a queue target from stored run state, which is authoritative for + * both the session identity and its canonical owner. + * + * @param array $input Queue input. + * @param array|null $owner Resolved principal owner. + * @return array{run_id:string,owner_fingerprint:string}|\WP_Error + */ + private static function queue_target( array $input, ?WP_Agent_Workspace_Scope $workspace, ?array $owner, ?WP_Agent_Conversation_Store $conversation_store ) { + $session_id = trim( self::string_value( $input['session_id'] ?? null ) ); + $run_id = trim( self::string_value( $input['run_id'] ?? null ) ); + $state = self::state( $workspace ); + $canonical_owner = self::session_owner_fingerprint( $session_id, $workspace, $owner, $conversation_store ); + if ( $canonical_owner instanceof \WP_Error ) { + return $canonical_owner; + } + + if ( '' !== $run_id ) { + $candidate = $state['runs'][ $run_id ] ?? null; + if ( ! is_array( $candidate ) || $session_id !== trim( self::string_value( $candidate['session_id'] ?? null ) ) || ! self::fingerprint_matches( $candidate['_owner'] ?? null, $canonical_owner ) ) { + return new \WP_Error( 'agents_chat_run_not_found', 'No chat run was found for the requested session owner.' ); + } + } else { + $latest_key = ''; + foreach ( $state['runs'] as $candidate_run_id => $candidate ) { + if ( $session_id === trim( self::string_value( $candidate['session_id'] ?? null ) ) && self::fingerprint_matches( $candidate['_owner'] ?? null, $canonical_owner ) ) { + $candidate_key = self::string_value( $candidate['updated_at'] ?? '' ) . ':' . self::string_value( $candidate['started_at'] ?? '' ) . ':' . $candidate_run_id; + if ( '' !== $run_id && $candidate_key <= $latest_key ) { + continue; + } + $latest_key = $candidate_key; + $run_id = $candidate_run_id; + } + } + } + + if ( '' === $run_id ) { + return new \WP_Error( 'agents_chat_run_not_found', 'No chat run was found for the requested session.' ); + } + + return array( + 'run_id' => $run_id, + 'owner_fingerprint' => $canonical_owner, + ); + } + + /** + * Resolve ownership from the canonical conversation store. + * + * @param array|null $owner Resolved principal owner. + * @return string|\WP_Error Canonical owner fingerprint. + */ + private static function session_owner_fingerprint( string $session_id, ?WP_Agent_Workspace_Scope $workspace, ?array $owner, ?WP_Agent_Conversation_Store $conversation_store ) { + $session_id = trim( $session_id ); + $fingerprint = self::owner_fingerprint( $owner ); + $conversation_store ??= WP_Agent_Conversation_Sessions::get_store(); + if ( '' === $session_id ) { + return new \WP_Error( 'agents_chat_run_not_found', 'No chat session was requested.' ); + } + if ( $workspace instanceof WP_Agent_Workspace_Scope && '' === $fingerprint ) { + return new \WP_Error( 'agents_chat_run_owner_required', 'Explicit workspace run control requires an authenticated conversation owner.' ); + } + + $canonical_workspace = $workspace; + if ( null === $canonical_workspace && $conversation_store instanceof WP_Agent_Conversation_Store && is_array( $owner ) ) { + $canonical_workspace = WP_Agent_Workspace_Scope::from_parts( 'site', function_exists( 'get_current_blog_id' ) ? (string) get_current_blog_id() : 'default' ); + } + + if ( $canonical_workspace instanceof WP_Agent_Workspace_Scope && $conversation_store instanceof WP_Agent_Conversation_Store ) { + if ( ! is_array( $owner ) ) { + return new \WP_Error( 'agents_chat_run_owner_forbidden', 'The canonical conversation session is not owned by this workspace principal.' ); + } + $canonical_owner = array( + 'type' => self::string_value( $owner['type'] ?? '' ), + 'key' => self::string_value( $owner['key'] ?? '' ), + ); + if ( ! is_array( WP_Agent_Conversation_Sessions::get_owned_session( $conversation_store, $session_id, $canonical_workspace, $canonical_owner ) ) ) { + return new \WP_Error( 'agents_chat_run_owner_forbidden', 'The canonical conversation session is not owned by this workspace principal.' ); + } + return $fingerprint; + } + + if ( $workspace instanceof WP_Agent_Workspace_Scope ) { + if ( ! $conversation_store instanceof WP_Agent_Conversation_Store ) { + return new \WP_Error( 'agents_chat_run_session_store_required', 'Explicit workspace run control requires an authoritative conversation session store.' ); + } + } + + // Omitted-workspace compatibility is limited to ownership already proven + // by a site-local run. New principal-owned sessions need a canonical store. + $historical_owners = array(); + foreach ( self::state( $workspace )['runs'] as $run ) { + if ( $session_id !== self::string_value( $run['session_id'] ?? '' ) ) { + continue; + } + $stored = is_string( $run['_owner'] ?? null ) ? $run['_owner'] : ''; + $historical_owners[ $stored ] = true; + } + + if ( count( $historical_owners ) > 1 ) { + return new \WP_Error( 'agents_chat_run_owner_forbidden', 'The session has conflicting historical owner state.' ); + } + if ( 1 === count( $historical_owners ) ) { + $stored = (string) array_key_first( $historical_owners ); + if ( ! self::fingerprint_matches( $stored, $fingerprint ) ) { + return new \WP_Error( 'agents_chat_run_owner_forbidden', 'The session is owned by another conversation principal.' ); + } + $fingerprint = $stored; + } elseif ( '' !== $fingerprint ) { + return new \WP_Error( 'agents_chat_run_session_store_required', 'Principal-owned run control requires an authoritative conversation session store.' ); + } + + return $fingerprint; } /** @param array|null $owner */ diff --git a/src/Runtime/class-wp-agent-conversation-loop.php b/src/Runtime/class-wp-agent-conversation-loop.php index 4a45419..0a011f2 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -129,15 +129,20 @@ public static function run( array $messages, ?callable $turn_runner = null, arra $budget_resolution = self::resolve_budgets( $options, $max_turns ); $budgets = $budget_resolution['budgets']; $has_explicit_turns = $budget_resolution['has_explicit_turns']; - $turn_runner = self::resolve_turn_runner( $turn_runner, $options, $tool_declarations, $run_id, $lock_session_id, $request, $budgets ); $wall_clock_started_at = microtime( true ); $wall_clock_initial = isset( $budgets['wall_clock_seconds'] ) ? $budgets['wall_clock_seconds']->current() : 0; $mediation_enabled = null !== $tool_executor && ! empty( $tool_declarations ); self::emit_tool_declaration_diagnostics( $on_event, $rejected_declarations, $tool_declarations, $tool_executor ); $messages = self::normalize_messages( $messages ); if ( '' !== $run_id && '' !== $lock_session_id ) { - WP_Agent_Chat_Run_Control::start_run( $run_id, $lock_session_id, array( 'source' => 'conversation_loop' ), $run_workspace, $run_owner ); + $conversation_store = ( $context['conversation_store'] ?? null ) instanceof \AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Store ? $context['conversation_store'] : null; + $started = WP_Agent_Chat_Run_Control::start_run( $run_id, $lock_session_id, array( 'source' => 'conversation_loop' ), $run_workspace, $run_owner, $conversation_store ); + if ( is_wp_error( $started ) ) { + self::emit_event( $on_event, 'failed', array( 'error' => $started->get_error_message() ) ); + return self::run_control_failure_result( $messages, $started ); + } } + $turn_runner = self::resolve_turn_runner( $turn_runner, $options, $tool_declarations, $run_id, $lock_session_id, $request, $budgets ); $events = array(); $tool_results = array(); $tool_events = self::normalize_array_list( array() ); @@ -1117,6 +1122,32 @@ private static function failure_result( array $messages, array $tool_results, ar ) ); } + /** + * Build a failure result when canonical session ownership rejects turn zero. + * + * @param array> $messages Normalized input messages. + * @return array + */ + private static function run_control_failure_result( array $messages, \WP_Error $error ): array { + return self::normalize_conversation_result( array( + 'messages' => $messages, + 'tool_execution_results' => array(), + 'tool_events' => array(), + 'tool_audit_events' => array(), + 'events' => array(), + 'status' => 'failed', + 'completed' => false, + 'turn_count' => 0, + 'final_content' => self::extract_final_content( $messages ), + 'failure' => array( + 'type' => 'run_control', + 'message' => $error->get_error_message(), + 'code' => $error->get_error_code(), + 'turn_count' => 0, + ), + ) ); + } + /** * Normalize a pending runtime-tool request embedded in a tool execution result. * diff --git a/src/Transcripts/class-wp-agent-conversation-sessions.php b/src/Transcripts/class-wp-agent-conversation-sessions.php index af7d722..e632bfe 100644 --- a/src/Transcripts/class-wp-agent-conversation-sessions.php +++ b/src/Transcripts/class-wp-agent-conversation-sessions.php @@ -7,6 +7,8 @@ namespace AgentsAPI\Core\Database\Chat; +use AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope; + defined( 'ABSPATH' ) || exit; /** @@ -31,4 +33,48 @@ public static function get_store( array $context = array() ): ?WP_Agent_Conversa $store = function_exists( 'apply_filters' ) ? apply_filters( 'wp_agent_conversation_store', null, $context ) : null; return $store instanceof WP_Agent_Conversation_Store ? $store : null; } + + /** + * Resolve a session only when its canonical store proves workspace ownership. + * + * @param array{type:string,key:string} $owner Canonical conversation owner. + * @return array|null Session row, or null when absent/not owned. + */ + public static function get_owned_session( WP_Agent_Conversation_Store $store, string $session_id, WP_Agent_Workspace_Scope $workspace, array $owner ): ?array { + if ( $store instanceof WP_Agent_Principal_Conversation_Session_Reader ) { + return $store->get_session_for_owner( $workspace, $owner, $session_id ); + } + + $session = $store->get_session( $session_id ); + if ( ! is_array( $session ) || ! self::session_matches_workspace( $session, $workspace ) || ! self::session_matches_owner( $session, $owner ) ) { + return null; + } + + return $session; + } + + /** @param array $session */ + private static function session_matches_workspace( array $session, WP_Agent_Workspace_Scope $workspace ): bool { + return self::string_value( $session['workspace_type'] ?? null ) === $workspace->workspace_type + && self::string_value( $session['workspace_id'] ?? null ) === $workspace->workspace_id; + } + + /** + * @param array $session Session row. + * @param array{type:string,key:string} $owner Canonical conversation owner. + */ + private static function session_matches_owner( array $session, array $owner ): bool { + $owner_type = $session['owner_type'] ?? $session['principal_owner_type'] ?? null; + $owner_key = $session['owner_key'] ?? $session['principal_owner_key'] ?? null; + if ( null !== $owner_type || null !== $owner_key ) { + return self::string_value( $owner_type ) === $owner['type'] && self::string_value( $owner_key ) === $owner['key']; + } + + $user_id = $session['user_id'] ?? 0; + return 'user' === $owner['type'] && is_scalar( $user_id ) && (int) $user_id === (int) $owner['key']; + } + + private static function string_value( mixed $value ): string { + return is_scalar( $value ) ? (string) $value : ''; + } } diff --git a/tests/agents-chat-ability-smoke.php b/tests/agents-chat-ability-smoke.php index 8afb857..adda140 100644 --- a/tests/agents-chat-ability-smoke.php +++ b/tests/agents-chat-ability-smoke.php @@ -72,6 +72,46 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & agents_api_smoke_require_module(); +$conversation_store = new class() implements AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Store { + public function create_session( AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope $workspace, int $user_id, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string { + unset( $workspace, $user_id, $agent_slug, $metadata, $context ); + return ''; + } + public function list_sessions( AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope $workspace, int $user_id, array $args = array() ): array { + unset( $workspace, $user_id, $args ); + return array(); + } + public function get_session( string $session_id ): ?array { + if ( 'runtime-s-1' !== $session_id ) { + return null; + } + return array( + 'session_id' => $session_id, + 'workspace_type' => 'site', + 'workspace_id' => 'default', + 'owner_type' => 'runtime', + 'owner_key' => 'runtime-session-1', + ); + } + public function update_session( string $session_id, array $messages, array $metadata = array(), string $provider = '', string $model = '', ?string $provider_response_id = null ): bool { + unset( $session_id, $messages, $metadata, $provider, $model, $provider_response_id ); + return true; + } + public function delete_session( string $session_id ): bool { + unset( $session_id ); + return true; + } + public function get_recent_pending_session( AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope $workspace, int $user_id, int $seconds = 600, string $context = 'chat', ?int $token_id = null ): ?array { + unset( $workspace, $user_id, $seconds, $context, $token_id ); + return null; + } + public function update_title( string $session_id, string $title ): bool { + unset( $session_id, $title ); + return true; + } +}; +add_filter( 'wp_agent_conversation_store', static fn() => $conversation_store ); + use function AgentsAPI\AI\Channels\agents_chat_dispatch; use function AgentsAPI\AI\Channels\agents_chat_permission; use function AgentsAPI\AI\Channels\agents_chat_input_schema; diff --git a/tests/chat-run-control-multisite-smoke.php b/tests/chat-run-control-multisite-smoke.php index f564e22..77bfe41 100644 --- a/tests/chat-run-control-multisite-smoke.php +++ b/tests/chat-run-control-multisite-smoke.php @@ -103,9 +103,12 @@ function wp_register_ability( string $ability, array $args ): void { agents_api_smoke_require_module(); use AgentsAPI\AI\WP_Agent_Chat_Run_Control; +use AgentsAPI\AI\WP_Agent_Conversation_Loop; use AgentsAPI\AI\WP_Agent_Execution_Principal; +use AgentsAPI\AI\WP_Agent_Message; use AgentsAPI\AI\WP_Agent_Run_Control; use AgentsAPI\AI\WP_Agent_Run_Control_Store; +use AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Store; use AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope; use function AgentsAPI\AI\Channels\agents_cancel_chat_run; use function AgentsAPI\AI\Channels\agents_chat_dispatch; @@ -124,6 +127,66 @@ function wp_register_ability( string $ability, array $args ): void { $principal = WP_Agent_Execution_Principal::user_session( 123, 'demo-agent' )->to_array(); $other_principal = WP_Agent_Execution_Principal::user_session( 456, 'demo-agent' )->to_array(); $owner = array( 'type' => 'user', 'key' => '123' ); +$other_owner = array( 'type' => 'user', 'key' => '456' ); +$GLOBALS['__agents_api_canonical_sessions'] = array( + 'network-session' => array( + 'session_id' => 'network-session', + 'workspace_type' => 'network', + 'workspace_id' => 'network-9', + 'owner_type' => 'user', + 'owner_key' => '123', + ), + 'legacy-session' => array( + 'session_id' => 'legacy-session', + 'workspace_type' => 'site', + 'workspace_id' => '1', + 'owner_type' => 'user', + 'owner_key' => '123', + ), + 'concurrent-session' => array( + 'session_id' => 'concurrent-session', + 'workspace_type' => 'network', + 'workspace_id' => 'network-9', + 'owner_type' => 'user', + 'owner_key' => '123', + ), +); +$GLOBALS['__agents_api_session_read_interleave'] = null; +$conversation_store = new class() implements WP_Agent_Conversation_Store { + public function create_session( WP_Agent_Workspace_Scope $workspace, int $user_id, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string { + unset( $workspace, $user_id, $agent_slug, $metadata, $context ); + return ''; + } + public function list_sessions( WP_Agent_Workspace_Scope $workspace, int $user_id, array $args = array() ): array { + unset( $workspace, $user_id, $args ); + return array(); + } + public function get_session( string $session_id ): ?array { + $interleave = $GLOBALS['__agents_api_session_read_interleave']; + $GLOBALS['__agents_api_session_read_interleave'] = null; + if ( is_callable( $interleave ) ) { + $interleave(); + } + return $GLOBALS['__agents_api_canonical_sessions'][ $session_id ] ?? null; + } + public function update_session( string $session_id, array $messages, array $metadata = array(), string $provider = '', string $model = '', ?string $provider_response_id = null ): bool { + unset( $session_id, $messages, $metadata, $provider, $model, $provider_response_id ); + return true; + } + public function delete_session( string $session_id ): bool { + unset( $session_id ); + return true; + } + public function get_recent_pending_session( WP_Agent_Workspace_Scope $workspace, int $user_id, int $seconds = 600, string $context = 'chat', ?int $token_id = null ): ?array { + unset( $workspace, $user_id, $seconds, $context, $token_id ); + return null; + } + public function update_title( string $session_id, string $title ): bool { + unset( $session_id, $title ); + return true; + } +}; +add_filter( 'wp_agent_conversation_store', static fn() => $conversation_store ); add_filter( 'wp_agent_chat_handler', @@ -150,6 +213,44 @@ function wp_register_ability( string $ability, array $args ): void { ); $run_id = is_array( $created ) ? (string) ( $created['run_id'] ?? '' ) : ''; agents_api_smoke_assert_equals( false, $created instanceof WP_Error, 'run is created on the first subsite under an explicit workspace', $failures, $passes ); +$workspace_scope = WP_Agent_Workspace_Scope::from_array( $workspace ); + +$foreign_run_id = 'run_foreign_owner'; +$GLOBALS['__agents_api_multisite_user'] = 456; +$foreign_run = agents_chat_dispatch( + array( + 'agent' => 'demo-agent', + 'message' => 'claim victim session', + 'session_id' => 'network-session', + 'run_id' => $foreign_run_id, + 'workspace' => $workspace, + 'principal' => $other_principal, + 'session_owner' => $other_owner, + ) +); +agents_api_smoke_assert_equals( 'agents_chat_run_owner_forbidden', $foreign_run instanceof WP_Error ? $foreign_run->get_error_code() : '', 'foreign principal cannot create a separately owned run for the victim session', $failures, $passes ); +$foreign_run_state = WP_Agent_Run_Control::state( 'agents_api_chat_run_control', $workspace_scope ); +agents_api_smoke_assert_equals( false, isset( $foreign_run_state['runs'][ $foreign_run_id ] ), 'denied foreign run is not persisted', $failures, $passes ); + +$foreign_turns = 0; +$foreign_loop = WP_Agent_Conversation_Loop::run( + array( WP_Agent_Message::text( 'user', 'foreign turn' ) ), + static function () use ( &$foreign_turns ): array { + ++$foreign_turns; + return array( 'messages' => array() ); + }, + array( + 'run_id' => 'run_foreign_loop', + 'transcript_session_id' => 'network-session', + 'workspace' => $workspace_scope, + 'principal' => $other_principal, + 'context' => array( 'conversation_store' => $conversation_store ), + ) +); +agents_api_smoke_assert_equals( 'failed', $foreign_loop['status'] ?? null, 'conversation loop surfaces canonical session owner conflicts as failures', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_chat_run_owner_forbidden', $foreign_loop['failure']['code'] ?? null, 'conversation loop preserves the canonical owner-conflict error code', $failures, $passes ); +agents_api_smoke_assert_equals( 0, $foreign_turns, 'conversation loop stops before foreign model or tool execution', $failures, $passes ); +$GLOBALS['__agents_api_multisite_user'] = 123; $queued = agents_queue_chat_message( array( @@ -164,6 +265,47 @@ function wp_register_ability( string $ability, array $args ): void { ); agents_api_smoke_assert_equals( 'queued', $queued['status'] ?? null, 'queue state is created on the first subsite', $failures, $passes ); +// Exact #451 exploit: a foreign principal knows both IDs and supplies its own +// otherwise-valid owner claim. Permission and execution must both deny it. +$foreign_input = array( + 'agent' => 'demo-agent', + 'message' => 'foreign poison', + 'session_id' => 'network-session', + 'run_id' => $run_id, + 'workspace' => $workspace, + 'principal' => $other_principal, + 'session_owner' => $other_owner, +); +$GLOBALS['__agents_api_multisite_user'] = 456; +$foreign_allowed = AgentsAPI\AI\Channels\agents_chat_run_enqueue_permission( $foreign_input ); +$foreign_queued = agents_queue_chat_message( $foreign_input ); +agents_api_smoke_assert_equals( false, $foreign_allowed, 'foreign principal cannot pass enqueue permission with a known session id', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_chat_run_not_found', $foreign_queued instanceof WP_Error ? $foreign_queued->get_error_code() : '', 'foreign principal cannot enqueue with its own owner claim', $failures, $passes ); +$GLOBALS['__agents_api_multisite_user'] = 123; + +// Simulate pre-fix corrupt state with the foreign run stored first. The +// canonical session binding must remain authoritative regardless of run order. +$reversed_state = WP_Agent_Run_Control::state( 'agents_api_chat_run_control', $workspace_scope ); +$reversed_state['runs'] = array_merge( + array( + $foreign_run_id => array( + 'run_id' => $foreign_run_id, + 'session_id' => 'network-session', + 'status' => 'running', + '_owner' => hash( 'sha256', 'user:456' ), + ), + ), + $reversed_state['runs'] +); +WP_Agent_Run_Control::save_state( 'agents_api_chat_run_control', $reversed_state, $workspace_scope ); +agents_api_smoke_assert_equals( $foreign_run_id, array_key_first( WP_Agent_Run_Control::state( 'agents_api_chat_run_control', $workspace_scope )['runs'] ), 'corrupt foreign run is first in storage order for regression coverage', $failures, $passes ); + +$foreign_owned_run_input = array_replace( $foreign_input, array( 'run_id' => $foreign_run_id ) ); +$GLOBALS['__agents_api_multisite_user'] = 456; +$foreign_owned_run_queue = agents_queue_chat_message( $foreign_owned_run_input ); +agents_api_smoke_assert_equals( 'agents_chat_run_not_found', $foreign_owned_run_queue instanceof WP_Error ? $foreign_owned_run_queue->get_error_code() : '', 'foreign principal cannot enqueue through a separately owned run for the victim session', $failures, $passes ); +$GLOBALS['__agents_api_multisite_user'] = 123; + switch_to_blog( 2 ); $control_input = array( 'session_id' => 'network-session', @@ -177,8 +319,18 @@ function wp_register_ability( string $ability, array $args ): void { agents_api_smoke_assert_equals( 'runtime_tool_pending', $status['status'] ?? null, 'second subsite reads workspace run status', $failures, $passes ); agents_api_smoke_assert_equals( true, count( $events['events'] ?? array() ) >= 2, 'second subsite reads workspace run events', $failures, $passes ); +$GLOBALS['__agents_api_multisite_user'] = 456; +$foreign_cross_site = agents_queue_chat_message( $foreign_input ); +agents_api_smoke_assert_equals( 'agents_chat_run_not_found', $foreign_cross_site instanceof WP_Error ? $foreign_cross_site->get_error_code() : '', 'foreign enqueue remains denied from another subsite', $failures, $passes ); +$foreign_claim = WP_Agent_Chat_Run_Control::claim_queued_messages( 'network-session', WP_Agent_Workspace_Scope::from_array( $workspace ), $other_owner ); +agents_api_smoke_assert_equals( array(), $foreign_claim, 'foreign claim cannot consume the owner queue', $failures, $passes ); +$preserved_queue = WP_Agent_Run_Control::state( 'agents_api_chat_run_control', $workspace_scope )['queues']['network-session'] ?? array(); +agents_api_smoke_assert_equals( 'queued continuation', $preserved_queue[0]['message'] ?? null, 'foreign claim preserves the legitimate queue when foreign run is first', $failures, $passes ); +$GLOBALS['__agents_api_multisite_user'] = 123; + $claimed = WP_Agent_Chat_Run_Control::claim_queued_messages( 'network-session', WP_Agent_Workspace_Scope::from_array( $workspace ), $owner ); agents_api_smoke_assert_equals( 'queued continuation', $claimed[0]['message'] ?? null, 'second subsite claims the workspace queue', $failures, $passes ); +agents_api_smoke_assert_equals( 1, count( $claimed ), 'rejected exploit does not poison or block owner queue progress', $failures, $passes ); $continued = agents_chat_dispatch( array( @@ -198,12 +350,16 @@ function wp_register_ability( string $ability, array $args ): void { $wrong_workspace = agents_get_chat_run( array_replace( $control_input, array( 'workspace' => $other_workspace ) ) ); agents_api_smoke_assert_equals( 'agents_chat_run_not_found', $wrong_workspace instanceof WP_Error ? $wrong_workspace->get_error_code() : '', 'same run id is inaccessible in another workspace', $failures, $passes ); +$wrong_workspace_queue = agents_queue_chat_message( array_replace( $control_input, array( 'agent' => 'demo-agent', 'message' => 'wrong workspace', 'workspace' => $other_workspace ) ) ); +agents_api_smoke_assert_equals( 'agents_chat_run_not_found', $wrong_workspace_queue instanceof WP_Error ? $wrong_workspace_queue->get_error_code() : '', 'enqueue cannot cross workspace boundaries', $failures, $passes ); $wrong_principal_input = $control_input; $wrong_principal_input['principal'] = $other_principal; $wrong_principal_input['session_owner'] = array( 'type' => 'user', 'key' => '456' ); $wrong_principal = agents_get_chat_run( $wrong_principal_input ); agents_api_smoke_assert_equals( 'agents_chat_run_not_found', $wrong_principal instanceof WP_Error ? $wrong_principal->get_error_code() : '', 'wrong principal cannot inspect a workspace run', $failures, $passes ); +$wrong_principal_queue = agents_queue_chat_message( array_replace( $wrong_principal_input, array( 'agent' => 'demo-agent', 'message' => 'wrong principal' ) ) ); +agents_api_smoke_assert_equals( 'agents_chat_run_not_found', $wrong_principal_queue instanceof WP_Error ? $wrong_principal_queue->get_error_code() : '', 'wrong principal cannot enqueue to a workspace run', $failures, $passes ); $forged_owner_input = $control_input; $forged_owner_input['session_owner'] = array( 'type' => 'user', 'key' => '456' ); @@ -213,14 +369,74 @@ function wp_register_ability( string $ability, array $args ): void { $invalid_workspace = agents_get_chat_run( array_replace( $control_input, array( 'workspace' => array( 'workspace_type' => 'network' ) ) ) ); agents_api_smoke_assert_equals( 'agents_chat_run_invalid_workspace', $invalid_workspace instanceof WP_Error ? $invalid_workspace->get_error_code() : '', 'malformed canonical workspaces are rejected', $failures, $passes ); +$mixed_first = agents_queue_chat_message( array_replace( $control_input, array( 'agent' => 'demo-agent', 'message' => 'owner first' ) ) ); +$mixed_second = agents_queue_chat_message( array_replace( $control_input, array( 'agent' => 'demo-agent', 'message' => 'owner second' ) ) ); +agents_api_smoke_assert_equals( 'queued', $mixed_first['status'] ?? null, 'owner can enqueue before mixed-state recovery', $failures, $passes ); +agents_api_smoke_assert_equals( 'queued', $mixed_second['status'] ?? null, 'owner can append before mixed-state recovery', $failures, $passes ); + +$mixed_state = WP_Agent_Run_Control::state( 'agents_api_chat_run_control', $workspace_scope ); +$mixed_state['queues']['network-session'][] = array( + 'queued_message_id' => 'queued_foreign', + 'session_id' => 'network-session', + 'run_id' => $run_id, + 'message' => 'foreign stored poison', + '_owner' => hash( 'sha256', 'user:456' ), +); +$mixed_state['queues']['network-session'][] = array( + 'queued_message_id' => 'queued_invalid', + 'session_id' => 'network-session', + 'run_id' => $run_id, + 'message' => 'invalid stored poison', +); +WP_Agent_Run_Control::save_state( 'agents_api_chat_run_control', $mixed_state, $workspace_scope ); + +$mixed_foreign_claim = WP_Agent_Chat_Run_Control::claim_queued_messages( 'network-session', $workspace_scope, $other_owner ); +agents_api_smoke_assert_equals( array(), $mixed_foreign_claim, 'wrong owner cannot use mixed entries to consume the queue', $failures, $passes ); +$mixed_preserved = WP_Agent_Run_Control::state( 'agents_api_chat_run_control', $workspace_scope )['queues']['network-session'] ?? array(); +agents_api_smoke_assert_equals( array( 'owner first', 'owner second' ), array_slice( array_column( $mixed_preserved, 'message' ), 0, 2 ), 'wrong-owner claim leaves legitimate entries intact beside corrupt rows', $failures, $passes ); +$mixed_claim = WP_Agent_Chat_Run_Control::claim_queued_messages( 'network-session', $workspace_scope, $owner ); +agents_api_smoke_assert_equals( array( 'owner first', 'owner second' ), array_column( $mixed_claim, 'message' ), 'owner claim progresses while discarding foreign and invalid entries', $failures, $passes ); +$recovered_state = WP_Agent_Run_Control::state( 'agents_api_chat_run_control', $workspace_scope ); +agents_api_smoke_assert_equals( false, isset( $recovered_state['queues']['network-session'] ), 'mixed-entry recovery cleans poison entries from storage', $failures, $passes ); +agents_api_smoke_assert_equals( 2, get_current_blog_id(), 'queue authorization and recovery preserve caller blog context', $failures, $passes ); + restore_current_blog(); $legacy = WP_Agent_Chat_Run_Control::start_run( 'legacy-run', 'legacy-session', array(), null, $owner ); +$legacy_queued = agents_queue_chat_message( + array( + 'agent' => 'demo-agent', + 'message' => 'legacy continuation', + 'session_id' => 'legacy-session', + 'run_id' => 'legacy-run', + 'principal' => $principal, + 'session_owner' => $owner, + ) +); +agents_api_smoke_assert_equals( 'queued', $legacy_queued['status'] ?? null, 'omitted-workspace enqueue remains compatible', $failures, $passes ); switch_to_blog( 2 ); $legacy_other_site = WP_Agent_Chat_Run_Control::get_run( 'legacy-run', null, $owner ); agents_api_smoke_assert_equals( null, $legacy_other_site, 'omitted workspace state remains site-local', $failures, $passes ); restore_current_blog(); $legacy_origin = WP_Agent_Chat_Run_Control::get_run( 'legacy-run', null, $owner ); agents_api_smoke_assert_equals( 'legacy-run', $legacy_origin['run_id'] ?? null, 'omitted workspace compatibility remains readable on its origin site', $failures, $passes ); +$legacy_claim = WP_Agent_Chat_Run_Control::claim_queued_messages( 'legacy-session', null, $owner ); +agents_api_smoke_assert_equals( 'legacy continuation', $legacy_claim[0]['message'] ?? null, 'omitted-workspace owner queue still progresses on its origin site', $failures, $passes ); + +$concurrent_foreign = null; +$GLOBALS['__agents_api_session_read_interleave'] = static function () use ( &$concurrent_foreign, $workspace_scope, $other_owner, $conversation_store ): void { + $concurrent_foreign = WP_Agent_Chat_Run_Control::start_run( 'concurrent-foreign', 'concurrent-session', array(), $workspace_scope, $other_owner, $conversation_store ); +}; +$concurrent_owner = WP_Agent_Chat_Run_Control::start_run( 'concurrent-owner', 'concurrent-session', array(), $workspace_scope, $owner, $conversation_store ); +agents_api_smoke_assert_equals( 'agents_chat_run_owner_forbidden', $concurrent_foreign instanceof WP_Error ? $concurrent_foreign->get_error_code() : '', 'interleaved foreign start loses against canonical session ownership', $failures, $passes ); +agents_api_smoke_assert_equals( false, $concurrent_owner instanceof WP_Error, 'interleaved canonical owner start succeeds without a parallel binding registry', $failures, $passes ); +$concurrent_state = WP_Agent_Run_Control::state( 'agents_api_chat_run_control', $workspace_scope ); +agents_api_smoke_assert_equals( false, isset( $concurrent_state['runs']['concurrent-foreign'] ), 'interleaved foreign run is never persisted', $failures, $passes ); +agents_api_smoke_assert_equals( true, isset( $concurrent_state['runs']['concurrent-owner'] ), 'interleaved canonical run is persisted once ownership is proven', $failures, $passes ); + +$unproven = WP_Agent_Chat_Run_Control::start_run( 'unproven-run', 'missing-canonical-session', array(), $workspace_scope, $owner, $conversation_store ); +agents_api_smoke_assert_equals( 'agents_chat_run_owner_forbidden', $unproven instanceof WP_Error ? $unproven->get_error_code() : '', 'workspace start fails closed when the canonical store cannot prove session ownership', $failures, $passes ); +$unproven_state = WP_Agent_Run_Control::state( 'agents_api_chat_run_control', $workspace_scope ); +agents_api_smoke_assert_equals( false, isset( $unproven_state['runs']['unproven-run'] ), 'unproven workspace run is never persisted', $failures, $passes ); $unsupported_store = new class() implements WP_Agent_Run_Control_Store { public function get_state( string $store_key ): array { diff --git a/tests/chat-run-control-smoke.php b/tests/chat-run-control-smoke.php index 362525b..9e38f6d 100644 --- a/tests/chat-run-control-smoke.php +++ b/tests/chat-run-control-smoke.php @@ -43,6 +43,12 @@ function get_current_user_id(): int { } } +if ( ! function_exists( 'sanitize_key' ) ) { + function sanitize_key( string $value ): string { + return (string) preg_replace( '/[^a-z0-9_-]/', '', strtolower( $value ) ); + } +} + if ( ! function_exists( 'wp_has_ability_category' ) ) { function wp_has_ability_category( string $category ): bool { return isset( $GLOBALS['__agents_api_smoke_categories'][ $category ] ); @@ -83,6 +89,43 @@ function update_option( string $option, $value, $autoload = null ): bool { agents_api_smoke_require_module(); +$conversation_store = new class() implements AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Store { + public function create_session( AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope $workspace, int $user_id, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string { + unset( $workspace, $user_id, $agent_slug, $metadata, $context ); + return 'session-1'; + } + public function list_sessions( AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope $workspace, int $user_id, array $args = array() ): array { + unset( $workspace, $user_id, $args ); + return array(); + } + public function get_session( string $session_id ): ?array { + return '' !== $session_id ? array( + 'session_id' => $session_id, + 'workspace_type' => 'site', + 'workspace_id' => 'default', + 'owner_type' => 'user', + 'owner_key' => '123', + ) : null; + } + public function update_session( string $session_id, array $messages, array $metadata = array(), string $provider = '', string $model = '', ?string $provider_response_id = null ): bool { + unset( $session_id, $messages, $metadata, $provider, $model, $provider_response_id ); + return true; + } + public function delete_session( string $session_id ): bool { + unset( $session_id ); + return true; + } + public function get_recent_pending_session( AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope $workspace, int $user_id, int $seconds = 600, string $context = 'chat', ?int $token_id = null ): ?array { + unset( $workspace, $user_id, $seconds, $context, $token_id ); + return null; + } + public function update_title( string $session_id, string $title ): bool { + unset( $session_id, $title ); + return true; + } +}; +add_filter( 'wp_agent_conversation_store', static fn() => $conversation_store ); + do_action( 'wp_abilities_api_categories_init' ); do_action( 'wp_abilities_api_init' ); @@ -92,14 +135,16 @@ function update_option( string $option, $value, $autoload = null ): bool { $chat_cancel_permission = 'AgentsAPI\AI\Channels\agents_chat_run_cancel_permission'; $chat_queue_permission = 'AgentsAPI\AI\Channels\agents_chat_run_enqueue_permission'; +AgentsAPI\AI\WP_Agent_Chat_Run_Control::start_run( 'run-1', 'session-1', array(), null, array( 'type' => 'user', 'key' => '123' ) ); + agents_api_smoke_assert_equals( true, call_user_func( $chat_read_permission, array( 'session_id' => 'session-1', 'run_id' => 'run-1' ) ), 'read-only user can read chat run status', $failures, $passes ); agents_api_smoke_assert_equals( false, call_user_func( $chat_cancel_permission, array( 'session_id' => 'session-1', 'run_id' => 'run-1' ) ), 'read-only user cannot cancel chat run by id alone', $failures, $passes ); agents_api_smoke_assert_equals( false, call_user_func( $chat_queue_permission, array( 'agent' => 'demo-agent', 'session_id' => 'session-1', 'message' => 'Next' ) ), 'read-only user cannot queue chat message by session id alone', $failures, $passes ); agents_api_smoke_assert_equals( true, call_user_func( $chat_cancel_permission, array( 'session_id' => 'session-1', 'run_id' => 'run-1', 'session_owner' => array( 'type' => 'user', 'key' => '123' ) ) ), 'session owner can cancel chat run', $failures, $passes ); -agents_api_smoke_assert_equals( true, call_user_func( $chat_queue_permission, array( 'agent' => 'demo-agent', 'session_id' => 'session-1', 'message' => 'Next', 'session_owner' => array( 'type' => 'user', 'key' => '123' ) ) ), 'session owner can queue chat message', $failures, $passes ); +agents_api_smoke_assert_equals( true, call_user_func( $chat_queue_permission, array( 'agent' => 'demo-agent', 'session_id' => 'session-1', 'run_id' => 'run-1', 'message' => 'Next', 'session_owner' => array( 'type' => 'user', 'key' => '123' ) ) ), 'session owner can queue chat message', $failures, $passes ); $GLOBALS['__agents_api_smoke_caps']['manage_options'] = true; agents_api_smoke_assert_equals( true, call_user_func( $chat_cancel_permission, array( 'session_id' => 'session-1', 'run_id' => 'run-1' ) ), 'manager can cancel chat run without owner claim', $failures, $passes ); -agents_api_smoke_assert_equals( true, call_user_func( $chat_queue_permission, array( 'agent' => 'demo-agent', 'session_id' => 'session-1', 'message' => 'Next' ) ), 'manager can queue chat message without owner claim', $failures, $passes ); +agents_api_smoke_assert_equals( true, call_user_func( $chat_queue_permission, array( 'agent' => 'demo-agent', 'session_id' => 'session-1', 'run_id' => 'run-1', 'message' => 'Next' ) ), 'manager can queue chat message without owner claim', $failures, $passes ); agents_api_smoke_assert_equals( true, wp_has_ability( AgentsAPI\AI\Channels\AGENTS_GET_CHAT_RUN_ABILITY ), 'get-run ability registers', $failures, $passes ); agents_api_smoke_assert_equals( true, wp_has_ability( AgentsAPI\AI\Channels\AGENTS_CANCEL_CHAT_RUN_ABILITY ), 'cancel-run ability registers', $failures, $passes ); @@ -195,14 +240,16 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { agents_api_smoke_assert_equals( 'approval_required', $approval_stored['status'] ?? null, 'chat dispatch keeps approval-required run addressable', $failures, $passes ); agents_api_smoke_assert_equals( false, 'completed' === ( $approval_stored['status'] ?? null ), 'approval-required run is not marked completed', $failures, $passes ); -AgentsAPI\AI\WP_Agent_Chat_Run_Control::start_run( 'run-default-cancel', 'session-1' ); +AgentsAPI\AI\WP_Agent_Chat_Run_Control::start_run( 'run-default-cancel', 'session-1', array(), null, array( 'type' => 'user', 'key' => '123' ) ); $default_cancelled = AgentsAPI\AI\Channels\agents_cancel_chat_run( array( 'session_id' => 'session-1', 'run_id' => 'run-default-cancel' ) ); agents_api_smoke_assert_equals( 'cancelling', $default_cancelled['status'] ?? null, 'default cancel marks running runs as cancelling', $failures, $passes ); +AgentsAPI\AI\WP_Agent_Chat_Run_Control::start_run( 'run-default-queue', 'session-default', array(), null, array( 'type' => 'user', 'key' => '123' ) ); $default_queued = AgentsAPI\AI\Channels\agents_queue_chat_message( array( 'agent' => 'demo-agent', 'session_id' => 'session-default', + 'run_id' => 'run-default-queue', 'message' => 'Default queue', ) ); diff --git a/tests/conversation-session-store-contract-smoke.php b/tests/conversation-session-store-contract-smoke.php index bb76c76..0845262 100644 --- a/tests/conversation-session-store-contract-smoke.php +++ b/tests/conversation-session-store-contract-smoke.php @@ -32,8 +32,10 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & require_once __DIR__ . '/../src/Transcripts/class-wp-agent-conversation-store.php'; require_once __DIR__ . '/../src/Transcripts/class-wp-agent-principal-conversation-store.php'; require_once __DIR__ . '/../src/Transcripts/class-wp-agent-principal-conversation-session-reader.php'; +require_once __DIR__ . '/../src/Transcripts/class-wp-agent-conversation-sessions.php'; use AgentsAPI\AI\WP_Agent_Execution_Principal; +use AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Sessions; use AgentsAPI\Core\Database\Chat\WP_Agent_Principal_Conversation_Session_Reader; use AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope; @@ -230,6 +232,8 @@ public function get_recent_pending_session( WP_Agent_Workspace_Scope $workspace, smoke_assert( 'browser:opaque', $store->get_session_for_owner( $workspace, $browser_owner, $browser_id )['owner_key'] ?? null, 'principal reader loads matching opaque owner', $failures, $passes ); smoke_assert( null, $store->get_session_for_owner( $workspace, array( 'type' => 'browser', 'key' => 'browser:other' ), $browser_id ), 'principal reader blocks other opaque owners', $failures, $passes ); +smoke_assert( $browser_id, WP_Agent_Conversation_Sessions::get_owned_session( $store, $browser_id, $workspace, $browser_owner )['session_id'] ?? null, 'canonical resolver delegates opaque ownership to the principal reader', $failures, $passes ); +smoke_assert( null, WP_Agent_Conversation_Sessions::get_owned_session( $store, $browser_id, $workspace, array( 'type' => 'browser', 'key' => 'browser:other' ) ), 'canonical resolver rejects a foreign opaque principal', $failures, $passes ); smoke_assert( 'fake-2', $store->get_recent_pending_session( $workspace, 8, 600, 'chat' )['session_id'] ?? null, 'pending lookup scopes user sessions', $failures, $passes ); smoke_assert( 'fake-4', $store->get_recent_pending_session_for_owner( $workspace, $browser_owner, 600, 'chat' )['session_id'] ?? null, 'pending lookup scopes principal sessions', $failures, $passes );