PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 All 36 releases
← All changes | includes/ai-copilot/search.php +189 -25 1.1.4 → 1.1.12 View file →
@@ -266,9 +266,9 @@
266 266 'properties' => array(
267 267 'answer_type' => array(
268 268 'type' => 'string',
269 269 'enum' => array( 'entity', 'navigation', 'chat' ),
270 - 'description' => 'Classification of the answer: "entity" when you identified a specific post/page/comment the user was asking about. "navigation" when the user asked where to find something in wp-admin and you are returning admin_links. "chat" for conversational responses that don\'t involve finding content or navigation (e.g. greetings, clarifications, "I couldn\'t find anything").',
270 + 'description' => 'Classification of the answer: "entity" when you identified a specific post/page/comment the user was asking about. "navigation" when you are returning admin_links: wp-admin destinations or plugin install links. "chat" for everything else, including summaries of tool results (error logs, site info), greetings, clarifications and "I couldn\'t find anything".',
271 271 ),
272 272 'message' => array(
273 273 'type' => 'string',
274 274 'description' => 'A friendly, conversational response to show the user. Write in first person like a helpful assistant (e.g. "I found your Málaga post — this one", "Here\'s where you manage categories"). NOT a search-engine sentence ("Match found").',
@@ -381,10 +381,16 @@
381 381 * Keyword-searches published posts or pages with WordPress's native search
382 382 * (`WP_Query` `s=`), returning data rich enough for the agent to compare
383 383 * AND for the UI to render links.
384 384 *
385 - * No AI analysis is required — every published post/page is searchable.
385 + * No AI analysis is required — every published, non-password-protected post/page is searchable.
386 386 *
387 + * Password-protected posts are excluded (`has_password => false`): `publish`
388 + * is also the status of a password-protected post, and this tool emits the
389 + * stored body as an excerpt without ever passing through `post_password_required()`.
390 + * Filtering at the query level keeps them out of both `items` and `found_posts`,
391 + * so the `total` counter cannot become an oracle for their contents either.
392 + *
387 393 * @param string $post_type 'post' | 'page'.
388 394 * @param string $query Keyword search terms (may be empty to list newest).
389 395 * @param int $offset
390 396 * @return array
@@ -393,8 +399,9 @@
393 399 $wp_query = new WP_Query(
394 400 array(
395 401 'post_type' => $post_type,
396 402 'post_status' => 'publish',
403 + 'has_password' => false,
397 404 's' => (string) $query,
398 405 'posts_per_page' => OPENSTATION_AI_SEARCH_BATCH_SIZE,
399 406 'offset' => $offset,
400 407 'no_found_rows' => false,
@@ -446,8 +453,82 @@
446 453 return (string) mb_substr( $text, 0, 300 );
447 454 }
448 455
449 456 /**
457 + * Whether the current user may read a post the comment tools are about to
458 + * surface.
459 + *
460 + * A comment being `approved` is a moderation decision — it says nothing about
461 + * who may see the discussion. An approved comment can hang on a private,
462 + * draft, or password-protected post the caller cannot reach, so the comment
463 + * search tools must gate on the PARENT POST's visibility before returning the
464 + * comment text or the parent title. Mirrors Core's
465 + * `WP_REST_Comments_Controller::check_read_post_permission()`:
466 + *
467 + * - a password-protected parent needs the password satisfied or `edit_post`.
468 + * `post_password_required()` honours the `wp-postpass` cookie Core's
469 + * password form sets, and that is deliberate Core parity, not a gap: the
470 + * cookie only exists because the caller already entered the correct
471 + * password, and Core's comments controller reads the same cookie. The
472 + * ability itself has no password input, so a caller who never unlocked
473 + * the post front-end is refused;
474 + * - a publicly viewable parent (public status AND viewable post type) is
475 + * readable by anyone the ability admits;
476 + * - a parent whose post TYPE is not viewable (an internal/admin-only CPT)
477 + * needs `edit_post` — `read_post` cannot stand in, because a public status
478 + * resolves it to plain `read` whatever the type's visibility, which is how
479 + * Core's REST layer needs its own post-type gate too;
480 + * - any other parent (private, draft, pending, …) needs `read_post`.
481 + *
482 + * @param int|WP_Post $post Post ID or object.
483 + * @return bool
484 + */
485 +function openstation_ai_can_read_post( $post ) {
486 + // An id of 0 must stay unreadable: get_post( 0 ) falls back to the global
487 + // $post, which would judge an orphaned comment against an unrelated post.
488 + if ( is_numeric( $post ) && (int) $post <= 0 ) {
489 + return false;
490 + }
491 +
492 + $post = get_post( $post );
493 + if ( ! $post instanceof WP_Post ) {
494 + return false;
495 + }
496 +
497 + if ( post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) {
498 + return false;
499 + }
500 +
501 + if ( is_post_publicly_viewable( $post ) ) {
502 + return true;
503 + }
504 +
505 + $post_type = get_post_type_object( $post->post_type );
506 + if ( ! $post_type || ! is_post_type_viewable( $post_type ) ) {
507 + return current_user_can( 'edit_post', $post->ID );
508 + }
509 +
510 + return current_user_can( 'read_post', $post->ID );
511 +}
512 +
513 +/**
514 + * Whether the current user may read the post a comment is attached to.
515 + *
516 + * Used to drop comments on posts the caller cannot see from the comment
517 + * search results. See {@see openstation_ai_can_read_post()}.
518 + *
519 + * @param int|WP_Comment $comment Comment ID or object.
520 + * @return bool
521 + */
522 +function openstation_ai_can_read_comment_parent( $comment ) {
523 + $comment = get_comment( $comment );
524 + if ( ! $comment instanceof WP_Comment ) {
525 + return false;
526 + }
527 + return openstation_ai_can_read_post( (int) $comment->comment_post_ID );
528 +}
529 +
530 +/**
450 531 * Keyword-searches approved comments across all posts with WordPress's
451 532 * native comment search (`get_comments` `search=`).
452 533 *
453 534 * No AI analysis is required — every approved comment is searchable.
@@ -489,12 +570,28 @@
489 570 if ( $parent_ids ) {
490 571 _prime_post_caches( $parent_ids, false, false );
491 572 }
492 573
574 + // "Approved" is a moderation decision, not a visibility one: drop comments
575 + // whose parent post the caller cannot read (private / draft / password /
576 + // internal CPT), so the comment text and the parent title never leak. See
577 + // openstation_ai_can_read_comment_parent().
578 + //
579 + // This runs per row, after the batch, and that is the price of gating on
580 + // per-caller readability: an Administrator reads comments on private
581 + // posts and a reader who entered a post password reads that post's
582 + // discussion, neither of which a single `post_status` or `has_password`
583 + // query var can express. `total` therefore counts rows this caller does
584 + // not get, and a batch can come back short. The alternative — a blanket
585 + // publish-only, no-password query — would be exact and would also hide
586 + // those discussions from the people entitled to them.
587 + $comments = array_values( array_filter( $comments, 'openstation_ai_can_read_comment_parent' ) );
588 +
493 589 $items = array();
494 590 foreach ( $comments as $comment ) {
591 + // Readable, per the filter above.
495 592 $parent_post = get_post( $comment->comment_post_ID );
496 - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '';
593 + $parent_title = wp_strip_all_tags( $parent_post->post_title );
497 594
498 595 $items[] = array(
499 596 'id' => (int) $comment->comment_ID,
500 597 'type' => 'comment',
@@ -504,9 +601,9 @@
504 601 // Links.
505 602 'url' => (string) get_comment_link( $comment ),
506 603 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID ),
507 604 'post_id' => (int) $comment->comment_post_ID,
508 - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '',
605 + 'post_url' => (string) get_permalink( $parent_post ),
509 606 );
510 607 }
511 608
512 609 return array(
@@ -553,8 +650,25 @@
553 650 'error' => 'post_id must be a positive integer.',
554 651 );
555 652 }
556 653
654 + // The model picks the post id, so it is untrusted the same way an entity
655 + // id is. Comments inherit their parent's reach: a thread on a private,
656 + // draft, password-protected or internal-CPT post is not this user's to
657 + // read, and the envelope below would otherwise echo its title back.
658 + if ( ! openstation_ai_can_read_post( $post_id ) ) {
659 + return array(
660 + 'tool' => 'search_comments_by_post',
661 + 'post_id' => $post_id,
662 + 'offset' => $offset,
663 + 'items' => array(),
664 + 'count' => 0,
665 + 'total' => 0,
666 + 'has_more' => false,
667 + 'error' => 'Post not found or not readable.',
668 + );
669 + }
670 +
557 671 $base_args = array(
558 672 'post_id' => $post_id,
559 673 'status' => 'approve',
560 674 'type' => 'comment',
@@ -573,10 +687,11 @@
573 687 );
574 688
575 689 $total = (int) get_comments( array_merge( $base_args, array( 'count' => true ) ) );
576 690
691 + // Readable, per the gate above.
577 692 $parent_post = get_post( $post_id );
578 - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '';
693 + $parent_title = wp_strip_all_tags( $parent_post->post_title );
579 694
580 695 $items = array();
581 696 foreach ( $comments as $comment ) {
582 697 $items[] = array(
@@ -616,8 +731,22 @@
616 731 * required. Comments opportunistically surface the `spam` / `harmful`
617 732 * verdict when the comment-moderation analysis happens to have run, but
618 733 * its absence never blocks the entity from being returned.
619 734 *
735 + * The id arrives from the MODEL's final answer, and model output is
736 + * untrusted — a search turn can be driven by attacker-controlled content, so
737 + * an injected instruction could name an entity the search tools never
738 + * surfaced. Hydration therefore re-checks readability itself instead of
739 + * trusting that the id came out of a filtered tool result: posts/pages go
740 + * through {@see openstation_ai_can_read_post()}, and so does a comment's
741 + * PARENT, because the comment record carries that post's title and permalink
742 + * — approval is a moderation decision, not a visibility one, and an approved
743 + * comment outlives its post being switched to private or back to draft.
744 + * Reading an unapproved comment needs `edit_comment`, mirroring Core's
745 + * `WP_REST_Comments_Controller::check_read_permission()`; the AI moderation
746 + * verdicts and the wp-admin edit link are narrower still. Unreadable ids
747 + * resolve to null, indistinguishable from nonexistent ones.
748 + *
620 749 * @param string $entity_type 'post' | 'page' | 'comment'.
621 750 * @param int $entity_id
622 751 * @return array|null
623 752 */
@@ -625,15 +754,26 @@
625 754 $entity_id = (int) $entity_id;
626 755
627 756 if ( in_array( $entity_type, array( 'post', 'page' ), true ) ) {
628 757 $post = get_post( $entity_id );
629 - if ( ! $post instanceof WP_Post ) {
758 +
759 + // The id must resolve to an actual post or page. The gate below answers
760 + // type visibility on its own, so this is the contract rather than the
761 + // lock: the record's `type` is what the client renders the card from,
762 + // and post/page is what the search tools surface. A viewable CPT row
763 + // would pass the gate and still have no card to land in.
764 + if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
630 765 return null;
631 766 }
767 +
768 + if ( ! openstation_ai_can_read_post( $post ) ) {
769 + return null;
770 + }
771 +
632 772 return array(
633 773 'id' => $entity_id,
634 774 'type' => $post->post_type,
635 - 'title' => wp_strip_all_tags( $post->post_title ),
775 + 'title' => openstation_plain_text_title( $post->post_title ),
636 776 'status' => $post->post_status,
637 777 'date' => $post->post_date ? substr( $post->post_date, 0, 10 ) : '',
638 778 'url' => (string) get_permalink( $post ),
639 779 'edit_url' => (string) get_edit_post_link( $entity_id, 'raw' ),
@@ -642,25 +782,49 @@
642 782 }
643 783
644 784 if ( 'comment' === $entity_type ) {
645 785 $comment = get_comment( $entity_id );
646 - if ( ! $comment instanceof WP_Comment ) {
786 +
787 + // The parent's reach bounds the comment's: approval is a moderation
788 + // decision, not a visibility one, and this record carries the parent's
789 + // title and permalink — so without this check, naming a comment id
790 + // would walk straight around the post branch's gate above.
791 + if ( ! $comment instanceof WP_Comment || ! openstation_ai_can_read_comment_parent( $comment ) ) {
647 792 return null;
648 793 }
649 - $meta = openstation_ai_get_meta( 'comment', $entity_id );
650 - $parent_post = get_post( $comment->comment_post_ID );
651 - return array(
794 +
795 + // Reading an unapproved comment is an editor's business, per Core's
796 + // WP_REST_Comments_Controller::check_read_permission().
797 + if ( '1' !== (string) $comment->comment_approved && ! current_user_can( 'edit_comment', $entity_id ) ) {
798 + return null;
799 + }
800 +
801 + // The AI verdicts are the moderation queue's data, so they follow the
802 + // moderation capability rather than the per-comment edit one.
803 + $can_moderate = current_user_can( 'moderate_comments' );
804 + $parent_post = get_post( (int) $comment->comment_post_ID );
805 +
806 + $meta = $can_moderate ? openstation_ai_get_meta( 'comment', $entity_id ) : null;
807 + $entity = array(
652 808 'id' => $entity_id,
653 809 'type' => 'comment',
654 810 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content ),
655 811 'post_id' => (int) $comment->comment_post_ID,
656 - 'post_title' => $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '',
657 - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '',
812 + 'post_title' => openstation_plain_text_title( $parent_post->post_title ),
813 + 'post_url' => (string) get_permalink( $parent_post ),
658 814 'url' => (string) get_comment_link( $comment ),
659 - 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . $entity_id ),
660 - 'harmful' => $meta ? (bool) ( $meta['harmful'] ?? false ) : false,
661 - 'spam' => $meta ? (bool) ( $meta['spam'] ?? false ) : false,
815 + 'edit_url' => current_user_can( 'edit_comment', $entity_id )
816 + ? admin_url( 'comment.php?action=editcomment&c=' . $entity_id )
817 + : '',
662 818 );
819 +
820 + // Moderation verdicts are for moderators only.
821 + if ( $can_moderate ) {
822 + $entity['harmful'] = $meta ? (bool) ( $meta['harmful'] ?? false ) : false;
823 + $entity['spam'] = $meta ? (bool) ( $meta['spam'] ?? false ) : false;
824 + }
825 +
826 + return $entity;
663 827 }
664 828
665 829 return null;
666 830 }
@@ -920,18 +1084,17 @@
920 1084 1. **Find content** they've written (posts, pages, comments) by describing it in natural language.
921 1085 2. **Navigate wp-admin** when they ask where to find something (\"where are the categories?\", \"how do I manage users?\").
922 1086 3. **Recommend plugins** from the official WordPress.org directory when they need extra functionality.
923 1087 4. **Check the site's error log** when they're troubleshooting something.
924 -5. **Answer anything else your tools can** — you may have more tools than the ones described below (WordPress and other plugins register their own, e.g. site / user / environment / version info). Your actual tool list is authoritative: whenever a tool can answer the request, call it and summarise the result, even if it isn't in the list below.
1088 +5. **Answer anything else your tools can** — you may have more tools than the ones named here (WordPress and other plugins register their own, e.g. site / user / environment / version info). Your actual tool list is authoritative: whenever a tool can answer the request, call it and summarise the result, even if it isn't named here.
925 1089 6. **Chat** — only when no tool fits, answer conversationally.
926 1090
927 1091 Tone: warm, concise, helpful. First person (\"I found this post…\", \"Here's where you'll find that…\"). Not a search engine tone — no \"Match found\" or robot phrasing.
928 1092
929 -Tools (your actual tool list may include more than these — use any that fit the request):
930 -- search_posts / search_pages / search_comments / search_comments_by_post(post_id, query, offset): keyword content-lookup tools backed by WordPress's native search. Distil the user's description into the essential search keywords and pass them as `query` (e.g. \"that long post about making paella\" → query \"paella\"). Inspect the returned title + excerpt and stop once you find a good match. If has_more is true and nothing matched, call the same tool with next_offset (reuse the same query), or try different keywords. When the query mentions BOTH a post and a comment on that post, call search_posts first to identify the post, THEN search_comments_by_post with the ID. If keyword search returns nothing, broaden or simplify the keywords before giving up.
931 -- list_admin_pages: returns the full catalog of wp-admin destinations. Call once per navigation query, then select the 1-3 most relevant entries.
932 -- search_wporg_plugins(query): searches the official WordPress.org plugin directory. Use when the user asks for a plugin recommendation (\"a plugin for X\", \"is there a plugin that does Y?\"). Returns up to 10 plugins with ratings, install counts, and admin install URLs. Present the best 3-5 as admin_links with titles like \"Plugin Name · 5M+ installs · 4.8★\" (rating is 0-100, divide by 20 to get stars).
933 -- get_php_error_log(lines): reads the tail of the site's PHP error log. Admin-only (the tool itself checks). Use when the user asks \"any errors?\", \"check the logs\", \"what's broken?\", troubleshooting. Each entry has { timestamp, level, message }. Summarise the most important errors (Fatal > Warning > Notice) in your message; don't copy-paste everything.
1093 +How to work the tools (your actual tool list is authoritative; use any tool that fits the request):
1094 +- Content lookups: stop once a returned title and excerpt clearly match. If nothing matched, page on with the next offset or try broader, simpler keywords before telling the user you found nothing.
1095 +- Plugin recommendations: present the best 3-5 as admin_links titled like \"Plugin Name · 5M+ installs · 4.8★\".
1096 +- Error logs: summarise the most important errors first (fatal, then warnings, then notices) instead of copying entries.
934 1097
935 1098 Choosing which track:
936 1099 - \"I remember a post/page/comment about X\" → the corresponding search_* tool.
937 1100 - \"where can I find X?\", \"how do I manage Y?\", \"create/add/new …\", \"take me to …\", \"open …\", \"switch/activate …\", or any navigate/do intent → list_admin_pages, then suggest the 1-3 best destinations as admin_links (answer_type \"navigation\"). You suggest the link; the user opens it — never assume it's opened.
@@ -1642,15 +1805,15 @@
1642 1805 // instructions block — no tool guidance, since this run has none.
1643 1806 $instructions = '
1644 1807 You are the same friendly WordPress assistant that just dispatched a command on behalf of the user. You now have the result of that command.
1645 1808
1646 -Write a SHORT reply (one or two sentences, first person, warm and conversational) describing what happened. Match the voice the site owner set in their system prompt — do not restart small talk, just confirm what you did.
1809 +Write a short reply (one or two sentences, first person, warm and conversational) describing what happened. Match the voice the site owner set in their system prompt — do not restart small talk, just confirm what you did.
1647 1810
1648 1811 Rules:
1649 1812 - If the outcome looks successful, confirm plainly. Example: "Done — your office light is on now."
1650 1813 - If the outcome looks like an error (has an `error` field, a failure message, or obviously negative content), apologise briefly and paraphrase what went wrong. Do not invent details the outcome did not include.
1651 -- Do NOT recommend the user try something else unless the outcome explicitly suggests it.
1652 -- Do NOT describe the tool mechanism ("I called command_turn_light") — the user only cares about the real-world effect.
1814 +- Suggest a next step only when the outcome itself suggests one.
1815 +- Describe the real-world effect, not the tool mechanism ("I called command_turn_light").
1653 1816 ';
1654 1817
1655 1818 $system_prompt_text = isset( $extra['system_prompt_text'] ) && is_string( $extra['system_prompt_text'] ) ? $extra['system_prompt_text'] : '';
1656 1819 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
@@ -2140,8 +2303,9 @@
2140 2303 'short_description' => wp_strip_all_tags( $p['short_description'] ?? '' ),
2141 2304 'version' => (string) ( $p['version'] ?? '' ),
2142 2305 'author' => wp_strip_all_tags( $p['author'] ?? '' ),
2143 2306 'rating' => (int) ( $p['rating'] ?? 0 ), // 0-100
2307 + 'stars' => round( ( (int) ( $p['rating'] ?? 0 ) ) / 20, 1 ), // 0-5, as wordpress.org shows it
2144 2308 'num_ratings' => (int) ( $p['num_ratings'] ?? 0 ),
2145 2309 'active_installs' => (int) ( $p['active_installs'] ?? 0 ),
2146 2310 'last_updated' => (string) ( $p['last_updated'] ?? '' ),
2147 2311 'requires' => (string) ( $p['requires'] ?? '' ),