PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
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 0.8.6 All 33 releases
desktop-mode / includes / agents / rest.php

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

632 lines 18.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Agents: REST surface at /desktop-mode/v1/agents.
4 *
5 * One CRUD surface over the two layers (user row + definition meta) so
6 * the bundle never coordinates `/wp/v2/users` and raw meta from JS.
7 *
8 * Routes:
9 *
10 * GET /desktop-mode/v1/agents list
11 * POST /desktop-mode/v1/agents create
12 * GET /desktop-mode/v1/agents/(?P<id>\d+) get
13 * POST /desktop-mode/v1/agents/(?P<id>\d+) patch
14 * DELETE /desktop-mode/v1/agents/(?P<id>\d+) delete
15 * POST /desktop-mode/v1/agents/(?P<id>\d+)/invoke run (chat trigger)
16 * GET /desktop-mode/v1/agents/abilities abilities catalogue
17 * GET /desktop-mode/v1/agents/trigger-kinds trigger kinds catalogue
18 * GET /desktop-mode/v1/agents/hooks-catalogue hook autocomplete
19 * GET /desktop-mode/v1/agents/roles assignable roles (writers only)
20 *
21 * Permissions: reads and invokes default to `edit_posts` (the same
22 * audience as the WP Explorer window hosting the UI); writes require
23 * `edit_users` (agents are real users — managing them is user
24 * management). All three are filterable.
25 *
26 * @package OpenStation
27 */
28
29 defined( 'ABSPATH' ) || exit;
30
31 /**
32 * Register REST routes on rest_api_init.
33 *
34 * @return void
35 */
36 function openstation_agents_register_rest_routes() {
37 $namespace = 'desktop-mode/v1';
38
39 register_rest_route(
40 $namespace,
41 '/agents',
42 array(
43 array(
44 'methods' => WP_REST_Server::READABLE,
45 'permission_callback' => 'openstation_agents_rest_read_permission',
46 'callback' => 'openstation_agents_rest_list',
47 ),
48 array(
49 'methods' => WP_REST_Server::CREATABLE,
50 'permission_callback' => 'openstation_agents_rest_write_permission',
51 'callback' => 'openstation_agents_rest_create',
52 'args' => array(
53 'name' => array(
54 'type' => 'string',
55 'required' => true,
56 'sanitize_callback' => 'sanitize_text_field',
57 ),
58 'role' => array(
59 'type' => 'string',
60 'required' => true,
61 'sanitize_callback' => 'sanitize_key',
62 ),
63 'description' => array(
64 'type' => 'string',
65 'default' => '',
66 'sanitize_callback' => 'sanitize_text_field',
67 ),
68 'instructions' => array(
69 'type' => 'string',
70 'default' => '',
71 ),
72 'abilities' => array(
73 'type' => 'array',
74 'default' => array(),
75 'items' => array( 'type' => 'string' ),
76 ),
77 // Like `face`, deliberately schema-light. Each row
78 // is validated against the live trigger-kind
79 // catalogue by openstation_agent_sanitize_triggers(),
80 // which drops rows it does not recognise rather
81 // than rejecting the whole create.
82 'triggers' => array(
83 'type' => 'array',
84 'default' => array(),
85 ),
86 'vibes' => array(
87 'type' => 'string',
88 'default' => '',
89 ),
90 // `face` carries no schema beyond "object" and no
91 // sanitize_callback on purpose. The real validator is
92 // openstation_agent_sanitize_face_json(), which clamps
93 // every number; a partial JSON Schema here would only
94 // suggest the route had checked it.
95 'face' => array(
96 'type' => 'object',
97 'default' => null,
98 ),
99 'faceSeed' => array(
100 'type' => 'integer',
101 'default' => 0,
102 'sanitize_callback' => 'absint',
103 ),
104 ),
105 ),
106 )
107 );
108
109 register_rest_route(
110 $namespace,
111 '/agents/abilities',
112 array(
113 'methods' => WP_REST_Server::READABLE,
114 'permission_callback' => 'openstation_agents_rest_read_permission',
115 'callback' => 'openstation_agents_rest_abilities_catalogue',
116 )
117 );
118
119 register_rest_route(
120 $namespace,
121 '/agents/draft',
122 array(
123 'methods' => WP_REST_Server::CREATABLE,
124 'permission_callback' => 'openstation_agents_rest_write_permission',
125 'callback' => 'openstation_agents_rest_draft',
126 'args' => array(
127 'brief' => array(
128 'type' => 'string',
129 'required' => true,
130 'sanitize_callback' => 'sanitize_textarea_field',
131 'validate_callback' => 'openstation_agents_rest_validate_brief',
132 ),
133 ),
134 )
135 );
136
137 register_rest_route(
138 $namespace,
139 '/agents/trigger-kinds',
140 array(
141 'methods' => WP_REST_Server::READABLE,
142 'permission_callback' => 'openstation_agents_rest_read_permission',
143 'callback' => 'openstation_agents_rest_trigger_kinds',
144 )
145 );
146
147 register_rest_route(
148 $namespace,
149 '/agents/hooks-catalogue',
150 array(
151 'methods' => WP_REST_Server::READABLE,
152 'permission_callback' => 'openstation_agents_rest_read_permission',
153 'callback' => 'openstation_agents_rest_hooks_catalogue',
154 )
155 );
156
157 register_rest_route(
158 $namespace,
159 '/agents/roles',
160 array(
161 'methods' => WP_REST_Server::READABLE,
162 'permission_callback' => 'openstation_agents_rest_write_permission',
163 'callback' => 'openstation_agents_rest_roles',
164 )
165 );
166
167 register_rest_route(
168 $namespace,
169 '/agents/(?P<id>\d+)',
170 array(
171 array(
172 'methods' => WP_REST_Server::READABLE,
173 'permission_callback' => 'openstation_agents_rest_read_permission',
174 'callback' => 'openstation_agents_rest_get',
175 ),
176 array(
177 'methods' => WP_REST_Server::CREATABLE,
178 'permission_callback' => 'openstation_agents_rest_write_permission',
179 'callback' => 'openstation_agents_rest_patch',
180 ),
181 array(
182 'methods' => WP_REST_Server::DELETABLE,
183 'permission_callback' => 'openstation_agents_rest_write_permission',
184 'callback' => 'openstation_agents_rest_delete',
185 ),
186 )
187 );
188
189 register_rest_route(
190 $namespace,
191 '/agents/(?P<id>\d+)/invoke',
192 array(
193 'methods' => WP_REST_Server::CREATABLE,
194 'permission_callback' => 'openstation_agents_rest_invoke_permission',
195 'callback' => 'openstation_agents_rest_invoke',
196 'args' => array(
197 'async' => array(
198 'type' => 'boolean',
199 'default' => false,
200 ),
201 'requestId' => array(
202 'type' => 'string',
203 'format' => 'uuid',
204 ),
205 'message' => array(
206 'type' => 'string',
207 'required' => true,
208 'sanitize_callback' => 'sanitize_textarea_field',
209 ),
210 'source' => array(
211 'type' => 'string',
212 'default' => 'chat',
213 'enum' => array( 'chat', 'drag', 'send-to' ),
214 'sanitize_callback' => 'sanitize_key',
215 ),
216 // Prior conversation turns, oldest first. Without these
217 // every message is a contextless run — a follow-up like
218 // "yes, do it" would be resolved against nothing and the
219 // agent could act on the wrong entity entirely.
220 'history' => array(
221 'type' => 'array',
222 'default' => array(),
223 'items' => array(
224 'type' => 'object',
225 'properties' => array(
226 'role' => array(
227 'type' => 'string',
228 'enum' => array( 'user', 'agent' ),
229 ),
230 'text' => array( 'type' => 'string' ),
231 ),
232 ),
233 ),
234 ),
235 )
236 );
237 }
238 add_action( 'rest_api_init', 'openstation_agents_register_rest_routes' );
239
240 // ---------------------------------------------------------------------------
241 // Permissions
242 //
243 // The three capability gates themselves (`openstation_agents_user_can_read`
244 // / `_manage` / `_invoke`) live in bootstrap.php: the WP Explorer
245 // integration loads while the feature flag is off, and this file does
246 // not.
247 // ---------------------------------------------------------------------------
248
249 /**
250 * Read-route permission callback.
251 *
252 * @return bool|WP_Error
253 */
254 function openstation_agents_rest_read_permission() {
255 if ( ! is_user_logged_in() || ! openstation_agents_user_can_read() ) {
256 return new WP_Error(
257 'openstation_agents_forbidden',
258 __( 'You do not have permission to read OpenStation agents.', 'desktop-mode' ),
259 array( 'status' => rest_authorization_required_code() )
260 );
261 }
262 return true;
263 }
264
265 /**
266 * Write-route permission callback.
267 *
268 * @return bool|WP_Error
269 */
270 function openstation_agents_rest_write_permission() {
271 if ( ! is_user_logged_in() || ! openstation_agents_user_can_manage() ) {
272 return new WP_Error(
273 'openstation_agents_forbidden',
274 __( 'You do not have permission to manage OpenStation agents.', 'desktop-mode' ),
275 array( 'status' => rest_authorization_required_code() )
276 );
277 }
278 return true;
279 }
280
281 /**
282 * Invoke-route permission callback.
283 *
284 * @return bool|WP_Error
285 */
286 function openstation_agents_rest_invoke_permission() {
287 if ( ! is_user_logged_in() || ! openstation_agents_user_can_invoke() ) {
288 return new WP_Error(
289 'openstation_agents_forbidden',
290 __( 'You do not have permission to invoke OpenStation agents.', 'desktop-mode' ),
291 array( 'status' => rest_authorization_required_code() )
292 );
293 }
294 return true;
295 }
296
297 // ---------------------------------------------------------------------------
298 // Handlers
299 // ---------------------------------------------------------------------------
300
301 /**
302 * GET /agents — list every agent on the site.
303 *
304 * @return WP_REST_Response
305 */
306 function openstation_agents_rest_list() {
307 $out = array();
308 foreach ( openstation_agent_get_agents() as $user ) {
309 $shape = openstation_agents_rest_shape_user( $user );
310 if ( $shape ) {
311 $out[] = $shape;
312 }
313 }
314 $response = rest_ensure_response( $out );
315 // Standard collection headers — WP Explorer's root grid derives
316 // its folder counts from `X-WP-Total`.
317 $response->header( 'X-WP-Total', (string) count( $out ) );
318 $response->header( 'X-WP-TotalPages', '1' );
319 return $response;
320 }
321
322 /**
323 * GET /agents/:id — fetch a single agent.
324 *
325 * @param WP_REST_Request $request REST request.
326 * @return WP_REST_Response|WP_Error
327 */
328 function openstation_agents_rest_get( WP_REST_Request $request ) {
329 $user = get_userdata( (int) $request['id'] );
330 if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
331 return new WP_Error(
332 'openstation_agents_not_found',
333 __( 'Agent not found.', 'desktop-mode' ),
334 array( 'status' => 404 )
335 );
336 }
337 return rest_ensure_response( openstation_agents_rest_shape_user( $user ) );
338 }
339
340 /**
341 * POST /agents — create.
342 *
343 * @param WP_REST_Request $request REST request.
344 * @return WP_REST_Response|WP_Error
345 */
346 function openstation_agents_rest_create( WP_REST_Request $request ) {
347 // Every field the route declares is forwarded. `vibes`, `face` and
348 // `faceSeed` are the character half of an agent, and a create that
349 // took the name and dropped the portrait is how an agent ends up
350 // wearing the fallback glyph seconds after someone picked a face
351 // for it. `openstation_agent_create()` sanitizes each one.
352 $user = openstation_agent_create(
353 array(
354 'name' => (string) $request['name'],
355 'role' => (string) $request['role'],
356 'description' => (string) $request['description'],
357 'instructions' => (string) $request['instructions'],
358 'abilities' => (array) $request['abilities'],
359 'triggers' => (array) $request['triggers'],
360 'vibes' => (string) $request['vibes'],
361 'face' => $request['face'],
362 'faceSeed' => (int) $request['faceSeed'],
363 )
364 );
365 if ( is_wp_error( $user ) ) {
366 $data = $user->get_error_data();
367 if ( ! is_array( $data ) || ! isset( $data['status'] ) ) {
368 $user->add_data( array( 'status' => 400 ) );
369 }
370 return $user;
371 }
372
373 $response = rest_ensure_response( openstation_agents_rest_shape_user( $user ) );
374 $response->set_status( 201 );
375 return $response;
376 }
377
378 /**
379 * POST /agents/:id — patch any subset of the definition fields.
380 *
381 * @param WP_REST_Request $request REST request.
382 * @return WP_REST_Response|WP_Error
383 */
384 function openstation_agents_rest_patch( WP_REST_Request $request ) {
385 $user = get_userdata( (int) $request['id'] );
386 if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
387 return new WP_Error(
388 'openstation_agents_not_found',
389 __( 'Agent not found.', 'desktop-mode' ),
390 array( 'status' => 404 )
391 );
392 }
393
394 $body = $request->get_json_params();
395 if ( ! is_array( $body ) ) {
396 $body = $request->get_body_params();
397 }
398 if ( ! is_array( $body ) ) {
399 $body = array();
400 }
401
402 $fields = array();
403 $allowed = array(
404 'name',
405 'role',
406 'description',
407 'instructions',
408 'abilities',
409 'triggers',
410 'model',
411 'rateLimit',
412 'vibes',
413 'face',
414 'faceSeed',
415 );
416 foreach ( $allowed as $field ) {
417 if ( array_key_exists( $field, $body ) ) {
418 $fields[ $field ] = $body[ $field ];
419 }
420 }
421
422 $updated = openstation_agent_update( (int) $user->ID, $fields );
423 if ( is_wp_error( $updated ) ) {
424 $updated->add_data( array( 'status' => 400 ) );
425 return $updated;
426 }
427
428 return rest_ensure_response(
429 openstation_agents_rest_shape_user( get_userdata( (int) $user->ID ) )
430 );
431 }
432
433 /**
434 * DELETE /agents/:id.
435 *
436 * @param WP_REST_Request $request REST request.
437 * @return WP_REST_Response|WP_Error
438 */
439 function openstation_agents_rest_delete( WP_REST_Request $request ) {
440 $user_id = (int) $request['id'];
441 $user = get_userdata( $user_id );
442 if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
443 return new WP_Error(
444 'openstation_agents_not_found',
445 __( 'Agent not found.', 'desktop-mode' ),
446 array( 'status' => 404 )
447 );
448 }
449
450 $result = openstation_agent_delete( $user_id );
451 if ( is_wp_error( $result ) ) {
452 $result->add_data( array( 'status' => 500 ) );
453 return $result;
454 }
455
456 return rest_ensure_response(
457 array(
458 'deleted' => true,
459 'id' => $user_id,
460 )
461 );
462 }
463
464 /**
465 * POST /agents/:id/invoke — run the agent with the supplied message.
466 *
467 * @param WP_REST_Request $request REST request.
468 * @return WP_REST_Response|WP_Error
469 */
470 function openstation_agents_rest_invoke( WP_REST_Request $request ) {
471 $user = get_userdata( (int) $request['id'] );
472 if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
473 return new WP_Error(
474 'openstation_agents_not_found',
475 __( 'Agent not found.', 'desktop-mode' ),
476 array( 'status' => 404 )
477 );
478 }
479
480 $source = (string) $request['source'];
481
482 // Per-agent gate. The route's `permission_callback` cannot run this
483 // one: it has no access to the resolved agent, and the capability an
484 // agent requires is a property of that agent's trigger config.
485 if ( ! openstation_agent_user_can_invoke_agent( (int) $user->ID, $source ) ) {
486 return new WP_Error(
487 'openstation_agents_forbidden',
488 __( 'You do not have permission to invoke this agent.', 'desktop-mode' ),
489 array( 'status' => rest_authorization_required_code() )
490 );
491 }
492
493 if ( $request['async'] ) {
494 return openstation_agents_rest_enqueue_job( $request );
495 }
496
497 $result = openstation_agent_invoke(
498 (int) $user->ID,
499 (string) $request['message'],
500 array(
501 'source' => $source,
502 'invoker' => get_current_user_id(),
503 'history' => (array) $request['history'],
504 )
505 );
506 if ( is_wp_error( $result ) ) {
507 $data = $result->get_error_data();
508 if ( ! is_array( $data ) || ! isset( $data['status'] ) ) {
509 $result->add_data( array( 'status' => 500 ) );
510 }
511 return $result;
512 }
513 return rest_ensure_response( $result );
514 }
515
516 /**
517 * GET /agents/abilities — the abilities catalogue for the picker.
518 *
519 * @return WP_REST_Response
520 */
521 function openstation_agents_rest_abilities_catalogue() {
522 return rest_ensure_response( openstation_agents_abilities_catalogue() );
523 }
524
525 /**
526 * `brief` must carry words and fit the drafting cap.
527 *
528 * @param mixed $value Raw param.
529 * @return bool
530 */
531 function openstation_agents_rest_validate_brief( $value ) {
532 return is_string( $value )
533 && '' !== trim( $value )
534 && mb_strlen( $value ) <= OPENSTATION_AGENT_DRAFT_BRIEF_MAX;
535 }
536
537 /**
538 * POST /agents/draft — draft a definition from a brief.
539 *
540 * Nothing is created: the wizard shows the draft for review and the
541 * create route is still the only way an agent comes to exist.
542 *
543 * @param WP_REST_Request $request Request.
544 * @return WP_REST_Response|WP_Error
545 */
546 function openstation_agents_rest_draft( WP_REST_Request $request ) {
547 $draft = openstation_agent_draft( (string) $request['brief'], get_current_user_id() );
548 if ( is_wp_error( $draft ) ) {
549 return $draft;
550 }
551 return rest_ensure_response( $draft );
552 }
553
554 /**
555 * GET /agents/trigger-kinds — the trigger-kinds catalogue.
556 *
557 * @return WP_REST_Response
558 */
559 function openstation_agents_rest_trigger_kinds() {
560 return rest_ensure_response( openstation_agent_trigger_kinds() );
561 }
562
563 /**
564 * GET /agents/hooks-catalogue — the curated WP hooks catalogue.
565 *
566 * @return WP_REST_Response
567 */
568 function openstation_agents_rest_hooks_catalogue() {
569 return rest_ensure_response( openstation_agent_hooks_catalogue() );
570 }
571
572 /**
573 * GET /agents/roles — roles the current user may assign to an agent.
574 *
575 * @return WP_REST_Response
576 */
577 function openstation_agents_rest_roles() {
578 $names = wp_roles()->get_names();
579 $out = array();
580 foreach ( openstation_agent_allowed_roles() as $slug ) {
581 $out[] = array(
582 'slug' => $slug,
583 'label' => isset( $names[ $slug ] ) ? translate_user_role( $names[ $slug ] ) : $slug,
584 );
585 }
586 return rest_ensure_response( $out );
587 }
588
589 /**
590 * Build the canonical REST shape for one agent.
591 *
592 * @param WP_User|null $user Agent user.
593 * @return array|null Null when the user is not an agent.
594 */
595 function openstation_agents_rest_shape_user( $user ) {
596 if ( ! $user instanceof WP_User || ! openstation_agent_is_agent( $user ) ) {
597 return null;
598 }
599
600 $slug = (string) $user->user_login;
601 if ( 0 === strpos( $slug, 'agent-' ) ) {
602 $slug = substr( $slug, strlen( 'agent-' ) );
603 }
604
605 $role = '';
606 if ( is_array( $user->roles ) && ! empty( $user->roles ) ) {
607 $role = (string) reset( $user->roles );
608 }
609
610 $avatar = get_avatar_url( $user->ID, array( 'size' => 96 ) );
611 if ( ! is_string( $avatar ) || '' === $avatar ) {
612 $avatar = openstation_agent_avatar_url( (int) $user->ID );
613 }
614
615 return array(
616 'id' => (int) $user->ID,
617 'slug' => $slug,
618 'name' => (string) $user->display_name,
619 'description' => openstation_agent_get_description( (int) $user->ID ),
620 'instructions' => openstation_agent_get_instructions( (int) $user->ID ),
621 'role' => $role,
622 'abilities' => openstation_agent_get_abilities( (int) $user->ID ),
623 'triggers' => openstation_agent_get_triggers( (int) $user->ID ),
624 'model' => openstation_agent_get_model( (int) $user->ID ),
625 'rateLimit' => openstation_agent_get_rate_limit( (int) $user->ID ),
626 'vibes' => openstation_agent_get_vibes( (int) $user->ID ),
627 'face' => openstation_agent_get_face( (int) $user->ID ),
628 'faceSeed' => openstation_agent_get_face_seed( (int) $user->ID ),
629 'avatarUrl' => $avatar,
630 );
631 }
632