From 949d0bbbe490168ac285111c9873c93b3b678320 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 28 Oct 2026 06:57:52 +0000 Subject: [PATCH] feat(config): land the configuration surface (serve-config, dial-config, Limits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ServeConfig: OQ-08 shell_argv / exec_wrapper_argv as Option> with unset-declines doc contracts (no crate default shell/wrapper); OQ-09 session_cwd/session_env (assembly-supplied, never wire-derived); injected Arc + Arc handles; optional Arc route-1 handle (absent -> tunnel rows decline even under the tunnel feature); Limits. - DialConfig: address + injected Arc and Arc handles + Limits. - Limits: the bounded-resources budget model both directions carry — in-crate caps (max_concurrent_channels 64, max_exec_command_len 8192) and the russh-knob mapping with documented postures (max_auth_attempts 10 per ADR-004 decision 6; window 2 MiB / packet 32 KiB / buffer 100; inactivity 600 s always-set shape; keepalive Some(15 s)/3 client posture). - Types only: no russh Config construction (wave 2), no dialing (wave 4); no construction-time validation — unset-declines lives in the type shape (error.rs no-config-error contract). Verified: cargo test (36), cargo test --all-features, clippy (all-targets, -D warnings, default + all-features), fmt --check. --- src/config.rs | 550 ++++++++++++++++++++++++++++++ src/lib.rs | 2 + tasks/implementation/w1-config.md | 91 ++++- 3 files changed, 634 insertions(+), 9 deletions(-) create mode 100644 src/config.rs diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c56a444 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,550 @@ +//! The pure configuration surface — the shapes every later wave's +//! constructors take (serve-config, dial-config, [`Limits`]). Types +//! only: russh `Config` construction is wave-2 material, dialing lands +//! at wave 4; nothing here touches the wire or the vault. +//! +//! Two directions, one budget model (`overview.md` §Public surface): +//! +//! - [`ServeConfig`] — the server direction's assembly-supplied serving +//! configuration: the OQ-08 fields ([`ServeConfig::shell_argv`] / +//! [`ServeConfig::exec_wrapper_argv`]), the injected identity, +//! host-key and tunnel handles, the OQ-09 `cwd`/`env` values, and the +//! [`Limits`]. +//! - [`DialConfig`] — the client direction's dial configuration +//! (`client.md` §The dial surface): address, key source, host-key +//! policy, and the bounded-resource knobs (carried by [`Limits`]). +//! +//! Crate rules this module encodes: +//! +//! - **Unset-declines, no crate defaults** (OQ-08): the OQ-08 fields +//! are `Option` and their unset semantics live in the type shape — +//! an unset field makes its row decline with a clean error at +//! request time (wave-2/3 consumption), never a fallback, never +//! construction-time validation (`error.rs`'s no-config-error +//! contract). No construction-time validation exists in this +//! module. +//! - **Injected handles, never crate-resolved identity or trust**: +//! the [`IdentityProvider`], [`HostKeySource`] and +//! [`HostKeyPolicy`] handles are `Arc` the assembly +//! supplies; the crate holds no identity model, no trust policy and +//! no vault lifecycle of its own. A git-only tunnel build simply +//! leaves [`ServeConfig::channel_client`] absent. +//! - **Bounded resources everywhere** (convention 9): every session +//! — both directions — carries a [`Limits`]; there is no +//! unbounded-loop or unbounded-buffer configuration shape the +//! assembly can build from these types. +//! +//! Knob-name parity with the russh 0.65 pin (ADR-002): +//! `window_size`, `maximum_packet_size`, `channel_buffer_size`, +//! `inactivity_timeout`, `keepalive_interval`, `keepalive_max`, +//! `max_auth_attempts` — wave-2/wave-4 map these fields into the russh +//! `Config` verbatim. russh's rekey `Limits` stay russh-enforced at +//! its own RFC-4253 §9 defaults (wave-2); they are not carried here — +//! this module's [`Limits`] is the crate's in-crate + mapped-knob +//! budget model. + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use alkcall::channels::client::ChannelClient; +use alkcall::core::auth::IdentityProvider; + +use crate::host_key::HostKeyPolicy; +use crate::key_source::{HostKeySource, KeySource}; + +/// The bounded-resources budget model (`overview.md` +/// §Bounded-resources policy): the in-crate caps plus the russh-knob +/// mapping with the documented default postures. Every session — +/// server and client direction — carries one (`AGENTS.md` convention +/// 9); there is no unbounded configuration shape. +/// +/// Carried by value (`Copy`): a [`Limits`] is plain tuning data, and +/// wave-2/4 copies it into the russh `Config` construction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// The crates' own cap on concurrent channels per session — + /// opened channels beyond the cap are declined (fail-closed + /// refuse, never queue). The per-identity policy layer (when the + /// alkcall bridge carries it) is additional and comes with the + /// bridge itself, not from this field. Assembly-tunable. + /// + /// Default: `DEFAULT_MAX_CONCURRENT_CHANNELS`. + pub max_concurrent_channels: usize, + /// The parse-input cap for the wave-3 exec grammar: a wire exec + /// command string longer than this declines before any parse or + /// resource allocation (decline-first, alloc-never — the ADR-006 + /// dispatch contract). Assembly-tunable. + /// + /// Default: `DEFAULT_MAX_EXEC_COMMAND_LEN`. + pub max_exec_command_len: usize, + /// Maximal number of allowed authentication attempts — mapped to + /// russh's `max_auth_attempts` (server direction). Default `10` + /// per ADR-004 decision 6: the OpenSSH counting `none`-probe + /// consumes one attempt, so `10` leaves `IdentitiesOnly`-style + /// clients at least nine real key attempts. + pub max_auth_attempts: usize, + /// The initial channel receive window — mapped to russh's + /// `window_size` (per-channel memory bound). + /// + /// Default: 2 MiB (russh's own default posture, kept). + pub window_size: u32, + /// The maximal size of a single SSH packet — mapped to russh's + /// `maximum_packet_size` (per-channel record-size bound). + /// + /// Default: 32 KiB (russh's own default posture, kept). + pub maximum_packet_size: u32, + /// Per-channel unprocessed-message buffer — mapped to russh's + /// `channel_buffer_size` (the backpressure bound); messages past + /// it propagate backpressure to the TCP stream, never an + /// unbounded buffer. + /// + /// Default: 100 (russh's own default posture, kept). + pub channel_buffer_size: usize, + /// The session wall clock — the connection is garbage-collected + /// after this long with no activity. Mapped to russh's + /// `Option` as always-set (`Some(value)`) in + /// wave 2/4: the bounded-resources policy's "set + /// (assembly-tunable)" posture — the crate never constructs a + /// session with no wall clock. Assembly-tunable. + /// + /// Default: `DEFAULT_INACTIVITY_TIMEOUT`. + pub inactivity_timeout: Duration, + /// Client-direction keepalive interval (cloud fleets): the + /// dead-peer probe, mapped to russh's `keepalive_interval`. + /// + /// Default: `Some(15 s)` — the reverse-runner-evidence posture + /// (OpenSSH `ServerAliveInterval=15` parity): with russh's own + /// default (`None`) a dead NAT'd connection sits unnoticed until + /// an exec health check fails (`reverse-runner.md` lesson 3). + /// `None` is available to the assembly but is not the posture. + pub keepalive_interval: Option, + /// How many unanswered keepalives close the connection — mapped + /// to russh's `keepalive_max`. + /// + /// Default: `3` (OpenSSH `ServerAliveCountMax=3` parity, the + /// reverse-runner-evidence posture). + pub keepalive_max: usize, +} + +/// The crate default for [`Limits::max_concurrent_channels`]: a +/// conservative per-session channel cap for the door shapes alkssh +/// serves (the git door's few exec channels; tunnel legs are +/// separate channels and fit well inside it). +pub const DEFAULT_MAX_CONCURRENT_CHANNELS: usize = 64; + +/// The crate default for [`Limits::max_exec_command_len`]: generous +/// room for the git exec row's command strings (the +/// `git-upload-pack '/repo'` grammar) plus arbitrary non-git +/// `ssh host "cmd"` commands, while staying far below the wire's +/// 32 KiB packet bound. +pub const DEFAULT_MAX_EXEC_COMMAND_LEN: usize = 8192; + +/// The crate default for [`Limits::inactivity_timeout`]: the 10-minute +/// wall clock (russh's server default posture; an assembly that +/// serves long-idle-but-alive sessions tunes it, together with the +/// keepalive knobs on the client direction). +pub const DEFAULT_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(600); + +/// The client-direction keepalive default posture +/// (`Limits::keepalive_interval`): 15 seconds, per the +/// reverse-runner evidence. +pub const DEFAULT_KEEPALIVE_INTERVAL: Option = Some(Duration::from_secs(15)); + +/// The crate default for [`Limits::keepalive_max`]: 3 unanswered +/// probes. +pub const DEFAULT_KEEPALIVE_MAX: usize = 3; + +impl Default for Limits { + fn default() -> Self { + Self { + max_concurrent_channels: DEFAULT_MAX_CONCURRENT_CHANNELS, + max_exec_command_len: DEFAULT_MAX_EXEC_COMMAND_LEN, + max_auth_attempts: 10, + window_size: 2_097_152, + maximum_packet_size: 32_768, + channel_buffer_size: 100, + inactivity_timeout: DEFAULT_INACTIVITY_TIMEOUT, + keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL, + keepalive_max: DEFAULT_KEEPALIVE_MAX, + } + } +} + +/// The server direction's assembly-supplied serving configuration +/// (`overview.md` §Server stack): everything wave-2's serving loop +/// takes at construction. Pure data + injected handles — no russh +/// `Config` is built here (wave 2), no serving loop exists yet. +/// +/// Construction carries no validation beyond the required handles: +/// the unfeatured/unset capability surfaces are structural `Option`s +/// that decline at request time (the fail-closed contract) — see +/// [`ServeConfig::shell_argv`] and [`ServeConfig::channel_client`]. +pub struct ServeConfig { + /// OQ-08 field 1: the shell argv, run verbatim for + /// `shell_request` (`NegotiateRequest.cmd`). No crate default — + /// **unset → `shell_request` declines with a clean error, never a + /// fallback**: the crate is not an sshd and has no account model + /// from which a shell could be derived, so any baked-in shell + /// would be deployment policy smuggled into the crate. A + /// `session` build with this unset serves neither shell nor exec + /// requests (the git row is unaffected — parsing never needs a + /// shell). + /// + /// Set to `Some(vec)` to serve the shell row (feature-gated at + /// wave 2: only under the `session` feature). + pub shell_argv: Option>, + /// OQ-08 field 2: the exec wrapper argv (platform-neutral, e.g. + /// `["/bin/sh", "-c"]`). A non-git `exec_request`'s wire command + /// string is appended as the final argv element + /// (`wrapper ++ [cmd]`) — vanilla `ssh host "cmd"` quoting + /// semantics; command strings are never naively split. No crate + /// default — **unset → the non-git exec row declines with a + /// clean error, never a fallback**. + /// + /// Set to `Some(vec)` to serve the pipe-exec row (feature-gated + /// at wave 2: only under the `session` feature). + pub exec_wrapper_argv: Option>, + /// The OQ-09 posture's assembly-supplied working directory for + /// alktty `NegotiateRequest.cwd` — **never wire-derived** + /// (`env_request` declines unconditionally; wire env is not + /// family config). Unset → the backend-side default applies. + pub session_cwd: Option, + /// The OQ-09 posture's assembly-supplied environment for + /// `NegotiateRequest.env` — **never wire-derived**. Empty by + /// default. + pub session_env: BTreeMap, + /// The injected identity provider (alkcall's + /// `IdentityProvider::resolve_from_fingerprint`) — the assembly + /// supplies it; the crate never resolves identities itself and + /// holds no identity model. + pub identity_provider: Arc, + /// The injected host-key source (the wave-1 + /// [`HostKeySource`] seam, decrypt-at-construction per ADR-005 + /// decision 6) — feeds wave-2's `Config::keys`; a load failure + /// means no server is constructed, never a half-configured one. + pub host_key_source: Arc, + /// The tunnel route-1 bridge's family-fabric entry: a held + /// alkcall `ChannelClient` (`TunnelSession::open_direct` runs on + /// it). **Absent → every tunnel row declines with a clean error + /// even under the `tunnel` feature** (the feature alone configures + /// nothing — the fabric handle is the row's other half). The + /// present handle is still gated by the `tunnel` feature at wave + /// 2: absent + unfeatured both decline; present + featured + /// serves. + pub channel_client: Option>, + /// The bounded-resources budget this serving direction enforces + /// (see [`Limits`]). + pub limits: Limits, +} + +impl ServeConfig { + /// Builds a serve-config from the two injected handles the + /// serving direction can never default — the identity provider + /// and the host-key source. The OQ-08 fields start unset (their + /// rows decline), the tunnel handle starts absent (its rows + /// decline), and `limits` starts at [`Limits::default()`] — + /// override the `pub` fields from the assembly. + pub fn new( + identity_provider: Arc, + host_key_source: Arc, + ) -> Self { + Self { + shell_argv: None, + exec_wrapper_argv: None, + session_cwd: None, + session_env: BTreeMap::new(), + identity_provider, + host_key_source, + channel_client: None, + limits: Limits::default(), + } + } + + /// Whether the `shell_request` row is configurable at all in this + /// configuration (`Some` under the `session` feature): `false` + /// means the row declines — the unset-declines contract, checked + /// at request time by the wave-2 dispatch, never here. + pub fn shell_configured(&self) -> bool { + self.shell_argv.is_some() + } + + /// Whether the non-git `exec_request` (pipe) row is configurable + /// at all in this configuration: `false` means the row declines — + /// the unset-declines contract, checked at request time by the + /// wave-2 dispatch, never here. + pub fn exec_wrapper_configured(&self) -> bool { + self.exec_wrapper_argv.is_some() + } + + /// Whether the tunnel rows are configurable at all in this + /// configuration (`channel_client` present; the `tunnel` feature + /// gate applies at wave 2 on top): `false` means the rows decline. + pub fn tunnel_configured(&self) -> bool { + self.channel_client.is_some() + } +} + +impl std::fmt::Debug for ServeConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ServeConfig") + .field("shell_argv", &self.shell_argv) + .field("exec_wrapper_argv", &self.exec_wrapper_argv) + .field("session_cwd", &self.session_cwd) + .field("session_env", &self.session_env) + .field("identity_provider", &"") + .field("host_key_source", &"") + .field("channel_client", &self.channel_client.as_ref().is_some()) + .field("limits", &self.limits) + .finish() + } +} + +/// The client direction's dial configuration (`client.md` §The dial +/// surface): everything wave-4's dial loop takes at construction. +/// Pure data + injected handles — no russh `Config` is built here +/// (wave 2), no dial happens (wave 4). +/// +/// Construction carries no validation: the key source's own +/// load path is fail-closed ([`KeySource`] — locked/absent/decryption +/// failures are all no-dial outcomes), and the host-key policy's +/// default is fail-closed ([crate::host_key::RejectAll]). +pub struct DialConfig { + /// The dial target — `host:port` as russh's connect surface + /// takes it (the reverse-runner shape: vanilla `sshd` on the + /// fleet box). + pub address: String, + /// The key source the dial authenticates with (the wave-1 + /// [`KeySource`] seam) — never a static key, never env vars; + /// decrypt-at-dial, fail-closed per ADR-005. + pub key_source: Arc, + /// The injected host-key policy (OQ-05) — the trust decision + /// surface the dial calls at russh's `check_server_key` hook. + /// The crate is policy-blind; the built-in default policy + /// ([crate::host_key::RejectAll]) connects to nothing, by design. + pub host_key_policy: Arc, + /// The bounded-resource budget this dial runs under — the + /// keepalive interval/max, the wall clock and the window/packet + /// knobs ride here (see [`Limits`]; the keepalive 15 s / 3 + /// default posture is the client direction's). + pub limits: Limits, +} + +impl DialConfig { + /// Builds a dial-config from the address and the two injected + /// handles the dial direction can never default — the key source + /// and the host-key policy. `limits` starts at + /// [`Limits::default()`] (keepalive 15 s / 3 posture riding + /// inside). + pub fn new( + address: impl Into, + key_source: Arc, + host_key_policy: Arc, + ) -> Self { + Self { + address: address.into(), + key_source, + host_key_policy, + limits: Limits::default(), + } + } +} + +impl std::fmt::Debug for DialConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DialConfig") + .field("address", &self.address) + .field("key_source", &"") + .field("host_key_policy", &"") + .field("limits", &self.limits) + .finish() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use alkcall::core::auth::NoopIdentityProvider; + use async_trait::async_trait; + + use super::{DEFAULT_INACTIVITY_TIMEOUT, DialConfig, Limits, ServeConfig}; + use crate::error::KeySourceError; + use crate::host_key::RejectAll; + use crate::key_source::{HostKeySource, KeySource}; + use russh::keys::PrivateKey; + + struct NoKeySource; + + #[async_trait] + impl KeySource for NoKeySource { + async fn load(&self) -> Result { + Err(KeySourceError::Absent) + } + } + + struct NoHostKeysSource; + + #[async_trait] + impl HostKeySource for NoHostKeysSource { + async fn load(&self) -> Result, KeySourceError> { + Err(KeySourceError::Absent) + } + } + + fn key_source() -> Arc { + Arc::new(NoKeySource) + } + + fn host_key_source() -> Arc { + Arc::new(NoHostKeysSource) + } + + #[test] + fn limits_default_carries_the_documented_postures() { + let limits = Limits::default(); + + assert_eq!(limits.max_concurrent_channels, 64); + assert_eq!(limits.max_exec_command_len, 8192); + assert_eq!(limits.max_auth_attempts, 10); + assert_eq!(limits.window_size, 2_097_152); + assert_eq!(limits.maximum_packet_size, 32_768); + assert_eq!(limits.channel_buffer_size, 100); + assert_eq!(limits.inactivity_timeout, DEFAULT_INACTIVITY_TIMEOUT); + assert_eq!(limits.inactivity_timeout.as_secs(), 600); + assert_eq!( + limits.keepalive_interval, + Some(std::time::Duration::from_secs(15)) + ); + assert_eq!(limits.keepalive_max, 3); + } + + #[test] + fn limits_are_copyable_and_tunable() { + let limits = Limits::default(); + let copy = limits; + assert_eq!(limits, copy); + + let tuned = Limits { + max_concurrent_channels: 8, + max_exec_command_len: 1024, + inactivity_timeout: std::time::Duration::from_secs(3600), + keepalive_interval: None, + ..Limits::default() + }; + assert_eq!(tuned.max_concurrent_channels, 8); + assert_eq!(tuned.max_exec_command_len, 1024); + assert_eq!(tuned.keepalive_interval, None); + } + + #[test] + fn serve_config_starts_unconfigured_everywhere() { + let serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); + + assert!(serve.shell_argv.is_none()); + assert!(serve.exec_wrapper_argv.is_none()); + assert!(!serve.shell_configured()); + assert!(!serve.exec_wrapper_configured()); + assert!(!serve.tunnel_configured()); + assert!(serve.channel_client.is_none()); + assert!(serve.session_cwd.is_none()); + assert!(serve.session_env.is_empty()); + assert_eq!(serve.limits, Limits::default()); + } + + #[test] + fn serve_config_option_semantics_flip_independently() { + let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); + + serve.shell_argv = Some(vec!["/bin/sh".into(), "-l".into()]); + assert!(serve.shell_configured()); + assert!(!serve.exec_wrapper_configured()); + assert!(!serve.tunnel_configured()); + + serve.exec_wrapper_argv = None; + serve.exec_wrapper_argv = Some(vec!["/bin/sh".into(), "-c".into()]); + assert!(serve.shell_configured()); + assert!(serve.exec_wrapper_configured()); + + serve.shell_argv = None; + assert!(!serve.shell_configured()); + assert!(serve.exec_wrapper_configured()); + } + + #[test] + fn serve_config_holds_the_injected_handles() { + let provider: Arc = + Arc::new(NoopIdentityProvider); + let keys = host_key_source(); + + let serve = ServeConfig::new(Arc::clone(&provider), Arc::clone(&keys)); + + assert!(Arc::ptr_eq(&provider, &serve.identity_provider)); + assert!(Arc::ptr_eq(&keys, &serve.host_key_source)); + } + + #[test] + fn serve_config_env_and_cwd_are_assembly_supplied_not_wire_derived() { + let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); + + serve.session_cwd = Some(std::path::PathBuf::from("/srv/git")); + serve + .session_env + .insert("GIT_DIR".to_string(), "/srv/git".to_string()); + + assert_eq!( + serve.session_cwd.as_deref(), + Some(std::path::Path::new("/srv/git")) + ); + assert_eq!( + serve.session_env.get("GIT_DIR").map(String::as_str), + Some("/srv/git") + ); + assert_eq!(serve.session_env.len(), 1); + } + + #[test] + fn dial_config_defaults_carry_the_keepalive_posture() { + let dial = DialConfig::new("runner.example:22", key_source(), Arc::new(RejectAll)); + + assert_eq!(dial.address, "runner.example:22"); + assert_eq!(dial.limits, Limits::default()); + assert_eq!( + dial.limits.keepalive_interval, + Some(std::time::Duration::from_secs(15)) + ); + assert_eq!(dial.limits.keepalive_max, 3); + assert!(dial.limits.inactivity_timeout > std::time::Duration::ZERO); + } + + #[test] + fn dial_config_holds_the_injected_handles() { + let keys = key_source(); + let policy: Arc = Arc::new(RejectAll); + + let dial = DialConfig::new("fleet.example:22", Arc::clone(&keys), Arc::clone(&policy)); + + assert!(Arc::ptr_eq(&keys, &dial.key_source)); + assert!(Arc::ptr_eq(&policy, &dial.host_key_policy)); + } + + #[tokio::test] + async fn dial_config_key_source_stays_fail_closed_through_the_config() { + let dial = DialConfig::new("fleet.example:22", key_source(), Arc::new(RejectAll)); + + let err = dial.key_source.load().await.expect_err("no key source"); + assert!(matches!(err, KeySourceError::Absent)); + + let policy = dial.host_key_policy.as_ref(); + assert!( + !policy + .decide(&crate::host_key::PresentedHostKey { + host: "fleet.example".into(), + port: 22, + fingerprint: "SHA256:anything".into(), + }) + .await + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 67a9649..ef12c00 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,11 +22,13 @@ //! the dependency surface and feature graph land with implementation //! start). See also `docs/research/` and family ADR-003. +mod config; mod error; mod fingerprint; mod host_key; mod key_source; +pub use config::{DialConfig, Limits, ServeConfig}; pub use error::KeySourceError; pub use fingerprint::fingerprint; pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll}; diff --git a/tasks/implementation/w1-config.md b/tasks/implementation/w1-config.md index d7b2d6b..be4b85f 100644 --- a/tasks/implementation/w1-config.md +++ b/tasks/implementation/w1-config.md @@ -1,7 +1,7 @@ --- id: w1-config name: Wave 1 — the config surface (serve-config, dial-config, Limits) -status: pending +status: completed depends_on: [w1-manifest, w1-key-sources, w1-host-key-policy] scope: moderate risk: low @@ -48,19 +48,19 @@ validation. ## Acceptance Criteria -- [ ] Serve-config carries the OQ-08 fields as `Option` with +- [x] Serve-config carries the OQ-08 fields as `Option` with unset-declines doc contracts; no crate default shell/wrapper -- [ ] `IdentityProvider`, host-key source, and `ChannelClient` are +- [x] `IdentityProvider`, host-key source, and `ChannelClient` are injected handles; the crate holds no identity/trust policy of its own -- [ ] Dial-config carries key source + `HostKeyPolicy` + the +- [x] Dial-config carries key source + `HostKeyPolicy` + the keepalive/inactivity/window knobs with the documented defaults -- [ ] `Limits` carries the in-crate caps (channels, exec-parse) and +- [x] `Limits` carries the in-crate caps (channels, exec-parse) and the russh knob defaults (`max_auth_attempts` 10, keepalive 15 s / 3 client posture) -- [ ] Unit tests cover default construction and the `Option`-field +- [x] Unit tests cover default construction and the `Option`-field semantics -- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, +- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References @@ -76,8 +76,81 @@ validation. ## Notes -> To be filled by implementation agent +> Landed `src/config.rs` (`ServeConfig`, `DialConfig`, `Limits`, +> four posture constants) + lib.rs re-exports. Design decisions +> pinned against the docs and the alkcall/russh 0.65 source: +> +> - **`Limits` is the single budget model both directions carry** +> (`Copy`, plain tuning data): the in-crate caps +> (`max_concurrent_channels` 64, `max_exec_command_len` 8192 — +> numbers chosen here, documented as assembly-tunable posture +> constants) plus the russh-knob mapping (`max_auth_attempts` 10 +> per ADR-004 decision 6; `window_size` 2 MiB / `maximum_packet_size` +> 32 KiB / `channel_buffer_size` 100 — russh's own defaults, kept; +> `inactivity_timeout` 600 s carried as plain `Duration` so the +> "set (assembly-tunable)" posture is structural — wave 2/4 map it +> to russh's `Option` as always-`Some`; +> `keepalive_interval`/`keepalive_max` Some(15 s)/3 riding inside as +> the client-direction posture, satisfying the dial-config +> criterion without a duplicate knob set). russh's rekey `Limits` +> stay russh-enforced at their RFC-4253 defaults and are not +> carried (documented; wave-2 material). +> - **Dial-config carries the knobs via its `Limits` field** — the +> criterion's "keepalive/inactivity/window knobs with the +> documented defaults" read as the budget model riding the config; +> the accepted criterion on `Limits` itself pins that placement, +> so a second knob set on `DialConfig` would have duplicated state. +> - **No `Default` for the two configs** — the required handles +> (`Arc`, +> `Arc`, `Arc`, +> `Arc`) have no crate-defaultable shape +> (identity resolution and trust are injection-only, per the +> criterion); `ServeConfig::new`/`DialConfig::new` take them, and +> every tunable is a `pub` field + `Limits::default()`. +> - **The OQ-08 fields are plain `Option>` `pub` fields +> + doc contracts**; no construction-time validation (the +> `error.rs` no-config-error contract). Two request-time check +> helpers exist (`shell_configured`/`exec_wrapper_configured`, +> plus `tunnel_configured`) for the wave-2 dispatch to call — +> reading the `Option`, never validating. +> - **`cwd`/`env` (OQ-09 posture) carried on `ServeConfig`** as +> `Option` / `BTreeMap` (empty defaults; +> never wire-derived — doc contract). +> - **`channel_client` is not feature-gated** — alkcall is a base +> dependency and a field presence is not a capability; the `tunnel` +> feature gate applies at wave 2 on top (absent → decline even +> under the feature; unfeatured → decline too). Doc contract +> records both gates. +> - Verified the alkcall 0.10 surfaces in-source (registry): +> `core::auth::IdentityProvider` is a sync, +> `Send + Sync + 'static` trait (dyn-compatible with no +> async-trait help on alkssh's side — `ChannelClient` and the +> `NoopIdentityProvider` test fixture ride +> `alkcall::channels::client` / `alkcall::core::auth`), and the +> russh 0.65.0 knob names (`window_size`, `maximum_packet_size`, +> `channel_buffer_size`, `inactivity_timeout`, +> `keepalive_interval`, `keepalive_max`, `max_auth_attempts`) match +> this module's fields one-for-one. ## Summary -> To be filled on completion \ No newline at end of file +> Config surface landed: `src/config.rs` with `Limits` (in-crate +> caps: `max_concurrent_channels` 64, `max_exec_command_len` 8192; +> russh knobs: `max_auth_attempts` 10, window 2 MiB, packet 32 KiB, +> buffer 100, inactivity 600 s, keepalive Some(15 s)/3 — every number +> a named posture constant or documented ADR anchor), `ServeConfig` +> (OQ-08 `shell_argv`/`exec_wrapper_argv` as unset-declines +> `Option>`, OQ-09 `session_cwd`/`session_env`, +> injected `IdentityProvider` + `HostKeySource` + optional +> `ChannelClient`, `limits`), and `DialConfig` (address, +> injected `KeySource` + `HostKeyPolicy`, `limits` with the +> keepalive/inactivity/window posture riding inside). No +> construction-time validation, no crate defaults for the unset +> rows, no `Default` for the configs (handles only). Tests: defaults +> carry the documented postures; `Option` fields flip independently; +> handles hold by `Arc::ptr_eq`; the dial's key source and policy +> stay fail-closed through the config. Verified: `cargo test` (36), +> `cargo test --all-features`, `cargo clippy --all-targets +> -- -D warnings`, `cargo clippy --all-features --all-targets +> -- -D warnings`, `cargo fmt --check` all pass; `taskgraph +> validate` clean. \ No newline at end of file