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 / store.php

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

1,026 lines 32.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: definition store (user meta on the agent row).
4 *
5 * Everything that defines an agent beyond its `wp_users` row lives as
6 * user meta on that row, in one `_openstation_agent_*` key family:
7 *
8 * - `_desktop_mode_agent` marker ('1') — the existence test
9 * - `_desktop_mode_agent_description` "when to use" short text
10 * - `_desktop_mode_agent_instructions` system prompt (markdown)
11 * - `_desktop_mode_agent_abilities` JSON array of ability slugs
12 * - `_desktop_mode_agent_triggers` JSON array of { kind, config }
13 * - `_desktop_mode_agent_model` model override (unused by the
14 * runner until the Core AI Client
15 * exposes model selection)
16 * - `_desktop_mode_agent_rate_limit` invocations/hour, 0 = default
17 * - `_desktop_mode_agent_created_by` creating user id (audit aid)
18 *
19 * User meta has no revisions — the audit trail for definition changes
20 * is the `openstation_agent_{created,updated,deleted}` actions fired
21 * from this module's orchestrators, each carrying before/after values
22 * so logging plugins can persist a history.
23 *
24 * This module owns every key: constants, `register_meta()` calls,
25 * sanitization, getters/setters, and the create/update orchestrators
26 * the REST surface calls. `identity.php` owns the user row itself.
27 *
28 * @package OpenStation
29 */
30
31 defined( 'ABSPATH' ) || exit;
32
33 require_once OPENSTATION_DIR . 'includes/agents/guard.php';
34
35 /**
36 * Meta keys owned by the agents store. Constants so the other layer
37 * files reuse them instead of typing the literals.
38 *
39 * `OPENSTATION_AGENT_USER_MARKER_META` is the exception — it lives in
40 * guard.php, which loads unconditionally, because the agent test has to
41 * resolve even when this module does not load.
42 *
43 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
44 * persisted or externally-visible identifier, so renaming it would
45 * orphan data already written by live installs (or break a live
46 * URL). The mismatch between this constant's name and its value is
47 * deliberate — it is NOT a half-finished rename.
48 */
49 const OPENSTATION_AGENT_DESCRIPTION_META = '_desktop_mode_agent_description';
50 /**
51 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
52 * persisted or externally-visible identifier, so renaming it would
53 * orphan data already written by live installs (or break a live
54 * URL). The mismatch between this constant's name and its value is
55 * deliberate — it is NOT a half-finished rename.
56 */
57 const OPENSTATION_AGENT_INSTRUCTIONS_META = '_desktop_mode_agent_instructions';
58 /**
59 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
60 * persisted or externally-visible identifier, so renaming it would
61 * orphan data already written by live installs (or break a live
62 * URL). The mismatch between this constant's name and its value is
63 * deliberate — it is NOT a half-finished rename.
64 */
65 const OPENSTATION_AGENT_ABILITIES_META = '_desktop_mode_agent_abilities';
66 /**
67 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
68 * persisted or externally-visible identifier, so renaming it would
69 * orphan data already written by live installs (or break a live
70 * URL). The mismatch between this constant's name and its value is
71 * deliberate — it is NOT a half-finished rename.
72 */
73 const OPENSTATION_AGENT_TRIGGERS_META = '_desktop_mode_agent_triggers';
74 /**
75 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
76 * persisted or externally-visible identifier, so renaming it would
77 * orphan data already written by live installs (or break a live
78 * URL). The mismatch between this constant's name and its value is
79 * deliberate — it is NOT a half-finished rename.
80 */
81 const OPENSTATION_AGENT_MODEL_META = '_desktop_mode_agent_model';
82 /**
83 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
84 * persisted or externally-visible identifier, so renaming it would
85 * orphan data already written by live installs (or break a live
86 * URL). The mismatch between this constant's name and its value is
87 * deliberate — it is NOT a half-finished rename.
88 */
89 const OPENSTATION_AGENT_RATE_LIMIT_META = '_desktop_mode_agent_rate_limit';
90 /**
91 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
92 * persisted or externally-visible identifier, so renaming it would
93 * orphan data already written by live installs (or break a live
94 * URL). The mismatch between this constant's name and its value is
95 * deliberate — it is NOT a half-finished rename.
96 */
97 const OPENSTATION_AGENT_CREATED_BY_META = '_desktop_mode_agent_created_by';
98
99 /**
100 * Every meta key the store writes — the privacy eraser and any future
101 * cleanup path iterate this list instead of re-typing the constants.
102 *
103 * @return string[]
104 */
105 function openstation_agent_meta_keys() {
106 return array(
107 OPENSTATION_AGENT_USER_MARKER_META,
108 OPENSTATION_AGENT_DESCRIPTION_META,
109 OPENSTATION_AGENT_INSTRUCTIONS_META,
110 OPENSTATION_AGENT_ABILITIES_META,
111 OPENSTATION_AGENT_TRIGGERS_META,
112 OPENSTATION_AGENT_MODEL_META,
113 OPENSTATION_AGENT_RATE_LIMIT_META,
114 OPENSTATION_AGENT_CREATED_BY_META,
115 );
116 }
117
118 /**
119 * Register the user-meta keys.
120 *
121 * `show_in_rest` stays false on every key — the module's own REST
122 * surface (rest.php) is the only reader/writer; core `wp/v2/users`
123 * never exposes agent definitions.
124 *
125 * @return void
126 */
127 function openstation_agents_register_user_meta() {
128 $auth = static function () {
129 return current_user_can( 'edit_users' );
130 };
131
132 register_meta(
133 'user',
134 OPENSTATION_AGENT_DESCRIPTION_META,
135 array(
136 'type' => 'string',
137 'single' => true,
138 'default' => '',
139 'show_in_rest' => false,
140 'sanitize_callback' => 'sanitize_text_field',
141 'auth_callback' => $auth,
142 )
143 );
144 register_meta(
145 'user',
146 OPENSTATION_AGENT_INSTRUCTIONS_META,
147 array(
148 'type' => 'string',
149 'single' => true,
150 'default' => '',
151 'show_in_rest' => false,
152 'sanitize_callback' => 'wp_kses_post',
153 'auth_callback' => $auth,
154 )
155 );
156 register_meta(
157 'user',
158 OPENSTATION_AGENT_ABILITIES_META,
159 array(
160 'type' => 'string',
161 'single' => true,
162 'default' => '',
163 'show_in_rest' => false,
164 'sanitize_callback' => 'openstation_agent_sanitize_abilities_json',
165 'auth_callback' => $auth,
166 )
167 );
168 register_meta(
169 'user',
170 OPENSTATION_AGENT_TRIGGERS_META,
171 array(
172 'type' => 'string',
173 'single' => true,
174 'default' => '',
175 'show_in_rest' => false,
176 'sanitize_callback' => 'openstation_agent_sanitize_triggers_json',
177 'auth_callback' => $auth,
178 )
179 );
180 register_meta(
181 'user',
182 OPENSTATION_AGENT_MODEL_META,
183 array(
184 'type' => 'string',
185 'single' => true,
186 'default' => '',
187 'show_in_rest' => false,
188 'sanitize_callback' => 'sanitize_text_field',
189 'auth_callback' => $auth,
190 )
191 );
192 register_meta(
193 'user',
194 OPENSTATION_AGENT_RATE_LIMIT_META,
195 array(
196 'type' => 'integer',
197 'single' => true,
198 'default' => 0,
199 'show_in_rest' => false,
200 'sanitize_callback' => 'absint',
201 'auth_callback' => $auth,
202 )
203 );
204 }
205 add_action( 'init', 'openstation_agents_register_user_meta' );
206
207 // ---------------------------------------------------------------------------
208 // Sanitizers
209 // ---------------------------------------------------------------------------
210
211 /**
212 * Normalize an ability-slug list: strings only, trimmed, deduped.
213 *
214 * @param mixed $value Incoming list.
215 * @return string[]
216 */
217 function openstation_agents_sanitize_ability_slugs( $value ) {
218 if ( is_string( $value ) ) {
219 $decoded = json_decode( $value, true );
220 $value = is_array( $decoded ) ? $decoded : array();
221 }
222 if ( ! is_array( $value ) ) {
223 return array();
224 }
225 $out = array();
226 foreach ( $value as $slug ) {
227 if ( ! is_string( $slug ) ) {
228 continue;
229 }
230 $clean = sanitize_text_field( $slug );
231 if ( '' === $clean ) {
232 continue;
233 }
234 $out[] = $clean;
235 }
236 return array_values( array_unique( $out ) );
237 }
238
239 /**
240 * `register_meta` sanitize callback — abilities land on disk as a JSON
241 * string so a read is one meta row and no PHP-serialized arrays exist.
242 *
243 * @param mixed $value Incoming value (array or JSON string).
244 * @return string JSON-encoded slug list.
245 */
246 function openstation_agent_sanitize_abilities_json( $value ) {
247 return (string) wp_json_encode( openstation_agents_sanitize_ability_slugs( $value ) );
248 }
249
250 /**
251 * Sanitize the triggers array.
252 *
253 * Validates each row against the kind catalogue. Drops any row that
254 * doesn't match a known kind — one bad row never rejects the whole
255 * array.
256 *
257 * @param mixed $value Incoming triggers array (or JSON string).
258 * @return array
259 */
260 function openstation_agent_sanitize_triggers( $value ) {
261 if ( is_string( $value ) ) {
262 $decoded = json_decode( $value, true );
263 $value = is_array( $decoded ) ? $decoded : array();
264 }
265 if ( ! is_array( $value ) ) {
266 return array();
267 }
268
269 $known_kinds = array();
270 foreach ( openstation_agent_trigger_kinds() as $kind ) {
271 $known_kinds[ $kind['slug'] ] = $kind;
272 }
273
274 $out = array();
275 foreach ( $value as $row ) {
276 if ( ! is_array( $row ) ) {
277 continue;
278 }
279 $kind = isset( $row['kind'] ) ? sanitize_key( $row['kind'] ) : '';
280 if ( '' === $kind || ! isset( $known_kinds[ $kind ] ) ) {
281 continue;
282 }
283
284 $config = isset( $row['config'] ) && is_array( $row['config'] ) ? $row['config'] : array();
285 $config = openstation_agent_sanitize_trigger_config_deep( $config );
286
287 $out[] = array(
288 'kind' => $kind,
289 'config' => $config,
290 );
291 }
292
293 return $out;
294 }
295
296 /**
297 * `register_meta` sanitize callback — triggers land on disk as JSON.
298 *
299 * @param mixed $value Incoming value.
300 * @return string JSON-encoded triggers list.
301 */
302 function openstation_agent_sanitize_triggers_json( $value ) {
303 return (string) wp_json_encode( openstation_agent_sanitize_triggers( $value ) );
304 }
305
306 /**
307 * Recursively coerce trigger-config values into safe primitives.
308 *
309 * Keys are camelCase by convention (`entityKinds`, `mimeTypes`,
310 * `fromAgents`) because they round-trip through the JS REST adapter
311 * verbatim — so the case is preserved and only non-identifier
312 * characters are stripped. `sanitize_key()` would lower-case
313 * everything, breaking the contract with the client.
314 *
315 * @param mixed $value Arbitrary input.
316 * @return mixed
317 */
318 function openstation_agent_sanitize_trigger_config_deep( $value ) {
319 if ( is_array( $value ) ) {
320 $out = array();
321 foreach ( $value as $k => $v ) {
322 if ( is_string( $k ) ) {
323 $key = preg_replace( '/[^A-Za-z0-9_\-]/', '', $k );
324 if ( '' === $key ) {
325 continue;
326 }
327 } else {
328 $key = (int) $k;
329 }
330 $out[ $key ] = openstation_agent_sanitize_trigger_config_deep( $v );
331 }
332 return $out;
333 }
334 if ( is_bool( $value ) || is_int( $value ) ) {
335 return $value;
336 }
337 if ( is_numeric( $value ) ) {
338 return $value + 0;
339 }
340 if ( is_string( $value ) ) {
341 return sanitize_text_field( $value );
342 }
343 return null;
344 }
345
346 // ---------------------------------------------------------------------------
347 // Catalogues
348 // ---------------------------------------------------------------------------
349
350 /**
351 * Built-in trigger kinds.
352 *
353 * `chat`, `send-to`, and `drag` are wired; the other kinds are declared so the
354 * Triggers pane can already store configuration for them, and later
355 * phases add the intake plumbing without a storage migration.
356 *
357 * Plugins can extend the list via the `openstation_agent_trigger_kinds`
358 * filter — each entry must declare a `slug`, `label`, and a JSON-Schema
359 * `config_schema` describing the shape of `trigger.config`.
360 *
361 * @return array<int, array{slug:string,wired:bool,label:string,description:string,icon:string,config_schema:array}>
362 */
363 function openstation_agent_trigger_kinds() {
364 $kinds = array(
365 array(
366 'slug' => 'chat',
367 'wired' => true,
368 'label' => __( 'Chat', 'desktop-mode' ),
369 'description' => __( 'Open a conversation window with the agent.', 'desktop-mode' ),
370 'icon' => 'dashicons-format-chat',
371 'config_schema' => array(
372 'type' => 'object',
373 'properties' => array(
374 'capability' => array( 'type' => 'string' ),
375 ),
376 ),
377 ),
378 array(
379 'slug' => 'send-to',
380 'wired' => true,
381 'label' => __( 'Send to (right-click menu)', 'desktop-mode' ),
382 'description' => __( 'The agent appears as a "Send to…" action in the right-click menu for the entity kinds you pick.', 'desktop-mode' ),
383 'icon' => 'dashicons-share-alt',
384 'config_schema' => array(
385 'type' => 'object',
386 'properties' => array(
387 'entityKinds' => array(
388 'type' => 'array',
389 'items' => array(
390 'type' => 'string',
391 'enum' => array( 'post', 'page', 'media', 'user', 'comment' ),
392 ),
393 ),
394 ),
395 ),
396 ),
397 array(
398 'slug' => 'drag',
399 'wired' => true,
400 'label' => __( 'Drag & drop', 'desktop-mode' ),
401 'description' => __( 'Drop a tile onto the agent.', 'desktop-mode' ),
402 'icon' => 'dashicons-move',
403 'config_schema' => array(
404 'type' => 'object',
405 'properties' => array(
406 'mimeTypes' => array(
407 'type' => 'array',
408 'items' => array( 'type' => 'string' ),
409 ),
410 'entityKinds' => array(
411 'type' => 'array',
412 'items' => array( 'type' => 'string' ),
413 ),
414 ),
415 ),
416 ),
417 array(
418 'slug' => 'hook',
419 'wired' => false,
420 'label' => __( 'WordPress hook', 'desktop-mode' ),
421 'description' => __( 'Run automatically when a WordPress action fires.', 'desktop-mode' ),
422 'icon' => 'dashicons-admin-plugins',
423 'config_schema' => array(
424 'type' => 'object',
425 'properties' => array(
426 'hook' => array( 'type' => 'string' ),
427 'priority' => array( 'type' => 'integer' ),
428 ),
429 'required' => array( 'hook' ),
430 ),
431 ),
432 array(
433 'slug' => 'endpoint',
434 'wired' => false,
435 'label' => __( 'REST endpoint', 'desktop-mode' ),
436 'description' => __( 'Expose a REST URL for external services to call.', 'desktop-mode' ),
437 'icon' => 'dashicons-rest-api',
438 'config_schema' => array(
439 'type' => 'object',
440 'properties' => array(
441 'auth' => array(
442 'type' => 'string',
443 'enum' => array( 'capability', 'application-password' ),
444 ),
445 'capability' => array( 'type' => 'string' ),
446 ),
447 ),
448 ),
449 array(
450 'slug' => 'agent',
451 'wired' => false,
452 'label' => __( 'Agent-to-agent', 'desktop-mode' ),
453 'description' => __( 'Run when another agent on this site emits a completion event.', 'desktop-mode' ),
454 'icon' => 'dashicons-networking',
455 'config_schema' => array(
456 'type' => 'object',
457 'properties' => array(
458 'fromAgents' => array(
459 'type' => 'array',
460 'items' => array( 'type' => 'string' ),
461 ),
462 ),
463 ),
464 ),
465 );
466
467 /**
468 * Filter the trigger kinds available to agents.
469 *
470 * @param array $kinds Default trigger kinds.
471 */
472 $filtered = apply_filters( 'openstation_agent_trigger_kinds', $kinds );
473 if ( ! is_array( $filtered ) ) {
474 return $kinds;
475 }
476 return array_values( $filtered );
477 }
478
479 /**
480 * Curated catalogue of WordPress hooks suggested for the Hook trigger.
481 *
482 * Not exhaustive — just the ones agents are most likely to subscribe
483 * to. The renderer offers it as an autocomplete; the user can type any
484 * hook name.
485 *
486 * @return array<int, array{hook:string, when:string}>
487 */
488 function openstation_agent_hooks_catalogue() {
489 $hooks = array(
490 array(
491 'hook' => 'save_post',
492 'when' => __( 'Every time a post is saved.', 'desktop-mode' ),
493 ),
494 array(
495 'hook' => 'wp_insert_post',
496 'when' => __( 'A new post is inserted.', 'desktop-mode' ),
497 ),
498 array(
499 'hook' => 'transition_post_status',
500 'when' => __( 'A post status changes.', 'desktop-mode' ),
501 ),
502 array(
503 'hook' => 'wp_insert_comment',
504 'when' => __( 'A new comment is inserted.', 'desktop-mode' ),
505 ),
506 array(
507 'hook' => 'comment_post',
508 'when' => __( 'A new comment is posted.', 'desktop-mode' ),
509 ),
510 array(
511 'hook' => 'user_register',
512 'when' => __( 'A new user registers.', 'desktop-mode' ),
513 ),
514 array(
515 'hook' => 'profile_update',
516 'when' => __( 'A user profile is updated.', 'desktop-mode' ),
517 ),
518 array(
519 'hook' => 'add_attachment',
520 'when' => __( 'A new attachment is added.', 'desktop-mode' ),
521 ),
522 );
523
524 /**
525 * Filter the curated catalogue of suggested hooks for the Hook
526 * trigger configurator.
527 *
528 * @param array $hooks Default catalogue.
529 */
530 $filtered = apply_filters( 'openstation_agent_hooks_catalogue', $hooks );
531 return is_array( $filtered ) ? array_values( $filtered ) : $hooks;
532 }
533
534 /**
535 * Whether the acting user may grant `$role` to an agent.
536 *
537 * An agent runs with its role's capabilities, so granting a role IS
538 * granting capability — it has to be gated like the promotion it is.
539 * Three constraints, all of which must hold:
540 *
541 * 1. `promote_users` — the capability wp-admin requires to set anyone's
542 * role. `edit_users` alone is not enough: role plugins hand
543 * `edit_users` to shop-manager-shaped roles routinely.
544 * 2. `get_editable_roles()` — core's extension point for "roles this
545 * install lets you hand out". NOTE this is a site-wide filtered
546 * list, NOT a per-user one: core's implementation is a bare
547 * `apply_filters( 'editable_roles', wp_roles()->roles )` with no
548 * reference to the current user. It is a useful constraint because
549 * plugins like WooCommerce filter it, but on a stock install it
550 * excludes nothing, so it cannot be the only gate.
551 * 3. `administrator` additionally requires the actor to genuinely be
552 * an administrator (super admin on multisite). This is the one that
553 * stops an `edit_users`-capable non-admin minting an agent that
554 * outranks them — the capability the agent would then act with.
555 *
556 * @param string $role Role slug being assigned.
557 * @return bool
558 */
559 function openstation_agent_actor_can_assign_role( $role ) {
560 $role = sanitize_key( (string) $role );
561 $can = current_user_can( 'promote_users' );
562
563 if ( $can && 'administrator' === $role ) {
564 $can = is_multisite()
565 ? is_super_admin()
566 : ( current_user_can( 'manage_options' ) && current_user_can( 'create_users' ) );
567 }
568
569 /**
570 * Filter whether the acting user may assign a role to an agent.
571 *
572 * The seam for automation that legitimately creates agents outside
573 * a request context (an activation routine, WP-CLI, a scheduled
574 * provisioning job), where there is no current user and the default
575 * answer is therefore a hard no.
576 *
577 * Granting a role here grants the capabilities an agent will act
578 * with — widen it only for code paths you control.
579 *
580 * @param bool $can Whether the assignment is allowed.
581 * @param string $role Role slug being assigned.
582 * @param int $user_id Acting user id (0 when there is none).
583 */
584 return (bool) apply_filters(
585 'openstation_agent_actor_can_assign_role',
586 $can,
587 $role,
588 get_current_user_id()
589 );
590 }
591
592 /**
593 * Roles an agent may be assigned, constrained to what the acting user
594 * can actually hand out.
595 *
596 * The whitelist keeps agents in the standard content-role band; each
597 * survivor is then run through
598 * {@see openstation_agent_actor_can_assign_role()}, which is where the
599 * real gating lives.
600 *
601 * @return string[] Role slugs.
602 */
603 function openstation_agent_allowed_roles() {
604 $whitelist = array( 'administrator', 'editor', 'author', 'contributor' );
605
606 /**
607 * Filter the roles an agent may be assigned.
608 *
609 * The result is always intersected with `get_editable_roles()` and
610 * then filtered through the per-role actor check — this filter can
611 * narrow or extend the candidate list, but a role it adds still has
612 * to clear both constraints.
613 *
614 * @param string[] $whitelist Default role slugs.
615 */
616 $whitelist = apply_filters( 'openstation_agent_allowed_roles', $whitelist );
617 if ( ! is_array( $whitelist ) ) {
618 return array();
619 }
620
621 if ( ! function_exists( 'get_editable_roles' ) ) {
622 require_once ABSPATH . 'wp-admin/includes/user.php';
623 }
624 $editable = array_keys( get_editable_roles() );
625
626 $candidates = array_intersect( array_map( 'strval', $whitelist ), $editable );
627
628 $allowed = array();
629 foreach ( $candidates as $role ) {
630 if ( openstation_agent_actor_can_assign_role( $role ) ) {
631 $allowed[] = $role;
632 }
633 }
634
635 return array_values( $allowed );
636 }
637
638 // ---------------------------------------------------------------------------
639 // Getters / setters
640 // ---------------------------------------------------------------------------
641
642 /**
643 * Read the "when to use" description.
644 *
645 * @param int $user_id Agent user id.
646 * @return string
647 */
648 function openstation_agent_get_description( $user_id ) {
649 return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_DESCRIPTION_META, true );
650 }
651
652 /**
653 * Read the system prompt.
654 *
655 * @param int $user_id Agent user id.
656 * @return string
657 */
658 function openstation_agent_get_instructions( $user_id ) {
659 return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_INSTRUCTIONS_META, true );
660 }
661
662 /**
663 * Read the ability allowlist.
664 *
665 * @param int $user_id Agent user id.
666 * @return string[]
667 */
668 function openstation_agent_get_abilities( $user_id ) {
669 $raw = get_user_meta( (int) $user_id, OPENSTATION_AGENT_ABILITIES_META, true );
670 if ( '' === $raw || null === $raw ) {
671 return array();
672 }
673 return openstation_agents_sanitize_ability_slugs( $raw );
674 }
675
676 /**
677 * Read triggers.
678 *
679 * @param int $user_id Agent user id.
680 * @return array
681 */
682 function openstation_agent_get_triggers( $user_id ) {
683 $raw = get_user_meta( (int) $user_id, OPENSTATION_AGENT_TRIGGERS_META, true );
684 if ( '' === $raw || null === $raw ) {
685 return array();
686 }
687 return openstation_agent_sanitize_triggers( $raw );
688 }
689
690 /**
691 * Read the model override.
692 *
693 * @param int $user_id Agent user id.
694 * @return string Empty string if not set.
695 */
696 function openstation_agent_get_model( $user_id ) {
697 return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_MODEL_META, true );
698 }
699
700 /**
701 * Read the rate limit (invocations per hour).
702 *
703 * @param int $user_id Agent user id.
704 * @return int Zero when no per-agent override is set.
705 */
706 function openstation_agent_get_rate_limit( $user_id ) {
707 return (int) get_user_meta( (int) $user_id, OPENSTATION_AGENT_RATE_LIMIT_META, true );
708 }
709
710 // ---------------------------------------------------------------------------
711 // Per-agent invocation gate
712 // ---------------------------------------------------------------------------
713
714 /**
715 * The agent's trigger row for a given invocation source, if any.
716 *
717 * Source slugs on the invoke route map 1:1 onto trigger kinds
718 * (`chat`, `drag`, `send-to`).
719 *
720 * @param int $agent_user_id Agent user id.
721 * @param string $source Invocation source slug.
722 * @return array|null Trigger row, or null when the agent declares none
723 * for this source.
724 */
725 function openstation_agent_trigger_for_source( $agent_user_id, $source ) {
726 $source = sanitize_key( (string) $source );
727 foreach ( openstation_agent_get_triggers( (int) $agent_user_id ) as $trigger ) {
728 if ( isset( $trigger['kind'] ) && $source === $trigger['kind'] ) {
729 return $trigger;
730 }
731 }
732 return null;
733 }
734
735 /**
736 * Whether the current user may invoke THIS agent through THIS source.
737 *
738 * The route-level `openstation_agents_user_can_invoke()` check is
739 * site-wide — it answers "may this user invoke agents at all". This is
740 * the per-agent half: a trigger may declare a `capability` in its
741 * config, and until it is enforced here the field is decorative. The
742 * Triggers pane collects it and the store persists it, so an
743 * administrator restricting an agent to `manage_options` has every
744 * reason to believe it took effect.
745 *
746 * An agent with no trigger for the source, or a trigger that declares
747 * no capability, is left to the route-level check — requiring a
748 * configured trigger would lock out every agent created before triggers
749 * were set up, which is all of them by default.
750 *
751 * @param int $agent_user_id Agent user id.
752 * @param string $source Invocation source slug.
753 * @return bool
754 */
755 function openstation_agent_user_can_invoke_agent( $agent_user_id, $source = 'chat' ) {
756 $trigger = openstation_agent_trigger_for_source( $agent_user_id, $source );
757 $capability = '';
758 if ( is_array( $trigger ) && isset( $trigger['config']['capability'] ) ) {
759 $capability = trim( (string) $trigger['config']['capability'] );
760 }
761
762 $can = '' === $capability || current_user_can( $capability );
763
764 /**
765 * Filter whether the current user may invoke a specific agent.
766 *
767 * @param bool $can Whether invocation is allowed.
768 * @param int $agent_user_id Agent user id.
769 * @param string $source Invocation source slug.
770 * @param array|null $trigger The matching trigger row, if any.
771 */
772 return (bool) apply_filters(
773 'openstation_agent_user_can_invoke_agent',
774 $can,
775 (int) $agent_user_id,
776 (string) $source,
777 $trigger
778 );
779 }
780
781 // ---------------------------------------------------------------------------
782 // List helper
783 // ---------------------------------------------------------------------------
784
785 /**
786 * Every agent on the site, ordered by display name.
787 *
788 * @param array $args Optional overrides merged into the `get_users()` query.
789 * @return WP_User[]
790 */
791 function openstation_agent_get_agents( $args = array() ) {
792 $defaults = array(
793 'meta_key' => OPENSTATION_AGENT_USER_MARKER_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
794 'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
795 'orderby' => 'display_name',
796 'order' => 'ASC',
797 'number' => 200,
798 );
799 return get_users( array_merge( $defaults, is_array( $args ) ? $args : array() ) );
800 }
801
802 // ---------------------------------------------------------------------------
803 // Orchestrators — the only write paths, each firing one audit action
804 // ---------------------------------------------------------------------------
805
806 /**
807 * Create an agent: synthetic user row + definition meta.
808 *
809 * @param array{name:string, role:string, slug?:string, description?:string, instructions?:string, abilities?:array} $args Creation args.
810 * @return WP_User|WP_Error
811 */
812 function openstation_agent_create( $args ) {
813 $role = isset( $args['role'] ) ? sanitize_key( (string) $args['role'] ) : '';
814 $allowed = openstation_agent_allowed_roles();
815 if ( '' === $role || ! in_array( $role, $allowed, true ) ) {
816 return new WP_Error(
817 'openstation_agent_invalid_role',
818 __( 'Pick a role you are allowed to assign to an agent.', 'desktop-mode' )
819 );
820 }
821
822 $user = openstation_agent_create_user( $args );
823 if ( is_wp_error( $user ) ) {
824 return $user;
825 }
826
827 $description = isset( $args['description'] ) ? sanitize_text_field( (string) $args['description'] ) : '';
828 $instructions = isset( $args['instructions'] ) ? wp_kses_post( (string) $args['instructions'] ) : '';
829 $abilities = isset( $args['abilities'] ) ? openstation_agents_sanitize_ability_slugs( $args['abilities'] ) : array();
830
831 if ( '' !== $description ) {
832 update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description );
833 }
834 if ( '' !== $instructions ) {
835 update_user_meta( $user->ID, OPENSTATION_AGENT_INSTRUCTIONS_META, $instructions );
836 }
837 if ( ! empty( $abilities ) ) {
838 update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) );
839 }
840 update_user_meta( $user->ID, OPENSTATION_AGENT_CREATED_BY_META, get_current_user_id() );
841
842 /**
843 * Fires after an agent is created.
844 *
845 * @param int $user_id Agent user id.
846 * @param array $args Sanitized creation fields (name, role,
847 * description, instructions, abilities).
848 * @param int $actor_id User who created the agent.
849 */
850 do_action(
851 'openstation_agent_created',
852 (int) $user->ID,
853 array(
854 'name' => (string) $user->display_name,
855 'role' => $role,
856 'description' => $description,
857 'instructions' => $instructions,
858 'abilities' => $abilities,
859 ),
860 get_current_user_id()
861 );
862
863 return $user;
864 }
865
866 /**
867 * Update an agent's definition. Accepts any subset of the recognized
868 * fields, applies the valid ones, and fires `openstation_agent_updated`
869 * once with a before/after map of everything that changed.
870 *
871 * Recognized fields: `name`, `role`, `description`, `instructions`,
872 * `abilities`, `triggers`, `model`, `rateLimit`.
873 *
874 * @param int $user_id Agent user id.
875 * @param array $fields Field map.
876 * @return true|WP_Error
877 */
878 function openstation_agent_update( $user_id, array $fields ) {
879 $user = get_userdata( (int) $user_id );
880 if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
881 return new WP_Error(
882 'openstation_agent_not_found',
883 __( 'Agent not found.', 'desktop-mode' )
884 );
885 }
886
887 $changed = array();
888
889 if ( isset( $fields['name'] ) ) {
890 $name = sanitize_text_field( (string) $fields['name'] );
891 if ( '' === $name ) {
892 return new WP_Error(
893 'openstation_agent_invalid_name',
894 __( 'Agent name cannot be empty.', 'desktop-mode' )
895 );
896 }
897 if ( $name !== (string) $user->display_name ) {
898 $changed['name'] = array(
899 'from' => (string) $user->display_name,
900 'to' => $name,
901 );
902 wp_update_user(
903 array(
904 'ID' => (int) $user->ID,
905 'display_name' => $name,
906 'nickname' => $name,
907 )
908 );
909 }
910 }
911
912 if ( isset( $fields['role'] ) ) {
913 $role = sanitize_key( (string) $fields['role'] );
914 if ( ! in_array( $role, openstation_agent_allowed_roles(), true ) ) {
915 return new WP_Error(
916 'openstation_agent_invalid_role',
917 __( 'Pick a role you are allowed to assign to an agent.', 'desktop-mode' )
918 );
919 }
920 $current_role = is_array( $user->roles ) && ! empty( $user->roles ) ? (string) reset( $user->roles ) : '';
921 if ( $role !== $current_role ) {
922 $changed['role'] = array(
923 'from' => $current_role,
924 'to' => $role,
925 );
926 $user->set_role( $role );
927 }
928 }
929
930 if ( isset( $fields['description'] ) ) {
931 $description = sanitize_text_field( (string) $fields['description'] );
932 $before = openstation_agent_get_description( $user->ID );
933 if ( $description !== $before ) {
934 $changed['description'] = array(
935 'from' => $before,
936 'to' => $description,
937 );
938 update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description );
939 }
940 }
941
942 if ( isset( $fields['instructions'] ) ) {
943 $instructions = wp_kses_post( (string) $fields['instructions'] );
944 $before = openstation_agent_get_instructions( $user->ID );
945 if ( $instructions !== $before ) {
946 $changed['instructions'] = array(
947 'from' => $before,
948 'to' => $instructions,
949 );
950 update_user_meta( $user->ID, OPENSTATION_AGENT_INSTRUCTIONS_META, $instructions );
951 }
952 }
953
954 if ( isset( $fields['abilities'] ) ) {
955 $abilities = openstation_agents_sanitize_ability_slugs( $fields['abilities'] );
956 $before = openstation_agent_get_abilities( $user->ID );
957 if ( $abilities !== $before ) {
958 $changed['abilities'] = array(
959 'from' => $before,
960 'to' => $abilities,
961 );
962 update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) );
963 }
964 }
965
966 if ( isset( $fields['triggers'] ) ) {
967 $triggers = openstation_agent_sanitize_triggers( $fields['triggers'] );
968 $before = openstation_agent_get_triggers( $user->ID );
969 if ( $triggers !== $before ) {
970 $changed['triggers'] = array(
971 'from' => $before,
972 'to' => $triggers,
973 );
974 update_user_meta( $user->ID, OPENSTATION_AGENT_TRIGGERS_META, wp_json_encode( $triggers ) );
975 }
976 }
977
978 if ( isset( $fields['model'] ) ) {
979 $model = sanitize_text_field( (string) $fields['model'] );
980 $before = openstation_agent_get_model( $user->ID );
981 if ( $model !== $before ) {
982 $changed['model'] = array(
983 'from' => $before,
984 'to' => $model,
985 );
986 if ( '' === $model ) {
987 delete_user_meta( $user->ID, OPENSTATION_AGENT_MODEL_META );
988 } else {
989 update_user_meta( $user->ID, OPENSTATION_AGENT_MODEL_META, $model );
990 }
991 }
992 }
993
994 if ( isset( $fields['rateLimit'] ) ) {
995 $rate = max( 0, (int) $fields['rateLimit'] );
996 $before = openstation_agent_get_rate_limit( $user->ID );
997 if ( $rate !== $before ) {
998 $changed['rateLimit'] = array(
999 'from' => $before,
1000 'to' => $rate,
1001 );
1002 if ( 0 === $rate ) {
1003 delete_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META );
1004 } else {
1005 update_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META, $rate );
1006 }
1007 }
1008 }
1009
1010 if ( ! empty( $changed ) ) {
1011 /**
1012 * Fires after an agent's definition changed.
1013 *
1014 * User meta has no revisions, so this action IS the audit
1015 * trail — each changed field carries its before/after value.
1016 *
1017 * @param int $user_id Agent user id.
1018 * @param array $changed Map of field => { from, to }.
1019 * @param int $actor_id User who made the change.
1020 */
1021 do_action( 'openstation_agent_updated', (int) $user->ID, $changed, get_current_user_id() );
1022 }
1023
1024 return true;
1025 }
1026