Skip to content

Commit ae21679

Browse files
committed
action handler: SSH executor target (B1) — arago ExecuteCommand-over-SSH
The native executor made remote. ogar-action-handler::SshExecutor shells out to the system `ssh` binary (dep-free, exactly like NativeCommandExecutor shells to `sh` — no SSH-library / C dep), non-interactive by construction (BatchMode=yes, StrictHostKeyChecking=accept-new), and returns the same output/stderr/exitcode resultParameters shape. An ssh connection failure surfaces as exitcode 255 (a reported result), not an executor error — same convention as the native one for a failed command. Clone, so it composes into Daemon / run_gated as a gated route. - build_args is the pure, testable half: BatchMode=yes always, optional -i/-p, the target, then `--` so the remote command is never re-parsed as an ssh flag. - 4 tests: argv is non-interactive + well-ordered; -i/-p threaded; missing command is an error before spawn (no host needed); unknown capability rejected. End-to-end remote exec needs a live sshd (absent in CI) — documented. Executor family split: the Command-based dep-free executors (native + SSH) live in OGAR ogar-action-handler; the library-based network executor (REST, ureq) lives in rs-graph-llm. WinRM is the one executor target left. Also recovers two doc commits orphaned when #123 merged before they landed (B2-transport + REST scorecard reflections), and adds the SSH reflection: ARAGO-ACTIONHANDLER-PARITY (SSH §3 bullet + scorecard row + verdict + cross-ref), D-ACTIONHANDLER-SSH discovery row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP
1 parent dfadbcd commit ae21679

3 files changed

Lines changed: 176 additions & 15 deletions

File tree

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: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,14 @@ downstream. The remaining bricks:
194194
`graph-flow-action-ogar::rest::RestExecutor` (`feature = "rest"`, pure-Rust
195195
`ureq`) POSTs the bound params to an HTTP endpoint and returns the response as
196196
`resultParameters` — the arago HTTP-callout shape — and runs only behind the
197-
gate (`rest_executor_runs_only_behind_the_gate`). SSH / WinRM follow the same
198-
`CapabilityExecutor` trait (SSH = arago's canonical `ExecuteCommand`-over-SSH);
199-
rs-graph-llm hosts the production executors (the gate runs `commit_via`).
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.
200205
- **B1-uplink — the hard gate before the executor (SHIPPED).** rs-graph-llm's
201206
`graph-flow-action-ogar` crate is the seam: `GatedOgarHandler` wraps an OGAR
202207
`CapabilityExecutor` as a `graph-flow-action::ActionHandler`, so the executor's
@@ -260,7 +265,8 @@ transport over them.
260265
| **Executor — native target (B1)** |`[G]` SHIPPED | `ogar-action-handler::NativeCommandExecutor` runs `ExecuteCommand` for real; `full_dispatch_runs_a_real_command` |
261266
| **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) |
262267
| **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`) |
263-
| **Executor — SSH/WinRM (B1)** |`[H]` | further `CapabilityExecutor` impls (SSH = arago's canonical `ExecuteCommand`-over-SSH; needs an SSH client + a live host to test) |
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) |
264270
| **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` |
265271
| **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 |
266272
| **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) |
@@ -280,17 +286,18 @@ end-to-end, `rest_registration_lifts_binds_and_runs`) and per-handler `StateGuar
280286
sets (`rest_applicabilities_lift_to_per_handler_guards`). And the **live daemon
281287
runs over a real socket**`graph-flow-action-ogar::daemon` drives the gated
282288
dispatch through a `Transport` trait, with the `action-ws` WebSocket edge proven
283-
by a mock-server roundtrip. Two executor targets run gated — **native** (local
284-
command) and **REST** (HTTP callout). What's left for a **live** drop-in
285-
replacement of arago's Python daemon: the **Kafka transport edge** (HIRO's
286-
internal bus — `rdkafka` over the same `Transport` trait, needs the topic/record
287-
shape pinned) and the **SSH executor** (arago's canonical `ExecuteCommand`-over-SSH
288-
— an SSH client over the same `CapabilityExecutor` trait, needs a live host to
289-
test). Each is a single edge/runner impl over existing types — **no missing IR,
290-
no missing protocol mapping**. That is the honest state: OGAR *is* an ActionHandler
291-
that reads its own registration, gates every action, runs commands and HTTP
292-
callouts, and speaks `action-ws` over a live socket; a Kafka consumer + an SSH
293-
runner away from being arago's Python daemon, on any HIRO deployment.
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.
294301

295302
---
296303

@@ -329,6 +336,8 @@ is replaceable; the parity claim is certified, not argued.
329336
`WsTransport` (action-ws WebSocket edge) + the OGIT-`Auth`-derived identity.
330337
- `rs-graph-llm/graph-flow-action-ogar/src/rest.rs` — the **REST executor**
331338
(`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.
332341
- arago: `github.com/arago/ActionHandlers`,
333342
`arago/python-hiro-stonebranch-actionhandler`, HIRO 7 Action API tutorial.
334343
- **HIRO 7 Action API machine-readable specs (the authoritative harvest, §2a):**

