PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 All 36 releases
← All changes | includes/agents/store.php +295 -22 1.1.1 → 1.1.12 View file →
@@ -94,10 +94,42 @@
94 94 * URL). The mismatch between this constant's name and its value is
95 95 * deliberate — it is NOT a half-finished rename.
96 96 */
97 97 const OPENSTATION_AGENT_CREATED_BY_META = '_desktop_mode_agent_created_by';
98 +/**
99 + * The VALUE keeps its pre-rebrand spelling on purpose: it is a
100 + * persisted or externally-visible identifier, so renaming it would
101 + * orphan data already written by live installs (or break a live
102 + * URL). The mismatch between this constant's name and its value is
103 + * deliberate — it is NOT a half-finished rename.
104 + */
105 +const OPENSTATION_AGENT_VIBES_META = '_desktop_mode_agent_vibes';
98 106
99 107 /**
108 + * Longest voice line an agent may carry.
109 + *
110 + * Short enough that it stays a voice rather than becoming a second
111 + * instruction block by volume.
112 + */
113 +const OPENSTATION_AGENT_VIBES_MAX_LENGTH = 120;
114 +/**
115 + * The VALUE keeps its pre-rebrand spelling on purpose: it is a
116 + * persisted or externally-visible identifier, so renaming it would
117 + * orphan data already written by live installs (or break a live
118 + * URL). The mismatch between this constant's name and its value is
119 + * deliberate — it is NOT a half-finished rename.
120 + */
121 +const OPENSTATION_AGENT_FACE_META = '_desktop_mode_agent_face';
122 +/**
123 + * The VALUE keeps its pre-rebrand spelling on purpose: it is a
124 + * persisted or externally-visible identifier, so renaming it would
125 + * orphan data already written by live installs (or break a live
126 + * URL). The mismatch between this constant's name and its value is
127 + * deliberate — it is NOT a half-finished rename.
128 + */
129 +const OPENSTATION_AGENT_FACE_SEED_META = '_desktop_mode_agent_face_seed';
130 +
131 +/**
100 132 * Every meta key the store writes — the privacy eraser and any future
101 133 * cleanup path iterate this list instead of re-typing the constants.
102 134 *
103 135 * @return string[]
@@ -111,8 +143,11 @@
111 143 OPENSTATION_AGENT_TRIGGERS_META,
112 144 OPENSTATION_AGENT_MODEL_META,
113 145 OPENSTATION_AGENT_RATE_LIMIT_META,
114 146 OPENSTATION_AGENT_CREATED_BY_META,
147 + OPENSTATION_AGENT_VIBES_META,
148 + OPENSTATION_AGENT_FACE_META,
149 + OPENSTATION_AGENT_FACE_SEED_META,
115 150 );
116 151 }
117 152
118 153 /**
@@ -200,8 +235,44 @@
200 235 'sanitize_callback' => 'absint',
201 236 'auth_callback' => $auth,
202 237 )
203 238 );
239 + register_meta(
240 + 'user',
241 + OPENSTATION_AGENT_VIBES_META,
242 + array(
243 + 'type' => 'string',
244 + 'single' => true,
245 + 'default' => '',
246 + 'show_in_rest' => false,
247 + 'sanitize_callback' => 'openstation_agent_sanitize_vibes',
248 + 'auth_callback' => $auth,
249 + )
250 + );
251 + register_meta(
252 + 'user',
253 + OPENSTATION_AGENT_FACE_META,
254 + array(
255 + 'type' => 'string',
256 + 'single' => true,
257 + 'default' => '',
258 + 'show_in_rest' => false,
259 + 'sanitize_callback' => 'openstation_agent_sanitize_face_json',
260 + 'auth_callback' => $auth,
261 + )
262 + );
263 + register_meta(
264 + 'user',
265 + OPENSTATION_AGENT_FACE_SEED_META,
266 + array(
267 + 'type' => 'integer',
268 + 'single' => true,
269 + 'default' => 0,
270 + 'show_in_rest' => false,
271 + 'sanitize_callback' => 'absint',
272 + 'auth_callback' => $auth,
273 + )
274 + );
204 275 }
205 276 add_action( 'init', 'openstation_agents_register_user_meta' );
206 277
207 278 // ---------------------------------------------------------------------------
@@ -247,8 +318,113 @@
247 318 return (string) wp_json_encode( openstation_agents_sanitize_ability_slugs( $value ) );
248 319 }
249 320
250 321 /**
322 + * Sanitize an agent's voice line.
323 + *
324 + * One short line of character: "blunt, precise, no sugarcoating". It
325 + * is appended to the agent's instructions at run time, so it reaches a
326 + * language model.
327 + *
328 + * **That is not a new privilege boundary.** Writing it needs
329 + * `edit_users`, the same capability that already lets you write
330 + * `instructions`, which is the entire system prompt. A 120-character
331 + * tone line is strictly less reach than that, so it deliberately sits
332 + * behind the same gate rather than a stricter one, and this note
333 + * exists so nobody "hardens" it later into a confusing split.
334 + *
335 + * Two structural guards it does need. `sanitize_text_field()` strips
336 + * line breaks, which is load-bearing: the runner marks operator turns
337 + * in the composed prompt, and a multi-line voice line could otherwise
338 + * fake a turn boundary. And the length cap keeps a "voice" from
339 + * becoming a second instruction block by volume.
340 + *
341 + * @param mixed $value Incoming line.
342 + * @return string
343 + */
344 +function openstation_agent_sanitize_vibes( $value ) {
345 + if ( ! is_scalar( $value ) ) {
346 + return '';
347 + }
348 + $clean = sanitize_text_field( (string) $value );
349 + return mb_substr( $clean, 0, OPENSTATION_AGENT_VIBES_MAX_LENGTH );
350 +}
351 +
352 +/**
353 + * Sanitize an agent's face for storage.
354 + *
355 + * The narrowing itself is `openstation_mio_narrow_look()`, which the
356 + * WP Explorer config also calls to preview the shipped cast while the
357 + * feature flag is off. Shared on purpose: two copies of "clamp, then
358 + * keep what was carried" is how a preview starts drawing a face the
359 + * seeder would never store. What this adds on top is the storage
360 + * shape — a JSON string, and an empty one when nothing was set, so an
361 + * agent with no opinion keeps no row at all rather than an empty blob.
362 + *
363 + * @param mixed $value Incoming look (array or JSON string).
364 + * @return string JSON, or an empty string when nothing was set.
365 + */
366 +function openstation_agent_sanitize_face_json( $value ) {
367 + if ( is_string( $value ) ) {
368 + $decoded = json_decode( $value, true );
369 + $value = is_array( $decoded ) ? $decoded : array();
370 + }
371 + $out = openstation_mio_narrow_look( $value );
372 + if ( empty( $out['appearance'] ) && empty( $out['physics'] ) ) {
373 + return '';
374 + }
375 + return (string) wp_json_encode( $out );
376 +}
377 +
378 +/**
379 + * Read an agent's voice line.
380 + *
381 + * @param int $user_id Agent user id.
382 + * @return string
383 + */
384 +function openstation_agent_get_vibes( $user_id ) {
385 + return (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_VIBES_META, true );
386 +}
387 +
388 +/**
389 + * Read an agent's stored face.
390 + *
391 + * A partial look: only what was overridden. Empty means "no face
392 + * chosen", which the avatar resolver reads as the shipped robot.
393 + *
394 + * @param int $user_id Agent user id.
395 + * @return array {
396 + * @type array $appearance Partial appearance overrides.
397 + * @type array $physics Partial silhouette overrides.
398 + * }
399 + */
400 +function openstation_agent_get_face( $user_id ) {
401 + $raw = (string) get_user_meta( (int) $user_id, OPENSTATION_AGENT_FACE_META, true );
402 + if ( '' === $raw ) {
403 + return array(
404 + 'appearance' => array(),
405 + 'physics' => array(),
406 + );
407 + }
408 + return openstation_sanitize_mio_look( json_decode( $raw, true ) );
409 +}
410 +
411 +/**
412 + * Read the seed an agent's face was rolled from.
413 + *
414 + * Kept alongside the face rather than instead of it. The face is what
415 + * gets drawn; the seed is provenance, and it is what lets a future
416 + * change to the randomizer's ranges re-roll every agent in one
417 + * migration instead of stranding them on an old palette.
418 + *
419 + * @param int $user_id Agent user id.
420 + * @return int Seed, or 0 when none was recorded.
421 + */
422 +function openstation_agent_get_face_seed( $user_id ) {
423 + return (int) get_user_meta( (int) $user_id, OPENSTATION_AGENT_FACE_SEED_META, true );
424 +}
425 +
426 +/**
251 427 * Sanitize the triggers array.
252 428 *
253 429 * Validates each row against the kind catalogue. Drops any row that
254 430 * doesn't match a known kind — one bad row never rejects the whole
@@ -714,9 +890,12 @@
714 890 /**
715 891 * The agent's trigger row for a given invocation source, if any.
716 892 *
717 893 * Source slugs on the invoke route map 1:1 onto trigger kinds
718 - * (`chat`, `drag`, `send-to`).
894 + * (`chat`, `drag`, `send-to`). The row is context for the invocation
895 + * filter; it does not decide which capabilities the invocation gate
896 + * requires, which is every capability on every trigger (see
897 + * `openstation_agent_user_can_invoke_agent()`).
719 898 *
720 899 * @param int $agent_user_id Agent user id.
721 900 * @param string $source Invocation source slug.
722 901 * @return array|null Trigger row, or null when the agent declares none
@@ -732,43 +911,65 @@
732 911 return null;
733 912 }
734 913
735 914 /**
736 - * Whether the current user may invoke THIS agent through THIS source.
915 + * Whether the current user may invoke THIS agent.
737 916 *
738 917 * The route-level `openstation_agents_user_can_invoke()` check is
739 918 * site-wide — it answers "may this user invoke agents at all". This is
740 919 * 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.
920 + * config, and the Triggers pane collects it and the store persists it,
921 + * so an administrator restricting an agent to `manage_options` has
922 + * every reason to believe it took effect.
745 923 *
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
924 + * The caller must hold EVERY capability declared on ANY of the agent's
925 + * triggers, whichever source the request names. The source is supplied
926 + * by the client (the invoke route takes it as a request parameter), so
927 + * it describes how the request says it arrived, not what it is allowed
928 + * to reach: a capability scoped to one trigger kind would be satisfied
929 + * by naming another. A capability configured on an agent is therefore
930 + * a property of the agent.
931 + *
932 + * An agent whose triggers declare no capability (including one with no
933 + * triggers at all) is left to the route-level check — requiring a
748 934 * configured trigger would lock out every agent created before triggers
749 935 * were set up, which is all of them by default.
750 936 *
751 937 * @param int $agent_user_id Agent user id.
752 - * @param string $source Invocation source slug.
938 + * @param string $source Invocation source slug the request names
939 + * (`chat`, `drag`, `send-to`). Context for
940 + * the filter only; it does not select which
941 + * capabilities apply.
753 942 * @return bool
754 943 */
755 944 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'] );
945 + $can = true;
946 + foreach ( openstation_agent_get_triggers( (int) $agent_user_id ) as $row ) {
947 + if ( ! isset( $row['config']['capability'] ) || ! is_scalar( $row['config']['capability'] ) ) {
948 + continue;
949 + }
950 + $capability = trim( (string) $row['config']['capability'] );
951 + if ( '' !== $capability && ! current_user_can( $capability ) ) {
952 + $can = false;
953 + break;
954 + }
760 955 }
761 956
762 - $can = '' === $capability || current_user_can( $capability );
763 -
764 957 /**
765 958 * Filter whether the current user may invoke a specific agent.
766 959 *
767 - * @param bool $can Whether invocation is allowed.
960 + * @param bool $can Whether invocation is allowed: the
961 + * caller holds every capability
962 + * declared on any of the agent's
963 + * triggers.
768 964 * @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.
965 + * @param string $source Invocation source slug the request
966 + * names. Client-supplied on the invoke
967 + * route, so context rather than proof
968 + * of how the request arrived.
969 + * @param array|null $trigger The trigger row whose kind matches
970 + * `$source`, if any. Context only: it
971 + * is not what decided `$can`.
771 972 */
772 973 return (bool) apply_filters(
773 974 'openstation_agent_user_can_invoke_agent',
774 975 $can,
@@ -773,9 +974,9 @@
773 974 'openstation_agent_user_can_invoke_agent',
774 975 $can,
775 976 (int) $agent_user_id,
776 977 (string) $source,
777 - $trigger
978 + openstation_agent_trigger_for_source( $agent_user_id, $source )
778 979 );
779 980 }
780 981
781 982 // ---------------------------------------------------------------------------
@@ -805,9 +1006,9 @@
805 1006
806 1007 /**
807 1008 * Create an agent: synthetic user row + definition meta.
808 1009 *
809 - * @param array{name:string, role:string, slug?:string, description?:string, instructions?:string, abilities?:array} $args Creation args.
1010 + * @param array{name:string, role:string, slug?:string, description?:string, instructions?:string, abilities?:array, triggers?:array, vibes?:string, face?:array|string, faceSeed?:int} $args Creation args.
810 1011 * @return WP_User|WP_Error
811 1012 */
812 1013 function openstation_agent_create( $args ) {
813 1014 $role = isset( $args['role'] ) ? sanitize_key( (string) $args['role'] ) : '';
@@ -826,8 +1027,19 @@
826 1027
827 1028 $description = isset( $args['description'] ) ? sanitize_text_field( (string) $args['description'] ) : '';
828 1029 $instructions = isset( $args['instructions'] ) ? wp_kses_post( (string) $args['instructions'] ) : '';
829 1030 $abilities = isset( $args['abilities'] ) ? openstation_agents_sanitize_ability_slugs( $args['abilities'] ) : array();
1031 + $triggers = isset( $args['triggers'] ) ? openstation_agent_sanitize_triggers( $args['triggers'] ) : array();
1032 + $vibes = isset( $args['vibes'] ) ? openstation_agent_sanitize_vibes( $args['vibes'] ) : '';
1033 + $face = isset( $args['face'] ) ? openstation_agent_sanitize_face_json( $args['face'] ) : '';
1034 + // Every agent gets a seed even when nobody chose a face, so the
1035 + // backfill has something deterministic to roll from and two admins
1036 + // racing it land on the same portrait. `crc32` of the login is
1037 + // stable, cheap, and already unique per agent.
1038 + $seed = isset( $args['faceSeed'] ) ? absint( $args['faceSeed'] ) : 0;
1039 + if ( 0 === $seed ) {
1040 + $seed = crc32( (string) $user->user_login );
1041 + }
830 1042
831 1043 if ( '' !== $description ) {
832 1044 update_user_meta( $user->ID, OPENSTATION_AGENT_DESCRIPTION_META, $description );
833 1045 }
@@ -836,8 +1048,18 @@
836 1048 }
837 1049 if ( ! empty( $abilities ) ) {
838 1050 update_user_meta( $user->ID, OPENSTATION_AGENT_ABILITIES_META, wp_json_encode( $abilities ) );
839 1051 }
1052 + if ( ! empty( $triggers ) ) {
1053 + update_user_meta( $user->ID, OPENSTATION_AGENT_TRIGGERS_META, wp_json_encode( $triggers ) );
1054 + }
1055 + if ( '' !== $vibes ) {
1056 + update_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META, $vibes );
1057 + }
1058 + if ( '' !== $face ) {
1059 + update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, $face );
1060 + }
1061 + update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_SEED_META, $seed );
840 1062 update_user_meta( $user->ID, OPENSTATION_AGENT_CREATED_BY_META, get_current_user_id() );
841 1063
842 1064 /**
843 1065 * Fires after an agent is created.
@@ -855,8 +1077,11 @@
855 1077 'role' => $role,
856 1078 'description' => $description,
857 1079 'instructions' => $instructions,
858 1080 'abilities' => $abilities,
1081 + 'vibes' => $vibes,
1082 + 'face' => $face,
1083 + 'faceSeed' => $seed,
859 1084 ),
860 1085 get_current_user_id()
861 1086 );
862 1087
@@ -868,9 +1093,10 @@
868 1093 * fields, applies the valid ones, and fires `openstation_agent_updated`
869 1094 * once with a before/after map of everything that changed.
870 1095 *
871 1096 * Recognized fields: `name`, `role`, `description`, `instructions`,
872 - * `abilities`, `triggers`, `model`, `rateLimit`.
1097 + * `abilities`, `triggers`, `model`, `rateLimit`, `vibes`, `face`,
1098 + * `faceSeed`.
873 1099 *
874 1100 * @param int $user_id Agent user id.
875 1101 * @param array $fields Field map.
876 1102 * @return true|WP_Error
@@ -893,9 +1119,12 @@
893 1119 'openstation_agent_invalid_name',
894 1120 __( 'Agent name cannot be empty.', 'desktop-mode' )
895 1121 );
896 1122 }
897 - if ( $name !== (string) $user->display_name ) {
1123 + // Compared as plain text: the stored name carries entities
1124 + // (`&`), and the client sends back the decoded one it was
1125 + // given on every save. Raw, that reads as a rename.
1126 + if ( openstation_plain_text_title( $name ) !== openstation_plain_text_title( $user->display_name ) ) {
898 1127 $changed['name'] = array(
899 1128 'from' => (string) $user->display_name,
900 1129 'to' => $name,
901 1130 );
@@ -1003,8 +1232,52 @@
1003 1232 delete_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META );
1004 1233 } else {
1005 1234 update_user_meta( $user->ID, OPENSTATION_AGENT_RATE_LIMIT_META, $rate );
1006 1235 }
1236 + }
1237 + }
1238 +
1239 + if ( isset( $fields['vibes'] ) ) {
1240 + $vibes = openstation_agent_sanitize_vibes( $fields['vibes'] );
1241 + $before = openstation_agent_get_vibes( $user->ID );
1242 + if ( $vibes !== $before ) {
1243 + $changed['vibes'] = array(
1244 + 'from' => $before,
1245 + 'to' => $vibes,
1246 + );
1247 + if ( '' === $vibes ) {
1248 + delete_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META );
1249 + } else {
1250 + update_user_meta( $user->ID, OPENSTATION_AGENT_VIBES_META, $vibes );
1251 + }
1252 + }
1253 + }
1254 +
1255 + if ( isset( $fields['face'] ) ) {
1256 + $face = openstation_agent_sanitize_face_json( $fields['face'] );
1257 + $before = (string) get_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, true );
1258 + if ( $face !== $before ) {
1259 + $changed['face'] = array(
1260 + 'from' => $before,
1261 + 'to' => $face,
1262 + );
1263 + if ( '' === $face ) {
1264 + delete_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META );
1265 + } else {
1266 + update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_META, $face );
1267 + }
1268 + }
1269 + }
1270 +
1271 + if ( isset( $fields['faceSeed'] ) ) {
1272 + $seed = absint( $fields['faceSeed'] );
1273 + $before = openstation_agent_get_face_seed( $user->ID );
1274 + if ( $seed !== $before ) {
1275 + $changed['faceSeed'] = array(
1276 + 'from' => $before,
1277 + 'to' => $seed,
1278 + );
1279 + update_user_meta( $user->ID, OPENSTATION_AGENT_FACE_SEED_META, $seed );
1007 1280 }
1008 1281 }
1009 1282
1010 1283 if ( ! empty( $changed ) ) {