'string', 'single' => true, 'default' => '', 'show_in_rest' => false, 'sanitize_callback' => 'sanitize_text_field', 'auth_callback' => $auth, ) ); register_meta( 'user', OPENSTATION_AGENT_INSTRUCTIONS_META, array( 'type' => 'string', 'single' => true, 'default' => '', 'show_in_rest' => false, 'sanitize_callback' => 'wp_kses_post', 'auth_callback' => $auth, ) ); register_meta( 'user', OPENSTATION_AGENT_ABILITIES_META, array( 'type' => 'string', 'single' => true, 'default' => '', 'show_in_rest' => false, 'sanitize_callback' => 'openstation_agent_sanitize_abilities_json', 'auth_callback' => $auth, ) ); register_meta( 'user', OPENSTATION_AGENT_TRIGGERS_META, array( 'type' => 'string', 'single' => true, 'default' => '', 'show_in_rest' => false, 'sanitize_callback' => 'openstation_agent_sanitize_triggers_json', 'auth_callback' => $auth, ) ); register_meta( 'user', OPENSTATION_AGENT_MODEL_META, array( 'type' => 'string', 'single' => true, 'default' => '', 'show_in_rest' => false, 'sanitize_callback' => 'sanitize_text_field', 'auth_callback' => $auth, ) ); register_meta( 'user', OPENSTATION_AGENT_RATE_LIMIT_META, array( 'type' => 'integer', 'single' => true, 'default' => 0, 'show_in_rest' => false, 'sanitize_callback' => 'absint', 'auth_callback' => $auth, ) ); } add_action( 'init', 'openstation_agents_register_user_meta' ); // --------------------------------------------------------------------------- // Sanitizers // --------------------------------------------------------------------------- /** * Normalize an ability-slug list: strings only, trimmed, deduped. * * @param mixed $value Incoming list. * @return string[] */ function openstation_agents_sanitize_ability_slugs( $value ) { if ( is_string( $value ) ) { $decoded = json_decode( $value, true ); $value = is_array( $decoded ) ? $decoded : array(); } if ( ! is_array( $value ) ) { return array(); } $out = array(); foreach ( $value as $slug ) { if ( ! is_string( $slug ) ) { continue; } $clean = sanitize_text_field( $slug ); if ( '' === $clean ) { continue; } $out[] = $clean; } return array_values( array_unique( $out ) ); } /** * `register_meta` sanitize callback — abilities land on disk as a JSON * string so a read is one meta row and no PHP-serialized arrays exist. * * @param mixed $value Incoming value (array or JSON string). * @return string JSON-encoded slug list. */ function openstation_agent_sanitize_abilities_json( $value ) { return (string) wp_json_encode( openstation_agents_sanitize_ability_slugs( $value ) ); } /** * Sanitize the triggers array. * * Validates each row against the kind catalogue. Drops any row that * doesn't match a known kind — one bad row never rejects the whole * array. * * @param mixed $value Incoming triggers array (or JSON string). * @return array */ function openstation_agent_sanitize_triggers( $value ) { if ( is_string( $value ) ) { $decoded = json_decode( $value, true ); $value = is_array( $decoded ) ? $decoded : array(); } if ( ! is_array( $value ) ) { return array(); } $known_kinds = array(); foreach ( openstation_agent_trigger_kinds() as $kind ) { $known_kinds[ $kind['slug'] ] = $kind; } $out = array(); foreach ( $value as $row ) { if ( ! is_array( $row ) ) { continue; } $kind = isset( $row['kind'] ) ? sanitize_key( $row['kind'] ) : ''; if ( '' === $kind || ! isset( $known_kinds[ $kind ] ) ) { continue; } $config = isset( $row['config'] ) && is_array( $row['config'] ) ? $row['config'] : array(); $config = openstation_agent_sanitize_trigger_config_deep( $config ); $out[] = array( 'kind' => $kind, 'config' => $config, ); } return $out; } /** * `register_meta` sanitize callback — triggers land on disk as JSON. * * @param mixed $value Incoming value. * @return string JSON-encoded triggers list. */ function openstation_agent_sanitize_triggers_json( $value ) { return (string) wp_json_encode( openstation_agent_sanitize_triggers( $value ) ); } /** * Recursively coerce trigger-config values into safe primitives. * * Keys are camelCase by convention (`entityKinds`, `mimeTypes`, * `fromAgents`) because they round-trip through the JS REST adapter * verbatim — so the case is preserved and only non-identifier * characters are stripped. `sanitize_key()` would lower-case * everything, breaking the contract with the client. * * @param mixed $value Arbitrary input. * @return mixed */ function openstation_agent_sanitize_trigger_config_deep( $value ) { if ( is_array( $value ) ) { $out = array(); foreach ( $value as $k => $v ) { if ( is_string( $k ) ) { $key = preg_replace( '/[^A-Za-z0-9_\-]/', '', $k ); if ( '' === $key ) { continue; } } else { $key = (int) $k; } $out[ $key ] = openstation_agent_sanitize_trigger_config_deep( $v ); } return $out; } if ( is_bool( $value ) || is_int( $value ) ) { return $value; } if ( is_numeric( $value ) ) { return $value + 0; } if ( is_string( $value ) ) { return sanitize_text_field( $value ); } return null; } // --------------------------------------------------------------------------- // Catalogues // --------------------------------------------------------------------------- /** * Built-in trigger kinds. * * `chat`, `send-to`, and `drag` are wired; the other kinds are declared so the * Triggers pane can already store configuration for them, and later * phases add the intake plumbing without a storage migration. * * Plugins can extend the list via the `openstation_agent_trigger_kinds` * filter — each entry must declare a `slug`, `label`, and a JSON-Schema * `config_schema` describing the shape of `trigger.config`. * * @return array */ function openstation_agent_trigger_kinds() { $kinds = array( array( 'slug' => 'chat', 'wired' => true, 'label' => __( 'Chat', 'desktop-mode' ), 'description' => __( 'Open a conversation window with the agent.', 'desktop-mode' ), 'icon' => 'dashicons-format-chat', 'config_schema' => array( 'type' => 'object', 'properties' => array( 'capability' => array( 'type' => 'string' ), ), ), ), array( 'slug' => 'send-to', 'wired' => true, 'label' => __( 'Send to (right-click menu)', 'desktop-mode' ), 'description' => __( 'The agent appears as a "Send to…" action in the right-click menu for the entity kinds you pick.', 'desktop-mode' ), 'icon' => 'dashicons-share-alt', 'config_schema' => array( 'type' => 'object', 'properties' => array( 'entityKinds' => array( 'type' => 'array', 'items' => array( 'type' => 'string', 'enum' => array( 'post', 'page', 'media', 'user', 'comment' ), ), ), ), ), ), array( 'slug' => 'drag', 'wired' => true, 'label' => __( 'Drag & drop', 'desktop-mode' ), 'description' => __( 'Drop a tile onto the agent.', 'desktop-mode' ), 'icon' => 'dashicons-move', 'config_schema' => array( 'type' => 'object', 'properties' => array( 'mimeTypes' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), 'entityKinds' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), ), ), ), array( 'slug' => 'hook', 'wired' => false, 'label' => __( 'WordPress hook', 'desktop-mode' ), 'description' => __( 'Run automatically when a WordPress action fires.', 'desktop-mode' ), 'icon' => 'dashicons-admin-plugins', 'config_schema' => array( 'type' => 'object', 'properties' => array( 'hook' => array( 'type' => 'string' ), 'priority' => array( 'type' => 'integer' ), ), 'required' => array( 'hook' ), ), ), array( 'slug' => 'endpoint', 'wired' => false, 'label' => __( 'REST endpoint', 'desktop-mode' ), 'description' => __( 'Expose a REST URL for external services to call.', 'desktop-mode' ), 'icon' => 'dashicons-rest-api', 'config_schema' => array( 'type' => 'object', 'properties' => array( 'auth' => array( 'type' => 'string', 'enum' => array( 'capability', 'application-password' ), ), 'capability' => array( 'type' => 'string' ), ), ), ), array( 'slug' => 'agent', 'wired' => false, 'label' => __( 'Agent-to-agent', 'desktop-mode' ), 'description' => __( 'Run when another agent on this site emits a completion event.', 'desktop-mode' ), 'icon' => 'dashicons-networking', 'config_schema' => array( 'type' => 'object', 'properties' => array( 'fromAgents' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), ), ), ), ); /** * Filter the trigger kinds available to agents. * * @param array $kinds Default trigger kinds. */ $filtered = apply_filters( 'openstation_agent_trigger_kinds', $kinds ); if ( ! is_array( $filtered ) ) { return $kinds; } return array_values( $filtered ); } /** * Curated catalogue of WordPress hooks suggested for the Hook trigger. * * Not exhaustive — just the ones agents are most likely to subscribe * to. The renderer offers it as an autocomplete; the user can type any * hook name. * * @return array */ function openstation_agent_hooks_catalogue() { $hooks = array( array( 'hook' => 'save_post', 'when' => __( 'Every time a post is saved.', 'desktop-mode' ), ), array( 'hook' => 'wp_insert_post', 'when' => __( 'A new post is inserted.', 'desktop-mode' ), ), array( 'hook' => 'transition_post_status', 'when' => __( 'A post status changes.', 'desktop-mode' ), ), array( 'hook' => 'wp_insert_comment', 'when' => __( 'A new comment is inserted.', 'desktop-mode' ), ), array( 'hook' => 'comment_post', 'when' => __( 'A new comment is posted.', 'desktop-mode' ), ), array( 'hook' => 'user_register', 'when' => __( 'A new user registers.', 'desktop-mode' ), ), array( 'hook' => 'profile_update', 'when' => __( 'A user profile is updated.', 'desktop-mode' ), ), array( 'hook' => 'add_attachment', 'when' => __( 'A new attachment is added.', 'desktop-mode' ), ), ); /** * Filter the curated catalogue of suggested hooks for the Hook * trigger configurator. * * @param array $hooks Default catalogue. */ $filtered = apply_filters( 'openstation_agent_hooks_catalogue', $hooks ); return is_array( $filtered ) ? array_values( $filtered ) : $hooks; } /** * Whether the acting user may grant `$role` to an agent. * * An agent runs with its role's capabilities, so granting a role IS * granting capability — it has to be gated like the promotion it is. * Three constraints, all of which must hold: * * 1. `promote_users` — the capability wp-admin requires to set anyone's * role. `edit_users` alone is not enough: role plugins hand * `edit_users` to shop-manager-shaped roles routinely. * 2. `get_editable_roles()` — core's extension point for "roles this * install lets you hand out". NOTE this is a site-wide filtered * list, NOT a per-user one: core's implementation is a bare * `apply_filters( 'editable_roles', wp_roles()->roles )` with no * reference to the current user. It is a useful constraint because * plugins like WooCommerce filter it, but on a stock install it * excludes nothing, so it cannot be the only gate. * 3. `administrator` additionally requires the actor to genuinely be * an administrator (super admin on multisite). This is the one that * stops an `edit_users`-capable non-admin minting an agent that * outranks them — the capability the agent would then act with. * * @param string $role Role slug being assigned. * @return bool */ function openstation_agent_actor_can_assign_role( $role ) { $role = sanitize_key( (string) $role ); $can = current_user_can( 'promote_users' ); if ( $can && 'administrator' === $role ) { $can = is_multisite() ? is_super_admin() : ( current_user_can( 'manage_options' ) && current_user_can( 'create_users' ) ); } /** * Filter whether the acting user may assign a role to an agent. * * The seam for automation that legitimately creates agents outside * a request context (an activation routine, WP-CLI, a scheduled * provisioning job), where there is no current user and the default * answer is therefore a hard no. * * Granting a role here grants the capabilities an agent will act * with — widen it only for code paths you control. * * @param bool $can Whether the assignment is allowed. * @param string $role Role slug being assigned. * @param int $user_id Acting user id (0 when there is none). */ return (bool) apply_filters( 'openstation_agent_actor_can_assign_role', $can, $role, get_current_user_id() ); } /** * Roles an agent may be assigned, constrained to what the acting user * can actually hand out. * * The whitelist keeps agents in the standard content-role band; each * survivor is then run through * {@see openstation_agent_actor_can_assign_role()}, which is where the * real gating lives. * * @return string[] Role slugs. */ function openstation_agent_allowed_roles() { $whitelist = array( 'administrator', 'editor', 'author', 'contributor' ); /** * Filter the roles an agent may be assigned. * * The result is always intersected with `get_editable_roles()` and * then filtered through the per-role actor check — this filter can * narrow or extend the candidate list, but a role it adds still has * to clear both constraints. * * @param string[] $whitelist Default role slugs. */ $whitelist = apply_filters( 'openstation_agent_allowed_roles', $whitelist ); if ( ! is_array( $whitelist ) ) { return array(); } if ( ! function_exists( 'get_editable_roles' ) ) { require_once ABSPATH . 'wp-admin/includes/user.php'; } $editable = array_keys( get_editable_roles() ); $candidates = array_intersect( array_map( 'strval', $whitelist ), $editable ); $allowed = array(); foreach ( $candidates as $role ) { if ( openstation_agent_actor_can_assign_role( $role ) ) { $allowed[] = $role; } } return array_values( $allowed ); } // --------------------------------------------------------------------------- // Getters / setters // --------------------------------------------------------------------------- /** * Read the "when to use" description. * * @param int $user_id Agent user id. * @return string */ function openstation_agent_get_description( $user_id ) { return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_DESCRIPTION_META, true ); } /** * Read the system prompt. * * @param int $user_id Agent user id. * @return string */ function openstation_agent_get_instructions( $user_id ) { return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_INSTRUCTIONS_META, true ); } /** * Read the ability allowlist. * * @param int $user_id Agent user id. * @return string[] */ function openstation_agent_get_abilities( $user_id ) { $raw = get_user_meta( (int) $user_id, OPENSTATION_AGENT_ABILITIES_META, true ); if ( '' === $raw || null === $raw ) { return array(); } return openstation_agents_sanitize_ability_slugs( $raw ); } /** * Read triggers. * * @param int $user_id Agent user id. * @return array */ function openstation_agent_get_triggers( $user_id ) { $raw = get_user_meta( (int) $user_id, OPENSTATION_AGENT_TRIGGERS_META, true ); if ( '' === $raw || null === $raw ) { return array(); } return openstation_agent_sanitize_triggers( $raw ); } /** * Read the model override. * * @param int $user_id Agent user id. * @return string Empty string if not set. */ function openstation_agent_get_model( $user_id ) { return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_MODEL_META, true ); } /** * Read the rate limit (invocations per hour). * * @param int $user_id Agent user id. * @return int Zero when no per-agent override is set. */ function openstation_agent_get_rate_limit( $user_id ) { return (int) get_user_meta( (int) $user_id, OPENSTATION_AGENT_RATE_LIMIT_META, true ); } // --------------------------------------------------------------------------- // Per-agent invocation gate // --------------------------------------------------------------------------- /** * The agent's trigger row for a given invocation source, if any. * * Source slugs on the invoke route map 1:1 onto trigger kinds * (`chat`, `drag`, `send-to`). * * @param int $agent_user_id Agent user id. * @param string $source Invocation source slug. * @return array|null Trigger row, or null when the agent declares none * for this source. */ function openstation_agent_trigger_for_source( $agent_user_id, $source ) { $source = sanitize_key( (string) $source ); foreach ( openstation_agent_get_triggers( (int) $agent_user_id ) as $trigger ) { if ( isset( $trigger['kind'] ) && $source === $trigger['kind'] ) { return $trigger; } } return null; } /** * Whether the current user may invoke THIS agent through THIS source. * * The route-level `openstation_agents_user_can_invoke()` check is * site-wide — it answers "may this user invoke agents at all". This is * the per-agent half: a trigger may declare a `capability` in its * config, and until it is enforced here the field is decorative. The * Triggers pane collects it and the store persists it, so an * administrator restricting an agent to `manage_options` has every * reason to believe it took effect. * * An agent with no trigger for the source, or a trigger that declares * no capability, is left to the route-level check — requiring a * configured trigger would lock out every agent created before triggers * were set up, which is all of them by default. * * @param int $agent_user_id Agent user id. * @param string $source Invocation source slug. * @return bool */ function openstation_agent_user_can_invoke_agent( $agent_user_id, $source = 'chat' ) { $trigger = openstation_agent_trigger_for_source( $agent_user_id, $source ); $capability = ''; if ( is_array( $trigger ) && isset( $trigger['config']['capability'] ) ) { $capability = trim( (string) $trigger['config']['capability'] ); } $can = '' === $capability || current_user_can( $capability ); /** * Filter whether the current user may invoke a specific agent. * * @param bool $can Whether invocation is allowed. * @param int $agent_user_id Agent user id. * @param string $source Invocation source slug. * @param array|null $trigger The matching trigger row, if any. */ return (bool) apply_filters( 'openstation_agent_user_can_invoke_agent', $can, (int) $agent_user_id, (string) $source, $trigger ); } // --------------------------------------------------------------------------- // List helper // --------------------------------------------------------------------------- /** * Every agent on the site, ordered by display name. * * @param array $args Optional overrides merged into the `get_users()` query. * @return WP_User[] */ function openstation_agent_get_agents( $args = array() ) { $defaults = array( 'meta_key' => OPENSTATION_AGENT_USER_MARKER_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'orderby' => 'display_name', 'order' => 'ASC', 'number' => 200, ); return get_users( array_merge( $defaults, is_array( $args ) ? $args : array() ) ); } // --------------------------------------------------------------------------- // Orchestrators — the only write paths, each firing one audit action // --------------------------------------------------------------------------- /** * Create an agent: synthetic user row + definition meta. * * @param array{name:string, role:string, slug?:string, description?:string, instructions?:string, abilities?:array} $args Creation args. * @return WP_User|WP_Error */ function openstation_agent_create( $args ) { $role = isset( $args['role'] ) ? sanitize_key( (string) $args['role'] ) : ''; $allowed = openstation_agent_allowed_roles(); if ( '' === $role || ! in_array( $role, $allowed, true ) ) { return new WP_Error( 'openstation_agent_invalid_role', __( 'Pick a role you are allowed to assign to an agent.', 'desktop-mode' ) ); } $user = openstation_agent_create_user( $args ); if ( is_wp_error( $user ) ) { return $user; } $description = isset( $args['description'] ) ? sanitize_text_field( (string) $args['description'] ) : ''; $instructions = isset( $args['instructions'] ) ? wp_kses_post( (string) $args['instructions'] ) : ''; $abilities = isset( $args['abilities'] ) ? openstation_agents_sanitize_ability_slugs( $args['abilities'] ) : array(); if ( '' !== $description ) { update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description ); } if ( '' !== $instructions ) { update_user_meta( $user->ID, OPENSTATION_AGENT_INSTRUCTIONS_META, $instructions ); } if ( ! empty( $abilities ) ) { update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) ); } update_user_meta( $user->ID, OPENSTATION_AGENT_CREATED_BY_META, get_current_user_id() ); /** * Fires after an agent is created. * * @param int $user_id Agent user id. * @param array $args Sanitized creation fields (name, role, * description, instructions, abilities). * @param int $actor_id User who created the agent. */ do_action( 'openstation_agent_created', (int) $user->ID, array( 'name' => (string) $user->display_name, 'role' => $role, 'description' => $description, 'instructions' => $instructions, 'abilities' => $abilities, ), get_current_user_id() ); return $user; } /** * Update an agent's definition. Accepts any subset of the recognized * fields, applies the valid ones, and fires `openstation_agent_updated` * once with a before/after map of everything that changed. * * Recognized fields: `name`, `role`, `description`, `instructions`, * `abilities`, `triggers`, `model`, `rateLimit`. * * @param int $user_id Agent user id. * @param array $fields Field map. * @return true|WP_Error */ function openstation_agent_update( $user_id, array $fields ) { $user = get_userdata( (int) $user_id ); if ( ! $user || ! openstation_agent_is_agent( $user ) ) { return new WP_Error( 'openstation_agent_not_found', __( 'Agent not found.', 'desktop-mode' ) ); } $changed = array(); if ( isset( $fields['name'] ) ) { $name = sanitize_text_field( (string) $fields['name'] ); if ( '' === $name ) { return new WP_Error( 'openstation_agent_invalid_name', __( 'Agent name cannot be empty.', 'desktop-mode' ) ); } if ( $name !== (string) $user->display_name ) { $changed['name'] = array( 'from' => (string) $user->display_name, 'to' => $name, ); wp_update_user( array( 'ID' => (int) $user->ID, 'display_name' => $name, 'nickname' => $name, ) ); } } if ( isset( $fields['role'] ) ) { $role = sanitize_key( (string) $fields['role'] ); if ( ! in_array( $role, openstation_agent_allowed_roles(), true ) ) { return new WP_Error( 'openstation_agent_invalid_role', __( 'Pick a role you are allowed to assign to an agent.', 'desktop-mode' ) ); } $current_role = is_array( $user->roles ) && ! empty( $user->roles ) ? (string) reset( $user->roles ) : ''; if ( $role !== $current_role ) { $changed['role'] = array( 'from' => $current_role, 'to' => $role, ); $user->set_role( $role ); } } if ( isset( $fields['description'] ) ) { $description = sanitize_text_field( (string) $fields['description'] ); $before = openstation_agent_get_description( $user->ID ); if ( $description !== $before ) { $changed['description'] = array( 'from' => $before, 'to' => $description, ); update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description ); } } if ( isset( $fields['instructions'] ) ) { $instructions = wp_kses_post( (string) $fields['instructions'] ); $before = openstation_agent_get_instructions( $user->ID ); if ( $instructions !== $before ) { $changed['instructions'] = array( 'from' => $before, 'to' => $instructions, ); update_user_meta( $user->ID, OPENSTATION_AGENT_INSTRUCTIONS_META, $instructions ); } } if ( isset( $fields['abilities'] ) ) { $abilities = openstation_agents_sanitize_ability_slugs( $fields['abilities'] ); $before = openstation_agent_get_abilities( $user->ID ); if ( $abilities !== $before ) { $changed['abilities'] = array( 'from' => $before, 'to' => $abilities, ); update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) ); } } if ( isset( $fields['triggers'] ) ) { $triggers = openstation_agent_sanitize_triggers( $fields['triggers'] ); $before = openstation_agent_get_triggers( $user->ID ); if ( $triggers !== $before ) { $changed['triggers'] = array( 'from' => $before, 'to' => $triggers, ); update_user_meta( $user->ID, OPENSTATION_AGENT_TRIGGERS_META, wp_json_encode( $triggers ) ); } } if ( isset( $fields['model'] ) ) { $model = sanitize_text_field( (string) $fields['model'] ); $before = openstation_agent_get_model( $user->ID ); if ( $model !== $before ) { $changed['model'] = array( 'from' => $before, 'to' => $model, ); if ( '' === $model ) { delete_user_meta( $user->ID, OPENSTATION_AGENT_MODEL_META ); } else { update_user_meta( $user->ID, OPENSTATION_AGENT_MODEL_META, $model ); } } } if ( isset( $fields['rateLimit'] ) ) { $rate = max( 0, (int) $fields['rateLimit'] ); $before = openstation_agent_get_rate_limit( $user->ID ); if ( $rate !== $before ) { $changed['rateLimit'] = array( 'from' => $before, 'to' => $rate, ); if ( 0 === $rate ) { delete_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META ); } else { update_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META, $rate ); } } } if ( ! empty( $changed ) ) { /** * Fires after an agent's definition changed. * * User meta has no revisions, so this action IS the audit * trail — each changed field carries its before/after value. * * @param int $user_id Agent user id. * @param array $changed Map of field => { from, to }. * @param int $actor_id User who made the change. */ do_action( 'openstation_agent_updated', (int) $user->ID, $changed, get_current_user_id() ); } return true; }