Skip to content

Commit ea19e0e

Browse files
authored
Merge pull request #124 from AdaWorldAPI/claude/actionhandler-ssh-executor
action handler: SSH executor target (ExecuteCommand-over-SSH) + recovered B2 docs
2 parents 149a73b + ae21679 commit ea19e0e

4 files changed

Lines changed: 248 additions & 15 deletions

File tree

.claude/board/EPIPHANIES.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,46 @@
77
88
---
99

10+
## 2026-06-24 — E-ACTIONHANDLER-TRANSPORT — the daemon is transport-agnostic because HIRO is multi-wire; and the OGIT Auth type unifies "who connects" with "who the gate authorizes"
11+
12+
**Status:** FINDING (`[G]` for the core + WebSocket edge; `[H]` for the Kafka edge).
13+
14+
Two design facts surfaced building B2-transport (the live action daemon, in
15+
rs-graph-llm `graph-flow-action-ogar::daemon`):
16+
17+
1. **HIRO distributes actions over more than one wire** — a handler-facing
18+
WebSocket (`action-ws`) AND an internal Kafka bus that legacy handlers consume
19+
directly (operator note, 2026-06-24). The wire differs; the dispatch doesn't.
20+
So the daemon is factored as: `Daemon::react` (the transport-agnostic core —
21+
one inbound `action-ws` frame → outbound frames, running the gate + executor,
22+
pure/no-I/O) + a `Transport` trait (`recv`/`send`, the swappable edge) +
23+
`Daemon::serve` (the loop, generic over `Transport`). The WebSocket edge
24+
(`WsTransport`) and a future Kafka edge (`rdkafka`) share `serve` verbatim —
25+
the gated dispatch is written once, the wire is a thin shell. This is the
26+
action-arm analogue of the codec stack's "one algebra, many carriers": one
27+
dispatch, many transports.
28+
29+
2. **The OGIT Auth type unifies the two identities that must be the same.** A
30+
handler's connection presents a credential; the gate authorizes an actor.
31+
These MUST be the same principal — and OGIT's `NTO/Auth/Configuration` (the
32+
`auth_store` class, OGAR `0x0B01`) already unifies them: it is keyed by
33+
`accountId` and maps `sub` → actor (`0x0104`), org/tenant → scope. So the
34+
daemon's `Auth` type is shaped after it: one value carries the `token` the
35+
transport presents (the `token-$TOKEN` subprotocol) AND the `account` the gate
36+
authorizes as (`accountId` → actor). `Daemon::new` takes `&Auth` and derives
37+
the gate actor from `auth.account`; `WsTransport::connect` takes `&Auth` and
38+
presents `auth.token`. The identity that connects IS the identity the RBAC
39+
grant is checked against — structurally, not by convention. (A future
40+
producer-side `auth_from_ogit(entity)` lift would populate `Auth` from a real
41+
`NTO/Auth/Configuration` node, the same way `assemble_action_handler` lifts the
42+
handler contract.)
43+
44+
Proven by `ws_roundtrip_against_a_mock_server` (engine `submitAction` → ack → real
45+
command → result over a live socket) + 10 pure-core tests. Scorecard: B2-transport
46+
WebSocket edge SHIPPED; Kafka edge reserved (`D-ACTIONHANDLER-TRANSPORT`).
47+
48+
---
49+
1050
## 2026-06-24 — E-ACTIONHANDLER-B2LIFT — the producer stays parser-free even when lifting a JSON REST response: it defines the `Deserialize` DTOs + the pure lift, the runtime does the `from_str`
1151

1252
**Status:** FINDING (`[G]` for capabilities; `[H]` for the applicabilities envelope).

crates/ogar-action-handler/src/lib.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,118 @@ impl CapabilityExecutor for NativeCommandExecutor {
141141
}
142142
}
143143

