From 4b5932d1b0e045b11d5935bba925148affb2114c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 21 Jul 2026 11:40:30 +0000 Subject: [PATCH 1/4] fix: bind queued messages to session owners --- ...ster-agents-chat-run-control-abilities.php | 21 +++++ .../class-wp-agent-chat-run-control.php | 83 +++++++++++++++++-- tests/chat-run-control-multisite-smoke.php | 74 +++++++++++++++++ tests/chat-run-control-smoke.php | 14 +++- 4 files changed, 181 insertions(+), 11 deletions(-) diff --git a/src/Channels/register-agents-chat-run-control-abilities.php b/src/Channels/register-agents-chat-run-control-abilities.php index 484580f..49b1796 100644 --- a/src/Channels/register-agents-chat-run-control-abilities.php +++ b/src/Channels/register-agents-chat-run-control-abilities.php @@ -232,6 +232,16 @@ 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'] ) ) { + 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 ) ) { @@ -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'] ); + } 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/Runtime/class-wp-agent-chat-run-control.php b/src/Runtime/class-wp-agent-chat-run-control.php index 6f02a2e..60a06a8 100644 --- a/src/Runtime/class-wp-agent-chat-run-control.php +++ b/src/Runtime/class-wp-agent-chat-run-control.php @@ -265,15 +265,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 ) { $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 ); + 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 +289,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 +299,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(), @@ -311,19 +316,31 @@ public static function queue_message( array $input, ?WP_Agent_Workspace_Scope $w * @return array> Queued items. */ public static function claim_queued_messages( string $session_id, ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null ): array { + $target = self::queue_target( array( 'session_id' => $session_id ), $workspace, $owner ); + if ( $target 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, $target['owner_fingerprint'] ) ) ); - 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 ): bool { + return ! ( self::queue_target( $input, $workspace, $owner ) instanceof \WP_Error ); + } + /** * List lifecycle events for a principal-owned run. * @@ -402,6 +419,54 @@ private static function owner_matches( mixed $stored, ?array $owner ): bool { return '' === $stored || ( '' !== self::owner_fingerprint( $owner ) && hash_equals( $stored, self::owner_fingerprint( $owner ) ) ); } + 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 ) { + $session_id = trim( self::string_value( $input['session_id'] ?? null ) ); + $run_id = trim( self::string_value( $input['run_id'] ?? null ) ); + $state = self::state( $workspace ); + $target = null; + + if ( '' !== $run_id ) { + $candidate = $state['runs'][ $run_id ] ?? null; + if ( is_array( $candidate ) && $session_id === trim( self::string_value( $candidate['session_id'] ?? null ) ) ) { + $target = $candidate; + } + } else { + foreach ( $state['runs'] as $candidate_run_id => $candidate ) { + if ( $session_id === trim( self::string_value( $candidate['session_id'] ?? null ) ) ) { + $target = $candidate; + $run_id = $candidate_run_id; + break; + } + } + } + + if ( ! is_array( $target ) ) { + return new \WP_Error( 'agents_chat_run_not_found', 'No chat run was found for the requested session.' ); + } + + $stored_owner = is_string( $target['_owner'] ?? null ) ? $target['_owner'] : ''; + if ( ( $workspace instanceof WP_Agent_Workspace_Scope && '' === $stored_owner ) || ! self::owner_matches( $stored_owner, $owner ) ) { + return new \WP_Error( 'agents_chat_run_not_found', 'No chat run was found for the requested session owner.' ); + } + + return array( + 'run_id' => $run_id, + 'owner_fingerprint' => $stored_owner, + ); + } + /** @param array|null $owner */ private static function can_access_run( string $run_id, ?WP_Agent_Workspace_Scope $workspace, ?array $owner ): bool { $state = self::state( $workspace ); diff --git a/tests/chat-run-control-multisite-smoke.php b/tests/chat-run-control-multisite-smoke.php index f564e22..c2250b6 100644 --- a/tests/chat-run-control-multisite-smoke.php +++ b/tests/chat-run-control-multisite-smoke.php @@ -124,6 +124,7 @@ 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' ); add_filter( 'wp_agent_chat_handler', @@ -164,6 +165,24 @@ 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; + switch_to_blog( 2 ); $control_input = array( 'session_id' => 'network-session', @@ -177,8 +196,16 @@ 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 ); +$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 +225,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 +244,57 @@ 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 ); + +$workspace_scope = WP_Agent_Workspace_Scope::from_array( $workspace ); +$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_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 ); $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..25dd5e0 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 ] ); @@ -92,14 +98,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 ); @@ -199,10 +207,12 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { $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' ); $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', ) ); From 239dec7dcf23665c977bc6cb98cfcee405e77b73 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 21 Jul 2026 12:08:44 +0000 Subject: [PATCH 2/4] fix: bind chat sessions to canonical owners --- src/Channels/register-agents-chat-ability.php | 12 +- .../class-wp-agent-chat-run-control.php | 119 ++++++++++++++---- tests/chat-run-control-multisite-smoke.php | 47 ++++++- tests/chat-run-control-smoke.php | 4 +- 4 files changed, 155 insertions(+), 27 deletions(-) diff --git a/src/Channels/register-agents-chat-ability.php b/src/Channels/register-agents-chat-ability.php index f675d54..00631f7 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'] ); } 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'] ); + 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/Runtime/class-wp-agent-chat-run-control.php b/src/Runtime/class-wp-agent-chat-run-control.php index 60a06a8..727f0e9 100644 --- a/src/Runtime/class-wp-agent-chat-run-control.php +++ b/src/Runtime/class-wp-agent-chat-run-control.php @@ -28,6 +28,7 @@ class WP_Agent_Chat_Run_Control { public const STATUS_STALLED = 'stalled'; public const STATUS_INTERRUPTED = 'interrupted'; private const OPTION_KEY = 'agents_api_chat_run_control'; + private const SESSION_OWNER_OPTION_KEY = 'agents_api_chat_session_owners'; /** @return string[] */ public static function statuses(): array { @@ -182,16 +183,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 ) { + $canonical_owner = self::session_owner_fingerprint( $session_id, $workspace, $owner, true ); + 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 ) ); @@ -316,15 +328,15 @@ public static function queue_message( array $input, ?WP_Agent_Workspace_Scope $w * @return array> Queued items. */ public static function claim_queued_messages( string $session_id, ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null ): array { - $target = self::queue_target( array( 'session_id' => $session_id ), $workspace, $owner ); - if ( $target instanceof \WP_Error ) { + $canonical_owner = self::session_owner_fingerprint( $session_id, $workspace, $owner, false ); + 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::fingerprint_matches( $item['_owner'] ?? null, $target['owner_fingerprint'] ) + static fn( array $item ): bool => self::fingerprint_matches( $item['_owner'] ?? null, $canonical_owner ) ) ); unset( $state['queues'][ $session_id ] ); self::save_state( $state, $workspace ); @@ -432,39 +444,102 @@ private static function fingerprint_matches( mixed $stored, string $expected ): * @return array{run_id:string,owner_fingerprint:string}|\WP_Error */ private static function queue_target( array $input, ?WP_Agent_Workspace_Scope $workspace, ?array $owner ) { - $session_id = trim( self::string_value( $input['session_id'] ?? null ) ); - $run_id = trim( self::string_value( $input['run_id'] ?? null ) ); - $state = self::state( $workspace ); - $target = null; + $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, false ); + 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 ) ) ) { - $target = $candidate; + 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 ) ) ) { - $target = $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; - break; } } } - if ( ! is_array( $target ) ) { + if ( '' === $run_id ) { return new \WP_Error( 'agents_chat_run_not_found', 'No chat run was found for the requested session.' ); } - $stored_owner = is_string( $target['_owner'] ?? null ) ? $target['_owner'] : ''; - if ( ( $workspace instanceof WP_Agent_Workspace_Scope && '' === $stored_owner ) || ! self::owner_matches( $stored_owner, $owner ) ) { - return new \WP_Error( 'agents_chat_run_not_found', 'No chat run was found for the requested session owner.' ); - } - return array( 'run_id' => $run_id, - 'owner_fingerprint' => $stored_owner, + 'owner_fingerprint' => $canonical_owner, + ); + } + + /** + * Resolve or create the immutable owner binding for a session. + * + * Existing runs are consulted only to migrate pre-binding state. Conflicting + * historical owners fail closed without changing the binding or queue. + * + * @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, bool $create ) { + $session_id = trim( $session_id ); + $fingerprint = self::owner_fingerprint( $owner ); + 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.' ); + } + + $binding_key = hash( 'sha256', $session_id ); + $binding_state = WP_Agent_Run_Control::state( self::SESSION_OWNER_OPTION_KEY, $workspace ); + $binding = $binding_state['runs'][ $binding_key ] ?? null; + if ( is_array( $binding ) ) { + $stored = is_string( $binding['_owner'] ?? null ) ? $binding['_owner'] : ''; + if ( $session_id !== self::string_value( $binding['session_id'] ?? '' ) || ! self::fingerprint_matches( $stored, $fingerprint ) ) { + return new \WP_Error( 'agents_chat_run_owner_forbidden', 'The session is owned by another conversation principal.' ); + } + return $stored; + } + + $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 ( ! $create ) { + return new \WP_Error( 'agents_chat_run_not_found', 'No chat run was found for the requested session.' ); + } + + $binding_state['runs'][ $binding_key ] = array( + 'session_id' => $session_id, + '_owner' => $fingerprint, ); + WP_Agent_Run_Control::save_state( self::SESSION_OWNER_OPTION_KEY, $binding_state, $workspace ); + + return $fingerprint; } /** @param array|null $owner */ diff --git a/tests/chat-run-control-multisite-smoke.php b/tests/chat-run-control-multisite-smoke.php index c2250b6..42fc0e8 100644 --- a/tests/chat-run-control-multisite-smoke.php +++ b/tests/chat-run-control-multisite-smoke.php @@ -151,6 +151,25 @@ 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 ); +$GLOBALS['__agents_api_multisite_user'] = 123; $queued = agents_queue_chat_message( array( @@ -183,6 +202,29 @@ function wp_register_ability( string $ability, array $args ): void { 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', @@ -201,6 +243,8 @@ function wp_register_ability( string $ability, array $args ): void { 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 ); @@ -249,7 +293,6 @@ function wp_register_ability( string $ability, array $args ): void { 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 ); -$workspace_scope = WP_Agent_Workspace_Scope::from_array( $workspace ); $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', @@ -268,6 +311,8 @@ function wp_register_ability( string $ability, array $args ): void { $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 ); diff --git a/tests/chat-run-control-smoke.php b/tests/chat-run-control-smoke.php index 25dd5e0..1a9909e 100644 --- a/tests/chat-run-control-smoke.php +++ b/tests/chat-run-control-smoke.php @@ -203,11 +203,11 @@ 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' ); +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', From 9ccb8971764f0313ed7561a91914888ea99bb9a3 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 21 Jul 2026 12:34:04 +0000 Subject: [PATCH 3/4] fix: atomically bind chat session owners --- agents-api.php | 2 + .../class-wp-agent-chat-run-control.php | 30 +++++--- .../class-wp-agent-conversation-loop.php | 34 ++++++++- ...lass-wp-agent-option-run-control-store.php | 36 +++++++++- src/Runtime/class-wp-agent-run-control.php | 20 ++++++ ...face-wp-agent-atomic-run-control-store.php | 27 +++++++ ...ent-atomic-workspace-run-control-store.php | 32 +++++++++ tests/canonical-run-lifecycle-smoke.php | 10 ++- tests/chat-run-control-multisite-smoke.php | 71 +++++++++++++++++++ tests/chat-run-control-smoke.php | 11 +++ tests/run-outcome-status-smoke.php | 11 +++ 11 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 src/Runtime/interface-wp-agent-atomic-run-control-store.php create mode 100644 src/Runtime/interface-wp-agent-atomic-workspace-run-control-store.php diff --git a/agents-api.php b/agents-api.php index b84c493..bb2b660 100644 --- a/agents-api.php +++ b/agents-api.php @@ -231,6 +231,8 @@ require_once AGENTS_API_PATH . 'src/Runtime/register-runtime-tool-lifecycle-abilities.php'; require_once AGENTS_API_PATH . 'src/Runtime/interface-wp-agent-run-control-store.php'; require_once AGENTS_API_PATH . 'src/Runtime/interface-wp-agent-workspace-run-control-store.php'; +require_once AGENTS_API_PATH . 'src/Runtime/interface-wp-agent-atomic-run-control-store.php'; +require_once AGENTS_API_PATH . 'src/Runtime/interface-wp-agent-atomic-workspace-run-control-store.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-option-run-control-store.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-run-control.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-run-result-envelope.php'; diff --git a/src/Runtime/class-wp-agent-chat-run-control.php b/src/Runtime/class-wp-agent-chat-run-control.php index 727f0e9..2598a32 100644 --- a/src/Runtime/class-wp-agent-chat-run-control.php +++ b/src/Runtime/class-wp-agent-chat-run-control.php @@ -500,9 +500,9 @@ private static function session_owner_fingerprint( string $session_id, ?WP_Agent return new \WP_Error( 'agents_chat_run_owner_required', 'Explicit workspace run control requires an authenticated conversation owner.' ); } - $binding_key = hash( 'sha256', $session_id ); - $binding_state = WP_Agent_Run_Control::state( self::SESSION_OWNER_OPTION_KEY, $workspace ); - $binding = $binding_state['runs'][ $binding_key ] ?? null; + $binding_store_key = self::SESSION_OWNER_OPTION_KEY . '_' . hash( 'sha256', $session_id ); + $binding_state = WP_Agent_Run_Control::state( $binding_store_key, $workspace ); + $binding = $binding_state['runs']['binding'] ?? null; if ( is_array( $binding ) ) { $stored = is_string( $binding['_owner'] ?? null ) ? $binding['_owner'] : ''; if ( $session_id !== self::string_value( $binding['session_id'] ?? '' ) || ! self::fingerprint_matches( $stored, $fingerprint ) ) { @@ -533,13 +533,27 @@ private static function session_owner_fingerprint( string $session_id, ?WP_Agent return new \WP_Error( 'agents_chat_run_not_found', 'No chat run was found for the requested session.' ); } - $binding_state['runs'][ $binding_key ] = array( - 'session_id' => $session_id, - '_owner' => $fingerprint, + $initial_state = array( + 'runs' => array( + 'binding' => array( + 'session_id' => $session_id, + '_owner' => $fingerprint, + ), + ), + 'queues' => array(), + 'events' => array(), ); - WP_Agent_Run_Control::save_state( self::SESSION_OWNER_OPTION_KEY, $binding_state, $workspace ); + if ( WP_Agent_Run_Control::create_state_if_absent( $binding_store_key, $initial_state, $workspace ) ) { + return $fingerprint; + } + + $binding = WP_Agent_Run_Control::state( $binding_store_key, $workspace )['runs']['binding'] ?? null; + $stored = is_array( $binding ) && is_string( $binding['_owner'] ?? null ) ? $binding['_owner'] : ''; + if ( ! is_array( $binding ) || $session_id !== self::string_value( $binding['session_id'] ?? '' ) || ! self::fingerprint_matches( $stored, $fingerprint ) ) { + return new \WP_Error( 'agents_chat_run_owner_forbidden', 'The session is owned by another conversation principal.' ); + } - return $fingerprint; + return $stored; } /** @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..72696ab 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -129,15 +129,19 @@ 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 ); + $started = WP_Agent_Chat_Run_Control::start_run( $run_id, $lock_session_id, array( 'source' => 'conversation_loop' ), $run_workspace, $run_owner ); + 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 +1121,32 @@ private static function failure_result( array $messages, array $tool_results, ar ) ); } + /** + * Build a failure result when run ownership rejects execution before turn zero. + * + * @param array> $messages Normalized input messages. + * @return array Normalized conversation failure result. + */ + 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/Runtime/class-wp-agent-option-run-control-store.php b/src/Runtime/class-wp-agent-option-run-control-store.php index 848bb4f..9e775cb 100644 --- a/src/Runtime/class-wp-agent-option-run-control-store.php +++ b/src/Runtime/class-wp-agent-option-run-control-store.php @@ -11,14 +11,14 @@ defined( 'ABSPATH' ) || exit; -if ( ! interface_exists( WP_Agent_Workspace_Run_Control_Store::class ) ) { - require_once __DIR__ . '/interface-wp-agent-workspace-run-control-store.php'; +if ( ! interface_exists( WP_Agent_Atomic_Workspace_Run_Control_Store::class ) ) { + require_once __DIR__ . '/interface-wp-agent-atomic-workspace-run-control-store.php'; } /** * Persists run-control state in WordPress options. */ -class WP_Agent_Option_Run_Control_Store implements WP_Agent_Workspace_Run_Control_Store { +class WP_Agent_Option_Run_Control_Store implements WP_Agent_Atomic_Workspace_Run_Control_Store { /** * @param string $store_key Store key. @@ -47,6 +47,23 @@ public function save_state( string $store_key, array $state ): void { } } + /** + * Atomically create site-local state using the option-name unique key. + * + * @param array{runs:array>,queues:array>>,events:array>>} $state Initial state envelope. + */ + public function create_state_if_absent( string $store_key, array $state ): bool { + if ( ! function_exists( 'add_option' ) ) { + if ( ! function_exists( 'get_option' ) && ! function_exists( 'update_option' ) ) { + // Pure-PHP harnesses have no shared store or concurrent writer. + return true; + } + throw new \RuntimeException( 'The run-control store cannot atomically create site state.' ); + } + + return add_option( $store_key, $state, '', false ); + } + /** * Read explicit workspace state from the network-wide option table. * @@ -84,6 +101,19 @@ public function save_workspace_state( string $store_key, WP_Agent_Workspace_Scop update_site_option( $this->workspace_option_key( $store_key, $workspace ), $state ); } + /** + * Atomically create workspace state using the network option-name unique key. + * + * @param array{runs:array>,queues:array>>,events:array>>} $state Initial state envelope. + */ + public function create_workspace_state_if_absent( string $store_key, WP_Agent_Workspace_Scope $workspace, array $state ): bool { + if ( ! function_exists( 'add_site_option' ) ) { + throw new \RuntimeException( 'The run-control store cannot atomically create workspace state.' ); + } + + return add_site_option( $this->workspace_option_key( $store_key, $workspace ), $state ); + } + private function workspace_option_key( string $store_key, WP_Agent_Workspace_Scope $workspace ): string { return $store_key . '_workspace_' . hash( 'sha256', $workspace->key() ); } diff --git a/src/Runtime/class-wp-agent-run-control.php b/src/Runtime/class-wp-agent-run-control.php index 5457aba..bca3c47 100644 --- a/src/Runtime/class-wp-agent-run-control.php +++ b/src/Runtime/class-wp-agent-run-control.php @@ -420,6 +420,26 @@ public static function save_state( string $store_key, array $state, ?WP_Agent_Wo $store->save_workspace_state( $store_key, $workspace, $state ); } + /** + * Atomically create state when no state exists for the selected scope. + * + * @param array{runs:array>,queues:array>>,events:array>>} $state Initial state envelope. + */ + public static function create_state_if_absent( string $store_key, array $state, ?WP_Agent_Workspace_Scope $workspace = null ): bool { + $store = self::store(); + if ( null === $workspace ) { + if ( ! $store instanceof WP_Agent_Atomic_Run_Control_Store ) { + throw new \RuntimeException( 'The registered run-control store does not support atomic state creation.' ); + } + return $store->create_state_if_absent( $store_key, $state ); + } + if ( ! $store instanceof WP_Agent_Atomic_Workspace_Run_Control_Store ) { + throw new \RuntimeException( 'The registered run-control store does not support atomic workspace state creation.' ); + } + + return $store->create_workspace_state_if_absent( $store_key, $workspace, $state ); + } + public static function now(): string { return gmdate( 'c' ); } diff --git a/src/Runtime/interface-wp-agent-atomic-run-control-store.php b/src/Runtime/interface-wp-agent-atomic-run-control-store.php new file mode 100644 index 0000000..909aeeb --- /dev/null +++ b/src/Runtime/interface-wp-agent-atomic-run-control-store.php @@ -0,0 +1,27 @@ +>,queues:array>>,events:array>>} $state Initial state envelope. + */ + public function create_state_if_absent( string $store_key, array $state ): bool; + } +} diff --git a/src/Runtime/interface-wp-agent-atomic-workspace-run-control-store.php b/src/Runtime/interface-wp-agent-atomic-workspace-run-control-store.php new file mode 100644 index 0000000..d14b184 --- /dev/null +++ b/src/Runtime/interface-wp-agent-atomic-workspace-run-control-store.php @@ -0,0 +1,32 @@ +>,queues:array>>,events:array>>} $state Initial state envelope. + */ + public function create_workspace_state_if_absent( string $store_key, WP_Agent_Workspace_Scope $workspace, array $state ): bool; + } +} diff --git a/tests/canonical-run-lifecycle-smoke.php b/tests/canonical-run-lifecycle-smoke.php index 17de85f..a5d11cb 100644 --- a/tests/canonical-run-lifecycle-smoke.php +++ b/tests/canonical-run-lifecycle-smoke.php @@ -36,7 +36,7 @@ function current_user_can( string $capability ): bool { agents_api_smoke_require_module(); -final class Agents_API_Smoke_Run_Control_Store implements AgentsAPI\AI\WP_Agent_Run_Control_Store { +final class Agents_API_Smoke_Run_Control_Store implements AgentsAPI\AI\WP_Agent_Atomic_Run_Control_Store { /** @var array>,queues:array>>,events:array>>}> */ private array $states = array(); @@ -55,6 +55,14 @@ public function save_state( string $store_key, array $state ): void { 'events' => is_array( $state['events'] ?? null ) ? $state['events'] : array(), ); } + + public function create_state_if_absent( string $store_key, array $state ): bool { + if ( isset( $this->states[ $store_key ] ) ) { + return false; + } + $this->save_state( $store_key, $state ); + return true; + } } AgentsAPI\AI\WP_Agent_Run_Control::set_store( new Agents_API_Smoke_Run_Control_Store() ); diff --git a/tests/chat-run-control-multisite-smoke.php b/tests/chat-run-control-multisite-smoke.php index 42fc0e8..1177c7f 100644 --- a/tests/chat-run-control-multisite-smoke.php +++ b/tests/chat-run-control-multisite-smoke.php @@ -25,6 +25,8 @@ $GLOBALS['__agents_api_multisite_options'] = array(); $GLOBALS['__agents_api_multisite_network'] = array(); $GLOBALS['__agents_api_multisite_user'] = 123; +$GLOBALS['__agents_api_site_add_before'] = null; +$GLOBALS['__agents_api_network_add_before'] = null; if ( ! class_exists( 'WP_Error' ) ) { class WP_Error { @@ -63,6 +65,21 @@ function update_option( string $option, $value, $autoload = null ): bool { return true; } +function add_option( string $option, $value = '', $deprecated = '', $autoload = null ): bool { + unset( $deprecated, $autoload ); + $before = $GLOBALS['__agents_api_site_add_before']; + $GLOBALS['__agents_api_site_add_before'] = null; + if ( is_callable( $before ) ) { + $before(); + } + $blog_id = get_current_blog_id(); + if ( array_key_exists( $option, $GLOBALS['__agents_api_multisite_options'][ $blog_id ] ?? array() ) ) { + return false; + } + $GLOBALS['__agents_api_multisite_options'][ $blog_id ][ $option ] = $value; + return true; +} + function get_site_option( string $option, $default = false ) { return $GLOBALS['__agents_api_multisite_network'][ $option ] ?? $default; } @@ -72,6 +89,19 @@ function update_site_option( string $option, $value ): bool { return true; } +function add_site_option( string $option, $value ): bool { + $before = $GLOBALS['__agents_api_network_add_before']; + $GLOBALS['__agents_api_network_add_before'] = null; + if ( is_callable( $before ) ) { + $before(); + } + if ( array_key_exists( $option, $GLOBALS['__agents_api_multisite_network'] ) ) { + return false; + } + $GLOBALS['__agents_api_multisite_network'][ $option ] = $value; + return true; +} + function get_current_user_id(): int { return (int) $GLOBALS['__agents_api_multisite_user']; } @@ -103,6 +133,8 @@ 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_Message; use AgentsAPI\AI\WP_Agent_Execution_Principal; use AgentsAPI\AI\WP_Agent_Run_Control; use AgentsAPI\AI\WP_Agent_Run_Control_Store; @@ -169,6 +201,24 @@ function wp_register_ability( string $ability, array $args ): void { 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, + ) +); +agents_api_smoke_assert_equals( 'failed', $foreign_loop['status'] ?? null, 'conversation loop surfaces 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 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( @@ -341,6 +391,27 @@ function wp_register_ability( string $ability, array $args ): void { $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 ); +$site_race_nested = null; +$GLOBALS['__agents_api_site_add_before'] = static function () use ( &$site_race_nested, $other_owner ): void { + $site_race_nested = WP_Agent_Chat_Run_Control::start_run( 'site-race-foreign', 'site-race-session', array(), null, $other_owner ); +}; +$site_race_outer = WP_Agent_Chat_Run_Control::start_run( 'site-race-owner', 'site-race-session', array(), null, $owner ); +agents_api_smoke_assert_equals( false, $site_race_nested instanceof WP_Error, 'site-local atomic create admits one interleaved first owner', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_chat_run_owner_forbidden', $site_race_outer instanceof WP_Error ? $site_race_outer->get_error_code() : '', 'site-local atomic create rejects the losing concurrent owner', $failures, $passes ); +agents_api_smoke_assert_equals( null, WP_Agent_Chat_Run_Control::get_run( 'site-race-owner', null, $owner ), 'site-local losing run is never persisted', $failures, $passes ); +agents_api_smoke_assert_equals( 'site-race-foreign', WP_Agent_Chat_Run_Control::get_run( 'site-race-foreign', null, $other_owner )['run_id'] ?? null, 'site-local winning owner remains canonical', $failures, $passes ); + +$workspace_race_nested = null; +$GLOBALS['__agents_api_network_add_before'] = static function () use ( &$workspace_race_nested, $other_owner, $workspace_scope ): void { + $workspace_race_nested = WP_Agent_Chat_Run_Control::start_run( 'workspace-race-foreign', 'workspace-race-session', array(), $workspace_scope, $other_owner ); +}; +$workspace_race_outer = WP_Agent_Chat_Run_Control::start_run( 'workspace-race-owner', 'workspace-race-session', array(), $workspace_scope, $owner ); +agents_api_smoke_assert_equals( false, $workspace_race_nested instanceof WP_Error, 'workspace atomic create admits one interleaved first owner', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_chat_run_owner_forbidden', $workspace_race_outer instanceof WP_Error ? $workspace_race_outer->get_error_code() : '', 'workspace atomic create rejects the losing concurrent owner', $failures, $passes ); +agents_api_smoke_assert_equals( null, WP_Agent_Chat_Run_Control::get_run( 'workspace-race-owner', $workspace_scope, $owner ), 'workspace losing run is never persisted', $failures, $passes ); +agents_api_smoke_assert_equals( 'workspace-race-foreign', WP_Agent_Chat_Run_Control::get_run( 'workspace-race-foreign', $workspace_scope, $other_owner )['run_id'] ?? null, 'workspace winning owner remains canonical', $failures, $passes ); +agents_api_smoke_assert_equals( 1, get_current_blog_id(), 'atomic site and workspace interleavings preserve blog context', $failures, $passes ); + $unsupported_store = new class() implements WP_Agent_Run_Control_Store { public function get_state( string $store_key ): array { unset( $store_key ); diff --git a/tests/chat-run-control-smoke.php b/tests/chat-run-control-smoke.php index 1a9909e..0944cc8 100644 --- a/tests/chat-run-control-smoke.php +++ b/tests/chat-run-control-smoke.php @@ -87,6 +87,17 @@ function update_option( string $option, $value, $autoload = null ): bool { } } +if ( ! function_exists( 'add_option' ) ) { + function add_option( string $option, $value = '', $deprecated = '', $autoload = null ): bool { + unset( $deprecated, $autoload ); + if ( array_key_exists( $option, $GLOBALS['__agents_api_smoke_options'] ) ) { + return false; + } + $GLOBALS['__agents_api_smoke_options'][ $option ] = $value; + return true; + } +} + agents_api_smoke_require_module(); do_action( 'wp_abilities_api_categories_init' ); diff --git a/tests/run-outcome-status-smoke.php b/tests/run-outcome-status-smoke.php index 53a9f51..db0867a 100644 --- a/tests/run-outcome-status-smoke.php +++ b/tests/run-outcome-status-smoke.php @@ -34,6 +34,17 @@ function update_option( string $option, $value, $autoload = null ): bool { } } +if ( ! function_exists( 'add_option' ) ) { + function add_option( string $option, $value = '', $deprecated = '', $autoload = null ): bool { + unset( $deprecated, $autoload ); + if ( array_key_exists( $option, $GLOBALS['__agents_api_smoke_options'] ) ) { + return false; + } + $GLOBALS['__agents_api_smoke_options'][ $option ] = $value; + return true; + } +} + agents_api_smoke_require_module(); $executor = new class() implements AgentsAPI\AI\Tools\WP_Agent_Tool_Executor { From 5943fd764f5d31987443e3125c231791faceeaec Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 21 Jul 2026 13:36:57 +0000 Subject: [PATCH 4/4] fix: authorize chat runs from session store --- agents-api.php | 2 - src/Channels/register-agents-chat-ability.php | 4 +- ...ster-agents-chat-run-control-abilities.php | 6 +- .../register-default-agents-chat-handler.php | 1 + .../class-wp-agent-chat-run-control.php | 101 +++++++------- .../class-wp-agent-conversation-loop.php | 7 +- ...lass-wp-agent-option-run-control-store.php | 36 +---- src/Runtime/class-wp-agent-run-control.php | 20 --- ...face-wp-agent-atomic-run-control-store.php | 27 ---- ...ent-atomic-workspace-run-control-store.php | 32 ----- .../class-wp-agent-conversation-sessions.php | 46 +++++++ tests/agents-chat-ability-smoke.php | 40 ++++++ tests/canonical-run-lifecycle-smoke.php | 10 +- tests/chat-run-control-multisite-smoke.php | 130 +++++++++++------- tests/chat-run-control-smoke.php | 46 +++++-- ...versation-session-store-contract-smoke.php | 4 + tests/run-outcome-status-smoke.php | 11 -- 17 files changed, 267 insertions(+), 256 deletions(-) delete mode 100644 src/Runtime/interface-wp-agent-atomic-run-control-store.php delete mode 100644 src/Runtime/interface-wp-agent-atomic-workspace-run-control-store.php diff --git a/agents-api.php b/agents-api.php index bb2b660..b84c493 100644 --- a/agents-api.php +++ b/agents-api.php @@ -231,8 +231,6 @@ require_once AGENTS_API_PATH . 'src/Runtime/register-runtime-tool-lifecycle-abilities.php'; require_once AGENTS_API_PATH . 'src/Runtime/interface-wp-agent-run-control-store.php'; require_once AGENTS_API_PATH . 'src/Runtime/interface-wp-agent-workspace-run-control-store.php'; -require_once AGENTS_API_PATH . 'src/Runtime/interface-wp-agent-atomic-run-control-store.php'; -require_once AGENTS_API_PATH . 'src/Runtime/interface-wp-agent-atomic-workspace-run-control-store.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-option-run-control-store.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-run-control.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-run-result-envelope.php'; diff --git a/src/Channels/register-agents-chat-ability.php b/src/Channels/register-agents-chat-ability.php index 00631f7..b8e881c 100644 --- a/src/Channels/register-agents-chat-ability.php +++ b/src/Channels/register-agents-chat-ability.php @@ -178,7 +178,7 @@ function agents_chat_dispatch( array $input ) { $agent = agents_chat_optional_string( $input['agent'] ?? null ) ?? ''; if ( null !== $session_id ) { try { - $started = 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() ); } @@ -224,7 +224,7 @@ 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 ) { - $started = 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; diff --git a/src/Channels/register-agents-chat-run-control-abilities.php b/src/Channels/register-agents-chat-run-control-abilities.php index 49b1796..3335512 100644 --- a/src/Channels/register-agents-chat-run-control-abilities.php +++ b/src/Channels/register-agents-chat-run-control-abilities.php @@ -233,7 +233,7 @@ function agents_queue_chat_message( array $input ) { $input['workspace'] = $context['workspace']->to_array(); } try { - if ( ! WP_Agent_Chat_Run_Control::can_queue_message( $input, $context['workspace'], $context['owner'] ) ) { + 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 ) { @@ -248,7 +248,7 @@ function agents_queue_chat_message( array $input ) { $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() ); } @@ -293,7 +293,7 @@ function agents_chat_run_enqueue_permission( array $input ): bool { $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'] ); + $allowed = WP_Agent_Chat_Run_Control::can_queue_message( $input, $context['workspace'], $context['owner'], $context['conversation_store'] ); } catch ( \RuntimeException $error ) { unset( $error ); $allowed = false; 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 2598a32..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; @@ -28,7 +30,6 @@ class WP_Agent_Chat_Run_Control { public const STATUS_STALLED = 'stalled'; public const STATUS_INTERRUPTED = 'interrupted'; private const OPTION_KEY = 'agents_api_chat_run_control'; - private const SESSION_OWNER_OPTION_KEY = 'agents_api_chat_session_owners'; /** @return string[] */ public static function statuses(): array { @@ -48,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; @@ -113,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 ) ), ); } @@ -185,8 +187,8 @@ public static function normalize_status( mixed $status ): string { * @param array|null $owner Canonical conversation owner. * @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 ) { - $canonical_owner = self::session_owner_fingerprint( $session_id, $workspace, $owner, true ); + 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; } @@ -279,13 +281,13 @@ public static function cancellation_interrupt_for_run( string $run_id, string $s * @param array|null $owner Canonical conversation owner. * @return array|\WP_Error Queue result. */ - public static function queue_message( array $input, ?WP_Agent_Workspace_Scope $workspace = null, ?array $owner = null ) { + 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 ) ); if ( '' === $session_id ) { throw new \InvalidArgumentException( 'session_id must be a non-empty string' ); } - $target = self::queue_target( $input, $workspace, $owner ); + $target = self::queue_target( $input, $workspace, $owner, $conversation_store ); if ( $target instanceof \WP_Error ) { return $target; } @@ -327,8 +329,8 @@ 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 { - $canonical_owner = self::session_owner_fingerprint( $session_id, $workspace, $owner, false ); + 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(); } @@ -349,8 +351,8 @@ public static function claim_queued_messages( string $session_id, ?WP_Agent_Work * @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 ): bool { - return ! ( self::queue_target( $input, $workspace, $owner ) instanceof \WP_Error ); + 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 ); } /** @@ -428,7 +430,8 @@ 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 { @@ -443,11 +446,11 @@ private static function fingerprint_matches( mixed $stored, string $expected ): * @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 ) { + 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, false ); + $canonical_owner = self::session_owner_fingerprint( $session_id, $workspace, $owner, $conversation_store ); if ( $canonical_owner instanceof \WP_Error ) { return $canonical_owner; } @@ -482,17 +485,15 @@ private static function queue_target( array $input, ?WP_Agent_Workspace_Scope $w } /** - * Resolve or create the immutable owner binding for a session. - * - * Existing runs are consulted only to migrate pre-binding state. Conflicting - * historical owners fail closed without changing the binding or queue. + * 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, bool $create ) { + 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.' ); } @@ -500,17 +501,33 @@ private static function session_owner_fingerprint( string $session_id, ?WP_Agent return new \WP_Error( 'agents_chat_run_owner_required', 'Explicit workspace run control requires an authenticated conversation owner.' ); } - $binding_store_key = self::SESSION_OWNER_OPTION_KEY . '_' . hash( 'sha256', $session_id ); - $binding_state = WP_Agent_Run_Control::state( $binding_store_key, $workspace ); - $binding = $binding_state['runs']['binding'] ?? null; - if ( is_array( $binding ) ) { - $stored = is_string( $binding['_owner'] ?? null ) ? $binding['_owner'] : ''; - if ( $session_id !== self::string_value( $binding['session_id'] ?? '' ) || ! self::fingerprint_matches( $stored, $fingerprint ) ) { - return new \WP_Error( 'agents_chat_run_owner_forbidden', 'The session is owned by another conversation principal.' ); + $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.' ); } - return $stored; + $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'] ?? '' ) ) { @@ -529,31 +546,11 @@ private static function session_owner_fingerprint( string $session_id, ?WP_Agent return new \WP_Error( 'agents_chat_run_owner_forbidden', 'The session is owned by another conversation principal.' ); } $fingerprint = $stored; - } elseif ( ! $create ) { - return new \WP_Error( 'agents_chat_run_not_found', 'No chat run was found for the requested session.' ); - } - - $initial_state = array( - 'runs' => array( - 'binding' => array( - 'session_id' => $session_id, - '_owner' => $fingerprint, - ), - ), - 'queues' => array(), - 'events' => array(), - ); - if ( WP_Agent_Run_Control::create_state_if_absent( $binding_store_key, $initial_state, $workspace ) ) { - return $fingerprint; - } - - $binding = WP_Agent_Run_Control::state( $binding_store_key, $workspace )['runs']['binding'] ?? null; - $stored = is_array( $binding ) && is_string( $binding['_owner'] ?? null ) ? $binding['_owner'] : ''; - if ( ! is_array( $binding ) || $session_id !== self::string_value( $binding['session_id'] ?? '' ) || ! self::fingerprint_matches( $stored, $fingerprint ) ) { - return new \WP_Error( 'agents_chat_run_owner_forbidden', 'The session is owned by another conversation principal.' ); + } elseif ( '' !== $fingerprint ) { + return new \WP_Error( 'agents_chat_run_session_store_required', 'Principal-owned run control requires an authoritative conversation session store.' ); } - return $stored; + 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 72696ab..0a011f2 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -135,7 +135,8 @@ public static function run( array $messages, ?callable $turn_runner = null, arra self::emit_tool_declaration_diagnostics( $on_event, $rejected_declarations, $tool_declarations, $tool_executor ); $messages = self::normalize_messages( $messages ); if ( '' !== $run_id && '' !== $lock_session_id ) { - $started = 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 ); @@ -1122,10 +1123,10 @@ private static function failure_result( array $messages, array $tool_results, ar } /** - * Build a failure result when run ownership rejects execution before turn zero. + * Build a failure result when canonical session ownership rejects turn zero. * * @param array> $messages Normalized input messages. - * @return array Normalized conversation failure result. + * @return array */ private static function run_control_failure_result( array $messages, \WP_Error $error ): array { return self::normalize_conversation_result( array( diff --git a/src/Runtime/class-wp-agent-option-run-control-store.php b/src/Runtime/class-wp-agent-option-run-control-store.php index 9e775cb..848bb4f 100644 --- a/src/Runtime/class-wp-agent-option-run-control-store.php +++ b/src/Runtime/class-wp-agent-option-run-control-store.php @@ -11,14 +11,14 @@ defined( 'ABSPATH' ) || exit; -if ( ! interface_exists( WP_Agent_Atomic_Workspace_Run_Control_Store::class ) ) { - require_once __DIR__ . '/interface-wp-agent-atomic-workspace-run-control-store.php'; +if ( ! interface_exists( WP_Agent_Workspace_Run_Control_Store::class ) ) { + require_once __DIR__ . '/interface-wp-agent-workspace-run-control-store.php'; } /** * Persists run-control state in WordPress options. */ -class WP_Agent_Option_Run_Control_Store implements WP_Agent_Atomic_Workspace_Run_Control_Store { +class WP_Agent_Option_Run_Control_Store implements WP_Agent_Workspace_Run_Control_Store { /** * @param string $store_key Store key. @@ -47,23 +47,6 @@ public function save_state( string $store_key, array $state ): void { } } - /** - * Atomically create site-local state using the option-name unique key. - * - * @param array{runs:array>,queues:array>>,events:array>>} $state Initial state envelope. - */ - public function create_state_if_absent( string $store_key, array $state ): bool { - if ( ! function_exists( 'add_option' ) ) { - if ( ! function_exists( 'get_option' ) && ! function_exists( 'update_option' ) ) { - // Pure-PHP harnesses have no shared store or concurrent writer. - return true; - } - throw new \RuntimeException( 'The run-control store cannot atomically create site state.' ); - } - - return add_option( $store_key, $state, '', false ); - } - /** * Read explicit workspace state from the network-wide option table. * @@ -101,19 +84,6 @@ public function save_workspace_state( string $store_key, WP_Agent_Workspace_Scop update_site_option( $this->workspace_option_key( $store_key, $workspace ), $state ); } - /** - * Atomically create workspace state using the network option-name unique key. - * - * @param array{runs:array>,queues:array>>,events:array>>} $state Initial state envelope. - */ - public function create_workspace_state_if_absent( string $store_key, WP_Agent_Workspace_Scope $workspace, array $state ): bool { - if ( ! function_exists( 'add_site_option' ) ) { - throw new \RuntimeException( 'The run-control store cannot atomically create workspace state.' ); - } - - return add_site_option( $this->workspace_option_key( $store_key, $workspace ), $state ); - } - private function workspace_option_key( string $store_key, WP_Agent_Workspace_Scope $workspace ): string { return $store_key . '_workspace_' . hash( 'sha256', $workspace->key() ); } diff --git a/src/Runtime/class-wp-agent-run-control.php b/src/Runtime/class-wp-agent-run-control.php index bca3c47..5457aba 100644 --- a/src/Runtime/class-wp-agent-run-control.php +++ b/src/Runtime/class-wp-agent-run-control.php @@ -420,26 +420,6 @@ public static function save_state( string $store_key, array $state, ?WP_Agent_Wo $store->save_workspace_state( $store_key, $workspace, $state ); } - /** - * Atomically create state when no state exists for the selected scope. - * - * @param array{runs:array>,queues:array>>,events:array>>} $state Initial state envelope. - */ - public static function create_state_if_absent( string $store_key, array $state, ?WP_Agent_Workspace_Scope $workspace = null ): bool { - $store = self::store(); - if ( null === $workspace ) { - if ( ! $store instanceof WP_Agent_Atomic_Run_Control_Store ) { - throw new \RuntimeException( 'The registered run-control store does not support atomic state creation.' ); - } - return $store->create_state_if_absent( $store_key, $state ); - } - if ( ! $store instanceof WP_Agent_Atomic_Workspace_Run_Control_Store ) { - throw new \RuntimeException( 'The registered run-control store does not support atomic workspace state creation.' ); - } - - return $store->create_workspace_state_if_absent( $store_key, $workspace, $state ); - } - public static function now(): string { return gmdate( 'c' ); } diff --git a/src/Runtime/interface-wp-agent-atomic-run-control-store.php b/src/Runtime/interface-wp-agent-atomic-run-control-store.php deleted file mode 100644 index 909aeeb..0000000 --- a/src/Runtime/interface-wp-agent-atomic-run-control-store.php +++ /dev/null @@ -1,27 +0,0 @@ ->,queues:array>>,events:array>>} $state Initial state envelope. - */ - public function create_state_if_absent( string $store_key, array $state ): bool; - } -} diff --git a/src/Runtime/interface-wp-agent-atomic-workspace-run-control-store.php b/src/Runtime/interface-wp-agent-atomic-workspace-run-control-store.php deleted file mode 100644 index d14b184..0000000 --- a/src/Runtime/interface-wp-agent-atomic-workspace-run-control-store.php +++ /dev/null @@ -1,32 +0,0 @@ ->,queues:array>>,events:array>>} $state Initial state envelope. - */ - public function create_workspace_state_if_absent( string $store_key, WP_Agent_Workspace_Scope $workspace, array $state ): bool; - } -} 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/canonical-run-lifecycle-smoke.php b/tests/canonical-run-lifecycle-smoke.php index a5d11cb..17de85f 100644 --- a/tests/canonical-run-lifecycle-smoke.php +++ b/tests/canonical-run-lifecycle-smoke.php @@ -36,7 +36,7 @@ function current_user_can( string $capability ): bool { agents_api_smoke_require_module(); -final class Agents_API_Smoke_Run_Control_Store implements AgentsAPI\AI\WP_Agent_Atomic_Run_Control_Store { +final class Agents_API_Smoke_Run_Control_Store implements AgentsAPI\AI\WP_Agent_Run_Control_Store { /** @var array>,queues:array>>,events:array>>}> */ private array $states = array(); @@ -55,14 +55,6 @@ public function save_state( string $store_key, array $state ): void { 'events' => is_array( $state['events'] ?? null ) ? $state['events'] : array(), ); } - - public function create_state_if_absent( string $store_key, array $state ): bool { - if ( isset( $this->states[ $store_key ] ) ) { - return false; - } - $this->save_state( $store_key, $state ); - return true; - } } AgentsAPI\AI\WP_Agent_Run_Control::set_store( new Agents_API_Smoke_Run_Control_Store() ); diff --git a/tests/chat-run-control-multisite-smoke.php b/tests/chat-run-control-multisite-smoke.php index 1177c7f..77bfe41 100644 --- a/tests/chat-run-control-multisite-smoke.php +++ b/tests/chat-run-control-multisite-smoke.php @@ -25,8 +25,6 @@ $GLOBALS['__agents_api_multisite_options'] = array(); $GLOBALS['__agents_api_multisite_network'] = array(); $GLOBALS['__agents_api_multisite_user'] = 123; -$GLOBALS['__agents_api_site_add_before'] = null; -$GLOBALS['__agents_api_network_add_before'] = null; if ( ! class_exists( 'WP_Error' ) ) { class WP_Error { @@ -65,21 +63,6 @@ function update_option( string $option, $value, $autoload = null ): bool { return true; } -function add_option( string $option, $value = '', $deprecated = '', $autoload = null ): bool { - unset( $deprecated, $autoload ); - $before = $GLOBALS['__agents_api_site_add_before']; - $GLOBALS['__agents_api_site_add_before'] = null; - if ( is_callable( $before ) ) { - $before(); - } - $blog_id = get_current_blog_id(); - if ( array_key_exists( $option, $GLOBALS['__agents_api_multisite_options'][ $blog_id ] ?? array() ) ) { - return false; - } - $GLOBALS['__agents_api_multisite_options'][ $blog_id ][ $option ] = $value; - return true; -} - function get_site_option( string $option, $default = false ) { return $GLOBALS['__agents_api_multisite_network'][ $option ] ?? $default; } @@ -89,19 +72,6 @@ function update_site_option( string $option, $value ): bool { return true; } -function add_site_option( string $option, $value ): bool { - $before = $GLOBALS['__agents_api_network_add_before']; - $GLOBALS['__agents_api_network_add_before'] = null; - if ( is_callable( $before ) ) { - $before(); - } - if ( array_key_exists( $option, $GLOBALS['__agents_api_multisite_network'] ) ) { - return false; - } - $GLOBALS['__agents_api_multisite_network'][ $option ] = $value; - return true; -} - function get_current_user_id(): int { return (int) $GLOBALS['__agents_api_multisite_user']; } @@ -134,10 +104,11 @@ function wp_register_ability( string $ability, array $args ): void { use AgentsAPI\AI\WP_Agent_Chat_Run_Control; use AgentsAPI\AI\WP_Agent_Conversation_Loop; -use AgentsAPI\AI\WP_Agent_Message; 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; @@ -157,6 +128,65 @@ function wp_register_ability( string $ability, array $args ): void { $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', @@ -214,10 +244,11 @@ static function () use ( &$foreign_turns ): array { '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 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 owner-conflict error code', $failures, $passes ); +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; @@ -391,26 +422,21 @@ static function () use ( &$foreign_turns ): array { $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 ); -$site_race_nested = null; -$GLOBALS['__agents_api_site_add_before'] = static function () use ( &$site_race_nested, $other_owner ): void { - $site_race_nested = WP_Agent_Chat_Run_Control::start_run( 'site-race-foreign', 'site-race-session', array(), null, $other_owner ); -}; -$site_race_outer = WP_Agent_Chat_Run_Control::start_run( 'site-race-owner', 'site-race-session', array(), null, $owner ); -agents_api_smoke_assert_equals( false, $site_race_nested instanceof WP_Error, 'site-local atomic create admits one interleaved first owner', $failures, $passes ); -agents_api_smoke_assert_equals( 'agents_chat_run_owner_forbidden', $site_race_outer instanceof WP_Error ? $site_race_outer->get_error_code() : '', 'site-local atomic create rejects the losing concurrent owner', $failures, $passes ); -agents_api_smoke_assert_equals( null, WP_Agent_Chat_Run_Control::get_run( 'site-race-owner', null, $owner ), 'site-local losing run is never persisted', $failures, $passes ); -agents_api_smoke_assert_equals( 'site-race-foreign', WP_Agent_Chat_Run_Control::get_run( 'site-race-foreign', null, $other_owner )['run_id'] ?? null, 'site-local winning owner remains canonical', $failures, $passes ); - -$workspace_race_nested = null; -$GLOBALS['__agents_api_network_add_before'] = static function () use ( &$workspace_race_nested, $other_owner, $workspace_scope ): void { - $workspace_race_nested = WP_Agent_Chat_Run_Control::start_run( 'workspace-race-foreign', 'workspace-race-session', array(), $workspace_scope, $other_owner ); +$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 ); }; -$workspace_race_outer = WP_Agent_Chat_Run_Control::start_run( 'workspace-race-owner', 'workspace-race-session', array(), $workspace_scope, $owner ); -agents_api_smoke_assert_equals( false, $workspace_race_nested instanceof WP_Error, 'workspace atomic create admits one interleaved first owner', $failures, $passes ); -agents_api_smoke_assert_equals( 'agents_chat_run_owner_forbidden', $workspace_race_outer instanceof WP_Error ? $workspace_race_outer->get_error_code() : '', 'workspace atomic create rejects the losing concurrent owner', $failures, $passes ); -agents_api_smoke_assert_equals( null, WP_Agent_Chat_Run_Control::get_run( 'workspace-race-owner', $workspace_scope, $owner ), 'workspace losing run is never persisted', $failures, $passes ); -agents_api_smoke_assert_equals( 'workspace-race-foreign', WP_Agent_Chat_Run_Control::get_run( 'workspace-race-foreign', $workspace_scope, $other_owner )['run_id'] ?? null, 'workspace winning owner remains canonical', $failures, $passes ); -agents_api_smoke_assert_equals( 1, get_current_blog_id(), 'atomic site and workspace interleavings preserve blog context', $failures, $passes ); +$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 0944cc8..9e38f6d 100644 --- a/tests/chat-run-control-smoke.php +++ b/tests/chat-run-control-smoke.php @@ -87,18 +87,44 @@ function update_option( string $option, $value, $autoload = null ): bool { } } -if ( ! function_exists( 'add_option' ) ) { - function add_option( string $option, $value = '', $deprecated = '', $autoload = null ): bool { - unset( $deprecated, $autoload ); - if ( array_key_exists( $option, $GLOBALS['__agents_api_smoke_options'] ) ) { - return false; - } - $GLOBALS['__agents_api_smoke_options'][ $option ] = $value; +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; } -} - -agents_api_smoke_require_module(); + 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' ); 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 ); diff --git a/tests/run-outcome-status-smoke.php b/tests/run-outcome-status-smoke.php index db0867a..53a9f51 100644 --- a/tests/run-outcome-status-smoke.php +++ b/tests/run-outcome-status-smoke.php @@ -34,17 +34,6 @@ function update_option( string $option, $value, $autoload = null ): bool { } } -if ( ! function_exists( 'add_option' ) ) { - function add_option( string $option, $value = '', $deprecated = '', $autoload = null ): bool { - unset( $deprecated, $autoload ); - if ( array_key_exists( $option, $GLOBALS['__agents_api_smoke_options'] ) ) { - return false; - } - $GLOBALS['__agents_api_smoke_options'][ $option ] = $value; - return true; - } -} - agents_api_smoke_require_module(); $executor = new class() implements AgentsAPI\AI\Tools\WP_Agent_Tool_Executor {