diff --git a/composer.lock b/composer.lock index e86ac11c..fe6b2c36 100644 --- a/composer.lock +++ b/composer.lock @@ -820,12 +820,12 @@ "source": { "type": "git", "url": "https://github.com/Automattic/agents-api.git", - "reference": "c365119d177e9d3fc568f8032695e87007ceab4c" + "reference": "f761ddc24a72f7c541884e54f8664968a0bd8799" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Automattic/agents-api/zipball/c365119d177e9d3fc568f8032695e87007ceab4c", - "reference": "c365119d177e9d3fc568f8032695e87007ceab4c", + "url": "https://api.github.com/repos/Automattic/agents-api/zipball/f761ddc24a72f7c541884e54f8664968a0bd8799", + "reference": "f761ddc24a72f7c541884e54f8664968a0bd8799", "shasum": "" }, "require": { @@ -845,6 +845,8 @@ "php tests/effective-agent-resolver-smoke.php", "php tests/caller-context-smoke.php", "php tests/authorization-smoke.php", + "php tests/agents-access-ability-smoke.php", + "php tests/agents-conversation-session-abilities-smoke.php", "php tests/action-policy-values-smoke.php", "php tests/consent-policy-smoke.php", "php tests/tool-policy-contracts-smoke.php", @@ -852,6 +854,7 @@ "php tests/tool-runtime-smoke.php", "php tests/pending-action-store-contract-smoke.php", "php tests/approval-resolver-contract-smoke.php", + "php tests/pending-action-abilities-smoke.php", "php tests/identity-smoke.php", "php tests/memory-metadata-contract-smoke.php", "php tests/approval-action-value-shape-smoke.php", @@ -871,6 +874,8 @@ "php tests/iteration-budget-smoke.php", "php tests/conversation-loop-budgets-smoke.php", "php tests/channels-smoke.php", + "php tests/frontend-chat-rest-smoke.php", + "php tests/agents-dispatch-message-ability-smoke.php", "php tests/webhook-safety-smoke.php", "php tests/remote-bridge-smoke.php", "php tests/context-authority-smoke.php", @@ -878,6 +883,7 @@ "php tests/workflow-bindings-smoke.php", "php tests/workflow-spec-validator-smoke.php", "php tests/workflow-runner-smoke.php", + "php tests/workflow-lifecycle-smoke.php", "php tests/agents-workflow-ability-smoke.php", "php tests/routine-smoke.php", "php tests/subagents-smoke.php", @@ -893,7 +899,7 @@ "issues": "https://github.com/Automattic/agents-api/issues", "source": "https://github.com/Automattic/agents-api" }, - "time": "2026-05-10T20:26:56+00:00" + "time": "2026-05-15T22:40:54+00:00" }, { "name": "dealerdirect/phpcodesniffer-composer-installer", @@ -1587,6 +1593,6 @@ "platform": { "php": ">=8.2" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/docs/core-system/wp-cli.md b/docs/core-system/wp-cli.md index 3c4b9889..f16e5d6e 100644 --- a/docs/core-system/wp-cli.md +++ b/docs/core-system/wp-cli.md @@ -249,7 +249,9 @@ wp datamachine agents delete my-agent --delete-files --yes # Manage access grants wp datamachine agents access grant my-agent 2 --role=operator +wp datamachine agents access grant-audience my-agent audience:public --role=operator wp datamachine agents access revoke my-agent 2 +wp datamachine agents access revoke-audience my-agent audience:public wp datamachine agents access list my-agent # Manage runtime bearer tokens. Raw token values are shown only at creation time. diff --git a/inc/Abilities/AgentAbilities.php b/inc/Abilities/AgentAbilities.php index 6ebb0a8c..0b7154ff 100644 --- a/inc/Abilities/AgentAbilities.php +++ b/inc/Abilities/AgentAbilities.php @@ -493,6 +493,49 @@ private function registerAbilities(): void { 'meta' => array( 'show_in_rest' => true ), ) ); + + wp_register_ability( + 'datamachine/grant-agent-audience-access', + array( + 'label' => 'Grant Agent Audience Access', + 'description' => 'Grant a selected agent to an explicit non-user audience principal such as audience:public.', + 'category' => 'datamachine-agent', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'agent', 'principal_id' ), + 'properties' => array( + 'agent' => array( + 'type' => 'string', + 'description' => 'Agent slug or ID to grant.', + ), + 'principal_type' => array( + 'type' => 'string', + 'description' => 'Principal type. Defaults to audience.', + ), + 'principal_id' => array( + 'type' => 'string', + 'description' => 'Principal identifier, such as public or automattician.', + ), + 'role' => array( + 'type' => 'string', + 'enum' => array( 'admin', 'operator', 'viewer' ), + 'description' => 'Access role. Defaults to operator.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'grant' => array( 'type' => 'object' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( self::class, 'grantAgentAudienceAccess' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); }; if ( doing_action( 'wp_abilities_api_init' ) ) { @@ -1460,11 +1503,12 @@ public static function getAgent( array $input ): array { } // Enrich with access grants. - $access_repo = new \DataMachine\Core\Database\Agents\AgentAccess(); - $access = array_map( + $access_repo = new \DataMachine\Core\Database\Agents\AgentAccess(); + $access = array_map( static fn( \WP_Agent_Access_Grant $grant ): array => $grant->to_array(), $access_repo->get_users_for_agent( (string) (int) $agent['agent_id'] ) ); + $principal_access = $access_repo->get_principals_for_agent( (string) (int) $agent['agent_id'] ); // Check for agent directory. $directory_manager = new DirectoryManager(); @@ -1473,22 +1517,64 @@ public static function getAgent( array $input ): array { return array( 'success' => true, 'agent' => array( - 'agent_id' => (int) $agent['agent_id'], - 'agent_slug' => (string) $agent['agent_slug'], - 'agent_name' => (string) $agent['agent_name'], - 'owner_id' => (int) $agent['owner_id'], - 'agent_config' => is_array( $agent['agent_config'] ?? null ) + 'agent_id' => (int) $agent['agent_id'], + 'agent_slug' => (string) $agent['agent_slug'], + 'agent_name' => (string) $agent['agent_name'], + 'owner_id' => (int) $agent['owner_id'], + 'agent_config' => is_array( $agent['agent_config'] ?? null ) ? $agent['agent_config'] : ( json_decode( $agent['agent_config'] ?? '{}', true ) ? json_decode( $agent['agent_config'] ?? '{}', true ) : array() ), - 'created_at' => $agent['created_at'] ?? '', - 'updated_at' => $agent['updated_at'] ?? '', - 'agent_dir' => $agent_dir, - 'has_files' => is_dir( $agent_dir ), - 'access' => $access, + 'created_at' => $agent['created_at'] ?? '', + 'updated_at' => $agent['updated_at'] ?? '', + 'agent_dir' => $agent_dir, + 'has_files' => is_dir( $agent_dir ), + 'access' => $access, + 'principal_access' => $principal_access, ), ); } + /** + * Grant an agent to an explicit audience/non-user principal. + * + * @param array $input Ability input. + * @return array Result. + */ + public static function grantAgentAudienceAccess( array $input ): array { + $agent_id = self::resolve_agent_input_id( $input ); + if ( is_wp_error( $agent_id ) ) { + return array( + 'success' => false, + 'error' => $agent_id->get_error_message(), + ); + } + + $principal_type = sanitize_key( (string) ( $input['principal_type'] ?? 'audience' ) ); + $principal_id = sanitize_title( (string) ( $input['principal_id'] ?? '' ) ); + $role = (string) ( $input['role'] ?? \WP_Agent_Access_Grant::ROLE_OPERATOR ); + + if ( '' === $principal_type || '' === $principal_id || 'user' === $principal_type ) { + return array( + 'success' => false, + 'error' => 'A non-user principal_type and principal_id are required.', + ); + } + + try { + $grant = ( new AgentAccess() )->grant_principal_access( (string) $agent_id, $principal_type, $principal_id, $role ); + } catch ( \Throwable $e ) { + return array( + 'success' => false, + 'error' => $e->getMessage(), + ); + } + + return array( + 'success' => true, + 'grant' => $grant, + ); + } + /** * Update an agent's mutable fields. * diff --git a/inc/Abilities/Chat/AgentsChatHandler.php b/inc/Abilities/Chat/AgentsChatHandler.php index 408701bd..019f37bb 100644 --- a/inc/Abilities/Chat/AgentsChatHandler.php +++ b/inc/Abilities/Chat/AgentsChatHandler.php @@ -54,9 +54,16 @@ public function registerHandler( $handler, array $input ) { * @return bool */ public function checkPermission( bool $allowed, array $input ): bool { - unset( $input ); + if ( $allowed || PermissionHelper::can( 'chat' ) ) { + return true; + } - return $allowed || PermissionHelper::can( 'chat' ); + $agent = sanitize_title( (string) ( $input['agent'] ?? '' ) ); + if ( '' === $agent || ! class_exists( '\WP_Agent_Access' ) || ! class_exists( '\WP_Agent_Access_Grant' ) ) { + return false; + } + + return \WP_Agent_Access::can_current_principal_access_agent( $agent, \WP_Agent_Access_Grant::ROLE_VIEWER ); } /** @@ -71,20 +78,16 @@ public function execute( array $input ): array|WP_Error { return new WP_Error( 'empty_message', __( 'Message cannot be empty.', 'data-machine' ), array( 'status' => 400 ) ); } - $user_id = PermissionHelper::acting_user_id(); - if ( $user_id <= 0 ) { - $user_id = get_current_user_id(); + $agent_id = $this->resolveAgentId( (string) ( $input['agent'] ?? '' ) ); + if ( $agent_id instanceof WP_Error ) { + return $agent_id; } + $user_id = $this->resolveRuntimeUserId( $agent_id, (string) ( $input['agent'] ?? '' ) ); if ( $user_id <= 0 ) { return new WP_Error( 'no_user', __( 'No user context available.', 'data-machine' ), array( 'status' => 400 ) ); } - $agent_id = $this->resolveAgentId( (string) ( $input['agent'] ?? '' ) ); - if ( $agent_id instanceof WP_Error ) { - return $agent_id; - } - $client_context = is_array( $input['client_context'] ?? null ) ? $input['client_context'] : array(); $mode = $this->resolveMode( $input, $client_context ); $agent_config = PluginSettings::resolveModelForAgentMode( 0 === $agent_id ? null : $agent_id, $mode ); @@ -150,6 +153,40 @@ private function resolveAgentId( string $agent ) { return (int) ( $row['agent_id'] ?? 0 ); } + /** + * Resolve the WordPress user context used by Data Machine's chat runtime. + * + * Anonymous audience chats execute under the selected agent owner after the + * Agents API access check succeeds, keeping model credentials and tool policy + * scoped to the site-owned brain agent instead of an anonymous WP user. + * + * @param int $agent_id Internal Data Machine agent ID. + * @param string $agent Requested Agents API agent slug/id. + * @return int Runtime WordPress user ID, or 0 when no safe context exists. + */ + private function resolveRuntimeUserId( int $agent_id, string $agent ): int { + $user_id = PermissionHelper::acting_user_id(); + if ( $user_id <= 0 ) { + $user_id = get_current_user_id(); + } + + if ( $user_id > 0 ) { + return $user_id; + } + + $agent_slug = sanitize_title( $agent ); + if ( '' === $agent_slug || ! class_exists( '\WP_Agent_Access' ) || ! class_exists( '\WP_Agent_Access_Grant' ) ) { + return 0; + } + + if ( ! \WP_Agent_Access::can_current_principal_access_agent( $agent_slug, \WP_Agent_Access_Grant::ROLE_VIEWER ) ) { + return 0; + } + + $row = ( new Agents() )->get_agent( $agent_id ); + return $row ? (int) ( $row['owner_id'] ?? 0 ) : 0; + } + /** * Resolve the Data Machine execution mode from canonical Agents API input. * diff --git a/inc/Cli/Commands/AgentsCommand.php b/inc/Cli/Commands/AgentsCommand.php index ba0d6993..02149fb0 100644 --- a/inc/Cli/Commands/AgentsCommand.php +++ b/inc/Cli/Commands/AgentsCommand.php @@ -434,13 +434,13 @@ public function delete( array $args, array $assoc_args ): void { * ## OPTIONS * * - * : Action: grant, revoke, or list. + * : Action: grant, grant-audience, revoke, revoke-audience, or list. * * * : Agent slug. * - * [] - * : User ID, login, or email (required for grant/revoke). + * [] + * : User ID/login/email for user grants, or audience slug / audience: for audience grants. * * [--role=] * : Access role (grant only). @@ -466,7 +466,9 @@ public function delete( array $args, array $assoc_args ): void { * ## EXAMPLES * * wp datamachine agents access grant chubes-bot 2 --role=admin + * wp datamachine agents access grant-audience chubes-bot audience:automattician --role=operator * wp datamachine agents access revoke chubes-bot 2 + * wp datamachine agents access revoke-audience chubes-bot audience:automattician * wp datamachine agents access list chubes-bot * * @subcommand access @@ -494,9 +496,10 @@ public function access( array $args, array $assoc_args ): void { switch ( $action ) { case 'list': - $grants = $access_repo->get_users_for_agent( (string) $agent_id ); + $grants = $access_repo->get_users_for_agent( (string) $agent_id ); + $principal_grants = $access_repo->get_principals_for_agent( (string) $agent_id ); - if ( empty( $grants ) ) { + if ( empty( $grants ) && empty( $principal_grants ) ) { WP_CLI::warning( sprintf( 'No access grants for agent "%s".', $slug ) ); return; } @@ -505,13 +508,46 @@ public function access( array $args, array $assoc_args ): void { foreach ( $grants as $grant ) { $user = get_user_by( 'id', $grant->user_id ); $items[] = array( - 'user_id' => $grant->user_id, - 'login' => $user ? $user->user_login : '(deleted)', - 'role' => $grant->role, + 'principal' => 'user:' . $grant->user_id, + 'label' => $user ? $user->user_login : '(deleted)', + 'role' => $grant->role, + ); + } + foreach ( $principal_grants as $grant ) { + $principal_type = (string) ( $grant['principal_type'] ?? '' ); + $principal_id = (string) ( $grant['principal_id'] ?? '' ); + $items[] = array( + 'principal' => $principal_type . ':' . $principal_id, + 'label' => $principal_id, + 'role' => (string) ( $grant['role'] ?? '' ), ); } - $this->format_items( $items, array( 'user_id', 'login', 'role' ), $assoc_args, 'user_id' ); + $this->format_items( $items, array( 'principal', 'label', 'role' ), $assoc_args, 'principal' ); + break; + + case 'grant-audience': + $audience = $args[2] ?? null; + if ( null === $audience ) { + WP_CLI::error( 'Audience is required. Usage: wp datamachine agents access grant-audience [--role=]' ); + return; + } + + list( $principal_type, $principal_id ) = $this->resolveAudiencePrincipal( (string) $audience ); + $role = (string) ( $assoc_args['role'] ?? 'operator' ); + + try { + $access_repo->grant_principal_access( (string) $agent_id, $principal_type, $principal_id, $role ); + $ok = true; + } catch ( \Throwable $e ) { + $ok = false; + } + + if ( $ok ) { + WP_CLI::success( sprintf( 'Granted %s access to %s:%s for agent "%s".', $role, $principal_type, $principal_id, $slug ) ); + } else { + WP_CLI::error( 'Failed to grant audience access.' ); + } break; case 'grant': @@ -560,9 +596,50 @@ public function access( array $args, array $assoc_args ): void { } break; + case 'revoke-audience': + $audience = $args[2] ?? null; + if ( null === $audience ) { + WP_CLI::error( 'Audience is required for revoke-audience.' ); + return; + } + + list( $principal_type, $principal_id ) = $this->resolveAudiencePrincipal( (string) $audience ); + $ok = $access_repo->revoke_principal_access( (string) $agent_id, $principal_type, $principal_id ); + + if ( $ok ) { + WP_CLI::success( sprintf( 'Revoked access for %s:%s on agent "%s".', $principal_type, $principal_id, $slug ) ); + } else { + WP_CLI::warning( 'No audience access grant found to revoke.' ); + } + break; + default: - WP_CLI::error( "Unknown action: {$action}. Use: grant, revoke, list" ); + WP_CLI::error( "Unknown action: {$action}. Use: grant, grant-audience, revoke, revoke-audience, list" ); + } + } + + /** + * Resolve audience CLI syntax to an explicit principal tuple. + * + * @return array{0:string,1:string} + */ + private function resolveAudiencePrincipal( string $audience ): array { + $audience = trim( $audience ); + $type = 'audience'; + $id = $audience; + + if ( false !== strpos( $audience, ':' ) ) { + list( $type, $id ) = explode( ':', $audience, 2 ); } + + $type = sanitize_key( $type ); + $id = sanitize_title( $id ); + + if ( '' === $type || '' === $id || 'user' === $type ) { + WP_CLI::error( 'Audience principal must be a non-user value such as audience:automattician.' ); + } + + return array( $type, $id ); } /** diff --git a/inc/Core/Auth/AgentAccessStoreAdapter.php b/inc/Core/Auth/AgentAccessStoreAdapter.php index c4ec6829..5292eed8 100644 --- a/inc/Core/Auth/AgentAccessStoreAdapter.php +++ b/inc/Core/Auth/AgentAccessStoreAdapter.php @@ -16,7 +16,7 @@ /** * Exposes Data Machine's existing agent access table through Agents API. */ -class AgentAccessStoreAdapter implements \WP_Agent_Access_Store { +class AgentAccessStoreAdapter implements \WP_Agent_Access_Store, \WP_Agent_Principal_Access_Store { /** * Existing Data Machine access repository. @@ -123,6 +123,104 @@ public function get_agent_ids_for_user( int $user_id, ?string $minimum_role = nu return $slugs; } + /** + * Create or update a non-user principal/audience grant. + * + * This method is intentionally additive because older Agents API versions only + * declare user-grant methods on WP_Agent_Access_Store. + * + * @param string $agent_id Registered agent slug/id. + * @param string $principal_type Principal type, for example audience. + * @param string $principal_id Principal identifier, for example public. + * @param string $role Access role. + * @return array + */ + public function grant_access_for_principal( string $agent_id, string $principal_type, string $principal_id, string $role = \WP_Agent_Access_Grant::ROLE_VIEWER ): array { + $resolved = $this->access_repository->grant_principal_access( $this->storage_agent_id( $agent_id ), $principal_type, $principal_id, $role ); + return $this->principal_grant_for_contract( $resolved, $agent_id )->to_array(); + } + + /** + * Alias for upstream contracts that choose principal-first naming. + * + * @return array + */ + public function grant_principal_access( string $agent_id, string $principal_type, string $principal_id, string $role = \WP_Agent_Access_Grant::ROLE_VIEWER ): array { + return $this->grant_access_for_principal( $agent_id, $principal_type, $principal_id, $role ); + } + + /** + * Revoke a non-user principal/audience grant. + */ + public function revoke_access_for_principal( string $agent_id, string $principal_type, string $principal_id, ?string $workspace_id = null ): bool { + unset( $workspace_id ); + return $this->access_repository->revoke_principal_access( $this->storage_agent_id( $agent_id ), $principal_type, $principal_id ); + } + + /** + * Fetch a grant for a resolved non-user principal/audience. + * + * @return \WP_Agent_Access_Grant|null + */ + public function get_access_for_principal( string $agent_id, \AgentsAPI\AI\WP_Agent_Execution_Principal $principal, ?string $workspace_id = null ): ?\WP_Agent_Access_Grant { + $normalized = $this->normalize_principal( $principal ); + if ( null === $normalized ) { + return null; + } + + $grant = $this->access_repository->get_principal_access( $this->storage_agent_id( $agent_id ), $normalized['principal_type'], $normalized['principal_id'], $workspace_id ); + return $grant ? $this->principal_grant_for_contract( $grant, $agent_id ) : null; + } + + /** + * List agent IDs accessible to a resolved non-user principal/audience. + * + * @return string[] + */ + public function get_agent_ids_for_principal( \AgentsAPI\AI\WP_Agent_Execution_Principal $principal, ?string $minimum_role = null, ?string $workspace_id = null ): array { + $normalized = $this->normalize_principal( $principal ); + if ( null === $normalized ) { + return array(); + } + + $agent_ids = $this->access_repository->get_agent_ids_for_principal( $normalized['principal_type'], $normalized['principal_id'], $minimum_role, $workspace_id ); + if ( empty( $agent_ids ) ) { + return array(); + } + + $rows = $this->agents_repository->get_agents_by_ids( array_map( 'intval', $agent_ids ) ); + $slugs_by_id = array(); + foreach ( $rows as $row ) { + $agent_id = (int) ( $row['agent_id'] ?? 0 ); + $slug = sanitize_title( (string) ( $row['agent_slug'] ?? '' ) ); + if ( $agent_id > 0 && '' !== $slug ) { + $slugs_by_id[ $agent_id ] = $slug; + } + } + + $slugs = array(); + foreach ( $agent_ids as $agent_id ) { + $agent_id = (int) $agent_id; + if ( isset( $slugs_by_id[ $agent_id ] ) ) { + $slugs[] = $slugs_by_id[ $agent_id ]; + } + } + + return $slugs; + } + + /** + * List non-user principal/audience grants for an agent. + * + * @return array> + */ + public function get_principals_for_agent( string $agent_id, ?string $workspace_id = null ): array { + return array_map( + fn( array $grant ): array => $this->principal_grant_for_contract( $grant, $agent_id )->to_array(), + $this->access_repository->get_principals_for_agent( $this->storage_agent_id( $agent_id ), $workspace_id ) + ); + } + /** * List users with access to an agent. * @@ -168,7 +266,8 @@ private function grant_for_storage( \WP_Agent_Access_Grant $grant ): \WP_Agent_A $grant->grant_id, $grant->granted_by_user_id, $grant->granted_at, - $grant->metadata + $grant->metadata, + $grant->audience_id ); } @@ -196,7 +295,8 @@ private function grant_for_contract( \WP_Agent_Access_Grant $grant, string $requ $grant->grant_id, $grant->granted_by_user_id, $grant->granted_at, - $grant->metadata + $grant->metadata, + $grant->audience_id ); } @@ -215,4 +315,85 @@ private function contract_agent_id( string $agent_id ): string { return $agent_id; } + + /** + * Convert a Data Machine numeric principal grant into the Agents API slug shape. + * + * @param array $grant Principal grant row. + * @param string $requested_agent_id Agent ID requested by caller. + * @return \WP_Agent_Access_Grant + */ + private function principal_grant_for_contract( array $grant, string $requested_agent_id = '' ): \WP_Agent_Access_Grant { + $agent_slug = $this->contract_agent_id( (string) ( $grant['agent_id'] ?? '' ) ); + if ( ! is_numeric( $requested_agent_id ) ) { + $requested_slug = sanitize_title( $requested_agent_id ); + if ( '' !== $requested_slug ) { + $agent_slug = $requested_slug; + } + } + + $audience_id = (string) ( $grant['audience_id'] ?? '' ); + if ( '' === $audience_id ) { + $principal_type = (string) ( $grant['principal_type'] ?? 'audience' ); + $principal_id = (string) ( $grant['principal_id'] ?? '' ); + $audience_id = '' !== $principal_type && '' !== $principal_id ? $principal_type . ':' . $principal_id : ''; + } + + return new \WP_Agent_Access_Grant( + $agent_slug, + 0, + (string) ( $grant['role'] ?? \WP_Agent_Access_Grant::ROLE_VIEWER ), + array_key_exists( 'workspace_id', $grant ) && null !== $grant['workspace_id'] ? (string) $grant['workspace_id'] : null, + isset( $grant['grant_id'] ) ? (int) $grant['grant_id'] : null, + 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(), + $audience_id + ); + } + + /** + * Normalize an Agents API principal object/array or an audience: string. + * + * @param mixed $principal Principal shape from Agents API. + * @return array{principal_type:string,principal_id:string}|null + */ + private function normalize_principal( $principal ): ?array { + if ( is_string( $principal ) && false !== strpos( $principal, ':' ) ) { + list( $type, $id ) = explode( ':', $principal, 2 ); + $type = sanitize_key( $type ); + $id = sanitize_title( $id ); + return '' !== $type && '' !== $id ? array( + 'principal_type' => $type, + 'principal_id' => $id, + ) : null; + } + + if ( is_array( $principal ) ) { + $type = (string) ( $principal['principal_type'] ?? $principal['type'] ?? '' ); + $id = (string) ( $principal['principal_id'] ?? $principal['id'] ?? '' ); + } elseif ( is_object( $principal ) ) { + $audience_id = (string) ( $principal->audience_id ?? '' ); + if ( '' !== $audience_id && false !== strpos( $audience_id, ':' ) ) { + list( $type, $id ) = explode( ':', $audience_id, 2 ); + } else { + $type = (string) ( $principal->principal_type ?? $principal->type ?? '' ); + $id = (string) ( $principal->principal_id ?? $principal->id ?? '' ); + } + } else { + return null; + } + + $type = sanitize_key( $type ); + $id = sanitize_title( $id ); + + if ( '' === $type || '' === $id || 'user' === $type ) { + return null; + } + + return array( + 'principal_type' => $type, + 'principal_id' => $id, + ); + } } diff --git a/inc/Core/Database/Agents/AgentAccess.php b/inc/Core/Database/Agents/AgentAccess.php index cd45eb7a..be722332 100644 --- a/inc/Core/Database/Agents/AgentAccess.php +++ b/inc/Core/Database/Agents/AgentAccess.php @@ -24,6 +24,12 @@ class AgentAccess extends BaseRepository { */ const TABLE_NAME = 'datamachine_agent_access'; + /** + * Audience/principal access grants live beside user grants so the legacy + * agent/user unique key remains untouched on existing installs. + */ + const PRINCIPAL_TABLE_NAME = 'datamachine_agent_principal_access'; + /** * Valid access roles. * @@ -69,6 +75,188 @@ public static function create_table(): void { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); + + $principal_table_name = $wpdb->base_prefix . self::PRINCIPAL_TABLE_NAME; + $principal_sql = "CREATE TABLE {$principal_table_name} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + agent_id BIGINT(20) UNSIGNED NOT NULL, + principal_type VARCHAR(40) NOT NULL, + principal_id VARCHAR(191) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'viewer', + granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY agent_principal (agent_id, principal_type, principal_id), + KEY agent_id (agent_id), + KEY principal (principal_type, principal_id), + KEY role (role) + ) {$charset_collate};"; + + dbDelta( $principal_sql ); + } + + /** + * Grant an audience/non-user principal access to an agent. + * + * @return array Persisted grant row in contract-friendly shape. + */ + public function grant_principal_access( string $agent_id, string $principal_type, string $principal_id, string $role ): array { + $agent_id = (int) $agent_id; + $principal_type = $this->normalize_principal_type( $principal_type ); + $principal_id = $this->normalize_principal_id( $principal_id ); + + if ( $agent_id <= 0 || '' === $principal_type || '' === $principal_id || ! \WP_Agent_Access_Grant::is_valid_role( $role ) ) { + throw new \InvalidArgumentException( 'invalid_datamachine_agent_principal_access_grant' ); + } + + $existing = $this->get_principal_access( (string) $agent_id, $principal_type, $principal_id ); + if ( $existing ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $this->wpdb->update( + $this->principal_table_name(), + array( 'role' => $role ), + array( + 'agent_id' => $agent_id, + 'principal_type' => $principal_type, + 'principal_id' => $principal_id, + ), + array( '%s' ), + array( '%d', '%s', '%s' ) + ); + + if ( false === $result ) { + throw new \RuntimeException( 'datamachine_agent_principal_access_update_failed' ); + } + + return $this->get_principal_access( (string) $agent_id, $principal_type, $principal_id ) ?? $existing; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $this->wpdb->insert( + $this->principal_table_name(), + array( + 'agent_id' => $agent_id, + 'principal_type' => $principal_type, + 'principal_id' => $principal_id, + 'role' => $role, + 'granted_at' => current_time( 'mysql', true ), + ), + array( '%d', '%s', '%s', '%s', '%s' ) + ); + + if ( false === $result ) { + throw new \RuntimeException( 'datamachine_agent_principal_access_insert_failed' ); + } + + return $this->get_principal_access( (string) $agent_id, $principal_type, $principal_id ) ?? array( + 'agent_id' => (string) $agent_id, + 'principal_type' => $principal_type, + 'principal_id' => $principal_id, + 'role' => $role, + ); + } + + /** + * Revoke an audience/non-user principal grant. + */ + public function revoke_principal_access( string $agent_id, string $principal_type, string $principal_id ): bool { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $this->wpdb->delete( + $this->principal_table_name(), + array( + 'agent_id' => (int) $agent_id, + 'principal_type' => $this->normalize_principal_type( $principal_type ), + 'principal_id' => $this->normalize_principal_id( $principal_id ), + ), + array( '%d', '%s', '%s' ) + ); + + return false !== $result && $result > 0; + } + + /** + * Fetch an audience/non-user principal grant. + * + * @return array|null + */ + public function get_principal_access( string $agent_id, string $principal_type, string $principal_id, ?string $workspace_id = null ): ?array { + unset( $workspace_id ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + $row = $this->wpdb->get_row( + $this->wpdb->prepare( + 'SELECT * FROM %i WHERE agent_id = %d AND principal_type = %s AND principal_id = %s', + $this->principal_table_name(), + (int) $agent_id, + $this->normalize_principal_type( $principal_type ), + $this->normalize_principal_id( $principal_id ) + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + + return $row ? self::principal_grant_from_row( $row ) : null; + } + + /** + * Get agent IDs accessible to an audience/non-user principal. + * + * @return int[] + */ + public function get_agent_ids_for_principal( string $principal_type, string $principal_id, ?string $minimum_role = null, ?string $workspace_id = null ): array { + unset( $workspace_id ); + + if ( null !== $minimum_role ) { + $allowed_roles = $this->roles_at_or_above( $minimum_role ); + if ( empty( $allowed_roles ) ) { + return array(); + } + + $placeholders = implode( ',', array_fill( 0, count( $allowed_roles ), '%s' ) ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $results = $this->wpdb->get_col( + $this->wpdb->prepare( + "SELECT agent_id FROM %i WHERE principal_type = %s AND principal_id = %s AND role IN ({$placeholders})", + array_merge( array( $this->principal_table_name(), $this->normalize_principal_type( $principal_type ), $this->normalize_principal_id( $principal_id ) ), $allowed_roles ) + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + } else { + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + $results = $this->wpdb->get_col( + $this->wpdb->prepare( + 'SELECT agent_id FROM %i WHERE principal_type = %s AND principal_id = %s', + $this->principal_table_name(), + $this->normalize_principal_type( $principal_type ), + $this->normalize_principal_id( $principal_id ) + ) + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + } + + return array_map( 'intval', $results ? $results : array() ); + } + + /** + * List principal grants for an agent. + * + * @return array> + */ + public function get_principals_for_agent( string $agent_id, ?string $workspace_id = null ): array { + unset( $workspace_id ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + $results = $this->wpdb->get_results( + $this->wpdb->prepare( + 'SELECT * FROM %i WHERE agent_id = %d ORDER BY granted_at ASC', + $this->principal_table_name(), + (int) $agent_id + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + + return array_map( array( self::class, 'principal_grant_from_row' ), $results ? $results : array() ); } /** @@ -287,6 +475,46 @@ private static function grant_from_row( array $row ): \WP_Agent_Access_Grant { ); } + /** + * Convert a persisted principal access row into a stable array contract. + * + * @param array $row Database row. + * @return array + */ + private static function principal_grant_from_row( array $row ): array { + return array( + 'grant_id' => isset( $row['id'] ) ? (int) $row['id'] : null, + 'agent_id' => (string) ( $row['agent_id'] ?? '' ), + 'principal_type' => (string) ( $row['principal_type'] ?? '' ), + 'principal_id' => (string) ( $row['principal_id'] ?? '' ), + 'role' => (string) ( $row['role'] ?? \WP_Agent_Access_Grant::ROLE_VIEWER ), + 'workspace_id' => null, + 'granted_at' => isset( $row['granted_at'] ) ? (string) $row['granted_at'] : null, + 'metadata' => array( 'source' => self::PRINCIPAL_TABLE_NAME ), + ); + } + + /** + * Return the full principal grants table name. + */ + private function principal_table_name(): string { + return static::get_table_prefix() . self::PRINCIPAL_TABLE_NAME; + } + + /** + * Normalize a non-user principal type. + */ + private function normalize_principal_type( string $principal_type ): string { + return sanitize_key( $principal_type ); + } + + /** + * Normalize a non-user principal identifier. + */ + private function normalize_principal_id( string $principal_id ): string { + return sanitize_title( $principal_id ); + } + /** * Get roles at or above the given role level. * diff --git a/tests/agents-api-access-store-adapter-smoke.php b/tests/agents-api-access-store-adapter-smoke.php index 7604bda0..7aa33dab 100644 --- a/tests/agents-api-access-store-adapter-smoke.php +++ b/tests/agents-api-access-store-adapter-smoke.php @@ -19,6 +19,13 @@ use DataMachine\Core\Database\Agents\AgentAccess; use DataMachine\Core\Database\Agents\Agents; +if ( ! function_exists( 'sanitize_key' ) ) { + function sanitize_key( $key ) { + $key = strtolower( (string) $key ); + return preg_replace( '/[^a-z0-9_\-]/', '', $key ); + } +} + $GLOBALS['wpdb'] = (object) array( 'base_prefix' => 'wp_', 'prefix' => 'wp_', @@ -78,9 +85,69 @@ public function get_users_for_agent( string $agent_id, ?string $workspace_id = n ); } + public function grant_principal_access( string $agent_id, string $principal_type, string $principal_id, string $role ): array { + $grant = array( + 'agent_id' => $agent_id, + 'principal_type' => $principal_type, + 'principal_id' => $principal_id, + 'role' => $role, + 'workspace_id' => null, + 'metadata' => array( 'source' => 'fake' ), + ); + $this->grants[ $this->principal_key( $agent_id, $principal_type, $principal_id ) ] = $grant; + return $grant; + } + + public function revoke_principal_access( string $agent_id, string $principal_type, string $principal_id ): bool { + $key = $this->principal_key( $agent_id, $principal_type, $principal_id ); + if ( ! isset( $this->grants[ $key ] ) ) { + return false; + } + + unset( $this->grants[ $key ] ); + return true; + } + + public function get_principal_access( string $agent_id, string $principal_type, string $principal_id, ?string $workspace_id = null ): ?array { + unset( $workspace_id ); + return $this->grants[ $this->principal_key( $agent_id, $principal_type, $principal_id ) ] ?? null; + } + + public function get_agent_ids_for_principal( string $principal_type, string $principal_id, ?string $minimum_role = null, ?string $workspace_id = null ): array { + unset( $workspace_id ); + $agent_ids = array(); + + foreach ( $this->grants as $grant ) { + if ( ! is_array( $grant ) ) { + continue; + } + + $role_matches = null === $minimum_role || ( new \WP_Agent_Access_Grant( (string) $grant['agent_id'], 1, (string) $grant['role'] ) )->role_meets( $minimum_role ); + if ( $grant['principal_type'] === $principal_type && $grant['principal_id'] === $principal_id && $role_matches ) { + $agent_ids[] = (int) $grant['agent_id']; + } + } + + return $agent_ids; + } + + public function get_principals_for_agent( string $agent_id, ?string $workspace_id = null ): array { + unset( $workspace_id ); + return array_values( + array_filter( + $this->grants, + static fn( $grant ): bool => is_array( $grant ) && $grant['agent_id'] === $agent_id + ) + ); + } + private function key( string $agent_id, int $user_id ): string { return $agent_id . ':' . $user_id; } + + private function principal_key( string $agent_id, string $principal_type, string $principal_id ): string { + return $agent_id . ':' . $principal_type . ':' . $principal_id; + } } class DataMachineAccessStoreAdapterFakeAgentsRepository extends Agents { @@ -142,6 +209,7 @@ public function get_users_for_agent( string $agent_id, ?string $workspace_id = n $adapter = new AgentAccessStoreAdapter( $repository, $agents ); agents_api_smoke_assert_equals( true, $adapter instanceof \WP_Agent_Access_Store, 'adapter implements Agents API store contract', $failures, $passes ); +agents_api_smoke_assert_equals( true, $adapter instanceof \WP_Agent_Principal_Access_Store, 'adapter implements Agents API principal store contract', $failures, $passes ); $existing = new DataMachineAccessStoreAdapterExistingStore(); agents_api_smoke_assert_equals( $existing, AgentAccessStoreAdapter::filter_access_store( $existing ), 'filter preserves existing host store', $failures, $passes ); @@ -159,4 +227,23 @@ public function get_users_for_agent( string $agent_id, ?string $workspace_id = n agents_api_smoke_assert_equals( true, $adapter->revoke_access( 'wiki-brain', 7 ), 'revoke maps slug to storage ID', $failures, $passes ); agents_api_smoke_assert_equals( null, $adapter->get_access( 'wiki-brain', 7 ), 'revoke removes repository grant', $failures, $passes ); +$principal_grant = array( + 'grant_id' => null, + 'agent_id' => 'wiki-brain', + 'user_id' => 0, + 'role' => \WP_Agent_Access_Grant::ROLE_OPERATOR, + 'workspace_id' => null, + 'granted_by_user_id' => null, + 'granted_at' => null, + 'metadata' => array( 'source' => 'fake' ), + 'audience_id' => 'audience:operators', +); +$principal = \AgentsAPI\AI\WP_Agent_Execution_Principal::audience( 'audience:operators', 'frontend-chat' ); +$adapter->grant_access_for_principal( 'wiki-brain', 'audience', 'operators', \WP_Agent_Access_Grant::ROLE_OPERATOR ); +agents_api_smoke_assert_equals( $principal_grant, $adapter->get_access_for_principal( 'wiki-brain', $principal )->to_array(), 'principal grant resolves audience principal', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'wiki-brain' ), $adapter->get_agent_ids_for_principal( $principal, \WP_Agent_Access_Grant::ROLE_VIEWER ), 'principal agent ID list is Agents API slug shape', $failures, $passes ); +agents_api_smoke_assert_equals( array( $principal_grant ), $adapter->get_principals_for_agent( 'wiki-brain' ), 'principal grants list returns slug contract', $failures, $passes ); +agents_api_smoke_assert_equals( true, $adapter->revoke_access_for_principal( 'wiki-brain', 'audience', 'operators' ), 'principal revoke maps slug to storage ID', $failures, $passes ); +agents_api_smoke_assert_equals( null, $adapter->get_access_for_principal( 'wiki-brain', $principal ), 'principal revoke removes grant', $failures, $passes ); + agents_api_smoke_finish( 'access-store adapter smoke', $failures, $passes );