144+
/// Reference executor for the **SSH** target: runs an `ExecuteCommand`
145+
/// capability's `command` on a remote host via the system `ssh` binary. This is
146+
/// arago's canonical ActionHandler shape (`ExecuteCommand`-over-SSH) — the
147+
/// [`NativeCommandExecutor`], remote.
148+
///
149+
/// Dependency-free by design: it shells out to `ssh` (like the native one shells
150+
/// out to `sh`), so it carries no SSH-library / C dependency and fits the same
151+
/// sync [`CapabilityExecutor`] seam. Non-interactive by construction
152+
/// (`BatchMode=yes` — never prompt for a password/passphrase; a connection that
153+
/// would prompt fails fast). Returns the same `output` / `stderr` / `exitcode`
154+
/// shape as the native executor — an `ssh` connection failure surfaces as a
155+
/// non-zero `exitcode` (255), the same way the native one reports a failed
156+
/// command, *not* an executor error.
157+
///
158+
/// **Trust model:** identical to the native executor — the gate
159+
/// (`commit_via<ClassRbac>`) runs upstream; this executor assumes its caller
160+
/// already authorized the action and runs the command verbatim on the target.
161+
#[derive(Debug, Clone)]
162+
pub struct SshExecutor {
163+
target: String,
164+
identity: Option<String>,
165+
port: Option<u16>,
166+
}
167+
168+
impl SshExecutor {
169+
/// The single capability this executor implements (same verb as native —
170+
/// it is remote execution of the same `ExecuteCommand`).
171+
pub const CAPABILITY: &'static str = "ExecuteCommand";
172+
173+
/// An SSH executor for `target` (`user@host` or `host`).
174+
pub fn new(target: impl Into<String>) -> Self {
175+
Self {
176+
target: target.into(),
177+
identity: None,
178+
port: None,
179+
}
180+
}
181+
182+
/// Use a specific identity file (`ssh -i`).
183+
#[must_use]
184+
pub fn with_identity(mut self, identity: impl Into<String>) -> Self {
185+
self.identity = Some(identity.into());
186+
self
187+
}
188+
189+
/// Connect on a non-default port (`ssh -p`).
190+
#[must_use]
191+
pub fn with_port(mut self, port: u16) -> Self {
192+
self.port = Some(port);
193+
self
194+
}
195+
196+
/// Build the `ssh` argument vector for `command` — the pure, testable half
197+
/// (no spawn). Always non-interactive (`BatchMode=yes`); `--` terminates ssh
198+
/// options so the remote command is never re-parsed as a flag.
199+
fn build_args(&self, command: &str) -> Vec<String> {
200+
let mut args = vec![
201+
"-o".to_owned(),
202+
"BatchMode=yes".to_owned(),
203+
"-o".to_owned(),
204+
"StrictHostKeyChecking=accept-new".to_owned(),
205+
];
206+
if let Some(port) = self.port {
207+
args.push("-p".to_owned());
208+
args.push(port.to_string());
209+
}
210+
if let Some(identity) = &self.identity {
211+
args.push("-i".to_owned());
212+
args.push(identity.clone());
213+
}
214+
args.push(self.target.clone());
215+
args.push("--".to_owned());
216+
args.push(command.to_owned());
217+
args
218+
}
219+
}
220+
221+
impl CapabilityExecutor for SshExecutor {
222+
fn execute(
223+
&self,
224+
capability: &str,
225+
bound: &[(String, String)],
226+
) -> Result<Vec<(String, String)>, String> {
227+
if capability != Self::CAPABILITY {
228+
return Err(format!(
229+
"ssh executor implements only `{}`, not `{capability}`",
230+
Self::CAPABILITY
231+
));
232+
}
233+
let command = bound
234+
.iter()
235+
.find(|(k, _)| k == "command")
236+
.map(|(_, v)| v.as_str())
237+
.ok_or_else(|| "missing `command` parameter".to_owned())?;
238+
239+
let output = Command::new("ssh")
240+
.args(self.build_args(command))
241+
.output()
242+
.map_err(|e| format!("failed to spawn ssh to `{}`: {e}", self.target))?;
243+
244+
let trim = |b: &[u8]| String::from_utf8_lossy(b).trim_end().to_owned();
245+
Ok(vec![
246+
("output".to_owned(), trim(&output.stdout)),
247+
("stderr".to_owned(), trim(&output.stderr)),
248+
(
249+
"exitcode".to_owned(),
250+
output.status.code().unwrap_or(-1).to_string(),
251+
),
252+
])
253+
}
254+
}
255+
144256
#[cfg(test)]
145257
mod tests {
146258
use super::*;
@@ -294,4 +406,43 @@ mod tests {
294406
result.result
295407
);
296408
}
409+
410+
#[test]
411+
fn ssh_builds_a_non_interactive_argv() {
412+
// The pure half: the ssh argv is well-formed and non-interactive.
413+
let args = SshExecutor::new("ops@node-1").build_args("uptime");
414+
// BatchMode=yes is mandatory (never prompt).
415+
assert!(args.windows(2).any(|w| w == ["-o", "BatchMode=yes"]));
416+
// target, then `--`, then the remote command (never re-parsed as a flag).
417+
let dashdash = args.iter().position(|a| a == "--").unwrap();
418+
assert_eq!(args[dashdash - 1], "ops@node-1");
419+
assert_eq!(args[dashdash + 1], "uptime");
420+
}
421+
422+
#[test]
423+
fn ssh_identity_and_port_are_threaded() {
424+
let args = SshExecutor::new("host")
425+
.with_identity("/keys/id_ed25519")
426+
.with_port(2222)
427+
.build_args("ls");
428+
assert!(args.windows(2).any(|w| w == ["-p", "2222"]));
429+
assert!(args.windows(2).any(|w| w == ["-i", "/keys/id_ed25519"]));
430+
}
431+
432+
#[test]
433+
fn ssh_missing_command_is_an_error_before_spawn() {
434+
// No `command` → error without ever invoking ssh (no host needed).
435+
let err = SshExecutor::new("host")
436+
.execute("ExecuteCommand", &[])
437+
.unwrap_err();
438+
assert!(err.contains("command"), "got: {err}");
439+
}
440+
441+
#[test]
442+
fn ssh_unknown_capability_is_rejected() {
443+
let err = SshExecutor::new("host")
444+
.execute("RunScript", &[("command".to_owned(), "x".to_owned())])
445+
.unwrap_err();
446+
assert!(err.contains("ExecuteCommand"), "got: {err}");
447+
}
297448
}

