PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.3
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.3
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 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / agents / abilities.php

abilities.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.3, at includes/agents/abilities.php

597 lines 20.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Agents: abilities bridge.
4 *
5 * Two halves:
6 *
7 * 1. Registers the agent-oriented abilities against Core's Abilities
8 * API: `desktop-mode/get-post` and `desktop-mode/get-media`
9 * (read-only) plus the mutating trio `desktop-mode/update-post`,
10 * `desktop-mode/update-media` (alt text / title / caption /
11 * description), and `desktop-mode/create-post` (draft-only). The
12 * `openstation` category ships from the AI Copilot module
13 * (always loaded), so this file only adds abilities to it. The
14 * read abilities carry the `readonly` annotation and therefore
15 * also become available to the AI Copilot assistant; the mutating
16 * ones do not — they are reachable only through an agent whose
17 * allowlist includes them.
18 *
19 * 2. Provides the abilities catalogue the picker UI consumes: every
20 * ability registered on the site, projected to
21 * `{ slug, label, description, category, readonly }`. Unlike the
22 * Copilot (which advertises only read-only abilities), agents may
23 * be granted mutating abilities — that is the point. The
24 * compensating controls are the explicit per-agent allowlist set by
25 * an `edit_users` human, the agent's role, and each ability's own
26 * `permission_callback` evaluated against the agent user.
27 *
28 * @package OpenStation
29 */
30
31 defined( 'ABSPATH' ) || exit;
32
33 /**
34 * Registers the agent-oriented abilities.
35 *
36 * @return void
37 */
38 function openstation_agents_register_abilities() {
39 if ( ! function_exists( 'wp_register_ability' ) ) {
40 return;
41 }
42
43 wp_register_ability(
44 'desktop-mode/get-post',
45 array(
46 'label' => __( 'Get post by id', 'desktop-mode' ),
47 // The rawness of `content` is load-bearing for any agent that
48 // edits posts, and it belongs here rather than in a prompt:
49 // stated once on the ability, every agent's generated tool
50 // manifest carries it. Saying it only in an agent's own
51 // instructions leaves every other agent guessing, and a
52 // cautious one will refuse to write rather than risk
53 // flattening blocks.
54 'description' => 'Return a post — title, content, excerpt, status, author, dates — by its numeric id. `content` is the RAW stored content exactly as saved, with block delimiter comments (`<!-- wp:… -->`) intact; it is never rendered output, so it is safe to edit and write back. Honours the caller\'s read capability.',
55 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
56 'input_schema' => array(
57 'type' => 'object',
58 'additionalProperties' => false,
59 'required' => array( 'post_id' ),
60 'properties' => array(
61 'post_id' => array(
62 'type' => 'integer',
63 'description' => 'The post id to fetch.',
64 ),
65 ),
66 ),
67 'output_schema' => openstation_ai_ability_output_schema(
68 array(
69 'id' => array( 'type' => 'integer' ),
70 'title' => array( 'type' => 'string' ),
71 'content' => array( 'type' => 'string' ),
72 'status' => array( 'type' => 'string' ),
73 )
74 ),
75 'execute_callback' => 'openstation_agents_ability_get_post',
76 'permission_callback' => 'openstation_agents_ability_get_post_can',
77 'meta' => array(
78 'annotations' => array(
79 'readonly' => true,
80 'idempotent' => true,
81 ),
82 'show_in_rest' => true,
83 ),
84 )
85 );
86
87 wp_register_ability(
88 'desktop-mode/get-media',
89 array(
90 'label' => __( 'Get media details', 'desktop-mode' ),
91 'description' => 'Return details for a media library item (attachment) by numeric id: file URL, mime type, dimensions, alt text, caption, and the post it is attached to. Use this to read images or other media referenced by posts.',
92 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
93 'input_schema' => array(
94 'type' => 'object',
95 'additionalProperties' => false,
96 'required' => array( 'attachment_id' ),
97 'properties' => array(
98 'attachment_id' => array(
99 'type' => 'integer',
100 'description' => 'The attachment (media library) id.',
101 ),
102 ),
103 ),
104 'output_schema' => openstation_ai_ability_output_schema(
105 array(
106 'id' => array( 'type' => 'integer' ),
107 'url' => array( 'type' => 'string' ),
108 'mime' => array( 'type' => 'string' ),
109 )
110 ),
111 'execute_callback' => 'openstation_agents_ability_get_media',
112 'permission_callback' => 'openstation_agents_ability_get_media_can',
113 'meta' => array(
114 'annotations' => array(
115 'readonly' => true,
116 'idempotent' => true,
117 ),
118 'show_in_rest' => true,
119 ),
120 )
121 );
122
123 wp_register_ability(
124 'desktop-mode/update-media',
125 array(
126 'label' => __( 'Update media details', 'desktop-mode' ),
127 'description' => 'Update metadata on a media library item (attachment): alt text, title, caption, and/or description. The file itself is never touched. Honours the edit capability on the attachment.',
128 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
129 'input_schema' => array(
130 'type' => 'object',
131 'additionalProperties' => false,
132 'required' => array( 'attachment_id' ),
133 'properties' => array(
134 'attachment_id' => array(
135 'type' => 'integer',
136 'description' => 'The attachment (media library) id.',
137 ),
138 'alt_text' => array(
139 'type' => 'string',
140 'description' => 'New alternative text for the image (plain text, describing what the image shows).',
141 ),
142 'title' => array(
143 'type' => 'string',
144 'description' => 'New attachment title.',
145 ),
146 'caption' => array(
147 'type' => 'string',
148 'description' => 'New caption.',
149 ),
150 'description' => array(
151 'type' => 'string',
152 'description' => 'New description.',
153 ),
154 ),
155 ),
156 'output_schema' => openstation_ai_ability_output_schema(
157 array(
158 'id' => array( 'type' => 'integer' ),
159 'updated' => array( 'type' => 'boolean' ),
160 )
161 ),
162 'execute_callback' => 'openstation_agents_ability_update_media',
163 'permission_callback' => 'openstation_agents_ability_update_media_can',
164 'meta' => array(
165 'show_in_rest' => true,
166 ),
167 )
168 );
169
170 wp_register_ability(
171 'desktop-mode/create-post',
172 array(
173 'label' => __( 'Create draft post', 'desktop-mode' ),
174 'description' => 'Create a NEW post or page as a DRAFT, authored by the calling user. The status is always draft: this ability can never publish. Use it to produce reviewable content (translations, variants, generated drafts) without touching any existing post. `content` is stored RAW, exactly as passed, so send block markup with its delimiter comments (`<!-- wp:… -->`) intact.',
175 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
176 'input_schema' => array(
177 'type' => 'object',
178 'additionalProperties' => false,
179 'required' => array( 'title', 'content' ),
180 'properties' => array(
181 'title' => array(
182 'type' => 'string',
183 'description' => 'Post title.',
184 ),
185 'content' => array(
186 'type' => 'string',
187 'description' => 'Post content (HTML / block markup).',
188 ),
189 'excerpt' => array(
190 'type' => 'string',
191 'description' => 'Optional excerpt.',
192 ),
193 'type' => array(
194 'type' => 'string',
195 'enum' => array( 'post', 'page' ),
196 'description' => 'Post type. Defaults to post.',
197 ),
198 ),
199 ),
200 'output_schema' => openstation_ai_ability_output_schema(
201 array(
202 'id' => array( 'type' => 'integer' ),
203 'status' => array( 'type' => 'string' ),
204 )
205 ),
206 'execute_callback' => 'openstation_agents_ability_create_post',
207 'permission_callback' => 'openstation_agents_ability_create_post_can',
208 'meta' => array(
209 'show_in_rest' => true,
210 ),
211 )
212 );
213
214 wp_register_ability(
215 'desktop-mode/update-post',
216 array(
217 'label' => __( 'Update post', 'desktop-mode' ),
218 'description' => 'Update fields on an existing post. Accepts any subset of title / content / excerpt / status. `content` is stored RAW, exactly as passed, so send block markup with its delimiter comments (`<!-- wp:… -->`) intact — passing rendered HTML would flatten the post\'s blocks. Honours the edit_post capability of the calling user.',
219 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
220 'input_schema' => array(
221 'type' => 'object',
222 'additionalProperties' => false,
223 'required' => array( 'post_id' ),
224 'properties' => array(
225 'post_id' => array(
226 'type' => 'integer',
227 'description' => 'The post id to update.',
228 ),
229 'title' => array(
230 'type' => 'string',
231 'description' => 'New post title.',
232 ),
233 'content' => array(
234 'type' => 'string',
235 'description' => 'New post content (HTML / block markup).',
236 ),
237 'excerpt' => array(
238 'type' => 'string',
239 'description' => 'New post excerpt.',
240 ),
241 'status' => array(
242 'type' => 'string',
243 'enum' => array( 'publish', 'draft', 'pending', 'private' ),
244 'description' => 'New post status.',
245 ),
246 ),
247 ),
248 'output_schema' => openstation_ai_ability_output_schema(
249 array(
250 'id' => array( 'type' => 'integer' ),
251 'updated' => array( 'type' => 'boolean' ),
252 )
253 ),
254 'execute_callback' => 'openstation_agents_ability_update_post',
255 'permission_callback' => 'openstation_agents_ability_update_post_can',
256 'meta' => array(
257 'show_in_rest' => true,
258 ),
259 )
260 );
261 }
262 add_action( 'wp_abilities_api_init', 'openstation_agents_register_abilities' );
263
264 /**
265 * `desktop-mode/get-post` execute callback.
266 *
267 * @param array $args Validated input.
268 * @return array|WP_Error
269 */
270 function openstation_agents_ability_get_post( $args ) {
271 $args = (array) $args;
272 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
273 $post = $post_id > 0 ? get_post( $post_id ) : null;
274 if ( ! ( $post instanceof WP_Post ) ) {
275 return new WP_Error( 'openstation_agent_post_not_found', __( 'Post not found.', 'desktop-mode' ) );
276 }
277 return array(
278 'id' => (int) $post->ID,
279 'title' => (string) $post->post_title,
280 'content' => (string) $post->post_content,
281 'excerpt' => (string) $post->post_excerpt,
282 'status' => (string) $post->post_status,
283 'type' => (string) $post->post_type,
284 'author' => (int) $post->post_author,
285 'date' => (string) $post->post_date_gmt,
286 'modified' => (string) $post->post_modified_gmt,
287 'link' => (string) get_permalink( $post ),
288 );
289 }
290
291 /**
292 * `desktop-mode/get-post` permission callback.
293 *
294 * @param array $args Input args.
295 * @return bool
296 */
297 function openstation_agents_ability_get_post_can( $args ) {
298 $args = (array) $args;
299 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
300 if ( $post_id <= 0 ) {
301 return false;
302 }
303 return current_user_can( 'read_post', $post_id );
304 }
305
306 /**
307 * `desktop-mode/get-media` execute callback.
308 *
309 * @param array $args Validated input.
310 * @return array|WP_Error
311 */
312 function openstation_agents_ability_get_media( $args ) {
313 $args = (array) $args;
314 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
315 $post = $attachment_id > 0 ? get_post( $attachment_id ) : null;
316 if ( ! ( $post instanceof WP_Post ) || 'attachment' !== $post->post_type ) {
317 return new WP_Error( 'openstation_agent_media_not_found', __( 'Attachment not found.', 'desktop-mode' ) );
318 }
319
320 $meta = wp_get_attachment_metadata( $attachment_id );
321 if ( ! is_array( $meta ) ) {
322 $meta = array();
323 }
324
325 return array(
326 'id' => (int) $post->ID,
327 'title' => (string) $post->post_title,
328 'url' => (string) wp_get_attachment_url( $attachment_id ),
329 'mime' => (string) get_post_mime_type( $post ),
330 'width' => isset( $meta['width'] ) ? (int) $meta['width'] : null,
331 'height' => isset( $meta['height'] ) ? (int) $meta['height'] : null,
332 'filesize' => isset( $meta['filesize'] ) ? (int) $meta['filesize'] : null,
333 'alt' => (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
334 'caption' => (string) $post->post_excerpt,
335 'date' => (string) $post->post_date_gmt,
336 'attachedTo' => (int) $post->post_parent,
337 );
338 }
339
340 /**
341 * `desktop-mode/get-media` permission callback.
342 *
343 * Gates on `upload_files` (author+), deliberately NOT on `read_post`:
344 * for `inherit`-status attachments that check defers to the parent
345 * post (and effectively requires edit rights when unattached), which
346 * wrongly blocks read-only access to media whose file URL is public
347 * on a standard site anyway.
348 *
349 * @param array $args Input args.
350 * @return bool
351 */
352 function openstation_agents_ability_get_media_can( $args ) {
353 $args = (array) $args;
354 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
355 if ( $attachment_id <= 0 ) {
356 return false;
357 }
358 return current_user_can( 'upload_files' );
359 }
360
361 /**
362 * `desktop-mode/update-media` execute callback.
363 *
364 * @param array $args Validated input.
365 * @return array|WP_Error
366 */
367 function openstation_agents_ability_update_media( $args ) {
368 $args = (array) $args;
369 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
370 $post = $attachment_id > 0 ? get_post( $attachment_id ) : null;
371 if ( ! ( $post instanceof WP_Post ) || 'attachment' !== $post->post_type ) {
372 return new WP_Error( 'openstation_agent_media_not_found', __( 'Attachment not found.', 'desktop-mode' ) );
373 }
374
375 if ( isset( $args['alt_text'] ) ) {
376 update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( (string) $args['alt_text'] ) );
377 }
378
379 $update = array( 'ID' => $attachment_id );
380 if ( isset( $args['title'] ) ) {
381 $update['post_title'] = sanitize_text_field( (string) $args['title'] );
382 }
383 if ( isset( $args['caption'] ) ) {
384 $update['post_excerpt'] = sanitize_text_field( (string) $args['caption'] );
385 }
386 if ( isset( $args['description'] ) ) {
387 $update['post_content'] = wp_kses_post( (string) $args['description'] );
388 }
389 if ( count( $update ) > 1 ) {
390 $result = wp_update_post( $update, true );
391 if ( is_wp_error( $result ) ) {
392 return $result;
393 }
394 }
395
396 return array(
397 'id' => $attachment_id,
398 'updated' => true,
399 );
400 }
401
402 /**
403 * `desktop-mode/update-media` permission callback — the same edit
404 * capability wp-admin requires to change attachment details.
405 *
406 * @param array $args Input args.
407 * @return bool
408 */
409 function openstation_agents_ability_update_media_can( $args ) {
410 $args = (array) $args;
411 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
412 if ( $attachment_id <= 0 ) {
413 return false;
414 }
415 return current_user_can( 'edit_post', $attachment_id );
416 }
417
418 /**
419 * `desktop-mode/create-post` execute callback. Status is hard-forced
420 * to `draft` — this ability can never publish, whatever the model
421 * asks for.
422 *
423 * @param array $args Validated input.
424 * @return array|WP_Error
425 */
426 function openstation_agents_ability_create_post( $args ) {
427 $args = (array) $args;
428 $type = isset( $args['type'] ) && 'page' === $args['type'] ? 'page' : 'post';
429
430 $post_id = wp_insert_post(
431 array(
432 'post_type' => $type,
433 'post_status' => 'draft',
434 'post_title' => sanitize_text_field( isset( $args['title'] ) ? (string) $args['title'] : '' ),
435 'post_content' => wp_kses_post( isset( $args['content'] ) ? (string) $args['content'] : '' ),
436 'post_excerpt' => sanitize_text_field( isset( $args['excerpt'] ) ? (string) $args['excerpt'] : '' ),
437 'post_author' => get_current_user_id(),
438 ),
439 true
440 );
441 if ( is_wp_error( $post_id ) ) {
442 return $post_id;
443 }
444
445 return array(
446 'id' => (int) $post_id,
447 'type' => $type,
448 'status' => 'draft',
449 'title' => (string) get_the_title( $post_id ),
450 'editLink' => (string) get_edit_post_link( $post_id, 'raw' ),
451 );
452 }
453
454 /**
455 * `desktop-mode/create-post` permission callback.
456 *
457 * @param array $args Input args.
458 * @return bool
459 */
460 function openstation_agents_ability_create_post_can( $args ) {
461 $args = (array) $args;
462 if ( isset( $args['type'] ) && 'page' === $args['type'] ) {
463 return current_user_can( 'edit_pages' );
464 }
465 return current_user_can( 'edit_posts' );
466 }
467
468 /**
469 * `desktop-mode/update-post` execute callback.
470 *
471 * @param array $args Validated input.
472 * @return array|WP_Error
473 */
474 function openstation_agents_ability_update_post( $args ) {
475 $args = (array) $args;
476 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
477 if ( $post_id <= 0 || ! get_post( $post_id ) ) {
478 return new WP_Error( 'openstation_agent_post_not_found', __( 'Post not found.', 'desktop-mode' ) );
479 }
480
481 $update = array( 'ID' => $post_id );
482 if ( isset( $args['title'] ) ) {
483 $update['post_title'] = sanitize_text_field( (string) $args['title'] );
484 }
485 if ( isset( $args['content'] ) ) {
486 $update['post_content'] = wp_kses_post( (string) $args['content'] );
487 }
488 if ( isset( $args['excerpt'] ) ) {
489 $update['post_excerpt'] = sanitize_text_field( (string) $args['excerpt'] );
490 }
491 if ( isset( $args['status'] ) ) {
492 $status = sanitize_key( (string) $args['status'] );
493 if ( ! in_array( $status, array( 'publish', 'draft', 'pending', 'private' ), true ) ) {
494 return new WP_Error( 'openstation_agent_invalid_status', __( 'Invalid post status.', 'desktop-mode' ) );
495 }
496 $update['post_status'] = $status;
497 }
498
499 $result = wp_update_post( $update, true );
500 if ( is_wp_error( $result ) ) {
501 return $result;
502 }
503 return array(
504 'id' => (int) $result,
505 'updated' => true,
506 );
507 }
508
509 /**
510 * `desktop-mode/update-post` permission callback.
511 *
512 * Publishing needs `publish_posts` on top of `edit_post` — the same
513 * split wp-admin enforces on a human editor.
514 *
515 * @param array $args Input args.
516 * @return bool
517 */
518 function openstation_agents_ability_update_post_can( $args ) {
519 $args = (array) $args;
520 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
521 if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
522 return false;
523 }
524 if ( isset( $args['status'] ) && 'publish' === $args['status'] && ! current_user_can( 'publish_posts' ) ) {
525 return false;
526 }
527 return true;
528 }
529
530 /**
531 * Catalogue of abilities exposed to the agents picker.
532 *
533 * Primary source: Core's Abilities API (`wp_get_abilities()`) — every
534 * ability the site registered, Core's, this plugin's, or any third
535 * party's, projected into the picker shape with an honest
536 * readonly/mutating badge derived from `meta.annotations.readonly`.
537 *
538 * @return array<int, array{slug:string, label:string, description:string, category:string, readonly:bool}>
539 */
540 function openstation_agents_abilities_catalogue() {
541 $catalogue = array();
542
543 if ( function_exists( 'wp_get_abilities' ) ) {
544 foreach ( wp_get_abilities() as $ability ) {
545 if ( ! $ability instanceof WP_Ability ) {
546 continue;
547 }
548 $meta = (array) $ability->get_meta();
549 $annotations = isset( $meta['annotations'] ) && is_array( $meta['annotations'] ) ? $meta['annotations'] : array();
550
551 $catalogue[] = array(
552 'slug' => (string) $ability->get_name(),
553 'label' => (string) $ability->get_label(),
554 'description' => (string) $ability->get_description(),
555 'category' => (string) $ability->get_category(),
556 'readonly' => ! empty( $annotations['readonly'] ),
557 );
558 }
559 }
560
561 /**
562 * Filter the catalogue of abilities exposed to the agents picker.
563 *
564 * Sites can narrow the pickable set (drop rows) or append
565 * Desktop-Mode-only entries. The preferred extension path stays
566 * `wp_register_ability()` so every agent runtime sees the same
567 * registry.
568 *
569 * @param array $catalogue Abilities projected from `wp_get_abilities()`.
570 */
571 $catalogue = apply_filters( 'openstation_agent_abilities_catalogue', $catalogue );
572 if ( ! is_array( $catalogue ) ) {
573 return array();
574 }
575
576 $seen = array();
577 $out = array();
578 foreach ( $catalogue as $row ) {
579 if ( ! is_array( $row ) || empty( $row['slug'] ) ) {
580 continue;
581 }
582 $slug = sanitize_text_field( (string) $row['slug'] );
583 if ( '' === $slug || isset( $seen[ $slug ] ) ) {
584 continue;
585 }
586 $seen[ $slug ] = true;
587 $out[] = array(
588 'slug' => $slug,
589 'label' => isset( $row['label'] ) && '' !== (string) $row['label'] ? (string) $row['label'] : $slug,
590 'description' => isset( $row['description'] ) ? (string) $row['description'] : '',
591 'category' => isset( $row['category'] ) ? (string) $row['category'] : '',
592 'readonly' => ! empty( $row['readonly'] ),
593 );
594 }
595 return $out;
596 }
597