Files
glm-5.3-flash f4b5f896b4 docs: sweep em dashes from all markdown prose
Renames the written-usage rule into practice across the contract surface:
docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files,
taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the
.opencode/agents/ prompts. Every replacement is a sense-preserving
rewrite (colons, semicolons, commas, parentheses, sentence splits, varied),
never a mechanical swap; no spaced hyphens were substituted. Enum fields,
frontmatter, tables, wire data, and cited identifiers survive verbatim;
en dashes inside numeric ranges keep their meaning.

Verification: taskgraph validate (43 tasks), repo grep clean (single
exempt hit: the verification command's own search pattern in AGENTS.md),
cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
2026-11-04 20:28:00 +00:00

246 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: w4-dial
name: "Wave 4: the client dial surface (russh client stack + the connection handle)"
status: completed
depends_on: [review-wave-3]
scope: moderate
risk: medium
impact: phase
level: implementation
---
## Description
Land the client direction's substrate, the door facing outward
(`client.md` §The dial surface): the russh client stack every wave-4
op row composes into, mirroring `w2-server-mount`'s role for the
server direction. Everything later in the wave rides the authenticated
connection handle this task lands.
- **The dial surface**: a `connect`-shaped entry taking the wave-1
[`DialConfig`](the address, the key source, the host-key policy, the
`Limits`): the russh client `Config` built from the bounded knobs
(keepalive 15 s / 3 default posture per the reverse-runner evidence
and `client.md` §Correctness facts; inactivity; window/packet knobs per
`overview.md`'s table), the dial target as `DialConfig.address`
carries it. If the dial surface needs a `DialConfig` amendment,
land + record it here (the `w3-seam-config` course-correction
pattern), while the wave-1 shape is expected to hold; decide and
record.
- **Key source decrypt-at-dial** (ADR-005), with the [`KeySource`]
load
happens at dial time; locked / absent / decryption-failure /
unknown-`key_version` are all **no-dial outcomes** with their
distinct `KeySourceError` variants surfaced (never env vars, never
plaintext at rest; the adapter-side `Zeroizing` wrap per OQ-07
already landed in wave 1, consumed here).
- **Host-key policy at `check_server_key`** (OQ-05), the wave-1
[`HostKeyPolicy`] seam consumed at russh's client hook (the verdict
type was pinned against this hook shape in `w1-host-key-policy`):
the crate default ([`RejectAll`]) connects to nothing; case-1 pins
hard-decline on mismatch; case-2 assembly policies decide. The
crate is policy-blind, with no accept-all built-in.
- **The client Handler skeleton at fail-closed defaults**: the
russh `client::Handler` impl the dial constructs: server-initiated
channel opens (`server_channel_open_forwarded_tcpip` /
`_forwarded_streamlocal` / `_unknown` / x11 / agent) **declined
cleanly**, never russh's accept-and-drop default (ADR-003 §4, the
client-side symmetry rule; the acceptor registry that serves them
is `w4-reverse-listen`'s surface, this task pins the default).
- **The authenticated connection handle**, the type everything else
rides: the russh `client::Handle` wrapped with the crate's surface
(publickey-only authentication from the key source, the family
contract; no password/keyboard-interactive paths), the policy and
limits carried, the lifecycle primitives' raw material (full
cancellation/clean-close shape is `w4-lifecycle`'s).
- **Test vehicle**, because the client direction dials *vanilla*
servers, so
the loopback harness (alkssh server) is not the vehicle: land an
in-process russh **server double** (a fake `sshd`: a hand-rolled
`russh::server` handler in `tests/common`) as the contract-test
vehicle, decide + record its placement and shape. Contract rows:
dial success (authenticated), each key-source failure posture
(locked/absent/decryption), policy-decline rows (default-reject,
pin-mismatch hard decline, case-2 accept), the bounded-config
wiring observable, server-initiated open declined cleanly.
- **The smoke posture** decided + recorded (an alkssh-client ↔
alkssh-server in-process loopback is the cheap real-stack smoke;
a real-`sshd` dial is wave-5 e2e territory, never silently
dropped).
## Acceptance Criteria
- [x] The dial surface lands over `DialConfig` (+ any amendment
decided and recorded); the russh client `Config` carries the
bounded knobs (keepalive 15 s / 3 posture, inactivity, window)
- [x] Key source decrypt-at-dial; every `KeySourceError` failure
posture is a no-dial outcome with its distinct variant surfaced
- [x] `check_server_key` calls the injected policy; `RejectAll`
connects to nothing; pin-mismatch hard-declines; no accept-all
built-in
- [x] The client Handler skeleton declines server-initiated opens
cleanly (fail-closed default pinned by wire rows)
- [x] The authenticated connection handle lands; authentication is
publickey-only from the key source
- [x] The in-process server double lands in `tests/common`; the
contract rows above green; the smoke posture decided + recorded
- [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2
(`-D warnings`), fmt, `cargo doc --no-deps` pass
## References
- docs/architecture/client.md §The dial surface, §Correctness facts
(the POC-recorded mechanics), §What the crate does *not* include
- docs/architecture/decisions/002-russh-pin.md (the client russh
surface), docs/architecture/decisions/003-feature-graph.md (§4 the
client-side fail-closed acceptor default this skeleton pins),
docs/architecture/decisions/005-key-sources.md (decrypt-at-dial)
- docs/research/russh.md §Client surface (`client::Handler` inventory,
where `check_server_key` defaults to refuse; the
`server_channel_open_*` hook set; `client::Config` knobs),
docs/research/reverse-runner.md (lessons 3/5, with the
keepalives
load-bearing, the ephemeral-fleet policy posture)
- docs/research/poc-1-auth-bridge.md §6/§7 (the client-side plumbing
facts), docs/research/poc-2-vault-keys.md (the dial lifecycle)
- src/config.rs (`DialConfig`), src/host_key.rs, src/key_source.rs
(the wave-1 seams consumed)
## Notes
### What landed (2026-10-29)
- `src/client.rs`: the dial surface, `dial(DialConfig)` over
[`DialConfig`], the four-step sequence (key load → bounded russh
`Config` → policy at `check_server_key` → single publickey
auth), [`SshConnection`] (the connection handle: `limits()`
/ `address()` / `username()` / `is_closed()` / `close()`, the
OQ-12 raw material; the full lifecycle surface is `w4-lifecycle`'s),
the address parse (`host:port` + the bracket form;
`DialError::Address` on refusal (dial-time, matching the family's
no-construction-validation shape), and the doc-hidden
`russh_client_config` mapping (the bounded-config observable).
- `src/client_handler.rs`: the client Handler skeleton (the
mirror of `handler.rs`'s server-direction handler; a separate
module because `w4-op-rows` / `w4-reverse-listen` grow it), with
`check_server_key` wired to the injected policy
(certificates fingerprint their inner key; the verdict passes
through), every `server_channel_open_*` hook overridden
explicit-over-inherited with a
`ChannelOpenFailure::AdministrativelyProhibited` reject (never
russh's accept-and-drop default), `should_accept_unknown_server_channel`
the explicit `false`, decline events on the crate vocabulary.
- `src/error.rs`: [`DialError`], the dial direction's closed
taxonomy (`KeySource` per-variant preserved / `Address` /
`HostKeyRejected` / `AuthRejected` / `Wire`), with a manual
`From<russh::Error>` that upgrades russh's `UnknownKey` (the only
producer of which is the `check_server_key` decline path) into
`HostKeyRejected`; hence the `Wire` variant carries no `#[from]`
(one conversion, one place).
- `src/config.rs`: the **DialConfig amendment** (below) + doc
updates; `src/observe.rs`: the client-side decline reasons
(`no-acceptor`, the skeleton's decline-all default; the registry
is `w4-reverse-listen`'s) recorded in the vocabulary; `src/lib.rs`:
the re-exports (`dial` / `SshConnection` / `DialError` public; the
handler + config mapping `#[doc(hidden)]` test surface, the
`serve.rs` pattern).
- `tests/common/sshd_double.rs`: the fake `sshd` (below);
`tests/client_dial.rs`: the contract rows incl. the smoke.
### Decided + recorded
1. **The DialConfig amendment (`username`), the task's sanctioned
course-correction, the only one.** The wave-1 shape carried
everything `client.md` §The dial surface names, but the
USERAUTH_REQUEST structurally requires a user string
(`authenticate_publickey(user, key)`) and the remote end evaluates
it, and no dial can exist without one. Decided with the
`w3-seam-config` pattern: `username: String` is a required
`DialConfig::new` parameter (no crate default, since a default account
would be deployment policy smuggled into the crate; the empty
string is accepted as data and is the remote end's to refuse). It
is *not identity*, as the identity model stays fingerprint-only
(ADR-004 decision 5, mirrored client-side), it is never logged
(the event vocabulary carries no username), and it rides
[`SshConnection`] as carried dial data. Wave-1 records amended
(the config module + field docs).
2. **The server double's placement/shape**
(`tests/common/sshd_double.rs`): a plain russh server (no alkssh
in its serving path, hence vanilla-parity) on a **real loopback TCP
listener** at `127.0.0.1:0` (a dial target is an address the surface
resolves and connects to; the duplex-pair trick is the
server-direction harness's, and a dial has no `&mut Handle` slot
to attach one to). One dial per instance (doubles are cheap); the
handler resolves fingerprints against an injected accept set (the
vanilla-`authorized_keys` stand-in) with every other auth hook an
explicit reject; the `auth_succeeded` hook hands the row the
per-connection `russh::server::Handle` through a one-shot carrier,
which is the initiator for the server-initiated-open decline rows (the
double calls `channel_open_*` at the client and observes each as
`Err(ChannelOpenFailure(AdministrativelyProhibited))`); a
per-session outcome channel is the clean-close rows' observable;
an optional client-session acceptor stub exists for later rows
(`next_opened_session`).
3. **The smoke posture**: an alkssh-client ↔ alkssh-server
in-process loopback over a real TCP socket, landed as the row
`smoke_alkssh_client_dials_the_alkssh_server_direction`
(`tests/client_dial.rs`, with both russh stacks, the full auth bridge
server-side, the full custody + policy chain client-side). A
real-`sshd` dial is wave-5 e2e territory (`docs/plans/
implementation.md` §Wave 5); nothing is silently dropped.
4. **The decline-class mapping**: russh surfaces a policy decline as
its `UnknownKey` wire error; since the client's `check_server_key`
hook is the only producer of that class, the manual `From` makes
it the semantic [`DialError::HostKeyRejected`], while every other russh
error stays [`DialError::Wire`].
5. **The skeleton's unknown-type gate** (`should_accept_unknown_
server_channel` → explicit `false`) is code-pinned, not
row-pinned: russh's server `Handle` exposes no initiator for an
unknown channel type, and russh's `ChannelId` is deliberately
unconstructible outside the library, so no wire row can reach the
hook. The six reachable hooks (session / direct-tcpip /
forwarded-tcpip / forwarded-streamlocal / x11 / agent) are
wire-row-pinned; the `_unknown` open hook is a defensive reject
behind the gate.
6. **Auth posture**: exactly one `authenticate_publickey` attempt
(ed25519-only, ADR-002 decision 4, `None` being the whole
hash-algorithm posture); a refusal closes the attempt cleanly
(`Disconnect::ByApplication`) and surfaces
[`DialError::AuthRejected`]. russh's other auth methods
(password / kbi / cert / gss / signer-injection, with the signer
seam dismissed by ADR-005 decision 8) are never reached from
[`SshConnection`].
7. **Bounded posture**: the knob mapping keeps the crate's documented
postures (`keepalive` 15 s / 3, the wall clock always `Some`,
window/packet/buffer verbatim) and leaves
russh's own default postures untouched (rekey `Limits`,
preferred algorithms, `client_id`, `nodelay`. No client-side
auth-attempt knob exists at this pin and the single-attempt
contract makes none needed).
### Verification
`cargo test` (default) and `cargo test --all-features` (and
per-feature `--features {session,git,tunnel}`) all green, with 14 test
binaries each, the new `tests/client_dial.rs` carrying 10 rows;
`cargo clippy --all-features --all-targets` and `cargo clippy
--all-targets` both `-D warnings` clean; `cargo fmt --check` clean;
`cargo doc --no-deps` warning-free.
## Summary
The client direction's substrate landed: `dial(config) ->
Result<SshConnection, DialError>` runs the fail-closed four-step
sequence (decrypt-at-dial key load with every no-dial posture
distinct; the bounded russh client `Config` from `Limits`; the
injected host-key policy at `check_server_key`, where `RejectAll`
connects to nothing; a single publickey-only auth attempt) and
returns the authenticated connection handle everything later in the
wave rides. One sanctioned `DialConfig` amendment: the required
`username` field (the USERAUTH_REQUEST's user string; no crate
default, never identity, never logged). The fail-closed client
Handler skeleton declines every server-initiated open on the wire
(the fake-`sshd` double in `tests/common` pins six hook rows +
decline events), and the alkssh-client ↔ alkssh-server loopback
smoke is landed as a row. The `w4-op-rows` chain grows this
skeleton in place.