docs/ARAGO-ACTIONHANDLER-PARITY.md

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,18 @@ downstream. The remaining bricks:
190190
**native target is SHIPPED**: `ogar-action-handler::NativeCommandExecutor` runs
191191
`ExecuteCommand` via a local POSIX shell and returns `output`/`stderr`/`exitcode`
192192
— proven end-to-end by `full_dispatch_runs_a_real_command` ("OGAR running it
193-
here," native). SSH / REST / WinRM targets follow the same trait; rs-graph-llm's
194-
`graph-flow-action` provides the production executors (and runs `commit_via`).
193+
here," native). The **REST target is SHIPPED too**: rs-graph-llm
194+
`graph-flow-action-ogar::rest::RestExecutor` (`feature = "rest"`, pure-Rust
195+
`ureq`) POSTs the bound params to an HTTP endpoint and returns the response as
196+
`resultParameters` — the arago HTTP-callout shape — and runs only behind the
197+
gate (`rest_executor_runs_only_behind_the_gate`). The **SSH target** is coded
198+
too: `ogar-action-handler::SshExecutor` shells out to the system `ssh`
199+
(dep-free, non-interactive `BatchMode=yes`) — arago's canonical
200+
`ExecuteCommand`-over-SSH, the native executor made remote; its argv
201+
construction + pre-spawn guards are tested, end-to-end exec needs a live host
202+
(no sshd in CI). WinRM is the one executor target left. The native + SSH
203+
(Command-based, dep-free) executors live in OGAR `ogar-action-handler`; the
204+
network ones (REST, library-based) live in rs-graph-llm.
195205
- **B1-uplink — the hard gate before the executor (SHIPPED).** rs-graph-llm's
196206
`graph-flow-action-ogar` crate is the seam: `GatedOgarHandler` wraps an OGAR
197207
`CapabilityExecutor` as a `graph-flow-action::ActionHandler`, so the executor's
@@ -201,10 +211,21 @@ downstream. The remaining bricks:
201211
unauthorized actor (`Denied`) or a MUL `Block` (`Escalated`) never reaches the
202212
OGAR executor. Three tests pin it; `NativeCommandExecutor` runs the real command
203213
only on the authorized path. OGAR owns the executor; rs-graph-llm owns the gate.
204-
- **B2-transport — the live WebSocket loop.** Wrap `handle_submit` in a
205-
`tokio-tungstenite` client (connect with the `token-$TOKEN` subprotocol, JSON-
206-
codec the six `action_ws` message types, drive the dispatch, retry-on-no-ack).
207-
All the message types, connection path, and auth are now pinned (§2a).
214+
- **B2-transport — the live daemon (SHIPPED, WebSocket edge).** Built in
215+
rs-graph-llm's `graph-flow-action-ogar::daemon` as a **transport-agnostic** core:
216+
`Daemon::react` turns one inbound `action-ws` JSON frame into the outbound frames
217+
it warrants (`acknowledged` + `sendActionResult`, or `negativeAcknowledged`),
218+
running the hard gate (`run_gated`) + the executor in between — pure, no I/O. A
219+
`Transport` trait is the swappable edge (`recv`/`send`); `Daemon::serve` is the
220+
loop. The **`WsTransport`** WebSocket edge (`feature = "ws"`) connects with the
221+
`token-$TOKEN` subprotocol and is proven by a mock-server roundtrip
222+
(`ws_roundtrip_against_a_mock_server`: engine `submitAction` → ack → run → result
223+
over a real socket). The connection identity is an `Auth` type shaped after OGIT
224+
`NTO/Auth/Configuration` (`auth_store` `0x0B01`) — the same principal the
225+
transport authenticates as (`accountId`) is the actor the gate authorizes.
226+
**HIRO also distributes actions over Kafka**; that edge (`rdkafka` over the same
227+
`Transport` trait) is reserved — the core is ready, it needs the topic/record
228+
shape pinned.
208229
- **B2-lift — the instance config lift (SHIPPED for capabilities).** Parse a
209230
deployed handler's REST registration → the concrete signatures the *schema*
210231
half cannot supply. `GET /capabilities` is **shipped**: `registration::{RegisteredCapability,
@@ -243,8 +264,11 @@ transport over them.
243264
| **Reactive dispatch + B1 seam** |`[G]` SHIPPED | `action_ws::handle_submit` + the `CapabilityExecutor` trait (validate→ack→bind→execute→result; tested with a mock) |
244265
| **Executor — native target (B1)** |`[G]` SHIPPED | `ogar-action-handler::NativeCommandExecutor` runs `ExecuteCommand` for real; `full_dispatch_runs_a_real_command` |
245266
| **Hard gate before executor (B1-uplink)** |`[G]` SHIPPED | rs-graph-llm `graph-flow-action-ogar::GatedOgarHandler``commit_via` (RBAC ∧ guard ∧ MUL) lands before `handle`; `take_result()` is `None` iff the gate refused (3 tests) |
246-
| **Executor — SSH/REST/WinRM (B1)** |`[H]` | further `CapabilityExecutor` impls (rs-graph-llm `graph-flow-action`) |
247-
| **Live WebSocket transport (B2-transport)** |`[H]` | wrap `handle_submit` in a `tokio-tungstenite` loop + JSON codec (all shapes pinned, §2a) |
267+
| **Executor — REST target (B1)** |`[G]` SHIPPED | rs-graph-llm `graph-flow-action-ogar::rest::RestExecutor` (`feature = "rest"`, ureq) POSTs bound params → resultParameters; runs only behind the gate (`rest_executor_runs_only_behind_the_gate`) |
268+
| **Executor — SSH target (B1)** | 🟡 `[G]` code / `[H]` live | `ogar-action-handler::SshExecutor` shells out to system `ssh` (non-interactive `BatchMode=yes`, same `output`/`stderr`/`exitcode` shape as native) — arago's canonical `ExecuteCommand`-over-SSH, dep-free. argv construction + pre-spawn guards tested; end-to-end needs a live host (no sshd in CI) |
269+
| **Executor — WinRM (B1)** |`[H]` | a further `CapabilityExecutor` impl (Windows remote exec) |
270+
| **Live transport — daemon core + WebSocket (B2-transport)** |`[G]` SHIPPED | rs-graph-llm `graph-flow-action-ogar::daemon`: transport-agnostic `Daemon::react`/`serve` + `Transport` trait + `WsTransport` (action-ws), gate-driving; mock-server roundtrip. `Auth` ← OGIT `NTO/Auth/Configuration` |
271+
| **Live transport — Kafka edge (B2-transport)** |`[H]` | `rdkafka` over the same `Transport` trait (action topic → result topic); core ready, needs the topic/record shape pinned |
248272
| **Instance config lift — capabilities (B2-lift)** |`[G]` SHIPPED | `registration::lift_registration` + `ogar-action-handler::parse_capabilities`: real `GET /capabilities` JSON → `ConcreteCapability` (`ActionParam[]`); `rest_registration_lifts_binds_and_runs` (JSON → lift → bind → run) |
249273
| **Instance config lift — applicabilities (B2-lift)** |`[G]` SHIPPED | `registration::lift_applicabilities` + `ogar-action-handler::parse_applicabilities`: real `GET /applicabilities` JSON → per-handler `StateGuard` sets; `rest_applicabilities_lift_to_per_handler_guards`. Residual: inner filter-list field name is alias-flexible pending a live response |
250274

@@ -259,13 +283,21 @@ MUL-blocked action never executes (proven structurally — `take_result()` is
259283
`None`). The **whole instance lift is shipped too** — real `GET /capabilities`
260284
and `GET /applicabilities` JSON bodies lift to concrete `ActionParam[]` (runs
261285
end-to-end, `rest_registration_lifts_binds_and_runs`) and per-handler `StateGuard`
262-
sets (`rest_applicabilities_lift_to_per_handler_guards`). What's left for
263-
a **live** drop-in replacement of arago's Python daemon: **B2-transport** (the
264-
WebSocket loop — all shapes/auth pinned) and the **non-native executor targets**
265-
(SSH/REST). Each is transport/runner glue over existing types — **no missing IR,
266-
no missing protocol mapping**. That is the honest state: OGAR *is* an ActionHandler
267-
that runs commands here, reads its own registration, and gates every action; a thin
268-
WebSocket transport away from connecting to a live HIRO engine.
286+
sets (`rest_applicabilities_lift_to_per_handler_guards`). And the **live daemon
287+
runs over a real socket**`graph-flow-action-ogar::daemon` drives the gated
288+
dispatch through a `Transport` trait, with the `action-ws` WebSocket edge proven
289+
by a mock-server roundtrip. Three executor targets run gated — **native** (local
290+
command), **SSH** (remote command, arago's canonical shape — coded, live-host test
291+
pending) and **REST** (HTTP callout). The one thing left for a **live** drop-in
292+
replacement of arago's Python daemon that needs a real input: the **Kafka
293+
transport edge** (HIRO's internal bus — `rdkafka` over the same `Transport` trait,
294+
needs the topic/record shape pinned + a broker to test). WinRM is a further
295+
executor for completeness. Everything is a single edge/runner impl over existing
296+
types — **no missing IR, no missing protocol mapping**. That is the honest state:
297+
OGAR *is* an ActionHandler that reads its own registration, gates every action,
298+
runs commands locally / over SSH / as HTTP callouts, and speaks `action-ws` over a
299+
live socket; a Kafka consumer away from being arago's Python daemon, on a HIRO
300+
deployment that distributes over Kafka.
269301

270302
---
271303

@@ -299,6 +331,13 @@ is replaceable; the parity claim is certified, not argued.
299331
- `rs-graph-llm/graph-flow-action-ogar` — the **uplink**: OGAR's
300332
`CapabilityExecutor` behind the hard gate (`GatedOgarHandler` / `run_gated`);
301333
`commit_via` lands before any execution.
334+
- `rs-graph-llm/graph-flow-action-ogar/src/daemon.rs`**B2-transport**: the
335+
transport-agnostic `Daemon` (`react`/`serve`) + the `Transport` trait +
336+
`WsTransport` (action-ws WebSocket edge) + the OGIT-`Auth`-derived identity.
337+
- `rs-graph-llm/graph-flow-action-ogar/src/rest.rs` — the **REST executor**
338+
(`RestExecutor`, `feature = "rest"`): the arago HTTP-callout target, gated.
339+
- `crates/ogar-action-handler/src/lib.rs` — the **native** (`NativeCommandExecutor`)
340+
+ **SSH** (`SshExecutor`, shells out to `ssh`) executor targets, dep-free.
302341
- arago: `github.com/arago/ActionHandlers`,
303342
`arago/python-hiro-stonebranch-actionhandler`, HIRO 7 Action API tutorial.
304343
- **HIRO 7 Action API machine-readable specs (the authoritative harvest, §2a):**

0 commit comments

Comments
 (0)