From 965a4d06ed681faec5cbca17f3b6820cb07a3c1a Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Sun, 29 Mar 2026 13:47:43 +0100 Subject: [PATCH 1/2] Add web_fetch ability with pluggable fetchers and action logging --- README.md | 1 + docs/policy-helper.md | 31 +- .../abilities/class-web-fetch-ability.php | 134 ++++++ includes/class-abilities.php | 2 + includes/helpers/class-abilities-helper.php | 70 ++- includes/helpers/class-policy-helper.php | 2 +- includes/helpers/class-web-fetch-helper.php | 400 ++++++++++++++++++ includes/web-fetch/class-wp-fetcher.php | 55 +++ includes/web-fetch/interface-fetcher.php | 30 ++ tests/Support/WordPressStubs.php | 107 +++++ tests/Unit/AbilitiesHelperTest.php | 42 +- tests/Unit/PolicyHelperTest.php | 2 + tests/Unit/WebFetchAbilityTest.php | 343 +++++++++++++++ 13 files changed, 1195 insertions(+), 24 deletions(-) create mode 100644 includes/abilities/class-web-fetch-ability.php create mode 100644 includes/helpers/class-web-fetch-helper.php create mode 100644 includes/web-fetch/class-wp-fetcher.php create mode 100644 includes/web-fetch/interface-fetcher.php create mode 100644 tests/Unit/WebFetchAbilityTest.php diff --git a/README.md b/README.md index 3222f2d..7936445 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Current Agent Features: - `file_read` - `file_write` - `file_delete` + - `web_fetch` (read-only remote fetch via the WordPress HTTP API, validated with `wp_http_validate_url()`, logged to the action log table) - `memory_long_term_add` - `memory_long_term_update` - `memory_long_term_delete` diff --git a/docs/policy-helper.md b/docs/policy-helper.md index 2357cac..bcce3a9 100644 --- a/docs/policy-helper.md +++ b/docs/policy-helper.md @@ -67,7 +67,7 @@ Unknown or empty trigger types are normalized to `chat`. ### `chat` -Uses base defaults (least restrictive profile). +Uses base defaults (least restrictive profile), including `allow_network = true`. ### `heartbeat` @@ -75,10 +75,11 @@ More restrictive than chat: 1. `allow_destructive_tools = false` 2. `allow_file_delete = false` -3. `max_tool_rounds = 2` -4. `max_tool_calls_per_round = 3` -5. `max_wall_time_seconds = 45` -6. `allow_background_followups = false` +3. `allow_network = true` +4. `max_tool_rounds = 2` +5. `max_tool_calls_per_round = 3` +6. `max_wall_time_seconds = 45` +7. `allow_background_followups = false` ### `spawned_agent` @@ -86,9 +87,10 @@ Restrictive but less constrained than heartbeat: 1. `allow_destructive_tools = false` 2. `allow_file_delete = false` -3. `max_tool_rounds = 3` -4. `max_tool_calls_per_round = 4` -5. `max_wall_time_seconds = 90` +3. `allow_network = true` +4. `max_tool_rounds = 3` +5. `max_tool_calls_per_round = 4` +6. `max_wall_time_seconds = 90` ## Where It Is Used @@ -107,9 +109,10 @@ Implementation: `includes/helpers/class-agent-loop-helper.php` `Abilities_Helper::execute_tool_call()` enforces policy gates in this order: 1. `allow_tools` -2. `allow_destructive_tools` (for destructive abilities) -3. `allow_file_delete` (for `file_delete`) -4. `require_confirmation_for_destructive` (confirmation workflow gate) +2. `allow_network` (for network-capable abilities such as `web_fetch`) +3. `allow_destructive_tools` (for destructive abilities) +4. `allow_file_delete` (for `file_delete`) +5. `require_confirmation_for_destructive` (confirmation workflow gate) On policy violation, it returns a structured payload with: @@ -200,8 +203,8 @@ $result = Abilities_Helper::get_instance()->execute_tool_call( ## Suggested Future Improvements -1. Wire `allow_network` to network-capable tools or provider request constraints. -2. Add a policy filter hook (for example, `clawpress_runtime_policy_resolved`) if third-party plugins need policy customization without patching core code. +1. Add a policy filter hook (for example, `clawpress_runtime_policy_resolved`) if third-party plugins need policy customization without patching core code. +2. If future network-capable tools need different restrictions than `web_fetch`, split `allow_network` into capability-specific fields without overloading destructive-tool policy. ## Test Coverage Today @@ -214,4 +217,4 @@ Current coverage includes: Recommended additional coverage: 1. Chat loop behavior under non-default `max_tool_rounds` and `max_tool_calls_per_round`. -2. Any new enforcement path added for currently informational fields. +2. Any new enforcement path added for future policy fields beyond the current tool and network gates. diff --git a/includes/abilities/class-web-fetch-ability.php b/includes/abilities/class-web-fetch-ability.php new file mode 100644 index 0000000..9977e15 --- /dev/null +++ b/includes/abilities/class-web-fetch-ability.php @@ -0,0 +1,134 @@ + __( 'Web Fetch', 'clawpress' ), + 'description' => __( 'Fetch a remote URL using the configured fetcher.', 'clawpress' ), + 'category' => Abilities::CATEGORY_SLUG, + 'input_schema' => [ + 'type' => 'object', + 'required' => [ 'url' ], + 'properties' => [ + 'url' => [ + 'type' => 'string', + 'description' => __( 'Remote URL to fetch.', 'clawpress' ), + ], + 'fetcher' => [ + 'type' => 'string', + 'description' => __( 'Fetcher provider slug. Defaults to `wp`.', 'clawpress' ), + ], + 'method' => [ + 'type' => 'string', + 'enum' => [ 'GET', 'HEAD' ], + 'description' => __( 'Read-only HTTP method. Defaults to `GET`.', 'clawpress' ), + ], + 'headers' => [ + 'type' => 'object', + 'description' => __( 'Optional request headers.', 'clawpress' ), + 'additionalProperties' => [ + 'type' => 'string', + ], + ], + 'timeout' => [ + 'type' => 'integer', + 'description' => __( 'Request timeout in seconds. Defaults to `15`.', 'clawpress' ), + ], + 'redirection' => [ + 'type' => 'integer', + 'description' => __( 'Maximum redirect count. Defaults to `5`.', 'clawpress' ), + ], + 'arguments' => [ + 'type' => 'object', + 'description' => __( 'Optional fetcher-specific arguments for future providers.', 'clawpress' ), + 'additionalProperties' => true, + ], + ], + 'additionalProperties' => false, + ], + 'output_schema' => [ + 'type' => 'object', + 'required' => [ + 'fetcher', + 'url', + 'method', + 'status_code', + 'status_message', + 'headers', + 'content_type', + 'body', + 'truncated', + 'body_bytes', + ], + 'properties' => [ + 'fetcher' => [ 'type' => 'string' ], + 'url' => [ 'type' => 'string' ], + 'method' => [ 'type' => 'string' ], + 'status_code' => [ 'type' => 'integer' ], + 'status_message' => [ 'type' => 'string' ], + 'headers' => [ + 'type' => 'object', + 'additionalProperties' => [ + 'type' => 'string', + ], + ], + 'content_type' => [ 'type' => 'string' ], + 'body' => [ 'type' => 'string' ], + 'truncated' => [ 'type' => 'boolean' ], + 'body_bytes' => [ 'type' => 'integer' ], + ], + 'additionalProperties' => false, + ], + 'execute_callback' => static fn( $input = [] ) => self::execute( is_array( $input ) ? $input : [] ), + 'permission_callback' => static fn(): bool => current_user_can( 'read' ), + 'meta' => [ + 'annotations' => [ + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + 'network' => true, + ], + ], + ] + ); + } + + /** + * Execute ability callback. + * + * @param array $input Ability input. + * @return array|\WP_Error + */ + public static function execute( array $input ) { + return Web_Fetch_Helper::get_instance()->fetch( $input ); + } +} diff --git a/includes/class-abilities.php b/includes/class-abilities.php index 4776ece..72796cd 100644 --- a/includes/class-abilities.php +++ b/includes/class-abilities.php @@ -19,6 +19,7 @@ use ClawPress\Abilities\BuiltIn\Memory_Short_Term_Add_Ability; use ClawPress\Abilities\BuiltIn\Memory_Short_Term_Delete_Ability; use ClawPress\Abilities\BuiltIn\Memory_Short_Term_Update_Ability; +use ClawPress\Abilities\BuiltIn\Web_Fetch_Ability; defined( 'ABSPATH' ) || exit; @@ -60,6 +61,7 @@ public function register_abilities(): void { File_Write_Ability::register(); File_Delete_Ability::register(); File_List_Ability::register(); + Web_Fetch_Ability::register(); Memory_Short_Term_Add_Ability::register(); Memory_Short_Term_Update_Ability::register(); diff --git a/includes/helpers/class-abilities-helper.php b/includes/helpers/class-abilities-helper.php index 9f14c96..b14d22c 100644 --- a/includes/helpers/class-abilities-helper.php +++ b/includes/helpers/class-abilities-helper.php @@ -346,7 +346,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e ], 'tool' => $normalized_tool_name, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload, $event_context ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $args, $payload, $event_context ); return $payload; } @@ -360,7 +360,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'tool' => $normalized_tool_name, 'ability' => $ability_name, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload, $event_context ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $args, $payload, $event_context ); return $payload; } @@ -375,7 +375,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'tool' => $normalized_tool_name, 'ability' => $ability_name, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload, $event_context ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $args, $payload, $event_context ); return $payload; } @@ -392,7 +392,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'tool' => $normalized_tool_name, 'ability' => $ability_name, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload, $event_context ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $args, $payload, $event_context ); return $payload; } @@ -416,6 +416,31 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e $execution_user_id, $this->resolve_policy_violation_log_status( $payload ), $args_hash, + $args, + $payload, + $event_context + ); + return $payload; + } + + if ( $this->is_network_capable( $ability ) && ! $this->is_policy_enabled( $runtime_policy['allow_network'] ?? false ) ) { + $payload = $this->build_policy_violation_payload( + 'clawpress_policy_network_denied', + __( 'Network access is blocked by runtime policy.', 'clawpress' ), + $normalized_tool_name, + $ability_name, + $safety_class, + $runtime_policy, + 'deny_network' + ); + $this->log_tool_call( + $normalized_tool_name, + $ability_name, + $requesting_user_id, + $execution_user_id, + $this->resolve_policy_violation_log_status( $payload ), + $args_hash, + $args, $payload, $event_context ); @@ -439,6 +464,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e $execution_user_id, $this->resolve_policy_violation_log_status( $payload ), $args_hash, + $args, $payload, $event_context ); @@ -462,6 +488,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e $execution_user_id, $this->resolve_policy_violation_log_status( $payload ), $args_hash, + $args, $payload, $event_context ); @@ -481,7 +508,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'ability' => $ability_name, 'safety_class' => $safety_class, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'warning', $args_hash, $payload, $event_context ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'warning', $args_hash, $args, $payload, $event_context ); return $payload; } @@ -508,7 +535,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'ability' => $ability_name, 'safety_class' => $safety_class, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'warning', $args_hash, $payload, $event_context ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'warning', $args_hash, $args, $payload, $event_context ); return $payload; } } @@ -531,7 +558,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'ability' => $ability_name, 'safety_class' => $safety_class, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $payload, $event_context ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'error', $args_hash, $args, $payload, $event_context ); return $payload; } @@ -543,7 +570,7 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e 'result' => $result, ]; - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'success', $args_hash, $payload, $event_context ); + $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'success', $args_hash, $args, $payload, $event_context ); return $payload; } @@ -698,6 +725,16 @@ private function get_ability_annotations( \WP_Ability $ability ): array { ]; } + /** + * Whether an ability is marked as network-capable. + * + * @param \WP_Ability $ability Ability instance. + */ + private function is_network_capable( \WP_Ability $ability ): bool { + $annotations = $ability->get_meta_item( 'annotations', [] ); + return is_array( $annotations ) && true === ( $annotations['network'] ?? false ); + } + /** * Resolve ability category slug. * @@ -1016,6 +1053,7 @@ private function resolve_policy_violation_log_status( array $payload ): string { * @param int $execution_user_id Execution user ID. * @param string $status Log status. * @param string $args_hash Hash of arguments. + * @param array $args Tool arguments. * @param array $payload Tool payload. * @param array $event_context Optional run/session context. */ @@ -1026,6 +1064,7 @@ private function log_tool_call( int $execution_user_id, string $status, string $args_hash, + array $args, array $payload, array $event_context ): void { @@ -1039,5 +1078,20 @@ private function log_tool_call( $payload, $event_context ); + + do_action( + 'clawpress_tool_call_logged', + [ + 'tool_name' => $tool_name, + 'ability_name' => $ability_name, + 'requesting_user_id' => $requesting_user_id, + 'execution_user_id' => $execution_user_id, + 'status' => $status, + 'args_hash' => $args_hash, + 'args' => $args, + 'payload' => $payload, + 'event_context' => $event_context, + ] + ); } } diff --git a/includes/helpers/class-policy-helper.php b/includes/helpers/class-policy-helper.php index 0a53ed7..31d2f95 100644 --- a/includes/helpers/class-policy-helper.php +++ b/includes/helpers/class-policy-helper.php @@ -35,7 +35,7 @@ final class Policy_Helper { 'max_tool_rounds' => 4, 'max_tool_calls_per_round' => 6, 'max_wall_time_seconds' => 120, - 'allow_network' => false, + 'allow_network' => true, 'allow_background_followups' => true, 'on_policy_violation' => 'deny', ]; diff --git a/includes/helpers/class-web-fetch-helper.php b/includes/helpers/class-web-fetch-helper.php new file mode 100644 index 0000000..fe11aca --- /dev/null +++ b/includes/helpers/class-web-fetch-helper.php @@ -0,0 +1,400 @@ + + */ + private const ALLOWED_METHODS = [ 'GET', 'HEAD' ]; + + /** + * Singleton instance. + * + * @var ?self + */ + private static ?self $instance = null; + + /** + * Registered fetchers by slug. + * + * @var array + */ + private array $fetchers = []; + + /** + * Constructor. + */ + private function __construct() { + $this->register_fetcher( new WP_Fetcher() ); + } + + /** + * Get singleton instance. + */ + public static function get_instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Register the generic tool-call listener used for fetch action logging. + */ + public static function register_logging_hook(): void { + add_action( 'clawpress_tool_call_logged', [ __CLASS__, 'handle_tool_call_logged' ] ); + } + + /** + * Write action-log rows for web_fetch tool attempts. + * + * @param array $event Generic tool-call event payload. + */ + public static function handle_tool_call_logged( array $event ): void { + $ability_name = isset( $event['ability_name'] ) ? (string) $event['ability_name'] : ''; + if ( 'clawpress/web-fetch' !== $ability_name ) { + return; + } + + $args = isset( $event['args'] ) && is_array( $event['args'] ) ? $event['args'] : []; + $payload = isset( $event['payload'] ) && is_array( $event['payload'] ) ? $event['payload'] : []; + $requesting_user_id = isset( $event['requesting_user_id'] ) ? (int) $event['requesting_user_id'] : 0; + $execution_user_id = isset( $event['execution_user_id'] ) ? (int) $event['execution_user_id'] : 0; + $result = isset( $payload['result'] ) && is_array( $payload['result'] ) ? $payload['result'] : []; + $fetcher = isset( $result['fetcher'] ) && '' !== trim( (string) $result['fetcher'] ) + ? (string) $result['fetcher'] + : strtolower( trim( (string) ( $args['fetcher'] ?? 'wp' ) ) ); + $url = isset( $result['url'] ) && '' !== trim( (string) $result['url'] ) + ? (string) $result['url'] + : trim( (string) ( $args['url'] ?? '' ) ); + $method = isset( $result['method'] ) && '' !== trim( (string) $result['method'] ) + ? strtoupper( trim( (string) $result['method'] ) ) + : strtoupper( trim( (string) ( $args['method'] ?? 'GET' ) ) ); + $status_code = isset( $result['status_code'] ) ? (int) $result['status_code'] : null; + $body_bytes = isset( $result['body_bytes'] ) ? (int) $result['body_bytes'] : null; + $log_status = ! empty( $payload['success'] ) + ? ( ! empty( $payload['degraded'] ) ? 'warning' : 'success' ) + : ( isset( $payload['policy'] ) && is_array( $payload['policy'] ) + ? self::resolve_policy_violation_log_status( $payload ) + : 'error' ); + $error_code = isset( $payload['error']['code'] ) ? (string) $payload['error']['code'] : ''; + $error_message = isset( $payload['error']['message'] ) ? (string) $payload['error']['message'] : ''; + + if ( ! empty( $payload['success'] ) && null !== $status_code ) { + $message = sprintf( + /* translators: 1: HTTP method, 2: URL, 3: HTTP status code. */ + __( 'Web fetch %1$s %2$s completed with status %3$d.', 'clawpress' ), + $method, + $url, + $status_code + ); + } elseif ( '' !== $error_message ) { + $message = sprintf( + /* translators: 1: HTTP method, 2: URL, 3: error message. */ + __( 'Web fetch %1$s %2$s failed: %3$s', 'clawpress' ), + $method, + $url, + $error_message + ); + } else { + $message = sprintf( + /* translators: 1: HTTP method, 2: URL. */ + __( 'Web fetch %1$s %2$s was blocked.', 'clawpress' ), + $method, + $url + ); + } + + $context = [ + 'tool' => isset( $event['tool_name'] ) ? (string) $event['tool_name'] : 'web_fetch', + 'ability' => $ability_name, + 'fetcher' => '' !== $fetcher ? $fetcher : 'wp', + 'url' => $url, + 'method' => '' !== $method ? $method : 'GET', + 'status_code' => $status_code, + 'truncated' => ! empty( $result['truncated'] ), + 'body_bytes' => $body_bytes, + 'requesting_user_id' => $requesting_user_id, + 'execution_user_id' => $execution_user_id, + ]; + + if ( isset( $payload['policy'] ) && is_array( $payload['policy'] ) ) { + $context['policy'] = $payload['policy']; + } + + if ( '' !== $error_code || '' !== $error_message ) { + $context['error'] = [ + 'code' => $error_code, + 'message' => $error_message, + ]; + } + + if ( ! empty( $payload['degraded'] ) ) { + $context['degraded'] = true; + } + + Action_Log_Helper::get_instance()->log_event( + 'web_fetch', + [ + 'event_type' => 'tool_call', + 'status' => $log_status, + 'message' => $message, + 'requesting_user_id' => $requesting_user_id, + 'execution_user_id' => $execution_user_id, + 'context' => $context, + ] + ); + } + + /** + * Register a fetcher implementation. + */ + public function register_fetcher( Fetcher_Interface $fetcher ): void { + $slug = $this->normalize_fetcher_slug( $fetcher->get_slug() ); + if ( '' === $slug ) { + return; + } + + $this->fetchers[ $slug ] = $fetcher; + } + + /** + * Execute a normalized fetch request. + * + * @param array $input Raw ability input. + * @return array|\WP_Error + */ + public function fetch( array $input ) { + $request = $this->normalize_request( $input ); + if ( is_wp_error( $request ) ) { + return $request; + } + + $fetcher = $this->fetchers[ $request['fetcher'] ] ?? null; + if ( ! $fetcher instanceof Fetcher_Interface ) { + return new \WP_Error( + 'clawpress_web_fetch_unknown_fetcher', + __( 'The requested `fetcher` is not registered.', 'clawpress' ) + ); + } + + $response = $fetcher->fetch( $request ); + if ( is_wp_error( $response ) ) { + return $response; + } + + return $this->normalize_response( $request, $response ); + } + + /** + * Normalize one raw request payload. + * + * @param array $input Raw ability input. + * @return array|\WP_Error + */ + private function normalize_request( array $input ) { + $raw_url = isset( $input['url'] ) ? trim( (string) $input['url'] ) : ''; + $url = '' !== $raw_url ? esc_url_raw( $raw_url ) : ''; + $url = '' !== $url ? wp_http_validate_url( $url ) : false; + + if ( ! is_string( $url ) || '' === trim( $url ) ) { + return new \WP_Error( + 'clawpress_web_fetch_invalid_url', + __( 'A valid `url` is required.', 'clawpress' ) + ); + } + + $fetcher = $this->normalize_fetcher_slug( $input['fetcher'] ?? self::DEFAULT_FETCHER ); + if ( '' === $fetcher ) { + $fetcher = self::DEFAULT_FETCHER; + } + + $method = strtoupper( trim( (string) ( $input['method'] ?? self::DEFAULT_METHOD ) ) ); + if ( ! in_array( $method, self::ALLOWED_METHODS, true ) ) { + return new \WP_Error( + 'clawpress_web_fetch_invalid_method', + __( 'The provided `method` is not supported.', 'clawpress' ) + ); + } + + return [ + 'url' => $url, + 'fetcher' => $fetcher, + 'method' => $method, + 'headers' => $this->normalize_headers( $input['headers'] ?? [] ), + 'timeout' => $this->normalize_timeout( $input['timeout'] ?? self::DEFAULT_TIMEOUT ), + 'redirection' => $this->normalize_redirection( $input['redirection'] ?? self::DEFAULT_REDIRECTION ), + 'arguments' => is_array( $input['arguments'] ?? null ) ? $input['arguments'] : [], + ]; + } + + /** + * Normalize one fetcher response payload. + * + * @param array $request Normalized request payload. + * @param array $response Raw fetcher response. + * @return array|\WP_Error + */ + private function normalize_response( array $request, array $response ) { + $headers = $this->normalize_headers( $response['headers'] ?? [] ); + $body = isset( $response['body'] ) ? (string) $response['body'] : ''; + $body_bytes = strlen( $body ); + $truncated = $body_bytes > self::MAX_BODY_BYTES; + $truncated_body = $truncated ? substr( $body, 0, self::MAX_BODY_BYTES ) : $body; + + $content_type = isset( $headers['content-type'] ) ? (string) $headers['content-type'] : ''; + if ( '' !== $content_type && false !== strpos( $content_type, ';' ) ) { + $content_type = trim( (string) strtok( $content_type, ';' ) ); + } + + return [ + 'fetcher' => (string) $request['fetcher'], + 'url' => (string) $request['url'], + 'method' => (string) $request['method'], + 'status_code' => isset( $response['status_code'] ) ? (int) $response['status_code'] : 0, + 'status_message' => isset( $response['status_message'] ) ? (string) $response['status_message'] : '', + 'headers' => $headers, + 'content_type' => $content_type, + 'body' => $truncated_body, + 'truncated' => $truncated, + 'body_bytes' => $body_bytes, + ]; + } + + /** + * Normalize a fetcher slug. + * + * @param mixed $value Raw slug value. + */ + private function normalize_fetcher_slug( $value ): string { + return strtolower( sanitize_key( sanitize_text_field( (string) $value ) ) ); + } + + /** + * Normalize HTTP headers into a lower-case string map. + * + * @param mixed $headers Raw headers payload. + * @return array + */ + private function normalize_headers( $headers ): array { + if ( is_object( $headers ) && method_exists( $headers, 'getAll' ) ) { + $headers = $headers->getAll(); + } + + if ( ! is_array( $headers ) ) { + return []; + } + + $normalized = []; + + foreach ( $headers as $name => $value ) { + $normalized_name = strtolower( trim( preg_replace( '/[^A-Za-z0-9\-]/', '', (string) $name ) ?? '' ) ); + if ( '' === $normalized_name ) { + continue; + } + + if ( is_array( $value ) ) { + $value = implode( + ', ', + array_map( + static fn( $item ): string => trim( str_replace( [ "\r", "\n" ], '', (string) $item ) ), + $value + ) + ); + } + + $normalized[ $normalized_name ] = trim( str_replace( [ "\r", "\n" ], '', (string) $value ) ); + } + + return $normalized; + } + + /** + * Normalize timeout. + * + * @param mixed $value Raw timeout. + */ + private function normalize_timeout( $value ): int { + $timeout = (int) $value; + if ( $timeout <= 0 ) { + return self::DEFAULT_TIMEOUT; + } + + return min( $timeout, 120 ); + } + + /** + * Normalize redirect count. + * + * @param mixed $value Raw redirect count. + */ + private function normalize_redirection( $value ): int { + $redirection = (int) $value; + if ( $redirection < 0 ) { + return self::DEFAULT_REDIRECTION; + } + + return min( $redirection, 10 ); + } + + /** + * Determine action-log status for policy violations. + * + * @param array $payload Policy violation payload. + */ + private static function resolve_policy_violation_log_status( array $payload ): string { + if ( ! empty( $payload['success'] ) ) { + return 'success'; + } + + $on_violation = isset( $payload['policy']['on_violation'] ) + ? strtolower( trim( (string) $payload['policy']['on_violation'] ) ) + : 'deny'; + + return 'fail' === $on_violation ? 'error' : 'warning'; + } +} diff --git a/includes/web-fetch/class-wp-fetcher.php b/includes/web-fetch/class-wp-fetcher.php new file mode 100644 index 0000000..951173f --- /dev/null +++ b/includes/web-fetch/class-wp-fetcher.php @@ -0,0 +1,55 @@ + $request Normalized request payload. + * @return array|\WP_Error + */ + public function fetch( array $request ) { + $response = wp_remote_request( + (string) $request['url'], + [ + 'method' => (string) $request['method'], + 'timeout' => (int) $request['timeout'], + 'redirection' => (int) $request['redirection'], + 'headers' => isset( $request['headers'] ) && is_array( $request['headers'] ) + ? $request['headers'] + : [], + ] + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + return [ + 'status_code' => (int) wp_remote_retrieve_response_code( $response ), + 'status_message' => (string) wp_remote_retrieve_response_message( $response ), + 'headers' => wp_remote_retrieve_headers( $response ), + 'body' => (string) wp_remote_retrieve_body( $response ), + ]; + } +} diff --git a/includes/web-fetch/interface-fetcher.php b/includes/web-fetch/interface-fetcher.php new file mode 100644 index 0000000..f5df0d6 --- /dev/null +++ b/includes/web-fetch/interface-fetcher.php @@ -0,0 +1,30 @@ + $request Normalized request payload. + * @return array|\WP_Error + */ + public function fetch( array $request ); +} diff --git a/tests/Support/WordPressStubs.php b/tests/Support/WordPressStubs.php index cd98748..3ba3b96 100644 --- a/tests/Support/WordPressStubs.php +++ b/tests/Support/WordPressStubs.php @@ -73,6 +73,18 @@ final class WordPress_Stubs { /** @var array> */ public static array $post_meta = array(); + /** @var array> */ + public static array $remote_requests = array(); + + /** @var array */ + public static array $remote_request_responses = array(); + + /** @var array */ + public static array $validated_urls = array(); + + /** @var array */ + public static array $http_validate_url_results = array(); + public static int $next_post_id = 1; public static bool $can_manage_options = true; @@ -114,6 +126,10 @@ public static function reset(): void { self::$user_meta = array(); self::$posts = array(); self::$post_meta = array(); + self::$remote_requests = array(); + self::$remote_request_responses = array(); + self::$validated_urls = array(); + self::$http_validate_url_results = array(); self::$next_post_id = 1; self::$can_manage_options = true; self::$user_capabilities = array(); @@ -766,6 +782,97 @@ function esc_url_raw( string $url ): string { } } + if ( ! function_exists( 'wp_http_validate_url' ) ) { + function wp_http_validate_url( string $url ) { + WordPress_Stubs::$validated_urls[] = $url; + + if ( array_key_exists( $url, WordPress_Stubs::$http_validate_url_results ) ) { + return WordPress_Stubs::$http_validate_url_results[ $url ]; + } + + $validated = filter_var( $url, FILTER_VALIDATE_URL ); + if ( false === $validated ) { + return false; + } + + $scheme = strtolower( (string) parse_url( $validated, PHP_URL_SCHEME ) ); + if ( ! in_array( $scheme, [ 'http', 'https' ], true ) ) { + return false; + } + + return (string) $validated; + } + } + + if ( ! function_exists( 'wp_remote_request' ) ) { + function wp_remote_request( string $url, array $args = array() ) { + WordPress_Stubs::$remote_requests[] = [ + 'url' => $url, + 'args' => $args, + ]; + + $method = strtoupper( trim( (string) ( $args['method'] ?? 'GET' ) ) ); + $signature = $method . ' ' . $url; + + if ( array_key_exists( $signature, WordPress_Stubs::$remote_request_responses ) ) { + return WordPress_Stubs::$remote_request_responses[ $signature ]; + } + + if ( array_key_exists( $url, WordPress_Stubs::$remote_request_responses ) ) { + return WordPress_Stubs::$remote_request_responses[ $url ]; + } + + return [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'headers' => [], + 'body' => '', + ]; + } + } + + if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { + function wp_remote_retrieve_response_code( $response ): int { + if ( ! is_array( $response ) || ! isset( $response['response']['code'] ) ) { + return 0; + } + + return (int) $response['response']['code']; + } + } + + if ( ! function_exists( 'wp_remote_retrieve_response_message' ) ) { + function wp_remote_retrieve_response_message( $response ): string { + if ( ! is_array( $response ) || ! isset( $response['response']['message'] ) ) { + return ''; + } + + return (string) $response['response']['message']; + } + } + + if ( ! function_exists( 'wp_remote_retrieve_headers' ) ) { + function wp_remote_retrieve_headers( $response ) { + if ( ! is_array( $response ) || ! isset( $response['headers'] ) ) { + return []; + } + + return $response['headers']; + } + } + + if ( ! function_exists( 'wp_remote_retrieve_body' ) ) { + function wp_remote_retrieve_body( $response ): string { + if ( ! is_array( $response ) || ! isset( $response['body'] ) ) { + return ''; + } + + return (string) $response['body']; + } + } + if ( ! function_exists( 'wp_create_nonce' ) ) { function wp_create_nonce( string $action = '-1' ): string { return 'nonce-' . $action; diff --git a/tests/Unit/AbilitiesHelperTest.php b/tests/Unit/AbilitiesHelperTest.php index 9188e38..076ba3a 100644 --- a/tests/Unit/AbilitiesHelperTest.php +++ b/tests/Unit/AbilitiesHelperTest.php @@ -67,10 +67,11 @@ protected function tearDown(): void { public function test_tool_declarations_are_built_from_registered_allowlist(): void { $declarations = Abilities_Helper::get_instance()->get_tool_declarations(); - $this->assertCount( 10, $declarations ); + $this->assertCount( 11, $declarations ); $this->assertInstanceOf( FunctionDeclaration::class, $declarations[0] ); $this->assertContains( 'file_read', array_map( static fn( FunctionDeclaration $item ): string => $item->getName(), $declarations ) ); $this->assertContains( 'memory_long_term_delete', array_map( static fn( FunctionDeclaration $item ): string => $item->getName(), $declarations ) ); + $this->assertContains( 'web_fetch', array_map( static fn( FunctionDeclaration $item ): string => $item->getName(), $declarations ) ); } public function test_tool_declarations_respect_enabled_abilities_option(): void { @@ -363,6 +364,45 @@ public function test_policy_violation_fail_mode_returns_error_payload(): void { $this->assertSame( 'fail', $result['policy']['on_violation'] ); } + public function test_network_tools_are_denied_when_runtime_policy_disallows_network_and_logged(): void { + $result = Abilities_Helper::get_instance()->execute_tool_call( + 'web_fetch', + [ + 'url' => 'https://example.test/feed', + ], + [ + 'requesting_user_id' => 1, + 'execution_user_id' => 1, + 'runtime_policy' => [ + 'trigger_type' => 'spawned_agent', + 'policy_profile' => 'default', + 'allow_tools' => true, + 'allow_network' => false, + 'allow_destructive_tools' => true, + 'require_confirmation_for_destructive' => true, + 'allow_file_delete' => true, + 'on_policy_violation' => 'deny', + ], + ] + ); + + $this->assertFalse( $result['success'] ); + $this->assertSame( 'clawpress_policy_network_denied', $result['error']['code'] ); + $this->assertSame( 'deny_network', $result['policy']['decision'] ); + + $action_log_inserts = array_values( + array_filter( + $GLOBALS['wpdb']->insert_calls, + static fn( array $call ): bool => 'wp_clawpress_action_logs' === $call['table'] + ) + ); + + $this->assertNotEmpty( $action_log_inserts ); + $context = json_decode( (string) $action_log_inserts[0]['data']['context'], true ); + $this->assertIsArray( $context ); + $this->assertSame( 'deny_network', $context['policy']['decision'] ); + } + public function test_destructive_confirmation_token_must_be_allowlisted_by_execution_context(): void { $initial = Abilities_Helper::get_instance()->execute_tool_call( 'file_delete', diff --git a/tests/Unit/PolicyHelperTest.php b/tests/Unit/PolicyHelperTest.php index b948eed..92a26a9 100644 --- a/tests/Unit/PolicyHelperTest.php +++ b/tests/Unit/PolicyHelperTest.php @@ -18,6 +18,7 @@ public function test_chat_trigger_uses_default_policy_contract(): void { $this->assertSame( 'chat', $policy['trigger_type'] ); $this->assertTrue( $policy['allow_tools'] ); + $this->assertTrue( $policy['allow_network'] ); $this->assertTrue( $policy['allow_destructive_tools'] ); $this->assertTrue( $policy['require_confirmation_for_destructive'] ); $this->assertSame( 6, $policy['max_tool_rounds'] ); @@ -29,6 +30,7 @@ public function test_heartbeat_trigger_resolves_stricter_defaults(): void { $this->assertSame( 'heartbeat', $policy['trigger_type'] ); $this->assertTrue( $policy['allow_tools'] ); + $this->assertTrue( $policy['allow_network'] ); $this->assertFalse( $policy['allow_destructive_tools'] ); $this->assertFalse( $policy['allow_file_delete'] ); $this->assertSame( 2, $policy['max_tool_rounds'] ); diff --git a/tests/Unit/WebFetchAbilityTest.php b/tests/Unit/WebFetchAbilityTest.php new file mode 100644 index 0000000..1e02fe6 --- /dev/null +++ b/tests/Unit/WebFetchAbilityTest.php @@ -0,0 +1,343 @@ +> + */ + public array $insert_calls = []; + + /** + * Capture insert operation. + * + * @param string $table Table name. + * @param array $data Insert row. + * @param array $format Insert formats. + */ + public function insert( string $table, array $data, array $format ) { + $this->insert_calls[] = [ + 'table' => $table, + 'data' => $data, + 'format' => $format, + ]; + + return 1; + } +} + +/** + * Test fetcher used to verify passthrough request arguments. + */ +final class CaptureFetcher implements Fetcher_Interface { + /** + * Last received request payload. + * + * @var array + */ + public static array $last_request = []; + + /** + * Get fetcher slug. + */ + public function get_slug(): string { + return 'capture'; + } + + /** + * Capture the normalized request and return a response payload. + * + * @param array $request Normalized request payload. + * @return array + */ + public function fetch( array $request ) { + self::$last_request = $request; + + return [ + 'status_code' => 204, + 'status_message' => 'No Content', + 'headers' => [ + 'content-type' => 'text/plain', + ], + 'body' => '', + ]; + } +} + +final class WebFetchAbilityTest extends TestCase { + protected function setUp(): void { + parent::setUp(); + $GLOBALS['wpdb'] = new WebFetchAbilityTestWpdb(); + CaptureFetcher::$last_request = []; + + Web_Fetch_Helper::get_instance()->register_fetcher( new CaptureFetcher() ); + + ( new Abilities() ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + protected function tearDown(): void { + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + public function test_execute_tool_call_fetches_url_with_default_wp_fetcher_and_logs_action(): void { + WordPress_Stubs::$remote_request_responses['GET https://example.test/feed'] = [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'headers' => [ + 'content-type' => 'text/plain; charset=utf-8', + 'x-test' => 'yes', + ], + 'body' => 'hello world', + ]; + + $result = Abilities_Helper::get_instance()->execute_tool_call( + 'web_fetch', + [ + 'url' => 'https://example.test/feed', + ], + [ + 'requesting_user_id' => 7, + 'execution_user_id' => 11, + ] + ); + + $this->assertTrue( $result['success'] ); + $this->assertSame( 'wp', $result['result']['fetcher'] ); + $this->assertSame( 200, $result['result']['status_code'] ); + $this->assertSame( 'text/plain', $result['result']['content_type'] ); + $this->assertSame( 'hello world', $result['result']['body'] ); + $this->assertCount( 1, WordPress_Stubs::$remote_requests ); + $this->assertSame( 'https://example.test/feed', WordPress_Stubs::$validated_urls[0] ); + $this->assertSame( 'GET', WordPress_Stubs::$remote_requests[0]['args']['method'] ); + + $log_call = $this->get_last_insert_for_table( 'wp_clawpress_action_logs' ); + $this->assertNotNull( $log_call ); + $this->assertSame( 'web_fetch', $log_call['data']['action_name'] ); + $this->assertSame( 'success', $log_call['data']['status'] ); + + $context = json_decode( (string) $log_call['data']['context'], true ); + $this->assertIsArray( $context ); + $this->assertSame( 'wp', $context['fetcher'] ); + $this->assertSame( 'https://example.test/feed', $context['url'] ); + $this->assertSame( 200, $context['status_code'] ); + $this->assertSame( 11, $log_call['data']['execution_user_id'] ); + } + + public function test_execute_tool_call_supports_head_requests(): void { + WordPress_Stubs::$remote_request_responses['HEAD https://example.test/health'] = [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'headers' => [ + 'content-type' => 'text/plain', + ], + 'body' => '', + ]; + + $result = Abilities_Helper::get_instance()->execute_tool_call( + 'web_fetch', + [ + 'url' => 'https://example.test/health', + 'method' => 'HEAD', + ], + [ + 'requesting_user_id' => 1, + 'execution_user_id' => 1, + ] + ); + + $this->assertTrue( $result['success'] ); + $this->assertSame( 'HEAD', $result['result']['method'] ); + $this->assertSame( 'HEAD', WordPress_Stubs::$remote_requests[0]['args']['method'] ); + } + + public function test_execute_tool_call_rejects_invalid_urls_and_logs_failure(): void { + WordPress_Stubs::$http_validate_url_results['notaurl'] = false; + + $result = Abilities_Helper::get_instance()->execute_tool_call( + 'web_fetch', + [ + 'url' => 'notaurl', + ], + [ + 'requesting_user_id' => 2, + 'execution_user_id' => 2, + ] + ); + + $this->assertFalse( $result['success'] ); + $this->assertSame( 'clawpress_web_fetch_invalid_url', $result['error']['code'] ); + $this->assertSame( [], WordPress_Stubs::$remote_requests ); + + $log_call = $this->get_last_insert_for_table( 'wp_clawpress_action_logs' ); + $this->assertNotNull( $log_call ); + $this->assertSame( 'error', $log_call['data']['status'] ); + + $context = json_decode( (string) $log_call['data']['context'], true ); + $this->assertIsArray( $context ); + $this->assertSame( 'clawpress_web_fetch_invalid_url', $context['error']['code'] ); + } + + public function test_execute_tool_call_rejects_unknown_fetcher(): void { + $result = Abilities_Helper::get_instance()->execute_tool_call( + 'web_fetch', + [ + 'url' => 'https://example.test/feed', + 'fetcher' => 'missing', + ], + [ + 'requesting_user_id' => 3, + 'execution_user_id' => 3, + ] + ); + + $this->assertFalse( $result['success'] ); + $this->assertSame( 'clawpress_web_fetch_unknown_fetcher', $result['error']['code'] ); + } + + public function test_execute_tool_call_rejects_unsupported_methods(): void { + $result = Abilities_Helper::get_instance()->execute_tool_call( + 'web_fetch', + [ + 'url' => 'https://example.test/feed', + 'method' => 'POST', + ], + [ + 'requesting_user_id' => 4, + 'execution_user_id' => 4, + ] + ); + + $this->assertFalse( $result['success'] ); + $this->assertSame( 'clawpress_web_fetch_invalid_method', $result['error']['code'] ); + } + + public function test_execute_tool_call_passes_arguments_through_to_custom_fetchers(): void { + $result = Abilities_Helper::get_instance()->execute_tool_call( + 'web_fetch', + [ + 'url' => 'https://example.test/custom', + 'fetcher' => 'capture', + 'arguments' => [ + 'token' => 'abc123', + ], + ], + [ + 'requesting_user_id' => 5, + 'execution_user_id' => 5, + ] + ); + + $this->assertTrue( $result['success'] ); + $this->assertSame( 'capture', $result['result']['fetcher'] ); + $this->assertSame( 'abc123', CaptureFetcher::$last_request['arguments']['token'] ); + } + + public function test_execute_tool_call_truncates_large_response_bodies(): void { + $large_body = str_repeat( 'a', 205000 ); + + WordPress_Stubs::$remote_request_responses['GET https://example.test/large'] = [ + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'headers' => [ + 'content-type' => 'text/plain', + ], + 'body' => $large_body, + ]; + + $result = Abilities_Helper::get_instance()->execute_tool_call( + 'web_fetch', + [ + 'url' => 'https://example.test/large', + ], + [ + 'requesting_user_id' => 6, + 'execution_user_id' => 6, + ] + ); + + $this->assertTrue( $result['success'] ); + $this->assertTrue( $result['result']['truncated'] ); + $this->assertSame( 205000, $result['result']['body_bytes'] ); + $this->assertSame( 204800, strlen( $result['result']['body'] ) ); + } + + public function test_execute_tool_call_propagates_remote_errors_and_logs_failure(): void { + WordPress_Stubs::$remote_request_responses['GET https://example.test/error'] = new \WP_Error( + 'http_request_failed', + 'Network down' + ); + + $result = Abilities_Helper::get_instance()->execute_tool_call( + 'web_fetch', + [ + 'url' => 'https://example.test/error', + ], + [ + 'requesting_user_id' => 8, + 'execution_user_id' => 8, + ] + ); + + $this->assertFalse( $result['success'] ); + $this->assertSame( 'http_request_failed', $result['error']['code'] ); + + $log_call = $this->get_last_insert_for_table( 'wp_clawpress_action_logs' ); + $this->assertNotNull( $log_call ); + $this->assertSame( 'error', $log_call['data']['status'] ); + + $context = json_decode( (string) $log_call['data']['context'], true ); + $this->assertIsArray( $context ); + $this->assertSame( 'http_request_failed', $context['error']['code'] ); + } + + /** + * Find the most recent insert call for a specific table. + * + * @return array|null + */ + private function get_last_insert_for_table( string $table ): ?array { + for ( $index = count( $GLOBALS['wpdb']->insert_calls ) - 1; $index >= 0; --$index ) { + $call = $GLOBALS['wpdb']->insert_calls[ $index ]; + if ( $table === $call['table'] ) { + return $call; + } + } + + return null; + } +} From 6ae8a23e72ac48906b58d4f9a8a990fbf7f15297 Mon Sep 17 00:00:00 2001 From: Brad Vincent Date: Sun, 29 Mar 2026 14:23:52 +0100 Subject: [PATCH 2/2] Add WordPress.org readme and fix plugin packaging --- README.md | 11 ----------- includes/functions.php | 5 +++-- package.json | 22 +++++++++++----------- readme.txt | 29 +++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 readme.txt diff --git a/README.md b/README.md index 7936445..3455d1c 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,6 @@ [Preview in WordPress Playground](https://playground.wordpress.net/?wp=beta&blueprint-url=https://raw.githubusercontent.com/bradvin/clawpress/refs/heads/main/blueprint.json) -``` -Contributors: bradvin, welbinator, foo-bender -Tags: ai, assistant, admin -Requires at least: 7.0 -Tested up to: 7.0 -Requires PHP: 8.1 -Stable tag: 0.0.2 -License: GPLv2 or later -License URI: https://www.gnu.org/licenses/gpl-2.0.html -``` - ## Quick Start ```bash diff --git a/includes/functions.php b/includes/functions.php index bf9b5bc..f02b834 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -58,8 +58,9 @@ function clawpress_render_minimum_wp_version_notice(): void { } /* translators: 1: minimum supported WordPress version, 2: current WordPress version. */ - $message = sprintf( - __( 'ClawPress requires WordPress %1$s or newer. You are currently running WordPress %2$s.', 'clawpress' ), + $message_template = __( 'ClawPress requires WordPress %1$s or newer. You are currently running WordPress %2$s.', 'clawpress' ); + $message = sprintf( + $message_template, '7.0', clawpress_get_wp_version() ?: __( 'an unknown version', 'clawpress' ) ); diff --git a/package.json b/package.json index 7b32d35..fa6c21d 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,22 @@ { "name": "clawpress", "version": "0.0.3", - "files": [ - "build", - "includes", - "languages", - "vendor", - "clawpress.php", - "composer.json", - "README.md", - "LICENSE" - ], + "files": [ + "build", + "includes", + "languages", + "vendor", + "clawpress.php", + "composer.json", + "readme.txt", + "LICENSE" + ], "scripts": { "build": "npm run build:scripts && npm run build:panel && npm run build:pot", "build:scripts": "wp-scripts build --config webpack.scripts.config.js", "build:panel": "wp-scripts build --config webpack.panel.config.js", "build:pot": "mkdir -p languages && wp i18n make-pot . languages/clawpress.pot --domain=clawpress --exclude=build,node_modules,vendor,tests,ideas,docs", - "plugin-zip": "mkdir -p dist && wp-scripts plugin-zip && mv -f \"$npm_package_name.zip\" dist/", + "plugin-zip": "mkdir -p dist && wp-scripts plugin-zip && if unzip -l \"$npm_package_name.zip\" \"$npm_package_name/README.md\" >/dev/null 2>&1; then zip -dq \"$npm_package_name.zip\" \"$npm_package_name/README.md\"; fi && mv -f \"$npm_package_name.zip\" dist/", "start": "npm run start:scripts & npm run start:panel", "start:scripts": "wp-scripts start --config webpack.scripts.config.js", "start:panel": "wp-scripts start --config webpack.panel.config.js", diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..bee0f23 --- /dev/null +++ b/readme.txt @@ -0,0 +1,29 @@ +=== ClawPress === +Contributors: bradvin, welbinator, foo-bender +Tags: ai, assistant, agent, openclaw, harness +Requires at least: 7.0 +Tested up to: 7.0 +Requires PHP: 8.1 +Stable tag: 0.0.3 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +The AI Agent for WordPress that actually does things + +== Description == + +ClawPress brings AI assistant workflows into WordPress admin. + +Current features include: + +* A floating chat panel across admin screens. +* Configurable providers, models, and agent abilities. +* Persistent agent memory, agent files, and workspace support. +* Built-in abilities including file operations and read-only web fetches. +* Action log tracking for agent tool calls. + +== Installation == + +1. Upload the plugin files to the `/wp-content/plugins/clawpress` directory, or install the plugin through the WordPress plugins screen. +2. Activate the plugin through the `Plugins` screen in WordPress. +3. Configure the provider and model settings from the ClawPress admin screens.