Devolutions / Devolutions/ironPosh
RFC: host call redesign — capability traits, default policy, typed exactly-once responders
- Dominant language
- Rust
- Stars
- 10
- Forks
- 2
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
Host calls are today the most painful consumer of the PSRP value layer: a flat dispatch over 56 numbered methods (`RemoteHostMethodId`, see reference `engine/remoting/host/RemoteHostMethodInfo.cs:15-90`) with positional CLIXML params, implicit void-vs-response semantics, and bespoke ~1,700-line handler files per frontend (`ironposh-client-tokio/src/hostcall.rs`, `ironposh-web/src/hostcall.rs`). This RFC proposes restructuring our side into **capability traits + a default answer policy + typed exactly-once responders**, with the wire dispatch generated from a declarative method table.
Companion to #12 (typed PSRP value layer) — the param/return types and table codegen here are Layer 3/4/5 artifacts of that design.
## Background: what PSHost / UI / RawUI actually are
The protocol RPCs PowerShell's v1 *local hosting* class hierarchy backwards over the wire. The three levels are a capability ladder, each optional:
- **`PSHost`** — "the application hosting the engine." Lifecycle, not I/O: `SetShouldExit`, nested prompts, `NotifyBegin/EndApplication`. A windowless service implements only this.
- **`PSHostUserInterface` (UI)** — "a conversation with the human." Content-level, device-agnostic: `WriteLine` (colors as intent), `ReadLine`, `Prompt` (field forms), `PromptForCredential/Choice`, `WriteProgress`. Meaningful with no terminal at all (a GUI host renders dialogs). What `Write-Host`/`Read-Host`/`Get-Credential` call.
- **`PSHostRawUserInterface` (RawUI)** — "the terminal as a device." A character-cell framebuffer: cursor/buffer/window geometry, console attributes, `ReadKey`, scrolling, `Get/SetBufferContents` (screen scraping), window title. Needed by `Clear-Host`, PSReadLine, progress rendering.
Semantically: **app ↔ human ↔ device**. Defensible as a local class hierarchy; painful because MS-PSRP v2 flattened all three into one numbered method table with positional `PsValue` params, implicit response requirements (forgetting to answer `ReadLine` hangs the remote pipeline), and a dual source of truth (`HostDefaultData` snapshot in `HostInfo` vs live RPCs).
## Proposed layer assignment (all 56 methods)
| Wire methods (ids) | Layer | Behavior |
|---|---|---|
| `GetName/Version/InstanceId/CurrentCulture/CurrentUICulture` (1-5) | **Policy** | Static session identity from config; frontend never sees them |
| `SetShouldExit` (6) | **Session core** | Becomes a session lifecycle event (REPL exit w/ code) |
| `Enter/ExitNestedPrompt` (7-8) | **Session core** | Decline with typed not-supported error (protocol-legal; server unwinds) |
| `NotifyBegin/EndApplication` (9-10) | **Session core** | Void no-op acks |
| `Write1/2`, `WriteLine1/2/3`, `WriteError/Debug/Verbose/WarningLine`, `WriteProgress` (13-22) | **`HostOutput` trait** | Fire-and-forget; no responder |
| `ReadLine(AsSecureString)`, `Prompt`, `PromptForCredential1/2`, `PromptForChoice` (11-12, 23-26), `PromptForChoiceMultipleSelection` (56) | **`HostPrompts` trait** | Response-mandatory with concrete return types (`String`, `SecureString`, field map, `PSCredential`, `i32`, `Vec`) |
| RawUI getters: colors, cursor/window pos, cursor size, buffer/window size, title, max sizes, `GetKeyAvailable`, `GetBufferContents` (27,29,31,33,35,37,39,41,43,44,45,50) | **Policy, overridable by `HostScreen`** | Answered from `HostDefaultData` snapshot + fallbacks (`KeyAvailable→false`, `GetBufferContents→empty`); live `HostScreen` values win when implemented |
| RawUI setters/actions: set colors/cursor/window/buffer/title, `FlushInputBuffer`, `SetBufferContents1/2`, `ScrollBufferContents` (28,30,32,34,36,38,40,42,47,48,49,51) | **`HostScreen` trait** | Void; maps ~1:1 onto xterm.js / crossterm; unimplemented → Policy acks + logs |
| `ReadKey` (46) | **`HostScreen`** | The one RawUI response-mandatory call (`KeyInfo`) |
| `PushRunspace/PopRunspace/GetIsRunspacePushed/GetRunspace` (52-55) | **Session core** | Interactive-session nesting; answer `false`/not-supported |
Net: a frontend implements **9 output + 7 prompt (+ optionally ~14 screen) methods**; 26 of 56 never reach it. A headless `-c` runner implements `HostOutput` only.
## Typed + mandatory responses (no hand-written switch)
**Trait implementors: the signature is the mandatory callback.**
```rust
trait HostPrompts {
async fn read_line(&mut self) -> Result;
async fn credential(&mut self, req: CredentialRequest) -> Result;
// ...
}
```
Cannot forget to reply (doesn't compile), cannot reply with the wrong type, `HostDecline` is the typed "can't do this" path so the server unwinds instead of hanging. The router awaits the future and writes the PSRP response.
**Event-loop UIs that can't await inline** (web, the tokio terminal thread): a one-shot token at the routing boundary —
```rust
#[must_use = "dropping a Responder auto-replies the policy default and logs an error"]
pub struct Responder { /* call id + oneshot */ }
impl Responder {
pub fn respond(self, value: T); // consumes self → no double-reply
pub fn decline(self, why: HostDecline);
}
// Drop (not consumed) → reply T::policy_default() + error!(method, "responder dropped")
```
Exactly-once by construction: consume-on-send kills double-reply, `Drop` kills never-reply (a panicked UI path degrades to a policy default instead of a hung remote pipeline), `T` kills wrong-shape. Strictly stronger than today's per-arm tribal knowledge of which methods need responses.
**The wire dispatch becomes a declarative table** (one decode point over 56 ids must exist, but generated, not hand-written):
```rust
host_methods! {
11 ReadLine () -> String => Prompts::read_line,
20 WriteProgress (record: ProgressRecord) -> () => Output::progress,
31 GetCursorPosition () -> Coordinates => Screen?::cursor | Policy,
46 ReadKey (options: ReadKeyOptions) -> KeyInfo => Screen::read_key,
// ... 56 rows
}
```
The macro generates: positional-param parsing into typed structs (the existing `host/methods.rs` types — `Coordinates`, `KeyInfo`, `ProgressRecord`, `FieldDescription`, … — become the #12 Layer-3 derive types), return serialization, the void/response distinction, and routing glue including the Policy fallback chain. Adding a method = one table row. The 56-arm match still exists — as generated code nobody reads, like a serde impl.
## Expected impact
- `ironposh-client-tokio/src/hostcall.rs` (~1,700 LOC) and `ironposh-web/src/hostcall.rs` collapse to trait impls expressing only that frontend's deltas; the shared Policy + router live once in client-core.
- `host/params.rs` + `returns.rs` (~800 LOC, 84 raw `PsValue` manipulations) replaced by table-generated conversions.
- Hung-pipeline bugs from missed responses become unrepresentable.
- New frontends (e.g. a future GUI credential prompt) implement one small trait instead of re-deciding 56 cases.
## Sequencing
Depends on #12 Layers 1+3 (accessors + derive) for the param/return conversions. Suggested order: land #12 phases 1-3 → method table + Policy + router in client-core (behind the existing dispatch, migrated method-group by method-group: Output first, Prompts, then Screen) → port tokio frontend → port web frontend → delete the bespoke handlers. Existing PTY e2e matrix (23 suites incl. hostcall matrix) is the regression net.
## References
- Reference: `engine/remoting/host/RemoteHostMethodInfo.cs:15-90` (method ids), `engine/hostifaces/` (PSHost/UI/RawUI contracts), `HostDefaultData` snapshot in `HostInfo`
- Ours: `crates/ironposh-client-core/src/host/{methods,params,returns,host_call}.rs`, `crates/ironposh-client-tokio/src/hostcall.rs`, `crates/ironposh-web/src/hostcall.rs`
- Related: #12 (typed PSRP value layer — this RFC is its flagship Layer-5 consumer)
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.