Skip to content

Commit 49a31c1

Browse files
committed
feat(channels): generic CLI transport runtime for agents/dispatch-message
Adds CliChannelRegistry and CliChannelTransport under inc/Channels/. CliChannelRegistry resolves a channel name to a normalized command template via the datamachine_code_cli_channels filter and matching option, with permissive validation that silently drops malformed entries. It also owns positional token substitution for {recipient}, {message}, {conversation_id}, and {channel}. CliChannelTransport claims wp_agent_dispatch_message_handler at priority 20 when Environment::has_shell() succeeds and the requested channel is registered. Otherwise it returns the existing handler so other runtimes can claim the filter cleanly. The runtime supports both detached (fire-and-forget, returns PID as message_id) and synchronous (stdout/stderr/exit_code captured, timeout enforced) delivery modes. Command and args are passed to proc_open as an array so no shell interpolation occurs — message bodies containing shell metacharacters are delivered to the child process untouched. Bootstrap registration is gated on the agents-api substrate being loaded (function_exists check for the canonical helper). Closes #412
1 parent cc65df2 commit 49a31c1

4 files changed

Lines changed: 1080 additions & 0 deletions

File tree

data-machine-code.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ function datamachine_code_bootstrap() {
9393
new \DataMachineCode\Handlers\GitHub\GitHubPullRequestPublish();
9494
new \DataMachineCode\Handlers\GitHub\GitHubUpsert();
9595

96+
// Register the generic CLI transport runtime for agents/dispatch-message.
97+
// Only wires up when the agents-api substrate is loaded — its
98+
// register_dispatch_message_handler() helper is the canonical signal
99+
// that the dispatch filter contract is present on this install.
100+
if ( function_exists( 'AgentsAPI\\AI\\Channels\\register_dispatch_message_handler' ) ) {
101+
\DataMachineCode\Channels\CliChannelTransport::register();
102+
}
103+
96104
// Register ability categories on the correct hook (must happen during wp_abilities_api_categories_init).
97105
add_action( 'wp_abilities_api_categories_init', 'datamachine_code_register_ability_categories' );
98106
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
<?php
2+
/**
3+
* CLI channel registry.
4+
*
5+
* Generic configuration lookup for the CLI transport runtime. Channel
6+
* configurations map a channel identifier (e.g. the `channel` field of an
7+
* `agents/dispatch-message` invocation) to a command template that the
8+
* transport will execute via `proc_open` to deliver an outbound message.
9+
*
10+
* This class has zero knowledge of any specific transport. It is a pure
11+
* configuration layer that exposes whatever has been registered via either:
12+
*
13+
* - the `datamachine_code_cli_channels` filter, or
14+
* - the `datamachine_code_cli_channels` option.
15+
*
16+
* Filtered values win over the option value, but the filter receives the
17+
* option value as its starting input so callers can merge if they want.
18+
*
19+
* @package DataMachineCode\Channels
20+
* @since 0.43.0
21+
*/
22+
23+
namespace DataMachineCode\Channels;
24+
25+
defined( 'ABSPATH' ) || exit;
26+
27+
/**
28+
* @phpstan-type ChannelConfig array{
29+
* command: string,
30+
* args: array<int, string>,
31+
* detach?: bool,
32+
* timeout?: int,
33+
* env?: array<string, string>,
34+
* cwd?: string|null,
35+
* }
36+
*/
37+
class CliChannelRegistry {
38+
39+
/**
40+
* Filter and option key used for the channel registry.
41+
*
42+
* @var string
43+
*/
44+
public const REGISTRY_KEY = 'datamachine_code_cli_channels';
45+
46+
/**
47+
* Return the full registered channel map.
48+
*
49+
* Invalid entries are silently dropped so a malformed entry can never
50+
* cascade into the transport. Validation is intentionally minimal: the
51+
* transport itself does not care what command it runs as long as the
52+
* shape is right; site admins own the policy of what commands they
53+
* register.
54+
*
55+
* @since 0.43.0
56+
*
57+
* @return array<string, array<string, mixed>> Channel name => config map.
58+
*/
59+
public static function get_channels(): array {
60+
$option_value = array();
61+
if ( function_exists( 'get_option' ) ) {
62+
$raw = get_option( self::REGISTRY_KEY, array() );
63+
if ( is_array( $raw ) ) {
64+
$option_value = $raw;
65+
}
66+
}
67+
68+
$channels = $option_value;
69+
if ( function_exists( 'apply_filters' ) ) {
70+
/**
71+
* Filter the CLI channel registry map.
72+
*
73+
* Consumers register channel configurations here. Each entry must
74+
* be a valid config array — see {@see CliChannelRegistry::normalize_entry()}.
75+
*
76+
* @since 0.43.0
77+
*
78+
* @param array<string, array<string, mixed>> $channels Existing registry.
79+
*/
80+
$filtered = apply_filters( self::REGISTRY_KEY, $channels );
81+
if ( is_array( $filtered ) ) {
82+
$channels = $filtered;
83+
}
84+
}
85+
86+
$valid = array();
87+
foreach ( $channels as $name => $config ) {
88+
if ( ! is_string( $name ) || '' === $name ) {
89+
continue;
90+
}
91+
if ( ! is_array( $config ) ) {
92+
continue;
93+
}
94+
$normalized = self::normalize_entry( $config );
95+
if ( null === $normalized ) {
96+
continue;
97+
}
98+
$valid[ $name ] = $normalized;
99+
}
100+
101+
return $valid;
102+
}
103+
104+
/**
105+
* Look up a single channel by name.
106+
*
107+
* @since 0.43.0
108+
*
109+
* @param string $channel Channel identifier.
110+
* @return array<string, mixed>|null Normalized config, or null if unknown / invalid.
111+
*/
112+
public static function lookup( string $channel ): ?array {
113+
if ( '' === $channel ) {
114+
return null;
115+
}
116+
117+
$channels = self::get_channels();
118+
if ( ! isset( $channels[ $channel ] ) ) {
119+
return null;
120+
}
121+
122+
return $channels[ $channel ];
123+
}
124+
125+
/**
126+
* Validate and normalize a single channel config entry.
127+
*
128+
* Returns the normalized array (with defaults applied) on success, or
129+
* null when the entry is malformed enough that the transport could not
130+
* reasonably execute it. The shape requirements are intentionally
131+
* narrow:
132+
*
133+
* - `command` must be a non-empty string.
134+
* - `args` must be an array of strings (empty allowed).
135+
* - `detach` defaults to true.
136+
* - `timeout` defaults to 30 seconds and is only meaningful when
137+
* `detach` is false.
138+
* - `env` defaults to an empty array.
139+
* - `cwd` defaults to null.
140+
*
141+
* @since 0.43.0
142+
*
143+
* @param array<string, mixed> $config Raw config entry.
144+
* @return array<string, mixed>|null Normalized config or null if invalid.
145+
*/
146+
public static function normalize_entry( array $config ): ?array {
147+
$command = $config['command'] ?? null;
148+
if ( ! is_string( $command ) || '' === trim( $command ) ) {
149+
return null;
150+
}
151+
152+
$args = $config['args'] ?? array();
153+
if ( ! is_array( $args ) ) {
154+
return null;
155+
}
156+
$normalized_args = array();
157+
foreach ( $args as $arg ) {
158+
if ( ! is_string( $arg ) ) {
159+
return null;
160+
}
161+
$normalized_args[] = $arg;
162+
}
163+
164+
$detach = $config['detach'] ?? true;
165+
if ( ! is_bool( $detach ) ) {
166+
$detach = (bool) $detach;
167+
}
168+
169+
$timeout = $config['timeout'] ?? 30;
170+
if ( ! is_int( $timeout ) || $timeout < 0 ) {
171+
$timeout = 30;
172+
}
173+
174+
$env = $config['env'] ?? array();
175+
if ( ! is_array( $env ) ) {
176+
$env = array();
177+
}
178+
$normalized_env = array();
179+
foreach ( $env as $env_key => $env_value ) {
180+
if ( ! is_string( $env_key ) || '' === $env_key ) {
181+
continue;
182+
}
183+
if ( ! is_scalar( $env_value ) ) {
184+
continue;
185+
}
186+
$normalized_env[ $env_key ] = (string) $env_value;
187+
}
188+
189+
$cwd = $config['cwd'] ?? null;
190+
if ( null !== $cwd && ( ! is_string( $cwd ) || '' === $cwd ) ) {
191+
$cwd = null;
192+
}
193+
194+
return array(
195+
'command' => $command,
196+
'args' => $normalized_args,
197+
'detach' => $detach,
198+
'timeout' => $timeout,
199+
'env' => $normalized_env,
200+
'cwd' => $cwd,
201+
);
202+
}
203+
204+
/**
205+
* Substitute canonical tokens into an args array.
206+
*
207+
* Tokens are replaced inside each string argument via simple string
208+
* replacement. The args list is then passed to `proc_open` as an array
209+
* — there is no shell interpolation step, so a `{message}` containing
210+
* shell metacharacters is delivered to the child process as a single
211+
* argv entry, untouched.
212+
*
213+
* Recognized tokens: `{recipient}`, `{message}`, `{conversation_id}`,
214+
* `{channel}`.
215+
*
216+
* Unknown tokens are left as-is. Missing input keys substitute the
217+
* empty string.
218+
*
219+
* @since 0.43.0
220+
*
221+
* @param array<int, string> $args Template args.
222+
* @param array<string, mixed> $input Canonical dispatch-message input.
223+
* @return array<int, string> Args with tokens substituted.
224+
*/
225+
public static function substitute_tokens( array $args, array $input ): array {
226+
$replacements = array(
227+
'{recipient}' => self::stringify( $input['recipient'] ?? '' ),
228+
'{message}' => self::stringify( $input['message'] ?? '' ),
229+
'{conversation_id}' => self::stringify( $input['conversation_id'] ?? '' ),
230+
'{channel}' => self::stringify( $input['channel'] ?? '' ),
231+
);
232+
233+
$result = array();
234+
foreach ( $args as $arg ) {
235+
$result[] = strtr( $arg, $replacements );
236+
}
237+
return $result;
238+
}
239+
240+
/**
241+
* Convert a value to a string for token substitution.
242+
*
243+
* @param mixed $value Source value.
244+
* @return string Stringified value.
245+
*/
246+
private static function stringify( $value ): string {
247+
if ( null === $value ) {
248+
return '';
249+
}
250+
if ( is_scalar( $value ) ) {
251+
return (string) $value;
252+
}
253+
return '';
254+
}
255+
}

0 commit comments

Comments
 (0)