diff --git a/agents-api.php b/agents-api.php index ded0a48..7d13c38 100644 --- a/agents-api.php +++ b/agents-api.php @@ -38,6 +38,7 @@ require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-capability-ceiling.php'; require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-access-grant.php'; require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-access-store.php'; +require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-principal-access-store.php'; require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-access.php'; require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-token.php'; require_once AGENTS_API_PATH . 'src/Auth/class-wp-agent-token-store.php'; diff --git a/src/Auth/class-wp-agent-access-grant.php b/src/Auth/class-wp-agent-access-grant.php index b14e518..54026a6 100644 --- a/src/Auth/class-wp-agent-access-grant.php +++ b/src/Auth/class-wp-agent-access-grant.php @@ -9,7 +9,7 @@ if ( ! class_exists( 'WP_Agent_Access_Grant' ) ) { /** - * Role-based access grant between a WordPress user and an agent. + * Role-based access grant between a WordPress user/audience and an agent. */ final class WP_Agent_Access_Grant { @@ -26,6 +26,7 @@ final class WP_Agent_Access_Grant { * @param int|null $granted_by_user_id Optional WordPress user ID that created the grant. * @param string|null $granted_at Optional UTC datetime string. * @param array $metadata Host-owned metadata. + * @param string|null $audience_id Optional non-user audience receiving access. */ public function __construct( public readonly string $agent_id, @@ -36,13 +37,22 @@ public function __construct( public readonly ?int $granted_by_user_id = null, public readonly ?string $granted_at = null, public readonly array $metadata = array(), + public readonly ?string $audience_id = null, ) { if ( '' === trim( $this->agent_id ) ) { throw self::invalid( 'agent_id', 'must be a non-empty string' ); } - if ( $this->user_id <= 0 ) { - throw self::invalid( 'user_id', 'must be a positive integer' ); + if ( $this->user_id < 0 ) { + throw self::invalid( 'user_id', 'must be zero or a positive integer' ); + } + + if ( 0 === $this->user_id && null === $this->audience_id ) { + throw self::invalid( 'user_id', 'must be positive unless audience_id is present' ); + } + + if ( null !== $this->audience_id && '' === trim( $this->audience_id ) ) { + throw self::invalid( 'audience_id', 'must be null or a non-empty string' ); } if ( null !== $this->grant_id && $this->grant_id <= 0 ) { @@ -94,7 +104,8 @@ public static function from_array( array $grant ): self { isset( $grant['grant_id'] ) ? (int) $grant['grant_id'] : null, isset( $grant['granted_by_user_id'] ) ? (int) $grant['granted_by_user_id'] : null, array_key_exists( 'granted_at', $grant ) && null !== $grant['granted_at'] ? (string) $grant['granted_at'] : null, - isset( $grant['metadata'] ) && is_array( $grant['metadata'] ) ? $grant['metadata'] : array() + isset( $grant['metadata'] ) && is_array( $grant['metadata'] ) ? $grant['metadata'] : array(), + array_key_exists( 'audience_id', $grant ) && null !== $grant['audience_id'] ? (string) $grant['audience_id'] : null ); } @@ -124,6 +135,7 @@ public function to_array(): array { 'granted_by_user_id' => $this->granted_by_user_id, 'granted_at' => $this->granted_at, 'metadata' => $this->metadata, + 'audience_id' => $this->audience_id, ); } diff --git a/src/Auth/class-wp-agent-access.php b/src/Auth/class-wp-agent-access.php index c13532c..266dcd5 100644 --- a/src/Auth/class-wp-agent-access.php +++ b/src/Auth/class-wp-agent-access.php @@ -14,6 +14,7 @@ final class WP_Agent_Access { private const CURRENT_USER_EFFECTIVE_AGENT_ID = '__wordpress_user__'; + private const PUBLIC_AUDIENCE_ID = 'audience:public'; /** * Resolve the host-provided access store. @@ -46,7 +47,18 @@ public static function get_current_principal( array $context = array() ): ?Agent $user_id = self::get_current_user_id(); if ( $user_id <= 0 ) { - return null; + if ( array_key_exists( 'allow_anonymous_audience', $context ) && false === (bool) $context['allow_anonymous_audience'] ) { + return null; + } + + return AgentsAPI\AI\WP_Agent_Execution_Principal::audience( + self::PUBLIC_AUDIENCE_ID, + self::PUBLIC_AUDIENCE_ID, + isset( $context['request_context'] ) ? (string) $context['request_context'] : AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + isset( $context['request_metadata'] ) && is_array( $context['request_metadata'] ) ? $context['request_metadata'] : array(), + array_key_exists( 'workspace_id', $context ) && null !== $context['workspace_id'] ? (string) $context['workspace_id'] : null, + array_key_exists( 'client_id', $context ) && null !== $context['client_id'] ? (string) $context['client_id'] : null + ); } return AgentsAPI\AI\WP_Agent_Execution_Principal::user_session( @@ -121,11 +133,13 @@ public static function list_accessible_agents_for_principal( AgentsAPI\AI\WP_Age $agent_ids = array(); $store = self::get_store( $context ); - if ( $store instanceof WP_Agent_Access_Store ) { + if ( $store instanceof WP_Agent_Principal_Access_Store ) { + $agent_ids = $store->get_agent_ids_for_principal( $principal, $minimum_role, $principal->workspace_id ); + } elseif ( $store instanceof WP_Agent_Access_Store && $principal->acting_user_id > 0 ) { $agent_ids = $store->get_agent_ids_for_user( $principal->acting_user_id, $minimum_role, $principal->workspace_id ); } - if ( self::CURRENT_USER_EFFECTIVE_AGENT_ID !== $principal->effective_agent_id ) { + if ( null === $principal->audience_id && self::CURRENT_USER_EFFECTIVE_AGENT_ID !== $principal->effective_agent_id ) { $agent_ids[] = $principal->effective_agent_id; } diff --git a/src/Auth/class-wp-agent-principal-access-store.php b/src/Auth/class-wp-agent-principal-access-store.php new file mode 100644 index 0000000..877b728 --- /dev/null +++ b/src/Auth/class-wp-agent-principal-access-store.php @@ -0,0 +1,32 @@ +get_access( $agent_id, $principal->acting_user_id, $principal->workspace_id ); + if ( $access_store instanceof WP_Agent_Principal_Access_Store ) { + $grant = $access_store->get_access_for_principal( $agent_id, $principal, $principal->workspace_id ); + } elseif ( $principal->acting_user_id > 0 ) { + $grant = $access_store->get_access( $agent_id, $principal->acting_user_id, $principal->workspace_id ); + } else { + $grant = null; + } + return $grant instanceof WP_Agent_Access_Grant && $grant->role_meets( $minimum_role ); } diff --git a/src/Auth/register-agent-access-abilities.php b/src/Auth/register-agent-access-abilities.php index 12f4530..9baed66 100644 --- a/src/Auth/register-agent-access-abilities.php +++ b/src/Auth/register-agent-access-abilities.php @@ -111,7 +111,7 @@ function agents_list_accessible_agents( array $input ): array { * @param array $input Ability input. */ function agents_access_permission( array $input ): bool { - $allowed = function_exists( 'is_user_logged_in' ) ? is_user_logged_in() : \WP_Agent_Access::get_current_principal( agents_access_request_scope( $input ) ) instanceof \AgentsAPI\AI\WP_Agent_Execution_Principal; + $allowed = \WP_Agent_Access::get_current_principal( agents_access_request_scope( $input ) ) instanceof \AgentsAPI\AI\WP_Agent_Execution_Principal; return (bool) apply_filters( 'agents_access_permission', $allowed, $input ); } diff --git a/src/Runtime/class-wp-agent-execution-principal.php b/src/Runtime/class-wp-agent-execution-principal.php index a1b99c5..34a1487 100644 --- a/src/Runtime/class-wp-agent-execution-principal.php +++ b/src/Runtime/class-wp-agent-execution-principal.php @@ -22,6 +22,7 @@ final class WP_Agent_Execution_Principal { public const AUTH_SOURCE_USER = 'user'; public const AUTH_SOURCE_APPLICATION_PASSWORD = 'application_password'; public const AUTH_SOURCE_AGENT_TOKEN = 'agent_token'; + public const AUTH_SOURCE_AUDIENCE = 'audience'; public const AUTH_SOURCE_SYSTEM = 'system'; public const REQUEST_CONTEXT_REST = 'rest'; @@ -40,6 +41,8 @@ final class WP_Agent_Execution_Principal { * @param string|null $client_id Optional client/login identifier. * @param \WP_Agent_Capability_Ceiling|null $capability_ceiling Optional capability ceiling for this execution. * @param \WP_Agent_Caller_Context|null $caller_context Optional cross-site caller context claims. + * @param string|null $audience_id Optional non-user audience/principal identifier. + * @param array $audience_claims Optional host-owned audience claims. */ public function __construct( public readonly int $acting_user_id, @@ -52,6 +55,8 @@ public function __construct( public readonly ?string $client_id = null, public readonly ?\WP_Agent_Capability_Ceiling $capability_ceiling = null, public readonly ?\WP_Agent_Caller_Context $caller_context = null, + public readonly ?string $audience_id = null, + public readonly array $audience_claims = array(), ) { if ( $this->acting_user_id < 0 ) { throw self::invalid( 'acting_user_id', 'must be zero or a positive integer' ); @@ -73,9 +78,17 @@ public function __construct( throw self::invalid( 'token_id', 'must be null or a positive integer' ); } + if ( null !== $this->audience_id && '' === trim( $this->audience_id ) ) { + throw self::invalid( 'audience_id', 'must be null or a non-empty string' ); + } + if ( false === self::jsonEncode( $this->request_metadata ) ) { throw self::invalid( 'request_metadata', 'must be JSON serializable' ); } + + if ( false === self::jsonEncode( $this->audience_claims ) ) { + throw self::invalid( 'audience_claims', 'must be JSON serializable' ); + } } /** @@ -133,6 +146,22 @@ public static function agent_token( int $acting_user_id, string $effective_agent return new self( $acting_user_id, $effective_agent_id, self::AUTH_SOURCE_AGENT_TOKEN, $request_context, $token_id, $request_metadata, $workspace_id, $client_id, $capability_ceiling, $caller_context ); } + /** + * Build a principal for a non-user audience resolved by the host. + * + * @param string $audience_id Host-owned audience identifier. + * @param string $effective_agent_id Registered agent ID/slug effective for the run. + * @param string $request_context Request context. + * @param array $request_metadata Request metadata. + * @param string|null $workspace_id Optional host workspace/scope identifier. + * @param string|null $client_id Optional client/login identifier. + * @param array $audience_claims Host-owned audience claims. + * @return self + */ + public static function audience( string $audience_id, string $effective_agent_id, string $request_context = self::REQUEST_CONTEXT_REST, array $request_metadata = array(), ?string $workspace_id = null, ?string $client_id = null, array $audience_claims = array() ): self { + return new self( 0, $effective_agent_id, self::AUTH_SOURCE_AUDIENCE, $request_context, null, $request_metadata, $workspace_id, $client_id, null, null, $audience_id, $audience_claims ); + } + /** * Build a principal from a request/context array. * @@ -168,7 +197,9 @@ public static function from_array( array $principal ): self { array_key_exists( 'workspace_id', $principal ) && null !== $principal['workspace_id'] ? (string) $principal['workspace_id'] : null, array_key_exists( 'client_id', $principal ) && null !== $principal['client_id'] ? (string) $principal['client_id'] : null, $capability_ceiling, - $caller_context + $caller_context, + array_key_exists( 'audience_id', $principal ) && null !== $principal['audience_id'] ? (string) $principal['audience_id'] : null, + isset( $principal['audience_claims'] ) && is_array( $principal['audience_claims'] ) ? $principal['audience_claims'] : array() ); } @@ -189,9 +220,18 @@ public function to_array(): array { 'client_id' => $this->client_id, 'capability_ceiling' => $this->capability_ceiling instanceof \WP_Agent_Capability_Ceiling ? $this->capability_ceiling->to_array() : null, 'caller_context' => $this->caller_context instanceof \WP_Agent_Caller_Context ? $this->caller_context->to_array() : null, + 'audience_id' => $this->audience_id, + 'audience_claims' => $this->audience_claims, ); } + /** + * Whether this principal represents a host-resolved non-user audience. + */ + public function has_audience(): bool { + return null !== $this->audience_id; + } + /** * Return a copy with additional request metadata. * @@ -209,7 +249,9 @@ public function with_request_metadata( array $request_metadata ): self { $this->workspace_id, $this->client_id, $this->capability_ceiling, - $this->caller_context + $this->caller_context, + $this->audience_id, + $this->audience_claims ); } diff --git a/tests/agents-access-ability-smoke.php b/tests/agents-access-ability-smoke.php index 32f1fed..fd9036d 100644 --- a/tests/agents-access-ability-smoke.php +++ b/tests/agents-access-ability-smoke.php @@ -74,11 +74,15 @@ static function (): void { $grant = new WP_Agent_Access_Grant( 'editor-agent', 7, WP_Agent_Access_Grant::ROLE_OPERATOR, 'site:42' ); -$access_store = new class( $grant ) implements WP_Agent_Access_Store { +$access_store = new class( $grant ) implements WP_Agent_Access_Store, WP_Agent_Principal_Access_Store { + private WP_Agent_Access_Grant $audience_grant; + /** * @param WP_Agent_Access_Grant $grant Test grant. */ - public function __construct( private WP_Agent_Access_Grant $grant ) {} + public function __construct( private WP_Agent_Access_Grant $grant ) { + $this->audience_grant = new WP_Agent_Access_Grant( 'admin-agent', 0, WP_Agent_Access_Grant::ROLE_OPERATOR, 'site:42', null, null, null, array(), 'audience:docs-readers' ); + } public function grant_access( WP_Agent_Access_Grant $grant ): WP_Agent_Access_Grant { $this->grant = $grant; @@ -104,6 +108,26 @@ public function get_agent_ids_for_user( int $user_id, ?string $minimum_role = nu public function get_users_for_agent( string $agent_id, ?string $workspace_id = null ): array { return $this->grant->agent_id === $agent_id && $this->grant->workspace_id === $workspace_id ? array( $this->grant ) : array(); } + + public function get_access_for_principal( string $agent_id, AgentsAPI\AI\WP_Agent_Execution_Principal $principal, ?string $workspace_id = null ): ?WP_Agent_Access_Grant { + if ( null === $principal->audience_id ) { + return $this->get_access( $agent_id, $principal->acting_user_id, $workspace_id ); + } + + return $this->audience_grant->agent_id === $agent_id && $this->audience_grant->audience_id === $principal->audience_id && $this->audience_grant->workspace_id === $workspace_id ? $this->audience_grant : null; + } + + public function get_agent_ids_for_principal( AgentsAPI\AI\WP_Agent_Execution_Principal $principal, ?string $minimum_role = null, ?string $workspace_id = null ): array { + if ( null === $principal->audience_id ) { + return $this->get_agent_ids_for_user( $principal->acting_user_id, $minimum_role, $workspace_id ); + } + + if ( $this->audience_grant->audience_id !== $principal->audience_id || $this->audience_grant->workspace_id !== $workspace_id ) { + return array(); + } + + return null === $minimum_role || $this->audience_grant->role_meets( $minimum_role ) ? array( $this->audience_grant->agent_id ) : array(); + } }; add_filter( @@ -149,6 +173,52 @@ static function ( $store ) use ( $access_store ) { $ability_list = AgentsAPI\AI\Auth\agents_list_accessible_agents( array( 'workspace_id' => 'site:42' ) ); agents_api_smoke_assert_equals( 'editor-agent', $ability_list['agents'][0]['slug'] ?? null, 'list-accessible ability returns granted registered agent', $failures, $passes ); +$GLOBALS['__agents_api_smoke_current_user_id'] = 0; +add_filter( + 'agents_api_execution_principal', + static function ( $principal, array $context ) { + if ( (int) $GLOBALS['__agents_api_smoke_current_user_id'] > 0 ) { + return $principal; + } + + return AgentsAPI\AI\WP_Agent_Execution_Principal::audience( + 'audience:docs-readers', + 'audience-gateway', + $context['request_context'] ?? AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + array( 'source' => 'smoke-test' ), + $context['workspace_id'] ?? null + ); + }, + 20, + 2 +); + +$audience_principal = WP_Agent_Access::get_current_principal( array( 'workspace_id' => 'site:42' ) ); +agents_api_smoke_assert_equals( 0, $audience_principal->acting_user_id, 'current principal can resolve anonymous audience', $failures, $passes ); +agents_api_smoke_assert_equals( 'audience:docs-readers', $audience_principal->audience_id, 'anonymous audience principal carries audience id', $failures, $passes ); +agents_api_smoke_assert_equals( true, AgentsAPI\AI\Auth\agents_access_permission( array( 'workspace_id' => 'site:42' ) ), 'access ability permission accepts resolved audience principal', $failures, $passes ); + +$audience_can_access = AgentsAPI\AI\Auth\agents_can_access_agent( + array( + 'agent' => 'admin-agent', + 'minimum_role' => WP_Agent_Access_Grant::ROLE_OPERATOR, + 'workspace_id' => 'site:42', + ) +); +agents_api_smoke_assert_equals( true, $audience_can_access['allowed'] ?? false, 'can-access ability returns allowed true for audience grant', $failures, $passes ); + +$audience_cannot_access = AgentsAPI\AI\Auth\agents_can_access_agent( + array( + 'agent' => 'admin-agent', + 'minimum_role' => WP_Agent_Access_Grant::ROLE_ADMIN, + 'workspace_id' => 'site:42', + ) +); +agents_api_smoke_assert_equals( false, $audience_cannot_access['allowed'] ?? true, 'can-access ability enforces audience grant role', $failures, $passes ); + +$audience_ability_list = AgentsAPI\AI\Auth\agents_list_accessible_agents( array( 'workspace_id' => 'site:42' ) ); +agents_api_smoke_assert_equals( 'admin-agent', $audience_ability_list['agents'][0]['slug'] ?? null, 'list-accessible ability returns audience granted agent', $failures, $passes ); + do_action( 'wp_abilities_api_categories_init' ); do_action( 'wp_abilities_api_init' ); diff --git a/tests/authorization-smoke.php b/tests/authorization-smoke.php index 33c2ded..2a13cc3 100644 --- a/tests/authorization-smoke.php +++ b/tests/authorization-smoke.php @@ -33,6 +33,10 @@ agents_api_smoke_assert_equals( false, $grant->role_meets( WP_Agent_Access_Grant::ROLE_ADMIN ), 'operator grant does not meet admin role', $failures, $passes ); agents_api_smoke_assert_equals( 'site:42', WP_Agent_Access_Grant::from_array( $grant->to_array() )->workspace_id, 'access grant round-trips workspace scope', $failures, $passes ); +$audience_grant = new WP_Agent_Access_Grant( 'viewer-agent', 0, WP_Agent_Access_Grant::ROLE_VIEWER, 'site:42', null, null, null, array(), 'audience:docs-readers' ); +agents_api_smoke_assert_equals( 'audience:docs-readers', $audience_grant->audience_id, 'access grant accepts audience without user id', $failures, $passes ); +agents_api_smoke_assert_equals( 'audience:docs-readers', WP_Agent_Access_Grant::from_array( $audience_grant->to_array() )->audience_id, 'access grant round-trips audience id', $failures, $passes ); + $raw_token = 'wp_agent_editor-agent_test-secret'; $token_hash = WP_Agent_Token::hash_token( $raw_token ); $token = new WP_Agent_Token( @@ -188,4 +192,47 @@ public function get_users_for_agent( string $agent_id, ?string $workspace_id = n agents_api_smoke_assert_equals( true, $access_policy->can_access_agent( $other_agent, 'editor-agent', WP_Agent_Access_Grant::ROLE_OPERATOR ), 'policy accepts access grant at operator level', $failures, $passes ); agents_api_smoke_assert_equals( false, $access_policy->can_access_agent( $other_agent, 'editor-agent', WP_Agent_Access_Grant::ROLE_ADMIN ), 'policy rejects access grant below admin level', $failures, $passes ); +$principal_access_store = new class( $audience_grant ) implements WP_Agent_Access_Store, WP_Agent_Principal_Access_Store { + public function __construct( private WP_Agent_Access_Grant $grant ) {} + + public function grant_access( WP_Agent_Access_Grant $grant ): WP_Agent_Access_Grant { + $this->grant = $grant; + return $grant; + } + + public function revoke_access( string $agent_id, int $user_id, ?string $workspace_id = null ): bool { + return $this->grant->agent_id === $agent_id && $this->grant->user_id === $user_id && $this->grant->workspace_id === $workspace_id; + } + + public function get_access( string $agent_id, int $user_id, ?string $workspace_id = null ): ?WP_Agent_Access_Grant { + return $this->grant->agent_id === $agent_id && $this->grant->user_id === $user_id && $this->grant->workspace_id === $workspace_id ? $this->grant : null; + } + + public function get_agent_ids_for_user( int $user_id, ?string $minimum_role = null, ?string $workspace_id = null ): array { + unset( $minimum_role, $workspace_id ); + return $this->grant->user_id === $user_id ? array( $this->grant->agent_id ) : array(); + } + + public function get_users_for_agent( string $agent_id, ?string $workspace_id = null ): array { + return $this->grant->agent_id === $agent_id && $this->grant->workspace_id === $workspace_id && $this->grant->user_id > 0 ? array( $this->grant ) : array(); + } + + public function get_access_for_principal( string $agent_id, AgentsAPI\AI\WP_Agent_Execution_Principal $principal, ?string $workspace_id = null ): ?WP_Agent_Access_Grant { + return $this->grant->agent_id === $agent_id && $this->grant->audience_id === $principal->audience_id && $this->grant->workspace_id === $workspace_id ? $this->grant : null; + } + + public function get_agent_ids_for_principal( AgentsAPI\AI\WP_Agent_Execution_Principal $principal, ?string $minimum_role = null, ?string $workspace_id = null ): array { + if ( $this->grant->audience_id !== $principal->audience_id || $this->grant->workspace_id !== $workspace_id ) { + return array(); + } + + return null === $minimum_role || $this->grant->role_meets( $minimum_role ) ? array( $this->grant->agent_id ) : array(); + } +}; + +$audience_policy = new WP_Agent_WordPress_Authorization_Policy( $principal_access_store ); +$audience_principal = AgentsAPI\AI\WP_Agent_Execution_Principal::audience( 'audience:docs-readers', 'audience-gateway', AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, array(), 'site:42' ); +agents_api_smoke_assert_equals( true, $audience_policy->can_access_agent( $audience_principal, 'viewer-agent', WP_Agent_Access_Grant::ROLE_VIEWER ), 'policy accepts principal-aware audience grant', $failures, $passes ); +agents_api_smoke_assert_equals( false, $audience_policy->can_access_agent( $audience_principal, 'viewer-agent', WP_Agent_Access_Grant::ROLE_OPERATOR ), 'policy rejects audience grant below operator level', $failures, $passes ); + agents_api_smoke_finish( 'Agents API authorization', $failures, $passes ); diff --git a/tests/execution-principal-smoke.php b/tests/execution-principal-smoke.php index 0981357..b67ed09 100644 --- a/tests/execution-principal-smoke.php +++ b/tests/execution-principal-smoke.php @@ -129,6 +129,34 @@ static function ( $principal, array $context ) { agents_api_smoke_assert_equals( array( 'request_id' => 'req-next' ), $with_metadata->request_metadata, 'metadata replacement returns updated copy', $failures, $passes ); agents_api_smoke_assert_equals( array( 'ip_hash' => 'abc123' ), $from_array->request_metadata, 'metadata replacement leaves original immutable', $failures, $passes ); +$audience_principal = AgentsAPI\AI\WP_Agent_Execution_Principal::audience( + 'audience:docs-readers', + 'audience-gateway', + AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + array( 'route' => '/agents/v1/chat' ), + 'site:42', + 'browser', + array( 'example' => 'docs-readers' ) +); +agents_api_smoke_assert_equals( 0, $audience_principal->acting_user_id, 'audience principal has no WordPress user', $failures, $passes ); +agents_api_smoke_assert_equals( AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_AUDIENCE, $audience_principal->auth_source, 'audience principal records audience auth source', $failures, $passes ); +agents_api_smoke_assert_equals( 'audience:docs-readers', $audience_principal->audience_id, 'audience principal records audience id', $failures, $passes ); +agents_api_smoke_assert_equals( true, $audience_principal->has_audience(), 'audience principal reports audience presence', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'example' => 'docs-readers' ), $audience_principal->to_array()['audience_claims'], 'audience principal exports claims', $failures, $passes ); + +$audience_from_array = AgentsAPI\AI\WP_Agent_Execution_Principal::from_array( + array( + 'acting_user_id' => 0, + 'effective_agent_id' => 'audience-gateway', + 'auth_source' => AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_AUDIENCE, + 'request_context' => AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST, + 'audience_id' => 'audience:docs-readers', + 'audience_claims' => array( 'tier' => 'viewer' ), + ) +); +agents_api_smoke_assert_equals( 'audience:docs-readers', $audience_from_array->audience_id, 'from_array restores audience id', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'tier' => 'viewer' ), $audience_from_array->audience_claims, 'from_array restores audience claims', $failures, $passes ); + try { new AgentsAPI\AI\WP_Agent_Execution_Principal( -1, 'agent', AgentsAPI\AI\WP_Agent_Execution_Principal::AUTH_SOURCE_USER, AgentsAPI\AI\WP_Agent_Execution_Principal::REQUEST_CONTEXT_REST ); agents_api_smoke_assert_equals( true, false, 'negative user id is rejected', $failures, $passes );