docs/DISCOVERY-MAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ two halves of a cell. ADR‑026 names the cascade that ties them.
215215
| D‑ACTIONHANDLER‑B2LIFT | REST registration **instance lift** (B2-lift) — turns a deployed handler's `GET /capabilities` JSON (`MapOfCapabilities`) into the concrete signatures the *schema* half can't supply: `registration::{RegisteredCapability,ModelFilter}` (typed DTOs, `Deserialize` behind the `serde` feature) + the pure lift `lift_registration → ConcreteCapability` (concrete `ActionParam[]` with `(name,mandatory,default)`) and `model_filter_to_guard` (arago `ModelFilter{Var,Mode,Value}`→`KausalSpec::StateGuard`, field‑for‑field). Producer stays parser‑free; the runtime `ogar-action-handler::parse_capabilities` does the `serde_json` read (producer‑defines‑types / runtime‑does‑I/O split). Proven end‑to‑end: `rest_registration_lifts_binds_and_runs` (real JSON → lift → `bind_parameters` → `NativeCommandExecutor` runs the command). Remaining: the `GET /applicabilities` `MapOfApplicabilities` envelope read (the `ModelFilter→StateGuard` lift is done). **CORRECTION (canon‑pass 2026‑06‑24): applicabilities envelope now SHIPPED** — `registration::{RegisteredApplicability,lift_applicabilities}` + `ogar-action-handler::parse_applicabilities` lift a real `GET /applicabilities` JSON body into per‑handler `StateGuard` sets (`rest_applicabilities_lift_to_per_handler_guards`); inner filter‑list field name alias‑flexible (`modelFilters`/`model`/`filters`) pending a live response. Supersedes the "Remaining" note | G | CODED | `ogar-from-schema/src/registration.rs`, `ogar-action-handler/src/lib.rs` | D‑ACTIONHANDLER‑PARITY |
216216
| D‑ACTIONHANDLER‑TRANSPORT | The live action daemon (B2-transport), **transport-agnostic** by construction: rs‑graph‑llm `graph-flow-action-ogar::daemon::Daemon::react` turns one inbound `action-ws` JSON frame into the outbound frames it warrants (ack + `sendActionResult`, or nack), running the hard gate (`run_gated`) + executor in between — pure, no I/O. A `Transport` trait (`recv`/`send`) is the swappable edge; `Daemon::serve` is the loop; both the WebSocket and a future Kafka edge share it verbatim (HIRO distributes actions over BOTH wires — the wire differs, the dispatch doesn't). The `WsTransport` action-ws edge (`feature = "ws"`, tokio-tungstenite) presents the `token-$TOKEN` subprotocol and is proven by `ws_roundtrip_against_a_mock_server` (engine submitAction → ack → real command → result over a socket). Connection identity is an `Auth` type shaped after OGIT `NTO/Auth/Configuration` (`auth_store` 0x0B01): the principal the transport authenticates as (`accountId`) IS the actor the gate authorizes. Remaining: the Kafka edge (`rdkafka` over the same trait — core ready, needs topic/record shape) and SSH/REST executors | G (core + ws edge) / H (kafka edge) | CODED | `rs-graph-llm/graph-flow-action-ogar/src/daemon.rs` | D‑ACTIONHANDLER‑UPLINK, D‑ACTIONHANDLER‑B2LIFT |
217217
| D‑ACTIONHANDLER‑REST | REST executor target (B1) — the arago HTTP-callout handler shape: rs‑graph‑llm `graph-flow-action-ogar::rest::RestExecutor` (`feature = "rest"`, pure-Rust `ureq`, sync — fits the sync `CapabilityExecutor` trait) POSTs the bound params as a JSON body to a configured endpoint, returns the response `status`+`body` as `resultParameters`. Any completed HTTP response (incl. 4xx/5xx) is `resultParameters`; only a transport failure is an executor `Err` (mirrors arago reporting the callee's response). `Clone` (ureq Agent is Arc-backed) ⟹ composes into `Daemon`/`run_gated` as a gated route. Proven: `posts_params_and_returns_status_and_body` (mock HTTP) + `rest_executor_runs_only_behind_the_gate` (authorized → REST call fires; unauthorized → `Denied`, endpoint never hit). Completes the executor family with native; SSH/WinRM remain | G | CODED | `rs-graph-llm/graph-flow-action-ogar/src/rest.rs` | D‑ACTIONHANDLER‑UPLINK |
218+
| D‑ACTIONHANDLER‑SSH | SSH executor target (B1) — arago's canonical `ExecuteCommand`-over-SSH: `ogar-action-handler::SshExecutor` shells out to the system `ssh` binary (dep-free, like `NativeCommandExecutor` shells to `sh`), non-interactive by construction (`BatchMode=yes`), same `output`/`stderr`/`exitcode` resultParameters shape — the native executor made remote. `build_args` (pure argv construction with `-i`/`-p` + `--` command terminator) and the pre-spawn guards (missing-command / unknown-capability) are tested; end-to-end remote exec needs a live sshd (absent in CI). The two Command-based dep-free executors (native + SSH) live in OGAR; library-based network executors (REST) in rs-graph-llm | G (code) / H (live exec) | CODED | `ogar-action-handler/src/lib.rs` | D‑ACTIONHANDLER‑REST |
218219
| D‑OSM | `ogar-from-osm-pbf` — Node/Way/Relation; quadkey NiblePath from resolved geometry | H | IDEA | (queued) | D‑VOCAB, `[per rt]` D‑OSM‑3 |
219220
| D‑PATTERN | `ogar-pattern` — recognition library + confidence (FMA‑D/FIBO/SKR/PROV‑O) | H | IDEA | (queued) | D‑TTL |
220221
| D‑ACTION | `ogar-actionable` — lifecycle → `ActionDef`/`KausalSpec` | H | IDEA | (queued) | D‑PATTERN |

0 commit comments

Comments
 (0)