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

13 KiB
Raw Permalink Blame History

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
w4-dial Wave 4: the client dial surface (russh client stack + the connection handle) completed
review-wave-3
moderate medium phase 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

  • 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)
  • Key source decrypt-at-dial; every KeySourceError failure posture is a no-dial outcome with its distinct variant surfaced
  • check_server_key calls the injected policy; RejectAll connects to nothing; pin-mismatch hard-declines; no accept-all built-in
  • The client Handler skeleton declines server-initiated opens cleanly (fail-closed default pinned by wire rows)
  • The authenticated connection handle lands; authentication is publickey-only from the key source
  • The in-process server double lands in tests/common; the contract rows above green; the smoke posture decided + recorded
  • 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.