diff --git a/docs/plans/implementation.md b/docs/plans/implementation.md index 424a808..6a590a0 100644 --- a/docs/plans/implementation.md +++ b/docs/plans/implementation.md @@ -44,7 +44,7 @@ mappings ∥ client direction) → validation & release. | Wave | Contents | Depends on | Status | |---|---|---|---| | [1](#wave-1--foundations) | Manifest landing (russh pin, feature graph); error model; config surface (serve-config + dial-config + `Limits`); vault-backed key sources; host-key policy seam; fingerprint; test doubles | — | decomposed | -| [2](#wave-2--the-server-stack) | `SshAdapter::handle` mount + `Config` construction; the auth bridge; parse-only dispatch + fail-closed decline core; spawn-and-return plumbing; bounded resources; loopback harness | wave 1 | pending | +| [2](#wave-2--the-server-stack) | `SshAdapter::handle` mount + `Config` construction; the auth bridge; parse-only dispatch + fail-closed decline core; spawn-and-return plumbing; bounded resources; loopback harness | wave 1 | decomposed | | [3](#wave-3--server-mappings) | session → alktty (translation bridge + exit derivation); git → alkgit (exec grammar + dispatch); tunnel → alktunnels route 1; sftp placeholder declines | wave 2 | pending | | [4](#wave-4--client-direction) | Dial surface; op rows (exec/pty, direct-tcpip, git exec); the `-R` leg (listen + acceptor registry); lifecycle primitives | wave 1 | pending | | [5](#wave-5--validation--release) | Decline-matrix contract suite; real-tool e2e; fuzzing; docs; **the publish** | waves 3 + 4 | pending | @@ -203,6 +203,44 @@ frame, before any door is hung: Wave 2 decomposes after the wave-1 gate, absorbing whatever it corrects. +**Decomposition record** (2026-10-28, after the wave-1 gate closed +green): wave 2 decomposed into six task files under +`tasks/implementation/` plus the gate. Shape: `w2-server-mount` first +(the `SshAdapter` mount + russh `Config` construction from the +serve-config — the substrate, with the Handler skeletoned at its +fail-closed defaults); then `w2-auth-bridge` (the ADR-004 +dispositions fill the auth hooks, over the mount + the wave-1 +fingerprint path and doubles); then `w2-loopback-harness` (the +in-process client/server pair — the contract-test vehicle — proving +the doubles live through the stack); then `w2-dispatch-decline-core` +(parse-only dispatch + the fail-closed decline core, contract-tested +per feature build through the harness); then `w2-spawn-return` (the +structural constraint pinned by test + the exit-reporting mechanics +via the cloned `Session::handle()`); then `w2-bounded-resources` (the +in-crate channel cap at the handler surface); and `review-wave-2` +(the gate) depending on all six. The chain is deliberately sequential +— the auth hooks, the dispatch, and the cap all edit the same +per-connection Handler impl, so no two tasks hold it in parallel. + +Interpretations recorded (flagged for the wave-2 gate): + +- **The git-parseability probe in wave 2 is skeleton-level** (service + prefix + path shape) — enough to contract-test the routing decision + and the session-build's git-gated decline; the full grammar + (quoted/unquoted paths, service-only → decline-with-reason, + per-length limits) is wave-3's git task, which replaces the probe. +- **Wave-2 routing stubs decline cleanly at the family-seam boundary** + — the seams land in wave 3; the stub behavior is documented as + skeleton so the routing decisions are contract-tested now. The + spawn-and-return mechanics are proven on a stub serving path. +- **The adapter error surface is shaped in `w2-server-mount`** — the + construction error wrapping `KeySourceError` (locked → no server) + plus the Handler `type Error` (`From`); this is where + the wave-1 note's "wire decline reasons are wave-2 shaped" lands. +- **The harness transport and placement are pinned in + `w2-loopback-harness`** (duplex pair vs TCP loopback; module + placement beside the wave-1 doubles) — flagged for the gate. + ## Wave 3 — Server mappings Three parallel branches over the stack (plus the folded sftp row), diff --git a/tasks/implementation/review-wave-2.md b/tasks/implementation/review-wave-2.md new file mode 100644 index 0000000..22cc42b --- /dev/null +++ b/tasks/implementation/review-wave-2.md @@ -0,0 +1,76 @@ +--- +id: review-wave-2 +name: Wave 2 gate — the server-stack review +status: pending +depends_on: [w2-server-mount, w2-auth-bridge, w2-loopback-harness, w2-dispatch-decline-core, w2-spawn-return, w2-bounded-resources] +scope: narrow +risk: low +impact: phase +level: review +--- + +## Description + +The wave-2 review gate. The server stack is the substrate every +mapping composes into (wave 3 hangs three mappings and the sftp row on +it; wave 4 shares its foundations) — a shape error in the mount +signature, the auth-bridge state, or the dispatch routing costs churn +across all of them. The gate reads the landed stack against the +architecture line by line: + +- **ADR-001** — the mount contract: `SshAdapter::handle` takes a + generic stream, mounts the full russh stack per stream, edge + termination only; no ALPN/relay code exists; the per-stream limits + posture holds. +- **ADR-002** — the Config construction (honest advertisement + `{publickey}` exactly; the knob mapping; no `des`/`dsa` posture + drift; the pin untouched). +- **ADR-003** — the feature gates as the dispatch's first `cfg` + consumers: flat, independent, `default = []` declines everything; + x11/auth-agent never feature-gated; honest advertisement per build. +- **ADR-004** — the dispositions in code (explicit declines for every + non-publickey method; offered-hook accept-and-delegate; ed25519-only + explicit decline; fingerprint-only resolution; the identity rides + the session). +- **ADR-006** — parse-only dispatch (no detect residue); decline-first- + alloc-never structurally; **the spawn-and-return constraint pinned + by test, not convention**. +- **`mapping.md`** — the skeleton decline-matrix rows green per build + through the harness; the wire decline reasons vanilla-observable. +- **Bounded resources** — the cap enforced and recovering; the knobs + behaviorally verified or recorded; no unbounded shape anywhere. +- **The harness** — the contract-test vehicle convention established; + the wave-1 doubles exercised live. +- **Docs honesty** — module docs state what is deliberately not here + yet (the family seams, the client direction); the skeleton-stub + behavior (declining routes awaiting wave 3) documented as such. +- **The wave-1 gate's lesson absorbed** — `cargo doc --no-deps` runs + clean on this gate too. + +Verdict: green (wave 3 decomposes, absorbing corrections) or findings +booked (remediation before wave 3 decomposes). + +## Acceptance Criteria + +- [ ] Every ADR/mapping surface above read and confirmed (or + corrected with the correction landed) +- [ ] The spawn-and-return constraint verified as test-pinned; + decline-first-alloc-never verified structurally +- [ ] Full verification set green (`cargo test`, `--all-features`, + clippy ×2, fmt, `cargo doc --no-deps`) +- [ ] Verdict recorded; wave 3 decomposition unblocked or findings + booked + +## References + +- docs/plans/implementation.md (wave 2 section + this gate's role) +- docs/architecture/decisions/001…006, mapping.md, auth.md, + overview.md, open-questions.md + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w2-auth-bridge.md b/tasks/implementation/w2-auth-bridge.md new file mode 100644 index 0000000..4786594 --- /dev/null +++ b/tasks/implementation/w2-auth-bridge.md @@ -0,0 +1,80 @@ +--- +id: w2-auth-bridge +name: Wave 2 — the auth bridge (dispositional handler surface) +status: pending +depends_on: [w2-server-mount, w1-fingerprint, w1-test-doubles] +scope: moderate +risk: low +impact: component +level: implementation +--- + +## Description + +Fill the Handler skeleton's auth hooks with the ADR-004 dispositions — +the fingerprint → identity bridge, fail-closed on every path: + +- **`auth_publickey`** (fires post-verification; the key arrives as + `VerifiedPublicKey.public_key` with the signature `.algorithm` + exposed): non-ed25519 algorithm → **explicit reject** (ADR-002 + decision 4 — the explicit-over-inherited pattern, so the posture + survives a pin bump); ed25519 → fingerprint via the crate's single + fingerprint path (`src/fingerprint.rs`, the ADR-004 contract + invariant) → `IdentityProvider::resolve_from_fingerprint` → + `Some(identity)` → `Auth::Accept` with the resolved identity stored + in the per-connection handler state (it rides the session for the + wave-3 seam propagation — `auth_succeeded` is the ride-along hook); + `None` → reject fail-closed. The resolved-identity-`None` path never + creates a weakly authorized session. +- **`auth_publickey_offered`** — accept-and-delegate (the russh + default shape at this pin; keep it explicit so the counting stays + honest — the hook does not consume an attempt, POC-1 verified). +- **Explicit declines** — `auth_none`, `auth_password`, + `auth_keyboard_interactive`, `auth_openssh_certificate` each get an + explicit `Auth::reject()` implementation (family contract, not + inherited defaults — a pin bump could silently change them). +- **The username carries no semantics** (OQ-03) — resolution is + fingerprint-only; the user string is never an identity input. + +Auth declines are *dispositions* (`Auth::Reject`), not errors — the +error model stays key-source-only. Tests run against the wave-1 +doubles (`MemIdentityProvider` + the ed25519 fixture keys) at the +handler level; the loopback harness proves the bridge end to end over +the wire in `w2-loopback-harness`. + +## Acceptance Criteria + +- [ ] `auth_publickey`: non-ed25519 → explicit reject; ed25519 → + fingerprint → `resolve_from_fingerprint` → accept-with-identity + / reject fail-closed; the resolved identity lands in + per-connection handler state +- [ ] `auth_publickey_offered` accepts-and-delegates explicitly +- [ ] `auth_none` / `auth_password` / `auth_keyboard_interactive` / + `auth_openssh_certificate` each explicitly reject +- [ ] The username is never an identity input (fingerprint-only + resolution; OQ-03) +- [ ] Handler-level tests: known-fingerprint accept (fixture key + + `MemIdentityProvider`), unknown-fingerprint reject, non-ed25519 + explicit reject, every non-publickey method rejects +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/decisions/004-auth-dispositions.md (the + dispositions, decisions 1–5) +- docs/architecture/auth.md §Server direction (the bridge steps; the + 0.65.0 `VerifiedPublicKey` shape) +- docs/architecture/decisions/002-russh-pin.md (decision 4 — + ed25519-only, the explicit-decline rationale) +- docs/plans/implementation.md §Wave 2 (the auth-bridge bullet) +- russh 0.65.0 in-source: `Auth`, `VerifiedPublicKey`, + `PublicKeyOffer`, the auth hook signatures + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w2-bounded-resources.md b/tasks/implementation/w2-bounded-resources.md new file mode 100644 index 0000000..a21b3f9 --- /dev/null +++ b/tasks/implementation/w2-bounded-resources.md @@ -0,0 +1,68 @@ +--- +id: w2-bounded-resources +name: Wave 2 — bounded resources (the in-crate channel cap at the handler surface) +status: pending +depends_on: [w2-spawn-return] +scope: narrow +risk: low +impact: component +level: implementation +--- + +## Description + +Enforce the in-crate bounded-resources posture at the handler surface +(`overview.md` §Bounded-resources policy; AGENTS.md convention 9 — +SSH servers are internet-facing; unbounded loops and unbounded buffers +are bugs): + +- **The in-crate concurrent-channel cap** — a count of active channels + enforced at every `channel_open_*` hook: opens beyond + `Limits::max_concurrent_channels` are declined fail-closed (a clean + refuse, never queued, never a half-open channel). The count + decrements on channel close so the cap recovers. Short-held + internal state (`parking_lot`, the project convention). +- **The russh knobs are already wired** (`w2-server-mount` mapped + `inactivity_timeout` always-`Some`, `max_auth_attempts`, + `window_size`, `maximum_packet_size`, `channel_buffer_size` from + `Limits`) — this task verifies them behaviorally where cheap + (e.g., the auth-attempt ceiling disconnects an exceeding client + through the harness) and records anything not cheaply observable + as covered by the config-mapping tests. +- **Per-length exec-parse limits stay reserved** for the wave-3 + grammar's consumption (`Limits::max_exec_command_len` is carried, + not yet enforced — the grammar owns it). + +The per-identity policy layer (when the alkcall bridge carries it) is +additional and arrives with the bridge itself (wave 3's tunnel leg) — +not from this task. + +## Acceptance Criteria + +- [ ] The concurrent-channel cap is enforced at every + `channel_open_*` hook: beyond `Limits::max_concurrent_channels` + → clean decline; the count recovers on close +- [ ] Harness tests: cap reached → next open declines; a close → an + open succeeds again; the cap is `Limits`-tunable +- [ ] The wired russh knobs verified behaviorally where cheap + (auth-attempt ceiling through the harness) or recorded as + covered by the config-mapping tests +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/overview.md §Bounded-resources policy (the knob + table) +- docs/architecture/mapping.md §Data-plane / limits +- docs/plans/implementation.md §Wave 2 (the bounded-resources bullet) +- src/config.rs (`Limits` — the budget model and its documented + postures) + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w2-dispatch-decline-core.md b/tasks/implementation/w2-dispatch-decline-core.md new file mode 100644 index 0000000..822ca7d --- /dev/null +++ b/tasks/implementation/w2-dispatch-decline-core.md @@ -0,0 +1,100 @@ +--- +id: w2-dispatch-decline-core +name: Wave 2 — parse-only dispatch + the fail-closed decline core +status: pending +depends_on: [w2-loopback-harness] +scope: moderate +risk: medium +impact: phase +level: implementation +--- + +## Description + +Land the dispatch skeleton and the fail-closed decline core (ADR-006 +as amended, ADR-003) — the routing decisions and the declines; the +family-seam serving paths are wave-3 material and arrive as clean +declining stubs this task shapes the routes for. + +- **Channel-open gates** (the `channel_open_*` hooks): `session` + channels open only where a session-serving feature exists + (`session` ∨ `git` — the parse path lives inside session channels); + `direct-tcpip` / `forwarded-tcpip` / `direct-streamlocal` gate on + the `tunnel` feature; **`x11` and `auth-agent` always decline** + (never feature-gated — ADR-003 §5); every other/unimplemented + channel type declines. Unfeatured → decline at open with a clean + wire reason — never a half-open channel. +- **Parse-only session dispatch** (ADR-006 point 3 as amended): + `exec_request` parses first — a git-parseable command routes to the + git row, anything else to the session pipe row; `pty_request` / + `shell_request` route to the session rows. **The git-parseability + probe here is skeleton-level** (service prefix + path shape); the + full grammar (quoted/unquoted paths, service-only → + decline-with-reason, per-length limits) is wave-3's git task, which + replaces this probe. Parsing happens before dispatch and before any + resource allocation — **decline first, alloc never** (a leaked + channel alloc is a capability leak; the POC-3 stage-A rule). +- **Request-level declines**: `subsystem_request` — the `sftp` name + declines-with-reason (the OQ-13 placeholder, folded here server + side); git service names decline-with-reason (a subsystem request + carries only a name, never a repo id); everything else declines. + `env_request` declines unconditionally (OQ-09 — no allowlist ships). +- **Routing stubs**: the git row and the session rows (pty/shell, + pipe-exec) route to stubs that decline cleanly at the family-seam + boundary — the seams land in wave 3; the stub shape is documented + as skeleton behavior so the routing decisions are contract-tested + now, not after the mappings exist. +- **Wire decline reasons are shaped here** (the wave-1 error-model + note's reservation): channel-open failure codes vs + `channel_failure` per decline class, chosen for vanilla-client + observability (POC-1 §5: channel-scoped decline, auth intact, no + hangs). +- **Contract tests through the loopback harness, per feature build**: + the skeleton rows of the decline matrix (`mapping.md` §Decline + matrix) — none-build declines everything; git-build routes + git-parseable exec (stub at the seam) and declines pty/shell and + plain exec; session-build declines git-parseable exec (the git + parse is its gate) and serves its rows only to the stub; tunnel + rows decline (the route-1 bridge is wave 3) even with the feature + on; x11/auth-agent decline in every build. The full per-build + matrix suite is wave-5's; this task tests the rows it lands. + +## Acceptance Criteria + +- [ ] Every `channel_open_*` hook gates per the feature graph; + x11/auth-agent decline in every build; unfeatured types decline + at open with clean wire reasons +- [ ] `exec_request` takes the parse path (git-parseable → git row, + else → session pipe row) before any allocation; request-less + session channels decline as unfeatured per ADR-006 +- [ ] `subsystem_request` declines-with-reason for `sftp` (OQ-13 + placeholder) and git service names; `env_request` declines + unconditionally (OQ-09) +- [ ] Decline-first-alloc-never holds structurally (declines precede + any channel/resource allocation on every path) +- [ ] Feature-gated code paths exercised per build (`cargo test + --features git/session/tunnel` rows + `--all-features`); the + skeleton decline-matrix rows green through the harness +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo clippy --all-features --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/decisions/006-mapping-bridge-and-dispatch.md + (points 2–3; the amendment — parse-only) +- docs/architecture/decisions/003-feature-graph.md (the feature graph; + §5 never-features) +- docs/architecture/mapping.md §Session-channel dispatch, §Request-level + rows, §Decline matrix +- docs/plans/implementation.md §Wave 2 (the dispatch bullet) +- docs/research/poc-3-mapping-bakeoff.md §stage A (the decline matrix + evidence; decline-first-alloc-never) + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w2-loopback-harness.md b/tasks/implementation/w2-loopback-harness.md new file mode 100644 index 0000000..c0bd13c --- /dev/null +++ b/tasks/implementation/w2-loopback-harness.md @@ -0,0 +1,77 @@ +--- +id: w2-loopback-harness +name: Wave 2 — the loopback harness (in-process client/server test pair) +status: pending +depends_on: [w2-auth-bridge, w1-test-doubles] +scope: moderate +risk: medium +impact: phase +level: implementation +--- + +## Description + +Land the in-process client/server test pair every later contract test +runs through — the decline matrix, every mapping's contract tests +(wave 3), and the client-direction smokes all drive the crate through +this harness. In-memory doubles from wave 1; real-tool smokes ride the +mapping waves (wave 3+) and the e2e wave (wave 5). + +- **Server side** — the `SshAdapter` from `w2-server-mount` over an + in-process transport. The transport choice (tokio duplex pair vs + 127.0.0.1:0 TCP loopback) is pinned here — both work with + `run_stream`/russh's client connect; decide on determinism and + portability and record the choice. +- **Client side** — a russh client driven directly (the wave-4 dial + surface does not exist yet; the harness builds the russh client + config from the doubles): in-memory key from the ed25519 fixtures + (`FIXTURE_KEYS`), the `MemHostKeyPolicy` double with the permissive + test posture (`with_default(true)` — the accept-all posture exists + only as a test choice, never a crate built-in), the keepalive + posture from `Limits`. +- **Harness helpers** — the vocabulary the contract tests speak: + authenticate with a fixture key (and observe auth failure shapes), + open a channel type (and observe the open-failure reason), send a + session request (and observe success/failure), await exit-status / + close. Helpers stay thin — the russh client API is the vocabulary; + the harness removes boilerplate, not semantics. +- **Convention** — lives with the wave-1 doubles under `tests/common/` + (or a sibling harness module — same convention, decide and record); + documented as *the* contract-test vehicle for the crate. + +The harness's first consumer is its own acceptance test: a full +in-process connect → authenticate (fixture key through +`MemIdentityProvider`) → authenticated-session observation, proving +the wave-1 doubles work live through the wave-2 stack. + +## Acceptance Criteria + +- [ ] In-process server/client pair connects and authenticates end to + end (fixture key → `MemIdentityProvider` → authenticated + session) with no TCP port dependency (or the TCP choice + recorded, if taken) +- [ ] Harness helpers cover: auth (success + failure observation), + channel open (success + open-failure reason), session requests + (success/failure), exit-status/close observation +- [ ] The transport choice and module placement are recorded +- [ ] The harness is documented as the contract-test vehicle; the + wave-1 doubles are exercised live through it +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/plans/implementation.md §Wave 2 (the loopback-harness bullet) +- docs/architecture/mapping.md §Decline matrix (the rows the harness + will drive) +- docs/research/poc-1-auth-bridge.md (the decline-observability facts + the helpers must be able to observe) +- tests/common/mod.rs (the wave-1 doubles this harness drives) + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w2-server-mount.md b/tasks/implementation/w2-server-mount.md new file mode 100644 index 0000000..44d62f2 --- /dev/null +++ b/tasks/implementation/w2-server-mount.md @@ -0,0 +1,98 @@ +--- +id: w2-server-mount +name: Wave 2 — SshAdapter::handle mount + russh Config construction +status: pending +depends_on: [review-wave-1, w1-config, w1-key-sources, w1-error-model] +scope: moderate +risk: medium +impact: project +level: implementation +--- + +## Description + +Land the door frame: the `SshAdapter` type — the ADR-001 mount point — +and the russh server `Config` construction from the wave-1 +`ServeConfig`. This is the substrate every mapping composes into; the +auth bridge and dispatch fill the Handler this task skeletons. + +- **`SshAdapter`** — holds the `ServeConfig`; construction + (`SshAdapter::new`) loads the host keys decrypt-at-construction via + the injected `HostKeySource` (ADR-005 decision 6): a locked / + absent / decryption-failed / unknown-version load means **no server + is constructed** — the error surfaces as a distinct adapter + construction error wrapping the `KeySourceError` variant (never a + half-configured adapter, never a keyless server that fails later). +- **russh `Config` construction** (ADR-002 surface notes) — built from + the serve-config: `methods` = `{publickey}` **exactly** + (`MethodSet` from `[MethodKind::Publickey]` — honest advertisement, + ADR-004 decision 4); `keys` from the loaded host-key set; + `window_size` / `maximum_packet_size` / `channel_buffer_size` / + `max_auth_attempts` mapped verbatim from `Limits`; + `inactivity_timeout` mapped **always-`Some`** (the bounded-resources + posture — the crate never constructs a session with no wall clock); + russh's rekey `Limits` stay russh-enforced at their RFC-4253 + defaults (recorded in the wave-1 config notes — keep that + recording). `server_id` stays russh's default posture unless the + task finds a family reason otherwise (decide and record). +- **The mount** — `SshAdapter::handle(stream)`: a generic stream in, + the full russh server stack per stream via `russh::server::run_stream` + (edge termination only — no ALPN code, no relay leg; the `alk/ssh` + pin is a ledger entry, ADR-001 — nothing here speaks it). The exact + return shape (awaited `RunningSession`, an internal spawn + join + handle, …) is decided here against the spawn-and-return posture + (ADR-006 decision 4) and the assembly's mounting needs — decide and + record; it is semver-visible once consumers exist. +- **The Handler skeleton** — the per-connection `russh::server::Handler` + impl with the adapter's `type Error` (`From`, thiserror + — the wave-2-shaped error surface the wave-1 error-model note + reserved); every hook left at its fail-closed russh default + (decline/reject). Later wave-2 tasks fill the auth hooks + (`w2-auth-bridge`) and the channel/request dispatch + (`w2-dispatch-decline-core`). + +No mapping seams, no feature-gated serving (wave 3); no client dial +(wave 4). The feature gates stay untouched — nothing consumes them +yet (the dispatch task is their first `cfg` consumer). + +## Acceptance Criteria + +- [ ] `SshAdapter::new(ServeConfig)` loads host keys + decrypt-at-construction; locked / absent / decryption-failure / + unknown-version → `Err` with the `KeySourceError` variant + preserved, no adapter constructed +- [ ] russh `Config` built from the serve-config: `methods` is exactly + `{publickey}`; host keys ride `Config::keys`; the `Limits` knobs + map one-for-one with `inactivity_timeout` always `Some` +- [ ] `SshAdapter::handle` mounts the full russh server stack per + generic stream (`run_stream`); no ALPN/relay code exists +- [ ] The Handler skeleton compiles with an adapter error type + (`From`); every hook at its fail-closed default +- [ ] Tests: honest advertisement (methods exactly `{publickey}`); the + knob mapping; construction failure postures (locked vault → no + server, absent host keys → no server) +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/decisions/001-alkssh-alpn-pin.md (the mount-point + contract: `SshAdapter::handle`, raw vanilla-SSH stream in) +- docs/architecture/decisions/002-russh-pin.md (the Config surface + notes; honest advertisement) +- docs/architecture/decisions/004-auth-dispositions.md (decision 4 — + `methods = {publickey}`) +- docs/architecture/decisions/005-at-rest-key-custody.md + (decrypt-at-construction; locked → no server) +- docs/architecture/overview.md §Bounded-resources policy +- docs/plans/implementation.md §Wave 2 (first bullet) +- russh 0.65.0 in-source: `server::Config`, `server::run_stream`, + `auth::MethodSet` + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file diff --git a/tasks/implementation/w2-spawn-return.md b/tasks/implementation/w2-spawn-return.md new file mode 100644 index 0000000..c90f536 --- /dev/null +++ b/tasks/implementation/w2-spawn-return.md @@ -0,0 +1,74 @@ +--- +id: w2-spawn-return +name: Wave 2 — spawn-and-return plumbing + exit/status reporting mechanics +status: pending +depends_on: [w2-dispatch-decline-core] +scope: narrow +risk: medium +impact: phase +level: implementation +--- + +## Description + +Land the structural constraint in code (ADR-006 decision 4): russh at +this pin runs server handler callbacks **inline inside the session's +packet loop** — a blocking handler suspends all packet routing for the +connection and deadlocks against the peer's immediate follow-up bytes +(POC-3 proved by construction with receive-pack). Therefore: + +- **Every serving path is `tokio::spawn`ed; handlers return fast.** + The dispatch's routing stubs become spawned serving paths (still + declining stubs — the seams are wave 3), so the pattern is in place + before any real mapping hangs on it. +- **Exit/status reporting mechanics** — reporting from spawned tasks + rides the cloned `Session::handle()`: `exit-status` sent **before** + channel close, from the spawned task. The *derivation contract's + rules* (which code → which SSH exit shape) are wave-3 + session-mapping material (`mapping.md` rules 1–6); this task lands + the mechanics — the handle clone, the exit-status send, the + ordering-before-close — proven end to end. +- **The proof** — a stub serving path that spawns, completes + asynchronously (after the handler has returned), and reports + `exit-status(N)` + close through the cloned handle; the loopback + harness client observes the exit-status arriving after the handler + already returned. A handler-returns-fast assertion (the handler + completes while the spawned task is still running) pins the + constraint as a test, not a convention. + +This is a load-bearing correctness constraint of the whole spine, not +an implementation choice — the wave-3 mappings inherit the pattern +mechanically from the stubs this task leaves behind. + +## Acceptance Criteria + +- [ ] Every serving path (the dispatch stubs) runs in a + `tokio::spawn`; no handler hook awaits a serving path inline +- [ ] Exit/status reporting from spawned tasks rides the cloned + `Session::handle()`; `exit-status` is sent before channel close +- [ ] A harness test proves the mechanics end to end: the client + observes `exit-status(N)` from a spawned stub that completed + after the handler returned +- [ ] A handler-returns-fast assertion pins the structural constraint + as a test +- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, + `cargo fmt --check` pass + +## References + +- docs/architecture/decisions/006-mapping-bridge-and-dispatch.md + (decision 4 — the structural constraint) +- docs/architecture/mapping.md §The structural constraint: + spawn-and-return; §exit_status row (sent before close, from the + spawned task) +- docs/plans/implementation.md §Wave 2 (the spawn-and-return bullet) +- docs/research/poc-3-mapping-bakeoff.md (the deadlock-by-construction + evidence) + +## Notes + +> To be filled by implementation agent + +## Summary + +> To be filled on completion \ No newline at end of file