PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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 0.9.8, at includes/agents/store.php

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