Skip to content

RFC #12 (2/5): derive PSRP & host-call messages#21

Merged
irvingouj@Devolutions (irvingoujAtDevolution) merged 19 commits into
stack/01-rfc12-derive-enginefrom
stack/02-rfc12-message-derives
Jun 23, 2026
Merged

RFC #12 (2/5): derive PSRP & host-call messages#21
irvingouj@Devolutions (irvingoujAtDevolution) merged 19 commits into
stack/01-rfc12-derive-enginefrom
stack/02-rfc12-message-derives

Conversation

@irvingoujAtDevolution

Copy link
Copy Markdown
Collaborator

Stack 2 of 5 — base: stack/01-rfc12-derive-engine (stacked on #20).

Applies the derive engine to the PSRP message set and host calls: typed
PsrpMessage stream (L4), tagged property-bag collapse, #[ps(dictionary/flatten/ value_dictionary/wrap/to_string/nil_when_none)], typed RemoteHostMethodId, and
derives for session/capability/runspace/progress/host-info/error/information records.

Second half of the RFC #12 series. Review after #20.

Claude (claude) and others added 14 commits June 17, 2026 21:07
…en FromParams/ToPs bodies

Migrate the 8 host param/return types (Coordinates, Size, Rectangle,
BufferCell, KeyInfo, ProgressRecord, FieldDescription, ChoiceDescription,
PSCredential) to #[derive(PsSerialize, PsDeserialize)] using the now
crate-agnostic macro. Dual camelCase+PascalCase emission via #[ps(also=..)],
<TN> chains via #[ps(type_names(..))], SecureString credential blob and the
nested ProgressRecordType enum via #[ps(with=..)], lenient host params via
#[ps(default)].

The ~700 lines of hand-written PsValue construction/parsing in
host/params.rs and host/returns.rs collapse to:
- the derived conversions (zero manual PsValue logic), plus
- thin positional adapters (arg(a,i) / Some(v.to_ps_value())) — argument
  plumbing, not CLIXML conversion. The only remaining hand-built object is the
  genuinely dynamic Prompt-result Hashtable (runtime field names).

Byte-for-byte safety: added host::snapshot_test characterization tests that
lock the exact CLIXML of the return types (incl. the credential SecureString
and dual-casing) captured from the old impls, and assert the derived path
reproduces them identically. Full workspace build, 40 test groups, clippy and
fmt all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
New field attr nil_when_none: an Option<..> field is always emitted (as Nil
when None) instead of being omitted — required by objects whose slot must be
present. Migrate CommandParameter (N is Nil when positional, V is the dynamic
escape-hatch value) to the derive. create_pipeline roundtrip tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…ssages

Define RemoteHostMethodId (MS-PSRP §2.2.3.17, 56 variants) as a PsEnum object —
the mi enum-object is now macro-derived (PsEnums<I32> is byte-identical to the
old ExtendedPrimitive<I32>, ToString = variant name).

Migrate RUNSPACEPOOL_HOST_CALL and PIPELINE_HOST_CALL to #[derive]: ci (i64),
mi (RemoteHostMethodId), mp (Vec<PsValue>, the method-specific params stay the
dynamic boundary). Replaces method_id:i32 + method_name:String + hand-built mi.

client-core's define_host_methods! now matches on the typed phc.method
(exhaustive over the enum) instead of an i32 literal; an unknown method id is
rejected when the wire enum is parsed (test repurposed accordingly). The
real-capture parse_real_pipeline_host_call test passes (WriteProgress + its
ProgressRecord param now go through the derived path). Workspace green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
Migrate RUNSPACEPOOL_HOST_RESPONSE and PIPELINE_HOST_RESPONSE to #[derive]:
ci (i64), mi (RemoteHostMethodId), mr/me (optional dynamic-value boundary).
All four host call/response messages now build their mi enum-object by macro;
no hand-written ComplexObject in any of them.

Threaded the typed method end-to-end across crates:
- HostCall::method() -> RemoteHostMethodId (generated by define_host_methods!);
- RemoteHostMethodId::from_id(i32) for the transport result path;
- client-core send_*_host_response and the CancelHostCall op now carry
  RemoteHostMethodId instead of (method_id: i32, method_name: String);
- web/tokio response construction uses host_call.method().

Workspace builds, 40 test groups, clippy and fmt green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…ipeline

New field attr to_string: also set the object's <ToString> from a String field.
Migrate Command and PowerShellPipeline to #[derive]:
- Command: cmd (#[ps(to_string)] -> the object's ToString, matching pwsh),
  Args = Vec<CommandParameter> (plain ArrayList; the old to_string on Args was
  a divergence from captured pwsh XML and is dropped), UseLocalScope
  (nil_when_none), 8 merge fields via a lenient merge_result_conv (#[ps(with)])
  that tolerates flag combinations like Output|Error=3 -> None, matching the
  old behavior.
- PowerShellPipeline: cmds = Vec<Command>, History via a with-converter
  (empty -> Nil).

The exact-XML round-trip command tests pass unchanged, proving byte fidelity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
ProgressRecordType -> PsEnum object repr; ProgressRecord -> #[derive], keeping
its TypedBuilder transforms (parent-id negative filter, percent clamp). The
old negative-filter/-1-default on the parse side were dead defensive paths
(the builder normalizes before serialize); the roundtrip tests pass unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…e ApplicationPrivateData

Add reusable ToPsValue/FromPsValue for BTreeMap<String, PsValue> (a
PSPrimitiveDictionary <DCT>). Migrate APPLICATION_PRIVATE_DATA to #[derive]:
data: Option<BTreeMap<..>> with #[ps(nil_when_none)] (Nil when absent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…licationArguments/PSVersionTable

New PsObject struct mode `#[ps(dictionary)]`: serialize fields as a <DCT>
PSPrimitiveDictionary keyed by field name (instead of an <MS> property bag),
with `#[ps(flatten)]` merging a BTreeMap field's entries into the same dict
(and collecting unclaimed keys on parse).

Migrate PSVersionTable and ApplicationArguments to the derive (dictionary mode
+ Version/version-array with-converters + flatten for additional_arguments).
The hand-built dictionary ComplexObject construction is gone; round-trip tests
pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…HostInfo/HostDefaultData

Eliminates the last hand-written ComplexObject in the host_info cluster.
HostDefaultData now derives an integer-keyed {T,V} value dictionary via
#[ps(value_dictionary, key, type_tag)]; HostInfo nests it one level under a
'data' member via #[ps(wrap)]. ValueWrapper and all manual TryFrom/From impls
are gone. Wire bytes verified unchanged by create_pipeline exact-XML test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
Both now derive their CLIXML conversions; the only bespoke bit is app_args_conv
collapsing empty ApplicationArguments to Nil (a primitive, not a built object).
Wire bytes verified by the create_pipeline exact-XML characterization test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…ject/to_string-with

ErrorRecord + ErrorCategory now fully macro-derived. New reusable macro
features: #[ps(flatten_prefix)] (prefix-flatten a nested Option<Struct>),
#[ps(fallback_object)] (read a field from a nested sibling object when absent
up top — for .NET records that nest payload in Exception), and #[ps(to_string)]
now works for any Display field. Real-world CommandNotFound XML verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…hic message_data

Adds the PsUnion derive (untagged polymorphic enum: primitive/type_match/
fallback dispatch) so InformationMessageData's 3-way union converts to/from
PsValue by macro. HostInformationMessage + InformationRecord now fully derive.
TimeGenerated (DateTime|String) and the index-keyed Tags String[] use small
typed converters (no object literals). ZERO hand-built ComplexObject literals
remain in the messages layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…equire dispatch)

Review hardening: #[ps(value_dictionary)] fields now honor a #[ps(with)]
converter instead of silently ignoring it; PsUnion now rejects (at compile
time) any variant lacking exactly one dispatch mode, which previously would
serialize but never deserialize. No behavior change for current consumers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
A PowerShell <Nil/> member is semantically equivalent to an absent one. For
Option/#[ps(default)] fields, a present-but-Nil property must map to None/Default
instead of being fed to from_ps_value (which rejects Nil). Fixes WriteProgress
rejection when ProgressRecord.CurrentOperation arrives as <Nil/>.
…ed auth

Over plain HTTP, SSPI message sealing is used (application/HTTP-SPNEGO-session-
encrypted). Over HTTPS, TLS provides confidentiality so the body is sent plain
(application/soap+xml) and authentication is connection-oriented (RFC 4559): the
connection is authenticated once during the handshake (the first operation rides
the SPNEGO challenge legs) and every subsequent operation reuses that authenticated
connection. requires_sspi_sealing() is therefore true only for plain HTTP.

Key points:
- SSPI INTEGRITY (sign) is requested unconditionally; CONFIDENTIALITY (seal) only
  when sealing. INTEGRITY over HTTPS lets the server trust the connection and
  produces the NTLM MIC / SPNEGO mechListMIC.
- EPA channel binding (tls-server-end-point): learned from the server cert after a
  401 and applied on a restarted auth sequence.
- AlreadyComplete is handled consistently across all four transports (tokio direct,
  gateway, sync, web).
- Fail fast on terminal auth rejection (401 -> PwshCoreError::Auth), try_join! so a
  failed handshake short-circuits, and the non-interactive client exits non-zero on
  failure / clean on success (command_completed is authoritative).
- Basic over plain HTTP is refused unless --http-insecure is given.
…lding

Add the 10-case transport_auth_matrix asserting the WinRM rule against a real
server: HTTP+SSPI seals; HTTPS+SSPI is unsealed (TLS); Basic refused over plain
HTTP unless forced, allowed (unsealed) over HTTPS. Helpers: sealed() detects the
multipart/encrypted envelope, connected() detects pipeline completion.

Also makes the shared e2e scaffolding auth-aware: e2e_pwsh_config gains
default_auth_method(); native_pty_matrix and pty_harness use it (the default DC
refuses Basic over HTTP), and fall back to the standard e2e host/creds out of the box.
test (5/5): transport × auth × sealing e2e matrix
feat (4/5): spec-correct WinRM HTTP/HTTPS transport + connection-oriented auth
fix (3/5): treat present-but-Nil property as absent in PsDeserialize
@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) merged commit 12ba49b into stack/01-rfc12-derive-engine Jun 23, 2026
@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) deleted the stack/02-rfc12-message-derives branch June 23, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants