Skip to content

feat(channels): generic CLI transport runtime for agents/dispatch-message#413

Merged
chubes4 merged 1 commit into
mainfrom
feat/412-cli-channel-transport
May 16, 2026
Merged

feat(channels): generic CLI transport runtime for agents/dispatch-message#413
chubes4 merged 1 commit into
mainfrom
feat/412-cli-channel-transport

Conversation

@chubes4
Copy link
Copy Markdown
Member

@chubes4 chubes4 commented May 16, 2026

Summary

Implements the generic CLI transport runtime for agents/dispatch-message
described in #412. The runtime claims wp_agent_dispatch_message_handler
when this host can spawn subprocesses and the requested channel is
registered in a config map. It then shells out via proc_open to deliver
the message — zero per-transport code, adding a new outbound bridge is a
config entry.

The runtime has no knowledge of any specific CLI bridge. It is paired
with separate bridge installers (e.g. Extra-Chill/wp-coding-agents#129)
that write the channel→command-template entries during install.

Closes #412

Files

  • inc/Channels/CliChannelRegistry.php — config lookup via the
    datamachine_code_cli_channels filter + matching option, permissive
    validation, and positional token substitution for {recipient},
    {message}, {conversation_id}, {channel}.
  • inc/Channels/CliChannelTransport.php — the runtime. Filter-resolver
    that claims when shell + channel are available, declines (passes
    through) when not. Implements both detach (fire-and-forget, PID as
    message_id) and sync (stdout/stderr/exit_code captured, timeout
    enforced) delivery modes via proc_open with array argv.
  • tests/smoke-cli-channel-transport.php — 40-assertion smoke test
    covering registry validation, token substitution (including shell
    metacharacter safety), claim/decline semantics, and both delivery
    modes using /bin/echo, /bin/true, /bin/false, /bin/sleep.
  • data-machine-code.php — bootstrap wiring, gated on the agents-api
    substrate being loaded.

Acceptance criteria

  • agents/dispatch-message invocations with a registered channel
    are routed to the CLI runtime (filter-resolver returns
    [CliChannelTransport::class, 'execute']).
  • When Environment::has_shell() is false, the runtime declines and
    returns the existing handler so the filter chain continues.
  • Unknown channel names pass through cleanly so other handlers can
    claim them.
  • Positional argv substitution (no shell interpolation) is enforced
    — args are handed to proc_open as an array; {message} is delivered
    to the child as a single argv entry regardless of contents.
  • Detach mode returns immediately with a PID in message_id; sync
    mode captures stdout/stderr/exit_code and respects timeout.
  • Canonical output schema is returned on success (sent, channel,
    recipient, message_id, metadata). WP_Error is returned on
    failure (with machine-readable error codes:
    datamachine_code_cli_dispatch_no_proc_open, _spawn_failed,
    _nonzero_exit, _timeout, _unknown_channel, _invalid_input).
  • Smoke test demonstrates end-to-end dispatch using stub commands.
  • No knowledge of any specific runtime (kimaki, discord, slack,
    telegram, etc.) anywhere in the new code — verified via
    grep -iE 'kimaki|discord|slack|telegram|whatsapp|cc-connect|signal' inc/Channels/ tests/smoke-cli-channel-transport.php -r returning
    zero matches.

Smoke test output

```
=== smoke-cli-channel-transport ===
[PASS] valid entry normalizes
[PASS] normalized entry has args array
[PASS] normalized entry preserves detach false
[PASS] normalized entry preserves timeout
[PASS] missing command is rejected
[PASS] non-string arg is rejected
[PASS] option-defined channel is present
[PASS] filter-defined channel is present
[PASS] malformed entries are dropped
[PASS] lookup returns config for known channel
[PASS] lookup returns null for unknown channel
[PASS] recipient token substituted
[PASS] message token substituted verbatim (no shell interp)
[PASS] conversation_id token substituted
[PASS] channel token substituted
[PASS] literal arg untouched
[PASS] token substituted inside compound arg
[PASS] missing input substitutes empty string
[PASS] claims registered channel
[PASS] declines unknown channel (returns existing null)
[PASS] preserves prior handler at filter chain
[PASS] declines empty channel name
[PASS] sync success returns array
[PASS] sync success sent=true
[PASS] sync success channel echoes input
[PASS] sync success recipient echoes input
[PASS] sync success metadata mode=sync
[PASS] sync success exit_code 0
[PASS] sync success captures substituted stdout
[PASS] sync /bin/true succeeds
[PASS] nonzero exit returns WP_Error
[PASS] nonzero exit error code is machine-readable
[PASS] nonzero exit error data carries exit_code
[PASS] timeout returns WP_Error
[PASS] timeout error code is machine-readable
[PASS] unknown channel from execute returns WP_Error
[PASS] detached returns array
[PASS] detached sent=true
[PASS] detached message_id is numeric PID string
[PASS] detached metadata mode=detached

OK
```

Reproduce locally with:

```
php tests/smoke-cli-channel-transport.php
```

Design notes

Why a direct `add_filter` instead of the substrate's `register_dispatch_message_handler` helper

The substrate's convenience helper wraps the supplied handler in a
filter callback that returns it unconditionally (modulo a prior winner).
This runtime needs conditional decline semantics — pass through when
shell is unavailable, pass through when the channel is unknown — so it
registers `CliChannelTransport::maybe_claim()` on
`wp_agent_dispatch_message_handler` directly at priority 20, leaving
room for higher-precedence runtimes to win first. The behavior matches
the design described in the issue body.

Security

  • The command is never user-controlled at dispatch time. It comes from
    the registry, which is populated via filter/option by site admins.
  • Args are an array; substitution is positional via `strtr`; `proc_open`
    is invoked with the array form so PHP bypasses the shell entirely.
  • The child environment is built from the configured `env` map plus
    the parent's `PATH` only — no other inherited variables leak.
  • Permission gate is the substrate's existing
    `agents_dispatch_message_permission` filter; no new permission layer.

Output caps

Captured stdout/stderr is truncated to 8KB per stream to keep a runaway
child from blowing up the response payload.

Confirmation: no transport-specific names

```
$ grep -iE 'kimaki|discord|slack|telegram|whatsapp|cc-connect|signal' \
inc/Channels/ tests/smoke-cli-channel-transport.php -r
(no matches)
```

Related work

The wp-coding-agents config-write integration
(Extra-Chill/wp-coding-agents#129) is a separate PR that depends on
this one — that's where the actual per-bridge command templates get
written into the registry.


cc <@532385681268408341> ready for review.

…sage

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
@homeboy-ci
Copy link
Copy Markdown

homeboy-ci Bot commented May 16, 2026

Homeboy Results — data-machine-code

Lint

lint — failed

  • phpstan — 13 finding(s)
  • other — 2 finding(s)
  • wp-alternatives — 2 finding(s)
  • Total: 17 finding(s)

ℹ️ Auto-fix: homeboy lint data-machine-code --path /home/runner/work/data-machine-code/data-machine-code --changed-since cc65df2 --fix (or homeboy refactor data-machine-code --path /home/runner/work/data-machine-code/data-machine-code --changed-since cc65df2 --from lint --write)
ℹ️ Some issues may require manual fixes
ℹ️ Full options: homeboy docs commands/lint
ℹ️ Save lint baseline: homeboy lint data-machine-code --baseline
Deep dive: homeboy lint data-machine-code --changed-since cc65df2

Test

test — failed

ℹ️ No tests ran — the runner failed before producing results. See raw_output.stderr_tail / raw_output.stdout_tail for the underlying error (bootstrap failure, missing deps, DB connection, etc.).
ℹ️ To run specific tests: homeboy test data-machine-code -- --filter=TestName
ℹ️ Auto-fix lint issues: homeboy refactor data-machine-code --from lint --write
ℹ️ Collect coverage: homeboy test data-machine-code --coverage
ℹ️ Analyze failures: homeboy test data-machine-code --analyze
ℹ️ Pass args to test runner: homeboy test -- [args]
ℹ️ Full options: homeboy docs commands/test
Deep dive: homeboy test data-machine-code --changed-since cc65df2

Audit

audit — passed

  • requested_detectors — 3 finding(s)
  • test_coverage — 2 finding(s)
  • intra-method-duplication — 1 finding(s)
  • Total: 6 finding(s)

Deep dive: homeboy audit data-machine-code --changed-since cc65df2

Tooling versions
  • Homeboy CLI: homeboy 0.182.0+49a31c1
  • Extension: wordpress from https://github.com/Extra-Chill/homeboy-extensions
  • Extension revision: 9eb4c10
  • Action: unknown@unknown

@chubes4 chubes4 merged commit 61da37f into main May 16, 2026
3 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(channels): generic CLI transport runtime for agents/dispatch-message

1 participant